agentic-orchestrator 0.1.20 → 0.1.21
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 +7 -1
- package/apps/control-plane/test/dashboard-client.spec.ts +51 -0
- package/config/agentic/orchestrator/agents.yaml +1 -1
- package/config/agentic/orchestrator/gates.yaml +2 -2
- package/config/agentic/orchestrator/prompts/builder.system.md +47 -0
- package/config/agentic/orchestrator/prompts/planner.system.md +52 -1
- package/config/agentic/orchestrator/prompts/qa.system.md +46 -0
- package/package.json +1 -1
- package/packages/web-dashboard/src/app/api/events/route.ts +37 -22
- package/packages/web-dashboard/src/app/page.tsx +45 -6
- package/packages/web-dashboard/src/lib/aop-client.ts +94 -4
- package/spec-files/progress.md +34 -1
|
@@ -21,7 +21,13 @@ Every `PLAN_SUBMISSION` must include ALL of the following fields in `plan_json`:
|
|
|
21
21
|
| `acceptance_criteria` | string[] (min 1) | Conditions that must all be met before merge |
|
|
22
22
|
| `gate_profile` | string | Gate profile name (e.g. `"fast"` or `"full"`) |
|
|
23
23
|
|
|
24
|
-
Optional fields
|
|
24
|
+
Optional fields (with types):
|
|
25
|
+
|
|
26
|
+
- `gate_targets`: `string[]` — explicit gate mode names to run
|
|
27
|
+
- `risk`: **`string[]`** — list of risk statements (e.g. `["Schema migration may require downtime"]`). **Must be an array, never a plain string.**
|
|
28
|
+
- `revision_of`: `integer` — plan_version this revises
|
|
29
|
+
- `revision_reason`: `string` — why the plan was revised
|
|
30
|
+
- `verification_overrides`: `object`
|
|
25
31
|
|
|
26
32
|
### Minimal example
|
|
27
33
|
|
|
@@ -118,6 +118,57 @@ describe('dashboard aop client mapping', () => {
|
|
|
118
118
|
expect(payload.features[0].gates).toEqual({ fast: 'pass', full: 'fail', merge: 'na' });
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
it('GIVEN_building_feature_without_activity_state_but_running_role_WHEN_readDashboardStatus_THEN_infers_active_activity', async () => {
|
|
122
|
+
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
123
|
+
tempRoots.push(repoRoot);
|
|
124
|
+
await fs.mkdir(path.join(repoRoot, '.aop', 'features'), { recursive: true });
|
|
125
|
+
await fs.writeFile(
|
|
126
|
+
path.join(repoRoot, '.aop', 'features', 'index.json'),
|
|
127
|
+
JSON.stringify({ active: ['feature_build'], blocked: [], merged: [], blocked_queue: [] }),
|
|
128
|
+
'utf8',
|
|
129
|
+
);
|
|
130
|
+
await writeState(
|
|
131
|
+
repoRoot,
|
|
132
|
+
'feature_build',
|
|
133
|
+
[
|
|
134
|
+
'feature_id: feature_build',
|
|
135
|
+
'version: 1',
|
|
136
|
+
'status: building',
|
|
137
|
+
'role_status:',
|
|
138
|
+
' planner: done',
|
|
139
|
+
' builder: running',
|
|
140
|
+
' qa: ready',
|
|
141
|
+
].join('\n'),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const payload = await readDashboardStatus(repoRoot);
|
|
145
|
+
expect(payload.features[0].activity_state).toBe('active');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('GIVEN_building_feature_without_activity_state_and_stale_timestamp_WHEN_readDashboardStatus_THEN_infers_idle_activity', async () => {
|
|
149
|
+
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
150
|
+
tempRoots.push(repoRoot);
|
|
151
|
+
await fs.mkdir(path.join(repoRoot, '.aop', 'features'), { recursive: true });
|
|
152
|
+
await fs.writeFile(
|
|
153
|
+
path.join(repoRoot, '.aop', 'features', 'index.json'),
|
|
154
|
+
JSON.stringify({ active: ['feature_idle'], blocked: [], merged: [], blocked_queue: [] }),
|
|
155
|
+
'utf8',
|
|
156
|
+
);
|
|
157
|
+
await writeState(
|
|
158
|
+
repoRoot,
|
|
159
|
+
'feature_idle',
|
|
160
|
+
[
|
|
161
|
+
'feature_id: feature_idle',
|
|
162
|
+
'version: 1',
|
|
163
|
+
'status: building',
|
|
164
|
+
'last_updated: 2026-01-01T00:00:00Z',
|
|
165
|
+
].join('\n'),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const payload = await readDashboardStatus(repoRoot);
|
|
169
|
+
expect(payload.features[0].activity_state).toBe('idle');
|
|
170
|
+
});
|
|
171
|
+
|
|
121
172
|
it('GIVEN_blocked_queue_feature_without_state_WHEN_readDashboardStatus_THEN_phase_is_blocked', async () => {
|
|
122
173
|
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
123
174
|
tempRoots.push(repoRoot);
|
|
@@ -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.
|
|
@@ -4,7 +4,58 @@ Produce deterministic plan submissions and plan updates that conform to plan sch
|
|
|
4
4
|
Avoid speculative edits outside the declared file scope.
|
|
5
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`).
|
|
6
6
|
|
|
7
|
-
|
|
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 (with types):
|
|
25
|
+
|
|
26
|
+
- `gate_targets`: `string[]` — explicit gate mode names to run
|
|
27
|
+
- `risk`: **`string[]`** — list of risk statements (e.g. `["Schema migration may require downtime"]`). **Must be an array, never a plain string.**
|
|
28
|
+
- `revision_of`: `integer` — plan_version this revises
|
|
29
|
+
- `revision_reason`: `string` — why the plan was revised
|
|
30
|
+
- `verification_overrides`: `object`
|
|
31
|
+
|
|
32
|
+
### Minimal example
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"type": "PLAN_SUBMISSION",
|
|
37
|
+
"plan_json": {
|
|
38
|
+
"feature_id": "my_feature",
|
|
39
|
+
"plan_version": 1,
|
|
40
|
+
"summary": "Implement X to achieve Y",
|
|
41
|
+
"allowed_areas": ["apps/control-plane/src/"],
|
|
42
|
+
"forbidden_areas": [],
|
|
43
|
+
"base_ref": "main",
|
|
44
|
+
"files": {
|
|
45
|
+
"create": ["apps/control-plane/src/new-module.ts"],
|
|
46
|
+
"modify": ["apps/control-plane/src/existing.ts"],
|
|
47
|
+
"delete": []
|
|
48
|
+
},
|
|
49
|
+
"contracts": { "openapi": "none", "events": "none", "db": "none" },
|
|
50
|
+
"acceptance_criteria": ["All tests pass at ≥90% coverage", "npm run lint passes"],
|
|
51
|
+
"gate_profile": "fast"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## After each QA wave
|
|
57
|
+
|
|
58
|
+
Perform an explicit reconciliation pass:
|
|
8
59
|
|
|
9
60
|
- re-read feature context (`feature.get_context`) including spec, accepted plan, QA index summary, and latest gate evidence
|
|
10
61
|
- 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.
|
package/package.json
CHANGED
|
@@ -12,40 +12,55 @@ export async function GET(req: Request): Promise<Response> {
|
|
|
12
12
|
|
|
13
13
|
const stream = new ReadableStream({
|
|
14
14
|
start(controller) {
|
|
15
|
+
let closed = false;
|
|
16
|
+
|
|
17
|
+
const stop = () => {
|
|
18
|
+
clearInterval(heartbeatInterval);
|
|
19
|
+
clearInterval(pollInterval);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const pushData = (payload: unknown): boolean => {
|
|
23
|
+
if (closed) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
closed = true;
|
|
31
|
+
stop();
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const pushSnapshot = async () => {
|
|
37
|
+
try {
|
|
38
|
+
const payload = await readDashboardStatus(repoRoot);
|
|
39
|
+
pushData({ type: 'snapshot', features: payload.index, payload });
|
|
40
|
+
} catch {
|
|
41
|
+
pushData({
|
|
42
|
+
type: 'error',
|
|
43
|
+
message: 'Unable to read dashboard status',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
15
48
|
// Send initial snapshot
|
|
16
|
-
void (
|
|
17
|
-
const payload = await readDashboardStatus(repoRoot);
|
|
18
|
-
controller.enqueue(
|
|
19
|
-
encoder.encode(
|
|
20
|
-
`data: ${JSON.stringify({ type: 'snapshot', features: payload.index, payload })}\n\n`,
|
|
21
|
-
),
|
|
22
|
-
);
|
|
23
|
-
})();
|
|
49
|
+
void pushSnapshot();
|
|
24
50
|
|
|
25
51
|
// Heartbeat every 15s
|
|
26
52
|
heartbeatInterval = setInterval(() => {
|
|
27
53
|
try {
|
|
28
54
|
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
|
29
55
|
} catch {
|
|
30
|
-
|
|
31
|
-
|
|
56
|
+
closed = true;
|
|
57
|
+
stop();
|
|
32
58
|
}
|
|
33
59
|
}, 15000);
|
|
34
60
|
|
|
35
61
|
// Poll every 2s
|
|
36
62
|
pollInterval = setInterval(() => {
|
|
37
|
-
void (
|
|
38
|
-
try {
|
|
39
|
-
const payload = await readDashboardStatus(repoRoot);
|
|
40
|
-
controller.enqueue(
|
|
41
|
-
encoder.encode(
|
|
42
|
-
`data: ${JSON.stringify({ type: 'snapshot', features: payload.index, payload })}\n\n`,
|
|
43
|
-
),
|
|
44
|
-
);
|
|
45
|
-
} catch {
|
|
46
|
-
/* ignore */
|
|
47
|
-
}
|
|
48
|
-
})();
|
|
63
|
+
void pushSnapshot();
|
|
49
64
|
}, 2000);
|
|
50
65
|
},
|
|
51
66
|
cancel() {
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
import styles from '@/styles/dashboard.module.css';
|
|
26
26
|
|
|
27
27
|
type ProjectEntry = { name: string; path: string };
|
|
28
|
+
type ProjectDefaults = { default_project?: string | null } | null;
|
|
28
29
|
|
|
29
30
|
async function jsonFetch<T>(input: string, init?: RequestInit): Promise<T> {
|
|
30
31
|
const response = await fetch(input, init);
|
|
@@ -45,6 +46,7 @@ function sortFeatures(features: FeatureSummary[]): FeatureSummary[] {
|
|
|
45
46
|
export default function Dashboard() {
|
|
46
47
|
const [payload, setPayload] = useState<DashboardStatusPayload>(emptyStatusPayload());
|
|
47
48
|
const [connected, setConnected] = useState(false);
|
|
49
|
+
const [lastSnapshotAt, setLastSnapshotAt] = useState<number | null>(null);
|
|
48
50
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
|
|
49
51
|
const [focusedFeatureId, setFocusedFeatureId] = useState<string | null>(null);
|
|
50
52
|
const [detail, setDetail] = useState<FeatureDetail | null>(null);
|
|
@@ -77,11 +79,26 @@ export default function Dashboard() {
|
|
|
77
79
|
|
|
78
80
|
useEffect(() => {
|
|
79
81
|
void (async () => {
|
|
80
|
-
const response = await jsonFetch<{
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
const response = await jsonFetch<{
|
|
83
|
+
ok: boolean;
|
|
84
|
+
data?: { projects: ProjectEntry[]; defaults?: ProjectDefaults };
|
|
85
|
+
}>('/api/projects');
|
|
83
86
|
if (response.ok && response.data) {
|
|
84
|
-
|
|
87
|
+
const nextProjects = response.data.projects ?? [];
|
|
88
|
+
setProjects(nextProjects);
|
|
89
|
+
|
|
90
|
+
const queryProject = new URL(window.location.href).searchParams.get('project');
|
|
91
|
+
const defaultProject = response.data.defaults?.default_project;
|
|
92
|
+
const hasKnownDefault =
|
|
93
|
+
typeof defaultProject === 'string' &&
|
|
94
|
+
defaultProject.length > 0 &&
|
|
95
|
+
nextProjects.some((entry) => entry.name === defaultProject);
|
|
96
|
+
if (!queryProject && hasKnownDefault) {
|
|
97
|
+
setProject((current) => (current.length > 0 ? current : defaultProject));
|
|
98
|
+
const url = new URL(window.location.href);
|
|
99
|
+
url.searchParams.set('project', defaultProject);
|
|
100
|
+
window.history.replaceState(null, '', url.toString());
|
|
101
|
+
}
|
|
85
102
|
}
|
|
86
103
|
})();
|
|
87
104
|
}, []);
|
|
@@ -101,23 +118,45 @@ export default function Dashboard() {
|
|
|
101
118
|
}, [project]);
|
|
102
119
|
|
|
103
120
|
useEffect(() => {
|
|
121
|
+
setConnected(false);
|
|
122
|
+
setLastSnapshotAt(null);
|
|
104
123
|
void refreshStatus();
|
|
105
124
|
const events = new EventSource(`/api/events${projectQuery(project)}`);
|
|
106
|
-
events.onopen = () =>
|
|
125
|
+
events.onopen = () => {
|
|
126
|
+
setConnected(true);
|
|
127
|
+
setLastSnapshotAt(Date.now());
|
|
128
|
+
};
|
|
107
129
|
events.onmessage = (event: MessageEvent<string>) => {
|
|
108
130
|
try {
|
|
109
131
|
const parsed = JSON.parse(event.data) as SSEEvent;
|
|
110
132
|
if (parsed.type === 'snapshot' && parsed.payload) {
|
|
111
133
|
setPayload(parsed.payload);
|
|
134
|
+
setConnected(true);
|
|
135
|
+
setLastSnapshotAt(Date.now());
|
|
112
136
|
}
|
|
113
137
|
} catch {
|
|
114
138
|
// ignore malformed SSE payload
|
|
115
139
|
}
|
|
116
140
|
};
|
|
117
|
-
events.onerror = () =>
|
|
141
|
+
events.onerror = () => {
|
|
142
|
+
// EventSource retries automatically. Keep the previous state and rely on
|
|
143
|
+
// snapshot freshness to avoid false "disconnected" signals on transient drops.
|
|
144
|
+
};
|
|
118
145
|
return () => events.close();
|
|
119
146
|
}, [project, refreshStatus]);
|
|
120
147
|
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const interval = setInterval(() => {
|
|
150
|
+
if (lastSnapshotAt == null) {
|
|
151
|
+
setConnected(false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
setConnected(Date.now() - lastSnapshotAt < 20_000);
|
|
155
|
+
}, 1000);
|
|
156
|
+
|
|
157
|
+
return () => clearInterval(interval);
|
|
158
|
+
}, [lastSnapshotAt]);
|
|
159
|
+
|
|
121
160
|
useEffect(() => {
|
|
122
161
|
if (selectedFeatureId) {
|
|
123
162
|
void refreshDetail(selectedFeatureId);
|
|
@@ -30,6 +30,99 @@ const STATUS_TO_PHASE: Record<string, FeatureSummary['phase']> = {
|
|
|
30
30
|
paused_budget: 'blocked',
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
const ACTIVE_PHASES = new Set<FeatureSummary['phase']>([
|
|
34
|
+
'planning',
|
|
35
|
+
'building',
|
|
36
|
+
'qa',
|
|
37
|
+
'ready_to_merge',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function isActivityState(value: unknown): value is NonNullable<FeatureSummary['activity_state']> {
|
|
41
|
+
return (
|
|
42
|
+
value === 'active' ||
|
|
43
|
+
value === 'idle' ||
|
|
44
|
+
value === 'waiting_input' ||
|
|
45
|
+
value === 'blocked' ||
|
|
46
|
+
value === 'exited' ||
|
|
47
|
+
value === 'unknown'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseRoleStatus(frontMatter: Record<string, unknown>): Record<string, string> | null {
|
|
52
|
+
const raw = frontMatter.role_status;
|
|
53
|
+
if (!raw || typeof raw !== 'object') {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const entries = Object.entries(raw as Record<string, unknown>).filter(
|
|
57
|
+
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
58
|
+
);
|
|
59
|
+
if (entries.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return Object.fromEntries(entries);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function firstValidTimestamp(frontMatter: Record<string, unknown>): number | null {
|
|
66
|
+
const candidates = [
|
|
67
|
+
frontMatter.activity_last_event_at,
|
|
68
|
+
frontMatter.last_heartbeat_at,
|
|
69
|
+
frontMatter.last_updated,
|
|
70
|
+
];
|
|
71
|
+
for (const candidate of candidates) {
|
|
72
|
+
if (typeof candidate !== 'string') {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const parsed = Date.parse(candidate);
|
|
76
|
+
if (!Number.isNaN(parsed)) {
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function inferActivityState(
|
|
84
|
+
frontMatter: Record<string, unknown>,
|
|
85
|
+
phase: FeatureSummary['phase'],
|
|
86
|
+
): NonNullable<FeatureSummary['activity_state']> | undefined {
|
|
87
|
+
if (isActivityState(frontMatter.activity_state)) {
|
|
88
|
+
return frontMatter.activity_state;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const status = typeof frontMatter.status === 'string' ? frontMatter.status : '';
|
|
92
|
+
if (phase === 'blocked') {
|
|
93
|
+
return 'blocked';
|
|
94
|
+
}
|
|
95
|
+
if (phase === 'merged' || status === 'failed') {
|
|
96
|
+
return 'exited';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const roleStatus = parseRoleStatus(frontMatter);
|
|
100
|
+
if (roleStatus) {
|
|
101
|
+
const values = Object.values(roleStatus).map((value) => value.toLowerCase());
|
|
102
|
+
if (values.includes('running')) {
|
|
103
|
+
return 'active';
|
|
104
|
+
}
|
|
105
|
+
if (values.includes('blocked')) {
|
|
106
|
+
return 'blocked';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (ACTIVE_PHASES.has(phase)) {
|
|
111
|
+
const timestamp = firstValidTimestamp(frontMatter);
|
|
112
|
+
if (timestamp == null) {
|
|
113
|
+
return 'idle';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ageMs = Date.now() - timestamp;
|
|
117
|
+
if (ageMs >= 0 && ageMs <= 120_000) {
|
|
118
|
+
return 'active';
|
|
119
|
+
}
|
|
120
|
+
return 'idle';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return 'unknown';
|
|
124
|
+
}
|
|
125
|
+
|
|
33
126
|
function parseFrontMatter(raw: string): ParsedFrontMatter {
|
|
34
127
|
const normalized = raw.replace(/\r\n/g, '\n');
|
|
35
128
|
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
@@ -99,10 +192,7 @@ function normalizeFeatureSummary(
|
|
|
99
192
|
typeof frontMatter.last_updated === 'string' ? frontMatter.last_updated : undefined,
|
|
100
193
|
status_reason:
|
|
101
194
|
typeof frontMatter.status_reason === 'string' ? frontMatter.status_reason : undefined,
|
|
102
|
-
activity_state:
|
|
103
|
-
typeof frontMatter.activity_state === 'string'
|
|
104
|
-
? (frontMatter.activity_state as FeatureSummary['activity_state'])
|
|
105
|
-
: undefined,
|
|
195
|
+
activity_state: inferActivityState(frontMatter, phase),
|
|
106
196
|
activity_last_event_at:
|
|
107
197
|
typeof frontMatter.activity_last_event_at === 'string'
|
|
108
198
|
? frontMatter.activity_last_event_at
|
package/spec-files/progress.md
CHANGED
|
@@ -935,9 +935,42 @@
|
|
|
935
935
|
|
|
936
936
|
---
|
|
937
937
|
|
|
938
|
+
✅ **Entry 193 — Fix dashboard stream/connectivity and building activity-state regression**
|
|
939
|
+
|
|
940
|
+
- **Goal:** Resolve post-init-policy UX regressions where the dashboard falsely showed `Disconnected from event stream` and building-phase workflows appeared non-running due missing inferred activity state.
|
|
941
|
+
- **Changes made:**
|
|
942
|
+
- `packages/web-dashboard/src/app/page.tsx`
|
|
943
|
+
- hardened SSE connection UX by tracking last snapshot freshness instead of immediately marking disconnected on any transient `EventSource` error.
|
|
944
|
+
- added stale-connection watchdog (`20s` freshness window) to reduce false disconnection indicators.
|
|
945
|
+
- updated projects bootstrap parsing to include `defaults.default_project` and auto-select it (when no `?project=` is set), including URL synchronization.
|
|
946
|
+
- `packages/web-dashboard/src/app/api/events/route.ts`
|
|
947
|
+
- replaced fragile initial snapshot/poll emit logic with guarded `pushSnapshot()` + `pushData()` helpers.
|
|
948
|
+
- added safe SSE error event emission (`type: 'error'`) when dashboard status reads fail, while keeping stream alive.
|
|
949
|
+
- added robust interval cleanup behavior on closed stream/enqueue failures.
|
|
950
|
+
- `packages/web-dashboard/src/lib/aop-client.ts`
|
|
951
|
+
- introduced `inferActivityState(...)` fallback logic when `activity_state` is missing in frontmatter.
|
|
952
|
+
- activity inference now uses phase/status, role-level running/blocked signals, and timestamp recency (`activity_last_event_at` / `last_heartbeat_at` / `last_updated`) to classify `active` vs `idle`.
|
|
953
|
+
- `apps/control-plane/test/dashboard-client.spec.ts`
|
|
954
|
+
- added regression tests verifying inferred `activity_state` for building features:
|
|
955
|
+
- role status `builder: running` -> `active`
|
|
956
|
+
- stale timestamp without explicit activity -> `idle`
|
|
957
|
+
- **Result:** `npm test` [PASS] (`74` test files / `1021` tests passing). `npm run typecheck` [PASS]. `npm run lint` [PASS]. `npm run --workspace @aop/web-dashboard typecheck` [PASS].
|
|
958
|
+
|
|
959
|
+
---
|
|
960
|
+
|
|
961
|
+
✅ **Entry 194 — Fix `risk` optional field type in planner system prompt**
|
|
962
|
+
|
|
963
|
+
- **Goal:** Fix `plan_validation_failed` runtime error where LLM submitted `risk` as a plain string instead of the required `string[]` array.
|
|
964
|
+
- **Changes made:**
|
|
965
|
+
- `agentic/orchestrator/prompts/planner.system.md` — expanded optional fields section to explicitly document `risk` as `string[]` with a concrete array example and a bold warning that it must never be a plain string.
|
|
966
|
+
- `config/agentic/orchestrator/prompts/planner.system.md` — synced identical change to the generated config copy so existing repos without a re-init pick up the fix immediately.
|
|
967
|
+
- **Result:** No code changes; prompt-only fix. Existing tests unaffected.
|
|
968
|
+
|
|
969
|
+
---
|
|
970
|
+
|
|
938
971
|
## Next Tasks:
|
|
939
972
|
|
|
940
|
-
1. None —
|
|
973
|
+
1. None — dashboard UX regression fixes completed in Entry 193.
|
|
941
974
|
|
|
942
975
|
## Spec Gap Tracker (§3.5):
|
|
943
976
|
|