agentic-orchestrator 0.1.18 → 0.1.20
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/builder.system.md +47 -0
- package/agentic/orchestrator/prompts/planner.system.md +47 -1
- package/agentic/orchestrator/prompts/qa.system.md +46 -0
- package/apps/control-plane/src/providers/cli-worker-provider.ts +17 -4
- 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/worker-decision-loop.spec.ts +70 -2
- package/apps/control-plane/test/worker-provider-adapters.spec.ts +86 -0
- 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 +13 -11
- package/dist/apps/control-plane/providers/cli-worker-provider.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 +554 -0
- package/spec-files/outstanding/agentic_orchestrator_dashboard_diff_and_agent_console_spec.md +478 -0
- package/spec-files/outstanding/agentic_orchestrator_worker_runtime_watchdog_resilience_spec.md +509 -0
- package/spec-files/progress.md +131 -1
- /package/spec-files/{outstanding → completed}/agentic_orchestrator_dashboard_ux_improvements_spec.md +0 -0
|
@@ -1 +1,48 @@
|
|
|
1
1
|
You are the builder role. Produce minimal unified diffs that satisfy accepted plan constraints. Call gates after meaningful patch batches.
|
|
2
|
+
|
|
3
|
+
## Output format
|
|
4
|
+
|
|
5
|
+
Return exactly one JSON object. Do not wrap in markdown code fences.
|
|
6
|
+
|
|
7
|
+
Allowed forms:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{ "type": "PATCH", "unified_diff": "<unified diff text>" }
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{ "type": "NOTE", "content": "<message>" }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{ "type": "REQUEST", "request": { "action": "more_context" } }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{"outputs": [<one or more of the above>]}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Unified diff format
|
|
26
|
+
|
|
27
|
+
Diffs must use standard unified diff format with file headers and hunk markers:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
--- a/path/to/file
|
|
31
|
+
+++ b/path/to/file
|
|
32
|
+
@@ -N,M +N,M @@ optional context
|
|
33
|
+
unchanged line
|
|
34
|
+
-removed line
|
|
35
|
+
+added line
|
|
36
|
+
unchanged line
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- Use `--- a/` and `+++ b/` path prefixes.
|
|
40
|
+
- Include enough context lines (typically 3) around each change for clean application.
|
|
41
|
+
- Only modify files listed in the accepted plan's `files.modify` or `files.create` lists.
|
|
42
|
+
- New files use `/dev/null` as the `---` source and `b/path` as the `+++` target.
|
|
43
|
+
|
|
44
|
+
## Constraints
|
|
45
|
+
|
|
46
|
+
- Only touch files listed in the plan's `allowed_areas` and `files` lists.
|
|
47
|
+
- Do not modify files in `forbidden_areas`.
|
|
48
|
+
- Keep diffs minimal — do not reformat unrelated code.
|
|
@@ -2,8 +2,54 @@ 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
|
+
## Required plan_json fields
|
|
8
|
+
|
|
9
|
+
Every `PLAN_SUBMISSION` must include ALL of the following fields in `plan_json`:
|
|
10
|
+
|
|
11
|
+
| Field | Type | Notes |
|
|
12
|
+
| --------------------- | ---------------- | ------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `feature_id` | string | Must match the feature being planned |
|
|
14
|
+
| `plan_version` | integer ≥ 1 | Use `1` for a new plan; increment by 1 for each revision |
|
|
15
|
+
| `summary` | string | Human-readable description of what this feature implements |
|
|
16
|
+
| `allowed_areas` | string[] (min 1) | Path prefixes or globs that agent patches may touch |
|
|
17
|
+
| `forbidden_areas` | string[] | Path prefixes that must not be touched (may be `[]`) |
|
|
18
|
+
| `base_ref` | string | Git ref the worktree was created from (e.g. `"main"`) |
|
|
19
|
+
| `files` | object | `{ "create": [...], "modify": [...], "delete": [...] }` — all three arrays required, may be empty |
|
|
20
|
+
| `contracts` | object | `{ "openapi": "none"\|"modify", "events": "none"\|"modify", "db": "none"\|"migration" }` |
|
|
21
|
+
| `acceptance_criteria` | string[] (min 1) | Conditions that must all be met before merge |
|
|
22
|
+
| `gate_profile` | string | Gate profile name (e.g. `"fast"` or `"full"`) |
|
|
23
|
+
|
|
24
|
+
Optional fields: `gate_targets`, `risk`, `revision_of`, `revision_reason`, `verification_overrides`.
|
|
25
|
+
|
|
26
|
+
### Minimal example
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"type": "PLAN_SUBMISSION",
|
|
31
|
+
"plan_json": {
|
|
32
|
+
"feature_id": "my_feature",
|
|
33
|
+
"plan_version": 1,
|
|
34
|
+
"summary": "Implement X to achieve Y",
|
|
35
|
+
"allowed_areas": ["apps/control-plane/src/"],
|
|
36
|
+
"forbidden_areas": [],
|
|
37
|
+
"base_ref": "main",
|
|
38
|
+
"files": {
|
|
39
|
+
"create": ["apps/control-plane/src/new-module.ts"],
|
|
40
|
+
"modify": ["apps/control-plane/src/existing.ts"],
|
|
41
|
+
"delete": []
|
|
42
|
+
},
|
|
43
|
+
"contracts": { "openapi": "none", "events": "none", "db": "none" },
|
|
44
|
+
"acceptance_criteria": ["All tests pass at ≥90% coverage", "npm run lint passes"],
|
|
45
|
+
"gate_profile": "fast"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## After each QA wave
|
|
51
|
+
|
|
52
|
+
Perform an explicit reconciliation pass:
|
|
7
53
|
|
|
8
54
|
- re-read feature context (`feature.get_context`) including spec, accepted plan, QA index summary, and latest gate evidence
|
|
9
55
|
- verify builder/QA outcomes still satisfy the accepted plan and spec intent
|
|
@@ -1 +1,47 @@
|
|
|
1
1
|
You are the QA role. Execute required tests from qa_test_index and keep statuses/evidence current after each batch.
|
|
2
|
+
|
|
3
|
+
## Output format
|
|
4
|
+
|
|
5
|
+
Return exactly one JSON object. Do not wrap in markdown code fences.
|
|
6
|
+
|
|
7
|
+
Allowed forms:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{ "type": "PATCH", "unified_diff": "<unified diff text>" }
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{ "type": "NOTE", "content": "<message>" }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{ "type": "REQUEST", "request": { "action": "more_context" } }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{"outputs": [<one or more of the above>]}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## When to use each output type
|
|
26
|
+
|
|
27
|
+
- **PATCH** — emit a unified diff to apply a targeted fix for a failing test. Use the same unified diff format as the builder role (`--- a/path`, `+++ b/path`, `@@ ... @@` hunk markers).
|
|
28
|
+
- **NOTE** — report test run results, gate evidence, or status updates. Use structured content (e.g. JSON) when logging test outcomes.
|
|
29
|
+
- **REQUEST `more_context`** — request a fresh feature context snapshot when you need updated state, plan, or gate evidence before proceeding.
|
|
30
|
+
|
|
31
|
+
## Unified diff format
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
--- a/path/to/file
|
|
35
|
+
+++ b/path/to/file
|
|
36
|
+
@@ -N,M +N,M @@ optional context
|
|
37
|
+
unchanged line
|
|
38
|
+
-removed line
|
|
39
|
+
+added line
|
|
40
|
+
unchanged line
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Constraints
|
|
44
|
+
|
|
45
|
+
- Only fix test code and implementation details needed to satisfy the accepted plan.
|
|
46
|
+
- Do not modify files in `forbidden_areas`.
|
|
47
|
+
- Keep patches minimal and targeted to the failing assertion or test gap.
|
|
@@ -33,8 +33,16 @@ interface CommandTemplateValues {
|
|
|
33
33
|
const CLAUDE_MIN_EFFECTIVE_TIMEOUT_MS = 300_000;
|
|
34
34
|
const HISTORICAL_DEFAULT_TIMEOUT_MS = 120_000;
|
|
35
35
|
|
|
36
|
-
function buildStructuredWorkerPrompt(
|
|
37
|
-
|
|
36
|
+
function buildStructuredWorkerPrompt(
|
|
37
|
+
payload: Record<string, unknown>,
|
|
38
|
+
systemPrompt: string | null,
|
|
39
|
+
): string {
|
|
40
|
+
const lines: string[] = [];
|
|
41
|
+
if (systemPrompt) {
|
|
42
|
+
lines.push(systemPrompt);
|
|
43
|
+
lines.push('---');
|
|
44
|
+
}
|
|
45
|
+
lines.push(
|
|
38
46
|
'Return exactly one JSON object and nothing else.',
|
|
39
47
|
'Allowed output object forms:',
|
|
40
48
|
'{"type":"PLAN_SUBMISSION|PATCH|NOTE|REQUEST", ...}',
|
|
@@ -42,7 +50,8 @@ function buildStructuredWorkerPrompt(payload: Record<string, unknown>): string {
|
|
|
42
50
|
'Do not wrap JSON in markdown code fences.',
|
|
43
51
|
'Worker input payload:',
|
|
44
52
|
JSON.stringify(payload),
|
|
45
|
-
|
|
53
|
+
);
|
|
54
|
+
return lines.join('\n');
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -196,6 +205,7 @@ export class CliWorkerProvider implements WorkerProvider {
|
|
|
196
205
|
private readonly attachTemplate: ProviderCommandTemplate | undefined;
|
|
197
206
|
private readonly sendTemplate: ProviderCommandTemplate | undefined;
|
|
198
207
|
private readonly sessionsByRoleFeature = new Map<string, string>();
|
|
208
|
+
private readonly systemPromptsBySession = new Map<string, string | null>();
|
|
199
209
|
|
|
200
210
|
constructor(selection: ProviderSelection, options: CliWorkerProviderOptions) {
|
|
201
211
|
this.selection = selection;
|
|
@@ -231,6 +241,7 @@ export class CliWorkerProvider implements WorkerProvider {
|
|
|
231
241
|
): Promise<WorkerSession> {
|
|
232
242
|
const sessionId = `${role}-${featureId}-${crypto.randomUUID()}`;
|
|
233
243
|
this.sessionsByRoleFeature.set(this.sessionKey(role, featureId), sessionId);
|
|
244
|
+
this.systemPromptsBySession.set(sessionId, systemPrompt);
|
|
234
245
|
return Promise.resolve({
|
|
235
246
|
session_id: sessionId,
|
|
236
247
|
role,
|
|
@@ -257,6 +268,7 @@ export class CliWorkerProvider implements WorkerProvider {
|
|
|
257
268
|
this.sessionsByRoleFeature.delete(key);
|
|
258
269
|
}
|
|
259
270
|
}
|
|
271
|
+
this.systemPromptsBySession.delete(sessionId);
|
|
260
272
|
return Promise.resolve({ closed: true });
|
|
261
273
|
}
|
|
262
274
|
|
|
@@ -296,7 +308,8 @@ export class CliWorkerProvider implements WorkerProvider {
|
|
|
296
308
|
runtime_selection: input.runtime_selection ?? null,
|
|
297
309
|
session_id: sessionId,
|
|
298
310
|
};
|
|
299
|
-
const
|
|
311
|
+
const systemPrompt = this.systemPromptsBySession.get(sessionId) ?? null;
|
|
312
|
+
const prompt = buildStructuredWorkerPrompt(promptPayload, systemPrompt);
|
|
300
313
|
|
|
301
314
|
const args = applyTemplateArgs(this.runTemplate.args, {
|
|
302
315
|
sessionId,
|
|
@@ -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
|
+
});
|