Skip to content

Commit 07ee6c2

Browse files
authored
Split workflow, run launch, attention and run control routes out of api.ts (#4512)
* Move workflow definition and command routes out of api.ts The workflow list, validate, get, save and delete routes, the commands list, and the workflow file lookup helpers now live in api.workflows.ts. validateCwd becomes a module helper there, since every caller moved with it. registerApiRoutes registers the list route and the definition routes at the same points as before, so route and OpenAPI order are unchanged. Removes the four GET /api/commands tests from api.health.test.ts and the command mocks only they used. api.workflows.test.ts "includes bundled commands with source:bundled" and "orders project commands by name and keeps project precedence on a collision" cover the same behavior. * Move the workflow run launch route out of api.ts POST /api/workflows/:name/run, its route config, and the inputs and model-override field parsers now live in api.workflow-run-launch.ts. The parsers read nothing from registerApiRoutes, so they become module helpers there. registerApiRoutes registers the route at the same point as before, so route and OpenAPI order are unchanged. No tests move or are deleted. * Move attention and run read routes out of api.ts The attention queue routes and the queue they read now live in api.run-attention.ts, which takes the two workflow operations it uses. The dashboard runs route and the run list, by-worker, detail and provider-events reads live in api.workflow-runs.ts. registerApiRoutes keeps the operations object and destructures only the names its own handlers still use, and registers each moved group at the same point as before, so route and OpenAPI order are unchanged. The SDK type exception for WorkflowRunStatus follows the type to its new file. No tests move or are deleted. * Move run control and cleanup routes out of api.ts Cancel, resume, signal and abandon now live in api.run-control.ts behind registerRunControlRoutes, and the run delete and node-session reset routes behind registerRunCleanupRoutes. Each registrar takes only the workflow operations its handlers call. registerApiRoutes drops the moved operation names from its destructuring and registers both groups at the same points as before, so route and OpenAPI order are unchanged. No tests move or are deleted.
1 parent 32baace commit 07ee6c2

8 files changed

Lines changed: 2249 additions & 2158 deletions

File tree

‎packages/server/src/routes/api.health.test.ts‎

Lines changed: 1 addition & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,7 @@ mock.module('@archon/workflows/loader', makeLoaderMock);
105105
mock.module('@archon/workflows/command-validation', makeCommandValidationMock);
106106
mock.module('@archon/workflows/defaults', () => ({
107107
BUNDLED_WORKFLOWS: {},
108-
BUNDLED_COMMANDS: {
109-
'archon-investigate': '# archon-investigate command',
110-
plan: '# plan command',
111-
implement: '# implement command',
112-
},
108+
BUNDLED_COMMANDS: {},
113109
isBinaryBuild: mock(() => false),
114110
}));
115111

@@ -178,10 +174,6 @@ mock.module('@archon/core/db/messages', () => ({
178174
listMessages: mock(async () => []),
179175
}));
180176

181-
mock.module('@archon/core/utils/commands', () => ({
182-
findCommandFiles: mock(async () => []),
183-
}));
184-
185177
import { registerApiRoutes } from './api';
186178

187179
// ---------------------------------------------------------------------------
@@ -501,57 +493,6 @@ describe('GET /api/config', () => {
501493
});
502494
});
503495

504-
// ---------------------------------------------------------------------------
505-
// Tests: GET /api/commands
506-
// ---------------------------------------------------------------------------
507-
508-
describe('GET /api/commands', () => {
509-
test('returns commands array with bundled commands', async () => {
510-
const app = makeApp();
511-
const response = await app.request('/api/commands');
512-
expect(response.status).toBe(200);
513-
514-
const body = (await response.json()) as { commands: Array<{ name: string; source: string }> };
515-
expect(Array.isArray(body.commands)).toBe(true);
516-
517-
// BUNDLED_COMMANDS mock has 3 entries
518-
const bundledCommands = body.commands.filter(c => c.source === 'bundled');
519-
expect(bundledCommands.length).toBeGreaterThan(0);
520-
});
521-
522-
test('includes archon-investigate as bundled command', async () => {
523-
const app = makeApp();
524-
const response = await app.request('/api/commands');
525-
expect(response.status).toBe(200);
526-
527-
const body = (await response.json()) as { commands: Array<{ name: string; source: string }> };
528-
const archonAssist = body.commands.find(c => c.name === 'archon-investigate');
529-
expect(archonAssist).toBeDefined();
530-
expect(archonAssist?.source).toBe('bundled');
531-
});
532-
533-
test('includes plan and implement as bundled commands', async () => {
534-
const app = makeApp();
535-
const response = await app.request('/api/commands');
536-
expect(response.status).toBe(200);
537-
538-
const body = (await response.json()) as { commands: Array<{ name: string; source: string }> };
539-
const names = body.commands.map(c => c.name);
540-
expect(names).toContain('plan');
541-
expect(names).toContain('implement');
542-
});
543-
544-
test('returns commands with cwd query param without error', async () => {
545-
const app = makeApp();
546-
// Use the registered codebase path (/tmp/project from the mock) so validateCwd passes
547-
const response = await app.request('/api/commands?cwd=/tmp/project');
548-
expect(response.status).toBe(200);
549-
550-
const body = (await response.json()) as { commands: Array<{ name: string }> };
551-
expect(Array.isArray(body.commands)).toBe(true);
552-
});
553-
});
554-
555496
// ---------------------------------------------------------------------------
556497
// Tests: GET /api/openapi.json — guards @hono/zod-openapi spec generation
557498
// (the v0 -> v1 / zod v4 upgrade is a major bump; this confirms every
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { type OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
2+
import * as codebaseDb from '@archon/core/db/codebases';
3+
import * as workflowDb from '@archon/core/db/workflows';
4+
import {
5+
attentionQueueSource,
6+
resolveAttentionProject,
7+
} from '@archon/core/operations/attention-queue';
8+
import { RunActionForbiddenError } from '@archon/core/operations/run-authorization';
9+
import {
10+
StaleGateAnswerError,
11+
assertLabelable,
12+
type WorkflowOperations,
13+
} from '@archon/core/operations/workflow-operations';
14+
import { createWorkflowHostStore } from '@archon/core/workflows/sql-host';
15+
import { createWorkflowStore } from '@archon/core/workflows/store-adapter';
16+
import {
17+
attentionQueueItems,
18+
countAttentionQueue,
19+
parseAttentionQueueRequest,
20+
readAttentionQueueItem,
21+
selectAttentionQueue,
22+
type AttentionQueueScope,
23+
} from '@archon/workflows/attention-queue';
24+
import { resolveRunActor } from './api-request-auth';
25+
import {
26+
apiError,
27+
getLog,
28+
getValidatedBody,
29+
getValidatedQuery,
30+
jsonError,
31+
openApiRouteRegistrar,
32+
} from './api-route-kit';
33+
import { toApiDashboardWorkflowRun } from './api-serializers';
34+
import {
35+
attentionQueueCountsResponseSchema,
36+
attentionQueueItemResponseSchema,
37+
attentionQueueQuerySchema,
38+
labelAttentionBodySchema,
39+
labelAttentionResponseSchema,
40+
runsNeedingAttentionResponseSchema,
41+
} from './schemas/workflow.schemas';
42+
43+
const getRunsNeedingAttentionRoute = createRoute({
44+
method: 'get',
45+
path: '/api/runs/attention',
46+
tags: ['Workflows'],
47+
summary: 'List or count the attention queue',
48+
request: { query: attentionQueueQuerySchema },
49+
responses: {
50+
200: {
51+
content: {
52+
'application/json': {
53+
schema: z.union([runsNeedingAttentionResponseSchema, attentionQueueCountsResponseSchema]),
54+
},
55+
},
56+
description: 'The items in scope, or their counts when countBy is given',
57+
},
58+
400: jsonError('Bad request'),
59+
500: jsonError('Server error'),
60+
},
61+
});
62+
63+
const getRunAttentionItemRoute = createRoute({
64+
method: 'get',
65+
path: '/api/runs/{runId}/attention',
66+
tags: ['Workflows'],
67+
summary: 'Read the attention queue item a run stands for',
68+
request: { params: z.object({ runId: z.string() }) },
69+
responses: {
70+
200: {
71+
content: { 'application/json': { schema: attentionQueueItemResponseSchema } },
72+
description: 'The item, or null when the run needs no one now',
73+
},
74+
404: jsonError('Not found'),
75+
500: jsonError('Server error'),
76+
},
77+
});
78+
79+
const labelRunAttentionRoute = createRoute({
80+
method: 'post',
81+
path: '/api/runs/{runId}/attention/labels',
82+
tags: ['Workflows'],
83+
summary: 'Set or remove labels on the attention wait a run is paused at',
84+
request: {
85+
params: z.object({ runId: z.string() }),
86+
body: { content: { 'application/json': { schema: labelAttentionBodySchema } } },
87+
},
88+
responses: {
89+
200: {
90+
content: { 'application/json': { schema: labelAttentionResponseSchema } },
91+
description: 'Labeled',
92+
},
93+
400: jsonError('Bad request'),
94+
403: jsonError('Run action forbidden'),
95+
404: jsonError('Not found'),
96+
409: jsonError('The write names an occurrence the run is no longer paused at'),
97+
500: jsonError('Server error'),
98+
},
99+
});
100+
101+
export function registerRunAttentionRoutes(
102+
app: OpenAPIHono,
103+
operations: Pick<WorkflowOperations, 'assertRunActionAllowed' | 'labelAttentionWait'>
104+
): void {
105+
const registerOpenApiRoute = openApiRouteRegistrar(app);
106+
const { assertRunActionAllowed, labelAttentionWait } = operations;
107+
const attentionQueue = attentionQueueSource(
108+
createWorkflowStore(),
109+
createWorkflowHostStore().isolation
110+
);
111+
112+
// GET /api/runs/attention - The attention queue. A server has no working directory, so
113+
// without `project` the scope is the whole install.
114+
registerOpenApiRoute(getRunsNeedingAttentionRoute, async c => {
115+
try {
116+
const query = getValidatedQuery(c, attentionQueueQuerySchema);
117+
const label = query.label ?? [];
118+
let scope: AttentionQueueScope;
119+
let request: ReturnType<typeof parseAttentionQueueRequest>;
120+
try {
121+
request = parseAttentionQueueRequest({
122+
...query,
123+
all: query.all === 'true',
124+
labels: typeof label === 'string' ? [label] : label,
125+
});
126+
scope =
127+
query.project === undefined
128+
? { kind: 'all' }
129+
: {
130+
kind: 'project',
131+
project: await resolveAttentionProject(codebaseDb, query.project),
132+
};
133+
} catch (refusal) {
134+
return apiError(c, 400, (refusal as Error).message);
135+
}
136+
// One read backs both the items and the console's dashboard rows for the same runs.
137+
const rows = await workflowDb.listAttentionQueueRows();
138+
const items = await attentionQueueItems(attentionQueue, rows);
139+
const { labels, order, countBy } = request;
140+
if (countBy !== undefined)
141+
return c.json({ counts: countAttentionQueue(items, { scope, labels, countBy }) });
142+
const selected = selectAttentionQueue(items, { scope, labels, order });
143+
const ids = new Set(selected.map(item => item.runId));
144+
const runs = rows.filter(run => ids.has(run.id));
145+
return c.json({ runs: runs.map(toApiDashboardWorkflowRun), items: selected });
146+
} catch (error) {
147+
getLog().error({ err: error }, 'list_runs_needing_attention_failed');
148+
return apiError(c, 500, 'Failed to list runs needing attention');
149+
}
150+
});
151+
152+
// GET /api/runs/:runId/attention - The item a run stands for; a parent blocked on
153+
// sub-runs resolves to its child's item.
154+
registerOpenApiRoute(getRunAttentionItemRoute, async c => {
155+
const runId = c.req.param('runId') ?? '';
156+
try {
157+
const item = await readAttentionQueueItem(attentionQueue, runId);
158+
if (item === undefined) return apiError(c, 404, 'Workflow run not found');
159+
return c.json({ item });
160+
} catch (error) {
161+
getLog().error({ err: error, runId }, 'api.run_attention_item_failed');
162+
return apiError(c, 500, 'Failed to read the attention queue item');
163+
}
164+
});
165+
166+
// POST /api/runs/:runId/attention/labels - Merge a label write into the open attention wait.
167+
registerOpenApiRoute(labelRunAttentionRoute, async c => {
168+
const runId = c.req.param('runId') ?? '';
169+
try {
170+
const run = await workflowDb.getWorkflowRun(runId);
171+
if (!run) return apiError(c, 404, 'Workflow run not found');
172+
const actor = await resolveRunActor(c);
173+
await assertRunActionAllowed(run, actor, 'respond');
174+
const body = getValidatedBody(c, labelAttentionBodySchema);
175+
const request = { occurrence: body.occurrence, set: body.set ?? {}, unset: body.unset ?? [] };
176+
try {
177+
assertLabelable(run, request, 'api');
178+
} catch (refusal) {
179+
if (refusal instanceof StaleGateAnswerError) return apiError(c, 409, refusal.message);
180+
return apiError(c, 400, (refusal as Error).message);
181+
}
182+
return c.json(await labelAttentionWait(runId, request, actor, 'api'));
183+
} catch (error) {
184+
if (error instanceof RunActionForbiddenError) return apiError(c, 403, error.message);
185+
// A race lost after the pre-check above: the run moved to another pause meanwhile.
186+
if (error instanceof StaleGateAnswerError) return apiError(c, 409, error.message);
187+
getLog().error({ err: error, runId }, 'api.run_attention_label_failed');
188+
return apiError(c, 500, 'Failed to label the attention wait');
189+
}
190+
});
191+
}

0 commit comments

Comments
 (0)
Sponsor
SponsoredKunjungi sekarang
Promo