@deksden-com/dd-flow-cli 0.9.0-beta.19 → 0.9.0-beta.22
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 +20 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/run-cli.js +8 -4
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +4 -6
- package/dist/services/eval-snapshots.js +218 -12
- package/dist/services/hooks.js +67 -28
- package/dist/services/lifecycle-command.js +3 -1
- package/dist/services/merge-server.js +27 -7
- package/dist/services/run-recovery.js +170 -20
- package/dist/services/runs.js +15 -0
- package/dist/services/vnext-merge.js +34 -9
- package/dist/services/work-registry.js +40 -13
- package/dist/storage/database.js +9 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.22
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Reject conflicting or ambiguous lifecycle receipts before any harness can
|
|
8
|
+
mutate a Session binding. Restore a Work-start receipt when its binding
|
|
9
|
+
fails, so the same verified invocation remains safely retryable.
|
|
10
|
+
|
|
11
|
+
## 0.9.0-beta.21
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Validate every selected engine artifact and execute its pinned snapshot even when its version matches the router. Retain a new recovery interruption before root acknowledgement.
|
|
16
|
+
|
|
17
|
+
## 0.9.0-beta.20
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Verify recovery payloads and workspace drift before resuming, preserve staged Git changes, publish snapshots through a temporary directory, and allow explicit recovery restoration into fresh targets. Fence RUN mutations and late asynchronous Work completion while recovery is active.
|
|
22
|
+
|
|
3
23
|
## 0.9.0-beta.19
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.22",
|
|
4
|
+
"cli_commit": "7a08ac0550694b06cec5438d5ed938d19f772418",
|
|
5
|
+
"built_at": "2026-09-07T02:03:13.276Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.0.6",
|
|
8
8
|
"commit": "2aafb30d23e3f646b736b7ead25438cab97b16bf",
|
package/dist/cli/help.js
CHANGED
|
@@ -157,7 +157,7 @@ Usage:
|
|
|
157
157
|
dd-flow run prepare-vnext-specify --project-root <root> --slug <slug> --json
|
|
158
158
|
dd-flow run recovery inspect|begin|seal|capture|resume <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
159
159
|
dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate|--incomplete|--recovery-id <RCV-ID>) --project-root <root> --output <directory> --json
|
|
160
|
-
dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> --json
|
|
160
|
+
dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> [--recovery-id <RCV-ID>] --json
|
|
161
161
|
dd-flow run snapshot bootstrap create --project-root <root> --output <directory> --json
|
|
162
162
|
dd-flow run snapshot bootstrap restore --snapshot <directory> --project-root <prepared-root> --json
|
|
163
163
|
dd-flow run timeline <RUN-ID|RUN-short-id> --project-root <root> --json
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -49,7 +49,7 @@ import { renderWorkerPrompt } from "../services/prompts.js";
|
|
|
49
49
|
import { finishStage, startStage } from "../services/stage-lifecycle.js";
|
|
50
50
|
import { addWorkBatch, assertStageLifecycleOwner, assertWorkLifecycleOwner, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
|
|
51
51
|
import { createEvalBootstrapSnapshot, createEvalRunSnapshot, prepareVnextSpecifyRun, restoreEvalBootstrapSnapshot, restoreEvalRunSnapshot } from "../services/eval-snapshots.js";
|
|
52
|
-
import { beginRunRecovery, inspectRunRecovery, recordRecoveryCapture, resumeRunRecovery, sealRunRecovery } from "../services/run-recovery.js";
|
|
52
|
+
import { acceptRunRecovery, beginRunRecovery, inspectRunRecovery, recordRecoveryCapture, resumeRunRecovery, sealRunRecovery } from "../services/run-recovery.js";
|
|
53
53
|
import { loadExternalStageContext } from "../services/stage-context.js";
|
|
54
54
|
import { stopManagedProcess, confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, reconcileExpiredManagedProcesses, registerManagedProcess } from "../services/managed-processes.js";
|
|
55
55
|
import { startRuntimeService } from "../services/runtime-service.js";
|
|
@@ -763,8 +763,9 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
763
763
|
return deleteWork(context, requiredPosition(parsed, 0, "work-id"));
|
|
764
764
|
if (family === "work" && command === "start") {
|
|
765
765
|
const hookEventId = optionalOption(parsed, "hook-event-id");
|
|
766
|
+
const recoveryId = optionalOption(parsed, "recovery-id");
|
|
766
767
|
const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work start");
|
|
767
|
-
return startWork(context, requiredPosition(parsed, 0, "work-id"), { ...(hookEventId ? { hookEventId } : {}), projectRoot });
|
|
768
|
+
return startWork(context, requiredPosition(parsed, 0, "work-id"), { ...(hookEventId ? { hookEventId } : {}), ...(recoveryId ? { recoveryId } : {}), projectRoot });
|
|
768
769
|
}
|
|
769
770
|
if (family === "work" && command === "finish") {
|
|
770
771
|
const resultFile = optionalOption(parsed, "result-file");
|
|
@@ -1476,8 +1477,10 @@ function dispatchRun(context, command, parsed) {
|
|
|
1476
1477
|
if (action === "capture")
|
|
1477
1478
|
return recordRecoveryCapture(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), capturePath: requiredOption(parsed, "capture-path") });
|
|
1478
1479
|
if (action === "resume")
|
|
1479
|
-
return resumeRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id") });
|
|
1480
|
-
|
|
1480
|
+
return resumeRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), daemonId: requiredOption(parsed, "daemon-id"), harness: requiredOption(parsed, "harness"), nativeSessionId: requiredOption(parsed, "native-session-id") });
|
|
1481
|
+
if (action === "accept")
|
|
1482
|
+
return acceptRunRecovery(context, { projectRoot, runId, recoveryId: requiredOption(parsed, "recovery-id"), ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
|
|
1483
|
+
throw new AppError("usage", "Use: dd-flow run recovery inspect|begin|seal|capture|resume|accept", 2);
|
|
1481
1484
|
}
|
|
1482
1485
|
if (command === "snapshot") {
|
|
1483
1486
|
const action = requiredPosition(parsed, 0, "snapshot action");
|
|
@@ -1518,6 +1521,7 @@ function dispatchRun(context, command, parsed) {
|
|
|
1518
1521
|
if (action === "restore")
|
|
1519
1522
|
return restoreEvalRunSnapshot(context, {
|
|
1520
1523
|
snapshot: requiredOption(parsed, "snapshot"),
|
|
1524
|
+
...(optionalOption(parsed, "recovery-id") ? { recoveryId: requiredOption(parsed, "recovery-id") } : {}),
|
|
1521
1525
|
projectRoot: requiredOption(parsed, "project-root")
|
|
1522
1526
|
});
|
|
1523
1527
|
throw new AppError("usage", "Use: dd-flow run snapshot bootstrap create|restore | dd-flow run snapshot create <RUN> (--stage-entry <stage>|--candidate) --project-root <path> --output <directory> | dd-flow run snapshot restore --snapshot <directory> --project-root <path>", 2);
|
|
@@ -19,6 +19,8 @@ export function getCmuxStatus(context, input) {
|
|
|
19
19
|
}
|
|
20
20
|
export function renderDashboard(context, input) {
|
|
21
21
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
22
|
+
if (projectRecoveryGuarded(context, project.id))
|
|
23
|
+
throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
|
|
22
24
|
const format = parseDashboardFormat(input.format);
|
|
23
25
|
if (format === "html") {
|
|
24
26
|
if (input.protocol) {
|
|
@@ -170,6 +172,10 @@ export function refreshAllDashboards(context, input = {}) {
|
|
|
170
172
|
}
|
|
171
173
|
export function autoRefreshDashboards(context, input) {
|
|
172
174
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
175
|
+
// Hooks and usage ingestion continue to record receipts after capture.
|
|
176
|
+
// Their derived timestamp must not mutate the captured workspace.
|
|
177
|
+
if (projectRecoveryGuarded(context, project.id))
|
|
178
|
+
return { ok: true, skipped: true, reason: "run_recovery_guarded" };
|
|
173
179
|
const config = readProjectConfig(context, project.id);
|
|
174
180
|
if (!config.dashboard.auto_refresh) {
|
|
175
181
|
return { ok: true, skipped: true, reason: "dashboard_auto_refresh_disabled" };
|
|
@@ -196,6 +202,9 @@ export function autoRefreshDashboards(context, input) {
|
|
|
196
202
|
}
|
|
197
203
|
return result;
|
|
198
204
|
}
|
|
205
|
+
function projectRecoveryGuarded(context, projectId) {
|
|
206
|
+
return Boolean(context.db.get("SELECT 1 FROM run_recovery_guards WHERE project_id = ? AND status IN ('draining', 'sealed', 'resuming') LIMIT 1", [projectId]));
|
|
207
|
+
}
|
|
199
208
|
export function dashboardSummary(context, projectId, projectRoot) {
|
|
200
209
|
const config = readProjectConfig(context, projectId);
|
|
201
210
|
return {
|
package/dist/services/engines.js
CHANGED
|
@@ -174,7 +174,9 @@ export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedPr
|
|
|
174
174
|
if (!selection.selected)
|
|
175
175
|
return Promise.resolve(null);
|
|
176
176
|
const current = getCliBuildInfo();
|
|
177
|
-
|
|
177
|
+
const currentRoot = findPackageRoot(path.dirname(sourceFile));
|
|
178
|
+
if (selection.selected.package_name === current.package_name && selection.selected.package_version === current.version
|
|
179
|
+
&& currentRoot !== null && fs.realpathSync(selection.selected.snapshot_root) === fs.realpathSync(currentRoot)) {
|
|
178
180
|
return Promise.resolve({ routed: true, exitCode: 0, inProcess: true });
|
|
179
181
|
}
|
|
180
182
|
const entrypoint = resolveManifestEntrypoint(selection.selected);
|
|
@@ -383,11 +385,7 @@ export function selectEngine(context, input, options = {}, classification = inpu
|
|
|
383
385
|
const manifests = readInstalledManifests(engineStoreRoot(context.ddFlowHome))
|
|
384
386
|
.filter((manifest) => manifest.package_name === packageName)
|
|
385
387
|
.filter((manifest) => requiredRange !== null && satisfiesRange(manifest.package_version, requiredRange))
|
|
386
|
-
.filter((manifest) =>
|
|
387
|
-
&& manifest.package_name === build.package_name
|
|
388
|
-
&& manifest.package_version === build.version
|
|
389
|
-
? true
|
|
390
|
-
: manifestHealthy(manifest))
|
|
388
|
+
.filter((manifest) => manifestHealthy(manifest))
|
|
391
389
|
.sort((a, b) => {
|
|
392
390
|
const versionOrder = compareSemverStrings(b.package_version, a.package_version);
|
|
393
391
|
if (versionOrder !== 0)
|
|
@@ -49,6 +49,31 @@ export function restoreEvalBootstrapSnapshot(_context, input) {
|
|
|
49
49
|
* to infer a safe subset of SQLite rows.
|
|
50
50
|
*/
|
|
51
51
|
export function createEvalRunSnapshot(context, input) {
|
|
52
|
+
const output = path.resolve(input.output);
|
|
53
|
+
if (fs.existsSync(output))
|
|
54
|
+
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
55
|
+
const temporary = `${output}.partial-${crypto.randomUUID()}`;
|
|
56
|
+
try {
|
|
57
|
+
const result = captureEvalRunSnapshot(context, { ...input, output: temporary });
|
|
58
|
+
if (input.recoveryId) {
|
|
59
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
60
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
61
|
+
const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
62
|
+
verifyRecoverySnapshot(temporary, { runId: input.runId, recoveryId: input.recoveryId, projectRoot, workspaceRoot: run?.workspace_root ?? projectRoot });
|
|
63
|
+
}
|
|
64
|
+
if (fs.existsSync(output))
|
|
65
|
+
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
66
|
+
syncSnapshotTree(temporary);
|
|
67
|
+
fs.renameSync(temporary, output);
|
|
68
|
+
syncPath(path.dirname(output));
|
|
69
|
+
return { ...result, snapshot: output };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function captureEvalRunSnapshot(context, input) {
|
|
52
77
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
53
78
|
// Snapshot is read-only at the control-plane boundary. An invalid workspace
|
|
54
79
|
// root must fail without registering a second project in a dedicated home.
|
|
@@ -117,6 +142,7 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
117
142
|
project_sha256: treeHash(path.join(output, "project")),
|
|
118
143
|
project_git: projectGit,
|
|
119
144
|
workspace,
|
|
145
|
+
payload_modes: { project: treeModesHash(path.join(output, "project")), runtime: treeModesHash(path.join(output, "runtime")), ...(workspace.kind === "git_worktree" ? { workspace: treeModesHash(path.join(output, "workspace")) } : {}) },
|
|
120
146
|
source_status: status
|
|
121
147
|
};
|
|
122
148
|
writeJson(path.join(output, "snapshot.json"), manifest);
|
|
@@ -135,13 +161,21 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
135
161
|
const manifest = readManifest(snapshot);
|
|
136
162
|
if (manifest.purpose === "incomplete")
|
|
137
163
|
throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
const recovery = manifest.purpose === "recovery";
|
|
165
|
+
if (recovery && input.recoveryId !== manifest.recovery_id)
|
|
166
|
+
throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require their explicit --recovery-id", 1, { snapshot });
|
|
167
|
+
if (!recovery && input.recoveryId)
|
|
168
|
+
throw new AppError("snapshot_invalid", "Recovery ID cannot restore a non-recovery snapshot", 1, { snapshot });
|
|
140
169
|
if (manifest.purpose === "candidate")
|
|
141
170
|
throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
|
|
142
|
-
if (!manifest.stage_entry)
|
|
171
|
+
if (!recovery && !manifest.stage_entry)
|
|
143
172
|
throw new AppError("snapshot_invalid", "Stage-entry snapshot has no target stage", 1, { snapshot });
|
|
144
173
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
174
|
+
if (recovery) {
|
|
175
|
+
if (fs.readdirSync(projectRoot).length || (context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0) > 0)
|
|
176
|
+
throw new AppError("recovery_restore_target_not_empty", "Recovery restore requires fresh project and runtime targets", 1);
|
|
177
|
+
verifyRecoverySnapshot(snapshot, { runId: manifest.run_id, recoveryId: input.recoveryId, projectRoot, workspaceRoot: projectRoot }, false);
|
|
178
|
+
}
|
|
145
179
|
const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"].includes(name));
|
|
146
180
|
if (existing.length > 0)
|
|
147
181
|
throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
|
|
@@ -151,6 +185,7 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
151
185
|
throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match manifest", 1, { snapshot });
|
|
152
186
|
if (treeHash(sourceProject) !== manifest.project_sha256)
|
|
153
187
|
throw new AppError("snapshot_checksum_mismatch", "Snapshot project checksum does not match manifest", 1, { snapshot });
|
|
188
|
+
verifySnapshotModes(snapshot, manifest);
|
|
154
189
|
clearProjectTree(projectRoot, false);
|
|
155
190
|
restoreProjectGit(snapshot, manifest.project_git, projectRoot);
|
|
156
191
|
replaceProjectTree(projectRoot, sourceProject);
|
|
@@ -162,14 +197,26 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
162
197
|
// A fresh process owns all subsequent normal commands.
|
|
163
198
|
const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
|
|
164
199
|
try {
|
|
165
|
-
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome);
|
|
200
|
+
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome, recovery);
|
|
166
201
|
registerProject(restored, { root: projectRoot });
|
|
167
202
|
requireProjectByRoot(restored, projectRoot);
|
|
168
203
|
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
169
|
-
|
|
204
|
+
if (!recovery)
|
|
205
|
+
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
206
|
+
else
|
|
207
|
+
restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
|
|
170
208
|
if (!status.run.run_root)
|
|
171
209
|
throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
|
|
172
210
|
const runHome = status.run.run_root;
|
|
211
|
+
const relocation = recovery ? {
|
|
212
|
+
schema_id: "dd-flow/recovery-relocation@1", physical_runtime_id: crypto.randomUUID(), recovery_id: input.recoveryId, run_id: manifest.run_id,
|
|
213
|
+
path_mapping: [{ from: manifest.dd_flow_home, to: context.ddFlowHome }, { from: sourceWorkspaceRoot(manifest), to: workspaceRoot }, { from: manifest.project_root, to: projectRoot }]
|
|
214
|
+
} : null;
|
|
215
|
+
const relocationFile = relocation ? path.join(context.ddFlowHome, "recovery-imports", `${relocation.physical_runtime_id}.json`) : null;
|
|
216
|
+
if (relocationFile) {
|
|
217
|
+
fs.mkdirSync(path.dirname(relocationFile), { recursive: true });
|
|
218
|
+
writeJson(relocationFile, relocation);
|
|
219
|
+
}
|
|
173
220
|
return {
|
|
174
221
|
ok: true,
|
|
175
222
|
schema_id: "dd-flow/eval-run-restore@1",
|
|
@@ -180,7 +227,8 @@ export function restoreEvalRunSnapshot(context, input) {
|
|
|
180
227
|
dd_flow_home: context.ddFlowHome,
|
|
181
228
|
run_home: runHome,
|
|
182
229
|
target_stage: manifest.stage_entry,
|
|
183
|
-
|
|
230
|
+
...(recovery ? { recovery_id: input.recoveryId, native_session_portability: "requires_adapter_verification", relocation_file: relocationFile, relocation } : {}),
|
|
231
|
+
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`
|
|
184
232
|
};
|
|
185
233
|
}
|
|
186
234
|
finally {
|
|
@@ -221,7 +269,7 @@ function assertTerminalCandidate(stages) {
|
|
|
221
269
|
if (unfinished.length > 0)
|
|
222
270
|
throw new AppError("snapshot_candidate_incomplete", "Candidate snapshot requires every started stage to be done", 1, { unfinished });
|
|
223
271
|
}
|
|
224
|
-
function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
272
|
+
function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome, recovery = false) {
|
|
225
273
|
context.db.exec("PRAGMA foreign_keys = OFF");
|
|
226
274
|
context.db.exec("BEGIN IMMEDIATE");
|
|
227
275
|
try {
|
|
@@ -242,7 +290,10 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
|
|
|
242
290
|
rewriteDatabaseColumn(context, "usage", "source_locator", oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
243
291
|
for (const column of ["worktree_path", "worktrunk_metadata_json", "last_command_result_json"])
|
|
244
292
|
rewriteDatabaseColumn(context, "worktree_records", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
245
|
-
|
|
293
|
+
// Recovery imports remain sealed. Their old Work sessions are historical
|
|
294
|
+
// bindings, not completed work; explicit recovery closes them interrupted.
|
|
295
|
+
if (!recovery)
|
|
296
|
+
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()]);
|
|
246
297
|
context.db.run("DELETE FROM codex_session_bindings");
|
|
247
298
|
context.db.run("COMMIT");
|
|
248
299
|
}
|
|
@@ -253,7 +304,10 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
|
|
|
253
304
|
finally {
|
|
254
305
|
context.db.exec("PRAGMA foreign_keys = ON");
|
|
255
306
|
}
|
|
256
|
-
|
|
307
|
+
// SQLite owns operational paths. Imported packets, results and journals are
|
|
308
|
+
// historical bytes; the relocation receipt records their path mapping.
|
|
309
|
+
if (!recovery)
|
|
310
|
+
rewriteStructuredFiles(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
257
311
|
}
|
|
258
312
|
function rewriteStructuredFiles(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
259
313
|
const visit = (dir) => {
|
|
@@ -303,7 +357,7 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
|
|
|
303
357
|
const bundle = path.join(output, "workspace.bundle");
|
|
304
358
|
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
|
|
305
359
|
runGit(projectRoot, ["bundle", "create", bundle, branch]);
|
|
306
|
-
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
|
|
360
|
+
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle), ...snapshotIndex(workspaceRoot, output, "workspace") };
|
|
307
361
|
}
|
|
308
362
|
function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
|
|
309
363
|
if (manifest.workspace.kind === "project")
|
|
@@ -326,6 +380,7 @@ function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
|
|
|
326
380
|
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 });
|
|
327
381
|
runGit(projectRoot, ["worktree", "add", workspaceRoot, manifest.workspace.branch]);
|
|
328
382
|
replaceProjectTree(workspaceRoot, source);
|
|
383
|
+
restoreIndex(snapshot, "workspace", manifest.workspace, workspaceRoot);
|
|
329
384
|
return fs.realpathSync(workspaceRoot);
|
|
330
385
|
}
|
|
331
386
|
function sourceWorkspaceRoot(manifest) {
|
|
@@ -338,7 +393,7 @@ function snapshotProjectGit(projectRoot, output) {
|
|
|
338
393
|
throw new AppError("snapshot_project_git_invalid", "Snapshot requires a project on a named Git branch with a readable HEAD", 1, { project_root: projectRoot });
|
|
339
394
|
const bundle = path.join(output, "project.bundle");
|
|
340
395
|
runGit(projectRoot, ["bundle", "create", bundle, "--all"]);
|
|
341
|
-
return { branch, head, bundle_sha256: fileHash(bundle) };
|
|
396
|
+
return { branch, head, bundle_sha256: fileHash(bundle), ...snapshotIndex(projectRoot, output, "project") };
|
|
342
397
|
}
|
|
343
398
|
function restoreProjectGit(snapshot, source, projectRoot) {
|
|
344
399
|
const bundle = path.join(snapshot, "project.bundle");
|
|
@@ -348,6 +403,110 @@ function restoreProjectGit(snapshot, source, projectRoot) {
|
|
|
348
403
|
runGit(projectRoot, ["init", "-b", source.branch]);
|
|
349
404
|
runGit(projectRoot, ["fetch", bundle, `+refs/heads/${source.branch}:refs/remotes/snapshot/${source.branch}`]);
|
|
350
405
|
runGit(projectRoot, ["checkout", "-B", source.branch, source.head]);
|
|
406
|
+
restoreIndex(snapshot, "project", source, projectRoot);
|
|
407
|
+
}
|
|
408
|
+
const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
|
|
409
|
+
function indexEntries(root) {
|
|
410
|
+
const entries = gitBytes(root, ["ls-files", "--stage", "-z", "--", ".", ":(exclude)**/.env", ":(exclude)**/.env.*"]);
|
|
411
|
+
const merge = {};
|
|
412
|
+
for (const name of mergeStateFiles) {
|
|
413
|
+
const file = git(root, ["rev-parse", "--git-path", name]);
|
|
414
|
+
if (!file)
|
|
415
|
+
throw new AppError("snapshot_index_invalid", "Could not locate Git merge state", 1);
|
|
416
|
+
const resolved = path.resolve(root, file);
|
|
417
|
+
if (fs.existsSync(resolved))
|
|
418
|
+
merge[name] = fs.readFileSync(resolved).toString("base64");
|
|
419
|
+
}
|
|
420
|
+
return Buffer.from(JSON.stringify({ entries: entries.toString("base64"), merge }));
|
|
421
|
+
}
|
|
422
|
+
function snapshotIndex(root, output, name) {
|
|
423
|
+
const bytes = indexEntries(root);
|
|
424
|
+
const value = JSON.parse(bytes.toString());
|
|
425
|
+
const objects = new Set();
|
|
426
|
+
for (const entry of Buffer.from(value.entries, "base64").toString("utf8").split("\0").filter(Boolean)) {
|
|
427
|
+
const match = /^\d{6} ([a-f0-9]{40,64}) [0-3]\t/.exec(entry);
|
|
428
|
+
if (!match)
|
|
429
|
+
throw new AppError("snapshot_index_invalid", "Malformed Git index entry", 1);
|
|
430
|
+
// Gitlinks belong to another repository and are preserved as index IDs.
|
|
431
|
+
if (!entry.startsWith("160000 "))
|
|
432
|
+
objects.add(match[1]);
|
|
433
|
+
}
|
|
434
|
+
for (const name of ["MERGE_HEAD", "ORIG_HEAD", "AUTO_MERGE"]) {
|
|
435
|
+
if (value.merge[name])
|
|
436
|
+
for (const oid of Buffer.from(value.merge[name], "base64").toString().trim().split("\n")) {
|
|
437
|
+
if (!/^[a-f0-9]{40,64}$/.test(oid))
|
|
438
|
+
throw new AppError("snapshot_index_invalid", "Malformed Git merge reference", 1);
|
|
439
|
+
objects.add(oid);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const payload = path.join(output, `${name}.index.patch`);
|
|
443
|
+
const pack = path.join(output, `${name}.index.pack`);
|
|
444
|
+
fs.writeFileSync(payload, bytes);
|
|
445
|
+
fs.writeFileSync(pack, gitBytes(root, ["pack-objects", "--stdout", "--revs"], Buffer.from([...objects].sort().join("\n") + "\n")));
|
|
446
|
+
return { index_format: "entries@1", index_sha256: fileHash(payload), index_objects_sha256: fileHash(pack) };
|
|
447
|
+
}
|
|
448
|
+
function gitBytes(root, args, input) {
|
|
449
|
+
const result = spawnSync("git", ["-C", root, ...args], { input, maxBuffer: 64 * 1024 * 1024 });
|
|
450
|
+
if (result.status !== 0)
|
|
451
|
+
throw new AppError("snapshot_index_invalid", "Could not preserve Git index objects", 1, { args, stderr: result.stderr?.toString().trim() });
|
|
452
|
+
return result.stdout;
|
|
453
|
+
}
|
|
454
|
+
function indexPatch(root) {
|
|
455
|
+
const result = spawnSync("git", ["-C", root, "diff", "--cached", "--binary", "--full-index", "--no-ext-diff", "HEAD", "--", ".", ":(exclude)**/.env", ":(exclude)**/.env.*"], { maxBuffer: 64 * 1024 * 1024 });
|
|
456
|
+
if (result.status !== 0)
|
|
457
|
+
throw new AppError("snapshot_index_invalid", "Could not capture Git index", 1);
|
|
458
|
+
return result.stdout;
|
|
459
|
+
}
|
|
460
|
+
function restoreIndex(snapshot, name, metadata, root) {
|
|
461
|
+
if (!metadata.index_sha256)
|
|
462
|
+
return;
|
|
463
|
+
const file = path.join(snapshot, `${name}.index.patch`);
|
|
464
|
+
if (fileHash(file) !== metadata.index_sha256)
|
|
465
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index checksum does not match", 1);
|
|
466
|
+
if (metadata.index_format === "entries@1") {
|
|
467
|
+
const pack = path.join(snapshot, `${name}.index.pack`);
|
|
468
|
+
if (!metadata.index_objects_sha256 || fileHash(pack) !== metadata.index_objects_sha256)
|
|
469
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot Git index object checksum does not match", 1);
|
|
470
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
471
|
+
gitBytes(root, ["unpack-objects"], fs.readFileSync(pack));
|
|
472
|
+
runGit(root, ["read-tree", "--empty"]);
|
|
473
|
+
gitBytes(root, ["update-index", "-z", "--index-info"], Buffer.from(value.entries, "base64"));
|
|
474
|
+
for (const name of mergeStateFiles) {
|
|
475
|
+
const target = git(root, ["rev-parse", "--git-path", name]);
|
|
476
|
+
if (!target)
|
|
477
|
+
throw new AppError("snapshot_index_invalid", "Could not locate restored Git merge state", 1);
|
|
478
|
+
if (value.merge[name] !== undefined)
|
|
479
|
+
fs.writeFileSync(path.resolve(root, target), Buffer.from(value.merge[name], "base64"));
|
|
480
|
+
else
|
|
481
|
+
fs.rmSync(path.resolve(root, target), { force: true });
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (fs.statSync(file).size)
|
|
486
|
+
runGit(root, ["apply", "--cached", "--binary", file]);
|
|
487
|
+
}
|
|
488
|
+
export function verifyRecoverySnapshot(snapshot, input, checkWorkingState = true) {
|
|
489
|
+
const manifest = readManifest(snapshot);
|
|
490
|
+
if (manifest.purpose !== "recovery" || manifest.recovery_id !== input.recoveryId || manifest.run_id !== input.runId)
|
|
491
|
+
throw new AppError("recovery_capture_invalid", "Snapshot does not identify this recovery", 1);
|
|
492
|
+
const entries = [
|
|
493
|
+
{ name: "project", root: input.projectRoot, hash: manifest.project_sha256, git: manifest.project_git, head: manifest.project_git.head },
|
|
494
|
+
...(manifest.workspace.kind === "git_worktree" ? [{ name: "workspace", root: input.workspaceRoot, hash: manifest.workspace.sha256, git: manifest.workspace, head: manifest.workspace.base_ref }] : [])
|
|
495
|
+
];
|
|
496
|
+
if (treeHash(path.join(snapshot, "runtime")) !== manifest.runtime_sha256)
|
|
497
|
+
throw new AppError("snapshot_checksum_mismatch", "Recovery runtime checksum does not match", 1);
|
|
498
|
+
verifySnapshotModes(snapshot, manifest);
|
|
499
|
+
for (const entry of entries) {
|
|
500
|
+
const expectedModes = manifest.payload_modes?.[entry.name];
|
|
501
|
+
if (checkWorkingState && expectedModes && treeModesHash(entry.root, true) !== expectedModes)
|
|
502
|
+
throw new AppError("recovery_workspace_drift", "Executable file modes changed after recovery capture", 1, { payload: entry.name });
|
|
503
|
+
if (treeHash(path.join(snapshot, entry.name)) !== entry.hash || fileHash(path.join(snapshot, `${entry.name}.bundle`)) !== entry.git.bundle_sha256 || (entry.git.index_sha256 && fileHash(path.join(snapshot, `${entry.name}.index.patch`)) !== entry.git.index_sha256))
|
|
504
|
+
throw new AppError("snapshot_checksum_mismatch", "Recovery payload checksum does not match", 1, { payload: entry.name });
|
|
505
|
+
if (entry.git.index_format === "entries@1" && (!entry.git.index_objects_sha256 || fileHash(path.join(snapshot, `${entry.name}.index.pack`)) !== entry.git.index_objects_sha256))
|
|
506
|
+
throw new AppError("snapshot_checksum_mismatch", "Recovery Git object checksum does not match", 1, { payload: entry.name });
|
|
507
|
+
if (checkWorkingState && (treeHash(entry.root, true) !== 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)))
|
|
508
|
+
throw new AppError("recovery_workspace_drift", "Working files or Git state changed after recovery capture", 1, { payload: entry.name });
|
|
509
|
+
}
|
|
351
510
|
}
|
|
352
511
|
function git(root, args) {
|
|
353
512
|
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
@@ -364,12 +523,14 @@ function snapshotPayloadAllowed(source) {
|
|
|
364
523
|
return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
|
|
365
524
|
&& name !== ".env" && !name.startsWith(".env.");
|
|
366
525
|
}
|
|
367
|
-
function treeHash(root) {
|
|
526
|
+
function treeHash(root, filtered = false) {
|
|
368
527
|
const hash = crypto.createHash("sha256");
|
|
369
528
|
const visit = (dir, relative) => {
|
|
370
529
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
371
530
|
const file = path.join(dir, entry.name);
|
|
372
531
|
const rel = path.join(relative, entry.name);
|
|
532
|
+
if (filtered && !snapshotPayloadAllowed(file))
|
|
533
|
+
continue;
|
|
373
534
|
if (entry.isDirectory())
|
|
374
535
|
visit(file, rel);
|
|
375
536
|
else if (entry.isFile()) {
|
|
@@ -385,7 +546,52 @@ function treeHash(root) {
|
|
|
385
546
|
visit(root, "");
|
|
386
547
|
return hash.digest("hex");
|
|
387
548
|
}
|
|
549
|
+
function treeModesHash(root, filtered = false) {
|
|
550
|
+
const entries = [];
|
|
551
|
+
const visit = (directory) => {
|
|
552
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
553
|
+
const file = path.join(directory, entry.name);
|
|
554
|
+
if (filtered && !snapshotPayloadAllowed(file))
|
|
555
|
+
continue;
|
|
556
|
+
if (entry.isDirectory())
|
|
557
|
+
visit(file);
|
|
558
|
+
else if (entry.isFile())
|
|
559
|
+
entries.push([path.relative(root, file), fs.statSync(file).mode & 0o111]);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
visit(root);
|
|
563
|
+
return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
564
|
+
}
|
|
565
|
+
function verifySnapshotModes(snapshot, manifest) {
|
|
566
|
+
if (!manifest.payload_modes)
|
|
567
|
+
return; // Legacy snapshots did not attest modes.
|
|
568
|
+
for (const name of ["project", "runtime", ...(manifest.workspace.kind === "git_worktree" ? ["workspace"] : [])]) {
|
|
569
|
+
if (treeModesHash(path.join(snapshot, name)) !== manifest.payload_modes[name])
|
|
570
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot executable modes do not match manifest", 1, { payload: name });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
388
573
|
function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
574
|
+
// Flush children before their directory entries, then publish and flush the
|
|
575
|
+
// parent. Never follow snapshot symlinks into the original project tree.
|
|
576
|
+
function syncSnapshotTree(root) {
|
|
577
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
578
|
+
const file = path.join(root, entry.name);
|
|
579
|
+
if (entry.isDirectory())
|
|
580
|
+
syncSnapshotTree(file);
|
|
581
|
+
else if (entry.isFile())
|
|
582
|
+
syncPath(file);
|
|
583
|
+
}
|
|
584
|
+
syncPath(root);
|
|
585
|
+
}
|
|
586
|
+
function syncPath(file) {
|
|
587
|
+
const descriptor = fs.openSync(file, "r");
|
|
588
|
+
try {
|
|
589
|
+
fs.fsyncSync(descriptor);
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
fs.closeSync(descriptor);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
389
595
|
function readManifest(snapshot) {
|
|
390
596
|
const file = path.join(snapshot, "snapshot.json");
|
|
391
597
|
if (!fs.existsSync(file))
|