@deksden-com/dd-flow-cli 0.9.0-beta.18 → 0.9.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +4 -2
- package/dist/cli/run-cli.js +64 -11
- package/dist/services/eval-snapshots.js +14 -6
- package/dist/services/hooks.js +85 -14
- package/dist/services/lifecycle-command.js +8 -0
- package/dist/services/run-recovery.js +104 -0
- package/dist/services/stage-lifecycle.js +3 -0
- package/dist/services/vnext-fanout.js +40 -0
- package/dist/services/work-registry.js +80 -7
- package/dist/storage/database.js +27 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.19
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Require a fresh trusted hook receipt from the physical Work owner before terminal stage or Work lifecycle commands can mutate a vNext RUN. Record terminal native-child observations at their originating Stage so unbound delegation is retained as a diagnostic instead of contaminating a later fan-out wave.
|
|
8
|
+
|
|
3
9
|
## 0.9.0-beta.18
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.19",
|
|
4
|
+
"cli_commit": "02648aa2db37e9332089ca941b06f5735b563496",
|
|
5
|
+
"built_at": "2026-09-06T13:33:22.164Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
|
-
"version": "4.0.
|
|
8
|
-
"commit": "
|
|
7
|
+
"version": "4.0.6",
|
|
8
|
+
"commit": "2aafb30d23e3f646b736b7ead25438cab97b16bf",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
10
|
"repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
|
|
11
11
|
"memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
|
package/dist/cli/help.js
CHANGED
|
@@ -155,7 +155,8 @@ Usage:
|
|
|
155
155
|
dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
156
156
|
dd-flow run list --project-root <root> --json
|
|
157
157
|
dd-flow run prepare-vnext-specify --project-root <root> --slug <slug> --json
|
|
158
|
-
dd-flow run
|
|
158
|
+
dd-flow run recovery inspect|begin|seal|capture|resume <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
159
|
+
dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate|--incomplete|--recovery-id <RCV-ID>) --project-root <root> --output <directory> --json
|
|
159
160
|
dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> --json
|
|
160
161
|
dd-flow run snapshot bootstrap create --project-root <root> --output <directory> --json
|
|
161
162
|
dd-flow run snapshot bootstrap restore --snapshot <directory> --project-root <prepared-root> --json
|
|
@@ -170,7 +171,7 @@ Usage:
|
|
|
170
171
|
|
|
171
172
|
RUN-* is the execution envelope for one concrete flow launch. Semantic truth remains in protocol, experiment, DEF, scenario, evidence, and Memory Bank documents. New run artifacts are stored under DD_FLOW_HOME/projects/<PRJ-ID-slug>/runs/<RUN-ID-slug>/ with one run.json projection and an append-only timeline.jsonl. Timeline is the compact full run report: summary is always present, while --hide may suppress bulky sections. Use \`dd-flow stat usage --run …\` to reread registered local Codex transcript counters and tool-call facts and refresh the current usage projection; unavailable data is explicit rather than zero.
|
|
172
173
|
|
|
173
|
-
run prepare-vnext-specify allocates an otherwise unstarted vNext RUN for the canonical SPECIFY entry checkpoint. run snapshot is the eval-only checkpoint boundary: it snapshots one quiescent RUN, its dedicated DD_FLOW_HOME, and the exact project tree including its Git repository, then restores them into a fresh dedicated runtime. It is not a general runtime backup command and fails closed for a shared runtime.
|
|
174
|
+
run prepare-vnext-specify allocates an otherwise unstarted vNext RUN for the canonical SPECIFY entry checkpoint. run recovery makes an interrupted RUN read-only, requires an explicit settled-tree receipt, captures immutable recovery evidence, then explicitly returns delegated Work to the ready graph. run snapshot is the eval-only checkpoint boundary: it snapshots one quiescent RUN, its dedicated DD_FLOW_HOME, and the exact project tree including its Git repository, then restores them into a fresh dedicated runtime. It is not a general runtime backup command and fails closed for a shared runtime.
|
|
174
175
|
|
|
175
176
|
Examples:
|
|
176
177
|
dd-flow run start --project-root "$PWD" --flow-kind mb_sdlc --subject-type protocol --subject-id PRT-001-demo --slug demo --json
|
|
@@ -192,6 +193,7 @@ Usage:
|
|
|
192
193
|
dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
|
|
193
194
|
dd-flow stage fanout dispatch <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
|
|
194
195
|
dd-flow stage fanout reconcile <RUN-ID> --project-root <root> --stage <name> --observations-file <native-observations.json> --json
|
|
196
|
+
dd-flow stage native observe <RUN-ID> --project-root <root> --stage <name> --observations-file <native-observations.json> --json
|
|
195
197
|
dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--semantic-file <RUN-local-json>] --json
|
|
196
198
|
dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage specify (--result-file <RUN-local-specify.json>|--result-stdin) --outcome <specified|failed|cancelled> --json
|
|
197
199
|
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -41,14 +41,15 @@ import { addVnextCodeRepair, finishVnextCode, startVnextCode } from "../services
|
|
|
41
41
|
import { addVnextCodeReviewRepair, finishVnextCodeReview, isVnextCodeReviewRun, startVnextCodeReview } from "../services/vnext-code-review.js";
|
|
42
42
|
import { applyVnextMerge, finishVnextMerge, getVnextMergeRequest, isVnextMergeRun, repairVnextMerge, routeVnextMergeRequest, startVnextMerge } from "../services/vnext-merge.js";
|
|
43
43
|
import { mergeServerStatus, serveMergeRequests, stopMergeServer } from "../services/merge-server.js";
|
|
44
|
-
import { getVnextFanoutStatus, reconcileVnextFanout } from "../services/vnext-fanout.js";
|
|
44
|
+
import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout } from "../services/vnext-fanout.js";
|
|
45
45
|
import { pauseStageForUser, resumeStageAfterUser } from "../services/stage-pause.js";
|
|
46
46
|
import { blockStageForRuntime, unblockStageAfterRuntimeRepair } from "../services/stage-blocker.js";
|
|
47
47
|
import { classifyCliOperation, requiresExplicitUpgradeAuthorization } from "../services/cli-operation-classifier.js";
|
|
48
48
|
import { renderWorkerPrompt } from "../services/prompts.js";
|
|
49
49
|
import { finishStage, startStage } from "../services/stage-lifecycle.js";
|
|
50
|
-
import { addWorkBatch, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
|
|
50
|
+
import { addWorkBatch, assertStageLifecycleOwner, assertWorkLifecycleOwner, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
|
|
51
51
|
import { createEvalBootstrapSnapshot, createEvalRunSnapshot, prepareVnextSpecifyRun, restoreEvalBootstrapSnapshot, restoreEvalRunSnapshot } from "../services/eval-snapshots.js";
|
|
52
|
+
import { beginRunRecovery, inspectRunRecovery, recordRecoveryCapture, resumeRunRecovery, sealRunRecovery } from "../services/run-recovery.js";
|
|
52
53
|
import { loadExternalStageContext } from "../services/stage-context.js";
|
|
53
54
|
import { stopManagedProcess, confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
|
|
54
55
|
import { startRuntimeService } from "../services/runtime-service.js";
|
|
@@ -670,6 +671,11 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
670
671
|
}
|
|
671
672
|
throw new AppError("usage", "Use: dd-flow stage fanout <status|dispatch|reconcile> <RUN> --stage <stage> --project-root <path>; reconcile requires --observations-file <native-observations.json>", 2);
|
|
672
673
|
}
|
|
674
|
+
if (family === "stage" && command === "native") {
|
|
675
|
+
if (requiredPosition(parsed, 0, "native action") !== "observe")
|
|
676
|
+
throw new AppError("usage", "Use: dd-flow stage native observe <RUN> --stage <stage> --observations-file <native-observations.json> --project-root <path>", 2);
|
|
677
|
+
return observeVnextNativeChildren(context, { projectRoot: requiredScopeProjectRoot(scopeProjectRoot, "stage native observe"), runId: requiredPosition(parsed, 1, "run-id"), stage: requiredOption(parsed, "stage"), observations: JSON.parse(fs.readFileSync(requiredOption(parsed, "observations-file"), "utf8")) });
|
|
678
|
+
}
|
|
673
679
|
if (family === "run" && command === "capacity") {
|
|
674
680
|
if (requiredPosition(parsed, 0, "capacity action") !== "record")
|
|
675
681
|
throw new AppError("usage", "Use: dd-flow run capacity record <RUN> --available-slots <0..15> --project-root <path>", 2);
|
|
@@ -765,11 +771,18 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
765
771
|
const resultStdin = hasOption(parsed, "result-stdin");
|
|
766
772
|
if (Boolean(resultFile) === resultStdin)
|
|
767
773
|
throw new AppError("usage", "work finish requires exactly one of --result-file or --result-stdin", 2);
|
|
774
|
+
const workId = requiredPosition(parsed, 0, "work-id");
|
|
775
|
+
const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work finish");
|
|
776
|
+
assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_finish", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
|
|
768
777
|
const result = resultFile ? fs.readFileSync(path.resolve(resultFile), "utf8") : await readStdin(io.stdin);
|
|
769
|
-
return await finishWork(context,
|
|
778
|
+
return await finishWork(context, workId, result, commandProgress(io, output));
|
|
779
|
+
}
|
|
780
|
+
if (family === "work" && command === "fail") {
|
|
781
|
+
const workId = requiredPosition(parsed, 0, "work-id");
|
|
782
|
+
const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work fail");
|
|
783
|
+
assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_fail", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
|
|
784
|
+
return failWork(context, workId, requiredOption(parsed, "reason"));
|
|
770
785
|
}
|
|
771
|
-
if (family === "work" && command === "fail")
|
|
772
|
-
return failWork(context, requiredPosition(parsed, 0, "work-id"), requiredOption(parsed, "reason"));
|
|
773
786
|
if (family === "work" && command === "cancel")
|
|
774
787
|
return cancelWork(context, requiredPosition(parsed, 0, "work-id"), requiredOption(parsed, "reason"));
|
|
775
788
|
if (family === "work" && command === "retry")
|
|
@@ -882,8 +895,17 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
882
895
|
const resultStdin = hasOption(parsed, "result-stdin");
|
|
883
896
|
const retryCheckId = optionalOption(parsed, "retry-check");
|
|
884
897
|
const retryReason = optionalOption(parsed, "reason");
|
|
898
|
+
const hookEventId = optionalOption(parsed, "hook-event-id");
|
|
885
899
|
if (retryCheckId && !retryReason?.trim())
|
|
886
900
|
throw new AppError("usage", "--retry-check requires --reason explaining the recovered environment", 2);
|
|
901
|
+
const protectedLifecycle = (stage === "specify" && isVnextSpecifyRun(context, { projectRoot, runId }))
|
|
902
|
+
|| (stage === "protocolize" && isVnextProtocolizeRun(context, { projectRoot, runId }))
|
|
903
|
+
|| (stage === "plan" && isVnextPlanRun(context, { projectRoot, runId }))
|
|
904
|
+
|| (stage === "plan-review" && isVnextPlanReviewRun(context, { projectRoot, runId }))
|
|
905
|
+
|| (stage === "code" && isVnextPlanRun(context, { projectRoot, runId }))
|
|
906
|
+
|| (stage === "code-review" && isVnextCodeReviewRun(context, { projectRoot, runId }));
|
|
907
|
+
if (protectedLifecycle)
|
|
908
|
+
assertStageLifecycleOwner(context, { projectRoot, runId, stage, operation: "stage_finish", ...(hookEventId ? { hookEventId } : {}) });
|
|
887
909
|
if (stage === "specify" && isVnextSpecifyRun(context, { projectRoot, runId })) {
|
|
888
910
|
const outcome = optionalOption(parsed, "outcome");
|
|
889
911
|
if (!outcome || !["specified", "failed", "cancelled"].includes(outcome)) {
|
|
@@ -949,11 +971,23 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
949
971
|
if (family === "stage" && command === "pause") {
|
|
950
972
|
if (!hasOption(parsed, "question-stdin"))
|
|
951
973
|
throw new AppError("usage", "stage pause requires --question-stdin", 2);
|
|
974
|
+
const projectRoot = requiredOption(parsed, "project-root");
|
|
975
|
+
const runId = requiredPosition(parsed, 0, "run-id");
|
|
976
|
+
const stage = requiredOption(parsed, "stage");
|
|
977
|
+
const workId = requiredOption(parsed, "work");
|
|
978
|
+
const protectedLifecycle = (stage === "specify" && isVnextSpecifyRun(context, { projectRoot, runId }))
|
|
979
|
+
|| (stage === "protocolize" && isVnextProtocolizeRun(context, { projectRoot, runId }))
|
|
980
|
+
|| (stage === "plan" && isVnextPlanRun(context, { projectRoot, runId }))
|
|
981
|
+
|| (stage === "plan-review" && isVnextPlanReviewRun(context, { projectRoot, runId }))
|
|
982
|
+
|| (stage === "code" && isVnextPlanRun(context, { projectRoot, runId }))
|
|
983
|
+
|| (stage === "code-review" && isVnextCodeReviewRun(context, { projectRoot, runId }));
|
|
984
|
+
if (protectedLifecycle)
|
|
985
|
+
assertStageLifecycleOwner(context, { projectRoot, runId, stage, workId, operation: "stage_pause", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
|
|
952
986
|
return pauseStageForUser(context, {
|
|
953
|
-
projectRoot
|
|
954
|
-
runId
|
|
955
|
-
stage
|
|
956
|
-
workId
|
|
987
|
+
projectRoot,
|
|
988
|
+
runId,
|
|
989
|
+
stage,
|
|
990
|
+
workId,
|
|
957
991
|
question: await readStdin(io.stdin)
|
|
958
992
|
});
|
|
959
993
|
}
|
|
@@ -1429,6 +1463,22 @@ function dispatchSession(context, command, parsed) {
|
|
|
1429
1463
|
throw new AppError("usage", `Unknown session command: ${command ?? "<empty>"}`, 2);
|
|
1430
1464
|
}
|
|
1431
1465
|
function dispatchRun(context, command, parsed) {
|
|
1466
|
+
if (command === "recovery") {
|
|
1467
|
+
const action = requiredPosition(parsed, 0, "recovery action");
|
|
1468
|
+
const projectRoot = requiredOption(parsed, "project-root");
|
|
1469
|
+
const runId = requiredPosition(parsed, 1, "run-id");
|
|
1470
|
+
if (action === "inspect")
|
|
1471
|
+
return inspectRunRecovery(context, { projectRoot, runId });
|
|
1472
|
+
if (action === "begin")
|
|
1473
|
+
return beginRunRecovery(context, { projectRoot, runId, interruption: requiredOption(parsed, "interruption-json") });
|
|
1474
|
+
if (action === "seal")
|
|
1475
|
+
return sealRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), settlement: requiredOption(parsed, "settlement-json") });
|
|
1476
|
+
if (action === "capture")
|
|
1477
|
+
return recordRecoveryCapture(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), capturePath: requiredOption(parsed, "capture-path") });
|
|
1478
|
+
if (action === "resume")
|
|
1479
|
+
return resumeRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id") });
|
|
1480
|
+
throw new AppError("usage", "Use: dd-flow run recovery inspect|begin|seal|capture|resume", 2);
|
|
1481
|
+
}
|
|
1432
1482
|
if (command === "snapshot") {
|
|
1433
1483
|
const action = requiredPosition(parsed, 0, "snapshot action");
|
|
1434
1484
|
if (action === "bootstrap") {
|
|
@@ -1448,9 +1498,10 @@ function dispatchRun(context, command, parsed) {
|
|
|
1448
1498
|
if (action === "create") {
|
|
1449
1499
|
const candidate = parsed.options.has("candidate");
|
|
1450
1500
|
const incomplete = parsed.options.has("incomplete");
|
|
1501
|
+
const recoveryId = optionalOption(parsed, "recovery-id");
|
|
1451
1502
|
const stageEntry = optionalOption(parsed, "stage-entry");
|
|
1452
|
-
if (Number(candidate) + Number(incomplete) + Number(stageEntry !== undefined) !== 1)
|
|
1453
|
-
throw new AppError("usage", "Use exactly one of --candidate, --incomplete or --stage-entry", 2);
|
|
1503
|
+
if (Number(candidate) + Number(incomplete) + Number(stageEntry !== undefined) + Number(recoveryId !== undefined) !== 1)
|
|
1504
|
+
throw new AppError("usage", "Use exactly one of --candidate, --incomplete, --recovery-id or --stage-entry", 2);
|
|
1454
1505
|
const input = {
|
|
1455
1506
|
projectRoot: requiredOption(parsed, "project-root"),
|
|
1456
1507
|
runId: requiredPosition(parsed, 1, "run-id"),
|
|
@@ -1458,6 +1509,8 @@ function dispatchRun(context, command, parsed) {
|
|
|
1458
1509
|
};
|
|
1459
1510
|
if (incomplete)
|
|
1460
1511
|
return createEvalRunSnapshot(context, { ...input, incomplete: true });
|
|
1512
|
+
if (recoveryId)
|
|
1513
|
+
return createEvalRunSnapshot(context, { ...input, recoveryId });
|
|
1461
1514
|
return candidate
|
|
1462
1515
|
? createEvalRunSnapshot(context, { ...input, candidate: true })
|
|
1463
1516
|
: createEvalRunSnapshot(context, { ...input, stageEntry: stageEntry });
|
|
@@ -55,7 +55,13 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
55
55
|
const project = requireProjectByRoot(context, projectRoot);
|
|
56
56
|
const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
|
|
57
57
|
assertDedicatedHome(context, project.id, input.runId);
|
|
58
|
-
|
|
58
|
+
const recovery = typeof input.recoveryId === "string";
|
|
59
|
+
if (recovery) {
|
|
60
|
+
const guard = context.db.get("SELECT status, recovery_id FROM run_recovery_guards WHERE project_id = ? AND run_id = ? AND recovery_id = ?", [project.id, input.runId, input.recoveryId]);
|
|
61
|
+
if (!guard || guard.status !== "sealed")
|
|
62
|
+
throw new AppError("recovery_invalid_state", "Recovery snapshots require the matching sealed recovery guard", 1, { run_id: input.runId, recovery_id: input.recoveryId ?? null, status: guard?.status ?? null });
|
|
63
|
+
}
|
|
64
|
+
if (input.incomplete || recovery) { /* Recovery captures only after the runner seals all writers. */ }
|
|
59
65
|
else if (input.candidate)
|
|
60
66
|
assertTerminalCandidate(status.index.stage_runs ?? []);
|
|
61
67
|
else if (input.stageEntry)
|
|
@@ -66,7 +72,7 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
66
72
|
const activeChildren = input.candidate
|
|
67
73
|
? context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND status IN ('created', 'running', 'paused')", [project.id, input.runId])?.count ?? 0
|
|
68
74
|
: context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? IS NULL OR COALESCE(result_schema, '') NOT LIKE ?)))", [project.id, input.runId, allowedCreatedResultSchema, allowedCreatedResultSchema ? `${allowedCreatedResultSchema}@%` : ""])?.count ?? 0;
|
|
69
|
-
if (!input.incomplete && activeChildren > 0)
|
|
75
|
+
if (!input.incomplete && !recovery && activeChildren > 0)
|
|
70
76
|
throw new AppError("snapshot_not_quiescent", `${input.candidate ? "Candidate" : "Stage-entry"} snapshot requires no active child Work`, 1, { run_id: input.runId, active_children: activeChildren });
|
|
71
77
|
const output = path.resolve(input.output);
|
|
72
78
|
if (fs.existsSync(output))
|
|
@@ -103,9 +109,9 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
103
109
|
project_id: project.id,
|
|
104
110
|
project_root: projectRoot,
|
|
105
111
|
dd_flow_home: context.ddFlowHome,
|
|
106
|
-
purpose: input.incomplete ? "incomplete" : input.candidate ? "candidate" : "stage_entry",
|
|
107
|
-
stage_entry: input.incomplete || input.candidate ? null : input.stageEntry,
|
|
108
|
-
...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : {}),
|
|
112
|
+
purpose: input.incomplete ? "incomplete" : recovery ? "recovery" : input.candidate ? "candidate" : "stage_entry",
|
|
113
|
+
stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
|
|
114
|
+
...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : recovery ? { recovery_id: input.recoveryId, consistency: "sealed_writer_barrier_required", active_child_work_count: activeChildren, restorable: "same-runtime-or-explicit-restore" } : {}),
|
|
109
115
|
created_at: context.now(),
|
|
110
116
|
runtime_sha256: treeHash(path.join(output, "runtime")),
|
|
111
117
|
project_sha256: treeHash(path.join(output, "project")),
|
|
@@ -129,6 +135,8 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
129
135
|
const manifest = readManifest(snapshot);
|
|
130
136
|
if (manifest.purpose === "incomplete")
|
|
131
137
|
throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
|
|
138
|
+
if (manifest.purpose === "recovery")
|
|
139
|
+
throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require the recovery runner; they are not a generic stage-entry fixture", 1, { snapshot });
|
|
132
140
|
if (manifest.purpose === "candidate")
|
|
133
141
|
throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
|
|
134
142
|
if (!manifest.stage_entry)
|
|
@@ -383,7 +391,7 @@ function readManifest(snapshot) {
|
|
|
383
391
|
if (!fs.existsSync(file))
|
|
384
392
|
throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
|
|
385
393
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
386
|
-
if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
|
|
394
|
+
if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete", "recovery"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
|
|
387
395
|
throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
|
|
388
396
|
if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
|
|
389
397
|
throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
|
package/dist/services/hooks.js
CHANGED
|
@@ -285,7 +285,7 @@ export function handleCodexHook(context, input) {
|
|
|
285
285
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
286
286
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
287
287
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
288
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
288
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
289
289
|
return {
|
|
290
290
|
ok: false,
|
|
291
291
|
observed: false,
|
|
@@ -299,14 +299,25 @@ export function handleCodexHook(context, input) {
|
|
|
299
299
|
}
|
|
300
300
|
};
|
|
301
301
|
}
|
|
302
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
302
|
+
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
303
303
|
const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
|
|
304
304
|
// A bootstrap stage is the first stateful command for a new materialized project.
|
|
305
305
|
// Its PreToolUse event arrives before stage start can register that project.
|
|
306
306
|
if (bootstrapStageStart && commandProjectRoot) {
|
|
307
307
|
registerProject(context, { root: commandProjectRoot });
|
|
308
308
|
}
|
|
309
|
-
|
|
309
|
+
let project;
|
|
310
|
+
try {
|
|
311
|
+
project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
// A terminal command outside a registered flow gets no trusted receipt;
|
|
315
|
+
// the CLI will fail closed if it is actually executed. This keeps hooks
|
|
316
|
+
// inert for unrelated shell-parser probes and stale project directories.
|
|
317
|
+
if (stageFinish || stagePause || workFinish || workFail)
|
|
318
|
+
return { ok: true, observed: false, reason: "project_not_registered" };
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
310
321
|
if (!project)
|
|
311
322
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
312
323
|
// Codex retains the root session_id for a thread-spawned child, while the
|
|
@@ -364,7 +375,7 @@ export function handleCodexHook(context, input) {
|
|
|
364
375
|
session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
|
|
365
376
|
...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
|
|
366
377
|
protocol_id: protocolId,
|
|
367
|
-
...(providerSessionId && (flowPayload || stageStart || stageResume || workStart)
|
|
378
|
+
...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
|
|
368
379
|
? {
|
|
369
380
|
hookSpecificOutput: {
|
|
370
381
|
hookEventName: "PreToolUse",
|
|
@@ -414,7 +425,7 @@ export function handleZcodeEvent(context, input) {
|
|
|
414
425
|
const lifecycle = lifecycleFacts(command);
|
|
415
426
|
if (!lifecycle)
|
|
416
427
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
417
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
428
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
418
429
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
|
|
419
430
|
standalone_command: lifecycle.invocation.command
|
|
420
431
|
});
|
|
@@ -521,12 +532,12 @@ export function handleGrokEvent(context, input) {
|
|
|
521
532
|
const lifecycle = lifecycleFacts(command);
|
|
522
533
|
if (!lifecycle)
|
|
523
534
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
524
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
535
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
525
536
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
526
537
|
standalone_command: lifecycle.invocation.command
|
|
527
538
|
});
|
|
528
539
|
}
|
|
529
|
-
const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
540
|
+
const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
530
541
|
const expectedRoot = resolveProjectRoot(input.projectRoot);
|
|
531
542
|
if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
|
|
532
543
|
throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
|
|
@@ -570,7 +581,7 @@ export function handleGrokEvent(context, input) {
|
|
|
570
581
|
});
|
|
571
582
|
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
572
583
|
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
573
|
-
return (flowPayload || stageStart || stageResume || workStart)
|
|
584
|
+
return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail)
|
|
574
585
|
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
575
586
|
: result;
|
|
576
587
|
}
|
|
@@ -635,7 +646,7 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
635
646
|
const lifecycle = lifecycleFacts(command);
|
|
636
647
|
if (!lifecycle)
|
|
637
648
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
638
|
-
if (lifecycle.analysis.kind === "compound") {
|
|
649
|
+
if (lifecycle.analysis.kind === "compound" && !allowsStdinLifecycleCompound(lifecycle)) {
|
|
639
650
|
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
640
651
|
}
|
|
641
652
|
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
@@ -747,25 +758,39 @@ function lifecycleFacts(command) {
|
|
|
747
758
|
return null;
|
|
748
759
|
const invocation = analysis.invocation;
|
|
749
760
|
const stageStart = invocation.operation === "stage_start";
|
|
761
|
+
const stageFinish = invocation.operation === "stage_finish";
|
|
762
|
+
const stagePause = invocation.operation === "stage_pause";
|
|
750
763
|
const stageResume = invocation.operation === "stage_resume";
|
|
751
764
|
const workStart = invocation.operation === "work_start";
|
|
765
|
+
const workFinish = invocation.operation === "work_finish";
|
|
766
|
+
const workFail = invocation.operation === "work_fail";
|
|
752
767
|
return {
|
|
753
768
|
analysis,
|
|
754
769
|
invocation,
|
|
755
770
|
flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
|
|
756
771
|
stageStart,
|
|
772
|
+
stageFinish,
|
|
773
|
+
stagePause,
|
|
757
774
|
stageResume,
|
|
758
|
-
workStart,
|
|
775
|
+
workStart, workFinish, workFail,
|
|
759
776
|
bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
|
|
760
777
|
commandProjectRoot: commandOption(invocation, "project-root"),
|
|
761
|
-
stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
762
|
-
runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
763
|
-
workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
|
|
778
|
+
stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
|
|
779
|
+
runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
|
|
780
|
+
workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
|
|
764
781
|
contextSha256: commandOption(invocation, "context-sha256")
|
|
765
782
|
};
|
|
766
783
|
}
|
|
784
|
+
// Worker completion and the two HITL commands intentionally take their
|
|
785
|
+
// payload from stdin. Their generated pipe/heredoc is one lifecycle command,
|
|
786
|
+
// not a second authority. Everything else remains standalone-only.
|
|
787
|
+
function allowsStdinLifecycleCompound(facts) {
|
|
788
|
+
const operation = facts.invocation.operation;
|
|
789
|
+
return (operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
|
|
790
|
+
|| ((operation === "stage_pause" || operation === "stage_resume") && facts.invocation.heredoc);
|
|
791
|
+
}
|
|
767
792
|
function lifecycleMatchKey(facts, projectRoot) {
|
|
768
|
-
const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
|
|
793
|
+
const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
|
|
769
794
|
if (bootstrapStageStart)
|
|
770
795
|
return bootstrapMatchKey({
|
|
771
796
|
projectRoot,
|
|
@@ -775,12 +800,58 @@ function lifecycleMatchKey(facts, projectRoot) {
|
|
|
775
800
|
});
|
|
776
801
|
if (stageStart && runId && stageName)
|
|
777
802
|
return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
|
|
803
|
+
if (stageFinish && runId && stageName)
|
|
804
|
+
return stageLifecycleMatchKey("stage_finish", runId, stageName, projectRoot);
|
|
805
|
+
if (stagePause && runId && stageName && workId)
|
|
806
|
+
return stageLifecycleMatchKey("stage_pause", runId, stageName, projectRoot, workId);
|
|
778
807
|
if (stageResume && runId && stageName && workId)
|
|
779
808
|
return stageResumeMatchKey(runId, stageName, workId, projectRoot);
|
|
780
809
|
if (workStart && workId)
|
|
781
810
|
return workStartMatchKey(workId, projectRoot);
|
|
811
|
+
if (workFinish && workId)
|
|
812
|
+
return workLifecycleMatchKey("work_finish", workId, projectRoot);
|
|
813
|
+
if (workFail && workId)
|
|
814
|
+
return workLifecycleMatchKey("work_fail", workId, projectRoot);
|
|
782
815
|
return null;
|
|
783
816
|
}
|
|
817
|
+
export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
|
|
818
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
|
|
819
|
+
}
|
|
820
|
+
export function workLifecycleMatchKey(operation, workId, projectRoot) {
|
|
821
|
+
return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
|
|
822
|
+
}
|
|
823
|
+
/** Claims a Work terminal command only after its exact hook receipt has proved
|
|
824
|
+
* the physical Session that issued it. Work code performs the owner check. */
|
|
825
|
+
export function claimWorkLifecycleHookEvent(context, input) {
|
|
826
|
+
const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
|
|
827
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
828
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
829
|
+
}).eventKey;
|
|
830
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
831
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
832
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey });
|
|
833
|
+
}
|
|
834
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
835
|
+
if (claimed.changes !== 1)
|
|
836
|
+
throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
|
|
837
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
838
|
+
}
|
|
839
|
+
/** Claims a lifecycle command only after its exact hook receipt has proved the
|
|
840
|
+
* physical Session that issued it. Stage code performs the Work-owner check. */
|
|
841
|
+
export function claimStageLifecycleHookEvent(context, input) {
|
|
842
|
+
const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
|
|
843
|
+
const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
|
|
844
|
+
projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
|
|
845
|
+
}).eventKey;
|
|
846
|
+
const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
|
|
847
|
+
if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
|
|
848
|
+
throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey });
|
|
849
|
+
}
|
|
850
|
+
const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
|
|
851
|
+
if (claimed.changes !== 1)
|
|
852
|
+
throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
|
|
853
|
+
return hookSessionIdentity(context, input.projectId, eventKey);
|
|
854
|
+
}
|
|
784
855
|
/** Trusted session identity is created by PreToolUse, never supplied by an agent. */
|
|
785
856
|
export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
786
857
|
const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
|
|
@@ -175,10 +175,18 @@ function lifecycleOperation(argv) {
|
|
|
175
175
|
return "session_register";
|
|
176
176
|
if (key === "stage start")
|
|
177
177
|
return "stage_start";
|
|
178
|
+
if (key === "stage finish")
|
|
179
|
+
return "stage_finish";
|
|
180
|
+
if (key === "stage pause")
|
|
181
|
+
return "stage_pause";
|
|
178
182
|
if (key === "stage resume")
|
|
179
183
|
return "stage_resume";
|
|
180
184
|
if (key === "work start")
|
|
181
185
|
return "work_start";
|
|
186
|
+
if (key === "work finish")
|
|
187
|
+
return "work_finish";
|
|
188
|
+
if (key === "work fail")
|
|
189
|
+
return "work_fail";
|
|
182
190
|
return null;
|
|
183
191
|
}
|
|
184
192
|
export function parseCommandArgs(argv) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
6
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
7
|
+
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
8
|
+
function requireRun(context, projectId, runId) {
|
|
9
|
+
const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]);
|
|
10
|
+
if (!run?.run_root)
|
|
11
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId });
|
|
12
|
+
return run;
|
|
13
|
+
}
|
|
14
|
+
function parseObject(value, label) { try {
|
|
15
|
+
const parsed = JSON.parse(value);
|
|
16
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
catch { /* validated below */ } throw new AppError("validation", `${label} must be a JSON object`, 2); }
|
|
20
|
+
export function recoveryGuard(context, projectId, runId) { return context.db.get("SELECT recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at FROM run_recovery_guards WHERE project_id = ? AND run_id = ? ORDER BY generation DESC LIMIT 1", [projectId, runId]) ?? null; }
|
|
21
|
+
export function assertRunMutationAllowed(context, projectId, runId) { const guard = recoveryGuard(context, projectId, runId); if (guard && ["draining", "sealed", "resuming"].includes(guard.status))
|
|
22
|
+
throw new AppError("run_recovery_guarded", "RUN is retained for recovery; reconcile or resume its recovery operation before accepting a new lifecycle mutation", 1, { run_id: runId, recovery_id: guard.recovery_id, generation: guard.generation, status: guard.status }); }
|
|
23
|
+
export function beginRunRecovery(context, input) {
|
|
24
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
25
|
+
const run = requireRun(context, project.id, input.runId);
|
|
26
|
+
const interruption = parseObject(input.interruption, "interruption");
|
|
27
|
+
const prior = recoveryGuard(context, project.id, run.id);
|
|
28
|
+
if (prior && ["draining", "sealed", "resuming"].includes(prior.status))
|
|
29
|
+
return { ok: true, recovery: { ...prior, interruption: JSON.parse(prior.interruption_json) }, reused: true };
|
|
30
|
+
const recoveryId = `RCV-${crypto.randomUUID()}`, generation = (prior?.generation ?? 0) + 1, now = context.now();
|
|
31
|
+
context.db.run("INSERT INTO run_recovery_guards (recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at) VALUES (?, ?, ?, ?, 'draining', ?, NULL, NULL, ?, ?, NULL, NULL)", [recoveryId, project.id, run.id, generation, JSON.stringify(interruption), now, now]);
|
|
32
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_draining", recovery_id: recoveryId, generation, interruption });
|
|
33
|
+
return { ok: true, recovery: { recovery_id: recoveryId, run_id: run.id, generation, status: "draining", interruption } };
|
|
34
|
+
}
|
|
35
|
+
export function sealRunRecovery(context, input) {
|
|
36
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
37
|
+
const run = requireRun(context, project.id, input.runId);
|
|
38
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
39
|
+
if (!guard || guard.recovery_id !== input.recoveryId)
|
|
40
|
+
throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
|
|
41
|
+
const settlement = parseObject(input.settlement, "settlement");
|
|
42
|
+
if (settlement.settled !== true)
|
|
43
|
+
throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1, { recovery_id: guard.recovery_id });
|
|
44
|
+
if (guard.status === "sealed")
|
|
45
|
+
return { ok: true, recovery: { ...guard, settlement: JSON.parse(guard.settlement_json ?? "{}") }, reused: true };
|
|
46
|
+
if (guard.status !== "draining")
|
|
47
|
+
throw new AppError("recovery_invalid_state", "Recovery is not draining", 1, { recovery_id: guard.recovery_id, status: guard.status });
|
|
48
|
+
const now = context.now();
|
|
49
|
+
context.db.run("UPDATE run_recovery_guards SET status = 'sealed', settlement_json = ?, sealed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'draining'", [JSON.stringify(settlement), now, now, guard.recovery_id]);
|
|
50
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_sealed", recovery_id: guard.recovery_id, generation: guard.generation, settlement });
|
|
51
|
+
return { ok: true, recovery: { recovery_id: guard.recovery_id, run_id: run.id, generation: guard.generation, status: "sealed", settlement } };
|
|
52
|
+
}
|
|
53
|
+
export function recordRecoveryCapture(context, input) {
|
|
54
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
55
|
+
const run = requireRun(context, project.id, input.runId);
|
|
56
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
57
|
+
if (!guard || guard.recovery_id !== input.recoveryId || guard.status !== "sealed")
|
|
58
|
+
throw new AppError("recovery_invalid_state", "Recovery capture requires a sealed recovery operation", 1, { recovery_id: input.recoveryId, run_id: run.id });
|
|
59
|
+
const capture = path.resolve(input.capturePath);
|
|
60
|
+
if (!fs.existsSync(capture))
|
|
61
|
+
throw new AppError("recovery_capture_missing", "Recovery capture path is unavailable", 1, { capture });
|
|
62
|
+
context.db.run("UPDATE run_recovery_guards SET capture_path = ?, updated_at = ? WHERE recovery_id = ?", [capture, context.now(), guard.recovery_id]);
|
|
63
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_capture_ready", recovery_id: guard.recovery_id, capture_path: capture });
|
|
64
|
+
return { ok: true, recovery_id: guard.recovery_id, capture_path: capture };
|
|
65
|
+
}
|
|
66
|
+
export function resumeRunRecovery(context, input) {
|
|
67
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
68
|
+
const run = requireRun(context, project.id, input.runId);
|
|
69
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
70
|
+
if (!guard || guard.recovery_id !== input.recoveryId)
|
|
71
|
+
throw new AppError("recovery_not_found", "Recovery operation is not registered for this RUN", 1, { recovery_id: input.recoveryId, run_id: run.id });
|
|
72
|
+
if (guard.status === "recovered")
|
|
73
|
+
return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: true, reused: true };
|
|
74
|
+
if (guard.status !== "sealed" || !guard.capture_path)
|
|
75
|
+
throw new AppError("recovery_capture_required", "Recovery must be sealed and captured before it can resume", 1, { recovery_id: guard.recovery_id, status: guard.status });
|
|
76
|
+
// The root coordinator keeps its same native Session and receives a recovery
|
|
77
|
+
// prompt from the eval runner. Only delegated Work is returned to the ready
|
|
78
|
+
// graph and therefore gets a fresh, addressable recovery packet.
|
|
79
|
+
const now = context.now(), running = context.db.all("SELECT work_id, started_at, status FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' AND parent_work_id IS NOT NULL ORDER BY work_id", [project.id, run.id]);
|
|
80
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
81
|
+
try {
|
|
82
|
+
for (const work of running) {
|
|
83
|
+
const evidence = path.join(run.run_root, "recoveries", guard.recovery_id, "works", work.work_id), source = path.join(run.run_root, "works", work.work_id);
|
|
84
|
+
fs.mkdirSync(path.dirname(evidence), { recursive: true });
|
|
85
|
+
if (fs.existsSync(source) && !fs.existsSync(evidence))
|
|
86
|
+
fs.cpSync(source, evidence, { recursive: true, verbatimSymlinks: true });
|
|
87
|
+
context.db.run("INSERT INTO work_recovery_segments (recovery_id, work_id, generation, prior_started_at, evidence_path, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)", [guard.recovery_id, work.work_id, guard.generation, work.started_at, evidence, now, now]);
|
|
88
|
+
context.db.run("UPDATE work_sessions SET status = 'interrupted', completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]);
|
|
89
|
+
context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
|
|
90
|
+
}
|
|
91
|
+
context.db.run("UPDATE run_recovery_guards SET status = 'recovered', resumed_at = ?, updated_at = ? WHERE recovery_id = ? AND status = 'sealed'", [now, now, guard.recovery_id]);
|
|
92
|
+
context.db.exec("COMMIT");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
context.db.exec("ROLLBACK");
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "run_recovery_resumed", recovery_id: guard.recovery_id, generation: guard.generation, interrupted_works: running.map((work) => work.work_id) });
|
|
99
|
+
return { ok: true, recovery_id: guard.recovery_id, generation: guard.generation, resumed: true, interrupted_works: running.map((work) => work.work_id) };
|
|
100
|
+
}
|
|
101
|
+
export function recoveryPromptForWork(context, workId) { const row = context.db.get("SELECT recovery_id, generation, evidence_path FROM work_recovery_segments WHERE work_id = ? AND status = 'prepared' ORDER BY generation DESC LIMIT 1", [workId]); return row ? { recoveryId: row.recovery_id, generation: row.generation, evidencePath: row.evidence_path } : null; }
|
|
102
|
+
export function activateRecoveryWork(context, workId) { context.db.run("UPDATE work_recovery_segments SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'prepared'", [context.now(), workId]); }
|
|
103
|
+
export function settleRecoveryWork(context, workId, status) { context.db.run("UPDATE work_recovery_segments SET status = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [status, context.now(), workId]); }
|
|
104
|
+
export function inspectRunRecovery(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const run = requireRun(context, project.id, input.runId); const guard = recoveryGuard(context, project.id, run.id); const works = context.db.all("SELECT w.work_id, w.status, s.recovery_id, s.status AS segment_status FROM works w LEFT JOIN work_recovery_segments s ON s.work_id = w.work_id AND s.generation = (SELECT MAX(generation) FROM work_recovery_segments latest WHERE latest.work_id = w.work_id) WHERE w.project_id = ? AND w.run_id = ? ORDER BY w.work_id", [project.id, run.id]); return { ok: true, run_id: run.id, recovery: guard ? { ...guard, interruption: JSON.parse(guard.interruption_json), settlement: guard.settlement_json ? JSON.parse(guard.settlement_json) : null } : null, works }; }
|
|
@@ -18,6 +18,7 @@ import { registerProject } from "./projects.js";
|
|
|
18
18
|
import { registerProtocol } from "./protocols.js";
|
|
19
19
|
import { previewNextEntityId } from "./ids.js";
|
|
20
20
|
import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
|
|
21
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
21
22
|
const stagePromptSections = [
|
|
22
23
|
"stage_identity",
|
|
23
24
|
"authoritative_runtime_facts",
|
|
@@ -119,6 +120,7 @@ export function startStage(context, input) {
|
|
|
119
120
|
if (!runId)
|
|
120
121
|
throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
|
|
121
122
|
const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
123
|
+
assertRunMutationAllowed(context, before.run.project_id, before.run.id);
|
|
122
124
|
const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
|
|
123
125
|
const attached = runView(attachFlowRunStage(context, {
|
|
124
126
|
projectRoot,
|
|
@@ -263,6 +265,7 @@ export function finishStage(context, input) {
|
|
|
263
265
|
throw new AppError("usage", "stage finish requires a RUN id", 2);
|
|
264
266
|
const runId = input.runId;
|
|
265
267
|
const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
268
|
+
assertRunMutationAllowed(context, view.run.project_id, view.run.id);
|
|
266
269
|
const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
|
|
267
270
|
if (!existing) {
|
|
268
271
|
throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
|
|
@@ -135,3 +135,43 @@ export async function reconcileVnextFanout(context, input) {
|
|
|
135
135
|
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_reconciliation", stage: input.stage, observations: value, issues, reconciled });
|
|
136
136
|
return { ok: true, issues, reconciled };
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Records every terminal native child at the Stage where it is first observed.
|
|
140
|
+
* This is separate from fan-out settlement: a historical child can be valid
|
|
141
|
+
* evidence for an earlier Work, while an unbound terminal child is retained
|
|
142
|
+
* as an inconsistency instead of being rediscovered in a later review wave.
|
|
143
|
+
*/
|
|
144
|
+
export function observeVnextNativeChildren(context, input) {
|
|
145
|
+
const value = input.observations;
|
|
146
|
+
if (!value || typeof value.harness_id !== "string" || !value.harness_id || typeof value.parent_session_id !== "string" || !Array.isArray(value.children)
|
|
147
|
+
|| value.children.some(child => !child || typeof child.session_id !== "string" || !["running", "unknown", "completed", "failed", "cancelled"].includes(String(child.status))))
|
|
148
|
+
throw new AppError("usage", "Expected native observations: harness_id, parent_session_id and children[{session_id,status}]", 2);
|
|
149
|
+
if (new Set(value.children.map(child => child.session_id)).size !== value.children.length)
|
|
150
|
+
throw new AppError("usage", "Native observations must contain each child Session exactly once", 2);
|
|
151
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
152
|
+
const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
|
|
153
|
+
const issues = [], history = [], pending = [];
|
|
154
|
+
for (const child of value.children) {
|
|
155
|
+
const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
|
|
156
|
+
const links = context.db.all(`SELECT ws.id, ws.work_id, ws.status, w.status AS work_status, w.parent_work_id, s.current_stage
|
|
157
|
+
FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id
|
|
158
|
+
JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
|
|
159
|
+
WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? AND s.parent_session_id = ? ORDER BY ws.created_at`, [project.id, input.runId, sessionId, parent]);
|
|
160
|
+
if (child.status === "running" || child.status === "unknown") {
|
|
161
|
+
pending.push({ session_id: child.session_id, outcome: child.status, links: links.map(link => ({ work_id: link.work_id, work_session_id: link.id })) });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!links.length) {
|
|
165
|
+
issues.push({ code: "unbound_native_delegation_detected", session_id: child.session_id, outcome: child.status, detected_at_stage: input.stage, origin_stage: "unknown" });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (links.length !== 1) {
|
|
169
|
+
issues.push({ code: "child_execution_ambiguous", session_id: child.session_id, outcome: child.status, detected_at_stage: input.stage, work_ids: links.map(link => link.work_id) });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const link = links[0];
|
|
173
|
+
history.push({ session_id: child.session_id, outcome: child.status, work_id: link.work_id, work_session_id: link.id, work_status: link.work_status, origin_stage: link.current_stage ?? "unknown", detected_at_stage: input.stage });
|
|
174
|
+
}
|
|
175
|
+
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_observation", stage: input.stage, observations: value, issues, history, pending });
|
|
176
|
+
return { ok: true, issues, history, pending };
|
|
177
|
+
}
|
|
@@ -2,20 +2,78 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
5
|
+
import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
6
6
|
import { validateSchema } from "./schema-validation.js";
|
|
7
7
|
import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
|
|
8
8
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
9
9
|
import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
|
|
10
10
|
import { nextWorkId, nextWorkIds } from "./ids.js";
|
|
11
11
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
12
|
+
import { appendAudit } from "./audit.js";
|
|
12
13
|
import { flowCommand } from "./stage-pause.js";
|
|
13
14
|
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
14
15
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
16
|
+
import { activateRecoveryWork, assertRunMutationAllowed, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
|
|
15
17
|
const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
|
|
16
18
|
export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
|
|
19
|
+
/**
|
|
20
|
+
* A Stage terminal command is allowed only from the Session that owns its
|
|
21
|
+
* running coordinator Work. A hook receipt proves the caller; a WorkSession
|
|
22
|
+
* proves the owner. Either fact alone is insufficient.
|
|
23
|
+
*/
|
|
24
|
+
export function assertStageLifecycleOwner(context, input) {
|
|
25
|
+
ensureWorkRegistry(context);
|
|
26
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
27
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
|
|
28
|
+
if (!project)
|
|
29
|
+
throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
|
|
30
|
+
requireRun(context, project.id, input.runId);
|
|
31
|
+
const owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id
|
|
32
|
+
FROM work_sessions ws
|
|
33
|
+
JOIN works w ON w.work_id = ws.work_id
|
|
34
|
+
JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
|
|
35
|
+
WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
|
|
36
|
+
AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
|
|
37
|
+
${input.workId ? "AND w.work_id = ?" : ""}
|
|
38
|
+
ORDER BY ws.created_at DESC LIMIT 1`, input.workId ? [project.id, input.runId, input.stage, input.workId] : [project.id, input.runId, input.stage]);
|
|
39
|
+
if (!owner)
|
|
40
|
+
throw new AppError("trusted_session_binding_required", "Stage lifecycle command has no running coordinator Work/Session binding", 1, { run_id: input.runId, stage: input.stage, ...(input.workId ? { work_id: input.workId } : {}) });
|
|
41
|
+
const identity = claimStageLifecycleHookEvent(context, {
|
|
42
|
+
projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
|
|
43
|
+
runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
|
|
44
|
+
});
|
|
45
|
+
if (identity.sessionId === owner.session_id)
|
|
46
|
+
return identity;
|
|
47
|
+
context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
|
|
48
|
+
const payload = { run_id: input.runId, stage: input.stage, work_id: owner.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
|
|
49
|
+
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "lifecycle_caller_mismatch", ...payload });
|
|
50
|
+
appendAudit(context, { projectId: project.id, eventType: "stage.lifecycle_caller_rejected", payload });
|
|
51
|
+
throw new AppError("lifecycle_caller_mismatch", "Stage lifecycle command was issued by a Session that does not own the running Work", 1, payload);
|
|
52
|
+
}
|
|
53
|
+
/** A Work terminal command belongs to the Session that started that Work.
|
|
54
|
+
* Controllers can still use their explicit non-agent operations; they cannot
|
|
55
|
+
* borrow a native worker's authority by naming its Work id. */
|
|
56
|
+
export function assertWorkLifecycleOwner(context, input) {
|
|
57
|
+
ensureWorkRegistry(context);
|
|
58
|
+
const work = requireWork(context, input.workId);
|
|
59
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
60
|
+
if (resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
61
|
+
throw new AppError("project_mismatch", "Work lifecycle project root does not match its RUN", 1, { work_id: work.work_id });
|
|
62
|
+
const owner = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
|
|
63
|
+
if (!owner)
|
|
64
|
+
throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
|
|
65
|
+
const identity = claimWorkLifecycleHookEvent(context, { projectId: work.project_id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation, workId: work.work_id, projectRoot: run.project_root });
|
|
66
|
+
if (identity.sessionId === owner.session_id)
|
|
67
|
+
return identity;
|
|
68
|
+
context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
|
|
69
|
+
const payload = { run_id: work.run_id, work_id: work.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
|
|
70
|
+
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_lifecycle_caller_mismatch", ...payload });
|
|
71
|
+
appendAudit(context, { projectId: work.project_id, eventType: "work.lifecycle_caller_rejected", payload });
|
|
72
|
+
throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
|
|
73
|
+
}
|
|
17
74
|
export function createChildWork(context, input) {
|
|
18
75
|
const parent = requireWork(context, input.parentWorkId);
|
|
76
|
+
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
19
77
|
if (parent.status !== "running")
|
|
20
78
|
throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
|
|
21
79
|
const id = nextWorkId(context, parent.project_id, input.slug);
|
|
@@ -46,6 +104,7 @@ export function validateWorkBatchFile(file) {
|
|
|
46
104
|
export function addWorkBatch(context, input) {
|
|
47
105
|
ensureWorkRegistry(context);
|
|
48
106
|
const parent = requireWork(context, input.parentWorkId);
|
|
107
|
+
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
49
108
|
if (parent.status !== "running")
|
|
50
109
|
throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
|
|
51
110
|
const parsed = readJson(input.file);
|
|
@@ -118,6 +177,7 @@ export function mutateWorkDeps(context, input) {
|
|
|
118
177
|
const work = requireWork(context, input.workId);
|
|
119
178
|
if (input.action === "list")
|
|
120
179
|
return { work_id: work.work_id, depends_on: parseDependencies(work) };
|
|
180
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
121
181
|
if (work.status !== "created")
|
|
122
182
|
throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
|
|
123
183
|
const dependencies = new Set(parseDependencies(work));
|
|
@@ -139,7 +199,7 @@ export function mutateWorkDeps(context, input) {
|
|
|
139
199
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
140
200
|
return { work_id: work.work_id, depends_on: [...dependencies] };
|
|
141
201
|
}
|
|
142
|
-
export function deleteWork(context, id) { const work = requireWork(context, id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
|
|
202
|
+
export function deleteWork(context, id) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
|
|
143
203
|
throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }
|
|
144
204
|
export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
145
205
|
/**
|
|
@@ -152,6 +212,7 @@ export function workStartCommand(context, work) { const run = requireRun(context
|
|
|
152
212
|
export function startWork(context, id, input) {
|
|
153
213
|
ensureWorkRegistry(context);
|
|
154
214
|
const work = requireWork(context, id);
|
|
215
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
155
216
|
if (work.status !== "created")
|
|
156
217
|
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
|
|
157
218
|
if (!isReady(context, work)) {
|
|
@@ -179,6 +240,7 @@ export function startWork(context, id, input) {
|
|
|
179
240
|
/** Binds the already-running coordinator of a stage to its trusted hook Session. */
|
|
180
241
|
export function bindRunningWorkSession(context, input) {
|
|
181
242
|
const work = requireWork(context, input.workId);
|
|
243
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
182
244
|
if (work.status !== "running")
|
|
183
245
|
throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
|
|
184
246
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
@@ -231,6 +293,7 @@ export function bindRunningWorkSession(context, input) {
|
|
|
231
293
|
/** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
|
|
232
294
|
export function bindStageCoordinatorWork(context, input) {
|
|
233
295
|
const work = requireWork(context, input.workId);
|
|
296
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
234
297
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
235
298
|
claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
236
299
|
const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
|
|
@@ -242,6 +305,7 @@ export function bindStageCoordinatorWork(context, input) {
|
|
|
242
305
|
/** Starts a created coordinator Work from its trusted `stage start` event. */
|
|
243
306
|
export function startStageCoordinatorWork(context, input) {
|
|
244
307
|
const work = requireWork(context, input.workId);
|
|
308
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
245
309
|
if (work.status !== "created")
|
|
246
310
|
throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
247
311
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
@@ -256,6 +320,7 @@ export function startStageCoordinatorWork(context, input) {
|
|
|
256
320
|
}
|
|
257
321
|
/** Stage entry points call this only after their own trusted hook claim. */
|
|
258
322
|
export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
323
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
259
324
|
if (work.status !== "created")
|
|
260
325
|
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
261
326
|
const now = context.now();
|
|
@@ -266,10 +331,13 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
266
331
|
const resultPath = path.join(directory, "result.json");
|
|
267
332
|
const contextPath = path.join(directory, "context.json");
|
|
268
333
|
const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
269
|
-
const
|
|
334
|
+
const recovery = recoveryPromptForWork(context, work.work_id);
|
|
335
|
+
let prompt = renderWorkerPrompt(context, work, run, dependencyResults);
|
|
336
|
+
if (recovery)
|
|
337
|
+
prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
|
|
270
338
|
const payload = parsePayload(work);
|
|
271
339
|
const readOnly = payload?.read_only === true;
|
|
272
|
-
const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
|
|
340
|
+
const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
|
|
273
341
|
const token = crypto.randomUUID();
|
|
274
342
|
const promptCandidate = `${promptPath}.${token}.tmp`;
|
|
275
343
|
const contextCandidate = `${contextPath}.${token}.tmp`;
|
|
@@ -294,17 +362,19 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
294
362
|
fs.rmSync(contextCandidate, { force: true });
|
|
295
363
|
throw error;
|
|
296
364
|
}
|
|
365
|
+
if (recovery)
|
|
366
|
+
activateRecoveryWork(context, work.work_id);
|
|
297
367
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
298
|
-
return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
|
|
368
|
+
return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
|
|
299
369
|
}
|
|
300
370
|
export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
|
|
301
371
|
/** Structured fan-in closes a parent after its Session was handed to a child. */
|
|
302
|
-
export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
|
|
372
|
+
export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
|
|
303
373
|
throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
|
|
304
374
|
throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
|
|
305
375
|
export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
|
|
306
376
|
export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
|
|
307
|
-
export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
|
|
377
|
+
export function retryWork(context, id, reason) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); id = work.work_id; if (work.status !== "failed")
|
|
308
378
|
throw new AppError("invalid_work_state", "Only failed Work may be retried", 2); const run = requireRun(context, work.project_id, work.run_id); const directory = path.join(requireRunHome(run), "works", id); const attempts = path.join(directory, "attempts"); const number = fs.existsSync(attempts) ? fs.readdirSync(attempts).filter((entry) => /^ATT-\d{3}$/.test(entry)).length + 1 : 1; const archive = path.join(attempts, `ATT-${String(number).padStart(3, "0")}`); fs.mkdirSync(archive, { recursive: true }); for (const file of ["prompt.md", "result.json", "context.json"]) {
|
|
309
379
|
const source = path.join(directory, file);
|
|
310
380
|
if (fs.existsSync(source))
|
|
@@ -312,6 +382,7 @@ export function retryWork(context, id, reason) { const work = requireWork(contex
|
|
|
312
382
|
} context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ?", [context.now(), id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, work_id: id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason }; }
|
|
313
383
|
async function settle(context, id, status, result, progress) {
|
|
314
384
|
const work = requireWork(context, id);
|
|
385
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
315
386
|
id = work.work_id;
|
|
316
387
|
if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
|
|
317
388
|
throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
|
|
@@ -367,6 +438,7 @@ async function settle(context, id, status, result, progress) {
|
|
|
367
438
|
context.db.exec("ROLLBACK");
|
|
368
439
|
throw error;
|
|
369
440
|
}
|
|
441
|
+
settleRecoveryWork(context, work.work_id, status);
|
|
370
442
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
371
443
|
const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
|
|
372
444
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
|
|
@@ -374,6 +446,7 @@ async function settle(context, id, status, result, progress) {
|
|
|
374
446
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
|
|
375
447
|
return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
|
|
376
448
|
}
|
|
449
|
+
function recoveryPrompt(recovery) { return ["<recovery_identity>", `- recovery_id: ${recovery.recoveryId}`, `- execution_generation: ${recovery.generation}`, "This Work was interrupted and is now an authorized recovery segment of the same assignment.", "</recovery_identity>", "", "<recovery_instructions>", `Read the retained pre-interruption evidence at ${recovery.evidencePath}.`, "Before changing files or rerunning a command, inspect the workspace, receipts, logs, and partial artifacts. Do not assume the last command succeeded or failed merely because its reply was lost.", "Preserve accepted work. Do not repeat completed Work, create undeclared Work, alter the original requirements, or edit dd-flow runtime state/evidence.", "Observe an existing check or finish instead of duplicating it. Retry only through an exact runtime retry command after establishing that its prior outcome permits it.", "Record the concrete unresolved operation rather than guessing if an external effect cannot be reconciled.", "</recovery_instructions>"].join("\n"); }
|
|
377
450
|
function plannedAreaDrift(packet, result) {
|
|
378
451
|
const changed = JSON.parse(result).changed_paths;
|
|
379
452
|
if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
|
package/dist/storage/database.js
CHANGED
|
@@ -7,6 +7,20 @@ import { AppError } from "../shared/errors.js";
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const { DatabaseSync } = require("node:sqlite");
|
|
9
9
|
const resourceDatabases = new Map();
|
|
10
|
+
/** Recovery guards apply to every RUN kind, including custom eval fixtures. */
|
|
11
|
+
export function ensureRecoveryStorage(db) {
|
|
12
|
+
db.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS run_recovery_guards (
|
|
14
|
+
recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
|
|
17
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
|
|
18
|
+
CREATE TABLE IF NOT EXISTS work_recovery_segments (
|
|
19
|
+
recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
|
|
22
|
+
`);
|
|
23
|
+
}
|
|
10
24
|
/** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
|
|
11
25
|
export function ensureVnextWorkStorage(db) {
|
|
12
26
|
const legacy = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('vnext_works', 'vnext_agent_turns', 'flow_agent_turns') LIMIT 1");
|
|
@@ -53,6 +67,16 @@ export function ensureVnextWorkStorage(db) {
|
|
|
53
67
|
);
|
|
54
68
|
CREATE INDEX IF NOT EXISTS idx_work_sessions_work ON work_sessions(work_id, created_at DESC);
|
|
55
69
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_sessions_open ON work_sessions(work_id) WHERE status = 'running';
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS run_recovery_guards (
|
|
72
|
+
recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
|
|
73
|
+
);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
|
|
75
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
|
|
76
|
+
CREATE TABLE IF NOT EXISTS work_recovery_segments (
|
|
77
|
+
recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
|
|
56
80
|
CREATE TABLE IF NOT EXISTS check_receipts (
|
|
57
81
|
id TEXT PRIMARY KEY,
|
|
58
82
|
project_id TEXT NOT NULL,
|
|
@@ -95,8 +119,10 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
95
119
|
configureDatabase(db, mode);
|
|
96
120
|
// Internal additive schema changes must be available to every write command.
|
|
97
121
|
// Higher-level Memory Bank migrations remain explicit in services/migrations.
|
|
98
|
-
if (mode !== "read_existing")
|
|
122
|
+
if (mode !== "read_existing") {
|
|
99
123
|
migrate(db, dbPath);
|
|
124
|
+
ensureRecoveryStorage(db);
|
|
125
|
+
}
|
|
100
126
|
if (mode === "read_existing")
|
|
101
127
|
db.exec("PRAGMA query_only = ON");
|
|
102
128
|
db.exec("PRAGMA foreign_keys = ON");
|