feat(cowork-service): add runtime action log projection

This commit is contained in:
Saravana Achu Mac 2026-04-04 02:20:19 -07:00
parent 2f936bb3de
commit b8242b4601
4 changed files with 123 additions and 1 deletions

View File

@ -243,6 +243,7 @@ These should be resolved before claiming the ecosystem docs are fully implementa
- Cowork product-native runtime projections are now implemented in cowork-service
- `023826e` adds `GET /api/agent-runtime/sessions`, `GET /api/agent-runtime/runs`, `GET /api/agent-runtime/approvals`, and `POST /api/agent-runtime/dispatch/validate`
- `01201f8` adds `GET /api/agent-runtime/tasks` with canonical `AgentTask` projection
- `COMMIT_PENDING` adds `GET /api/agent-runtime/actions` with canonical `AgentActionLog` projection
- FlowMonk runtime-emitter implementation work was started, but this clone cannot currently verify it because the repo depends on a local npm registry at `http://localhost:3300` and backend dependencies are not installed
### 6.1 Remaining Direct Runtime TODOs

View File

@ -71,8 +71,9 @@ Observed baseline:
- `GET /api/agent-runtime/tasks`
- `GET /api/agent-runtime/runs`
- `GET /api/agent-runtime/approvals`
- `GET /api/agent-runtime/actions`
- `POST /api/agent-runtime/dispatch/validate`
- Cowork projections now emit canonical `AgentSession`, `AgentTask`, `AgentRun`, and `AgentApprovalCheckpoint` objects from the product backend instead of only relying on platform projections
- Cowork projections now emit canonical `AgentSession`, `AgentTask`, `AgentRun`, `AgentApprovalCheckpoint`, and `AgentActionLog` objects from the product backend instead of only relying on platform projections
---

View File

@ -165,6 +165,40 @@ describe('agent runtime routes', () => {
});
});
it('projects audit records into AgentActionLog objects', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
records: [
{
id: 'audit-2',
action: 'task_completed',
category: 'execution',
timestamp: '2026-04-04T09:05:00.000Z',
details: {
taskId: 'task-1',
sessionId: 'sess-1',
correlationId: 'corr-1',
result: 'success',
},
},
],
}),
});
const res = await app.inject({ method: 'GET', url: '/api/agent-runtime/actions' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.actions[0]).toMatchObject({
actionLogId: 'audit-2',
sessionId: 'sess-1',
runId: 'task-1',
eventName: 'task_completed',
actorType: 'agent',
correlationId: 'corr-1',
});
});
it('validates Cowork-targeted dispatch payloads', async () => {
const res = await app.inject({
method: 'POST',

View File

@ -1,10 +1,12 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
AgentActionLogSchema,
AgentApprovalCheckpointSchema,
AgentDispatchRequestSchema,
AgentRunSchema,
AgentSessionSchema,
AgentTaskSchema,
type AgentActionLog,
type AgentApprovalCheckpoint,
type AgentRun,
type AgentSession,
@ -205,6 +207,58 @@ function toApprovalCheckpoint(
});
}
function mapActorType(record: Record<string, unknown>): AgentActionLog['actorType'] {
if (typeof record.action === 'string' && record.action.startsWith('approval_')) {
return 'user';
}
const category = typeof record.category === 'string' ? record.category : '';
if (category === 'system' || category === 'platform') {
return 'system';
}
return 'agent';
}
function toActionLog(record: Record<string, unknown>, fallbackNow: string): AgentActionLog {
const details =
record.details && typeof record.details === 'object'
? (record.details as Record<string, unknown>)
: {};
const sessionId =
typeof details.sessionId === 'string' && details.sessionId.length > 0
? details.sessionId
: typeof details.taskId === 'string' && details.taskId.length > 0
? details.taskId
: 'unknown-session';
const runId =
typeof details.runId === 'string' && details.runId.length > 0
? details.runId
: typeof details.taskId === 'string' && details.taskId.length > 0
? details.taskId
: 'unknown-run';
return AgentActionLogSchema.parse({
actionLogId:
typeof record.id === 'string' && record.id.length > 0 ? record.id : `action_${fallbackNow}`,
sessionId,
runId,
eventName:
typeof record.action === 'string' && record.action.length > 0
? record.action
: 'unknown_action',
occurredAt: asIsoString(record.timestamp ?? record.createdAt, fallbackNow),
actorType: mapActorType(record),
correlationId:
typeof details.correlationId === 'string'
? details.correlationId
: typeof details.taskId === 'string'
? details.taskId
: null,
payload: details,
});
}
async function fetchApprovalRecords(req: FastifyRequest): Promise<Record<string, unknown>[]> {
const params = new URLSearchParams({
days: '30',
@ -234,6 +288,27 @@ async function fetchApprovalRecords(req: FastifyRequest): Promise<Record<string,
return records;
}
async function fetchAuditRecords(req: FastifyRequest): Promise<Record<string, unknown>[]> {
const params = new URLSearchParams({
days: '30',
limit: '100',
});
const res = await fetch(`${config.PLATFORM_SERVICE_URL}/audit?${params.toString()}`, {
headers: {
'x-product-id': PRODUCT_ID,
'x-request-id': req.id,
},
});
if (!res.ok) {
throw new BadRequestError(`Platform returned ${res.status} while fetching action logs`);
}
const body = (await res.json()) as { records?: Record<string, unknown>[] };
return Array.isArray(body.records) ? body.records : [];
}
export async function agentRuntimeRoutes(app: FastifyInstance) {
const bridge = getIpcBridge();
@ -303,6 +378,17 @@ export async function agentRuntimeRoutes(app: FastifyInstance) {
};
});
app.get('/api/agent-runtime/actions', async req => {
const records = await fetchAuditRecords(req);
const now = new Date().toISOString();
const actions = records.map(record => toActionLog(record, now));
return {
actions,
count: actions.length,
};
});
app.post('/api/agent-runtime/dispatch/validate', async req => {
const parsed = AgentDispatchRequestSchema.safeParse(req.body);
if (!parsed.success) {