@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.111
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 +526 -0
- package/README.md +68 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +89 -13
- package/dist/cli/hook-ingress.js +77 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/native-hook-ingress.js +48 -0
- package/dist/cli/run-cli.js +1465 -384
- package/dist/cli.js +8 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +26 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +394 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +327 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +71 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +2 -2
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +3 -3
- package/dist/schemas/code-work-result.schema.json +3 -3
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +132 -39
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +589 -106
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +173 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +994 -97
- package/dist/services/execution-policy.js +181 -0
- package/dist/services/external-work-launch.js +166 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +702 -327
- package/dist/services/lanes.js +78 -65
- package/dist/services/lifecycle-command.js +113 -18
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1379 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +302 -34
- package/dist/services/merge-queue.js +228 -149
- package/dist/services/merge-server.js +66 -28
- package/dist/services/migrations.js +37 -16
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +26 -11
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +20 -15
- package/dist/services/prompts.js +25 -16
- package/dist/services/protocols.js +160 -86
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +142 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/review-copy.js +136 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +351 -0
- package/dist/services/run-control.js +888 -0
- package/dist/services/run-controller-adapter.js +231 -0
- package/dist/services/run-controller-capture.js +167 -0
- package/dist/services/run-controller-process.js +180 -0
- package/dist/services/run-controller-recovery.js +221 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +798 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +142 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-runtime.js +80 -0
- package/dist/services/run-recovery.js +345 -0
- package/dist/services/runs.js +536 -181
- package/dist/services/runtime-budget.js +413 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +52 -0
- package/dist/services/runtime-scope-control.js +460 -0
- package/dist/services/runtime-scope-resume.js +577 -0
- package/dist/services/runtime-scope-stop.js +159 -0
- package/dist/services/runtime-scope-worker.js +242 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/scope-writer-cleanup.js +26 -0
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +50 -19
- package/dist/services/stage-lifecycle.js +100 -103
- package/dist/services/stage-pause.js +88 -75
- package/dist/services/stage-report-renderer.js +116 -6
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +35 -98
- package/dist/services/vnext-code-review.js +197 -84
- package/dist/services/vnext-code.js +278 -251
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +172 -17
- package/dist/services/vnext-merge.js +345 -111
- package/dist/services/vnext-plan-review.js +171 -111
- package/dist/services/vnext-plan.js +180 -87
- package/dist/services/vnext-protocolize.js +320 -74
- package/dist/services/vnext-specify.js +115 -77
- package/dist/services/work-registry.js +1001 -187
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +88 -36
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +692 -35
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/paths.js +17 -4
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +85 -0
- package/package.json +24 -13
- package/tools/repair-paused-run-status.mjs +84 -0
|
@@ -2,14 +2,25 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { registerStoreWriter } from "../storage/writer-contract.js";
|
|
7
|
+
import { beginSnapshotImport, completeSnapshotImport } from "../storage/import-guard.js";
|
|
8
|
+
import { foreignKeyTransaction } from "../storage/foreign-key-transaction.js";
|
|
5
9
|
import { createContext } from "../runtime/context.js";
|
|
6
10
|
import { AppError } from "../shared/errors.js";
|
|
7
|
-
import { resolveProjectRoot } from "../storage/paths.js";
|
|
11
|
+
import { resolveProjectRoot, runtimeProtocolDir, runtimeRunDir } from "../storage/paths.js";
|
|
8
12
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
9
|
-
import { getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
13
|
+
import { freezeRunExecutionRouting, getFlowRunStatus, importFlowRunExecutionProfile, startFlowRun } from "./runs.js";
|
|
14
|
+
import { loadExecutionRoutingInput, resolveExecutionPolicy } from "./execution-policy.js";
|
|
10
15
|
import { readVnextFlowDefinition } from "./vnext-specify.js";
|
|
16
|
+
import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recovery-snapshot-database.js";
|
|
17
|
+
import { resourceHome } from "./managed-processes.js";
|
|
18
|
+
import { codexHomeSharedEntries } from "./hooks.js";
|
|
19
|
+
import { prepareOutputFile } from "../cli/input-preparation.js";
|
|
20
|
+
import { localEnvironmentAllowed, projectServicePathFilter } from "./workspace-files.js";
|
|
21
|
+
import { cleanupScopeWriters } from "./scope-writer-cleanup.js";
|
|
11
22
|
/** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
|
|
12
|
-
export function
|
|
23
|
+
export function prepareEvalBootstrapSnapshot(context, input) {
|
|
13
24
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
14
25
|
const output = path.resolve(input.output);
|
|
15
26
|
if (fs.existsSync(output))
|
|
@@ -18,44 +29,152 @@ export function createEvalBootstrapSnapshot(context, input) {
|
|
|
18
29
|
throw new AppError("snapshot_output_inside_project", "Bootstrap snapshot output must be outside the project tree", 1, { output });
|
|
19
30
|
if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
|
|
20
31
|
throw new AppError("snapshot_output_inside_runtime", "Bootstrap snapshot output must be outside DD_FLOW_HOME", 1, { output });
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
prepareOutputFile(output);
|
|
33
|
+
try {
|
|
34
|
+
prepareSnapshotProjectGit(projectRoot);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (!(error instanceof AppError) || error.code !== "snapshot_project_git_invalid")
|
|
38
|
+
throw error;
|
|
39
|
+
throw new AppError(error.code, error.message, 2, { ...error.details, phase: "prepare", effect: "no_effect", recoverable: true });
|
|
40
|
+
}
|
|
41
|
+
return { projectRoot, output };
|
|
42
|
+
}
|
|
43
|
+
export function createEvalBootstrapSnapshot(context, input) {
|
|
44
|
+
const { projectRoot, output } = prepareEvalBootstrapSnapshot(context, input);
|
|
45
|
+
const temporary = `${output}.partial-${crypto.randomUUID()}`;
|
|
46
|
+
const before = bootstrapSourceVersion(projectRoot);
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(temporary, { recursive: true });
|
|
49
|
+
fs.cpSync(projectRoot, path.join(temporary, "project"), { recursive: true, verbatimSymlinks: true, filter: projectPayloadFilter(projectRoot) });
|
|
50
|
+
const after = bootstrapSourceVersion(projectRoot);
|
|
51
|
+
if (before !== after)
|
|
52
|
+
throw new AppError("snapshot_source_changed", "Bootstrap source changed during snapshot capture", 1, { phase: "bootstrap", recoverable: true });
|
|
53
|
+
const manifest = {
|
|
54
|
+
schema_id: "dd-eval/bootstrap-snapshot@1",
|
|
55
|
+
stage: "specify",
|
|
56
|
+
project_root: projectRoot,
|
|
57
|
+
project_sha256: treeHash(path.join(temporary, "project")),
|
|
58
|
+
project_git: snapshotProjectGit(projectRoot, temporary)
|
|
59
|
+
};
|
|
60
|
+
writeJson(path.join(temporary, "bootstrap.json"), manifest);
|
|
61
|
+
if (after !== bootstrapSourceVersion(projectRoot))
|
|
62
|
+
throw new AppError("snapshot_source_changed", "Bootstrap source changed while preserving Git evidence", 1, { phase: "bootstrap", recoverable: true });
|
|
63
|
+
syncSnapshotTree(temporary);
|
|
64
|
+
fs.renameSync(temporary, output);
|
|
65
|
+
syncPath(path.dirname(output));
|
|
66
|
+
return { ok: true, snapshot: output, ...manifest };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function bootstrapSourceVersion(projectRoot) {
|
|
74
|
+
return JSON.stringify([
|
|
75
|
+
treeHash(projectRoot, true),
|
|
76
|
+
treeModesHash(projectRoot, true),
|
|
77
|
+
git(projectRoot, ["symbolic-ref", "--quiet", "HEAD"]),
|
|
78
|
+
gitBytes(projectRoot, ["show-ref", "--head"]).toString("base64"),
|
|
79
|
+
indexEntries(projectRoot).toString("base64")
|
|
80
|
+
]);
|
|
32
81
|
}
|
|
33
82
|
/** Restore a pre-RUN project state; `stage start --bootstrap` creates runtime state afterwards. */
|
|
34
|
-
export function
|
|
83
|
+
export function prepareEvalBootstrapRestore(input) {
|
|
35
84
|
const snapshot = path.resolve(input.snapshot);
|
|
36
85
|
const manifest = readBootstrapManifest(snapshot);
|
|
37
86
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
87
|
+
assertRestoreTargetSeparation(snapshot, [projectRoot]);
|
|
38
88
|
const sourceProject = path.join(snapshot, "project");
|
|
39
|
-
|
|
40
|
-
|
|
89
|
+
verifyGitSnapshotPayload(snapshot, { name: "project", hash: manifest.project_sha256, git: manifest.project_git });
|
|
90
|
+
return { snapshot, manifest, projectRoot, sourceProject };
|
|
91
|
+
}
|
|
92
|
+
export function restoreEvalBootstrapSnapshot(_context, input, prepared = prepareEvalBootstrapRestore(input)) {
|
|
93
|
+
const { snapshot, manifest, projectRoot, sourceProject } = prepared;
|
|
94
|
+
assertRestoreTargetSeparation(snapshot, [projectRoot]);
|
|
41
95
|
clearProjectTree(projectRoot, false);
|
|
42
96
|
restoreProjectGit(snapshot, manifest.project_git, projectRoot);
|
|
43
97
|
replaceProjectTree(projectRoot, sourceProject);
|
|
44
98
|
return { ok: true, schema_id: "dd-eval/bootstrap-restore@1", snapshot, project_root: projectRoot, target_stage: "specify" };
|
|
45
99
|
}
|
|
46
100
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* to infer a safe subset of SQLite rows.
|
|
101
|
+
* Eval fixtures require a dedicated home. Sealed recovery exports instead
|
|
102
|
+
* retain only the selected RUN's relational and filesystem evidence.
|
|
50
103
|
*/
|
|
51
104
|
export function createEvalRunSnapshot(context, input) {
|
|
105
|
+
const { output } = prepareEvalRunSnapshotCreation(context, input);
|
|
106
|
+
const temporary = `${output}.partial-${crypto.randomUUID()}`;
|
|
107
|
+
let sourceVersion = null;
|
|
108
|
+
try {
|
|
109
|
+
input.boundary?.verify();
|
|
110
|
+
sourceVersion = input.boundary ? snapshotSourceVersion(context, input) : null;
|
|
111
|
+
const result = captureEvalRunSnapshot(context, { ...input, output: temporary });
|
|
112
|
+
if (input.boundary && sourceVersion) {
|
|
113
|
+
Object.assign(result, { consistency: "controller_boundary_writer_barrier@1", boundary_capture: { controller_id: input.boundary.controllerId, operation_id: input.boundary.operationId, boundary_key: input.boundary.boundaryKey, source_sha256: sourceVersion.sha256, observation: input.boundary.observation } });
|
|
114
|
+
writeJson(path.join(temporary, "snapshot.json"), Object.fromEntries(Object.entries(result).filter(([key]) => key !== "ok" && key !== "snapshot")));
|
|
115
|
+
}
|
|
116
|
+
if (input.recoveryId) {
|
|
117
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
118
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
119
|
+
const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
120
|
+
verifyRecoverySnapshot(temporary, { runId: input.runId, recoveryId: input.recoveryId, projectRoot, workspaceRoot: run?.workspace_root ?? projectRoot });
|
|
121
|
+
}
|
|
122
|
+
if (fs.existsSync(output))
|
|
123
|
+
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
124
|
+
syncSnapshotTree(temporary);
|
|
125
|
+
if (input.boundary) {
|
|
126
|
+
if (sourceVersion) {
|
|
127
|
+
let after;
|
|
128
|
+
try {
|
|
129
|
+
after = snapshotSourceVersion(context, input, sourceVersion.databases);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error.code !== "ENOENT")
|
|
133
|
+
throw error;
|
|
134
|
+
const missing = error.path;
|
|
135
|
+
throw new AppError("snapshot_source_changed", "Snapshot source disappeared during boundary capture", 1, { components: [missing && path.isAbsolute(missing) && isInside(context.ddFlowHome, missing) ? `runtime/${path.relative(context.ddFlowHome, missing)}` : "source_path_disappeared"] });
|
|
136
|
+
}
|
|
137
|
+
if (sourceVersion.sha256 !== after.sha256)
|
|
138
|
+
throw new AppError("snapshot_source_changed", "Snapshot source files or Git state changed during boundary capture", 1, { components: snapshotSourceChanges(sourceVersion.entries, after.entries), changes: snapshotSourceChangeProofs(sourceVersion.entries, after.entries) });
|
|
139
|
+
}
|
|
140
|
+
input.boundary.verify();
|
|
141
|
+
}
|
|
142
|
+
fs.renameSync(temporary, output);
|
|
143
|
+
syncPath(path.dirname(output));
|
|
144
|
+
return { ...result, snapshot: output };
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
for (const db of sourceVersion?.databases.values() ?? [])
|
|
152
|
+
db.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export function prepareEvalRunSnapshotCreation(context, input) {
|
|
156
|
+
if (Number(Boolean(input.candidate)) + Number(Boolean(input.incomplete)) + Number(input.stageEntry !== undefined) + Number(input.recoveryId !== undefined) !== 1)
|
|
157
|
+
throw new AppError("usage", "Use exactly one of candidate, incomplete, recoveryId or stageEntry", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
|
|
52
158
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
53
159
|
// Snapshot is read-only at the control-plane boundary. An invalid workspace
|
|
54
160
|
// root must fail without registering a second project in a dedicated home.
|
|
55
161
|
const project = requireProjectByRoot(context, projectRoot);
|
|
56
162
|
const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
|
|
57
|
-
|
|
58
|
-
if (input.
|
|
163
|
+
const recovery = typeof input.recoveryId === "string";
|
|
164
|
+
if (!recovery && !input.incomplete && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'")) {
|
|
165
|
+
const controller = context.db.get("SELECT controller_id,status,owner_pid,owner_token FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded','stop_target_reached')", [project.id, input.runId]);
|
|
166
|
+
if (controller && (controller.status !== "capturing" || controller.controller_id !== input.boundary?.controllerId || controller.owner_pid !== process.pid || controller.owner_token !== context.env.DD_FLOW_CONTROLLER_TOKEN))
|
|
167
|
+
throw new AppError("snapshot_controller_capture_required", "A live managed RUN must be captured by its fenced controller, not an external observer", 1, { controller_id: controller.controller_id });
|
|
168
|
+
}
|
|
169
|
+
if (!recovery)
|
|
170
|
+
assertDedicatedHome(context, project.id, input.runId);
|
|
171
|
+
if (recovery) {
|
|
172
|
+
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]);
|
|
173
|
+
if (!guard || guard.status !== "sealed")
|
|
174
|
+
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 });
|
|
175
|
+
}
|
|
176
|
+
if (input.incomplete || recovery) { /* Recovery captures only after the runner seals all writers. */ }
|
|
177
|
+
else if (input.candidate)
|
|
59
178
|
assertTerminalCandidate(status.index.stage_runs ?? []);
|
|
60
179
|
else if (input.stageEntry)
|
|
61
180
|
assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
|
|
@@ -65,7 +184,7 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
65
184
|
const activeChildren = input.candidate
|
|
66
185
|
? 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
|
|
67
186
|
: 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;
|
|
68
|
-
if (activeChildren > 0)
|
|
187
|
+
if (!input.incomplete && !recovery && activeChildren > 0)
|
|
69
188
|
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 });
|
|
70
189
|
const output = path.resolve(input.output);
|
|
71
190
|
if (fs.existsSync(output))
|
|
@@ -75,36 +194,90 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
75
194
|
const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
|
|
76
195
|
if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
|
|
77
196
|
throw new AppError("snapshot_output_inside_project", "Snapshot output must be outside the project and RUN workspace trees", 1, { output });
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
});
|
|
197
|
+
prepareOutputFile(output);
|
|
198
|
+
prepareSnapshotProjectGit(projectRoot);
|
|
199
|
+
prepareSnapshotWorkspace(workspaceRoot, projectRoot);
|
|
200
|
+
return { projectRoot, project, status, recovery, output, workspaceRoot, activeChildren };
|
|
201
|
+
}
|
|
202
|
+
function captureEvalRunSnapshot(context, input) {
|
|
203
|
+
const { projectRoot, project, status, recovery, output, workspaceRoot, activeChildren } = prepareEvalRunSnapshotCreation(context, input);
|
|
204
|
+
fs.mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
205
|
+
const resourceDatabase = path.join(context.ddFlowHome, "runtime.sqlite");
|
|
206
|
+
const databaseFiles = [context.db.path, resourceDatabase].flatMap(file => [file, `${file}-wal`, `${file}-shm`]);
|
|
207
|
+
const codexExclusions = codexHomeSnapshotExclusions(context, project.id, input.runId);
|
|
208
|
+
if (recovery) {
|
|
209
|
+
if (typeof status.run.run_root !== "string" || !isInside(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)) || samePath(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)))
|
|
210
|
+
throw new AppError("snapshot_run_scope_invalid", "Recovery requires a RUN artifact root within its runtime", 1);
|
|
211
|
+
for (const source of new Set([status.run.run_root, runtimeRunDir(context.ddFlowHome, project.id, input.runId)])) {
|
|
212
|
+
if (fs.existsSync(source))
|
|
213
|
+
copyRuntimeTree(source, path.join(output, "runtime", path.relative(context.ddFlowHome, source)), true, codexExclusions);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else
|
|
217
|
+
copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions], grokProviderHomes(context.ddFlowHome));
|
|
218
|
+
// VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
|
|
219
|
+
// pages. Copying the live database and its WAL separately is not atomic.
|
|
220
|
+
const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
|
|
221
|
+
fs.mkdirSync(path.dirname(databaseCopy), { recursive: true });
|
|
222
|
+
if (recovery) {
|
|
223
|
+
const report = typeof status.run.run_root === "string" ? path.join(output, "runtime", path.relative(context.ddFlowHome, status.run.run_root), "02-protocolize", "stage-report.json") : null;
|
|
224
|
+
if (report && !isInside(physicalPath(path.join(output, "runtime")), physicalPath(report)))
|
|
225
|
+
throw new AppError("snapshot_protocol_scope_invalid", "Recovery protocol evidence is outside the captured runtime", 1);
|
|
226
|
+
const protocols = report && fs.existsSync(report) ? JSON.parse(fs.readFileSync(report, "utf8")).semantic?.acceptance : [];
|
|
227
|
+
if (!Array.isArray(protocols) || !protocols.every(id => typeof id === "string" && id.startsWith("PRT-")))
|
|
228
|
+
throw new AppError("snapshot_protocol_scope_invalid", "Recovery export requires the accepted PROTOCOLIZE protocol IDs", 1);
|
|
229
|
+
captureRecoveryDatabase(context.db, databaseCopy, project.id, input.runId, protocols);
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
context.db.exec(`VACUUM INTO '${databaseCopy.replace(/'/g, "''")}'`);
|
|
233
|
+
// Resource heartbeats can continue after productive RUN writers are sealed.
|
|
234
|
+
// Keep their committed evidence consistent too; never copy a live WAL pair.
|
|
235
|
+
if (!recovery && fs.existsSync(resourceDatabase)) {
|
|
236
|
+
const resources = new DatabaseSync(resourceDatabase, { readOnly: true });
|
|
237
|
+
try {
|
|
238
|
+
const resourceCopy = path.join(output, "runtime", "runtime.sqlite");
|
|
239
|
+
resources.exec("PRAGMA busy_timeout = 4000");
|
|
240
|
+
resources.exec(`VACUUM INTO '${resourceCopy.replace(/'/g, "''")}'`);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
resources.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
88
246
|
fs.cpSync(projectRoot, path.join(output, "project"), {
|
|
89
247
|
recursive: true,
|
|
90
248
|
verbatimSymlinks: true,
|
|
91
|
-
filter: (
|
|
249
|
+
filter: projectPayloadFilter(projectRoot)
|
|
92
250
|
});
|
|
93
251
|
const projectGit = snapshotProjectGit(projectRoot, output);
|
|
94
252
|
const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
|
|
253
|
+
// Accepted Work results are captured in SQLite. Their file representations
|
|
254
|
+
// are retained evidence, not a second authority or a checkpoint gate.
|
|
255
|
+
if (recovery) {
|
|
256
|
+
const copiedContext = createContext({ ...context.env, DD_FLOW_HOME: path.join(output, "runtime") }, "read_existing");
|
|
257
|
+
try {
|
|
258
|
+
captureRecoveryAuxiliary(context, copiedContext, { projectId: project.id, runId: input.runId, output });
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
copiedContext.db.close?.();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
95
264
|
const manifest = {
|
|
96
265
|
schema_id: "dd-flow/eval-run-snapshot@5",
|
|
97
266
|
run_id: input.runId,
|
|
98
267
|
project_id: project.id,
|
|
99
268
|
project_root: projectRoot,
|
|
100
269
|
dd_flow_home: context.ddFlowHome,
|
|
101
|
-
purpose: input.candidate ? "candidate" : "stage_entry",
|
|
102
|
-
stage_entry: input.candidate ? null : input.stageEntry,
|
|
270
|
+
purpose: input.incomplete ? "incomplete" : recovery ? "recovery" : input.candidate ? "candidate" : "stage_entry",
|
|
271
|
+
stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
|
|
272
|
+
...(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" } : {}),
|
|
103
273
|
created_at: context.now(),
|
|
104
274
|
runtime_sha256: treeHash(path.join(output, "runtime")),
|
|
105
275
|
project_sha256: treeHash(path.join(output, "project")),
|
|
276
|
+
project_payload_policy: "service-files@1",
|
|
277
|
+
...(!recovery ? { runtime_payload_policy: "flow-evidence@1" } : {}),
|
|
106
278
|
project_git: projectGit,
|
|
107
279
|
workspace,
|
|
280
|
+
payload_modes: { project: treeModesHash(path.join(output, "project")), runtime: treeModesHash(path.join(output, "runtime")), ...(workspace.kind === "git_worktree" ? { workspace: treeModesHash(path.join(output, "workspace")) } : {}) },
|
|
108
281
|
source_status: status
|
|
109
282
|
};
|
|
110
283
|
writeJson(path.join(output, "snapshot.json"), manifest);
|
|
@@ -118,23 +291,111 @@ function createdWorkSchemaForStage(stage) {
|
|
|
118
291
|
return "dd-flow/merge-result";
|
|
119
292
|
return null;
|
|
120
293
|
}
|
|
121
|
-
|
|
122
|
-
|
|
294
|
+
/** Validate sealed source data before any target cleanup or copy. */
|
|
295
|
+
export function prepareEvalRunSnapshot(snapshot) {
|
|
123
296
|
const manifest = readManifest(snapshot);
|
|
297
|
+
if (manifest.purpose === "incomplete")
|
|
298
|
+
throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
|
|
124
299
|
if (manifest.purpose === "candidate")
|
|
125
300
|
throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
|
|
126
|
-
|
|
301
|
+
verifySnapshotPayload(snapshot, manifest);
|
|
302
|
+
if (manifest.purpose === "stage_entry") {
|
|
303
|
+
const db = new DatabaseSync(path.join(snapshot, "runtime", "db.sqlite"), { readOnly: true });
|
|
304
|
+
try {
|
|
305
|
+
assertSnapshotRuntimeDetachAllowed(sql => db.prepare(sql).get());
|
|
306
|
+
const row = db.prepare("SELECT index_json FROM runs WHERE id = ?").get(manifest.run_id);
|
|
307
|
+
if (!row)
|
|
308
|
+
throw new AppError("snapshot_invalid", "Snapshot RUN is not registered", 1);
|
|
309
|
+
const index = JSON.parse(row.index_json);
|
|
310
|
+
if (!Array.isArray(index?.stage_runs))
|
|
311
|
+
throw new AppError("snapshot_invalid", "Snapshot RUN index is invalid", 1);
|
|
312
|
+
assertStageEntry(index.stage_runs, manifest.stage_entry);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
db.close();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return manifest;
|
|
319
|
+
}
|
|
320
|
+
export function prepareEvalRunRestore(context, input, preparedManifest) {
|
|
321
|
+
const snapshot = path.resolve(input.snapshot);
|
|
322
|
+
const manifest = preparedManifest ?? prepareEvalRunSnapshot(snapshot);
|
|
323
|
+
if (manifest.purpose === "incomplete")
|
|
324
|
+
throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
|
|
325
|
+
const recovery = manifest.purpose === "recovery";
|
|
326
|
+
if (recovery && input.executionRoutingFile !== undefined)
|
|
327
|
+
throw new AppError("snapshot_recovery_policy_frozen", "Recovery must retain its frozen execution policy", 2);
|
|
328
|
+
if (recovery && input.recoveryId !== manifest.recovery_id)
|
|
329
|
+
throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require their explicit --recovery-id", 1, { snapshot });
|
|
330
|
+
if (!recovery && input.recoveryId)
|
|
331
|
+
throw new AppError("snapshot_invalid", "Recovery ID cannot restore a non-recovery snapshot", 1, { snapshot });
|
|
332
|
+
if (manifest.purpose === "candidate")
|
|
333
|
+
throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
|
|
334
|
+
if (!recovery && !manifest.stage_entry)
|
|
127
335
|
throw new AppError("snapshot_invalid", "Stage-entry snapshot has no target stage", 1, { snapshot });
|
|
128
336
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
337
|
+
assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest);
|
|
338
|
+
if (recovery)
|
|
339
|
+
verifyRecoverySnapshot(snapshot, { runId: manifest.run_id, recoveryId: input.recoveryId, projectRoot, workspaceRoot: projectRoot }, false);
|
|
132
340
|
const sourceRuntime = path.join(snapshot, "runtime");
|
|
133
341
|
const sourceProject = path.join(snapshot, "project");
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
342
|
+
const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
|
|
343
|
+
let selectedProfile;
|
|
344
|
+
if (routingInput) {
|
|
345
|
+
// Resolve selected profiles before replacing the caller's fresh runtime.
|
|
346
|
+
const sourceDb = new DatabaseSync(path.join(sourceRuntime, "db.sqlite"), { readOnly: true });
|
|
347
|
+
try {
|
|
348
|
+
const row = sourceDb.prepare("SELECT index_json FROM runs WHERE id = ?").get(manifest.run_id);
|
|
349
|
+
const index = row ? JSON.parse(row.index_json) : null;
|
|
350
|
+
if (!index?.execution_profile || !["vnext_specify", "vnext_protocolize"].includes(index.flow_kind))
|
|
351
|
+
throw new AppError("execution_profile_not_frozen", "Stage-entry routing requires a vNext execution snapshot", 1);
|
|
352
|
+
assertStageEntry(index.stage_runs, manifest.stage_entry);
|
|
353
|
+
selectedProfile = freezeRunExecutionRouting(context.ddFlowHome, index.execution_profile, routingInput);
|
|
354
|
+
for (const row of sourceDb.prepare("SELECT payload_json FROM works WHERE run_id = ? AND status = 'created'").all(manifest.run_id)) {
|
|
355
|
+
const payload = row.payload_json ? JSON.parse(row.payload_json) : null;
|
|
356
|
+
if (payload?.execution_override !== undefined)
|
|
357
|
+
resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: routingInput.execution, profiles: selectedProfile.agent_profiles, stage: manifest.stage_entry, work: payload.execution_override });
|
|
358
|
+
}
|
|
359
|
+
selectedProfile.routing_input.artifact = `execution-routing.${routingInput.source_checksum}.input.json`;
|
|
360
|
+
const sourceRun = sourceDb.prepare("SELECT run_root FROM runs WHERE id = ?").get(manifest.run_id);
|
|
361
|
+
if (typeof sourceRun?.run_root !== "string" || !path.isAbsolute(sourceRun.run_root) || !isInside(manifest.dd_flow_home, sourceRun.run_root))
|
|
362
|
+
throw new AppError("snapshot_invalid", "Snapshot RUN root must be inside its runtime home", 1);
|
|
363
|
+
const existingRouting = path.join(sourceRuntime, path.relative(manifest.dd_flow_home, sourceRun.run_root), selectedProfile.routing_input.artifact);
|
|
364
|
+
if (fs.existsSync(existingRouting) && !fs.readFileSync(existingRouting).equals(routingInput.bytes))
|
|
365
|
+
throw new AppError("snapshot_checksum_mismatch", "Existing routing input does not match its checksum", 1);
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
sourceDb.close();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile };
|
|
372
|
+
}
|
|
373
|
+
function assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest) {
|
|
374
|
+
assertRestoreTargetSeparation(snapshot, [projectRoot, context.ddFlowHome]);
|
|
375
|
+
if (manifest.workspace.kind === "git_worktree") {
|
|
376
|
+
const workspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, context.ddFlowHome);
|
|
377
|
+
assertRestoreTargetSeparation(snapshot, [workspaceRoot]);
|
|
378
|
+
if (isInside(physicalPath(projectRoot), physicalPath(workspaceRoot)) || isInside(physicalPath(workspaceRoot), physicalPath(projectRoot)) || physicalPath(workspaceRoot) === physicalPath(context.ddFlowHome))
|
|
379
|
+
throw new AppError("snapshot_path_conflict", "Feature workspace overlaps a restore target", 2);
|
|
380
|
+
if (fs.existsSync(workspaceRoot))
|
|
381
|
+
throw new AppError("snapshot_workspace_target_exists", "Snapshot feature workspace target already exists", 1, { workspace_root: workspaceRoot });
|
|
382
|
+
}
|
|
383
|
+
if (manifest.purpose === "recovery") {
|
|
384
|
+
if (fs.readdirSync(projectRoot).length || (context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0) > 0)
|
|
385
|
+
throw new AppError("recovery_restore_target_not_empty", "Recovery restore requires fresh project and runtime targets", 1);
|
|
386
|
+
}
|
|
387
|
+
const existing = (fs.existsSync(context.ddFlowHome) ? fs.readdirSync(context.ddFlowHome) : []).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm", ...(input.executionRoutingFile !== undefined ? ["agent-profiles"] : [])].includes(name));
|
|
388
|
+
if (existing.length > 0)
|
|
389
|
+
throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
|
|
390
|
+
if (context.db.get("SELECT 1 FROM projects LIMIT 1") || context.db.get("SELECT 1 FROM runs LIMIT 1"))
|
|
391
|
+
throw new AppError("restore_home_not_empty", "Snapshot restore cannot replace an initialized runtime", 1);
|
|
392
|
+
}
|
|
393
|
+
export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRunRestore(context, input), prearmedImportId) {
|
|
394
|
+
const { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile } = prepared;
|
|
395
|
+
assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest);
|
|
396
|
+
const sourceManifestSha256 = fileHash(path.join(snapshot, "snapshot.json"));
|
|
397
|
+
const importId = prearmedImportId ?? beginSnapshotImport({ home: context.ddFlowHome, projectRoot,
|
|
398
|
+
workspaceRoot: restoredWorkspaceRoot(manifest, projectRoot, context.ddFlowHome), sourceManifestSha256 });
|
|
138
399
|
clearProjectTree(projectRoot, false);
|
|
139
400
|
restoreProjectGit(snapshot, manifest.project_git, projectRoot);
|
|
140
401
|
replaceProjectTree(projectRoot, sourceProject);
|
|
@@ -144,17 +405,46 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
144
405
|
const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
|
|
145
406
|
// The dispatch context was intentionally closed before replacing db.sqlite.
|
|
146
407
|
// A fresh process owns all subsequent normal commands.
|
|
147
|
-
const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
|
|
408
|
+
const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome }, "initialize", importId);
|
|
409
|
+
let result, primary, failed = false;
|
|
148
410
|
try {
|
|
149
|
-
|
|
411
|
+
const runtimeLineage = recovery ? null : detachSnapshotRuntimeOwners(restored, snapshot, manifest);
|
|
412
|
+
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome, recovery);
|
|
150
413
|
registerProject(restored, { root: projectRoot });
|
|
151
414
|
requireProjectByRoot(restored, projectRoot);
|
|
415
|
+
if (selectedProfile && routingInput && runtimeLineage) {
|
|
416
|
+
const run = restored.db.get("SELECT run_root FROM runs WHERE id = ?", [manifest.run_id]);
|
|
417
|
+
const artifact = path.join(run.run_root, selectedProfile.routing_input.artifact);
|
|
418
|
+
if (fs.existsSync(artifact)) {
|
|
419
|
+
if (!fs.readFileSync(artifact).equals(routingInput.bytes))
|
|
420
|
+
throw new AppError("snapshot_checksum_mismatch", "Existing routing input does not match its checksum", 1);
|
|
421
|
+
}
|
|
422
|
+
else
|
|
423
|
+
fs.writeFileSync(artifact, routingInput.bytes, { flag: "wx", mode: 0o600 });
|
|
424
|
+
importFlowRunExecutionProfile(restored, { projectRoot, runId: manifest.run_id, stage: manifest.stage_entry, lineageFile: runtimeLineage, profile: selectedProfile });
|
|
425
|
+
}
|
|
152
426
|
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
153
|
-
|
|
427
|
+
if (!recovery)
|
|
428
|
+
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
429
|
+
else
|
|
430
|
+
restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
|
|
154
431
|
if (!status.run.run_root)
|
|
155
432
|
throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
|
|
156
433
|
const runHome = status.run.run_root;
|
|
157
|
-
|
|
434
|
+
const relocation = recovery ? {
|
|
435
|
+
schema_id: "dd-flow/recovery-relocation@1", physical_runtime_id: crypto.randomUUID(), recovery_id: input.recoveryId, run_id: manifest.run_id,
|
|
436
|
+
path_mapping: [{ from: manifest.dd_flow_home, to: context.ddFlowHome }, { from: sourceWorkspaceRoot(manifest), to: workspaceRoot }, { from: manifest.project_root, to: projectRoot }]
|
|
437
|
+
} : null;
|
|
438
|
+
const relocationFile = relocation ? path.join(context.ddFlowHome, "recovery-imports", `${relocation.physical_runtime_id}.json`) : null;
|
|
439
|
+
if (relocationFile) {
|
|
440
|
+
fs.mkdirSync(path.dirname(relocationFile), { recursive: true });
|
|
441
|
+
writeJson(relocationFile, relocation);
|
|
442
|
+
}
|
|
443
|
+
const expectedWorkspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, context.ddFlowHome);
|
|
444
|
+
if (physicalPath(workspaceRoot) !== physicalPath(expectedWorkspaceRoot))
|
|
445
|
+
throw new AppError("snapshot_workspace_restore_failed", "Restored workspace differs from the import target", 1, { workspace_root: workspaceRoot, expected_workspace_root: expectedWorkspaceRoot });
|
|
446
|
+
completeSnapshotImport(context.ddFlowHome, importId, { projectRoot, workspaceRoot: expectedWorkspaceRoot, sourceManifestSha256 });
|
|
447
|
+
result = {
|
|
158
448
|
ok: true,
|
|
159
449
|
schema_id: "dd-flow/eval-run-restore@1",
|
|
160
450
|
snapshot,
|
|
@@ -164,12 +454,24 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
164
454
|
dd_flow_home: context.ddFlowHome,
|
|
165
455
|
run_home: runHome,
|
|
166
456
|
target_stage: manifest.stage_entry,
|
|
167
|
-
|
|
457
|
+
...(runtimeLineage ? { runtime_lineage_file: runtimeLineage } : {}),
|
|
458
|
+
...(recovery ? { recovery_id: input.recoveryId, native_session_portability: "requires_adapter_verification", relocation_file: relocationFile, relocation } : {}),
|
|
459
|
+
next_command: recovery ? `dd-flow run recovery inspect ${status.run.id} --project-root ${JSON.stringify(projectRoot)} --json` : `dd-flow stage start ${status.run.id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
|
|
168
460
|
};
|
|
169
461
|
}
|
|
170
|
-
|
|
171
|
-
|
|
462
|
+
catch (error) {
|
|
463
|
+
primary = error;
|
|
464
|
+
failed = true;
|
|
172
465
|
}
|
|
466
|
+
const cleanup = cleanupScopeWriters([{ name: restored.ddFlowHome, db: restored.db, rollback: false, close: "always" }]);
|
|
467
|
+
if (failed) {
|
|
468
|
+
if (primary instanceof AppError && cleanup.length)
|
|
469
|
+
primary.details.import_cleanup_errors = cleanup;
|
|
470
|
+
throw primary;
|
|
471
|
+
}
|
|
472
|
+
if (cleanup.length)
|
|
473
|
+
throw new AppError("snapshot_import_cleanup_failed", "Snapshot imported but its connection could not be closed", 1, { effect: "committed", import_complete: true, import_cleanup_errors: cleanup });
|
|
474
|
+
return result;
|
|
173
475
|
}
|
|
174
476
|
export function prepareVnextSpecifyRun(context, input) {
|
|
175
477
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
@@ -182,6 +484,7 @@ export function prepareVnextSpecifyRun(context, input) {
|
|
|
182
484
|
subjectType: "discussion",
|
|
183
485
|
subjectId: "SPECIFY",
|
|
184
486
|
slug: input.slug,
|
|
487
|
+
executionRoutingFile: input.executionRoutingFile,
|
|
185
488
|
nextAction: "start_specify"
|
|
186
489
|
});
|
|
187
490
|
return { ok: true, run_id: started.run.id, run_root: started.run.run_root, target_stage: "specify" };
|
|
@@ -196,19 +499,91 @@ function assertDedicatedHome(context, projectId, runId) {
|
|
|
196
499
|
function assertStageEntry(stages, target) {
|
|
197
500
|
if (stages.some((entry) => entry.stage === target))
|
|
198
501
|
throw new AppError("snapshot_target_started", "Stage-entry snapshot requires its target stage to be unstarted", 1, { target });
|
|
199
|
-
const unfinished = stages.filter((entry) => entry.status
|
|
502
|
+
const unfinished = stages.filter((entry) => !["done", "skipped"].includes(entry.status ?? ""));
|
|
200
503
|
if (unfinished.length > 0)
|
|
201
504
|
throw new AppError("snapshot_predecessor_incomplete", "Stage-entry snapshot requires completed predecessor stages", 1, { target, unfinished });
|
|
202
505
|
}
|
|
203
506
|
function assertTerminalCandidate(stages) {
|
|
204
|
-
const unfinished = stages.filter((entry) => entry.status
|
|
507
|
+
const unfinished = stages.filter((entry) => !["done", "skipped"].includes(entry.status ?? ""));
|
|
205
508
|
if (unfinished.length > 0)
|
|
206
509
|
throw new AppError("snapshot_candidate_incomplete", "Candidate snapshot requires every started stage to be done", 1, { unfinished });
|
|
207
510
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
511
|
+
/** A stage-entry import is a new experiment, not a lease takeover or recovery.
|
|
512
|
+
* Keep source authority as evidence, never as executable target inventory. */
|
|
513
|
+
function assertSnapshotRuntimeDetachAllowed(get) {
|
|
514
|
+
if (get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_recovery_guards'") && get("SELECT 1 FROM run_recovery_guards WHERE status IN ('draining','sealed','resuming') LIMIT 1"))
|
|
515
|
+
throw new AppError("snapshot_control_requires_recovery", "A fenced RUN requires explicit recovery, not a stage-entry fork", 1);
|
|
516
|
+
if (get("SELECT 1 FROM merge_requests WHERE status IN ('dispatching','active','waiting_user','action_required','recovery_required') LIMIT 1"))
|
|
517
|
+
throw new AppError("snapshot_execution_active", "A stage-entry fork cannot discard an unresolved MERGE execution", 1);
|
|
518
|
+
}
|
|
519
|
+
function detachSnapshotRuntimeOwners(context, snapshot, manifest) {
|
|
520
|
+
const hasTable = (table) => Boolean(context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", [table]));
|
|
521
|
+
assertSnapshotRuntimeDetachAllowed(sql => context.db.get(sql));
|
|
522
|
+
const detachedTables = ["lifecycle_packet_assignments", "lifecycle_invocations", "run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers", "run_controls", "run_controller_events", "run_controller_operations", "run_controllers", "run_recovery_bindings", "work_recovery_segments", "run_recovery_guards", "work_launches", "merge_servers", "merge_sessions", "lane_locks", "lane_waiters"].filter(hasTable);
|
|
523
|
+
const runTables = Object.fromEntries([...detachedTables, "runs", "merge_requests", "sessions", "work_sessions"].map(table => [table, context.db.all(`SELECT * FROM ${table}`)]));
|
|
524
|
+
const resourceFile = path.join(context.ddFlowHome, "runtime.sqlite");
|
|
525
|
+
const resources = fs.existsSync(resourceFile) ? new DatabaseSync(resourceFile) : null;
|
|
526
|
+
let resourceHeld = false, resourceCommitted = false, result, primary, failed = false;
|
|
211
527
|
try {
|
|
528
|
+
if (resources)
|
|
529
|
+
registerStoreWriter(resources);
|
|
530
|
+
const resourceTables = Object.fromEntries(["managed_resources", "managed_processes"].filter(table => resources?.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table)).map(table => [table, resources.prepare(`SELECT * FROM ${table}`).all()]));
|
|
531
|
+
const directory = path.join(context.ddFlowHome, "snapshot-imports");
|
|
532
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
533
|
+
const file = path.join(directory, `${crypto.randomUUID()}.json`);
|
|
534
|
+
fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/snapshot-runtime-lineage@1", source_snapshot: snapshot, source_manifest_sha256: fileHash(path.join(snapshot, "snapshot.json")), source_runtime: manifest.dd_flow_home, restored_runtime: context.ddFlowHome, run_id: manifest.run_id, run_tables: runTables, resource_tables: resourceTables }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
535
|
+
syncPath(file);
|
|
536
|
+
syncPath(directory);
|
|
537
|
+
const lineage = JSON.stringify({ lineage_file: file, lineage_sha256: fileHash(file) });
|
|
538
|
+
context.db.writeTransaction(() => {
|
|
539
|
+
assertSnapshotRuntimeDetachAllowed(sql => context.db.get(sql));
|
|
540
|
+
for (const [table, rows] of Object.entries(runTables))
|
|
541
|
+
if (JSON.stringify(context.db.all(`SELECT * FROM ${table}`)) !== JSON.stringify(rows))
|
|
542
|
+
throw new AppError("snapshot_runtime_changed", "Imported runtime changed during lineage preparation", 1, { table });
|
|
543
|
+
context.db.run("UPDATE sessions SET metadata_json = json_set(metadata_json, '$.snapshot_import', json(?))", [lineage]);
|
|
544
|
+
for (const table of detachedTables) {
|
|
545
|
+
try {
|
|
546
|
+
context.db.exec(`DELETE FROM ${table}`);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
throw new AppError("snapshot_runtime_detach_failed", `Could not detach imported ${table} authority`, 1, { table, cause: String(error) });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
context.db.run("UPDATE merge_requests SET dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, lock_acquired_at = NULL, adapter_receipt_json = NULL");
|
|
553
|
+
// Only the copied dedicated registry is touched. Source/external homes
|
|
554
|
+
// are historical references in the archive and are never opened here.
|
|
555
|
+
if (resources) {
|
|
556
|
+
resources.exec("BEGIN IMMEDIATE");
|
|
557
|
+
resourceHeld = true;
|
|
558
|
+
for (const [table, rows] of Object.entries(resourceTables)) {
|
|
559
|
+
if (JSON.stringify(resources.prepare(`SELECT * FROM ${table}`).all()) !== JSON.stringify(rows))
|
|
560
|
+
throw new AppError("snapshot_runtime_changed", "Imported resource evidence changed during lineage preparation", 1, { table });
|
|
561
|
+
resources.exec(`DELETE FROM ${table}`);
|
|
562
|
+
}
|
|
563
|
+
resources.exec("COMMIT");
|
|
564
|
+
resourceHeld = false;
|
|
565
|
+
resourceCommitted = true;
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
result = file;
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
primary = error;
|
|
572
|
+
failed = true;
|
|
573
|
+
}
|
|
574
|
+
const cleanup = resources ? cleanupScopeWriters([{ name: resourceFile, db: resources, rollback: resourceHeld, close: "always" }]) : [];
|
|
575
|
+
if (failed || cleanup.length) {
|
|
576
|
+
const details = { effect: resourceCommitted ? "partial" : "unknown", ...(cleanup.length ? { import_cleanup_errors: cleanup } : {}) };
|
|
577
|
+
if (primary instanceof AppError) {
|
|
578
|
+
Object.assign(primary.details, details);
|
|
579
|
+
throw primary;
|
|
580
|
+
}
|
|
581
|
+
throw new AppError("snapshot_runtime_detach_failed", primary instanceof Error ? primary.message : "Imported runtime authority could not be detached", 1, { ...details, ...(failed ? { cause: String(primary) } : {}) });
|
|
582
|
+
}
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
585
|
+
function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome, recovery = false) {
|
|
586
|
+
foreignKeyTransaction(context.db, () => {
|
|
212
587
|
context.db.run("UPDATE projects SET root = ?", [newRoot]);
|
|
213
588
|
context.db.run("UPDATE runs SET project_root = ?, workspace_root = ?", [newRoot, newWorkspace]);
|
|
214
589
|
for (const column of ["runtime_path", "run_dir", "run_index_path", "run_home_path", "run_root", "index_json"])
|
|
@@ -216,7 +591,12 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
|
|
|
216
591
|
context.db.run("UPDATE protocols SET project_root = ?", [newRoot]);
|
|
217
592
|
for (const column of ["state_path", "plan_path"])
|
|
218
593
|
rewriteDatabaseColumn(context, "protocols", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
219
|
-
|
|
594
|
+
for (const column of ["source_workspace", "target_workspace"])
|
|
595
|
+
rewriteDatabaseColumn(context, "merge_requests", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
596
|
+
if (recovery)
|
|
597
|
+
context.db.run("UPDATE sessions SET project_root = ?, status = CASE WHEN status = 'active' THEN 'stopped' ELSE status END, stop_reason = CASE WHEN status = 'active' THEN 'snapshot_restored' ELSE stop_reason END", [newRoot]);
|
|
598
|
+
else
|
|
599
|
+
context.db.run("UPDATE sessions SET project_root = ?, status = 'stopped', stop_reason = 'snapshot_restored', stopped_at = ?", [newRoot, context.now()]);
|
|
220
600
|
for (const column of ["workspace_path", "cwd", "transcript_path"])
|
|
221
601
|
rewriteDatabaseColumn(context, "sessions", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
222
602
|
for (const column of ["prompt_path", "result_path"])
|
|
@@ -226,35 +606,27 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
|
|
|
226
606
|
rewriteDatabaseColumn(context, "usage", "source_locator", oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
227
607
|
for (const column of ["worktree_path", "worktrunk_metadata_json", "last_command_result_json"])
|
|
228
608
|
rewriteDatabaseColumn(context, "worktree_records", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
229
|
-
|
|
609
|
+
// Recovery imports remain sealed. Their old Work sessions are historical
|
|
610
|
+
// bindings, not completed work; explicit recovery closes them interrupted.
|
|
611
|
+
if (!recovery)
|
|
612
|
+
context.db.run("UPDATE work_sessions SET status = CASE WHEN status = 'running' THEN 'completed' ELSE status END, completed_at = COALESCE(completed_at, ?), updated_at = ?", [context.now(), context.now()]);
|
|
230
613
|
context.db.run("DELETE FROM codex_session_bindings");
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
finally {
|
|
238
|
-
context.db.exec("PRAGMA foreign_keys = ON");
|
|
239
|
-
}
|
|
240
|
-
rewriteStructuredFiles(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
614
|
+
});
|
|
615
|
+
// Only operational database paths move. Imported packets, results and
|
|
616
|
+
// journals retain their historical bytes, including interrupted JSONL tails.
|
|
617
|
+
// The frozen workspace route is an operational receipt consumed by later
|
|
618
|
+
// stages, so its relocated path is the sole structured-file exception.
|
|
619
|
+
rewriteOperationalRouteReceipts(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
241
620
|
}
|
|
242
|
-
function
|
|
243
|
-
const visit = (
|
|
244
|
-
for (const entry of fs.readdirSync(
|
|
245
|
-
const file = path.join(
|
|
246
|
-
|
|
247
|
-
// They are project/tool bytes, not flow records; parsing every *.json
|
|
248
|
-
// there breaks on valid JSON-with-comments such as tsconfig files.
|
|
249
|
-
if (entry.isDirectory() && ![".git", "checkouts", "engines", "node_modules", "tools"].includes(entry.name))
|
|
621
|
+
function rewriteOperationalRouteReceipts(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
622
|
+
const visit = (directory) => {
|
|
623
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
624
|
+
const file = path.join(directory, entry.name);
|
|
625
|
+
if (entry.isDirectory())
|
|
250
626
|
visit(file);
|
|
251
|
-
else if (entry.isFile() && entry.name
|
|
627
|
+
else if (entry.isFile() && entry.name === "workspace-route.json") {
|
|
252
628
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
256
|
-
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.stringify(rebaseStructuredValue(JSON.parse(line), oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)));
|
|
257
|
-
fs.writeFileSync(file, `${lines.join("\n")}\n`);
|
|
629
|
+
fs.writeFileSync(file, `${JSON.stringify(rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome), null, 2)}\n`);
|
|
258
630
|
}
|
|
259
631
|
}
|
|
260
632
|
};
|
|
@@ -274,20 +646,27 @@ function rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspa
|
|
|
274
646
|
function rebasePathString(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) { for (const [oldPrefix, newPrefix] of [[oldHome, newHome], [oldWorkspace, newWorkspace], [oldRoot, newRoot]])
|
|
275
647
|
if (value === oldPrefix || value.startsWith(`${oldPrefix}${path.sep}`))
|
|
276
648
|
return `${newPrefix}${value.slice(oldPrefix.length)}`; return value; }
|
|
277
|
-
function
|
|
649
|
+
function prepareSnapshotWorkspace(workspaceRoot, projectRoot) {
|
|
278
650
|
if (samePath(workspaceRoot, projectRoot))
|
|
279
|
-
return
|
|
651
|
+
return null;
|
|
280
652
|
const branch = git(workspaceRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
281
653
|
if (!branch)
|
|
282
654
|
throw new AppError("snapshot_workspace_invalid", "Feature workspace must be on a named Git branch", 1, { workspace_root: workspaceRoot });
|
|
283
655
|
const baseRef = git(workspaceRoot, ["rev-parse", "HEAD"]);
|
|
284
656
|
if (!baseRef)
|
|
285
657
|
throw new AppError("snapshot_workspace_invalid", "Feature workspace has no readable HEAD", 1, { workspace_root: workspaceRoot });
|
|
658
|
+
return { branch, baseRef };
|
|
659
|
+
}
|
|
660
|
+
function snapshotWorkspace(workspaceRoot, projectRoot, output) {
|
|
661
|
+
const prepared = prepareSnapshotWorkspace(workspaceRoot, projectRoot);
|
|
662
|
+
if (!prepared)
|
|
663
|
+
return { kind: "project" };
|
|
664
|
+
const { branch, baseRef } = prepared;
|
|
286
665
|
const workspace = path.join(output, "workspace");
|
|
287
666
|
const bundle = path.join(output, "workspace.bundle");
|
|
288
|
-
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: (
|
|
667
|
+
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: projectPayloadFilter(workspaceRoot) });
|
|
289
668
|
runGit(projectRoot, ["bundle", "create", bundle, branch]);
|
|
290
|
-
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
|
|
669
|
+
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle), ...snapshotIndex(workspaceRoot, output, "workspace") };
|
|
291
670
|
}
|
|
292
671
|
function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
|
|
293
672
|
if (manifest.workspace.kind === "project")
|
|
@@ -298,11 +677,7 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
|
|
|
298
677
|
throw new AppError("snapshot_checksum_mismatch", "Snapshot workspace checksum does not match manifest", 1, { snapshot });
|
|
299
678
|
if (!fs.existsSync(bundle) || fileHash(bundle) !== manifest.workspace.bundle_sha256)
|
|
300
679
|
throw new AppError("snapshot_checksum_mismatch", "Snapshot workspace Git bundle does not match manifest", 1, { snapshot });
|
|
301
|
-
const
|
|
302
|
-
const sourceRoot = physicalPath(manifest.workspace.source_root);
|
|
303
|
-
const workspaceRoot = isInside(managedRoot, sourceRoot)
|
|
304
|
-
? path.join(ddFlowHome, path.relative(managedRoot, sourceRoot))
|
|
305
|
-
: path.join(path.dirname(projectRoot), "workspace");
|
|
680
|
+
const workspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, ddFlowHome);
|
|
306
681
|
if (fs.existsSync(workspaceRoot))
|
|
307
682
|
throw new AppError("snapshot_workspace_target_exists", "Snapshot feature workspace target already exists", 1, { workspace_root: workspaceRoot });
|
|
308
683
|
runGit(projectRoot, ["fetch", bundle, `+refs/heads/${manifest.workspace.branch}:refs/heads/${manifest.workspace.branch}`]);
|
|
@@ -310,19 +685,38 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
|
|
|
310
685
|
throw new AppError("snapshot_workspace_restore_failed", "Snapshot Git bundle did not restore the frozen feature base", 1, { branch: manifest.workspace.branch, expected_base: manifest.workspace.base_ref });
|
|
311
686
|
runGit(projectRoot, ["worktree", "add", workspaceRoot, manifest.workspace.branch]);
|
|
312
687
|
replaceProjectTree(workspaceRoot, source);
|
|
688
|
+
restoreIndex(snapshot, "workspace", manifest.workspace, workspaceRoot);
|
|
313
689
|
return fs.realpathSync(workspaceRoot);
|
|
314
690
|
}
|
|
691
|
+
export function restoredWorkspaceRoot(manifest, projectRoot, ddFlowHome) {
|
|
692
|
+
if (manifest.workspace.kind === "project")
|
|
693
|
+
return projectRoot;
|
|
694
|
+
const managedRoot = physicalPath(manifest.dd_flow_home), sourceRoot = physicalPath(manifest.workspace.source_root);
|
|
695
|
+
return isInside(managedRoot, sourceRoot) ? path.join(ddFlowHome, path.relative(managedRoot, sourceRoot)) : path.join(path.dirname(projectRoot), "workspace");
|
|
696
|
+
}
|
|
697
|
+
function assertRestoreTargetSeparation(snapshot, targets) {
|
|
698
|
+
const roots = [snapshot, ...targets].map(physicalPath);
|
|
699
|
+
for (let i = 0; i < roots.length; i++)
|
|
700
|
+
for (let j = i + 1; j < roots.length; j++) {
|
|
701
|
+
if (isInside(roots[i], roots[j]) || isInside(roots[j], roots[i]))
|
|
702
|
+
throw new AppError("snapshot_path_conflict", "Snapshot source and restore targets must not overlap", 2, { source: roots[i], target: roots[j] });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
315
705
|
function sourceWorkspaceRoot(manifest) {
|
|
316
706
|
return manifest.workspace.kind === "project" ? manifest.project_root : manifest.workspace.source_root;
|
|
317
707
|
}
|
|
318
|
-
function
|
|
708
|
+
function prepareSnapshotProjectGit(projectRoot) {
|
|
319
709
|
const branch = git(projectRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
320
710
|
const head = git(projectRoot, ["rev-parse", "HEAD"]);
|
|
321
711
|
if (!branch || !head)
|
|
322
712
|
throw new AppError("snapshot_project_git_invalid", "Snapshot requires a project on a named Git branch with a readable HEAD", 1, { project_root: projectRoot });
|
|
713
|
+
return { branch, head };
|
|
714
|
+
}
|
|
715
|
+
function snapshotProjectGit(projectRoot, output) {
|
|
716
|
+
const { branch, head } = prepareSnapshotProjectGit(projectRoot);
|
|
323
717
|
const bundle = path.join(output, "project.bundle");
|
|
324
718
|
runGit(projectRoot, ["bundle", "create", bundle, "--all"]);
|
|
325
|
-
return { branch, head, bundle_sha256: fileHash(bundle) };
|
|
719
|
+
return { branch, head, bundle_sha256: fileHash(bundle), ...snapshotIndex(projectRoot, output, "project") };
|
|
326
720
|
}
|
|
327
721
|
function restoreProjectGit(snapshot, source, projectRoot) {
|
|
328
722
|
const bundle = path.join(snapshot, "project.bundle");
|
|
@@ -332,6 +726,131 @@ function restoreProjectGit(snapshot, source, projectRoot) {
|
|
|
332
726
|
runGit(projectRoot, ["init", "-b", source.branch]);
|
|
333
727
|
runGit(projectRoot, ["fetch", bundle, `+refs/heads/${source.branch}:refs/remotes/snapshot/${source.branch}`]);
|
|
334
728
|
runGit(projectRoot, ["checkout", "-B", source.branch, source.head]);
|
|
729
|
+
restoreIndex(snapshot, "project", source, projectRoot);
|
|
730
|
+
}
|
|
731
|
+
const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
|
|
732
|
+
function indexEntries(root) {
|
|
733
|
+
const entries = Buffer.from(gitBytes(root, ["ls-files", "--stage", "-z"])
|
|
734
|
+
.toString("utf8").split("\0").filter(entry => entry && localEnvironmentAllowed(entry.slice(entry.indexOf("\t") + 1)))
|
|
735
|
+
.map(entry => `${entry}\0`).join(""));
|
|
736
|
+
const merge = {};
|
|
737
|
+
for (const name of mergeStateFiles) {
|
|
738
|
+
const file = git(root, ["rev-parse", "--git-path", name]);
|
|
739
|
+
if (!file)
|
|
740
|
+
throw new AppError("snapshot_index_invalid", "Could not locate Git merge state", 1);
|
|
741
|
+
const resolved = path.resolve(root, file);
|
|
742
|
+
if (fs.existsSync(resolved))
|
|
743
|
+
merge[name] = fs.readFileSync(resolved).toString("base64");
|
|
744
|
+
}
|
|
745
|
+
return Buffer.from(JSON.stringify({ entries: entries.toString("base64"), merge }));
|
|
746
|
+
}
|
|
747
|
+
function snapshotIndex(root, output, name) {
|
|
748
|
+
const bytes = indexEntries(root);
|
|
749
|
+
const value = JSON.parse(bytes.toString());
|
|
750
|
+
const objects = new Set();
|
|
751
|
+
for (const entry of Buffer.from(value.entries, "base64").toString("utf8").split("\0").filter(Boolean)) {
|
|
752
|
+
const match = /^\d{6} ([a-f0-9]{40,64}) [0-3]\t/.exec(entry);
|
|
753
|
+
if (!match)
|
|
754
|
+
throw new AppError("snapshot_index_invalid", "Malformed Git index entry", 1);
|
|
755
|
+
// Gitlinks belong to another repository and are preserved as index IDs.
|
|
756
|
+
if (!entry.startsWith("160000 "))
|
|
757
|
+
objects.add(match[1]);
|
|
758
|
+
}
|
|
759
|
+
for (const name of ["MERGE_HEAD", "ORIG_HEAD", "AUTO_MERGE"]) {
|
|
760
|
+
if (value.merge[name])
|
|
761
|
+
for (const oid of Buffer.from(value.merge[name], "base64").toString().trim().split("\n")) {
|
|
762
|
+
if (!/^[a-f0-9]{40,64}$/.test(oid))
|
|
763
|
+
throw new AppError("snapshot_index_invalid", "Malformed Git merge reference", 1);
|
|
764
|
+
objects.add(oid);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const payload = path.join(output, `${name}.index.patch`);
|
|
768
|
+
const pack = path.join(output, `${name}.index.pack`);
|
|
769
|
+
fs.writeFileSync(payload, bytes);
|
|
770
|
+
fs.writeFileSync(pack, gitBytes(root, ["pack-objects", "--stdout", "--revs"], Buffer.from([...objects].sort().join("\n") + "\n")));
|
|
771
|
+
return { index_format: "entries@1", index_sha256: fileHash(payload), index_objects_sha256: fileHash(pack) };
|
|
772
|
+
}
|
|
773
|
+
function gitBytes(root, args, input) {
|
|
774
|
+
const result = spawnSync("git", ["-C", root, ...args], { input, maxBuffer: 64 * 1024 * 1024 });
|
|
775
|
+
if (result.status !== 0)
|
|
776
|
+
throw new AppError("snapshot_index_invalid", "Could not preserve Git index objects", 1, { args, stderr: result.stderr?.toString().trim() });
|
|
777
|
+
return result.stdout;
|
|
778
|
+
}
|
|
779
|
+
function indexPatch(root) {
|
|
780
|
+
const result = spawnSync("git", ["-C", root, "diff", "--cached", "--binary", "--full-index", "--no-ext-diff", "HEAD", "--", ".", ":(exclude)**/.env", ":(exclude)**/.env.*"], { maxBuffer: 64 * 1024 * 1024 });
|
|
781
|
+
if (result.status !== 0)
|
|
782
|
+
throw new AppError("snapshot_index_invalid", "Could not capture Git index", 1);
|
|
783
|
+
return result.stdout;
|
|
784
|
+
}
|
|
785
|
+
function restoreIndex(snapshot, name, metadata, root) {
|
|
786
|
+
if (!metadata.index_sha256)
|
|
787
|
+
return;
|
|
788
|
+
const file = path.join(snapshot, `${name}.index.patch`);
|
|
789
|
+
if (fileHash(file) !== metadata.index_sha256)
|
|
790
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index checksum does not match", 1);
|
|
791
|
+
if (metadata.index_format === "entries@1") {
|
|
792
|
+
const pack = path.join(snapshot, `${name}.index.pack`);
|
|
793
|
+
if (!metadata.index_objects_sha256 || fileHash(pack) !== metadata.index_objects_sha256)
|
|
794
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index object checksum does not match", 1);
|
|
795
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
796
|
+
gitBytes(root, ["unpack-objects"], fs.readFileSync(pack));
|
|
797
|
+
runGit(root, ["read-tree", "--empty"]);
|
|
798
|
+
gitBytes(root, ["update-index", "-z", "--index-info"], Buffer.from(value.entries, "base64"));
|
|
799
|
+
for (const name of mergeStateFiles) {
|
|
800
|
+
const target = git(root, ["rev-parse", "--git-path", name]);
|
|
801
|
+
if (!target)
|
|
802
|
+
throw new AppError("snapshot_index_invalid", "Could not locate restored Git merge state", 1);
|
|
803
|
+
if (value.merge[name] !== undefined)
|
|
804
|
+
fs.writeFileSync(path.resolve(root, target), Buffer.from(value.merge[name], "base64"));
|
|
805
|
+
else
|
|
806
|
+
fs.rmSync(path.resolve(root, target), { force: true });
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (fs.statSync(file).size)
|
|
811
|
+
runGit(root, ["apply", "--cached", "--binary", file]);
|
|
812
|
+
}
|
|
813
|
+
export function verifyRecoverySnapshot(snapshot, input, checkWorkingState = true) {
|
|
814
|
+
const manifest = readManifest(snapshot);
|
|
815
|
+
if (manifest.purpose !== "recovery" || manifest.recovery_id !== input.recoveryId || manifest.run_id !== input.runId)
|
|
816
|
+
throw new AppError("recovery_capture_invalid", "Snapshot does not identify this recovery", 1);
|
|
817
|
+
const entries = [
|
|
818
|
+
{ name: "project", root: input.projectRoot, hash: manifest.project_sha256, git: manifest.project_git, head: manifest.project_git.head },
|
|
819
|
+
...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", root: input.workspaceRoot, hash: manifest.workspace.sha256, git: manifest.workspace, head: manifest.workspace.base_ref }] : [])
|
|
820
|
+
];
|
|
821
|
+
verifySnapshotPayload(snapshot, manifest);
|
|
822
|
+
for (const entry of entries) {
|
|
823
|
+
const allowed = manifest.project_payload_policy === "service-files@1" ? projectPayloadFilter(entry.root) : snapshotPayloadAllowed;
|
|
824
|
+
const expectedModes = manifest.payload_modes?.[entry.name];
|
|
825
|
+
if (checkWorkingState && expectedModes && treeModesHash(entry.root, allowed) !== expectedModes)
|
|
826
|
+
throw new AppError("recovery_workspace_drift", "Executable file modes changed after recovery capture", 1, { payload: entry.name });
|
|
827
|
+
if (checkWorkingState && (treeHash(entry.root, allowed) !== entry.hash || git(entry.root, ["rev-parse", "HEAD"]) !== entry.head || git(entry.root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== entry.git.branch || (entry.git.index_sha256 && crypto.createHash("sha256").update(entry.git.index_format === "entries@1" ? indexEntries(entry.root) : indexPatch(entry.root)).digest("hex") !== entry.git.index_sha256)))
|
|
828
|
+
throw new AppError("recovery_workspace_drift", "Working files or Git state changed after recovery capture", 1, { payload: entry.name });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** Reuse an already published boundary only after checking its identity and
|
|
832
|
+
* every payload, including its Git index objects. Never overwrite evidence. */
|
|
833
|
+
export function verifyControllerBoundarySnapshot(snapshot, input) {
|
|
834
|
+
const manifest = readManifest(snapshot), boundary = manifest.boundary_capture;
|
|
835
|
+
if (!boundary || manifest.consistency !== "controller_boundary_writer_barrier@1" || manifest.run_id !== input.runId || boundary.controller_id !== input.controllerId || boundary.operation_id !== input.operationId || boundary.boundary_key !== input.boundaryKey || manifest.stage_entry !== input.stageEntry || manifest.purpose !== (input.stageEntry ? "stage_entry" : "candidate") || !/^[a-f0-9]{64}$/.test(boundary.source_sha256) || boundary.observation?.settled !== true || boundary.observation.operation_id !== input.operationId)
|
|
836
|
+
throw new AppError("controller_boundary_snapshot_invalid", "Published snapshot does not prove this controller boundary", 1, { snapshot });
|
|
837
|
+
verifySnapshotPayload(snapshot, manifest);
|
|
838
|
+
const file = path.join(snapshot, "snapshot.json");
|
|
839
|
+
if (input.manifestSha256 !== undefined && fileHash(file) !== input.manifestSha256)
|
|
840
|
+
throw new AppError("snapshot_checksum_mismatch", "Boundary manifest no longer matches its retained publication receipt", 1, { snapshot });
|
|
841
|
+
return { snapshot, manifest: file, manifest_sha256: fileHash(file), purpose: manifest.purpose, stage_entry: manifest.stage_entry };
|
|
842
|
+
}
|
|
843
|
+
function verifySnapshotPayload(snapshot, manifest) {
|
|
844
|
+
if (treeHash(path.join(snapshot, "runtime")) !== manifest.runtime_sha256)
|
|
845
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match", 1);
|
|
846
|
+
verifySnapshotModes(snapshot, manifest);
|
|
847
|
+
const payloads = [{ name: "project", hash: manifest.project_sha256, git: manifest.project_git }, ...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", hash: manifest.workspace.sha256, git: manifest.workspace }] : [])];
|
|
848
|
+
for (const payload of payloads)
|
|
849
|
+
verifyGitSnapshotPayload(snapshot, payload);
|
|
850
|
+
}
|
|
851
|
+
function verifyGitSnapshotPayload(snapshot, payload) {
|
|
852
|
+
if (treeHash(path.join(snapshot, payload.name)) !== payload.hash || fileHash(path.join(snapshot, `${payload.name}.bundle`)) !== payload.git.bundle_sha256 || payload.git.index_sha256 && fileHash(path.join(snapshot, `${payload.name}.index.patch`)) !== payload.git.index_sha256 || payload.git.index_format === "entries@1" && (!payload.git.index_objects_sha256 || fileHash(path.join(snapshot, `${payload.name}.index.pack`)) !== payload.git.index_objects_sha256))
|
|
853
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot payload or Git index checksum does not match", 1, { payload: payload.name });
|
|
335
854
|
}
|
|
336
855
|
function git(root, args) {
|
|
337
856
|
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
@@ -343,12 +862,342 @@ function runGit(root, args) {
|
|
|
343
862
|
throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
|
|
344
863
|
}
|
|
345
864
|
function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
|
|
346
|
-
function
|
|
865
|
+
function snapshotPayloadAllowed(source) {
|
|
866
|
+
const name = path.basename(source);
|
|
867
|
+
return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
|
|
868
|
+
&& localEnvironmentAllowed(source);
|
|
869
|
+
}
|
|
870
|
+
function projectPayloadFilter(root) {
|
|
871
|
+
const serviceAllowed = projectServicePathFilter(root);
|
|
872
|
+
return file => snapshotPayloadAllowed(file) && serviceAllowed(file);
|
|
873
|
+
}
|
|
874
|
+
function runtimePayloadAllowed(source) {
|
|
875
|
+
return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "auth.encrypted", "auth.v2.key", "auth.v2.file", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
|
|
876
|
+
}
|
|
877
|
+
/** Only a Grok daemon's verified provider root is excluded from flow evidence.
|
|
878
|
+
* An unrelated directory named grok-home remains ordinary project data. */
|
|
879
|
+
function grokProviderHomes(runtimeRoot) {
|
|
880
|
+
const homes = [];
|
|
881
|
+
const visit = (directory) => {
|
|
882
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
883
|
+
const file = path.join(directory, entry.name);
|
|
884
|
+
if (entry.isDirectory()) {
|
|
885
|
+
if (![".git", "node_modules", "grok-home"].includes(entry.name))
|
|
886
|
+
visit(file);
|
|
887
|
+
}
|
|
888
|
+
else if (entry.isFile() && entry.name === "daemon.json") {
|
|
889
|
+
let state;
|
|
890
|
+
try {
|
|
891
|
+
state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
if (state.schema_id !== "dd-grok/daemon-state@1")
|
|
897
|
+
continue;
|
|
898
|
+
const expected = path.join(directory, "grok-home");
|
|
899
|
+
if (state.config?.grokHome !== expected || state.config?.ddFlowHome !== runtimeRoot)
|
|
900
|
+
throw new AppError("snapshot_native_scope_invalid", "Grok provider home differs from its retained daemon binding", 1, { state_dir: directory });
|
|
901
|
+
homes.push(expected);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
visit(runtimeRoot);
|
|
906
|
+
return homes;
|
|
907
|
+
}
|
|
908
|
+
function sqliteFile(file) {
|
|
909
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
910
|
+
return false;
|
|
911
|
+
const descriptor = fs.openSync(file, "r");
|
|
912
|
+
try {
|
|
913
|
+
const header = Buffer.alloc(16);
|
|
914
|
+
return fs.readSync(descriptor, header, 0, 16, 0) === 16 && header.toString() === "SQLite format 3\0";
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
fs.closeSync(descriptor);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
function sqliteIdentifier(value) { return `"${value.replaceAll('"', '""')}"`; }
|
|
921
|
+
function sqliteValue(value) {
|
|
922
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
923
|
+
return value;
|
|
924
|
+
if (typeof value === "number")
|
|
925
|
+
return Number.isFinite(value) ? value : String(value);
|
|
926
|
+
if (typeof value === "bigint")
|
|
927
|
+
return `${value}n`;
|
|
928
|
+
if (Buffer.isBuffer(value))
|
|
929
|
+
return `blob:${value.toString("base64")}`;
|
|
930
|
+
if (value instanceof Uint8Array)
|
|
931
|
+
return `blob:${Buffer.from(value).toString("base64")}`;
|
|
932
|
+
return String(value);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* SQLite page layout is not semantic identity: VACUUM, checkpoint and a
|
|
936
|
+
* delete/insert rewrite can change serialized bytes without changing the
|
|
937
|
+
* logical database. Snapshot only the schema and ordered logical row values.
|
|
938
|
+
* This intentionally applies to the small set of managed SQLite files, not
|
|
939
|
+
* to arbitrary provider databases.
|
|
940
|
+
*/
|
|
941
|
+
function sqliteSemanticFingerprint(db, resourceRegistry = false) {
|
|
942
|
+
db.exec("PRAGMA busy_timeout = 4000");
|
|
943
|
+
db.exec("BEGIN");
|
|
944
|
+
try {
|
|
945
|
+
const schema = db.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name, tbl_name, sql").all();
|
|
946
|
+
const tables = schema.filter(row => row.type === "table").map(row => {
|
|
947
|
+
const columns = db.prepare(`PRAGMA table_info(${sqliteIdentifier(row.name)})`).all()
|
|
948
|
+
.sort((left, right) => Number(left.cid ?? 0) - Number(right.cid ?? 0))
|
|
949
|
+
.map(column => ({ name: String(column.name ?? ""), type: String(column.type ?? ""), notnull: Number(column.notnull ?? 0), default: sqliteValue(column.dflt_value ?? null), pk: Number(column.pk ?? 0) }));
|
|
950
|
+
const names = columns.map(column => column.name);
|
|
951
|
+
const stableNames = resourceRegistry && row.name === "managed_processes" ? names.filter(name => name !== "lease_expires_at" && name !== "updated_at") : names;
|
|
952
|
+
const rows = db.prepare(`SELECT * FROM ${sqliteIdentifier(row.name)}`).all()
|
|
953
|
+
.map(record => stableNames.map(name => [name, sqliteValue(record[name])]))
|
|
954
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
955
|
+
return { name: row.name, columns, rows };
|
|
956
|
+
});
|
|
957
|
+
return crypto.createHash("sha256").update(JSON.stringify({ schema, tables })).digest("hex");
|
|
958
|
+
}
|
|
959
|
+
finally {
|
|
960
|
+
try {
|
|
961
|
+
db.exec("ROLLBACK");
|
|
962
|
+
}
|
|
963
|
+
catch { /* The read transaction may already have closed on provider error. */ }
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
/** A WAL is copied through its SQLite read snapshot, not as raw bytes. */
|
|
967
|
+
function runtimeSnapshotKind(file, excluded, providerHomes = []) {
|
|
968
|
+
if (!runtimePayloadAllowed(file) || providerHomes.some(home => file === home || isInside(home, file)) || excluded.includes(file))
|
|
969
|
+
return "excluded";
|
|
970
|
+
if (fs.lstatSync(file).isFile()) {
|
|
971
|
+
if (/-(?:wal|shm|journal)$/.test(file) && sqliteFile(file.replace(/-(?:wal|shm|journal)$/, "")))
|
|
972
|
+
return "sidecar";
|
|
973
|
+
if (sqliteFile(file))
|
|
974
|
+
return "sqlite";
|
|
975
|
+
}
|
|
976
|
+
return "ordinary";
|
|
977
|
+
}
|
|
978
|
+
/** Productive writers are fenced separately. Compare exactly the copied
|
|
979
|
+
* source set; SQLite serialization includes committed WAL pages without
|
|
980
|
+
* mistaking a physical checkpoint for new logical content. */
|
|
981
|
+
function snapshotSourceVersion(context, input, retainedDatabases) {
|
|
982
|
+
const databases = retainedDatabases ?? new Map();
|
|
983
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
984
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
985
|
+
const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
986
|
+
if (!run)
|
|
987
|
+
throw new AppError("not_found", "Snapshot RUN is not registered", 1);
|
|
988
|
+
const entries = new Map();
|
|
989
|
+
const excluded = codexHomeSnapshotExclusions(context, project.id, input.runId);
|
|
990
|
+
const providerHomes = input.recoveryId === undefined ? grokProviderHomes(context.ddFlowHome) : [];
|
|
991
|
+
const visit = (directory) => {
|
|
992
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
993
|
+
const file = path.join(directory, entry.name);
|
|
994
|
+
const kind = runtimeSnapshotKind(file, excluded, providerHomes);
|
|
995
|
+
if (kind === "excluded" || kind === "sidecar")
|
|
996
|
+
continue;
|
|
997
|
+
const key = `runtime/${path.relative(context.ddFlowHome, file)}`;
|
|
998
|
+
const stat = fs.lstatSync(file);
|
|
999
|
+
if (stat.isDirectory()) {
|
|
1000
|
+
entries.set(key, "directory");
|
|
1001
|
+
visit(file);
|
|
1002
|
+
}
|
|
1003
|
+
else if (stat.isSymbolicLink())
|
|
1004
|
+
entries.set(key, `link:${fs.readlinkSync(file)}`);
|
|
1005
|
+
else if (kind === "sqlite") {
|
|
1006
|
+
let db = databases.get(file);
|
|
1007
|
+
if (!db) {
|
|
1008
|
+
db = new DatabaseSync(file, { readOnly: true });
|
|
1009
|
+
databases.set(file, db);
|
|
1010
|
+
}
|
|
1011
|
+
// Resource heartbeats are copied consistently but are not productive
|
|
1012
|
+
// RUN writers; owner identity is fenced by boundary.verify().
|
|
1013
|
+
if (file === path.join(resourceHome(context), "runtime.sqlite")) {
|
|
1014
|
+
entries.set(key, `resource-sqlite:${sqliteSemanticFingerprint(db, true)}:${stat.mode & 0o111}`);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
entries.set(key, `sqlite:${sqliteSemanticFingerprint(db)}:${stat.mode & 0o111}`);
|
|
1018
|
+
}
|
|
1019
|
+
else if (stat.isFile())
|
|
1020
|
+
entries.set(key, `file:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}:${stat.mode & 0o111}`);
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
try {
|
|
1024
|
+
visit(context.ddFlowHome);
|
|
1025
|
+
for (const root of new Set([projectRoot, run.workspace_root]))
|
|
1026
|
+
entries.set(`source/${root}`, JSON.stringify([
|
|
1027
|
+
treeHash(root, true), treeModesHash(root, true), git(root, ["symbolic-ref", "--quiet", "HEAD"]), gitBytes(root, ["show-ref", "--head"]).toString("base64"), indexEntries(root).toString("base64")
|
|
1028
|
+
]));
|
|
1029
|
+
return { entries, databases, sha256: crypto.createHash("sha256").update(JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)))).digest("hex") };
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
if (!retainedDatabases)
|
|
1033
|
+
for (const db of databases.values())
|
|
1034
|
+
try {
|
|
1035
|
+
db.close();
|
|
1036
|
+
}
|
|
1037
|
+
catch { /* Preserve the original source error. */ }
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function snapshotSourceChanges(before, after) {
|
|
1042
|
+
return [...new Set([...before.keys(), ...after.keys()])].filter(key => before.get(key) !== after.get(key)).sort().slice(0, 8);
|
|
1043
|
+
}
|
|
1044
|
+
function snapshotSourceChangeProofs(before, after) {
|
|
1045
|
+
return snapshotSourceChanges(before, after).map(component => ({
|
|
1046
|
+
component,
|
|
1047
|
+
before: before.has(component) ? crypto.createHash("sha256").update(before.get(component)).digest("hex") : null,
|
|
1048
|
+
after: after.has(component) ? crypto.createHash("sha256").update(after.get(component)).digest("hex") : null
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
1051
|
+
/** Managed Codex homes share host configuration, not owned snapshot payload. */
|
|
1052
|
+
function codexHomeSnapshotExclusions(context, projectId, runId) {
|
|
1053
|
+
const homes = new Set(context.db.all("SELECT target_home FROM codex_home_profiles WHERE project_id = ?", [projectId]).map(row => row.target_home));
|
|
1054
|
+
if (context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_controllers'")) {
|
|
1055
|
+
for (const { home } of context.db.all("SELECT json_extract(value, '$.codex_home') AS home FROM run_controllers, json_each(state_json, '$.sessions') WHERE project_id = ? AND run_id = ?", [projectId, runId]))
|
|
1056
|
+
if (home)
|
|
1057
|
+
homes.add(home);
|
|
1058
|
+
}
|
|
1059
|
+
const excluded = [];
|
|
1060
|
+
for (const home of homes) {
|
|
1061
|
+
if (!path.isAbsolute(home) || !fs.existsSync(home) || fs.lstatSync(home).isSymbolicLink())
|
|
1062
|
+
continue;
|
|
1063
|
+
const marker = path.join(home, ".dd-flow-home.json");
|
|
1064
|
+
if (!fs.existsSync(marker) || fs.lstatSync(marker).isSymbolicLink())
|
|
1065
|
+
continue;
|
|
1066
|
+
const identity = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
1067
|
+
if (identity.managed_by !== "dd-flow-cli" || identity.project_id !== projectId || identity.target_home !== home || !identity.source_home || !path.isAbsolute(identity.source_home))
|
|
1068
|
+
continue;
|
|
1069
|
+
for (const entry of codexHomeSharedEntries) {
|
|
1070
|
+
const file = path.join(home, entry);
|
|
1071
|
+
// Skip only the exact links installed by initCodexHome, never arbitrary
|
|
1072
|
+
// external links or owned local files with the same convenient name.
|
|
1073
|
+
if (fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink() && fs.readlinkSync(file) === path.join(identity.source_home, entry))
|
|
1074
|
+
excluded.push(file);
|
|
1075
|
+
}
|
|
1076
|
+
excluded.push(path.join(home, "tmp"), path.join(home, ".tmp"));
|
|
1077
|
+
}
|
|
1078
|
+
return excluded;
|
|
1079
|
+
}
|
|
1080
|
+
/** Copy a dedicated home or scoped recovery tree, preserving SQLite WAL commits. */
|
|
1081
|
+
function copyRuntimeTree(source, destination, scoped = true, excluded = [], providerHomes = []) {
|
|
1082
|
+
if (fs.lstatSync(source).isSymbolicLink())
|
|
1083
|
+
throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact roots must not be symbolic links", 1, { path: source });
|
|
1084
|
+
const root = physicalPath(source);
|
|
1085
|
+
if (isInside(root, physicalPath(destination)))
|
|
1086
|
+
throw new AppError("snapshot_output_inside_runtime", "Recovery output must be outside its source artifact tree", 1);
|
|
1087
|
+
const databases = [];
|
|
1088
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
1089
|
+
fs.cpSync(source, destination, {
|
|
1090
|
+
recursive: true, verbatimSymlinks: true,
|
|
1091
|
+
filter: file => {
|
|
1092
|
+
const kind = runtimeSnapshotKind(file, excluded, providerHomes);
|
|
1093
|
+
if (kind === "excluded" || kind === "sidecar")
|
|
1094
|
+
return false;
|
|
1095
|
+
const stat = fs.lstatSync(file);
|
|
1096
|
+
if (stat.isSymbolicLink()) {
|
|
1097
|
+
if (scoped && !isInside(root, physicalPath(file)))
|
|
1098
|
+
throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact link leaves its owned tree", 1, { path: file });
|
|
1099
|
+
return true;
|
|
1100
|
+
}
|
|
1101
|
+
if (!stat.isDirectory() && !stat.isFile())
|
|
1102
|
+
return false;
|
|
1103
|
+
if (stat.isFile()) {
|
|
1104
|
+
if (kind === "sqlite") {
|
|
1105
|
+
databases.push(file);
|
|
1106
|
+
return false;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
for (const file of databases) {
|
|
1113
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
1114
|
+
try {
|
|
1115
|
+
const target = source === file ? destination : path.join(destination, path.relative(source, file));
|
|
1116
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1117
|
+
db.exec("PRAGMA busy_timeout = 4000");
|
|
1118
|
+
db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
|
1119
|
+
}
|
|
1120
|
+
finally {
|
|
1121
|
+
db.close();
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
function captureRecoveryAuxiliary(context, captured, input) {
|
|
1126
|
+
const runtime = path.join(input.output, "runtime");
|
|
1127
|
+
const codexExclusions = codexHomeSnapshotExclusions(context, input.projectId, input.runId);
|
|
1128
|
+
const run = captured.db.get("SELECT run_root, project_root, workspace_root FROM runs WHERE project_id = ? AND id = ?", [input.projectId, input.runId]);
|
|
1129
|
+
const roots = [run.run_root, runtimeRunDir(context.ddFlowHome, input.projectId, input.runId)];
|
|
1130
|
+
for (const protocol of captured.db.all("SELECT id, state_path, plan_path FROM protocols")) {
|
|
1131
|
+
const directory = runtimeProtocolDir(context.ddFlowHome, input.projectId, protocol.id);
|
|
1132
|
+
if (!isInside(physicalPath(context.ddFlowHome), physicalPath(directory)))
|
|
1133
|
+
throw new AppError("snapshot_protocol_scope_invalid", "Protocol runtime leaves DD_FLOW_HOME", 1);
|
|
1134
|
+
if (fs.existsSync(directory)) {
|
|
1135
|
+
copyRuntimeTree(directory, path.join(runtime, path.relative(context.ddFlowHome, directory)), true, codexExclusions);
|
|
1136
|
+
roots.push(directory);
|
|
1137
|
+
}
|
|
1138
|
+
for (const file of [protocol.state_path, protocol.plan_path]) {
|
|
1139
|
+
if (!fs.existsSync(file) || roots.some(root => isInside(physicalPath(root), physicalPath(file))))
|
|
1140
|
+
continue;
|
|
1141
|
+
if (isInside(physicalPath(context.ddFlowHome), physicalPath(file)) && fs.statSync(file).isFile())
|
|
1142
|
+
copyRuntimeTree(file, path.join(runtime, path.relative(context.ddFlowHome, file)));
|
|
1143
|
+
else if (![run.project_root, run.workspace_root].some(root => isInside(physicalPath(root), physicalPath(file))))
|
|
1144
|
+
throw new AppError("snapshot_protocol_scope_invalid", "Protocol evidence is outside captured payloads", 1, { path: file });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const homes = new Set([context.ddFlowHome, resourceHome(context)]);
|
|
1148
|
+
const queries = {
|
|
1149
|
+
run_controllers: "SELECT json_extract(manifest_json, '$.resource_home') AS home FROM run_controllers UNION SELECT json_extract(value, '$.owner.resource_home') FROM run_controllers, json_each(state_json, '$.sessions')",
|
|
1150
|
+
run_control_workers: "SELECT resource_home AS home FROM run_control_workers",
|
|
1151
|
+
work_launches: "SELECT json_extract(profile_json, '$.runtime_adapter.owner.resource_home') AS home FROM work_launches",
|
|
1152
|
+
merge_requests: "SELECT json_extract(adapter_receipt_json, '$.owner.resource_home') AS home FROM merge_requests",
|
|
1153
|
+
run_recovery_guards: "SELECT value AS home FROM run_recovery_guards, json_each(settlement_json, '$.resource_homes')"
|
|
1154
|
+
};
|
|
1155
|
+
const references = Object.entries(queries).flatMap(([table, query]) => captured.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", [table]) ? captured.db.all(query) : []);
|
|
1156
|
+
for (const { home } of references) {
|
|
1157
|
+
if (home === null)
|
|
1158
|
+
continue;
|
|
1159
|
+
if (typeof home !== "string" || !path.isAbsolute(home))
|
|
1160
|
+
throw new AppError("snapshot_resource_scope_invalid", "Retained resource home must be an absolute path", 1);
|
|
1161
|
+
homes.add(home);
|
|
1162
|
+
}
|
|
1163
|
+
const nativeCaptures = new Map();
|
|
1164
|
+
const registries = [];
|
|
1165
|
+
for (const home of [...homes].sort()) {
|
|
1166
|
+
if (!path.isAbsolute(home))
|
|
1167
|
+
throw new AppError("snapshot_resource_scope_invalid", "Resource home must be an absolute path", 1);
|
|
1168
|
+
const evidence = readRecoveryResourceEvidence(home, input.projectId, input.runId, { db: captured.db, ddFlowHome: context.ddFlowHome });
|
|
1169
|
+
for (const process of evidence.processes) {
|
|
1170
|
+
const directory = JSON.parse(process.metadata_json).state_dir;
|
|
1171
|
+
if (directory === undefined)
|
|
1172
|
+
continue;
|
|
1173
|
+
if (typeof directory !== "string" || !path.isAbsolute(directory))
|
|
1174
|
+
throw new AppError("snapshot_native_scope_invalid", "Native artifact root must be absolute", 1);
|
|
1175
|
+
if (roots.some(root => isInside(physicalPath(root), physicalPath(directory))) || nativeCaptures.has(directory))
|
|
1176
|
+
continue;
|
|
1177
|
+
const state = JSON.parse(fs.readFileSync(path.join(directory, "daemon.json"), "utf8"));
|
|
1178
|
+
const owner = state.config?.runtime_owner;
|
|
1179
|
+
if (!owner || owner.project_id !== input.projectId || owner.run_id !== input.runId || owner.owner_id !== process.owner_id || owner.dd_flow_home !== context.ddFlowHome || owner.resource_home !== home || owner.state_dir !== directory)
|
|
1180
|
+
throw new AppError("snapshot_native_scope_invalid", "External native artifact root has no matching RUN owner", 1, { state_dir: directory });
|
|
1181
|
+
const relative = path.join("native-evidence", crypto.createHash("sha256").update(directory).digest("hex"));
|
|
1182
|
+
copyRuntimeTree(directory, path.join(runtime, relative), true, codexExclusions);
|
|
1183
|
+
nativeCaptures.set(directory, relative);
|
|
1184
|
+
}
|
|
1185
|
+
const relative = path.join("resource-evidence", `${crypto.createHash("sha256").update(home).digest("hex")}.json`);
|
|
1186
|
+
fs.mkdirSync(path.dirname(path.join(runtime, relative)), { recursive: true });
|
|
1187
|
+
writeJson(path.join(runtime, relative), evidence);
|
|
1188
|
+
registries.push(relative);
|
|
1189
|
+
}
|
|
1190
|
+
writeJson(path.join(runtime, "recovery-evidence.json"), { schema_id: "dd-flow/recovery-runtime-evidence@1", project_id: input.projectId, run_id: input.runId, registries, native_captures: [...nativeCaptures].map(([source_root, payload]) => ({ source_root, payload })) });
|
|
1191
|
+
}
|
|
1192
|
+
function treeHash(root, filtered = false) {
|
|
347
1193
|
const hash = crypto.createHash("sha256");
|
|
1194
|
+
const allowed = typeof filtered === "function" ? filtered : filtered ? projectPayloadFilter(root) : () => true;
|
|
348
1195
|
const visit = (dir, relative) => {
|
|
349
1196
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
350
1197
|
const file = path.join(dir, entry.name);
|
|
351
1198
|
const rel = path.join(relative, entry.name);
|
|
1199
|
+
if (!allowed(file))
|
|
1200
|
+
continue;
|
|
352
1201
|
if (entry.isDirectory())
|
|
353
1202
|
visit(file, rel);
|
|
354
1203
|
else if (entry.isFile()) {
|
|
@@ -364,14 +1213,62 @@ function treeHash(root) {
|
|
|
364
1213
|
visit(root, "");
|
|
365
1214
|
return hash.digest("hex");
|
|
366
1215
|
}
|
|
1216
|
+
function treeModesHash(root, filtered = false) {
|
|
1217
|
+
const entries = [];
|
|
1218
|
+
const allowed = typeof filtered === "function" ? filtered : filtered ? projectPayloadFilter(root) : () => true;
|
|
1219
|
+
const visit = (directory) => {
|
|
1220
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1221
|
+
const file = path.join(directory, entry.name);
|
|
1222
|
+
if (!allowed(file))
|
|
1223
|
+
continue;
|
|
1224
|
+
if (entry.isDirectory())
|
|
1225
|
+
visit(file);
|
|
1226
|
+
else if (entry.isFile())
|
|
1227
|
+
entries.push([path.relative(root, file), fs.statSync(file).mode & 0o111]);
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
visit(root);
|
|
1231
|
+
return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
1232
|
+
}
|
|
1233
|
+
function verifySnapshotModes(snapshot, manifest) {
|
|
1234
|
+
if (!manifest.payload_modes)
|
|
1235
|
+
return; // Legacy snapshots did not attest modes.
|
|
1236
|
+
for (const name of ["project", "runtime", ...(manifest.workspace.kind === "git_worktree" ? ["workspace"] : [])]) {
|
|
1237
|
+
if (treeModesHash(path.join(snapshot, name)) !== manifest.payload_modes[name])
|
|
1238
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot executable modes do not match manifest", 1, { payload: name });
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
367
1241
|
function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
1242
|
+
// Flush children before their directory entries, then publish and flush the
|
|
1243
|
+
// parent. Never follow snapshot symlinks into the original project tree.
|
|
1244
|
+
function syncSnapshotTree(root) {
|
|
1245
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
1246
|
+
const file = path.join(root, entry.name);
|
|
1247
|
+
if (entry.isDirectory())
|
|
1248
|
+
syncSnapshotTree(file);
|
|
1249
|
+
else if (entry.isFile())
|
|
1250
|
+
syncPath(file);
|
|
1251
|
+
}
|
|
1252
|
+
syncPath(root);
|
|
1253
|
+
}
|
|
1254
|
+
function syncPath(file) {
|
|
1255
|
+
const descriptor = fs.openSync(file, "r");
|
|
1256
|
+
try {
|
|
1257
|
+
fs.fsyncSync(descriptor);
|
|
1258
|
+
}
|
|
1259
|
+
finally {
|
|
1260
|
+
fs.closeSync(descriptor);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
368
1263
|
function readManifest(snapshot) {
|
|
369
1264
|
const file = path.join(snapshot, "snapshot.json");
|
|
370
1265
|
if (!fs.existsSync(file))
|
|
371
1266
|
throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
|
|
372
1267
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
373
|
-
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"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose
|
|
1268
|
+
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)
|
|
374
1269
|
throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
|
|
1270
|
+
if (value.runtime_payload_policy !== undefined && (value.runtime_payload_policy !== "flow-evidence@1" || value.purpose === "recovery"))
|
|
1271
|
+
throw new AppError("snapshot_invalid", "Snapshot runtime payload policy is invalid for its purpose", 1, { snapshot });
|
|
375
1272
|
if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
|
|
376
1273
|
throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
|
|
377
1274
|
if (value.workspace.kind === "git_worktree" && (!value.workspace.branch || !value.workspace.base_ref || !value.workspace.bundle_sha256))
|