@deksden-com/dd-flow-cli 0.9.0-beta.74 → 0.9.0-beta.79
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 +8 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +7 -11
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +85 -42
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +207 -94
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
package/dist/services/cleanup.js
CHANGED
|
@@ -2,8 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import {
|
|
6
|
-
import { ensureReadableFile } from "../storage/database.js";
|
|
5
|
+
import { prepareJsonFile } from "../cli/input-preparation.js";
|
|
7
6
|
import { projectCheckoutRoot, resolveProjectRoot, runtimeStateJsonPath } from "../storage/paths.js";
|
|
8
7
|
import { appendAudit } from "./audit.js";
|
|
9
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
@@ -173,20 +172,29 @@ export function cleanupScan(context, input) {
|
|
|
173
172
|
plan: { project_root: project.root, project_id: project.id, actions }
|
|
174
173
|
};
|
|
175
174
|
}
|
|
176
|
-
export function
|
|
177
|
-
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
175
|
+
export function prepareCleanupApply(context, input) {
|
|
178
176
|
const reason = input.reason.trim();
|
|
179
177
|
if (!reason) {
|
|
180
178
|
throw new AppError("validation", "cleanup apply requires --reason", 2);
|
|
181
179
|
}
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
const value = prepareJsonFile(input.planFile, "cleanup plan").value;
|
|
181
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
182
|
+
throw new AppError("validation", "cleanup plan must be an object", 2);
|
|
183
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
184
|
+
const plan = validateCleanupPlan(value, project);
|
|
184
185
|
validateNoDestructiveSurprises(context, plan, input.force);
|
|
186
|
+
return { project, reason, plan };
|
|
187
|
+
}
|
|
188
|
+
export function cleanupApply(context, input) {
|
|
189
|
+
const { project, reason, plan } = input.prepared ?? prepareCleanupApply(context, input);
|
|
185
190
|
const now = context.now();
|
|
186
191
|
const results = [];
|
|
187
192
|
context.db.beginWriteTransaction();
|
|
193
|
+
let executing = false;
|
|
188
194
|
try {
|
|
195
|
+
validateNoDestructiveSurprises(context, plan, input.force);
|
|
189
196
|
for (const action of plan.actions) {
|
|
197
|
+
executing = true;
|
|
190
198
|
results.push(applyAction(context, action, reason, input.force, now));
|
|
191
199
|
}
|
|
192
200
|
const changed = results.filter((result) => result.changed).length;
|
|
@@ -203,6 +211,8 @@ export function cleanupApply(context, input) {
|
|
|
203
211
|
}
|
|
204
212
|
catch (error) {
|
|
205
213
|
context.db.exec("ROLLBACK");
|
|
214
|
+
if (executing)
|
|
215
|
+
throw new AppError("cleanup_apply_failed", "Cleanup failed after execution began; inspect retained artifacts before retrying", 1, { effect: "unknown", database_rolled_back: true, attempted_results: results, cause: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { cause_code: error.code } : {}) });
|
|
206
216
|
throw error;
|
|
207
217
|
}
|
|
208
218
|
return {
|
|
@@ -251,11 +261,29 @@ function validateCleanupPlan(value, project) {
|
|
|
251
261
|
throw new AppError("validation", "close_flow_session requires a native session identity", 2);
|
|
252
262
|
}
|
|
253
263
|
}
|
|
264
|
+
const requiredField = action.kind === "discard_stale_run" ? "run_id"
|
|
265
|
+
: ["repair_protocol_state", "cancel_queue_job", "close_worktree_record"].includes(String(action.kind)) ? "protocol_id" : undefined;
|
|
266
|
+
if (requiredField && (typeof action[requiredField] !== "string" || !action[requiredField].trim()))
|
|
267
|
+
throw new AppError("validation", `cleanup ${String(action.kind)} requires ${requiredField}`, 2);
|
|
268
|
+
const numericField = action.kind === "expire_lane_lock" ? "lock_id" : action.kind === "expire_lane_waiter" ? "waiter_id" : undefined;
|
|
269
|
+
if (numericField && (!Number.isSafeInteger(action[numericField]) || action[numericField] < 1))
|
|
270
|
+
throw new AppError("validation", `cleanup ${String(action.kind)} requires a positive integer ${numericField}`, 2);
|
|
271
|
+
if (action.worktree_path !== undefined && (typeof action.worktree_path !== "string" || !path.isAbsolute(action.worktree_path)))
|
|
272
|
+
throw new AppError("validation", "cleanup worktree_path must be an absolute path", 2);
|
|
254
273
|
return action;
|
|
255
274
|
});
|
|
256
275
|
return { project_root: project.root, project_id: project.id, actions };
|
|
257
276
|
}
|
|
258
277
|
function validateNoDestructiveSurprises(context, plan, force) {
|
|
278
|
+
for (const action of plan.actions) {
|
|
279
|
+
if (action.kind === "repair_protocol_state")
|
|
280
|
+
requireProtocol(context, action.protocol_id, action.project_id);
|
|
281
|
+
if (action.kind === "discard_stale_run") {
|
|
282
|
+
const run = context.db.get("SELECT index_json, status FROM runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
|
|
283
|
+
if (run && ["running", "discarded"].includes(run.status))
|
|
284
|
+
cleanupRunIndex(run.index_json);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
259
287
|
const destructive = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
|
|
260
288
|
if (destructive && !force) {
|
|
261
289
|
throw new AppError("worktree_dirty", "cleanup apply refuses dirty worktree actions without --force --reason", 1, {
|
|
@@ -410,7 +438,7 @@ function applyAction(context, action, reason, force, now) {
|
|
|
410
438
|
return { kind: action.kind, changed: false, skipped: true, reason: "invalid_action" };
|
|
411
439
|
}
|
|
412
440
|
function discardedRunIndex(indexJson, reason, now) {
|
|
413
|
-
const index =
|
|
441
|
+
const index = cleanupRunIndex(indexJson);
|
|
414
442
|
index.schema_id = "dd-flow/flow-run@3";
|
|
415
443
|
index.status = "discarded";
|
|
416
444
|
index.verdict = "discarded";
|
|
@@ -420,12 +448,25 @@ function discardedRunIndex(indexJson, reason, now) {
|
|
|
420
448
|
index.finished_at ??= now;
|
|
421
449
|
return index;
|
|
422
450
|
}
|
|
451
|
+
function cleanupRunIndex(indexJson) {
|
|
452
|
+
try {
|
|
453
|
+
const index = JSON.parse(indexJson);
|
|
454
|
+
if (!index || typeof index !== "object" || Array.isArray(index))
|
|
455
|
+
throw new Error("RUN index must be an object");
|
|
456
|
+
return index;
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
throw new AppError("runtime_state_invalid", "Cleanup cannot read retained RUN state", 1, { cause: String(error) });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
423
462
|
function dirtyGit(worktreePath) {
|
|
424
463
|
if (!fs.existsSync(worktreePath)) {
|
|
425
464
|
return false;
|
|
426
465
|
}
|
|
427
466
|
const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { encoding: "utf8" });
|
|
428
|
-
|
|
467
|
+
if (result.error || result.status !== 0)
|
|
468
|
+
throw new AppError("workspace_status_failed", "Cannot establish worktree status before cleanup", 1, { path: worktreePath, cause: result.error?.message ?? result.stderr });
|
|
469
|
+
return result.stdout.trim().length > 0;
|
|
429
470
|
}
|
|
430
471
|
function safeCleanupWorktreePath(context, projectId, worktreePath) {
|
|
431
472
|
if (!fs.existsSync(worktreePath)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCommandArgs } from "./lifecycle-command.js";
|
|
1
2
|
export function classifyCliOperation(args, env = process.env) {
|
|
2
3
|
const positional = positionalArgs(args);
|
|
3
4
|
const [family = null, command = null, action = null] = positional;
|
|
@@ -14,19 +15,7 @@ export function classifyCliOperation(args, env = process.env) {
|
|
|
14
15
|
return { mode: "normal_write", operation, family, command, action };
|
|
15
16
|
}
|
|
16
17
|
function positionalArgs(args) {
|
|
17
|
-
|
|
18
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
19
|
-
const value = args[index];
|
|
20
|
-
if (!value)
|
|
21
|
-
continue;
|
|
22
|
-
if (value.startsWith("--")) {
|
|
23
|
-
if (!value.includes("=") && args[index + 1] && !args[index + 1]?.startsWith("--"))
|
|
24
|
-
index += 1;
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
positional.push(value);
|
|
28
|
-
}
|
|
29
|
-
return positional;
|
|
18
|
+
return parseCommandArgs(args).positional;
|
|
30
19
|
}
|
|
31
20
|
export function isReadOnlyCliOperation(args, env = process.env) {
|
|
32
21
|
return classifyCliOperation(args, env).mode === "read_only_diagnostics";
|
|
@@ -57,7 +46,7 @@ function isMbUpgradeMode(args, env) {
|
|
|
57
46
|
}
|
|
58
47
|
return marked && isUpgradeAllowlisted(args);
|
|
59
48
|
}
|
|
60
|
-
function isUpgradeAllowlisted(args) {
|
|
49
|
+
export function isUpgradeAllowlisted(args) {
|
|
61
50
|
const positional = positionalArgs(args);
|
|
62
51
|
const [family, command] = positional;
|
|
63
52
|
if (family === "status")
|
|
@@ -82,14 +71,9 @@ function hasRunBinding(args) {
|
|
|
82
71
|
return Boolean(optionValue(args, "run") || positionalArgs(args).some((value) => value.startsWith("RUN-")));
|
|
83
72
|
}
|
|
84
73
|
function optionValue(args, name) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return args[index + 1] ?? null;
|
|
89
|
-
if (arg?.startsWith(`--${name}=`))
|
|
90
|
-
return arg.slice(name.length + 3);
|
|
91
|
-
}
|
|
92
|
-
return null;
|
|
74
|
+
const values = parseCommandArgs(args).options.get(name);
|
|
75
|
+
const value = values?.[values.length - 1];
|
|
76
|
+
return value && value !== "__dd_flow_flag__" ? value : null;
|
|
93
77
|
}
|
|
94
78
|
function isRouterNative(args) {
|
|
95
79
|
const first = args[0];
|
|
@@ -125,13 +109,17 @@ function isReadOnlyDiagnostic(family, command, action) {
|
|
|
125
109
|
if (family === "canon")
|
|
126
110
|
return command === "status" || command === "resolve";
|
|
127
111
|
if (family === "project")
|
|
128
|
-
return command === "status" || command === "resolve";
|
|
112
|
+
return command === "status" || command === "resolve" || (command === "config" && action === "status");
|
|
129
113
|
if (family === "protocol")
|
|
130
114
|
return ["status", "branch-status", "ready", "blockers", "implement"].includes(command ?? "");
|
|
131
115
|
if (family === "plan")
|
|
132
116
|
return command === "status";
|
|
133
117
|
if (family === "run")
|
|
134
|
-
return
|
|
118
|
+
return ["status", "list", "timeline"].includes(command ?? "") || (["config", "flags", "drive", "control"].includes(command ?? "") && action === "status") || (command === "vars" && ["ls", "get"].includes(action ?? "")) || (command === "recovery" && action === "inspect");
|
|
119
|
+
if (family === "runtime" && command === "process" && ["status", "check-admission"].includes(action ?? ""))
|
|
120
|
+
return true;
|
|
121
|
+
if (family === "runtime" && command === "scope" && action === "status")
|
|
122
|
+
return true;
|
|
135
123
|
if (family === "work")
|
|
136
124
|
return ["status", "ls", "show"].includes(command ?? "");
|
|
137
125
|
if (family === "stage")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { callDaemon } from "../harness-runtime/lib/dd-codex-daemon.mjs";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand } from "./lifecycle-command.js";
|
|
5
|
+
/** The hook asks the existing daemon about native evidence; it cannot mint it. */
|
|
6
|
+
export async function verifyCodexHookDelivery(env, payload) {
|
|
7
|
+
const command = lifecycleCommandFromPayload(payload);
|
|
8
|
+
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
9
|
+
if (parsed.kind === "none")
|
|
10
|
+
return undefined;
|
|
11
|
+
const managed = commandOption(parsed.invocation, "invocation-id") || env.DD_FLOW_DAEMON_ID;
|
|
12
|
+
if (!managed)
|
|
13
|
+
return undefined; // Explicit legacy/unmanaged transport.
|
|
14
|
+
if (!env.DD_FLOW_CODEX_STATE_DIR || !env.DD_FLOW_DAEMON_ID)
|
|
15
|
+
throw new AppError("native_hook_unproven", "Managed hook has no owning native daemon", 1, { effect: "no_effect" });
|
|
16
|
+
const proof = await callDaemon(env.DD_FLOW_CODEX_STATE_DIR, "hook.verify", {
|
|
17
|
+
daemonId: env.DD_FLOW_DAEMON_ID, rootSessionId: payload.session_id,
|
|
18
|
+
sessionId: payload.agent_id ?? payload.session_id, toolCallId: payload.tool_use_id, turnId: payload.turn_id
|
|
19
|
+
}, 5000);
|
|
20
|
+
if (typeof proof.projectRoot !== "string"
|
|
21
|
+
|| typeof proof.ddFlowHome !== "string" || !env.DD_FLOW_HOME || path.resolve(env.DD_FLOW_HOME) !== path.resolve(proof.ddFlowHome)
|
|
22
|
+
|| proof.daemonId !== env.DD_FLOW_DAEMON_ID || typeof proof.rootSessionId !== "string" || typeof proof.sessionId !== "string"
|
|
23
|
+
|| typeof proof.toolCallId !== "string" || !(proof.parentSessionId === null || typeof proof.parentSessionId === "string")) {
|
|
24
|
+
throw new AppError("native_hook_unproven", "Native hook proof differs from the requested project/runtime", 1, { effect: "no_effect" });
|
|
25
|
+
}
|
|
26
|
+
return { daemonId: proof.daemonId, rootSessionId: proof.rootSessionId, sessionId: proof.sessionId,
|
|
27
|
+
parentSessionId: proof.parentSessionId, toolCallId: proof.toolCallId, projectRoot: proof.projectRoot, command: command };
|
|
28
|
+
}
|
package/dist/services/config.js
CHANGED
|
@@ -28,7 +28,8 @@ export function getProjectConfigStatus(context, project) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
export function setProjectConfigValue(context, project, input) {
|
|
31
|
-
const value =
|
|
31
|
+
const value = prepareProjectConfigValue(input.key, input.value);
|
|
32
|
+
migrateLegacyConfig(context, project.id);
|
|
32
33
|
const now = context.now();
|
|
33
34
|
context.db.run(`INSERT INTO project_config (project_id, key, value_json, created_at, updated_at)
|
|
34
35
|
VALUES (?, ?, ?, ?, ?)
|
|
@@ -38,13 +39,15 @@ export function setProjectConfigValue(context, project, input) {
|
|
|
38
39
|
return getProjectConfigStatus(context, project);
|
|
39
40
|
}
|
|
40
41
|
export function readProjectConfig(context, projectId) {
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
migrateLegacyConfig(context, projectId);
|
|
42
|
+
// Project legacy values without changing storage. Explicit writes migrate
|
|
43
|
+
// them; a canonical override always wins over its legacy alias.
|
|
44
|
+
const rows = configOverrides(context, projectId), keys = new Set(rows.map(row => row.key));
|
|
45
45
|
const config = structuredClone(defaultProjectConfig);
|
|
46
|
-
for (const row of
|
|
47
|
-
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
const key = legacyConfigKey(row.key);
|
|
48
|
+
if (key !== row.key && keys.has(key))
|
|
49
|
+
continue;
|
|
50
|
+
applyConfigValue(config, key, JSON.parse(row.value_json));
|
|
48
51
|
}
|
|
49
52
|
return config;
|
|
50
53
|
}
|
|
@@ -89,7 +92,7 @@ export function globalDashboardHtmlPath(context) {
|
|
|
89
92
|
function configOverrides(context, projectId) {
|
|
90
93
|
return context.db.all("SELECT key, value_json, updated_at FROM project_config WHERE project_id = ? ORDER BY key ASC", [projectId]);
|
|
91
94
|
}
|
|
92
|
-
function
|
|
95
|
+
export function prepareProjectConfigValue(key, value) {
|
|
93
96
|
if (key === "integrations.cmux.mode") {
|
|
94
97
|
if (!cmuxModes.includes(value)) {
|
|
95
98
|
throw new AppError("validation", "--value for integrations.cmux.mode must be off, auto, or required", 2);
|
|
@@ -3,6 +3,7 @@ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout,
|
|
|
3
3
|
import { getFlowRunVariables } from "./runs.js";
|
|
4
4
|
import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
|
|
5
5
|
import { resolveExecutionPolicy } from "./execution-policy.js";
|
|
6
|
+
import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
|
|
6
7
|
/** Stage entry acknowledges the packet before the controller issues child commands. */
|
|
7
8
|
export function controllerStageEntryPrompt(stage, command, responseFile) {
|
|
8
9
|
return [
|
|
@@ -11,7 +12,7 @@ export function controllerStageEntryPrompt(stage, command, responseFile) {
|
|
|
11
12
|
`Read the complete authoritative packet saved to ${JSON.stringify(responseFile)}, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.`,
|
|
12
13
|
"If the returned packet has orchestration.kind = work_fanout, this Turn is only Stage entry: acknowledge the packet and end your Turn immediately. The packet describes the whole Stage, but its graph/dispatch/finish instructions belong to later controller continuations. Do not list, start, launch or finish Work, create children, change files, run checks, or finish the Stage in this entry Turn. The controller will inspect the graph and send the next assignment with exact issued commands (or dispatch external workers itself).",
|
|
13
14
|
"Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
|
|
14
|
-
"
|
|
15
|
+
"Execute each runtime-issued lifecycle operation and target as one standalone call. Internal invocation IDs are intentionally absent; never add, copy or invent one. For a no-effect input rejection, correct the input and use its returned retry_command. If a required command is missing or no safe retry is returned, report the exact error and end the Turn so the controller can handle it."
|
|
15
16
|
].join("\n\n");
|
|
16
17
|
}
|
|
17
18
|
/** The shared controller owns this former eval-only Work-graph decision. */
|
|
@@ -63,21 +64,16 @@ export async function nextControllerFanout(context, input) {
|
|
|
63
64
|
if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1)
|
|
64
65
|
return { kind: "wait", reason: "qualified_capacity_required" };
|
|
65
66
|
const wave = choices.slice(0, capacity).map(choice => choice.work);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!work.work_id || !work.start_command)
|
|
71
|
-
throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
|
|
72
|
-
return `Assign one leaf child Work ${work.work_id}. ${work.launch_policy === "fresh_agent_required" ? "Use empty, non-inherited context when supported; a new Session id alone does not prove empty context." : "Inherit context only through the harness's declared mechanism."}\nIts first technical action must be this exact standalone command:\n${work.start_command}\nUse only the Work packet returned by it. Complete its declared result and exact work finish command, then stop. Do not create children or start another Work. Missing facts go through the Work's failure/result contract; only the coordinator handles Stage HITL.`;
|
|
73
|
-
})
|
|
74
|
-
].join("\n\n"));
|
|
67
|
+
for (const work of wave)
|
|
68
|
+
if (!work.work_id || !work.start_command)
|
|
69
|
+
throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
|
|
70
|
+
return prompt(renderDelegationInstructions({ harness: input.harness, stage: input.stage, capacity, works: wave }));
|
|
75
71
|
}
|
|
76
72
|
if (fanout.works.running)
|
|
77
73
|
return { kind: "wait", reason: "work_receipts_pending" };
|
|
78
74
|
if (fanout.works.created)
|
|
79
75
|
throw new AppError("fanout_graph_stalled", "Created Work has no ready or running dependency path", 1);
|
|
80
|
-
return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet.
|
|
76
|
+
return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet. A rejected stage finish may return either a repair creation command or an already-registered repair Work. Execute only a returned repair creation command, then return. If it reports repair_required with an existing repair Work/start_command, do not invoke that start_command in this coordinator Session: return immediately so the controller can launch a fresh child. Do not start a successor Stage.`);
|
|
81
77
|
}
|
|
82
78
|
/** Normalize provider-observed topology only; never parse assistant prose. */
|
|
83
79
|
export function controllerNativeChildren(receipt, rootSessionId) {
|
|
@@ -3,7 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { isActiveProtocolStatus, isActiveQueueStatus, isQueueHistoryStatus } from "../domain/contracts.js";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
|
-
import {
|
|
6
|
+
import { prepareOutputFile } from "../cli/input-preparation.js";
|
|
7
|
+
import { ensureDir, resolveProjectRoot, resolveProtocolPlanPath } from "../storage/paths.js";
|
|
7
8
|
import { dashboardMarkdownPath, globalDashboardHtmlPath, globalDashboardJsonPath, globalDashboardMarkdownPath, projectDashboardHtmlPath, projectDashboardJsonPath, projectSummaryJsonPath, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
|
|
8
9
|
import { queueForProject } from "./merge-queue.js";
|
|
9
10
|
import { publishProjectSummary } from "./project-summary.js";
|
|
@@ -17,44 +18,58 @@ export function getCmuxStatus(context, input) {
|
|
|
17
18
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
18
19
|
return { ok: true, project_root: project.root, cmux: detectCmux(context) };
|
|
19
20
|
}
|
|
20
|
-
export function
|
|
21
|
+
export function prepareDashboardRender(context, input) {
|
|
21
22
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
22
23
|
if (projectRecoveryGuarded(context, project.id))
|
|
23
24
|
throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
|
|
24
25
|
const format = parseDashboardFormat(input.format);
|
|
26
|
+
if (input.protocol)
|
|
27
|
+
requireProtocol(context, input.protocol, project.id);
|
|
28
|
+
const config = readProjectConfig(context, project.id);
|
|
29
|
+
const output = input.output
|
|
30
|
+
? path.resolve(project.root, input.output)
|
|
31
|
+
: format === "html"
|
|
32
|
+
? input.protocol ? protocolDashboardHtmlPath(context, project.id, input.protocol) : projectDashboardHtmlPath(context, project.id)
|
|
33
|
+
: dashboardMarkdownPath(project.root, config);
|
|
34
|
+
prepareOutputFile(output);
|
|
35
|
+
if (format === "html")
|
|
36
|
+
prepareOutputFile(input.protocol ? protocolDashboardJsonPath(context, project.id, input.protocol) : projectDashboardJsonPath(context, project.id));
|
|
37
|
+
return { project, format, output };
|
|
38
|
+
}
|
|
39
|
+
export function renderDashboard(context, input) {
|
|
40
|
+
const { project, format, output } = prepareDashboardRender(context, input);
|
|
25
41
|
if (format === "html") {
|
|
26
42
|
if (input.protocol) {
|
|
27
43
|
return renderProtocolDashboardHtml(context, project, input.protocol, input.output);
|
|
28
44
|
}
|
|
29
45
|
return renderProjectDashboardHtml(context, project, input.output);
|
|
30
46
|
}
|
|
31
|
-
const config = readProjectConfig(context, project.id);
|
|
32
|
-
const output = input.output
|
|
33
|
-
? path.resolve(path.isAbsolute(input.output) ? input.output : path.join(project.root, input.output))
|
|
34
|
-
: dashboardMarkdownPath(project.root, config);
|
|
35
47
|
const markdown = renderProjectDashboardMarkdown(context, project, output);
|
|
36
48
|
writeMarkdown(output, markdown);
|
|
37
49
|
return { ok: true, project_root: project.root, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
|
|
38
50
|
}
|
|
39
|
-
export function
|
|
51
|
+
export function prepareGlobalDashboardRender(context, input = {}) {
|
|
40
52
|
const format = parseDashboardFormat(input.format);
|
|
53
|
+
const output = input.output ? path.resolve(input.output) : format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context);
|
|
54
|
+
prepareOutputFile(output);
|
|
55
|
+
if (format === "html")
|
|
56
|
+
prepareOutputFile(globalDashboardJsonPath(context));
|
|
57
|
+
return { format, output };
|
|
58
|
+
}
|
|
59
|
+
export function renderGlobalDashboard(context, input = {}) {
|
|
60
|
+
const { format, output } = prepareGlobalDashboardRender(context, input);
|
|
41
61
|
if (format === "html") {
|
|
42
62
|
return renderGlobalDashboardHtml(context, input.output);
|
|
43
63
|
}
|
|
44
|
-
const output = input.output
|
|
45
|
-
? path.resolve(input.output)
|
|
46
|
-
: globalDashboardMarkdownPath(context);
|
|
47
64
|
const markdown = renderGlobalDashboardMarkdown(context, output);
|
|
48
65
|
writeMarkdown(output, markdown);
|
|
49
66
|
return { ok: true, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
|
|
50
67
|
}
|
|
51
68
|
export function openDashboard(context, input) {
|
|
69
|
+
prepareDashboardOptions(input);
|
|
52
70
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
53
71
|
const config = readProjectConfig(context, project.id);
|
|
54
72
|
const format = parseDashboardFormat(input.format, "html");
|
|
55
|
-
if (input.viewer && input.viewer !== "cmux") {
|
|
56
|
-
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
57
|
-
}
|
|
58
73
|
const dashboardPath = format === "html" ? projectDashboardHtmlPath(context, project.id) : dashboardMarkdownPath(project.root, config);
|
|
59
74
|
return {
|
|
60
75
|
ok: true,
|
|
@@ -64,9 +79,7 @@ export function openDashboard(context, input) {
|
|
|
64
79
|
};
|
|
65
80
|
}
|
|
66
81
|
export function openGlobalDashboard(context, input = {}) {
|
|
67
|
-
|
|
68
|
-
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
69
|
-
}
|
|
82
|
+
prepareDashboardOptions(input);
|
|
70
83
|
const format = parseDashboardFormat(input.format, "html");
|
|
71
84
|
const rendered = renderGlobalDashboard(context, { format });
|
|
72
85
|
return {
|
|
@@ -76,9 +89,7 @@ export function openGlobalDashboard(context, input = {}) {
|
|
|
76
89
|
};
|
|
77
90
|
}
|
|
78
91
|
export function openProtocolDashboard(context, input) {
|
|
79
|
-
|
|
80
|
-
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
81
|
-
}
|
|
92
|
+
prepareDashboardOptions(input);
|
|
82
93
|
const format = parseDashboardFormat(input.format, "html");
|
|
83
94
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
84
95
|
if (format !== "html") {
|
|
@@ -95,14 +106,40 @@ export function openProtocolDashboard(context, input) {
|
|
|
95
106
|
open: openCmuxDashboard(context, project.root, rendered.dashboard.path, format)
|
|
96
107
|
};
|
|
97
108
|
}
|
|
98
|
-
export function
|
|
109
|
+
export function prepareDashboardRefresh(context, input) {
|
|
110
|
+
prepareDashboardOptions(input);
|
|
99
111
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
100
112
|
const config = readProjectConfig(context, project.id);
|
|
101
113
|
const format = parseDashboardFormat(input.format);
|
|
102
114
|
const openMode = input.open ?? "auto";
|
|
103
|
-
if (
|
|
104
|
-
|
|
115
|
+
if (config.dashboard.project) {
|
|
116
|
+
if (projectRecoveryGuarded(context, project.id))
|
|
117
|
+
throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
|
|
118
|
+
if (input.protocol)
|
|
119
|
+
requireProtocol(context, input.protocol, project.id);
|
|
120
|
+
prepareRefreshOutputs(context, project, config, format, input.protocol);
|
|
105
121
|
}
|
|
122
|
+
return { project, config, format, openMode };
|
|
123
|
+
}
|
|
124
|
+
function prepareRefreshOutputs(context, project, config, format, protocol) {
|
|
125
|
+
if (config.dashboard.project) {
|
|
126
|
+
if (!protocol)
|
|
127
|
+
prepareOutputFile(projectSummaryJsonPath(context, project.id));
|
|
128
|
+
if (format === "html") {
|
|
129
|
+
prepareOutputFile(protocol ? protocolDashboardHtmlPath(context, project.id, protocol) : projectDashboardHtmlPath(context, project.id));
|
|
130
|
+
prepareOutputFile(protocol ? protocolDashboardJsonPath(context, project.id, protocol) : projectDashboardJsonPath(context, project.id));
|
|
131
|
+
}
|
|
132
|
+
else
|
|
133
|
+
prepareOutputFile(dashboardMarkdownPath(project.root, config));
|
|
134
|
+
}
|
|
135
|
+
if (config.dashboard.global) {
|
|
136
|
+
prepareOutputFile(format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context, config));
|
|
137
|
+
if (format === "html")
|
|
138
|
+
prepareOutputFile(globalDashboardJsonPath(context));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function refreshDashboard(context, input) {
|
|
142
|
+
const { project, config, format, openMode } = prepareDashboardRefresh(context, input);
|
|
106
143
|
if (!config.dashboard.project) {
|
|
107
144
|
return {
|
|
108
145
|
ok: true,
|
|
@@ -145,8 +182,9 @@ export function refreshAllDashboards(context, input = {}) {
|
|
|
145
182
|
return { ok: true, project_id: project.id, project_root: project.root, skipped: true, reason: "dashboard_project_disabled" };
|
|
146
183
|
}
|
|
147
184
|
try {
|
|
148
|
-
|
|
185
|
+
prepareRefreshOutputs(context, project, { ...config, dashboard: { ...config.dashboard, global: false } }, format);
|
|
149
186
|
const rendered = renderDashboard(context, { projectRoot: project.root, format });
|
|
187
|
+
const projectSummary = publishProjectSummary(context, { project });
|
|
150
188
|
return { ok: true, project_id: project.id, project_root: project.root, dashboard: rendered.dashboard, project_summary: projectSummary };
|
|
151
189
|
}
|
|
152
190
|
catch (error) {
|
|
@@ -180,6 +218,7 @@ export function autoRefreshDashboards(context, input) {
|
|
|
180
218
|
if (!config.dashboard.auto_refresh) {
|
|
181
219
|
return { ok: true, skipped: true, reason: "dashboard_auto_refresh_disabled" };
|
|
182
220
|
}
|
|
221
|
+
prepareRefreshOutputs(context, project, config, "markdown");
|
|
183
222
|
const result = { ok: true, project_root: project.root };
|
|
184
223
|
if (config.dashboard.project) {
|
|
185
224
|
publishProjectSummary(context, { project });
|
|
@@ -291,6 +330,14 @@ function detectCmux(context) {
|
|
|
291
330
|
? { available: true, source: "path", version: result.stdout.trim() }
|
|
292
331
|
: { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
|
|
293
332
|
}
|
|
333
|
+
export function prepareDashboardOptions(input) {
|
|
334
|
+
if (input.format !== undefined)
|
|
335
|
+
parseDashboardFormat(input.format);
|
|
336
|
+
if (input.viewer !== undefined && input.viewer !== "cmux")
|
|
337
|
+
throw new AppError("validation", "--viewer must be cmux", 2);
|
|
338
|
+
if (input.open !== undefined && !["auto", "true", "false"].includes(input.open))
|
|
339
|
+
throw new AppError("validation", "--open must be auto, true, or false", 2);
|
|
340
|
+
}
|
|
294
341
|
function parseDashboardFormat(value, defaultFormat = "markdown") {
|
|
295
342
|
const format = value ?? defaultFormat;
|
|
296
343
|
if (format !== "markdown" && format !== "html") {
|
|
@@ -303,15 +350,18 @@ function renderProjectDashboardHtml(context, project, output) {
|
|
|
303
350
|
? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
|
|
304
351
|
: projectDashboardHtmlPath(context, project.id);
|
|
305
352
|
const jsonPath = projectDashboardJsonPath(context, project.id);
|
|
353
|
+
prepareOutputFile(htmlPath);
|
|
354
|
+
prepareOutputFile(jsonPath);
|
|
306
355
|
const data = buildProjectDashboardData(context, project, htmlPath);
|
|
307
356
|
const html = renderDashboardHtmlPage(data);
|
|
308
|
-
writeJsonFile(jsonPath, data);
|
|
309
|
-
writeTextFile(htmlPath, html);
|
|
310
357
|
const protocolCards = Array.isArray(data.protocol_cards) ? data.protocol_cards : [];
|
|
311
|
-
const
|
|
358
|
+
const preparedPages = protocolCards
|
|
312
359
|
.filter((card) => card.generate_page !== false)
|
|
313
360
|
.slice(0, 24)
|
|
314
|
-
.map((card) =>
|
|
361
|
+
.map((card) => prepareProtocolDashboardHtml(context, project, String(card.id)));
|
|
362
|
+
writeJsonFile(jsonPath, data);
|
|
363
|
+
writeTextFile(htmlPath, html);
|
|
364
|
+
const protocolPages = preparedPages.map(writeProtocolDashboardHtml)
|
|
315
365
|
.map((result) => ({
|
|
316
366
|
id: String(result.protocol_id),
|
|
317
367
|
path: result.dashboard.path,
|
|
@@ -330,12 +380,23 @@ function renderProjectDashboardHtml(context, project, output) {
|
|
|
330
380
|
};
|
|
331
381
|
}
|
|
332
382
|
function renderProtocolDashboardHtml(context, project, protocolId, output) {
|
|
383
|
+
return writeProtocolDashboardHtml(prepareProtocolDashboardHtml(context, project, protocolId, output));
|
|
384
|
+
}
|
|
385
|
+
function prepareProtocolDashboardHtml(context, project, protocolId, output) {
|
|
386
|
+
if (projectRecoveryGuarded(context, project.id))
|
|
387
|
+
throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
|
|
333
388
|
const htmlPath = output
|
|
334
389
|
? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
|
|
335
390
|
: protocolDashboardHtmlPath(context, project.id, protocolId);
|
|
336
391
|
const jsonPath = protocolDashboardJsonPath(context, project.id, protocolId);
|
|
392
|
+
prepareOutputFile(htmlPath);
|
|
393
|
+
prepareOutputFile(jsonPath);
|
|
337
394
|
const data = buildProtocolDashboardData(context, project, protocolId, htmlPath);
|
|
338
395
|
const html = renderDashboardHtmlPage(data);
|
|
396
|
+
return { project, protocolId, htmlPath, jsonPath, data, html };
|
|
397
|
+
}
|
|
398
|
+
function writeProtocolDashboardHtml(prepared) {
|
|
399
|
+
const { project, protocolId, htmlPath, jsonPath, data, html } = prepared;
|
|
339
400
|
writeJsonFile(jsonPath, data);
|
|
340
401
|
writeTextFile(htmlPath, html);
|
|
341
402
|
return {
|
|
@@ -353,6 +414,8 @@ function renderProtocolDashboardHtml(context, project, protocolId, output) {
|
|
|
353
414
|
function renderGlobalDashboardHtml(context, output) {
|
|
354
415
|
const htmlPath = output ? path.resolve(output) : globalDashboardHtmlPath(context);
|
|
355
416
|
const jsonPath = globalDashboardJsonPath(context);
|
|
417
|
+
prepareOutputFile(htmlPath);
|
|
418
|
+
prepareOutputFile(jsonPath);
|
|
356
419
|
const data = buildGlobalDashboardData(context, htmlPath);
|
|
357
420
|
const html = renderDashboardHtmlPage(data);
|
|
358
421
|
writeJsonFile(jsonPath, data);
|
|
@@ -897,12 +960,12 @@ function protocolsForProject(context, projectId) {
|
|
|
897
960
|
FROM protocols WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
|
|
898
961
|
}
|
|
899
962
|
function planSummaryForProtocol(context, projectId, protocolId) {
|
|
900
|
-
const protocol = context.db.get("SELECT plan_path FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
|
|
963
|
+
const protocol = context.db.get("SELECT plan_path, project_root FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
|
|
901
964
|
if (!protocol) {
|
|
902
965
|
return { plan_id: "none", total: 0, done: 0, blocked: 0 };
|
|
903
966
|
}
|
|
904
967
|
try {
|
|
905
|
-
const summary = planSummary(context, { projectId, protocolId, planPath: protocol.plan_path }, { bind: false });
|
|
968
|
+
const summary = planSummary(context, { projectId, protocolId, planPath: resolveProtocolPlanPath(protocol.project_root, protocolId, protocol.plan_path) }, { bind: false });
|
|
906
969
|
return { plan_id: summary.plan_id, total: summary.total, done: summary.done, blocked: summary.blocked };
|
|
907
970
|
}
|
|
908
971
|
catch {
|
|
@@ -912,13 +975,13 @@ function planSummaryForProtocol(context, projectId, protocolId) {
|
|
|
912
975
|
function activePlanItems(context, projectId, protocols) {
|
|
913
976
|
const items = [];
|
|
914
977
|
for (const protocol of protocols) {
|
|
915
|
-
const record = context.db.get("SELECT plan_path FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocol.id]);
|
|
978
|
+
const record = context.db.get("SELECT plan_path, project_root FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocol.id]);
|
|
916
979
|
if (!record) {
|
|
917
980
|
continue;
|
|
918
981
|
}
|
|
919
982
|
let plan;
|
|
920
983
|
try {
|
|
921
|
-
plan = planWithProgress(context, { projectId, protocolId: protocol.id, planPath: record.plan_path }, { bind: false }).plan;
|
|
984
|
+
plan = planWithProgress(context, { projectId, protocolId: protocol.id, planPath: resolveProtocolPlanPath(record.project_root, protocol.id, record.plan_path) }, { bind: false }).plan;
|
|
922
985
|
}
|
|
923
986
|
catch {
|
|
924
987
|
continue;
|