@deksden-com/dd-flow-cli 0.7.0 → 0.8.0
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 +666 -0
- package/README.md +7 -2
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +88 -10
- package/dist/cli/run-cli.js +523 -28
- package/dist/domain/stage-catalog.js +22 -0
- package/dist/domain/validation.js +1 -1
- package/dist/schemas/code-review-decision.schema.json +26 -0
- package/dist/schemas/code-review-result.schema.json +14 -0
- package/dist/schemas/code-verification.schema.json +14 -0
- package/dist/schemas/code-work-batch.schema.json +24 -0
- package/dist/schemas/code-work-result.schema.json +16 -0
- package/dist/schemas/compatibility.schema.json +32 -0
- package/dist/schemas/flow-contract.schema.json +9 -5
- package/dist/schemas/flow-run.schema.json +16 -123
- package/dist/schemas/plan-aspect-map.schema.json +22 -0
- package/dist/schemas/plan-review-decision.schema.json +14 -0
- package/dist/schemas/plan-review-result.schema.json +42 -0
- package/dist/schemas/protocol-plan.schema.json +15 -182
- package/dist/schemas/stage-finish-input.schema.json +16 -2
- package/dist/schemas/stage-report.schema.json +8 -7
- package/dist/schemas/stage-start-response.schema.json +4 -2
- package/dist/schemas/status-report.schema.json +76 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
- package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
- package/dist/schemas/vnext-specify.schema.json +45 -0
- package/dist/services/branch-context.js +1 -1
- package/dist/services/cleanup.js +8 -8
- package/dist/services/cli-operation-classifier.js +10 -2
- package/dist/services/code-checks.js +244 -0
- package/dist/services/config.js +7 -1
- package/dist/services/dashboard.js +12 -12
- package/dist/services/engines.js +1 -1
- package/dist/services/eval-snapshots.js +404 -0
- package/dist/services/hooks.js +774 -18
- package/dist/services/ids.js +16 -6
- package/dist/services/lanes.js +1 -1
- package/dist/services/merge-queue.js +5 -5
- package/dist/services/merge-worker.js +2 -2
- package/dist/services/migrations.js +2 -2
- package/dist/services/plan-runtime.js +1 -1
- package/dist/services/projects.js +4 -4
- package/dist/services/prompts.js +17 -11
- package/dist/services/protocols.js +8 -8
- package/dist/services/run-projection.js +49 -13
- package/dist/services/runs.js +504 -51
- package/dist/services/schema-validation.js +21 -3
- package/dist/services/sessions.js +51 -12
- package/dist/services/stage-blocker.js +57 -0
- package/dist/services/stage-context.js +90 -0
- package/dist/services/stage-lifecycle.js +198 -75
- package/dist/services/stage-pause.js +175 -0
- package/dist/services/stage-report-renderer.js +65 -0
- package/dist/services/usage.js +526 -18
- package/dist/services/vnext-code-review.js +305 -0
- package/dist/services/vnext-code.js +686 -0
- package/dist/services/vnext-contracts.js +1 -0
- package/dist/services/vnext-execution-profile.js +27 -0
- package/dist/services/vnext-fanout.js +79 -0
- package/dist/services/vnext-plan-review.js +499 -0
- package/dist/services/vnext-plan.js +552 -0
- package/dist/services/vnext-protocolize.js +542 -0
- package/dist/services/vnext-specify.js +595 -0
- package/dist/services/vnext-workspace-policy.js +87 -0
- package/dist/services/work-registry.js +522 -0
- package/dist/services/worktrees.js +58 -37
- package/dist/storage/database.js +263 -34
- package/dist/storage/paths.js +47 -1
- package/package.json +12 -12
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createContext } from "../runtime/context.js";
|
|
6
|
+
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
8
|
+
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
9
|
+
import { getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
10
|
+
import { readVnextFlowDefinition } from "./vnext-specify.js";
|
|
11
|
+
/** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
|
|
12
|
+
export function createEvalBootstrapSnapshot(context, input) {
|
|
13
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
14
|
+
const output = path.resolve(input.output);
|
|
15
|
+
if (fs.existsSync(output))
|
|
16
|
+
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
17
|
+
if (isInside(physicalPath(projectRoot), physicalPath(output)))
|
|
18
|
+
throw new AppError("snapshot_output_inside_project", "Bootstrap snapshot output must be outside the project tree", 1, { output });
|
|
19
|
+
if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
|
|
20
|
+
throw new AppError("snapshot_output_inside_runtime", "Bootstrap snapshot output must be outside DD_FLOW_HOME", 1, { output });
|
|
21
|
+
fs.mkdirSync(output, { recursive: true });
|
|
22
|
+
fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
|
|
23
|
+
const manifest = {
|
|
24
|
+
schema_id: "dd-eval/bootstrap-snapshot@1",
|
|
25
|
+
stage: "specify",
|
|
26
|
+
project_root: projectRoot,
|
|
27
|
+
project_sha256: treeHash(path.join(output, "project")),
|
|
28
|
+
project_git: snapshotProjectGit(projectRoot, output)
|
|
29
|
+
};
|
|
30
|
+
writeJson(path.join(output, "bootstrap.json"), manifest);
|
|
31
|
+
return { ok: true, snapshot: output, ...manifest };
|
|
32
|
+
}
|
|
33
|
+
/** Restore a pre-RUN project state; `stage start --bootstrap` creates runtime state afterwards. */
|
|
34
|
+
export function restoreEvalBootstrapSnapshot(_context, input) {
|
|
35
|
+
const snapshot = path.resolve(input.snapshot);
|
|
36
|
+
const manifest = readBootstrapManifest(snapshot);
|
|
37
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
38
|
+
const sourceProject = path.join(snapshot, "project");
|
|
39
|
+
if (treeHash(sourceProject) !== manifest.project_sha256)
|
|
40
|
+
throw new AppError("snapshot_checksum_mismatch", "Bootstrap project checksum does not match manifest", 1, { snapshot });
|
|
41
|
+
clearProjectTree(projectRoot, false);
|
|
42
|
+
restoreProjectGit(snapshot, manifest.project_git, projectRoot);
|
|
43
|
+
replaceProjectTree(projectRoot, sourceProject);
|
|
44
|
+
return { ok: true, schema_id: "dd-eval/bootstrap-restore@1", snapshot, project_root: projectRoot, target_stage: "specify" };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Snapshot a deliberately dedicated DD_FLOW_HOME. Eval checkpoints are not a
|
|
48
|
+
* general runtime export format: a shared home fails closed instead of trying
|
|
49
|
+
* to infer a safe subset of SQLite rows.
|
|
50
|
+
*/
|
|
51
|
+
export function createEvalRunSnapshot(context, input) {
|
|
52
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
53
|
+
// Snapshot is read-only at the control-plane boundary. An invalid workspace
|
|
54
|
+
// root must fail without registering a second project in a dedicated home.
|
|
55
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
56
|
+
const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
|
|
57
|
+
assertDedicatedHome(context, project.id, input.runId);
|
|
58
|
+
if (input.candidate)
|
|
59
|
+
assertTerminalCandidate(status.index.stage_runs ?? []);
|
|
60
|
+
else if (input.stageEntry)
|
|
61
|
+
assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
|
|
62
|
+
else
|
|
63
|
+
throw new AppError("usage", "Snapshot requires exactly one of stageEntry or candidate", 2);
|
|
64
|
+
const activeChildren = input.candidate
|
|
65
|
+
? 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
|
|
66
|
+
: 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 (? <> 'code' OR COALESCE(result_schema, '') NOT LIKE 'dd-flow/code-work-result@%')))", [project.id, input.runId, input.stageEntry])?.count ?? 0;
|
|
67
|
+
if (activeChildren > 0)
|
|
68
|
+
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 });
|
|
69
|
+
const output = path.resolve(input.output);
|
|
70
|
+
if (fs.existsSync(output))
|
|
71
|
+
throw new AppError("snapshot_exists", "Snapshot output already exists", 1, { output });
|
|
72
|
+
if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
|
|
73
|
+
throw new AppError("snapshot_output_inside_runtime", "Snapshot output must be outside DD_FLOW_HOME", 1, { output });
|
|
74
|
+
const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
|
|
75
|
+
if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
|
|
76
|
+
throw new AppError("snapshot_output_inside_project", "Snapshot output must be outside the project and RUN workspace trees", 1, { output });
|
|
77
|
+
context.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
78
|
+
fs.mkdirSync(output, { recursive: true });
|
|
79
|
+
fs.cpSync(context.ddFlowHome, path.join(output, "runtime"), {
|
|
80
|
+
recursive: true,
|
|
81
|
+
verbatimSymlinks: true,
|
|
82
|
+
filter: (source) => path.basename(source) !== "engines"
|
|
83
|
+
});
|
|
84
|
+
fs.cpSync(projectRoot, path.join(output, "project"), {
|
|
85
|
+
recursive: true,
|
|
86
|
+
verbatimSymlinks: true,
|
|
87
|
+
filter: (source) => path.basename(source) !== ".git"
|
|
88
|
+
});
|
|
89
|
+
const projectGit = snapshotProjectGit(projectRoot, output);
|
|
90
|
+
const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
|
|
91
|
+
const manifest = {
|
|
92
|
+
schema_id: "dd-flow/eval-run-snapshot@5",
|
|
93
|
+
run_id: input.runId,
|
|
94
|
+
project_id: project.id,
|
|
95
|
+
project_root: projectRoot,
|
|
96
|
+
dd_flow_home: context.ddFlowHome,
|
|
97
|
+
purpose: input.candidate ? "candidate" : "stage_entry",
|
|
98
|
+
stage_entry: input.candidate ? null : input.stageEntry,
|
|
99
|
+
created_at: context.now(),
|
|
100
|
+
runtime_sha256: treeHash(path.join(output, "runtime")),
|
|
101
|
+
project_sha256: treeHash(path.join(output, "project")),
|
|
102
|
+
project_git: projectGit,
|
|
103
|
+
workspace,
|
|
104
|
+
source_status: status
|
|
105
|
+
};
|
|
106
|
+
writeJson(path.join(output, "snapshot.json"), manifest);
|
|
107
|
+
return { ok: true, snapshot: output, ...manifest };
|
|
108
|
+
}
|
|
109
|
+
export function restoreEvalRunSnapshot(context, input) {
|
|
110
|
+
const snapshot = path.resolve(input.snapshot);
|
|
111
|
+
const manifest = readManifest(snapshot);
|
|
112
|
+
if (manifest.purpose === "candidate")
|
|
113
|
+
throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
|
|
114
|
+
if (!manifest.stage_entry)
|
|
115
|
+
throw new AppError("snapshot_invalid", "Stage-entry snapshot has no target stage", 1, { snapshot });
|
|
116
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
117
|
+
const existing = fs.readdirSync(context.ddFlowHome).filter((name) => !["db.sqlite", "db.sqlite-wal", "db.sqlite-shm"].includes(name));
|
|
118
|
+
if (existing.length > 0)
|
|
119
|
+
throw new AppError("restore_home_not_empty", "Snapshot restore requires an empty DD_FLOW_HOME", 1, { dd_flow_home: context.ddFlowHome, existing });
|
|
120
|
+
const sourceRuntime = path.join(snapshot, "runtime");
|
|
121
|
+
const sourceProject = path.join(snapshot, "project");
|
|
122
|
+
if (treeHash(sourceRuntime) !== manifest.runtime_sha256)
|
|
123
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot runtime checksum does not match manifest", 1, { snapshot });
|
|
124
|
+
if (treeHash(sourceProject) !== manifest.project_sha256)
|
|
125
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot project checksum does not match manifest", 1, { snapshot });
|
|
126
|
+
clearProjectTree(projectRoot, false);
|
|
127
|
+
restoreProjectGit(snapshot, manifest.project_git, projectRoot);
|
|
128
|
+
replaceProjectTree(projectRoot, sourceProject);
|
|
129
|
+
const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
|
|
130
|
+
context.db.close?.();
|
|
131
|
+
fs.rmSync(context.ddFlowHome, { recursive: true, force: true });
|
|
132
|
+
fs.cpSync(sourceRuntime, context.ddFlowHome, { recursive: true, verbatimSymlinks: true });
|
|
133
|
+
// The dispatch context was intentionally closed before replacing db.sqlite.
|
|
134
|
+
// A fresh process owns all subsequent normal commands.
|
|
135
|
+
const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
|
|
136
|
+
try {
|
|
137
|
+
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome);
|
|
138
|
+
registerProject(restored, { root: projectRoot });
|
|
139
|
+
const project = requireProjectByRoot(restored, projectRoot);
|
|
140
|
+
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
141
|
+
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
142
|
+
const runHome = status.run.run_home_path ?? path.join(context.ddFlowHome, "projects", project.id, "runs", manifest.run_id);
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
schema_id: "dd-flow/eval-run-restore@1",
|
|
146
|
+
snapshot,
|
|
147
|
+
run_id: status.run.id,
|
|
148
|
+
project_root: projectRoot,
|
|
149
|
+
workspace_root: workspaceRoot,
|
|
150
|
+
dd_flow_home: context.ddFlowHome,
|
|
151
|
+
run_home: runHome,
|
|
152
|
+
target_stage: manifest.stage_entry,
|
|
153
|
+
next_command: `dd-flow stage start ${status.run.id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
restored.db.close?.();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function prepareVnextSpecifyRun(context, input) {
|
|
161
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
162
|
+
const definition = readVnextFlowDefinition(projectRoot);
|
|
163
|
+
if (!definition)
|
|
164
|
+
throw new AppError("not_found", "vNext SPECIFY flow definition is missing", 1, { project_root: projectRoot });
|
|
165
|
+
const started = startFlowRun(context, {
|
|
166
|
+
projectRoot,
|
|
167
|
+
flowKind: definition.id === "mb-sdlc-vnext-protocolize" ? "vnext_protocolize" : "vnext_specify",
|
|
168
|
+
subjectType: "discussion",
|
|
169
|
+
subjectId: "SPECIFY",
|
|
170
|
+
slug: input.slug,
|
|
171
|
+
nextAction: "start_specify"
|
|
172
|
+
});
|
|
173
|
+
return { ok: true, run_id: started.run.id, run_home: started.run.run_home_path, target_stage: "specify" };
|
|
174
|
+
}
|
|
175
|
+
function assertDedicatedHome(context, projectId, runId) {
|
|
176
|
+
const projects = context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0;
|
|
177
|
+
const runs = context.db.get("SELECT COUNT(*) AS count FROM runs")?.count ?? 0;
|
|
178
|
+
const own = context.db.get("SELECT COUNT(*) AS count FROM runs WHERE project_id = ? AND id = ?", [projectId, runId])?.count ?? 0;
|
|
179
|
+
if (projects !== 1 || runs !== 1 || own !== 1)
|
|
180
|
+
throw new AppError("snapshot_home_not_dedicated", "Snapshot requires a dedicated DD_FLOW_HOME with exactly one project and RUN", 1, { projects, runs, run_id: runId });
|
|
181
|
+
}
|
|
182
|
+
function assertStageEntry(stages, target) {
|
|
183
|
+
if (stages.some((entry) => entry.stage === target))
|
|
184
|
+
throw new AppError("snapshot_target_started", "Stage-entry snapshot requires its target stage to be unstarted", 1, { target });
|
|
185
|
+
const unfinished = stages.filter((entry) => entry.status !== "done");
|
|
186
|
+
if (unfinished.length > 0)
|
|
187
|
+
throw new AppError("snapshot_predecessor_incomplete", "Stage-entry snapshot requires completed predecessor stages", 1, { target, unfinished });
|
|
188
|
+
}
|
|
189
|
+
function assertTerminalCandidate(stages) {
|
|
190
|
+
const unfinished = stages.filter((entry) => entry.status !== "done");
|
|
191
|
+
if (unfinished.length > 0)
|
|
192
|
+
throw new AppError("snapshot_candidate_incomplete", "Candidate snapshot requires every started stage to be done", 1, { unfinished });
|
|
193
|
+
}
|
|
194
|
+
function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
195
|
+
context.db.exec("PRAGMA foreign_keys = OFF");
|
|
196
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
197
|
+
try {
|
|
198
|
+
context.db.run("UPDATE projects SET root = ?", [newRoot]);
|
|
199
|
+
context.db.run("UPDATE runs SET project_root = ?, workspace_root = ?", [newRoot, newWorkspace]);
|
|
200
|
+
for (const column of ["runtime_path", "run_dir", "run_index_path", "run_home_path", "run_root", "index_json"])
|
|
201
|
+
rewriteDatabaseColumn(context, "runs", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
202
|
+
context.db.run("UPDATE protocols SET project_root = ?", [newRoot]);
|
|
203
|
+
for (const column of ["state_path", "plan_path"])
|
|
204
|
+
rewriteDatabaseColumn(context, "protocols", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
205
|
+
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]);
|
|
206
|
+
for (const column of ["workspace_path", "cwd", "transcript_path"])
|
|
207
|
+
rewriteDatabaseColumn(context, "sessions", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
208
|
+
for (const column of ["prompt_path", "result_path"])
|
|
209
|
+
rewriteDatabaseColumn(context, "work_sessions", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
210
|
+
for (const column of ["stdout_path", "stderr_path", "receipt_path", "artifacts_json"])
|
|
211
|
+
rewriteDatabaseColumn(context, "check_receipts", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
212
|
+
rewriteDatabaseColumn(context, "usage", "source_locator", oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
213
|
+
for (const column of ["worktree_path", "worktrunk_metadata_json", "last_command_result_json"])
|
|
214
|
+
rewriteDatabaseColumn(context, "worktree_records", column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
215
|
+
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()]);
|
|
216
|
+
context.db.run("DELETE FROM codex_session_bindings");
|
|
217
|
+
context.db.run("COMMIT");
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
context.db.exec("ROLLBACK");
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
context.db.exec("PRAGMA foreign_keys = ON");
|
|
225
|
+
}
|
|
226
|
+
rewriteStructuredFiles(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
227
|
+
}
|
|
228
|
+
function rewriteStructuredFiles(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
229
|
+
const visit = (dir) => {
|
|
230
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
231
|
+
const file = path.join(dir, entry.name);
|
|
232
|
+
// DD_FLOW_HOME also contains the restored checkout and installed tools.
|
|
233
|
+
// They are project/tool bytes, not flow records; parsing every *.json
|
|
234
|
+
// there breaks on valid JSON-with-comments such as tsconfig files.
|
|
235
|
+
if (entry.isDirectory() && ![".git", "checkouts", "engines", "node_modules", "tools"].includes(entry.name))
|
|
236
|
+
visit(file);
|
|
237
|
+
else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
238
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
239
|
+
writeJson(file, rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome));
|
|
240
|
+
}
|
|
241
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
242
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.stringify(rebaseStructuredValue(JSON.parse(line), oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)));
|
|
243
|
+
fs.writeFileSync(file, `${lines.join("\n")}\n`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
visit(root);
|
|
248
|
+
}
|
|
249
|
+
function rewriteDatabaseColumn(context, table, column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) { for (const row of context.db.all(`SELECT rowid AS rid, ${column} AS value FROM ${table} WHERE ${column} IS NOT NULL`)) {
|
|
250
|
+
if (!row.value)
|
|
251
|
+
continue;
|
|
252
|
+
const value = column.endsWith("_json") || column === "index_json" ? JSON.stringify(rebaseStructuredValue(JSON.parse(row.value), oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)) : rebasePathString(row.value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
253
|
+
if (value !== row.value)
|
|
254
|
+
context.db.run(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`, [value, row.rid]);
|
|
255
|
+
} }
|
|
256
|
+
function rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) { if (typeof value === "string")
|
|
257
|
+
return rebasePathString(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome); if (Array.isArray(value))
|
|
258
|
+
return value.map((item) => rebaseStructuredValue(item, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)); if (value && typeof value === "object")
|
|
259
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rebaseStructuredValue(item, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)])); return value; }
|
|
260
|
+
function rebasePathString(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) { for (const [oldPrefix, newPrefix] of [[oldHome, newHome], [oldWorkspace, newWorkspace], [oldRoot, newRoot]])
|
|
261
|
+
if (value === oldPrefix || value.startsWith(`${oldPrefix}${path.sep}`))
|
|
262
|
+
return `${newPrefix}${value.slice(oldPrefix.length)}`; return value; }
|
|
263
|
+
function snapshotWorkspace(workspaceRoot, projectRoot, output) {
|
|
264
|
+
if (samePath(workspaceRoot, projectRoot))
|
|
265
|
+
return { kind: "project" };
|
|
266
|
+
const branch = git(workspaceRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
267
|
+
if (!branch)
|
|
268
|
+
throw new AppError("snapshot_workspace_invalid", "Feature workspace must be on a named Git branch", 1, { workspace_root: workspaceRoot });
|
|
269
|
+
const baseRef = git(workspaceRoot, ["rev-parse", "HEAD"]);
|
|
270
|
+
if (!baseRef)
|
|
271
|
+
throw new AppError("snapshot_workspace_invalid", "Feature workspace has no readable HEAD", 1, { workspace_root: workspaceRoot });
|
|
272
|
+
const workspace = path.join(output, "workspace");
|
|
273
|
+
const bundle = path.join(output, "workspace.bundle");
|
|
274
|
+
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: (source) => path.basename(source) !== ".git" });
|
|
275
|
+
runGit(projectRoot, ["bundle", "create", bundle, branch]);
|
|
276
|
+
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
|
|
277
|
+
}
|
|
278
|
+
function restoreWorkspace(snapshot, manifest, projectRoot, ddFlowHome) {
|
|
279
|
+
if (manifest.workspace.kind === "project")
|
|
280
|
+
return projectRoot;
|
|
281
|
+
const source = path.join(snapshot, "workspace");
|
|
282
|
+
const bundle = path.join(snapshot, "workspace.bundle");
|
|
283
|
+
if (treeHash(source) !== manifest.workspace.sha256)
|
|
284
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot workspace checksum does not match manifest", 1, { snapshot });
|
|
285
|
+
if (!fs.existsSync(bundle) || fileHash(bundle) !== manifest.workspace.bundle_sha256)
|
|
286
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot workspace Git bundle does not match manifest", 1, { snapshot });
|
|
287
|
+
const managedRoot = physicalPath(manifest.dd_flow_home);
|
|
288
|
+
const sourceRoot = physicalPath(manifest.workspace.source_root);
|
|
289
|
+
const workspaceRoot = isInside(managedRoot, sourceRoot)
|
|
290
|
+
? path.join(ddFlowHome, path.relative(managedRoot, sourceRoot))
|
|
291
|
+
: path.join(path.dirname(projectRoot), "workspace");
|
|
292
|
+
if (fs.existsSync(workspaceRoot))
|
|
293
|
+
throw new AppError("snapshot_workspace_target_exists", "Snapshot feature workspace target already exists", 1, { workspace_root: workspaceRoot });
|
|
294
|
+
runGit(projectRoot, ["fetch", bundle, `+refs/heads/${manifest.workspace.branch}:refs/heads/${manifest.workspace.branch}`]);
|
|
295
|
+
if (git(projectRoot, ["rev-parse", `refs/heads/${manifest.workspace.branch}`]) !== manifest.workspace.base_ref)
|
|
296
|
+
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 });
|
|
297
|
+
runGit(projectRoot, ["worktree", "add", workspaceRoot, manifest.workspace.branch]);
|
|
298
|
+
replaceProjectTree(workspaceRoot, source);
|
|
299
|
+
return fs.realpathSync(workspaceRoot);
|
|
300
|
+
}
|
|
301
|
+
function sourceWorkspaceRoot(manifest) {
|
|
302
|
+
return manifest.workspace.kind === "project" ? manifest.project_root : manifest.workspace.source_root;
|
|
303
|
+
}
|
|
304
|
+
function snapshotProjectGit(projectRoot, output) {
|
|
305
|
+
const branch = git(projectRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
306
|
+
const head = git(projectRoot, ["rev-parse", "HEAD"]);
|
|
307
|
+
if (!branch || !head)
|
|
308
|
+
throw new AppError("snapshot_project_git_invalid", "Snapshot requires a project on a named Git branch with a readable HEAD", 1, { project_root: projectRoot });
|
|
309
|
+
const bundle = path.join(output, "project.bundle");
|
|
310
|
+
runGit(projectRoot, ["bundle", "create", bundle, "--all"]);
|
|
311
|
+
return { branch, head, bundle_sha256: fileHash(bundle) };
|
|
312
|
+
}
|
|
313
|
+
function restoreProjectGit(snapshot, source, projectRoot) {
|
|
314
|
+
const bundle = path.join(snapshot, "project.bundle");
|
|
315
|
+
if (!fs.existsSync(bundle) || fileHash(bundle) !== source.bundle_sha256)
|
|
316
|
+
throw new AppError("snapshot_checksum_mismatch", "Snapshot project Git bundle checksum does not match manifest", 1, { snapshot });
|
|
317
|
+
fs.rmSync(path.join(projectRoot, ".git"), { recursive: true, force: true });
|
|
318
|
+
runGit(projectRoot, ["init", "-b", source.branch]);
|
|
319
|
+
runGit(projectRoot, ["fetch", bundle, `+refs/heads/${source.branch}:refs/remotes/snapshot/${source.branch}`]);
|
|
320
|
+
runGit(projectRoot, ["checkout", "-B", source.branch, source.head]);
|
|
321
|
+
}
|
|
322
|
+
function git(root, args) {
|
|
323
|
+
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
324
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
325
|
+
}
|
|
326
|
+
function runGit(root, args) {
|
|
327
|
+
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
328
|
+
if (result.status !== 0)
|
|
329
|
+
throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
|
|
330
|
+
}
|
|
331
|
+
function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
|
|
332
|
+
function treeHash(root) {
|
|
333
|
+
const hash = crypto.createHash("sha256");
|
|
334
|
+
const visit = (dir, relative) => {
|
|
335
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
336
|
+
const file = path.join(dir, entry.name);
|
|
337
|
+
const rel = path.join(relative, entry.name);
|
|
338
|
+
if (entry.isDirectory())
|
|
339
|
+
visit(file, rel);
|
|
340
|
+
else if (entry.isFile()) {
|
|
341
|
+
hash.update(rel);
|
|
342
|
+
hash.update(fs.readFileSync(file));
|
|
343
|
+
}
|
|
344
|
+
else if (entry.isSymbolicLink()) {
|
|
345
|
+
hash.update(rel);
|
|
346
|
+
hash.update(`symlink:${fs.readlinkSync(file)}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
visit(root, "");
|
|
351
|
+
return hash.digest("hex");
|
|
352
|
+
}
|
|
353
|
+
function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
354
|
+
function readManifest(snapshot) {
|
|
355
|
+
const file = path.join(snapshot, "snapshot.json");
|
|
356
|
+
if (!fs.existsSync(file))
|
|
357
|
+
throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
|
|
358
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
359
|
+
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 === "candidate" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
|
|
360
|
+
throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
|
|
361
|
+
if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
|
|
362
|
+
throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
|
|
363
|
+
if (value.workspace.kind === "git_worktree" && (!value.workspace.branch || !value.workspace.base_ref || !value.workspace.bundle_sha256))
|
|
364
|
+
throw new AppError("snapshot_invalid", "Snapshot worktree manifest is incomplete", 1, { snapshot });
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
function readBootstrapManifest(snapshot) {
|
|
368
|
+
const file = path.join(snapshot, "bootstrap.json");
|
|
369
|
+
if (!fs.existsSync(file))
|
|
370
|
+
throw new AppError("snapshot_missing", "Bootstrap snapshot manifest is missing", 1, { snapshot });
|
|
371
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
372
|
+
if (value.schema_id !== "dd-eval/bootstrap-snapshot@1" || value.stage !== "specify" || !value.project_root || !value.project_sha256 || !value.project_git?.branch || !value.project_git.head || !value.project_git.bundle_sha256)
|
|
373
|
+
throw new AppError("snapshot_invalid", "Bootstrap snapshot manifest is invalid", 1, { snapshot });
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
function replaceProjectTree(projectRoot, source) {
|
|
377
|
+
if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory())
|
|
378
|
+
throw new AppError("project_missing", "Snapshot restore target project is missing", 1, { project_root: projectRoot });
|
|
379
|
+
clearProjectTree(projectRoot, true);
|
|
380
|
+
fs.cpSync(source, projectRoot, { recursive: true, verbatimSymlinks: true });
|
|
381
|
+
}
|
|
382
|
+
function clearProjectTree(projectRoot, preserveGit) {
|
|
383
|
+
if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory())
|
|
384
|
+
throw new AppError("project_missing", "Snapshot restore target project is missing", 1, { project_root: projectRoot });
|
|
385
|
+
for (const entry of fs.readdirSync(projectRoot)) {
|
|
386
|
+
if (preserveGit && entry === ".git")
|
|
387
|
+
continue;
|
|
388
|
+
fs.rmSync(path.join(projectRoot, entry), { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function physicalPath(value) {
|
|
392
|
+
const missing = [];
|
|
393
|
+
let current = path.resolve(value);
|
|
394
|
+
while (!fs.existsSync(current)) {
|
|
395
|
+
missing.unshift(path.basename(current));
|
|
396
|
+
current = path.dirname(current);
|
|
397
|
+
}
|
|
398
|
+
return path.join(fs.realpathSync(current), ...missing);
|
|
399
|
+
}
|
|
400
|
+
function isInside(root, candidate) {
|
|
401
|
+
const relative = path.relative(root, candidate);
|
|
402
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
403
|
+
}
|
|
404
|
+
function writeJson(file, value) { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); }
|