feat(runtime): add checkpoint summaries to shared and cowork surfaces
This commit is contained in:
parent
3330ca55cd
commit
9aeb9bbd59
@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentActionLogSchema,
|
||||
AgentCheckpointSchema,
|
||||
AgentApprovalCheckpointSchema,
|
||||
AgentDispatchRequestSchema,
|
||||
AgentRunSchema,
|
||||
@ -132,4 +133,36 @@ describe('agent runtime contract baseline', () => {
|
||||
|
||||
expect(run.status).toBe('queued');
|
||||
});
|
||||
|
||||
it('validates checkpoint summaries used for resume review', () => {
|
||||
const checkpoint = AgentCheckpointSchema.parse({
|
||||
checkpointId: 'ckpt_cowork_1',
|
||||
sessionId: 'sess_cowork_1',
|
||||
runId: 'run_cowork_1',
|
||||
productId: 'clawcowork',
|
||||
userId: 'saravana',
|
||||
createdAt: '2026-04-04T12:00:00.000Z',
|
||||
statusAtCapture: 'waiting-approval',
|
||||
currentTaskId: 'task_cowork_1',
|
||||
todoIds: ['todo_cowork_1'],
|
||||
artifactRefs: [],
|
||||
memoryRefs: [],
|
||||
approvalRefs: ['approval_1'],
|
||||
dispatchContext: {
|
||||
originSurface: 'desktop',
|
||||
originProductId: 'clawcowork',
|
||||
dispatchMode: 'interactive',
|
||||
initiatedAt: '2026-04-04T11:58:00.000Z',
|
||||
},
|
||||
resumeToken: 'task_cowork_1',
|
||||
stateSummary: {
|
||||
title: 'Investigate imported roadmap risks',
|
||||
summary: 'Paused for approval after scanning the repository and proposing changes.',
|
||||
lastActionAt: '2026-04-04T12:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(checkpoint.statusAtCapture).toBe('waiting-approval');
|
||||
expect(checkpoint.resumeToken).toBe('task_cowork_1');
|
||||
});
|
||||
});
|
||||
|
||||
@ -62,6 +62,38 @@ export const AgentTodoSchema = z.object({
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const AgentCheckpointStatusSchema = z.enum([
|
||||
'queued',
|
||||
'running',
|
||||
'paused',
|
||||
'waiting-approval',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
export const AgentCheckpointSchema = z.object({
|
||||
checkpointId: z.string().min(1),
|
||||
sessionId: z.string().min(1),
|
||||
runId: z.string().min(1).nullable().optional(),
|
||||
productId: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
createdAt: z.string().datetime(),
|
||||
statusAtCapture: AgentCheckpointStatusSchema,
|
||||
currentTaskId: z.string().min(1).nullable().optional(),
|
||||
todoIds: z.array(z.string().min(1)),
|
||||
artifactRefs: z.array(z.string().min(1)),
|
||||
memoryRefs: z.array(z.string().min(1)),
|
||||
approvalRefs: z.array(z.string().min(1)),
|
||||
dispatchContext: AgentDispatchContextSchema.nullable().optional(),
|
||||
resumeToken: z.string().min(1).nullable().optional(),
|
||||
stateSummary: z.object({
|
||||
title: z.string().min(1),
|
||||
summary: z.string().min(1),
|
||||
lastActionAt: z.string().datetime().nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AgentRunStatusSchema = z.enum([
|
||||
'queued',
|
||||
'running',
|
||||
@ -122,6 +154,7 @@ export type AgentDispatchContext = z.infer<typeof AgentDispatchContextSchema>;
|
||||
export type AgentSession = z.infer<typeof AgentSessionSchema>;
|
||||
export type AgentTask = z.infer<typeof AgentTaskSchema>;
|
||||
export type AgentTodo = z.infer<typeof AgentTodoSchema>;
|
||||
export type AgentCheckpoint = z.infer<typeof AgentCheckpointSchema>;
|
||||
export type AgentRun = z.infer<typeof AgentRunSchema>;
|
||||
export type AgentApprovalCheckpoint = z.infer<typeof AgentApprovalCheckpointSchema>;
|
||||
export type AgentDispatchRequest = z.infer<typeof AgentDispatchRequestSchema>;
|
||||
|
||||
@ -5,6 +5,7 @@ export type { DurableEventBusOptions } from './durable.js';
|
||||
export { PlatformEventSchemas } from './types.js';
|
||||
export {
|
||||
AgentActionLogSchema,
|
||||
AgentCheckpointSchema,
|
||||
AgentApprovalCheckpointSchema,
|
||||
AgentDispatchContextSchema,
|
||||
AgentDispatchRequestSchema,
|
||||
@ -52,6 +53,7 @@ export {
|
||||
} from './timeline.js';
|
||||
export type {
|
||||
AgentActionLog,
|
||||
AgentCheckpoint,
|
||||
AgentApprovalCheckpoint,
|
||||
AgentDispatchContext,
|
||||
AgentDispatchRequest,
|
||||
|
||||
@ -154,20 +154,38 @@ describe('agent runtime routes', () => {
|
||||
});
|
||||
|
||||
it('projects IPC tasks into shared AgentTodo objects', async () => {
|
||||
call.mockResolvedValue({
|
||||
result: {
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
goal: 'Review the repository and summarize changes',
|
||||
status: 'running',
|
||||
createdAt: '2026-04-04T08:00:00.000Z',
|
||||
updatedAt: '2026-04-04T08:10:00.000Z',
|
||||
sessionId: 'sess-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
call
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
goal: 'Review the repository and summarize changes',
|
||||
status: 'running',
|
||||
createdAt: '2026-04-04T08:00:00.000Z',
|
||||
updatedAt: '2026-04-04T08:10:00.000Z',
|
||||
sessionId: 'sess-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
checkpoints: [
|
||||
{
|
||||
task_id: 'task-1',
|
||||
user_id: 'demo-user',
|
||||
goal: 'Review the repository and summarize changes',
|
||||
created_at: '2026-04-04T08:00:00.000Z',
|
||||
last_updated: '2026-04-04T08:10:00.000Z',
|
||||
completed: false,
|
||||
cancelled: false,
|
||||
completed_tool_calls: 3,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/agent-runtime/todos' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -180,6 +198,53 @@ describe('agent runtime routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('projects persisted checkpoints into shared AgentCheckpoint objects', async () => {
|
||||
call
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
goal: 'Review the repository and summarize changes',
|
||||
status: 'pending',
|
||||
createdAt: '2026-04-04T08:00:00.000Z',
|
||||
updatedAt: '2026-04-04T08:10:00.000Z',
|
||||
sessionId: 'sess-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
checkpoints: [
|
||||
{
|
||||
task_id: 'task-1',
|
||||
user_id: 'demo-user',
|
||||
goal: 'Review the repository and summarize changes',
|
||||
created_at: '2026-04-04T08:00:00.000Z',
|
||||
last_updated: '2026-04-04T08:10:00.000Z',
|
||||
completed: false,
|
||||
cancelled: false,
|
||||
completed_tool_calls: 2,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/agent-runtime/checkpoints' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.payload);
|
||||
expect(body.checkpoints[0]).toMatchObject({
|
||||
checkpointId: 'ckpt_task-1',
|
||||
sessionId: 'sess-1',
|
||||
runId: 'task-1',
|
||||
userId: 'demo-user',
|
||||
statusAtCapture: 'queued',
|
||||
resumeToken: 'task-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects approval audit records into AgentApprovalCheckpoint objects', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
AgentActionLogSchema,
|
||||
AgentCheckpointSchema,
|
||||
AgentApprovalCheckpointSchema,
|
||||
AgentDispatchRequestSchema,
|
||||
AgentRunSchema,
|
||||
@ -8,6 +9,7 @@ import {
|
||||
AgentTaskSchema,
|
||||
AgentTodoSchema,
|
||||
type AgentActionLog,
|
||||
type AgentCheckpoint,
|
||||
type AgentApprovalCheckpoint,
|
||||
type AgentRun,
|
||||
type AgentSession,
|
||||
@ -27,7 +29,13 @@ function buildAuth(req: FastifyRequest): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function asIsoString(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? value : fallback;
|
||||
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function mapTaskStatus(status: unknown): AgentRun['status'] {
|
||||
@ -164,25 +172,137 @@ function mapTodoStatus(status: unknown): AgentTodo['status'] {
|
||||
}
|
||||
}
|
||||
|
||||
function toAgentTodo(task: Record<string, unknown>, fallbackNow: string): AgentTodo {
|
||||
const todoId =
|
||||
typeof task.id === 'string' && task.id.length > 0 ? `todo_${task.id}` : `todo_${fallbackNow}`;
|
||||
const createdAt = asIsoString(task.createdAt ?? task.startedAt, fallbackNow);
|
||||
const updatedAt = asIsoString(task.updatedAt ?? task.completedAt ?? createdAt, createdAt);
|
||||
function toAgentTodo(
|
||||
task: Record<string, unknown> | null,
|
||||
checkpoint: Record<string, unknown>,
|
||||
fallbackNow: string
|
||||
): AgentTodo {
|
||||
const taskId =
|
||||
typeof checkpoint.task_id === 'string' && checkpoint.task_id.length > 0
|
||||
? checkpoint.task_id
|
||||
: typeof task?.id === 'string' && task.id.length > 0
|
||||
? task.id
|
||||
: fallbackNow;
|
||||
const todoId = `todo_${taskId}`;
|
||||
const createdAt = asIsoString(
|
||||
task?.createdAt ?? checkpoint.created_at ?? checkpoint.last_updated,
|
||||
fallbackNow
|
||||
);
|
||||
const updatedAt = asIsoString(task?.updatedAt ?? checkpoint.last_updated ?? createdAt, createdAt);
|
||||
const text =
|
||||
typeof task.goal === 'string' && task.goal.trim().length > 0 ? task.goal.trim() : 'Cowork task';
|
||||
typeof checkpoint.goal === 'string' && checkpoint.goal.trim().length > 0
|
||||
? checkpoint.goal.trim()
|
||||
: typeof task?.goal === 'string' && task.goal.trim().length > 0
|
||||
? task.goal.trim()
|
||||
: 'Cowork task';
|
||||
const status =
|
||||
checkpoint.completed === true
|
||||
? 'done'
|
||||
: checkpoint.cancelled === true
|
||||
? 'dropped'
|
||||
: typeof checkpoint.error === 'string' && checkpoint.error.length > 0
|
||||
? 'dropped'
|
||||
: mapTodoStatus(task?.status);
|
||||
|
||||
return AgentTodoSchema.parse({
|
||||
todoId,
|
||||
sessionId:
|
||||
typeof task.sessionId === 'string' && task.sessionId.length > 0 ? task.sessionId : todoId,
|
||||
typeof task?.sessionId === 'string' && task.sessionId.length > 0 ? task.sessionId : taskId,
|
||||
text,
|
||||
status: mapTodoStatus(task.status),
|
||||
status,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function mapCheckpointStatus(
|
||||
task: Record<string, unknown> | null,
|
||||
checkpoint: Record<string, unknown>
|
||||
): AgentCheckpoint['statusAtCapture'] {
|
||||
if (checkpoint.completed === true) return 'completed';
|
||||
if (checkpoint.cancelled === true) return 'cancelled';
|
||||
if (typeof checkpoint.error === 'string' && checkpoint.error.length > 0) return 'failed';
|
||||
|
||||
switch (task?.status) {
|
||||
case 'running':
|
||||
return 'running';
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'failed':
|
||||
return 'failed';
|
||||
case 'cancelled':
|
||||
return 'cancelled';
|
||||
case 'pending':
|
||||
return 'queued';
|
||||
default:
|
||||
return 'paused';
|
||||
}
|
||||
}
|
||||
|
||||
function toAgentCheckpoint(
|
||||
task: Record<string, unknown> | null,
|
||||
checkpoint: Record<string, unknown>,
|
||||
fallbackNow: string
|
||||
): AgentCheckpoint {
|
||||
const taskId =
|
||||
typeof checkpoint.task_id === 'string' && checkpoint.task_id.length > 0
|
||||
? checkpoint.task_id
|
||||
: typeof task?.id === 'string' && task.id.length > 0
|
||||
? task.id
|
||||
: `checkpoint_${fallbackNow}`;
|
||||
const createdAt = asIsoString(checkpoint.created_at ?? checkpoint.last_updated, fallbackNow);
|
||||
const lastActionAt = asIsoString(checkpoint.last_updated, createdAt);
|
||||
const goal =
|
||||
typeof checkpoint.goal === 'string' && checkpoint.goal.trim().length > 0
|
||||
? checkpoint.goal.trim()
|
||||
: typeof task?.goal === 'string' && task.goal.trim().length > 0
|
||||
? task.goal.trim()
|
||||
: 'Cowork task';
|
||||
const completedToolCalls =
|
||||
typeof checkpoint.completed_tool_calls === 'number' ? checkpoint.completed_tool_calls : 0;
|
||||
const summary = [
|
||||
`${completedToolCalls} tool calls recorded`,
|
||||
checkpoint.completed === true
|
||||
? 'task completed'
|
||||
: checkpoint.cancelled === true
|
||||
? 'task cancelled'
|
||||
: typeof checkpoint.error === 'string' && checkpoint.error.length > 0
|
||||
? `task failed: ${checkpoint.error}`
|
||||
: 'checkpoint available for resume',
|
||||
].join('; ');
|
||||
|
||||
return AgentCheckpointSchema.parse({
|
||||
checkpointId: `ckpt_${taskId}`,
|
||||
sessionId:
|
||||
typeof task?.sessionId === 'string' && task.sessionId.length > 0 ? task.sessionId : taskId,
|
||||
runId: typeof task?.id === 'string' && task.id.length > 0 ? task.id : taskId,
|
||||
productId: PRODUCT_ID,
|
||||
userId:
|
||||
typeof checkpoint.user_id === 'string' && checkpoint.user_id.length > 0
|
||||
? checkpoint.user_id
|
||||
: 'unknown-user',
|
||||
createdAt,
|
||||
statusAtCapture: mapCheckpointStatus(task, checkpoint),
|
||||
currentTaskId: taskId,
|
||||
todoIds: [`todo_${taskId}`],
|
||||
artifactRefs: [],
|
||||
memoryRefs: [],
|
||||
approvalRefs: [],
|
||||
dispatchContext: {
|
||||
originSurface: 'desktop',
|
||||
originProductId: PRODUCT_ID,
|
||||
dispatchMode: 'interactive',
|
||||
initiatedAt: createdAt,
|
||||
},
|
||||
resumeToken: taskId,
|
||||
stateSummary: {
|
||||
title: goal.length > 120 ? `${goal.slice(0, 117)}...` : goal,
|
||||
summary,
|
||||
lastActionAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mapApprovalStatus(action: unknown): AgentApprovalCheckpoint['status'] {
|
||||
switch (action) {
|
||||
case 'approval_granted':
|
||||
@ -339,6 +459,30 @@ async function fetchApprovalRecords(req: FastifyRequest): Promise<Record<string,
|
||||
return records;
|
||||
}
|
||||
|
||||
async function fetchTasks(req: FastifyRequest): Promise<Record<string, unknown>[]> {
|
||||
const bridge = getIpcBridge();
|
||||
if (!bridge?.isRunning) return [];
|
||||
|
||||
const response = await bridge.call('list_tasks', { auth: buildAuth(req) });
|
||||
const result =
|
||||
response.result && typeof response.result === 'object'
|
||||
? (response.result as Record<string, unknown>)
|
||||
: {};
|
||||
return Array.isArray(result.tasks) ? (result.tasks as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
async function fetchCheckpoints(req: FastifyRequest): Promise<Record<string, unknown>[]> {
|
||||
const bridge = getIpcBridge();
|
||||
if (!bridge?.isRunning) return [];
|
||||
|
||||
const response = await bridge.call('list_checkpoints', { auth: buildAuth(req) });
|
||||
const result =
|
||||
response.result && typeof response.result === 'object'
|
||||
? (response.result as Record<string, unknown>)
|
||||
: {};
|
||||
return Array.isArray(result.checkpoints) ? (result.checkpoints as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
async function fetchAuditRecords(req: FastifyRequest): Promise<Record<string, unknown>[]> {
|
||||
const params = new URLSearchParams({
|
||||
days: '30',
|
||||
@ -423,12 +567,25 @@ export async function agentRuntimeRoutes(app: FastifyInstance) {
|
||||
return { todos: [], count: 0 };
|
||||
}
|
||||
|
||||
const resp = await bridge.call('list_tasks', { auth: buildAuth(req) });
|
||||
if (resp.error) throw new BadRequestError(resp.error.message);
|
||||
|
||||
const raw = (resp.result as { tasks?: Record<string, unknown>[] } | undefined)?.tasks ?? [];
|
||||
const [rawTasks, checkpoints] = await Promise.all([fetchTasks(req), fetchCheckpoints(req)]);
|
||||
const taskMap = new Map(
|
||||
rawTasks
|
||||
.filter(task => typeof task.id === 'string' && task.id.length > 0)
|
||||
.map(task => [task.id as string, task])
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const todos = raw.map(task => toAgentTodo(task, now));
|
||||
const todos =
|
||||
checkpoints.length > 0
|
||||
? checkpoints.map(checkpoint =>
|
||||
toAgentTodo(
|
||||
typeof checkpoint.task_id === 'string'
|
||||
? (taskMap.get(checkpoint.task_id) ?? null)
|
||||
: null,
|
||||
checkpoint,
|
||||
now
|
||||
)
|
||||
)
|
||||
: rawTasks.map(task => toAgentTodo(task, { task_id: task.id, goal: task.goal }, now));
|
||||
|
||||
return {
|
||||
todos,
|
||||
@ -436,6 +593,32 @@ export async function agentRuntimeRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/agent-runtime/checkpoints', async req => {
|
||||
if (!bridge.isRunning) {
|
||||
return { checkpoints: [], count: 0 };
|
||||
}
|
||||
|
||||
const [rawTasks, checkpoints] = await Promise.all([fetchTasks(req), fetchCheckpoints(req)]);
|
||||
const taskMap = new Map(
|
||||
rawTasks
|
||||
.filter(task => typeof task.id === 'string' && task.id.length > 0)
|
||||
.map(task => [task.id as string, task])
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const projected = checkpoints.map(checkpoint =>
|
||||
toAgentCheckpoint(
|
||||
typeof checkpoint.task_id === 'string' ? (taskMap.get(checkpoint.task_id) ?? null) : null,
|
||||
checkpoint,
|
||||
now
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
checkpoints: projected,
|
||||
count: projected.length,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/agent-runtime/approvals', async req => {
|
||||
const records = await fetchApprovalRecords(req);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user