agentic-orchestrator 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agentic/orchestrator/prompts/planner.system.md +1 -0
- package/apps/control-plane/src/providers/cli-worker-provider.ts +25 -3
- package/apps/control-plane/src/providers/providers.ts +4 -0
- package/apps/control-plane/src/supervisor/worker-decision-loop.ts +30 -2
- package/apps/control-plane/test/dashboard-api.integration.spec.ts +94 -0
- package/apps/control-plane/test/dashboard-client.spec.ts +55 -1
- package/apps/control-plane/test/dashboard-ui-utils.spec.ts +139 -0
- package/apps/control-plane/test/providers.spec.ts +1 -0
- package/apps/control-plane/test/worker-decision-loop.spec.ts +70 -2
- package/apps/control-plane/test/worker-provider-adapters.spec.ts +34 -0
- package/config/agentic/orchestrator/agents.yaml +1 -1
- package/config/agentic/orchestrator/prompts/planner.system.md +1 -0
- package/dist/apps/control-plane/providers/cli-worker-provider.d.ts +1 -0
- package/dist/apps/control-plane/providers/cli-worker-provider.js +16 -3
- package/dist/apps/control-plane/providers/cli-worker-provider.js.map +1 -1
- package/dist/apps/control-plane/providers/providers.d.ts +1 -0
- package/dist/apps/control-plane/providers/providers.js +3 -0
- package/dist/apps/control-plane/providers/providers.js.map +1 -1
- package/dist/apps/control-plane/supervisor/worker-decision-loop.js +29 -2
- package/dist/apps/control-plane/supervisor/worker-decision-loop.js.map +1 -1
- package/package.json +1 -1
- package/packages/web-dashboard/src/app/api/actions/route.ts +30 -1
- package/packages/web-dashboard/src/app/api/features/[id]/evidence/[artifact]/route.ts +1 -1
- package/packages/web-dashboard/src/app/api/features/[id]/log/route.ts +18 -0
- package/packages/web-dashboard/src/app/globals.css +12 -3
- package/packages/web-dashboard/src/app/page.tsx +247 -317
- package/packages/web-dashboard/src/components/collision-queue.tsx +38 -0
- package/packages/web-dashboard/src/components/confirmation-modal.tsx +73 -0
- package/packages/web-dashboard/src/components/detail-panel.tsx +431 -0
- package/packages/web-dashboard/src/components/diff-viewer.tsx +68 -0
- package/packages/web-dashboard/src/components/evidence-viewer.tsx +127 -0
- package/packages/web-dashboard/src/components/feature-card.tsx +145 -0
- package/packages/web-dashboard/src/components/filter-bar.tsx +153 -0
- package/packages/web-dashboard/src/components/gate-results.tsx +50 -0
- package/packages/web-dashboard/src/components/human-input-panel.tsx +74 -0
- package/packages/web-dashboard/src/components/kanban-board.tsx +72 -0
- package/packages/web-dashboard/src/components/plan-viewer.tsx +93 -0
- package/packages/web-dashboard/src/components/pr-status-card.tsx +97 -0
- package/packages/web-dashboard/src/components/summary-bar.tsx +65 -0
- package/packages/web-dashboard/src/components/toast.tsx +96 -0
- package/packages/web-dashboard/src/lib/aop-client.ts +96 -0
- package/packages/web-dashboard/src/lib/dashboard-utils.ts +393 -0
- package/packages/web-dashboard/src/lib/orchestrator-tools.ts +85 -0
- package/packages/web-dashboard/src/lib/types.ts +16 -0
- package/packages/web-dashboard/src/styles/dashboard.module.css +906 -0
- package/spec-files/outstanding/agentic_orchestrator_dashboard_advanced_ux_spec.md +660 -0
- package/spec-files/outstanding/agentic_orchestrator_worker_runtime_watchdog_resilience_spec.md +509 -0
- package/spec-files/progress.md +26 -1
- /package/spec-files/{outstanding → completed}/agentic_orchestrator_dashboard_ux_improvements_spec.md +0 -0
|
@@ -2,6 +2,7 @@ You are the planner role.
|
|
|
2
2
|
|
|
3
3
|
Produce deterministic plan submissions and plan updates that conform to plan schema and policy constraints.
|
|
4
4
|
Avoid speculative edits outside the declared file scope.
|
|
5
|
+
When emitting `PLAN_SUBMISSION` or `REQUEST.action=amend_plan`, `plan_json` must only include keys allowed by `plan.schema.json` (no extra fields such as `phase_notes`).
|
|
5
6
|
|
|
6
7
|
After each QA wave, perform an explicit reconciliation pass:
|
|
7
8
|
|
|
@@ -30,6 +30,9 @@ interface CommandTemplateValues {
|
|
|
30
30
|
message?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const CLAUDE_MIN_EFFECTIVE_TIMEOUT_MS = 300_000;
|
|
34
|
+
const HISTORICAL_DEFAULT_TIMEOUT_MS = 120_000;
|
|
35
|
+
|
|
33
36
|
function buildStructuredWorkerPrompt(payload: Record<string, unknown>): string {
|
|
34
37
|
return [
|
|
35
38
|
'Return exactly one JSON object and nothing else.',
|
|
@@ -301,14 +304,15 @@ export class CliWorkerProvider implements WorkerProvider {
|
|
|
301
304
|
featureId: input.feature_id,
|
|
302
305
|
role: input.role,
|
|
303
306
|
});
|
|
307
|
+
const effectiveTimeoutMs = this.resolveRunTimeoutMs();
|
|
304
308
|
|
|
305
309
|
const runResult = await this.commandRunner.run(this.runTemplate.command, args, {
|
|
306
310
|
stdin: prompt,
|
|
307
|
-
timeoutMs:
|
|
311
|
+
timeoutMs: effectiveTimeoutMs,
|
|
308
312
|
env: buildRunCommandEnv(this.selection),
|
|
309
313
|
});
|
|
310
314
|
|
|
311
|
-
this.ensureRunSucceeded(runResult, this.runTemplate.command, args);
|
|
315
|
+
this.ensureRunSucceeded(runResult, this.runTemplate.command, args, effectiveTimeoutMs);
|
|
312
316
|
|
|
313
317
|
return this.outputParser.parse(runResult.stdout ?? '', {
|
|
314
318
|
sessionId,
|
|
@@ -323,7 +327,12 @@ export class CliWorkerProvider implements WorkerProvider {
|
|
|
323
327
|
return `${role}::${featureId}`;
|
|
324
328
|
}
|
|
325
329
|
|
|
326
|
-
private ensureRunSucceeded(
|
|
330
|
+
private ensureRunSucceeded(
|
|
331
|
+
result: ProviderCommandResult,
|
|
332
|
+
command: string,
|
|
333
|
+
args: string[],
|
|
334
|
+
timeoutMs: number,
|
|
335
|
+
): void {
|
|
327
336
|
if (result.exitCode === 0) {
|
|
328
337
|
return;
|
|
329
338
|
}
|
|
@@ -338,11 +347,24 @@ export class CliWorkerProvider implements WorkerProvider {
|
|
|
338
347
|
exit_code: result.exitCode,
|
|
339
348
|
signal: result.signal,
|
|
340
349
|
error_code: result.errorCode,
|
|
350
|
+
timed_out: result.timedOut ?? false,
|
|
351
|
+
timeout_ms: timeoutMs,
|
|
341
352
|
stderr: result.stderr,
|
|
342
353
|
},
|
|
343
354
|
);
|
|
344
355
|
}
|
|
345
356
|
|
|
357
|
+
private resolveRunTimeoutMs(): number {
|
|
358
|
+
if (
|
|
359
|
+
this.selection.provider === 'claude' &&
|
|
360
|
+
this.workerResponseTimeoutMs >= HISTORICAL_DEFAULT_TIMEOUT_MS &&
|
|
361
|
+
this.workerResponseTimeoutMs < CLAUDE_MIN_EFFECTIVE_TIMEOUT_MS
|
|
362
|
+
) {
|
|
363
|
+
return CLAUDE_MIN_EFFECTIVE_TIMEOUT_MS;
|
|
364
|
+
}
|
|
365
|
+
return this.workerResponseTimeoutMs;
|
|
366
|
+
}
|
|
367
|
+
|
|
346
368
|
private async executeControlCommand(
|
|
347
369
|
kind: 'attach' | 'send',
|
|
348
370
|
sessionId: string,
|
|
@@ -242,6 +242,7 @@ export interface ProviderCommandResult {
|
|
|
242
242
|
exitCode: number;
|
|
243
243
|
signal: NodeJS.Signals | null;
|
|
244
244
|
errorCode?: number | string;
|
|
245
|
+
timedOut?: boolean;
|
|
245
246
|
stdout?: string;
|
|
246
247
|
stderr?: string;
|
|
247
248
|
}
|
|
@@ -309,8 +310,10 @@ export class NodeProviderCommandRunner implements ProviderCommandRunner {
|
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
313
|
+
let killedByTimeout = false;
|
|
312
314
|
if (!options.interactive && typeof options.timeoutMs === 'number' && options.timeoutMs > 0) {
|
|
313
315
|
timeoutHandle = setTimeout(() => {
|
|
316
|
+
killedByTimeout = true;
|
|
314
317
|
child.kill('SIGKILL');
|
|
315
318
|
}, options.timeoutMs);
|
|
316
319
|
}
|
|
@@ -323,6 +326,7 @@ export class NodeProviderCommandRunner implements ProviderCommandRunner {
|
|
|
323
326
|
exitCode: code ?? (spawnErrorCode === 'ENOENT' ? 127 : 1),
|
|
324
327
|
signal,
|
|
325
328
|
errorCode: spawnErrorCode,
|
|
329
|
+
timedOut: killedByTimeout,
|
|
326
330
|
stdout,
|
|
327
331
|
stderr,
|
|
328
332
|
});
|
|
@@ -7,6 +7,23 @@ import type { RuntimeRole, SupervisorToolCaller } from './types.js';
|
|
|
7
7
|
import type { ActivityMonitorService } from '../application/services/activity-monitor-service.js';
|
|
8
8
|
|
|
9
9
|
type AnyRecord = Record<string, unknown>;
|
|
10
|
+
const PLAN_TOP_LEVEL_KEYS = new Set([
|
|
11
|
+
'feature_id',
|
|
12
|
+
'plan_version',
|
|
13
|
+
'summary',
|
|
14
|
+
'allowed_areas',
|
|
15
|
+
'forbidden_areas',
|
|
16
|
+
'base_ref',
|
|
17
|
+
'files',
|
|
18
|
+
'contracts',
|
|
19
|
+
'acceptance_criteria',
|
|
20
|
+
'gate_profile',
|
|
21
|
+
'gate_targets',
|
|
22
|
+
'risk',
|
|
23
|
+
'revision_of',
|
|
24
|
+
'revision_reason',
|
|
25
|
+
'verification_overrides',
|
|
26
|
+
]);
|
|
10
27
|
|
|
11
28
|
export interface WorkerDecisionInput {
|
|
12
29
|
role: RuntimeRole;
|
|
@@ -65,6 +82,17 @@ function readPlanVersion(value: unknown): number | null {
|
|
|
65
82
|
return Math.floor(value);
|
|
66
83
|
}
|
|
67
84
|
|
|
85
|
+
function sanitizePlannerPlan(plan: AnyRecord): AnyRecord {
|
|
86
|
+
const sanitized: AnyRecord = {};
|
|
87
|
+
for (const [key, value] of Object.entries(plan)) {
|
|
88
|
+
if (!PLAN_TOP_LEVEL_KEYS.has(key)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
sanitized[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return sanitized;
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
function asOutputs(result: Record<string, unknown>): AnyRecord[] {
|
|
69
97
|
const outputs = result.outputs;
|
|
70
98
|
if (Array.isArray(outputs)) {
|
|
@@ -223,7 +251,7 @@ export class WorkerDecisionLoop implements WorkerDecisionRunner {
|
|
|
223
251
|
return;
|
|
224
252
|
}
|
|
225
253
|
|
|
226
|
-
const planJson = asRecord(output.plan_json ?? output.plan);
|
|
254
|
+
const planJson = sanitizePlannerPlan(asRecord(output.plan_json ?? output.plan));
|
|
227
255
|
if (Object.keys(planJson).length === 0) {
|
|
228
256
|
return;
|
|
229
257
|
}
|
|
@@ -350,7 +378,7 @@ export class WorkerDecisionLoop implements WorkerDecisionRunner {
|
|
|
350
378
|
}
|
|
351
379
|
|
|
352
380
|
if (action === 'amend_plan' && input.role === 'planner') {
|
|
353
|
-
const planJson = asRecord(request.plan_json);
|
|
381
|
+
const planJson = sanitizePlannerPlan(asRecord(request.plan_json));
|
|
354
382
|
if (Object.keys(planJson).length === 0) {
|
|
355
383
|
return;
|
|
356
384
|
}
|
|
@@ -11,6 +11,12 @@ const readDashboardStatusMock = vi.hoisted(() =>
|
|
|
11
11
|
})),
|
|
12
12
|
);
|
|
13
13
|
const readFeatureStateMock = vi.hoisted(() => vi.fn(async () => null));
|
|
14
|
+
const readFeatureLogMock = vi.hoisted(() =>
|
|
15
|
+
vi.fn(async () => ({
|
|
16
|
+
entries: [{ timestamp: '2026-03-05T00:00:00Z', message: 'log message', type: 'log' }],
|
|
17
|
+
total: 1,
|
|
18
|
+
})),
|
|
19
|
+
);
|
|
14
20
|
const getAopRootMock = vi.hoisted(() => vi.fn(() => process.cwd()));
|
|
15
21
|
const approveFeatureReviewMock = vi.hoisted(() =>
|
|
16
22
|
vi.fn(async () => ({ ok: true, data: { merged: true } })),
|
|
@@ -21,12 +27,19 @@ const denyFeatureReviewMock = vi.hoisted(() =>
|
|
|
21
27
|
const requestFeatureChangesMock = vi.hoisted(() =>
|
|
22
28
|
vi.fn(async () => ({ ok: true, data: { delivered: true } })),
|
|
23
29
|
);
|
|
30
|
+
const sendFeatureMessageMock = vi.hoisted(() =>
|
|
31
|
+
vi.fn(async () => ({ ok: true, data: { delivered: true } })),
|
|
32
|
+
);
|
|
33
|
+
const retryFeatureMock = vi.hoisted(() =>
|
|
34
|
+
vi.fn(async () => ({ ok: true, data: { retry_mode: 'full' } })),
|
|
35
|
+
);
|
|
24
36
|
const execFileMock = vi.hoisted(() => vi.fn());
|
|
25
37
|
|
|
26
38
|
vi.mock('../../../packages/web-dashboard/src/lib/aop-client.js', () => ({
|
|
27
39
|
resolveProjectRoot: resolveProjectRootMock,
|
|
28
40
|
readDashboardStatus: readDashboardStatusMock,
|
|
29
41
|
readFeatureState: readFeatureStateMock,
|
|
42
|
+
readFeatureLog: readFeatureLogMock,
|
|
30
43
|
getAopRoot: getAopRootMock,
|
|
31
44
|
}));
|
|
32
45
|
|
|
@@ -34,6 +47,8 @@ vi.mock('../../../packages/web-dashboard/src/lib/orchestrator-tools.js', () => (
|
|
|
34
47
|
approveFeatureReview: approveFeatureReviewMock,
|
|
35
48
|
denyFeatureReview: denyFeatureReviewMock,
|
|
36
49
|
requestFeatureChanges: requestFeatureChangesMock,
|
|
50
|
+
sendFeatureMessage: sendFeatureMessageMock,
|
|
51
|
+
retryFeature: retryFeatureMock,
|
|
37
52
|
}));
|
|
38
53
|
|
|
39
54
|
vi.mock('node:child_process', () => ({
|
|
@@ -52,6 +67,7 @@ vi.mock('node:child_process', () => ({
|
|
|
52
67
|
|
|
53
68
|
import { POST as actionsPost } from '../../../packages/web-dashboard/src/app/api/actions/route.js';
|
|
54
69
|
import { POST as checkoutPost } from '../../../packages/web-dashboard/src/app/api/features/[id]/checkout/route.js';
|
|
70
|
+
import { GET as logGet } from '../../../packages/web-dashboard/src/app/api/features/[id]/log/route.js';
|
|
55
71
|
import { GET as statusGet } from '../../../packages/web-dashboard/src/app/api/status/route.js';
|
|
56
72
|
import { GET as projectsGet } from '../../../packages/web-dashboard/src/app/api/projects/route.js';
|
|
57
73
|
|
|
@@ -67,10 +83,13 @@ describe('dashboard api integration', () => {
|
|
|
67
83
|
resolveProjectRootMock.mockReset();
|
|
68
84
|
readDashboardStatusMock.mockReset();
|
|
69
85
|
readFeatureStateMock.mockReset();
|
|
86
|
+
readFeatureLogMock.mockReset();
|
|
70
87
|
getAopRootMock.mockReset();
|
|
71
88
|
approveFeatureReviewMock.mockReset();
|
|
72
89
|
denyFeatureReviewMock.mockReset();
|
|
73
90
|
requestFeatureChangesMock.mockReset();
|
|
91
|
+
sendFeatureMessageMock.mockReset();
|
|
92
|
+
retryFeatureMock.mockReset();
|
|
74
93
|
execFileMock.mockReset();
|
|
75
94
|
|
|
76
95
|
resolveProjectRootMock.mockResolvedValue(repoRoot);
|
|
@@ -89,6 +108,10 @@ describe('dashboard api integration', () => {
|
|
|
89
108
|
},
|
|
90
109
|
],
|
|
91
110
|
});
|
|
111
|
+
readFeatureLogMock.mockResolvedValue({
|
|
112
|
+
entries: [{ timestamp: '2026-03-05T00:00:00Z', message: 'log message', type: 'log' }],
|
|
113
|
+
total: 1,
|
|
114
|
+
});
|
|
92
115
|
});
|
|
93
116
|
|
|
94
117
|
afterEach(async () => {
|
|
@@ -154,6 +177,58 @@ describe('dashboard api integration', () => {
|
|
|
154
177
|
expect(body.error.code).toBe('reason_required');
|
|
155
178
|
});
|
|
156
179
|
|
|
180
|
+
it('GIVEN_feature_send_message_without_message_WHEN_posted_THEN_returns_400', async () => {
|
|
181
|
+
const response = await actionsPost(
|
|
182
|
+
new Request('http://localhost/api/actions', {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
action: 'feature.send_message',
|
|
186
|
+
feature_id: 'feature_checkout',
|
|
187
|
+
}),
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const body = (await response.json()) as { ok: boolean; error: { code: string } };
|
|
192
|
+
expect(response.status).toBe(400);
|
|
193
|
+
expect(body.ok).toBe(false);
|
|
194
|
+
expect(body.error.code).toBe('message_required');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('GIVEN_feature_send_message_WHEN_posted_THEN_routes_to_tool', async () => {
|
|
198
|
+
const response = await actionsPost(
|
|
199
|
+
new Request('http://localhost/api/actions?project=alpha', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
body: JSON.stringify({
|
|
202
|
+
action: 'feature.send_message',
|
|
203
|
+
feature_id: 'feature_checkout',
|
|
204
|
+
message: 'Need clarification',
|
|
205
|
+
}),
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(response.status).toBe(200);
|
|
210
|
+
expect(sendFeatureMessageMock).toHaveBeenCalledWith(
|
|
211
|
+
'feature_checkout',
|
|
212
|
+
'Need clarification',
|
|
213
|
+
repoRoot,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('GIVEN_feature_retry_WHEN_posted_THEN_routes_to_retry_tool', async () => {
|
|
218
|
+
const response = await actionsPost(
|
|
219
|
+
new Request('http://localhost/api/actions', {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
action: 'feature.retry',
|
|
223
|
+
feature_id: 'feature_checkout',
|
|
224
|
+
}),
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(response.status).toBe(200);
|
|
229
|
+
expect(retryFeatureMock).toHaveBeenCalledWith('feature_checkout', repoRoot);
|
|
230
|
+
});
|
|
231
|
+
|
|
157
232
|
it('GIVEN_checkout_then_restore_WHEN_feature_branch_exists_THEN_persists_and_restores_checkout_record', async () => {
|
|
158
233
|
readFeatureStateMock.mockResolvedValue({
|
|
159
234
|
id: 'feature_checkout',
|
|
@@ -276,4 +351,23 @@ describe('dashboard api integration', () => {
|
|
|
276
351
|
expect(body.data.projects.map((project) => project.name)).toEqual(['alpha', 'beta']);
|
|
277
352
|
expect(body.data.projects.map((project) => project.path)).toEqual([projectAlpha, projectBeta]);
|
|
278
353
|
});
|
|
354
|
+
|
|
355
|
+
it('GIVEN_feature_history_route_WHEN_requested_THEN_returns_log_entries', async () => {
|
|
356
|
+
const response = await logGet(
|
|
357
|
+
new Request('http://localhost/api/features/feature_checkout/log?project=alpha'),
|
|
358
|
+
{
|
|
359
|
+
params: Promise.resolve({ id: 'feature_checkout' }),
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
const body = (await response.json()) as {
|
|
363
|
+
ok: boolean;
|
|
364
|
+
data: { entries: Array<{ message: string }>; total: number };
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
expect(response.status).toBe(200);
|
|
368
|
+
expect(body.ok).toBe(true);
|
|
369
|
+
expect(body.data.total).toBe(1);
|
|
370
|
+
expect(body.data.entries[0].message).toBe('log message');
|
|
371
|
+
expect(readFeatureLogMock).toHaveBeenCalledWith('feature_checkout', repoRoot);
|
|
372
|
+
});
|
|
279
373
|
});
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { readDashboardStatus } from '@/lib/aop-client.js';
|
|
5
|
+
import { readDashboardStatus, readFeatureLog } from '@/lib/aop-client.js';
|
|
6
6
|
|
|
7
7
|
async function writeState(repoRoot: string, featureId: string, frontMatter: string): Promise<void> {
|
|
8
8
|
const featureDir = path.join(repoRoot, '.aop', 'features', featureId);
|
|
@@ -91,6 +91,33 @@ describe('dashboard aop client mapping', () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it('GIVEN_state_with_gate_map_WHEN_readDashboardStatus_THEN_gate_statuses_are_parsed', async () => {
|
|
95
|
+
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
96
|
+
tempRoots.push(repoRoot);
|
|
97
|
+
await fs.mkdir(path.join(repoRoot, '.aop', 'features'), { recursive: true });
|
|
98
|
+
await fs.writeFile(
|
|
99
|
+
path.join(repoRoot, '.aop', 'features', 'index.json'),
|
|
100
|
+
JSON.stringify({ active: ['feature_gate'], blocked: [], merged: [], blocked_queue: [] }),
|
|
101
|
+
'utf8',
|
|
102
|
+
);
|
|
103
|
+
await writeState(
|
|
104
|
+
repoRoot,
|
|
105
|
+
'feature_gate',
|
|
106
|
+
[
|
|
107
|
+
'feature_id: feature_gate',
|
|
108
|
+
'version: 1',
|
|
109
|
+
'status: qa',
|
|
110
|
+
'gates:',
|
|
111
|
+
' fast: pass',
|
|
112
|
+
' full: fail',
|
|
113
|
+
' merge: na',
|
|
114
|
+
].join('\n'),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const payload = await readDashboardStatus(repoRoot);
|
|
118
|
+
expect(payload.features[0].gates).toEqual({ fast: 'pass', full: 'fail', merge: 'na' });
|
|
119
|
+
});
|
|
120
|
+
|
|
94
121
|
it('GIVEN_blocked_queue_feature_without_state_WHEN_readDashboardStatus_THEN_phase_is_blocked', async () => {
|
|
95
122
|
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
96
123
|
tempRoots.push(repoRoot);
|
|
@@ -119,4 +146,31 @@ describe('dashboard aop client mapping', () => {
|
|
|
119
146
|
phase: 'blocked',
|
|
120
147
|
});
|
|
121
148
|
});
|
|
149
|
+
|
|
150
|
+
it('GIVEN_state_body_and_evidence_WHEN_readFeatureLog_THEN_returns_sorted_entries', async () => {
|
|
151
|
+
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
152
|
+
tempRoots.push(repoRoot);
|
|
153
|
+
const featureRoot = path.join(repoRoot, '.aop', 'features', 'feature_hist');
|
|
154
|
+
const evidenceRoot = path.join(featureRoot, 'evidence');
|
|
155
|
+
await fs.mkdir(evidenceRoot, { recursive: true });
|
|
156
|
+
await fs.writeFile(
|
|
157
|
+
path.join(featureRoot, 'state.md'),
|
|
158
|
+
[
|
|
159
|
+
'---',
|
|
160
|
+
'feature_id: feature_hist',
|
|
161
|
+
'status: qa',
|
|
162
|
+
'---',
|
|
163
|
+
'[2026-03-05T00:00:00Z] status changed to qa',
|
|
164
|
+
'[2026-03-04T20:00:00Z] gates fast pass',
|
|
165
|
+
].join('\n'),
|
|
166
|
+
'utf8',
|
|
167
|
+
);
|
|
168
|
+
await fs.writeFile(path.join(evidenceRoot, 'qa-summary.txt'), 'ok', 'utf8');
|
|
169
|
+
|
|
170
|
+
const log = await readFeatureLog('feature_hist', repoRoot);
|
|
171
|
+
expect(log.total).toBeGreaterThanOrEqual(3);
|
|
172
|
+
expect(log.entries[0].timestamp >= log.entries[1].timestamp).toBe(true);
|
|
173
|
+
expect(log.entries.some((entry) => entry.type === 'evidence')).toBe(true);
|
|
174
|
+
expect(log.entries.some((entry) => entry.type === 'state_change')).toBe(true);
|
|
175
|
+
});
|
|
122
176
|
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
applyQuickFilter,
|
|
4
|
+
computeSummaryCounts,
|
|
5
|
+
createDefaultFilters,
|
|
6
|
+
filterFeatures,
|
|
7
|
+
formatElapsedTime,
|
|
8
|
+
formatRelativeTime,
|
|
9
|
+
parseUnifiedDiff,
|
|
10
|
+
truncateText,
|
|
11
|
+
} from '../../../packages/web-dashboard/src/lib/dashboard-utils.js';
|
|
12
|
+
import type {
|
|
13
|
+
DashboardStatusPayload,
|
|
14
|
+
FeatureSummary,
|
|
15
|
+
} from '../../../packages/web-dashboard/src/lib/types.js';
|
|
16
|
+
|
|
17
|
+
function feature(overrides: Partial<FeatureSummary>): FeatureSummary {
|
|
18
|
+
return {
|
|
19
|
+
id: overrides.feature_id ?? 'feature_a',
|
|
20
|
+
feature_id: overrides.feature_id ?? 'feature_a',
|
|
21
|
+
status: overrides.status ?? 'planning',
|
|
22
|
+
phase: overrides.phase ?? 'planning',
|
|
23
|
+
branch: overrides.branch ?? null,
|
|
24
|
+
worktree_path: overrides.worktree_path ?? null,
|
|
25
|
+
activity_state: overrides.activity_state ?? 'idle',
|
|
26
|
+
pr: overrides.pr ?? null,
|
|
27
|
+
gates: overrides.gates,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('dashboard utilities', () => {
|
|
33
|
+
it('applies quick filters deterministically', () => {
|
|
34
|
+
const active = applyQuickFilter('active');
|
|
35
|
+
expect(active.phases).toEqual(['planning', 'building', 'qa', 'ready_to_merge']);
|
|
36
|
+
|
|
37
|
+
const awaitingInput = applyQuickFilter('awaiting_input');
|
|
38
|
+
expect(awaitingInput.activities).toEqual(['waiting_input']);
|
|
39
|
+
|
|
40
|
+
const ciFailing = applyQuickFilter('ci_failing');
|
|
41
|
+
expect(ciFailing.ciFailuresOnly).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('filters feature collections by search, phase, activity, gates, and ci', () => {
|
|
45
|
+
const features = [
|
|
46
|
+
feature({
|
|
47
|
+
feature_id: 'alpha',
|
|
48
|
+
phase: 'blocked',
|
|
49
|
+
activity_state: 'waiting_input',
|
|
50
|
+
gates: { fast: 'fail' },
|
|
51
|
+
pr: { ci_status: 'failure' },
|
|
52
|
+
}),
|
|
53
|
+
feature({
|
|
54
|
+
feature_id: 'beta',
|
|
55
|
+
phase: 'qa',
|
|
56
|
+
activity_state: 'active',
|
|
57
|
+
gates: { fast: 'pass' },
|
|
58
|
+
pr: { ci_status: 'success' },
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const filters = {
|
|
63
|
+
...createDefaultFilters(),
|
|
64
|
+
searchText: 'alp',
|
|
65
|
+
phases: ['blocked'] as FeatureSummary['phase'][],
|
|
66
|
+
activities: ['waiting_input'] as NonNullable<FeatureSummary['activity_state']>[],
|
|
67
|
+
gateFailuresOnly: true,
|
|
68
|
+
ciFailuresOnly: true,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const filtered = filterFeatures(features, filters);
|
|
72
|
+
expect(filtered).toHaveLength(1);
|
|
73
|
+
expect(filtered[0].feature_id).toBe('alpha');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('computes summary counters from status payload', () => {
|
|
77
|
+
const payload: DashboardStatusPayload = {
|
|
78
|
+
index: {
|
|
79
|
+
active: ['alpha', 'beta'],
|
|
80
|
+
blocked: ['alpha'],
|
|
81
|
+
merged: [],
|
|
82
|
+
blocked_queue: [],
|
|
83
|
+
},
|
|
84
|
+
features: [
|
|
85
|
+
feature({
|
|
86
|
+
feature_id: 'alpha',
|
|
87
|
+
phase: 'blocked',
|
|
88
|
+
activity_state: 'waiting_input',
|
|
89
|
+
pr: { ci_status: 'failure' },
|
|
90
|
+
}),
|
|
91
|
+
feature({
|
|
92
|
+
feature_id: 'beta',
|
|
93
|
+
phase: 'ready_to_merge',
|
|
94
|
+
activity_state: 'active',
|
|
95
|
+
pr: { ci_status: 'success' },
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const counts = computeSummaryCounts(payload);
|
|
101
|
+
expect(counts).toEqual({
|
|
102
|
+
active: 2,
|
|
103
|
+
blocked: 1,
|
|
104
|
+
awaitingInput: 1,
|
|
105
|
+
readyToMerge: 1,
|
|
106
|
+
ciFailing: 1,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('parses unified diff into file and hunk metadata with line numbers', () => {
|
|
111
|
+
const parsed = parseUnifiedDiff(
|
|
112
|
+
[
|
|
113
|
+
'diff --git a/file.ts b/file.ts',
|
|
114
|
+
'index abc..def 100644',
|
|
115
|
+
'--- a/file.ts',
|
|
116
|
+
'+++ b/file.ts',
|
|
117
|
+
'@@ -1,2 +1,2 @@',
|
|
118
|
+
'-const oldValue = 1;',
|
|
119
|
+
'+const newValue = 2;',
|
|
120
|
+
' export const done = true;',
|
|
121
|
+
].join('\n'),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(parsed).toHaveLength(1);
|
|
125
|
+
expect(parsed[0].filePath).toBe('file.ts');
|
|
126
|
+
const allLines = parsed[0].hunks.flatMap((hunk) => hunk.lines);
|
|
127
|
+
const firstDelete = allLines.find((line) => line.type === 'del');
|
|
128
|
+
const firstAdd = allLines.find((line) => line.type === 'add');
|
|
129
|
+
expect(firstDelete).toMatchObject({ type: 'del', oldLineNumber: 1 });
|
|
130
|
+
expect(firstAdd).toMatchObject({ type: 'add', newLineNumber: 1 });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('formats relative and elapsed time and truncates text', () => {
|
|
134
|
+
const now = Date.parse('2026-03-05T00:01:00Z');
|
|
135
|
+
expect(formatRelativeTime('2026-03-05T00:00:00Z', now)).toBe('1 minute ago');
|
|
136
|
+
expect(formatElapsedTime('2026-03-04T22:31:00Z', now)).toBe('1h 30m');
|
|
137
|
+
expect(truncateText('feature_abcdefghijklmnopqrstuvwxyz', 24)).toBe('feature_abcdefghijklm...');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -689,5 +689,6 @@ describe('NodeProviderCommandRunner', () => {
|
|
|
689
689
|
const result = await runner.run('sleep', ['10'], { timeoutMs: 50 });
|
|
690
690
|
// Killed by SIGKILL → exitCode should be non-zero or null
|
|
691
691
|
expect(result.signal).toBe('SIGKILL');
|
|
692
|
+
expect(result.timedOut).toBe(true);
|
|
692
693
|
});
|
|
693
694
|
});
|
|
@@ -19,7 +19,7 @@ describe('WorkerDecisionLoop', () => {
|
|
|
19
19
|
|
|
20
20
|
function makeToolCaller() {
|
|
21
21
|
return {
|
|
22
|
-
callTool: vi.fn(async (_role: string, toolName: string) => {
|
|
22
|
+
callTool: vi.fn(async (_role: string, toolName: string, _input?: unknown) => {
|
|
23
23
|
if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
|
|
24
24
|
return { ok: true, data: { refreshed: true } };
|
|
25
25
|
}
|
|
@@ -117,6 +117,38 @@ describe('WorkerDecisionLoop', () => {
|
|
|
117
117
|
);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
it('GIVEN_planner_submission_with_unknown_plan_fields_WHEN_executed_THEN_drops_unrecognized_fields', async () => {
|
|
121
|
+
const provider = makeProvider({
|
|
122
|
+
type: 'PLAN_SUBMISSION',
|
|
123
|
+
plan_json: {
|
|
124
|
+
feature_id: 'feature_a',
|
|
125
|
+
plan_version: 1,
|
|
126
|
+
summary: 'Initial plan',
|
|
127
|
+
phase_notes: ['not in schema'],
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const toolCaller = makeToolCaller();
|
|
131
|
+
const loop = new WorkerDecisionLoop({
|
|
132
|
+
provider: provider as never,
|
|
133
|
+
toolCaller: toolCaller as never,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = await loop.execute({
|
|
137
|
+
role: 'planner',
|
|
138
|
+
featureId: 'feature_a',
|
|
139
|
+
contextBundle: { plan: null },
|
|
140
|
+
instructions: 'plan',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.planSubmission).toBe(true);
|
|
144
|
+
const planSubmitCall = toolCaller.callTool.mock.calls.find(
|
|
145
|
+
(call) => call[0] === 'planner' && call[1] === TOOLS.PLAN_SUBMIT,
|
|
146
|
+
);
|
|
147
|
+
expect(planSubmitCall).toBeDefined();
|
|
148
|
+
const payload = planSubmitCall?.[2] as { plan_json?: Record<string, unknown> };
|
|
149
|
+
expect(payload.plan_json?.phase_notes).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
120
152
|
it('GIVEN_planner_submission_with_existing_plan_WHEN_executed_THEN_routes_to_plan_update', async () => {
|
|
121
153
|
const provider = makeProvider({
|
|
122
154
|
type: 'PLAN_SUBMISSION',
|
|
@@ -478,6 +510,42 @@ describe('WorkerDecisionLoop', () => {
|
|
|
478
510
|
);
|
|
479
511
|
});
|
|
480
512
|
|
|
513
|
+
it('GIVEN_planner_amend_plan_with_unknown_plan_fields_WHEN_executed_THEN_strips_unrecognized_fields', async () => {
|
|
514
|
+
const provider = makeProvider({
|
|
515
|
+
type: 'REQUEST',
|
|
516
|
+
request: {
|
|
517
|
+
action: 'amend_plan',
|
|
518
|
+
plan_json: {
|
|
519
|
+
feature_id: 'feature_a',
|
|
520
|
+
plan_version: 1,
|
|
521
|
+
summary: 'Plan with extra metadata',
|
|
522
|
+
phase_notes: ['drop me'],
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
const toolCaller = makeToolCaller();
|
|
527
|
+
const loop = new WorkerDecisionLoop({
|
|
528
|
+
provider: provider as never,
|
|
529
|
+
toolCaller: toolCaller as never,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const result = await loop.execute({
|
|
533
|
+
role: 'planner',
|
|
534
|
+
featureId: 'feature_a',
|
|
535
|
+
contextBundle: { plan: null },
|
|
536
|
+
instructions: 'amend',
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
expect(result.requestHandled).toBe(true);
|
|
540
|
+
expect(result.planSubmission).toBe(true);
|
|
541
|
+
const planSubmitCall = toolCaller.callTool.mock.calls.find(
|
|
542
|
+
(call) => call[0] === 'planner' && call[1] === TOOLS.PLAN_SUBMIT,
|
|
543
|
+
);
|
|
544
|
+
expect(planSubmitCall).toBeDefined();
|
|
545
|
+
const payload = planSubmitCall?.[2] as { plan_json?: Record<string, unknown> };
|
|
546
|
+
expect(payload.plan_json?.phase_notes).toBeUndefined();
|
|
547
|
+
});
|
|
548
|
+
|
|
481
549
|
it('GIVEN_planner_amend_plan_request_WHEN_existing_version_present_THEN_updates_plan', async () => {
|
|
482
550
|
const provider = makeProvider({
|
|
483
551
|
type: 'REQUEST',
|
|
@@ -620,7 +688,7 @@ describe('WorkerDecisionLoop amend_plan revision_of branches', () => {
|
|
|
620
688
|
|
|
621
689
|
function makeToolCaller() {
|
|
622
690
|
return {
|
|
623
|
-
callTool: vi.fn(async (_role: string, toolName: string) => {
|
|
691
|
+
callTool: vi.fn(async (_role: string, toolName: string, _input?: unknown) => {
|
|
624
692
|
if (toolName === TOOLS.FEATURE_GET_CONTEXT) {
|
|
625
693
|
return { ok: true, data: { refreshed: true } };
|
|
626
694
|
}
|
|
@@ -323,6 +323,40 @@ describe('CliWorkerProvider', () => {
|
|
|
323
323
|
expect(options?.stdin).toContain('"feature_id":"feature-a"');
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
+
it('GIVEN_claude_with_legacy_default_timeout_WHEN_runWorker_THEN_uses_extended_effective_timeout', async () => {
|
|
327
|
+
const run = vi.fn(
|
|
328
|
+
async (
|
|
329
|
+
_command: string,
|
|
330
|
+
_args: string[],
|
|
331
|
+
_options?: Record<string, unknown>,
|
|
332
|
+
): Promise<ProviderCommandResult> => ({
|
|
333
|
+
exitCode: 0,
|
|
334
|
+
signal: null,
|
|
335
|
+
stdout: JSON.stringify({
|
|
336
|
+
outputs: [{ type: 'NOTE', content: 'ok' }],
|
|
337
|
+
}),
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const provider = new CliWorkerProvider(makeSelection('claude'), {
|
|
342
|
+
outputParser: new GenericCliOutputParser(),
|
|
343
|
+
commandRunner: { run },
|
|
344
|
+
workerResponseTimeoutMs: 120_000,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await provider.createSession('planner', 'feature-timeout', 'prompt');
|
|
348
|
+
await provider.runWorker({
|
|
349
|
+
role: 'planner',
|
|
350
|
+
feature_id: 'feature-timeout',
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const firstCall = run.mock.calls.at(0);
|
|
354
|
+
const options = firstCall?.[2] as
|
|
355
|
+
| { stdin?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv }
|
|
356
|
+
| undefined;
|
|
357
|
+
expect(options?.timeoutMs).toBe(300_000);
|
|
358
|
+
});
|
|
359
|
+
|
|
326
360
|
it('GIVEN_nonzero_run_exit_WHEN_runWorker_THEN_throws_runtime_unavailable', async () => {
|
|
327
361
|
const provider = new CliWorkerProvider(makeSelection('codex'), {
|
|
328
362
|
outputParser: new GenericCliOutputParser(),
|