@surlymochan/evalid 0.0.0-stage → 0.3.1
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/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +315 -2
- package/RELEASE_NOTES.md +102 -0
- package/atoms/registry.json +758 -0
- package/bin/xflow.js +7208 -0
- package/docs/adoption/README.md +128 -0
- package/docs/adoption/openflow-release-hardening.md +61 -0
- package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
- package/docs/compatibility.md +52 -0
- package/docs/competitive-benchmark.md +108 -0
- package/docs/corps-operator-guide.md +75 -0
- package/docs/demo-proof.md +126 -0
- package/docs/examples-gallery.md +115 -0
- package/docs/fixtures/tracker-item.json +7 -0
- package/docs/goal-vs-codex.md +129 -0
- package/docs/gstack-comparison.md +88 -0
- package/docs/install-upgrade.md +133 -0
- package/docs/integrations.md +49 -0
- package/docs/launch-demo.md +123 -0
- package/docs/launch-dossier.md +180 -0
- package/docs/methodology.md +149 -0
- package/docs/npm-publish-handoff.md +83 -0
- package/docs/openspec-migration.md +106 -0
- package/docs/public-benchmark.md +68 -0
- package/docs/public-release.md +145 -0
- package/docs/quality-assessment.md +139 -0
- package/docs/quickstart.md +156 -0
- package/docs/reviewer-guide.md +43 -0
- package/docs/spec-kit-benchmark.md +73 -0
- package/docs/super-assistant-comparison.md +106 -0
- package/docs/superpowers-comparison.md +82 -0
- package/docs/team-adoption.md +123 -0
- package/docs/tooling-matrix.md +91 -0
- package/docs/walkthrough.md +76 -0
- package/docs/workflow-manual.md +1125 -0
- package/package.json +83 -4
- package/prompts/pencil-draft.md +46 -0
- package/prompts/pencil-refine.md +46 -0
- package/prompts/phase-artifact.md +24 -0
- package/prompts/phase-execution.md +32 -0
- package/schemas/competitor-reconstruction-contract.schema.json +91 -0
- package/schemas/llm-design-review-aggregate.schema.json +83 -0
- package/schemas/visual-benchmark.schema.json +978 -0
- package/schemas/workflow.schema.json +100 -0
- package/scripts/run-tests.mjs +23 -0
- package/scripts/takein_digest.py +139 -0
- package/specs/README.md +118 -0
- package/specs/architecture.md +36 -0
- package/specs/product.md +33 -0
- package/specs/workflow.md +349 -0
- package/src/agent_team/atoms/codex-app-bridge.js +8 -0
- package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
- package/src/agent_team/atoms/design/contract-freeze.js +8 -0
- package/src/agent_team/atoms/design/generation-contract.js +442 -0
- package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
- package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
- package/src/agent_team/atoms/patch-challenge.js +19 -0
- package/src/agent_team/atoms/pencil/accept.js +9 -0
- package/src/agent_team/atoms/pencil/draft.js +365 -0
- package/src/agent_team/atoms/pencil/refine.js +229 -0
- package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
- package/src/agent_team/atoms/team-run.js +380 -0
- package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
- package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
- package/src/agent_team/atoms/visual/benchmark.js +1652 -0
- package/src/agent_team/atoms/visual/design-selection.js +23 -0
- package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
- package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
- package/src/agent_team/atoms/visual/image-diff.js +186 -0
- package/src/agent_team/atoms/visual/layout-competition.js +8 -0
- package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
- package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
- package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
- package/src/agent_team/atoms/visual/review.js +77 -0
- package/src/agent_team/atoms/visual/visual-report.js +197 -0
- package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
- package/src/agent_team/common.js +311 -0
- package/src/core/atoms/agent-invoke.js +684 -0
- package/src/core/atoms/event/ingest.js +30 -0
- package/src/core/atoms/experience/digest.js +55 -0
- package/src/core/atoms/gate/artifact-verify.js +83 -0
- package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
- package/src/core/atoms/gate/evidence-collect.js +64 -0
- package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
- package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
- package/src/core/atoms/gate/workbench.js +79 -0
- package/src/core/atoms/intake/classify.js +15 -0
- package/src/core/atoms/intake/design-skill-select.js +16 -0
- package/src/core/atoms/intake/preview-or-refine.js +19 -0
- package/src/core/atoms/intake/recommend.js +20 -0
- package/src/core/atoms/intake/research-same-category.js +19 -0
- package/src/core/atoms/intervention/create.js +30 -0
- package/src/core/atoms/memory/manage.js +27 -0
- package/src/core/atoms/memory/mount.js +13 -0
- package/src/core/atoms/mission/create.js +19 -0
- package/src/core/atoms/mission/list.js +14 -0
- package/src/core/atoms/mission/show.js +26 -0
- package/src/core/atoms/notify/checkpoint.js +46 -0
- package/src/core/atoms/phase-run/complete.js +42 -0
- package/src/core/atoms/phase-run/fail.js +40 -0
- package/src/core/atoms/phase-run/persist.js +39 -0
- package/src/core/atoms/phase-run/start.js +30 -0
- package/src/core/atoms/plugin/run.js +76 -0
- package/src/core/atoms/policy/gate.js +90 -0
- package/src/core/atoms/policy/resolve.js +11 -0
- package/src/core/atoms/storage/sync.js +57 -0
- package/src/core/atoms/workflow/advance.js +25 -0
- package/src/core/atoms/workflow/guard.js +127 -0
- package/src/core/atoms/workflow/state-load.js +37 -0
- package/src/core/bindings.js +37 -0
- package/src/core/change-artifacts.js +134 -0
- package/src/core/competitive-score.js +265 -0
- package/src/core/concurrency-policy.js +35 -0
- package/src/core/execution-log.js +108 -0
- package/src/core/intake-recommendation.js +134 -0
- package/src/core/memory-state.js +62 -0
- package/src/core/mission-state.js +780 -0
- package/src/core/pending-gates.js +66 -0
- package/src/core/phase-cost.js +116 -0
- package/src/core/phase-run-read.js +27 -0
- package/src/core/policy-overlay.js +180 -0
- package/src/core/state-store.js +201 -0
- package/src/core/workflow-executor.js +998 -0
- package/src/core/workflow-integrity.js +111 -0
- package/src/core/workflow-loader.js +163 -0
- package/src/core/workflow-rerun.js +84 -0
- package/src/core/workflow-state.js +119 -0
- package/src/server.js +1307 -0
- package/workflows/corps.yaml +479 -0
- package/workflows/yolo.yaml +233 -0
- package/xflow/README.md +76 -0
- package/xflow/aha/SKILL.md +82 -0
- package/xflow/aha/scripts/append_aha.py +50 -0
- package/xflow/archive/scripts/archive_change.py +338 -0
- package/xflow/archive/templates/merge-design.md +54 -0
- package/xflow/archive/templates/merge-workflow.md +26 -0
- package/xflow/atoms/__init__.py +6 -0
- package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
- package/xflow/atoms/a2_branch_create_and_link.py +42 -0
- package/xflow/atoms/a3_repo_doctor.py +11 -0
- package/xflow/atoms/a4_project_set_status.py +11 -0
- package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
- package/xflow/atoms/a6_pr_create.py +72 -0
- package/xflow/atoms/b1_change_scaffold.py +11 -0
- package/xflow/atoms/b2_status_read.py +16 -0
- package/xflow/atoms/b2b_status_write.py +20 -0
- package/xflow/atoms/b3_status_transition.py +27 -0
- package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
- package/xflow/atoms/b5_aha_append.py +11 -0
- package/xflow/atoms/b6_takein_digest.py +11 -0
- package/xflow/atoms/b6b_lookback_digest.py +11 -0
- package/xflow/atoms/b7_change_mission_bind.py +43 -0
- package/xflow/atoms/c1_mem.py +54 -0
- package/xflow/atoms/e6_gate_local_precheck.py +23 -0
- package/xflow/atoms/h1_design_lite_gate.py +74 -0
- package/xflow/atoms/i6a_tdd_run.py +104 -0
- package/xflow/atoms/i6b_tdd_proof_validate.py +165 -0
- package/xflow/atoms/i6c_tdd_quality_review.py +357 -0
- package/xflow/atoms/j1_tests_run.py +129 -0
- package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
- package/xflow/atoms/j4b_spec_delta_review.py +316 -0
- package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
- package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
- package/xflow/atoms/j6_cr_review.py +443 -0
- package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
- package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
- package/xflow/atoms/k3_handoff_refresh.py +14 -0
- package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
- package/xflow/atoms/k5_archive_publish.py +21 -0
- package/xflow/atoms/k6_aha_merge.py +42 -0
- package/xflow/atoms/tdd_lib.py +747 -0
- package/xflow/corps/SKILL.md +109 -0
- package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
- package/xflow/goal/SKILL.md +150 -0
- package/xflow/handoff/SKILL.md +95 -0
- package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
- package/xflow/lookback/SKILL.md +66 -0
- package/xflow/lookback/scripts/lookback_digest.py +46 -0
- package/xflow/openissue/scripts/open_issue_flow.py +438 -0
- package/xflow/plan/SKILL.md +38 -0
- package/xflow/ralph/SKILL.md +123 -0
- package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
- package/xflow/scripts/common.py +480 -0
- package/xflow/scripts/doctor.py +278 -0
- package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
- package/xflow/takein/SKILL.md +81 -0
- package/xflow/takein/scripts/takein_digest.py +88 -0
- package/xflow/yolo/SKILL.md +66 -0
- package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
- package/xflow/yolo/scripts/yolo_gate.py +312 -0
- package/xflow/yolo/templates/DESIGN.md +179 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createIntervention,
|
|
9
|
+
createOperatorAction,
|
|
10
|
+
createRerun,
|
|
11
|
+
createMission,
|
|
12
|
+
getMissionControl,
|
|
13
|
+
getMissionDetail,
|
|
14
|
+
getRerun,
|
|
15
|
+
listMissions,
|
|
16
|
+
startPhaseRunSession,
|
|
17
|
+
persistPhaseRun,
|
|
18
|
+
completePhaseRunSession,
|
|
19
|
+
findPhaseRunSession,
|
|
20
|
+
listReruns,
|
|
21
|
+
setMissionControl,
|
|
22
|
+
updateRerun,
|
|
23
|
+
} from './core/mission-state.js';
|
|
24
|
+
import { mutateStore, readStore, resolveDataFile } from './core/state-store.js';
|
|
25
|
+
import { readExecutionLog, resolveExecutionLogPath } from './core/execution-log.js';
|
|
26
|
+
import { ackPendingGate, listPendingGates } from './core/pending-gates.js';
|
|
27
|
+
import { prepareWorkflowRerun } from './core/workflow-rerun.js';
|
|
28
|
+
import { readBinding } from './core/bindings.js';
|
|
29
|
+
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const ROOT = resolve(__dirname, '..');
|
|
32
|
+
const activeWorkflowRuns = new Map();
|
|
33
|
+
|
|
34
|
+
function resolveActiveRunsPath(projectRoot) {
|
|
35
|
+
return resolve(projectRoot, '.as-xflow', 'active-runs.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serializeWorkflowRun(entry = {}) {
|
|
39
|
+
return {
|
|
40
|
+
pid: entry.pid || null,
|
|
41
|
+
workflow_path: entry.workflow_path,
|
|
42
|
+
project_root: entry.project_root,
|
|
43
|
+
change_id: entry.change_id || null,
|
|
44
|
+
mission_id: entry.mission_id || null,
|
|
45
|
+
rerun_id: entry.rerun_id || null,
|
|
46
|
+
max_parallel_agents: entry.max_parallel_agents || null,
|
|
47
|
+
launched_at: entry.launched_at || null,
|
|
48
|
+
finished_at: entry.finished_at || null,
|
|
49
|
+
exit_code: entry.exit_code ?? null,
|
|
50
|
+
error: entry.error || null,
|
|
51
|
+
cancel_requested: Boolean(entry.cancel_requested),
|
|
52
|
+
orphaned: Boolean(entry.orphaned),
|
|
53
|
+
recovered_at: entry.recovered_at || null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function persistActiveWorkflowRuns(projectRoot) {
|
|
58
|
+
const file = resolveActiveRunsPath(projectRoot);
|
|
59
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
60
|
+
const runs = [...activeWorkflowRuns.values()]
|
|
61
|
+
.filter((entry) => entry.project_root === projectRoot)
|
|
62
|
+
.map((entry) => serializeWorkflowRun(entry));
|
|
63
|
+
writeFileSync(file, `${JSON.stringify({ version: 1, runs }, null, 2)}\n`, 'utf8');
|
|
64
|
+
return file;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findActiveWorkflowRunsForRerun(missionId, rerunId) {
|
|
68
|
+
return [...activeWorkflowRuns.entries()]
|
|
69
|
+
.filter(([, entry]) => entry.mission_id === missionId && entry.rerun_id === rerunId)
|
|
70
|
+
.map(([key, entry]) => ({ key, entry }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clearActiveWorkflowRunsForRerun({ projectRoot, missionId, rerunId }) {
|
|
74
|
+
const matches = findActiveWorkflowRunsForRerun(missionId, rerunId);
|
|
75
|
+
const touchedRoots = new Set([projectRoot]);
|
|
76
|
+
for (const { key, entry } of matches) {
|
|
77
|
+
if (entry.project_root) {
|
|
78
|
+
touchedRoots.add(entry.project_root);
|
|
79
|
+
}
|
|
80
|
+
activeWorkflowRuns.delete(key);
|
|
81
|
+
}
|
|
82
|
+
for (const root of touchedRoots) {
|
|
83
|
+
persistActiveWorkflowRuns(root);
|
|
84
|
+
}
|
|
85
|
+
return matches.map(({ entry }) => serializeWorkflowRun(entry));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function loadPersistedActiveWorkflowRuns(projectRoot) {
|
|
89
|
+
const file = resolveActiveRunsPath(projectRoot);
|
|
90
|
+
if (!existsSync(file)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const payload = JSON.parse(readFileSync(file, 'utf8'));
|
|
95
|
+
return Array.isArray(payload.runs) ? payload.runs : [];
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function hydrateActiveWorkflowRuns(projectRoot, dataFile) {
|
|
102
|
+
const recoveredAt = new Date().toISOString();
|
|
103
|
+
const persistedRuns = loadPersistedActiveWorkflowRuns(projectRoot);
|
|
104
|
+
const orphaned = [];
|
|
105
|
+
for (const persistedRun of persistedRuns) {
|
|
106
|
+
const record = {
|
|
107
|
+
...serializeWorkflowRun(persistedRun),
|
|
108
|
+
orphaned: persistedRun.exit_code === null ? true : Boolean(persistedRun.orphaned),
|
|
109
|
+
recovered_at: persistedRun.exit_code === null ? recoveredAt : persistedRun.recovered_at || null,
|
|
110
|
+
abort_controller: undefined,
|
|
111
|
+
};
|
|
112
|
+
const runKey = buildWorkflowRunKey(record);
|
|
113
|
+
activeWorkflowRuns.set(runKey, record);
|
|
114
|
+
if (record.orphaned && record.mission_id && record.rerun_id) {
|
|
115
|
+
orphaned.push(record);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (orphaned.length > 0) {
|
|
119
|
+
await mutateStore((mutableState) => {
|
|
120
|
+
for (const record of orphaned) {
|
|
121
|
+
updateRerun(mutableState, record.mission_id, record.rerun_id, {
|
|
122
|
+
status: 'orphaned',
|
|
123
|
+
error: 'Workflow run was active when the control-plane server restarted; retry or resume is required.',
|
|
124
|
+
workflow_run: serializeWorkflowRun(record),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}, dataFile);
|
|
128
|
+
persistActiveWorkflowRuns(projectRoot);
|
|
129
|
+
}
|
|
130
|
+
return orphaned;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sendJson(response, statusCode, payload) {
|
|
134
|
+
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
135
|
+
response.end(`${JSON.stringify(payload, null, 2)}\n`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sendHtml(response, statusCode, html) {
|
|
139
|
+
response.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
140
|
+
response.end(html);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sendEvent(response, event, payload) {
|
|
144
|
+
response.write(`event: ${event}\n`);
|
|
145
|
+
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readJsonBody(request) {
|
|
149
|
+
const chunks = [];
|
|
150
|
+
for await (const chunk of request) {
|
|
151
|
+
chunks.push(chunk);
|
|
152
|
+
}
|
|
153
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
154
|
+
return raw ? JSON.parse(raw) : {};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function deriveParallelSummary(entries = []) {
|
|
158
|
+
const latestWorkflowStart = entries.find((entry) => entry.kind === 'workflow_started') || null;
|
|
159
|
+
return {
|
|
160
|
+
max_parallel_agents: latestWorkflowStart?.max_parallel_agents || 3,
|
|
161
|
+
policy: latestWorkflowStart?.parallel_policy || 'fixed',
|
|
162
|
+
source: latestWorkflowStart?.parallel_source || 'default',
|
|
163
|
+
source_workflow: latestWorkflowStart?.workflow || null,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function deriveExecutionSummary(entries = []) {
|
|
168
|
+
const latestWorkflowStart = entries.find((entry) => entry.kind === 'workflow_started') || null;
|
|
169
|
+
const latestWorkflowEnd = entries.find((entry) => ['workflow_completed', 'workflow_failed'].includes(entry.kind)) || null;
|
|
170
|
+
return {
|
|
171
|
+
workflow: latestWorkflowStart?.workflow || null,
|
|
172
|
+
change_id: latestWorkflowStart?.change_id || null,
|
|
173
|
+
rerun_phase: latestWorkflowStart?.rerun_phase || null,
|
|
174
|
+
rerun_atom: latestWorkflowStart?.rerun_atom || null,
|
|
175
|
+
status: latestWorkflowEnd?.kind === 'workflow_failed' ? 'failed' : latestWorkflowEnd ? 'completed' : latestWorkflowStart ? 'running' : 'idle',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function deriveMetricsSummary(entries = []) {
|
|
180
|
+
const atomRuns = entries.filter((entry) => entry.kind === 'atom_run');
|
|
181
|
+
const durations = atomRuns
|
|
182
|
+
.map((entry) => Number(entry.duration_ms))
|
|
183
|
+
.filter((duration) => Number.isFinite(duration) && duration >= 0);
|
|
184
|
+
const adapterCounts = atomRuns.reduce((acc, entry) => {
|
|
185
|
+
const adapter = entry.adapter || 'unknown';
|
|
186
|
+
acc[adapter] = (acc[adapter] || 0) + 1;
|
|
187
|
+
return acc;
|
|
188
|
+
}, {});
|
|
189
|
+
const failedAtomRuns = atomRuns.filter((entry) => entry.ok === false || entry.exit_code > 0).length;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
atom_runs: atomRuns.length,
|
|
193
|
+
failed_atom_runs: failedAtomRuns,
|
|
194
|
+
avg_atom_duration_ms: durations.length > 0
|
|
195
|
+
? Math.round(durations.reduce((sum, duration) => sum + duration, 0) / durations.length)
|
|
196
|
+
: null,
|
|
197
|
+
adapter_counts: adapterCounts,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function incrementCounter(counter, key) {
|
|
202
|
+
const normalizedKey = key || 'unknown';
|
|
203
|
+
counter[normalizedKey] = (counter[normalizedKey] || 0) + 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function deriveTimelineAnalytics(timeline = []) {
|
|
207
|
+
const analytics = {
|
|
208
|
+
total_events: timeline.length,
|
|
209
|
+
by_kind: {},
|
|
210
|
+
time_range: {
|
|
211
|
+
first_seen_at: null,
|
|
212
|
+
last_seen_at: null,
|
|
213
|
+
},
|
|
214
|
+
events: {
|
|
215
|
+
total: 0,
|
|
216
|
+
by_type: {},
|
|
217
|
+
},
|
|
218
|
+
interventions: {
|
|
219
|
+
total: 0,
|
|
220
|
+
by_action: {},
|
|
221
|
+
status_counts: {},
|
|
222
|
+
},
|
|
223
|
+
operator_actions: {
|
|
224
|
+
total: 0,
|
|
225
|
+
by_kind: {},
|
|
226
|
+
status_counts: {},
|
|
227
|
+
},
|
|
228
|
+
reruns: {
|
|
229
|
+
total: 0,
|
|
230
|
+
status_counts: {},
|
|
231
|
+
mode_counts: {},
|
|
232
|
+
},
|
|
233
|
+
phase_runs: {
|
|
234
|
+
total: 0,
|
|
235
|
+
status_counts: {},
|
|
236
|
+
effective_status_counts: {},
|
|
237
|
+
phase_counts: {},
|
|
238
|
+
},
|
|
239
|
+
phase_run_sessions: {
|
|
240
|
+
total: 0,
|
|
241
|
+
status_counts: {},
|
|
242
|
+
derived_status_counts: {},
|
|
243
|
+
workflow_mode_counts: {},
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
const timestamps = [];
|
|
247
|
+
|
|
248
|
+
for (const entry of timeline) {
|
|
249
|
+
const kind = entry?.kind || 'unknown';
|
|
250
|
+
const data = entry?.data || {};
|
|
251
|
+
incrementCounter(analytics.by_kind, kind);
|
|
252
|
+
if (entry?.timestamp) {
|
|
253
|
+
timestamps.push(entry.timestamp);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (kind === 'event') {
|
|
257
|
+
analytics.events.total += 1;
|
|
258
|
+
incrementCounter(analytics.events.by_type, data.type);
|
|
259
|
+
} else if (kind === 'intervention') {
|
|
260
|
+
analytics.interventions.total += 1;
|
|
261
|
+
incrementCounter(analytics.interventions.by_action, data.action);
|
|
262
|
+
incrementCounter(analytics.interventions.status_counts, data.status);
|
|
263
|
+
} else if (kind === 'operator_action') {
|
|
264
|
+
analytics.operator_actions.total += 1;
|
|
265
|
+
incrementCounter(analytics.operator_actions.by_kind, data.kind);
|
|
266
|
+
incrementCounter(analytics.operator_actions.status_counts, data.status);
|
|
267
|
+
} else if (kind === 'rerun') {
|
|
268
|
+
analytics.reruns.total += 1;
|
|
269
|
+
incrementCounter(analytics.reruns.status_counts, data.status);
|
|
270
|
+
incrementCounter(analytics.reruns.mode_counts, data.mode);
|
|
271
|
+
} else if (kind === 'phase_run') {
|
|
272
|
+
analytics.phase_runs.total += 1;
|
|
273
|
+
incrementCounter(analytics.phase_runs.status_counts, data.status);
|
|
274
|
+
for (const phase of Array.isArray(data.phases) ? data.phases : []) {
|
|
275
|
+
incrementCounter(analytics.phase_runs.phase_counts, phase.phase);
|
|
276
|
+
incrementCounter(analytics.phase_runs.effective_status_counts, phase.effective_status || phase.status);
|
|
277
|
+
}
|
|
278
|
+
} else if (kind === 'phase_run_session') {
|
|
279
|
+
analytics.phase_run_sessions.total += 1;
|
|
280
|
+
incrementCounter(analytics.phase_run_sessions.status_counts, data.status);
|
|
281
|
+
incrementCounter(analytics.phase_run_sessions.derived_status_counts, data.derived_status);
|
|
282
|
+
incrementCounter(analytics.phase_run_sessions.workflow_mode_counts, data.workflow_mode);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
timestamps.sort();
|
|
287
|
+
analytics.time_range.first_seen_at = timestamps[0] || null;
|
|
288
|
+
analytics.time_range.last_seen_at = timestamps[timestamps.length - 1] || null;
|
|
289
|
+
return analytics;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function listActiveWorkflowRuns(projectRoot = null) {
|
|
293
|
+
const values = [...activeWorkflowRuns.values()]
|
|
294
|
+
.filter((entry) => (projectRoot ? entry.project_root === projectRoot : true))
|
|
295
|
+
.map((entry) => ({
|
|
296
|
+
mission_id: entry.mission_id || null,
|
|
297
|
+
rerun_id: entry.rerun_id || null,
|
|
298
|
+
workflow_path: entry.workflow_path,
|
|
299
|
+
change_id: entry.change_id || null,
|
|
300
|
+
max_parallel_agents: entry.max_parallel_agents || null,
|
|
301
|
+
status: entry.orphaned ? 'orphaned' : entry.exit_code === null ? (entry.cancel_requested ? 'cancel_requested' : 'running') : entry.exit_code === 0 ? 'completed' : entry.error === 'Workflow execution canceled' ? 'canceled' : 'failed',
|
|
302
|
+
launched_at: entry.launched_at,
|
|
303
|
+
finished_at: entry.finished_at || null,
|
|
304
|
+
error: entry.error || null,
|
|
305
|
+
recovered_at: entry.recovered_at || null,
|
|
306
|
+
}))
|
|
307
|
+
.sort((left, right) => String(right.launched_at || '').localeCompare(String(left.launched_at || '')));
|
|
308
|
+
return values;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function buildSummary(state, executionEntries = []) {
|
|
312
|
+
const missions = listMissions(state);
|
|
313
|
+
const statusCounts = missions.reduce((acc, mission) => {
|
|
314
|
+
const key = mission.status || 'unknown';
|
|
315
|
+
acc[key] = (acc[key] || 0) + 1;
|
|
316
|
+
return acc;
|
|
317
|
+
}, {});
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
totals: {
|
|
321
|
+
missions: missions.length,
|
|
322
|
+
tasks: Array.isArray(state.tasks) ? state.tasks.length : 0,
|
|
323
|
+
events: Array.isArray(state.events) ? state.events.length : 0,
|
|
324
|
+
gate_decisions: Array.isArray(state.gate_decisions) ? state.gate_decisions.length : 0,
|
|
325
|
+
phase_runs: Array.isArray(state.phase_runs) ? state.phase_runs.length : 0,
|
|
326
|
+
phase_run_sessions: Array.isArray(state.phase_run_sessions) ? state.phase_run_sessions.length : 0,
|
|
327
|
+
},
|
|
328
|
+
status_counts: statusCounts,
|
|
329
|
+
parallel: deriveParallelSummary(executionEntries),
|
|
330
|
+
execution: deriveExecutionSummary(executionEntries),
|
|
331
|
+
metrics: deriveMetricsSummary(executionEntries),
|
|
332
|
+
active_runs: listActiveWorkflowRuns(),
|
|
333
|
+
recent_missions: missions.slice(0, 8).map((mission) => ({
|
|
334
|
+
mission_id: mission.mission_id,
|
|
335
|
+
goal: mission.goal,
|
|
336
|
+
status: mission.status,
|
|
337
|
+
updated_at: mission.updated_at,
|
|
338
|
+
})),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renderDashboardHtml() {
|
|
343
|
+
return `<!doctype html>
|
|
344
|
+
<html lang="en">
|
|
345
|
+
<head>
|
|
346
|
+
<meta charset="utf-8" />
|
|
347
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
348
|
+
<title>xflow control plane</title>
|
|
349
|
+
<style>
|
|
350
|
+
:root {
|
|
351
|
+
--bg: #f5f1e8;
|
|
352
|
+
--panel: #fffaf0;
|
|
353
|
+
--ink: #1f1a14;
|
|
354
|
+
--muted: #6f665c;
|
|
355
|
+
--accent: #0f766e;
|
|
356
|
+
--line: #d8cdbd;
|
|
357
|
+
}
|
|
358
|
+
body { margin: 0; font-family: "Iowan Old Style", "Palatino Linotype", serif; background: linear-gradient(180deg, #f8f4ec 0%, var(--bg) 100%); color: var(--ink); }
|
|
359
|
+
main { max-width: 1080px; margin: 0 auto; padding: 32px 20px 60px; }
|
|
360
|
+
h1 { font-size: 40px; margin: 0 0 8px; }
|
|
361
|
+
p { color: var(--muted); margin-top: 0; }
|
|
362
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 24px 0; }
|
|
363
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px; box-shadow: 0 8px 24px rgba(31,26,20,0.06); }
|
|
364
|
+
.metric { font-size: 30px; margin: 0; }
|
|
365
|
+
.label { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
|
366
|
+
table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
|
|
367
|
+
th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: 14px; }
|
|
368
|
+
th { color: var(--muted); font-weight: 600; }
|
|
369
|
+
tr:last-child td { border-bottom: none; }
|
|
370
|
+
.status { color: var(--accent); font-weight: 700; }
|
|
371
|
+
.pill { display: inline-block; padding: 4px 8px; border-radius: 999px; background: #e6fffb; color: #115e59; margin: 4px 6px 0 0; font-size: 12px; }
|
|
372
|
+
.stack { display: grid; gap: 16px; margin-top: 16px; }
|
|
373
|
+
.gate { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--line); }
|
|
374
|
+
.gate:last-child { border-bottom: none; }
|
|
375
|
+
.meta { font-size: 12px; color: var(--muted); }
|
|
376
|
+
button { border: none; border-radius: 999px; background: var(--accent); color: white; padding: 10px 14px; font: inherit; cursor: pointer; }
|
|
377
|
+
button:disabled { opacity: .6; cursor: wait; }
|
|
378
|
+
button.secondary { background: #e6ded0; color: var(--ink); }
|
|
379
|
+
ul { list-style: none; margin: 0; padding: 0; }
|
|
380
|
+
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
|
|
381
|
+
li:last-child { border-bottom: none; }
|
|
382
|
+
code { font-size: 12px; }
|
|
383
|
+
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin-top: 12px; }
|
|
384
|
+
label { display: grid; gap: 6px; font-size: 13px; color: var(--muted); }
|
|
385
|
+
input, select { width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: #fffdf8; font: inherit; color: var(--ink); }
|
|
386
|
+
.actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
|
|
387
|
+
.inline-code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; }
|
|
388
|
+
</style>
|
|
389
|
+
</head>
|
|
390
|
+
<body>
|
|
391
|
+
<main>
|
|
392
|
+
<h1>xflow Control Plane</h1>
|
|
393
|
+
<p>Live summary of missions, gates, phase runs, and the latest execution state.</p>
|
|
394
|
+
<section id="metrics" class="grid"></section>
|
|
395
|
+
<section class="card">
|
|
396
|
+
<div class="label">Mission Status Mix</div>
|
|
397
|
+
<div id="statuses"></div>
|
|
398
|
+
</section>
|
|
399
|
+
<section class="card" style="margin-top: 16px;">
|
|
400
|
+
<div class="label">Rerun Control</div>
|
|
401
|
+
<div id="rerun-meta" class="meta" style="margin-top: 8px;"></div>
|
|
402
|
+
<form id="rerun-form">
|
|
403
|
+
<div class="form-grid">
|
|
404
|
+
<label>Mission
|
|
405
|
+
<input id="rerun-mission-id" name="mission_id" placeholder="mis_..." />
|
|
406
|
+
</label>
|
|
407
|
+
<label>Workflow path
|
|
408
|
+
<input id="rerun-workflow-path" name="workflow_path" placeholder="workflows/corps.yaml" />
|
|
409
|
+
</label>
|
|
410
|
+
<label>Change ID
|
|
411
|
+
<input id="rerun-change-id" name="change_id" placeholder="2026-04-15-example" />
|
|
412
|
+
</label>
|
|
413
|
+
<label>Phase
|
|
414
|
+
<select id="rerun-phase-id" name="phase_id">
|
|
415
|
+
<option value="">Select phase</option>
|
|
416
|
+
</select>
|
|
417
|
+
</label>
|
|
418
|
+
<label>Atom (optional)
|
|
419
|
+
<input id="rerun-atom-id" name="atom_id" placeholder="B2b.status.write" />
|
|
420
|
+
</label>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="actions">
|
|
423
|
+
<button type="submit" id="rerun-submit">Start rerun</button>
|
|
424
|
+
<button type="button" class="secondary" id="rerun-reset">Clear</button>
|
|
425
|
+
</div>
|
|
426
|
+
<div id="rerun-status" class="meta" style="margin-top: 10px;"></div>
|
|
427
|
+
</form>
|
|
428
|
+
</section>
|
|
429
|
+
<section style="margin-top: 16px;">
|
|
430
|
+
<div class="label" style="margin-bottom: 10px;">Recent Missions</div>
|
|
431
|
+
<table>
|
|
432
|
+
<thead><tr><th>Mission</th><th>Goal</th><th>Status</th><th>Updated</th><th>Action</th></tr></thead>
|
|
433
|
+
<tbody id="missions"></tbody>
|
|
434
|
+
</table>
|
|
435
|
+
</section>
|
|
436
|
+
<section class="stack">
|
|
437
|
+
<section class="card">
|
|
438
|
+
<div class="label">Pending Gates</div>
|
|
439
|
+
<div id="gates"></div>
|
|
440
|
+
</section>
|
|
441
|
+
<section class="card">
|
|
442
|
+
<div class="label">Recent Activity</div>
|
|
443
|
+
<ul id="activity"></ul>
|
|
444
|
+
</section>
|
|
445
|
+
</section>
|
|
446
|
+
</main>
|
|
447
|
+
<script>
|
|
448
|
+
const rerunState = {
|
|
449
|
+
mission: null,
|
|
450
|
+
summary: null,
|
|
451
|
+
};
|
|
452
|
+
function defaultWorkflowPath(summary) {
|
|
453
|
+
if (!summary?.execution?.workflow) return '';
|
|
454
|
+
const workflow = summary.execution.workflow;
|
|
455
|
+
if (workflow === 'yolo' || workflow === 'corps') {
|
|
456
|
+
return 'workflows/' + workflow + '.yaml';
|
|
457
|
+
}
|
|
458
|
+
return '';
|
|
459
|
+
}
|
|
460
|
+
function setRerunStatus(message, isError = false) {
|
|
461
|
+
const node = document.getElementById('rerun-status');
|
|
462
|
+
node.textContent = message || '';
|
|
463
|
+
node.style.color = isError ? '#9f1239' : '';
|
|
464
|
+
}
|
|
465
|
+
function populateMissionDetail(detail) {
|
|
466
|
+
rerunState.mission = detail;
|
|
467
|
+
const phaseSelect = document.getElementById('rerun-phase-id');
|
|
468
|
+
const phases = (detail.phase_overview || []).map((entry) => entry.phase);
|
|
469
|
+
const currentPhase = detail.execution_overview?.current_phase || '';
|
|
470
|
+
phaseSelect.innerHTML = '<option value="">Select phase</option>' + phases.map((phase) => (
|
|
471
|
+
'<option value="' + phase + '"' + (phase === currentPhase ? ' selected' : '') + '>' + phase + '</option>'
|
|
472
|
+
)).join('');
|
|
473
|
+
document.getElementById('rerun-meta').innerHTML = [
|
|
474
|
+
detail.mission?.goal ? 'mission: ' + detail.mission.goal : '',
|
|
475
|
+
currentPhase ? 'current phase: <span class="inline-code">' + currentPhase + '</span>' : '',
|
|
476
|
+
detail.execution_overview?.latest_phase_run_status ? 'latest run: ' + detail.execution_overview.latest_phase_run_status : '',
|
|
477
|
+
].filter(Boolean).join(' · ');
|
|
478
|
+
}
|
|
479
|
+
async function openRerunForMission(missionId) {
|
|
480
|
+
const response = await fetch('/api/missions/' + missionId);
|
|
481
|
+
const detail = await response.json();
|
|
482
|
+
populateMissionDetail(detail);
|
|
483
|
+
document.getElementById('rerun-mission-id').value = missionId;
|
|
484
|
+
document.getElementById('rerun-workflow-path').value = document.getElementById('rerun-workflow-path').value || defaultWorkflowPath(rerunState.summary);
|
|
485
|
+
document.getElementById('rerun-change-id').value = document.getElementById('rerun-change-id').value || rerunState.summary?.execution?.change_id || '';
|
|
486
|
+
setRerunStatus('Ready to rerun ' + missionId);
|
|
487
|
+
}
|
|
488
|
+
function renderSummary(data) {
|
|
489
|
+
rerunState.summary = data;
|
|
490
|
+
const metrics = [
|
|
491
|
+
['missions', data.totals.missions],
|
|
492
|
+
['tasks', data.totals.tasks],
|
|
493
|
+
['events', data.totals.events],
|
|
494
|
+
['gates', data.totals.gate_decisions],
|
|
495
|
+
['phase runs', data.totals.phase_runs],
|
|
496
|
+
['sessions', data.totals.phase_run_sessions],
|
|
497
|
+
['max parallel', data.parallel.max_parallel_agents],
|
|
498
|
+
['parallel policy', data.parallel.policy],
|
|
499
|
+
['parallel source', data.parallel.source],
|
|
500
|
+
['atom runs', data.metrics.atom_runs],
|
|
501
|
+
['failed atoms', data.metrics.failed_atom_runs],
|
|
502
|
+
['avg atom ms', data.metrics.avg_atom_duration_ms ?? '-'],
|
|
503
|
+
];
|
|
504
|
+
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) => (
|
|
505
|
+
'<div class="card"><div class="label">' + label + '</div><p class="metric">' + value + '</p></div>'
|
|
506
|
+
)).join('');
|
|
507
|
+
document.getElementById('statuses').innerHTML = Object.entries(data.status_counts).map(([key, value]) => (
|
|
508
|
+
'<span class="pill">' + key + ': ' + value + '</span>'
|
|
509
|
+
)).join('') || '<span class="pill">no missions</span>';
|
|
510
|
+
if (!document.getElementById('rerun-workflow-path').value) {
|
|
511
|
+
document.getElementById('rerun-workflow-path').value = defaultWorkflowPath(data);
|
|
512
|
+
}
|
|
513
|
+
if (!document.getElementById('rerun-change-id').value && data.execution?.change_id) {
|
|
514
|
+
document.getElementById('rerun-change-id').value = data.execution.change_id;
|
|
515
|
+
}
|
|
516
|
+
document.getElementById('missions').innerHTML = data.recent_missions.map((mission) => (
|
|
517
|
+
'<tr><td><code>' + mission.mission_id + '</code></td><td>' + mission.goal + '</td><td class="status">' + mission.status + '</td><td>' + (mission.updated_at || '') + '</td><td><button class="secondary" data-mission="' + mission.mission_id + '">Rerun</button></td></tr>'
|
|
518
|
+
)).join('') || '<tr><td colspan="5">No missions yet</td></tr>';
|
|
519
|
+
}
|
|
520
|
+
function renderGates(gates) {
|
|
521
|
+
document.getElementById('gates').innerHTML = gates.map((gate) => (
|
|
522
|
+
'<div class="gate">' +
|
|
523
|
+
'<div><div><strong>' + gate.phase + '</strong></div>' +
|
|
524
|
+
'<div class="meta">' + (gate.instructions || 'Pending human review') + '</div></div>' +
|
|
525
|
+
'<button data-phase="' + gate.phase + '">Approve</button>' +
|
|
526
|
+
'</div>'
|
|
527
|
+
)).join('') || '<div class="meta">No pending gates</div>';
|
|
528
|
+
}
|
|
529
|
+
function renderActivity(entries) {
|
|
530
|
+
document.getElementById('activity').innerHTML = entries.map((entry) => (
|
|
531
|
+
'<li><div><strong>' + (entry.atom_id || entry.kind || 'event') + '</strong></div>' +
|
|
532
|
+
'<div class="meta">' + [
|
|
533
|
+
entry.phase_id,
|
|
534
|
+
entry.status,
|
|
535
|
+
entry.adapter,
|
|
536
|
+
entry.adapter_reason,
|
|
537
|
+
entry.artifact_type,
|
|
538
|
+
entry.parallel_weight ? 'weight ' + entry.parallel_weight : '',
|
|
539
|
+
entry.timestamp,
|
|
540
|
+
].filter(Boolean).join(' · ') + '</div></li>'
|
|
541
|
+
)).join('') || '<li class="meta">No execution activity yet</li>';
|
|
542
|
+
}
|
|
543
|
+
async function refreshFallback() {
|
|
544
|
+
const [summaryRes, gatesRes, logsRes] = await Promise.all([
|
|
545
|
+
fetch('/api/summary'),
|
|
546
|
+
fetch('/api/gates/pending'),
|
|
547
|
+
fetch('/api/logs/execution?limit=12'),
|
|
548
|
+
]);
|
|
549
|
+
renderSummary(await summaryRes.json());
|
|
550
|
+
renderGates((await gatesRes.json()).pending_gates || []);
|
|
551
|
+
renderActivity((await logsRes.json()).entries || []);
|
|
552
|
+
}
|
|
553
|
+
document.addEventListener('click', async (event) => {
|
|
554
|
+
const button = event.target.closest('button[data-phase]');
|
|
555
|
+
if (button) {
|
|
556
|
+
button.disabled = true;
|
|
557
|
+
await fetch('/api/gates/' + button.dataset.phase + '/ack', { method: 'POST' });
|
|
558
|
+
await refreshFallback();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const missionButton = event.target.closest('button[data-mission]');
|
|
562
|
+
if (missionButton) {
|
|
563
|
+
missionButton.disabled = true;
|
|
564
|
+
try {
|
|
565
|
+
await openRerunForMission(missionButton.dataset.mission);
|
|
566
|
+
} finally {
|
|
567
|
+
missionButton.disabled = false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
document.getElementById('rerun-reset').addEventListener('click', () => {
|
|
572
|
+
rerunState.mission = null;
|
|
573
|
+
document.getElementById('rerun-form').reset();
|
|
574
|
+
document.getElementById('rerun-phase-id').innerHTML = '<option value="">Select phase</option>';
|
|
575
|
+
document.getElementById('rerun-meta').textContent = '';
|
|
576
|
+
setRerunStatus('');
|
|
577
|
+
});
|
|
578
|
+
document.getElementById('rerun-form').addEventListener('submit', async (event) => {
|
|
579
|
+
event.preventDefault();
|
|
580
|
+
const missionId = document.getElementById('rerun-mission-id').value.trim();
|
|
581
|
+
const workflowPath = document.getElementById('rerun-workflow-path').value.trim();
|
|
582
|
+
const changeId = document.getElementById('rerun-change-id').value.trim();
|
|
583
|
+
const phaseId = document.getElementById('rerun-phase-id').value.trim();
|
|
584
|
+
const atomId = document.getElementById('rerun-atom-id').value.trim();
|
|
585
|
+
if (!missionId || !workflowPath || !phaseId) {
|
|
586
|
+
setRerunStatus('Mission, workflow path, and phase are required.', true);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const submit = document.getElementById('rerun-submit');
|
|
590
|
+
submit.disabled = true;
|
|
591
|
+
try {
|
|
592
|
+
const response = await fetch('/api/missions/' + missionId + '/rerun', {
|
|
593
|
+
method: 'POST',
|
|
594
|
+
headers: { 'content-type': 'application/json' },
|
|
595
|
+
body: JSON.stringify({
|
|
596
|
+
workflow_path: workflowPath,
|
|
597
|
+
change_id: changeId || undefined,
|
|
598
|
+
phase_id: phaseId,
|
|
599
|
+
atom_id: atomId || undefined,
|
|
600
|
+
}),
|
|
601
|
+
});
|
|
602
|
+
const payload = await response.json();
|
|
603
|
+
if (!response.ok) {
|
|
604
|
+
throw new Error(payload.error || 'Rerun failed');
|
|
605
|
+
}
|
|
606
|
+
setRerunStatus('Rerun started for ' + payload.rerun.phase_id + (payload.rerun.atom_id ? '/' + payload.rerun.atom_id : ''));
|
|
607
|
+
await refreshFallback();
|
|
608
|
+
} catch (error) {
|
|
609
|
+
setRerunStatus(error.message || String(error), true);
|
|
610
|
+
} finally {
|
|
611
|
+
submit.disabled = false;
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
if (window.EventSource) {
|
|
615
|
+
const events = new EventSource('/api/events');
|
|
616
|
+
events.addEventListener('snapshot', (event) => {
|
|
617
|
+
const payload = JSON.parse(event.data);
|
|
618
|
+
renderSummary(payload.summary);
|
|
619
|
+
renderGates(payload.pending_gates || []);
|
|
620
|
+
renderActivity(payload.execution_log?.entries || []);
|
|
621
|
+
});
|
|
622
|
+
events.onerror = () => {
|
|
623
|
+
events.close();
|
|
624
|
+
refreshFallback();
|
|
625
|
+
setInterval(refreshFallback, 3000);
|
|
626
|
+
};
|
|
627
|
+
} else {
|
|
628
|
+
refreshFallback();
|
|
629
|
+
setInterval(refreshFallback, 3000);
|
|
630
|
+
}
|
|
631
|
+
</script>
|
|
632
|
+
</body>
|
|
633
|
+
</html>`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function buildSnapshot(projectRoot, state) {
|
|
637
|
+
const executionLog = await readExecutionLog(projectRoot, { limit: 12 });
|
|
638
|
+
return {
|
|
639
|
+
summary: buildSummary(state, executionLog.entries),
|
|
640
|
+
pending_gates: listPendingGates(projectRoot),
|
|
641
|
+
execution_log: executionLog,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function buildWorkflowRunKey({ projectRoot, workflowPath, changeId }) {
|
|
646
|
+
return `${projectRoot}::${workflowPath}::${changeId || ''}`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function validateRerunRequest({ state, missionId, projectRoot, workflowPath, changeId, phaseId, atomId, workflow, phases }) {
|
|
650
|
+
const detail = getMissionDetail(state, missionId);
|
|
651
|
+
const mission = detail.mission;
|
|
652
|
+
if (mission.workspace_root && resolve(mission.workspace_root) !== resolve(projectRoot)) {
|
|
653
|
+
throw new Error(`Mission ${missionId} is bound to a different workspace root`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const binding = changeId ? readBinding(projectRoot, changeId) : null;
|
|
657
|
+
if (binding?.mission_id && binding.mission_id !== missionId) {
|
|
658
|
+
throw new Error(`change_id ${changeId} is already bound to mission ${binding.mission_id}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const targetPhase = phases.find((phase) => phase.id === phaseId);
|
|
662
|
+
if (!targetPhase) {
|
|
663
|
+
throw new Error(`Unknown rerun phase: ${phaseId}`);
|
|
664
|
+
}
|
|
665
|
+
if (atomId && !(targetPhase.atoms || []).some((atomRef) => atomRef.id === atomId)) {
|
|
666
|
+
throw new Error(`Atom "${atomId}" is not present in phase "${phaseId}"`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const activeMissionRun = listActiveWorkflowRuns(projectRoot).find((entry) => entry.mission_id === missionId && entry.status === 'running');
|
|
670
|
+
if (activeMissionRun) {
|
|
671
|
+
throw new Error(`Mission ${missionId} already has an active workflow run`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
mission_status: mission.status,
|
|
676
|
+
workflow_name: workflow.name,
|
|
677
|
+
phase_id: phaseId,
|
|
678
|
+
atom_id: atomId || null,
|
|
679
|
+
change_id: changeId || null,
|
|
680
|
+
workspace_root: projectRoot,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function resolveMissionBudgetOverride(state, missionId, explicitBudget = null) {
|
|
685
|
+
if (explicitBudget !== undefined && explicitBudget !== null && explicitBudget !== '') {
|
|
686
|
+
const parsed = Number(explicitBudget);
|
|
687
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
688
|
+
throw new Error(`Invalid parallel budget override: ${String(explicitBudget)}`);
|
|
689
|
+
}
|
|
690
|
+
return Math.floor(parsed);
|
|
691
|
+
}
|
|
692
|
+
return getMissionControl(state, missionId)?.parallel_budget_override || null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function launchWorkflowRun({ projectRoot, workflowPath, changeId = null, missionId = null, rerunId = null, maxParallelAgentsOverride = null, dataFile = null }) {
|
|
696
|
+
const runKey = buildWorkflowRunKey({ projectRoot, workflowPath, changeId });
|
|
697
|
+
const existing = activeWorkflowRuns.get(runKey);
|
|
698
|
+
if (existing && existing.exit_code === null) {
|
|
699
|
+
throw new Error(`Workflow run already active for ${workflowPath}`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const launchedAt = new Date().toISOString();
|
|
703
|
+
const abortController = new AbortController();
|
|
704
|
+
const record = {
|
|
705
|
+
pid: process.pid,
|
|
706
|
+
workflow_path: workflowPath,
|
|
707
|
+
project_root: projectRoot,
|
|
708
|
+
change_id: changeId,
|
|
709
|
+
mission_id: missionId,
|
|
710
|
+
rerun_id: rerunId,
|
|
711
|
+
max_parallel_agents: maxParallelAgentsOverride || null,
|
|
712
|
+
launched_at: launchedAt,
|
|
713
|
+
exit_code: null,
|
|
714
|
+
error: null,
|
|
715
|
+
abort_controller: abortController,
|
|
716
|
+
cancel_requested: false,
|
|
717
|
+
};
|
|
718
|
+
activeWorkflowRuns.set(runKey, record);
|
|
719
|
+
persistActiveWorkflowRuns(projectRoot);
|
|
720
|
+
|
|
721
|
+
queueMicrotask(async () => {
|
|
722
|
+
try {
|
|
723
|
+
const { load } = await import('./core/workflow-loader.js');
|
|
724
|
+
const { WorkflowExecutor } = await import('./core/workflow-executor.js');
|
|
725
|
+
const { workflow, phases, registry } = load(workflowPath, { skipRuntimeChecks: true });
|
|
726
|
+
const executor = new WorkflowExecutor({
|
|
727
|
+
workflow,
|
|
728
|
+
phases,
|
|
729
|
+
registry,
|
|
730
|
+
projectRoot,
|
|
731
|
+
changeId,
|
|
732
|
+
exitOnFailure: false,
|
|
733
|
+
maxParallelAgentsOverride,
|
|
734
|
+
abortSignal: abortController.signal,
|
|
735
|
+
});
|
|
736
|
+
await executor.run();
|
|
737
|
+
activeWorkflowRuns.set(runKey, {
|
|
738
|
+
...record,
|
|
739
|
+
exit_code: 0,
|
|
740
|
+
finished_at: new Date().toISOString(),
|
|
741
|
+
abort_controller: undefined,
|
|
742
|
+
});
|
|
743
|
+
persistActiveWorkflowRuns(projectRoot);
|
|
744
|
+
if (dataFile && missionId && rerunId) {
|
|
745
|
+
await mutateStore((mutableState) => updateRerun(mutableState, missionId, rerunId, {
|
|
746
|
+
status: 'completed',
|
|
747
|
+
finished_at: new Date().toISOString(),
|
|
748
|
+
error: null,
|
|
749
|
+
}), dataFile);
|
|
750
|
+
}
|
|
751
|
+
} catch (error) {
|
|
752
|
+
const canceled = error?.name === 'AbortError';
|
|
753
|
+
activeWorkflowRuns.set(runKey, {
|
|
754
|
+
...record,
|
|
755
|
+
exit_code: canceled ? 130 : 1,
|
|
756
|
+
finished_at: new Date().toISOString(),
|
|
757
|
+
error: error instanceof Error ? error.message : String(error),
|
|
758
|
+
abort_controller: undefined,
|
|
759
|
+
});
|
|
760
|
+
persistActiveWorkflowRuns(projectRoot);
|
|
761
|
+
if (dataFile && missionId && rerunId) {
|
|
762
|
+
await mutateStore((mutableState) => updateRerun(mutableState, missionId, rerunId, {
|
|
763
|
+
status: canceled ? 'canceled' : 'failed',
|
|
764
|
+
finished_at: new Date().toISOString(),
|
|
765
|
+
error: error instanceof Error ? error.message : String(error),
|
|
766
|
+
}), dataFile);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
return record;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export default async function startServer(options = {}) {
|
|
775
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
776
|
+
const dataFile = resolveDataFile({ projectRoot, explicitPath: options.dataFile || null });
|
|
777
|
+
const host = options.host || '127.0.0.1';
|
|
778
|
+
const requestedPort = options.port ?? process.env.PORT ?? 8787;
|
|
779
|
+
const port = Number(requestedPort);
|
|
780
|
+
await hydrateActiveWorkflowRuns(projectRoot, dataFile);
|
|
781
|
+
|
|
782
|
+
const server = createServer(async (request, response) => {
|
|
783
|
+
const url = new URL(request.url || '/', `http://${host}:${port}`);
|
|
784
|
+
if (request.method === 'GET' && url.pathname === '/') {
|
|
785
|
+
sendHtml(response, 200, renderDashboardHtml());
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (request.method === 'GET' && url.pathname === '/api/health') {
|
|
789
|
+
sendJson(response, 200, { ok: true });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const state = await readStore(dataFile);
|
|
794
|
+
|
|
795
|
+
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
796
|
+
sendJson(response, 200, state);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (request.method === 'GET' && url.pathname === '/api/summary') {
|
|
801
|
+
const executionLog = await readExecutionLog(projectRoot, { limit: 40 });
|
|
802
|
+
sendJson(response, 200, buildSummary(state, executionLog.entries));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (request.method === 'GET' && url.pathname === '/api/gates/pending') {
|
|
807
|
+
sendJson(response, 200, { pending_gates: listPendingGates(projectRoot) });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (request.method === 'GET' && url.pathname === '/api/events') {
|
|
812
|
+
response.writeHead(200, {
|
|
813
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
814
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
815
|
+
Connection: 'keep-alive',
|
|
816
|
+
});
|
|
817
|
+
response.write('\n');
|
|
818
|
+
|
|
819
|
+
const sendSnapshot = async () => {
|
|
820
|
+
const nextState = await readStore(dataFile);
|
|
821
|
+
sendEvent(response, 'snapshot', await buildSnapshot(projectRoot, nextState));
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const heartbeat = setInterval(() => {
|
|
825
|
+
response.write(': ping\n\n');
|
|
826
|
+
}, 15000);
|
|
827
|
+
const timer = setInterval(() => {
|
|
828
|
+
sendSnapshot().catch(() => {});
|
|
829
|
+
}, 3000);
|
|
830
|
+
|
|
831
|
+
await sendSnapshot();
|
|
832
|
+
request.on('close', () => {
|
|
833
|
+
clearInterval(timer);
|
|
834
|
+
clearInterval(heartbeat);
|
|
835
|
+
});
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (request.method === 'GET' && url.pathname === '/api/logs/execution') {
|
|
840
|
+
const limit = Number(url.searchParams.get('limit') || 20);
|
|
841
|
+
const payload = await readExecutionLog(projectRoot, { limit });
|
|
842
|
+
sendJson(response, 200, { log_file: resolveExecutionLogPath(projectRoot), entries: payload.entries });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (request.method === 'GET' && url.pathname === '/api/missions') {
|
|
847
|
+
sendJson(response, 200, listMissions(state));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (request.method === 'POST' && url.pathname === '/api/missions') {
|
|
852
|
+
try {
|
|
853
|
+
const payload = await readJsonBody(request);
|
|
854
|
+
const result = await mutateStore((mutableState) => createMission(mutableState, payload), dataFile);
|
|
855
|
+
sendJson(response, 201, { ok: true, ...result });
|
|
856
|
+
} catch (error) {
|
|
857
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
858
|
+
}
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (url.pathname.startsWith('/api/missions/')) {
|
|
863
|
+
const [, , , missionId, tail, subtail] = url.pathname.split('/');
|
|
864
|
+
if (request.method === 'GET' && missionId && !tail) {
|
|
865
|
+
try {
|
|
866
|
+
sendJson(response, 200, getMissionDetail(state, missionId));
|
|
867
|
+
} catch (error) {
|
|
868
|
+
sendJson(response, 404, { error: error instanceof Error ? error.message : String(error) });
|
|
869
|
+
}
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (request.method === 'POST' && missionId && tail === 'interventions') {
|
|
874
|
+
try {
|
|
875
|
+
const payload = await readJsonBody(request);
|
|
876
|
+
const result = await mutateStore(
|
|
877
|
+
(mutableState) => createIntervention(mutableState, missionId, payload),
|
|
878
|
+
dataFile,
|
|
879
|
+
);
|
|
880
|
+
sendJson(response, 201, { ok: true, ...result });
|
|
881
|
+
} catch (error) {
|
|
882
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
883
|
+
}
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (request.method === 'POST' && missionId && tail === 'phase-runs') {
|
|
888
|
+
try {
|
|
889
|
+
const payload = await readJsonBody(request);
|
|
890
|
+
const session = await mutateStore(
|
|
891
|
+
(mutableState) => startPhaseRunSession(mutableState, missionId, payload),
|
|
892
|
+
dataFile,
|
|
893
|
+
);
|
|
894
|
+
sendJson(response, 201, { ok: true, mission_id: missionId, session });
|
|
895
|
+
} catch (error) {
|
|
896
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
897
|
+
}
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (request.method === 'POST' && missionId && tail === 'phase-runs-persist') {
|
|
902
|
+
try {
|
|
903
|
+
const payload = await readJsonBody(request);
|
|
904
|
+
const result = await mutateStore((mutableState) => {
|
|
905
|
+
const phaseRun = persistPhaseRun(mutableState, missionId, payload);
|
|
906
|
+
const session = findPhaseRunSession(mutableState, missionId, payload.session_id || null);
|
|
907
|
+
return { phaseRun, session };
|
|
908
|
+
}, dataFile);
|
|
909
|
+
sendJson(response, 200, { ok: true, mission_id: missionId, phase_run: result.phaseRun, session: result.session });
|
|
910
|
+
} catch (error) {
|
|
911
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
912
|
+
}
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (request.method === 'POST' && missionId && tail === 'phase-runs-complete') {
|
|
917
|
+
try {
|
|
918
|
+
const payload = await readJsonBody(request);
|
|
919
|
+
const session = await mutateStore((mutableState) => {
|
|
920
|
+
const currentSession = payload.session_id
|
|
921
|
+
? findPhaseRunSession(mutableState, missionId, payload.session_id)
|
|
922
|
+
: findPhaseRunSession(mutableState, missionId);
|
|
923
|
+
if (!currentSession) {
|
|
924
|
+
throw new Error(`No phase run session found for mission ${missionId}`);
|
|
925
|
+
}
|
|
926
|
+
return completePhaseRunSession(mutableState, missionId, currentSession.session_id, payload);
|
|
927
|
+
}, dataFile);
|
|
928
|
+
sendJson(response, 200, { ok: true, mission_id: missionId, session });
|
|
929
|
+
} catch (error) {
|
|
930
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
931
|
+
}
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (request.method === 'GET' && missionId && tail === 'timeline' && subtail === 'analytics') {
|
|
936
|
+
try {
|
|
937
|
+
const detail = getMissionDetail(state, missionId);
|
|
938
|
+
sendJson(response, 200, { mission_id: missionId, analytics: deriveTimelineAnalytics(detail.timeline) });
|
|
939
|
+
} catch (error) {
|
|
940
|
+
sendJson(response, 404, { error: error instanceof Error ? error.message : String(error) });
|
|
941
|
+
}
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (request.method === 'GET' && missionId && tail === 'timeline' && !subtail) {
|
|
946
|
+
try {
|
|
947
|
+
sendJson(response, 200, { mission_id: missionId, timeline: getMissionDetail(state, missionId).timeline });
|
|
948
|
+
} catch (error) {
|
|
949
|
+
sendJson(response, 404, { error: error instanceof Error ? error.message : String(error) });
|
|
950
|
+
}
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (request.method === 'GET' && missionId && tail === 'reruns') {
|
|
955
|
+
try {
|
|
956
|
+
sendJson(response, 200, { mission_id: missionId, reruns: listReruns(state, missionId) });
|
|
957
|
+
} catch (error) {
|
|
958
|
+
sendJson(response, 404, { error: error instanceof Error ? error.message : String(error) });
|
|
959
|
+
}
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (request.method === 'POST' && missionId && tail === 'budget-override') {
|
|
964
|
+
try {
|
|
965
|
+
const payload = await readJsonBody(request);
|
|
966
|
+
const result = await mutateStore((mutableState) => {
|
|
967
|
+
const control = setMissionControl(mutableState, missionId, {
|
|
968
|
+
parallel_budget_override: payload.max_parallel_agents,
|
|
969
|
+
note: payload.note || '',
|
|
970
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
971
|
+
});
|
|
972
|
+
const action = createOperatorAction(mutableState, missionId, {
|
|
973
|
+
kind: 'budget_override',
|
|
974
|
+
target_type: 'mission',
|
|
975
|
+
target_id: missionId,
|
|
976
|
+
payload: { max_parallel_agents: control.parallel_budget_override },
|
|
977
|
+
note: payload.note || '',
|
|
978
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
979
|
+
});
|
|
980
|
+
return { control, action };
|
|
981
|
+
}, dataFile);
|
|
982
|
+
sendJson(response, 200, { ok: true, mission_id: missionId, control: result.control, operator_action: result.action });
|
|
983
|
+
} catch (error) {
|
|
984
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
985
|
+
}
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (request.method === 'POST' && missionId && tail === 'rerun') {
|
|
990
|
+
try {
|
|
991
|
+
const payload = await readJsonBody(request);
|
|
992
|
+
const workflowPath = String(payload.workflow_path || '').trim();
|
|
993
|
+
const phaseId = String(payload.phase_id || payload.phase || '').trim();
|
|
994
|
+
const atomId = String(payload.atom_id || payload.atom || '').trim() || null;
|
|
995
|
+
const projectRootForRun = payload.project_root || projectRoot;
|
|
996
|
+
const changeId = payload.change_id || null;
|
|
997
|
+
|
|
998
|
+
if (!workflowPath) {
|
|
999
|
+
throw new Error('workflow_path is required');
|
|
1000
|
+
}
|
|
1001
|
+
if (!phaseId) {
|
|
1002
|
+
throw new Error('phase_id is required');
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const resolvedWorkflowPath = resolve(projectRootForRun, workflowPath);
|
|
1006
|
+
const { load } = await import('./core/workflow-loader.js');
|
|
1007
|
+
const { workflow, phases } = load(resolvedWorkflowPath, { skipRuntimeChecks: true });
|
|
1008
|
+
const validation = validateRerunRequest({
|
|
1009
|
+
state,
|
|
1010
|
+
missionId,
|
|
1011
|
+
projectRoot: projectRootForRun,
|
|
1012
|
+
workflowPath: resolvedWorkflowPath,
|
|
1013
|
+
changeId,
|
|
1014
|
+
phaseId,
|
|
1015
|
+
atomId,
|
|
1016
|
+
workflow,
|
|
1017
|
+
phases,
|
|
1018
|
+
});
|
|
1019
|
+
const budgetOverride = resolveMissionBudgetOverride(state, missionId, payload.max_parallel_agents);
|
|
1020
|
+
const rerunState = prepareWorkflowRerun({
|
|
1021
|
+
workflow,
|
|
1022
|
+
phases,
|
|
1023
|
+
projectRoot: projectRootForRun,
|
|
1024
|
+
changeId,
|
|
1025
|
+
phaseId,
|
|
1026
|
+
atomId,
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
const result = await mutateStore((mutableState) => {
|
|
1030
|
+
const operatorAction = createOperatorAction(mutableState, missionId, {
|
|
1031
|
+
kind: 'rerun_request',
|
|
1032
|
+
target_type: 'mission',
|
|
1033
|
+
target_id: missionId,
|
|
1034
|
+
payload: {
|
|
1035
|
+
workflow_path: resolvedWorkflowPath,
|
|
1036
|
+
change_id: changeId,
|
|
1037
|
+
phase_id: phaseId,
|
|
1038
|
+
atom_id: atomId,
|
|
1039
|
+
max_parallel_agents: budgetOverride,
|
|
1040
|
+
},
|
|
1041
|
+
note: payload.note || '',
|
|
1042
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
1043
|
+
});
|
|
1044
|
+
const rerun = createRerun(mutableState, missionId, {
|
|
1045
|
+
workflow_path: resolvedWorkflowPath,
|
|
1046
|
+
project_root: projectRootForRun,
|
|
1047
|
+
change_id: changeId,
|
|
1048
|
+
phase_id: phaseId,
|
|
1049
|
+
atom_id: atomId,
|
|
1050
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
1051
|
+
request_note: payload.note || '',
|
|
1052
|
+
validation,
|
|
1053
|
+
budget_override: budgetOverride,
|
|
1054
|
+
action_id: operatorAction.action_id,
|
|
1055
|
+
});
|
|
1056
|
+
const interventionResult = createIntervention(mutableState, missionId, {
|
|
1057
|
+
action: 'rerun',
|
|
1058
|
+
note: payload.note || `Rerun requested for ${phaseId}${atomId ? `/${atomId}` : ''}`,
|
|
1059
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
1060
|
+
metadata: {
|
|
1061
|
+
workflow_path: resolvedWorkflowPath,
|
|
1062
|
+
change_id: changeId,
|
|
1063
|
+
phase_id: phaseId,
|
|
1064
|
+
atom_id: atomId,
|
|
1065
|
+
state_file: rerunState.state_file,
|
|
1066
|
+
},
|
|
1067
|
+
});
|
|
1068
|
+
const run = launchWorkflowRun({
|
|
1069
|
+
projectRoot: projectRootForRun,
|
|
1070
|
+
workflowPath: resolvedWorkflowPath,
|
|
1071
|
+
changeId,
|
|
1072
|
+
missionId,
|
|
1073
|
+
rerunId: rerun.rerun_id,
|
|
1074
|
+
maxParallelAgentsOverride: budgetOverride,
|
|
1075
|
+
dataFile,
|
|
1076
|
+
});
|
|
1077
|
+
const nextRerun = updateRerun(mutableState, missionId, rerun.rerun_id, {
|
|
1078
|
+
status: 'running',
|
|
1079
|
+
started_at: new Date().toISOString(),
|
|
1080
|
+
run_key: buildWorkflowRunKey({ projectRoot: projectRootForRun, workflowPath: resolvedWorkflowPath, changeId }),
|
|
1081
|
+
workflow_run: run,
|
|
1082
|
+
});
|
|
1083
|
+
return { intervention: interventionResult.intervention, event: interventionResult.event, mission: interventionResult.mission, run, rerun: nextRerun, operatorAction };
|
|
1084
|
+
}, dataFile);
|
|
1085
|
+
|
|
1086
|
+
sendJson(response, 202, {
|
|
1087
|
+
ok: true,
|
|
1088
|
+
mission_id: missionId,
|
|
1089
|
+
rerun: result.rerun,
|
|
1090
|
+
workflow_run: result.run,
|
|
1091
|
+
intervention: result.intervention,
|
|
1092
|
+
operator_action: result.operatorAction,
|
|
1093
|
+
});
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1096
|
+
const statusCode = message.includes('already active') ? 409 : 400;
|
|
1097
|
+
sendJson(response, statusCode, { error: message });
|
|
1098
|
+
}
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (request.method === 'POST' && missionId && tail === 'reruns' && subtail) {
|
|
1103
|
+
try {
|
|
1104
|
+
const payload = await readJsonBody(request);
|
|
1105
|
+
const rerunId = subtail;
|
|
1106
|
+
const action = url.pathname.split('/')[6] || payload.action || '';
|
|
1107
|
+
if (!action) {
|
|
1108
|
+
throw new Error('rerun action is required');
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (action === 'cancel') {
|
|
1112
|
+
const run = [...activeWorkflowRuns.values()].find((entry) => entry.mission_id === missionId && entry.rerun_id === rerunId && entry.exit_code === null);
|
|
1113
|
+
if (!run) {
|
|
1114
|
+
throw new Error(`No active rerun found for ${rerunId}`);
|
|
1115
|
+
}
|
|
1116
|
+
run.cancel_requested = true;
|
|
1117
|
+
persistActiveWorkflowRuns(run.project_root);
|
|
1118
|
+
run.abort_controller?.abort();
|
|
1119
|
+
const result = await mutateStore((mutableState) => {
|
|
1120
|
+
const operatorAction = createOperatorAction(mutableState, missionId, {
|
|
1121
|
+
kind: 'rerun_cancel',
|
|
1122
|
+
target_type: 'rerun',
|
|
1123
|
+
target_id: rerunId,
|
|
1124
|
+
payload: {},
|
|
1125
|
+
note: payload.note || '',
|
|
1126
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
1127
|
+
});
|
|
1128
|
+
const rerun = updateRerun(mutableState, missionId, rerunId, {
|
|
1129
|
+
status: 'cancel_requested',
|
|
1130
|
+
error: null,
|
|
1131
|
+
});
|
|
1132
|
+
return { operatorAction, rerun };
|
|
1133
|
+
}, dataFile);
|
|
1134
|
+
sendJson(response, 202, { ok: true, mission_id: missionId, rerun: result.rerun, operator_action: result.operatorAction });
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (action === 'acknowledge-orphan') {
|
|
1139
|
+
const currentRerun = getRerun(state, missionId, rerunId);
|
|
1140
|
+
if (!currentRerun) {
|
|
1141
|
+
throw new Error(`Rerun not found: ${rerunId}`);
|
|
1142
|
+
}
|
|
1143
|
+
if (currentRerun.status !== 'orphaned') {
|
|
1144
|
+
throw new Error(`Rerun ${rerunId} is not orphaned`);
|
|
1145
|
+
}
|
|
1146
|
+
const acknowledgedAt = new Date().toISOString();
|
|
1147
|
+
const matchingRuns = findActiveWorkflowRunsForRerun(missionId, rerunId);
|
|
1148
|
+
const workflowRun = matchingRuns[0]
|
|
1149
|
+
? serializeWorkflowRun(matchingRuns[0].entry)
|
|
1150
|
+
: currentRerun.workflow_run || {};
|
|
1151
|
+
const result = await mutateStore((mutableState) => {
|
|
1152
|
+
const operatorAction = createOperatorAction(mutableState, missionId, {
|
|
1153
|
+
kind: 'rerun_orphan_acknowledge',
|
|
1154
|
+
target_type: 'rerun',
|
|
1155
|
+
target_id: rerunId,
|
|
1156
|
+
payload: {
|
|
1157
|
+
cleared_active_runs: matchingRuns.length,
|
|
1158
|
+
acknowledged_at: acknowledgedAt,
|
|
1159
|
+
},
|
|
1160
|
+
note: payload.note || '',
|
|
1161
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
1162
|
+
});
|
|
1163
|
+
const rerun = updateRerun(mutableState, missionId, rerunId, {
|
|
1164
|
+
status: 'canceled',
|
|
1165
|
+
finished_at: acknowledgedAt,
|
|
1166
|
+
error: `Orphaned workflow run acknowledged by operator.${payload.note ? ` ${payload.note}` : ''}`,
|
|
1167
|
+
workflow_run: {
|
|
1168
|
+
...workflowRun,
|
|
1169
|
+
orphaned: true,
|
|
1170
|
+
acknowledged_at: acknowledgedAt,
|
|
1171
|
+
cleared_active_runs: matchingRuns.length,
|
|
1172
|
+
},
|
|
1173
|
+
});
|
|
1174
|
+
return { operatorAction, rerun };
|
|
1175
|
+
}, dataFile);
|
|
1176
|
+
const clearedRuns = clearActiveWorkflowRunsForRerun({
|
|
1177
|
+
projectRoot: currentRerun.project_root || projectRoot,
|
|
1178
|
+
missionId,
|
|
1179
|
+
rerunId,
|
|
1180
|
+
});
|
|
1181
|
+
sendJson(response, 202, {
|
|
1182
|
+
ok: true,
|
|
1183
|
+
mission_id: missionId,
|
|
1184
|
+
rerun: result.rerun,
|
|
1185
|
+
operator_action: result.operatorAction,
|
|
1186
|
+
cleared_active_runs: clearedRuns.length,
|
|
1187
|
+
});
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (['retry', 'resume'].includes(action)) {
|
|
1192
|
+
const currentRerun = getRerun(state, missionId, rerunId);
|
|
1193
|
+
if (!currentRerun) {
|
|
1194
|
+
throw new Error(`Rerun not found: ${rerunId}`);
|
|
1195
|
+
}
|
|
1196
|
+
if (!['failed', 'canceled', 'orphaned'].includes(currentRerun.status)) {
|
|
1197
|
+
throw new Error(`Rerun ${rerunId} is not eligible for ${action}`);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const { load } = await import('./core/workflow-loader.js');
|
|
1201
|
+
const { workflow, phases } = load(currentRerun.workflow_path, { skipRuntimeChecks: true });
|
|
1202
|
+
const validation = validateRerunRequest({
|
|
1203
|
+
state,
|
|
1204
|
+
missionId,
|
|
1205
|
+
projectRoot: currentRerun.project_root || projectRoot,
|
|
1206
|
+
workflowPath: currentRerun.workflow_path,
|
|
1207
|
+
changeId: currentRerun.change_id,
|
|
1208
|
+
phaseId: currentRerun.phase_id,
|
|
1209
|
+
atomId: currentRerun.atom_id,
|
|
1210
|
+
workflow,
|
|
1211
|
+
phases,
|
|
1212
|
+
});
|
|
1213
|
+
const budgetOverride = resolveMissionBudgetOverride(state, missionId, currentRerun.budget_override);
|
|
1214
|
+
prepareWorkflowRerun({
|
|
1215
|
+
workflow,
|
|
1216
|
+
phases,
|
|
1217
|
+
projectRoot: currentRerun.project_root || projectRoot,
|
|
1218
|
+
changeId: currentRerun.change_id,
|
|
1219
|
+
phaseId: currentRerun.phase_id,
|
|
1220
|
+
atomId: currentRerun.atom_id,
|
|
1221
|
+
});
|
|
1222
|
+
const result = await mutateStore((mutableState) => {
|
|
1223
|
+
const operatorAction = createOperatorAction(mutableState, missionId, {
|
|
1224
|
+
kind: action === 'retry' ? 'rerun_retry' : 'rerun_resume',
|
|
1225
|
+
target_type: 'rerun',
|
|
1226
|
+
target_id: rerunId,
|
|
1227
|
+
payload: { source_rerun_id: rerunId },
|
|
1228
|
+
note: payload.note || '',
|
|
1229
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
1230
|
+
});
|
|
1231
|
+
const rerun = createRerun(mutableState, missionId, {
|
|
1232
|
+
source_rerun_id: rerunId,
|
|
1233
|
+
workflow_path: currentRerun.workflow_path,
|
|
1234
|
+
project_root: currentRerun.project_root,
|
|
1235
|
+
change_id: currentRerun.change_id,
|
|
1236
|
+
phase_id: currentRerun.phase_id,
|
|
1237
|
+
atom_id: currentRerun.atom_id,
|
|
1238
|
+
requested_by: payload.requested_by || 'human:control-plane',
|
|
1239
|
+
request_note: payload.note || currentRerun.request_note || '',
|
|
1240
|
+
validation,
|
|
1241
|
+
budget_override: budgetOverride,
|
|
1242
|
+
action_id: operatorAction.action_id,
|
|
1243
|
+
});
|
|
1244
|
+
const run = launchWorkflowRun({
|
|
1245
|
+
projectRoot: currentRerun.project_root || projectRoot,
|
|
1246
|
+
workflowPath: currentRerun.workflow_path,
|
|
1247
|
+
changeId: currentRerun.change_id,
|
|
1248
|
+
missionId,
|
|
1249
|
+
rerunId: rerun.rerun_id,
|
|
1250
|
+
maxParallelAgentsOverride: budgetOverride,
|
|
1251
|
+
dataFile,
|
|
1252
|
+
});
|
|
1253
|
+
const nextRerun = updateRerun(mutableState, missionId, rerun.rerun_id, {
|
|
1254
|
+
status: 'running',
|
|
1255
|
+
started_at: new Date().toISOString(),
|
|
1256
|
+
run_key: buildWorkflowRunKey({ projectRoot: currentRerun.project_root || projectRoot, workflowPath: currentRerun.workflow_path, changeId: currentRerun.change_id }),
|
|
1257
|
+
workflow_run: run,
|
|
1258
|
+
});
|
|
1259
|
+
return { operatorAction, rerun: nextRerun, workflow_run: run };
|
|
1260
|
+
}, dataFile);
|
|
1261
|
+
sendJson(response, 202, { ok: true, mission_id: missionId, rerun: result.rerun, operator_action: result.operatorAction, workflow_run: result.workflow_run });
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
throw new Error(`Unknown rerun action: ${action}`);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1268
|
+
}
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (request.method === 'GET' && missionId) {
|
|
1273
|
+
try {
|
|
1274
|
+
sendJson(response, 200, getMissionDetail(state, missionId));
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
sendJson(response, 404, { error: error instanceof Error ? error.message : String(error) });
|
|
1277
|
+
}
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (url.pathname.startsWith('/api/gates/')) {
|
|
1283
|
+
const phase = decodeURIComponent(url.pathname.split('/')[3] || '');
|
|
1284
|
+
const tail = url.pathname.split('/')[4] || '';
|
|
1285
|
+
if (request.method === 'POST' && phase && tail === 'ack') {
|
|
1286
|
+
try {
|
|
1287
|
+
const payload = await readJsonBody(request);
|
|
1288
|
+
const gate = ackPendingGate(projectRoot, phase, {
|
|
1289
|
+
approved_by: payload.approved_by || 'http:control-plane',
|
|
1290
|
+
});
|
|
1291
|
+
sendJson(response, 200, { ok: true, gate });
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
sendJson(response, 404, { error: error instanceof Error ? error.message : String(error) });
|
|
1294
|
+
}
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
sendJson(response, 404, { error: 'Not found' });
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
1303
|
+
const address = server.address();
|
|
1304
|
+
const boundPort = typeof address === 'object' && address ? address.port : port;
|
|
1305
|
+
console.log(`evalid server listening on http://${host}:${boundPort}`);
|
|
1306
|
+
return server;
|
|
1307
|
+
}
|