@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75
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 +404 -0
- package/README.md +65 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -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/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -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 +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -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 +70 -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/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- 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/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +116 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +211 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +224 -216
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +605 -157
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -2,36 +2,18 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { AppError } from "../shared/errors.js";
|
|
4
4
|
export const runEngineBindingSchemaId = "dd-flow/run-engine-binding@1";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** Resolve a RUN from SQLite authority; never discover one by scanning storage paths. */
|
|
6
|
+
export function locateRunRoot(db, projectRoot, runIdOrAlias) {
|
|
7
|
+
const rows = db.all(`SELECT r.project_id, r.id AS run_id, r.run_root
|
|
8
|
+
FROM runs r JOIN projects p ON p.id = r.project_id
|
|
9
|
+
WHERE p.root = ? AND (r.id = ? OR r.short_id = ?)`, [realpathOrResolve(projectRoot), runIdOrAlias, runIdOrAlias]).filter((row) => Boolean(row.run_root));
|
|
10
|
+
if (rows.length > 1)
|
|
11
|
+
throw new AppError("ambiguous_id", `RUN id is ambiguous for project: ${runIdOrAlias}`, 2, { project_root: realpathOrResolve(projectRoot), candidates: rows.map((row) => row.run_id) });
|
|
12
|
+
const row = rows[0];
|
|
13
|
+
if (!row?.run_root)
|
|
8
14
|
return null;
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
for (const projectId of fs.readdirSync(projectsRoot)) {
|
|
12
|
-
const runsRoot = path.join(projectsRoot, projectId, "runs");
|
|
13
|
-
if (!isDirectory(runsRoot))
|
|
14
|
-
continue;
|
|
15
|
-
for (const runId of fs.readdirSync(runsRoot)) {
|
|
16
|
-
if (!runIdMatches(runId, runIdOrAlias))
|
|
17
|
-
continue;
|
|
18
|
-
const runHome = path.join(runsRoot, runId);
|
|
19
|
-
const authorityPath = path.join(runHome, "run.json");
|
|
20
|
-
const bindingPath = path.join(runHome, "engine-binding.json");
|
|
21
|
-
const binding = readRunEngineBinding(bindingPath, { allowMissing: true });
|
|
22
|
-
const authorityRoot = binding?.project_root ?? projectRootFromAuthority(authorityPath);
|
|
23
|
-
if (!authorityRoot || realpathOrResolve(authorityRoot) !== expectedRoot)
|
|
24
|
-
continue;
|
|
25
|
-
matches.push({ project_id: projectId, run_id: runId, run_home: runHome, authority_path: authorityPath, binding_path: bindingPath });
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
if (matches.length > 1) {
|
|
29
|
-
throw new AppError("ambiguous_id", `RUN id is ambiguous for project: ${runIdOrAlias}`, 2, {
|
|
30
|
-
project_root: expectedRoot,
|
|
31
|
-
candidates: matches.map((match) => match.run_id)
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
return matches[0] ?? null;
|
|
15
|
+
const runRoot = path.resolve(row.run_root);
|
|
16
|
+
return { project_id: row.project_id, run_id: row.run_id, run_root: runRoot, authority_path: path.join(runRoot, "run.json"), binding_path: path.join(runRoot, "engine-binding.json") };
|
|
35
17
|
}
|
|
36
18
|
export function readRunEngineBinding(file, options = {}) {
|
|
37
19
|
if (!fs.existsSync(file)) {
|
|
@@ -86,6 +68,14 @@ export function allRunEngineBindings(ddFlowHome) {
|
|
|
86
68
|
}
|
|
87
69
|
return bindings;
|
|
88
70
|
}
|
|
71
|
+
function isDirectory(value) {
|
|
72
|
+
try {
|
|
73
|
+
return fs.statSync(value).isDirectory();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
89
79
|
function isRunEngineBinding(value) {
|
|
90
80
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
91
81
|
return false;
|
|
@@ -96,7 +86,7 @@ function isRunEngineBinding(value) {
|
|
|
96
86
|
&& typeof record.run_id === "string"
|
|
97
87
|
&& typeof record.project_root === "string"
|
|
98
88
|
&& typeof record.bound_at === "string"
|
|
99
|
-
&& ["run_creation", "legacy_recovery"].includes(String(record.source))
|
|
89
|
+
&& ["run_creation", "legacy_recovery", "fork_upgrade"].includes(String(record.source))
|
|
100
90
|
&& typeof record.reason === "string"
|
|
101
91
|
&& Boolean(engine && typeof engine === "object" && !Array.isArray(engine)
|
|
102
92
|
&& typeof engine.package_name === "string"
|
|
@@ -115,32 +105,6 @@ function sameEngine(left, right) {
|
|
|
115
105
|
&& left.integrity_checksum === right.integrity_checksum
|
|
116
106
|
&& left.snapshot_root === right.snapshot_root;
|
|
117
107
|
}
|
|
118
|
-
function projectRootFromAuthority(file) {
|
|
119
|
-
try {
|
|
120
|
-
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
121
|
-
const execution = recordValue(value.execution);
|
|
122
|
-
const workspace = recordValue(value.workspace);
|
|
123
|
-
const project = recordValue(value.project);
|
|
124
|
-
return stringValue(execution?.project_root)
|
|
125
|
-
?? stringValue(workspace?.project_root)
|
|
126
|
-
?? stringValue(project?.root)
|
|
127
|
-
?? stringValue(value.project_root);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
function runIdMatches(fullId, idOrAlias) {
|
|
134
|
-
return fullId === idOrAlias || /^RUN-[0-9]+$/.test(idOrAlias) && fullId.startsWith(`${idOrAlias}-`);
|
|
135
|
-
}
|
|
136
|
-
function isDirectory(value) {
|
|
137
|
-
try {
|
|
138
|
-
return fs.statSync(value).isDirectory();
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
108
|
function realpathOrResolve(value) {
|
|
145
109
|
try {
|
|
146
110
|
return fs.realpathSync(value);
|
|
@@ -149,9 +113,3 @@ function realpathOrResolve(value) {
|
|
|
149
113
|
return path.resolve(value);
|
|
150
114
|
}
|
|
151
115
|
}
|
|
152
|
-
function recordValue(value) {
|
|
153
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
154
|
-
}
|
|
155
|
-
function stringValue(value) {
|
|
156
|
-
return typeof value === "string" && value.length > 0 ? value : null;
|
|
157
|
-
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createContext } from "../runtime/context.js";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { canonicalPath } from "../storage/paths.js";
|
|
7
|
+
import { installCurrentEngine, upgradeForkedRunEngine } from "./engines.js";
|
|
8
|
+
import { restoreEvalRunSnapshot } from "./eval-snapshots.js";
|
|
9
|
+
/** Restore a sealed stage boundary into its own home and replace only its
|
|
10
|
+
* copied engine binding. The original snapshot and RUN are read-only. */
|
|
11
|
+
export function forkRunFromSnapshot(context, input) {
|
|
12
|
+
const requestId = requireId(input.requestId, "request-id");
|
|
13
|
+
const snapshot = canonicalPath(input.snapshot);
|
|
14
|
+
const output = path.resolve(input.output);
|
|
15
|
+
if (snapshot === output || snapshot.startsWith(`${output}${path.sep}`) || output.startsWith(`${snapshot}${path.sep}`)) {
|
|
16
|
+
throw new AppError("fork_path_conflict", "Fork output must not contain or be contained by its source snapshot", 2, { snapshot, output });
|
|
17
|
+
}
|
|
18
|
+
const manifestFile = path.join(snapshot, "snapshot.json");
|
|
19
|
+
const manifest = readJson(manifestFile);
|
|
20
|
+
if (manifest.schema_id !== "dd-flow/eval-run-snapshot@5" || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
|
|
21
|
+
throw new AppError("fork_checkpoint_invalid", "Fork requires a sealed stage-entry snapshot", 2, { snapshot });
|
|
22
|
+
}
|
|
23
|
+
const intent = {
|
|
24
|
+
schema_id: "dd-flow/run-fork-intent@1",
|
|
25
|
+
request_id: requestId,
|
|
26
|
+
snapshot,
|
|
27
|
+
snapshot_sha256: fileHash(manifestFile),
|
|
28
|
+
engine_version: requireText(input.engineVersion, "engine-version"),
|
|
29
|
+
integrity_checksum: input.integrityChecksum ?? null,
|
|
30
|
+
target_stage: manifest.stage_entry
|
|
31
|
+
};
|
|
32
|
+
const intentFile = path.join(output, ".fork-intent.json");
|
|
33
|
+
const receiptFile = path.join(output, "fork.json");
|
|
34
|
+
if (fs.existsSync(receiptFile)) {
|
|
35
|
+
const receipt = readJson(receiptFile);
|
|
36
|
+
if (hash(receipt.intent) !== hash(intent))
|
|
37
|
+
throw new AppError("fork_request_conflict", "Fork output already belongs to another request", 1, { output });
|
|
38
|
+
return receipt;
|
|
39
|
+
}
|
|
40
|
+
if (fs.existsSync(output) && fs.readdirSync(output).length > 0) {
|
|
41
|
+
if (!fs.existsSync(intentFile) || hash(readJson(intentFile).intent) !== hash(intent))
|
|
42
|
+
throw new AppError("fork_output_not_empty", "Fork output must be empty", 2, { output });
|
|
43
|
+
const unknown = fs.readdirSync(output).filter(name => ![".fork-intent.json", "project", "dd-flow-home"].includes(name));
|
|
44
|
+
if (unknown.length)
|
|
45
|
+
throw new AppError("fork_retry_inspect_required", "Fork output contains data not owned by the interrupted fork", 1, { output, unknown });
|
|
46
|
+
for (const name of ["project", "dd-flow-home"])
|
|
47
|
+
fs.rmSync(path.join(output, name), { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
fs.mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
50
|
+
writeJsonAtomic(intentFile, { schema_id: "dd-flow/run-fork-intent@1", intent });
|
|
51
|
+
const projectRoot = path.join(output, "project");
|
|
52
|
+
const home = path.join(output, "dd-flow-home");
|
|
53
|
+
fs.mkdirSync(projectRoot, { recursive: true, mode: 0o700 });
|
|
54
|
+
const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
55
|
+
const restored = restoreEvalRunSnapshot(targetContext, { snapshot, projectRoot });
|
|
56
|
+
const restoredContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
57
|
+
try {
|
|
58
|
+
const runHome = String(restored.run_home);
|
|
59
|
+
const previousBinding = readJson(path.join(runHome, "engine-binding.json"));
|
|
60
|
+
const lineage = {
|
|
61
|
+
schema_id: "dd-flow/run-fork-lineage@1",
|
|
62
|
+
request_id: requestId,
|
|
63
|
+
source_snapshot: snapshot,
|
|
64
|
+
source_snapshot_sha256: intent.snapshot_sha256,
|
|
65
|
+
source_run_id: manifest.run_id,
|
|
66
|
+
target_stage: manifest.stage_entry,
|
|
67
|
+
inherited_engine: previousBinding,
|
|
68
|
+
created_at: restoredContext.now()
|
|
69
|
+
};
|
|
70
|
+
writeJsonAtomic(path.join(runHome, "fork-lineage.json"), lineage);
|
|
71
|
+
// Install the immutable artifact belonging to the currently executing
|
|
72
|
+
// CLI into the new home. A different exact version must already be
|
|
73
|
+
// supplied as an installed engine artifact for this fork home.
|
|
74
|
+
installCurrentEngine(restoredContext, { forRouting: true });
|
|
75
|
+
const binding = upgradeForkedRunEngine(restoredContext, {
|
|
76
|
+
projectRoot,
|
|
77
|
+
runId: String(restored.run_id),
|
|
78
|
+
engineVersion: intent.engine_version,
|
|
79
|
+
...(intent.integrity_checksum ? { integrityChecksum: intent.integrity_checksum } : {}),
|
|
80
|
+
reason: `Fork ${requestId} from sealed checkpoint ${intent.snapshot_sha256}.`
|
|
81
|
+
});
|
|
82
|
+
const receipt = {
|
|
83
|
+
ok: true,
|
|
84
|
+
schema_id: "dd-flow/run-fork@1",
|
|
85
|
+
status: "ready",
|
|
86
|
+
intent,
|
|
87
|
+
fork: {
|
|
88
|
+
project_root: projectRoot,
|
|
89
|
+
dd_flow_home: home,
|
|
90
|
+
run_id: restored.run_id,
|
|
91
|
+
run_home: runHome,
|
|
92
|
+
workspace_root: restored.workspace_root,
|
|
93
|
+
target_stage: manifest.stage_entry,
|
|
94
|
+
engine: binding.engine,
|
|
95
|
+
lineage_file: path.join(runHome, "fork-lineage.json")
|
|
96
|
+
},
|
|
97
|
+
next_command: `dd-flow stage start ${restored.run_id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
|
|
98
|
+
};
|
|
99
|
+
writeJsonAtomic(receiptFile, receipt);
|
|
100
|
+
return receipt;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
restoredContext.db.close?.();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function readJson(file) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
throw new AppError("fork_checkpoint_invalid", "Fork metadata is not valid JSON", 2, { file, cause: String(error) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function writeJsonAtomic(file, value) {
|
|
115
|
+
const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
116
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
117
|
+
fs.renameSync(temporary, file);
|
|
118
|
+
}
|
|
119
|
+
function hash(value) { return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
|
120
|
+
function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
121
|
+
function requireText(value, name) { const text = value.trim(); if (!text)
|
|
122
|
+
throw new AppError("usage", `Missing required value: ${name}`, 2); return text; }
|
|
123
|
+
function requireId(value, name) { const text = requireText(value, name); if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(text))
|
|
124
|
+
throw new AppError("usage", `Invalid ${name}`, 2); return text; }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ToolObservations, replayToolJournal } from "../harness-runtime/lib/tool-observations.mjs";
|
|
4
|
+
import { harnessKey } from "./harness-config.js";
|
|
5
|
+
/** Evidence has ancestry, not executable ownership. Never open a source home. */
|
|
6
|
+
export function runObservations(context, projectId, runId) {
|
|
7
|
+
const home = fs.realpathSync(context.ddFlowHome), sources = new Map(), reasons = [];
|
|
8
|
+
const sessions = context.db.all("SELECT session_id, provider_session_id, harness, transcript_path FROM sessions WHERE project_id = ? AND run_id = ?", [projectId, runId]);
|
|
9
|
+
const inside = (root, file) => { const p = path.relative(root, file); return p !== "" && p !== ".." && !p.startsWith(`..${path.sep}`) && !path.isAbsolute(p); };
|
|
10
|
+
const add = (id, nativeId, journal, origin, provenance, harness) => {
|
|
11
|
+
if (sources.has(id))
|
|
12
|
+
return;
|
|
13
|
+
const candidates = sessions.filter(s => (s.provider_session_id ?? s.session_id) === nativeId && (!harness || s.harness === harness));
|
|
14
|
+
if (candidates.length > 1) {
|
|
15
|
+
reasons.push("journal_session_ambiguous");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const owner = candidates[0];
|
|
19
|
+
if (!owner)
|
|
20
|
+
return;
|
|
21
|
+
const source = { id, provenance, session: { harness_id: owner.harness, session_id: nativeId }, path: null, status: "unavailable" };
|
|
22
|
+
sources.set(id, source);
|
|
23
|
+
if (typeof journal !== "string" || !path.isAbsolute(journal) || !inside(origin, journal)) {
|
|
24
|
+
source.reason = "journal_outside_owned_home";
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
source.path = path.relative(origin, journal);
|
|
28
|
+
const file = path.resolve(home, source.path);
|
|
29
|
+
try {
|
|
30
|
+
if (!inside(home, fs.realpathSync(file))) {
|
|
31
|
+
source.reason = "journal_outside_owned_home";
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!fs.statSync(file).isFile()) {
|
|
35
|
+
source.reason = "journal_not_file";
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
source.status = "available";
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
source.reason = "journal_missing";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const controllers = (rows, origin, provenance) => {
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
if (row.project_id !== projectId || row.run_id !== runId)
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
for (const session of JSON.parse(row.state_json).sessions ?? [])
|
|
50
|
+
add(`${row.controller_id}:${session.id}`, session.id, session.journal, origin, provenance, session.profile?.harness ? harnessKey(session.profile.harness) : undefined);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
reasons.push("controller_evidence_invalid");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (context.db.all("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_controllers'").length) {
|
|
58
|
+
controllers(context.db.all("SELECT controller_id, project_id, run_id, state_json FROM run_controllers WHERE project_id = ? AND run_id = ?", [projectId, runId]), home, "current");
|
|
59
|
+
}
|
|
60
|
+
const imports = path.join(home, "snapshot-imports");
|
|
61
|
+
if (fs.existsSync(imports))
|
|
62
|
+
for (const file of fs.readdirSync(imports).filter(name => name.endsWith(".json")).sort()) {
|
|
63
|
+
try {
|
|
64
|
+
const location = path.join(imports, file);
|
|
65
|
+
if (!inside(home, fs.realpathSync(location))) {
|
|
66
|
+
reasons.push("lineage_outside_owned_home");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const archive = JSON.parse(fs.readFileSync(location, "utf8"));
|
|
70
|
+
if (archive.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || archive.run_id !== runId)
|
|
71
|
+
continue;
|
|
72
|
+
if (typeof archive.source_runtime !== "string" || !path.isAbsolute(archive.source_runtime)) {
|
|
73
|
+
reasons.push("lineage_source_invalid");
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
controllers(archive.run_tables?.run_controllers ?? [], archive.source_runtime, "inherited");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
reasons.push("lineage_evidence_invalid");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Native transcript adapters need no managed controller journal.
|
|
83
|
+
for (const session of sessions)
|
|
84
|
+
if (session.transcript_path && ![...sources.values()].some(s => s.session.harness_id === session.harness && s.session.session_id === (session.provider_session_id ?? session.session_id))) {
|
|
85
|
+
add(`transcript:${session.harness}:${session.provider_session_id ?? session.session_id}`, session.provider_session_id ?? session.session_id, session.transcript_path, home, "current", session.harness);
|
|
86
|
+
}
|
|
87
|
+
const reducer = new ToolObservations();
|
|
88
|
+
for (const reason of reasons)
|
|
89
|
+
reducer.gaps.add(reason);
|
|
90
|
+
for (const source of sources.values()) {
|
|
91
|
+
if (source.status !== "available") {
|
|
92
|
+
reducer.gaps.add(source.reason ?? "journal_unavailable");
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
replayToolJournal(path.join(home, source.path), { harness: source.session.harness_id, rootSessionId: source.session.session_id }, reducer);
|
|
96
|
+
}
|
|
97
|
+
const expected = [...new Map(sessions.map(s => { const session = { harness_id: s.harness, session_id: s.provider_session_id ?? s.session_id }; return [JSON.stringify(session), session]; })).values()];
|
|
98
|
+
const expectedIds = new Set(expected.map(s => JSON.stringify(s)));
|
|
99
|
+
for (const [key, event] of reducer.calls)
|
|
100
|
+
if (!expectedIds.has(JSON.stringify(event.session))) {
|
|
101
|
+
reducer.calls.delete(key);
|
|
102
|
+
reducer.gaps.add("tool_session_not_bound_to_run");
|
|
103
|
+
}
|
|
104
|
+
const summary = reducer.summary();
|
|
105
|
+
const observed = new Set(summary.sessions.map(s => JSON.stringify(s.session)));
|
|
106
|
+
const missing = expected.filter(s => !observed.has(JSON.stringify(s)));
|
|
107
|
+
const completeness = missing.length || summary.completeness !== "complete" ? "partial" : "complete";
|
|
108
|
+
return { schema_id: "dd-flow/run-observations@1", home, sources: [...sources.values()], tools: { ...summary,
|
|
109
|
+
completeness, status: observed.size === 0 ? "unavailable" : completeness === "complete" ? "measured" : "partial",
|
|
110
|
+
observed_sessions: expected.length - missing.length, expected_sessions: expected.length, unavailable_sessions: missing.map(s => s.session_id), missing_sessions: missing,
|
|
111
|
+
reasons: [...new Set([...summary.reasons, ...(missing.length ? ["sessions_without_tool_observations"] : [])])] } };
|
|
112
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
5
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
6
|
+
import { acceptanceCommand, recoveryGuard, recoveryPacketReceipt, requireRecoveryRun } from "./run-recovery.js";
|
|
7
|
+
import { processIsAlive } from "./managed-processes.js";
|
|
8
|
+
import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
9
|
+
/** Pin retained Session loading and its first acknowledgement Turn, never a general fence bypass. */
|
|
10
|
+
export function prepareRunRecoveryDispatch(context, input) {
|
|
11
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(input.operationId) || !path.isAbsolute(input.stateDir))
|
|
12
|
+
throw new AppError("usage", "Recovery dispatch requires an operation id and absolute state directory", 2);
|
|
13
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
14
|
+
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
15
|
+
context.db.beginWriteTransaction();
|
|
16
|
+
try {
|
|
17
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
18
|
+
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
|
|
19
|
+
if (!guard || guard.recovery_id !== input.recoveryId || !binding || !["resuming", "recovered"].includes(guard.status))
|
|
20
|
+
throw new AppError("recovery_state_changed", "Recovery dispatch no longer belongs to the current generation", 1);
|
|
21
|
+
recoveryPacketReceipt(binding);
|
|
22
|
+
if (binding.runtime_dispatch_json) {
|
|
23
|
+
const prior = JSON.parse(binding.runtime_dispatch_json);
|
|
24
|
+
if (prior.state_dir !== input.stateDir || prior.operation_id !== input.operationId)
|
|
25
|
+
throw new AppError("recovery_binding_mismatch", "Recovery dispatch is already pinned to another operation", 1);
|
|
26
|
+
context.db.exec("COMMIT");
|
|
27
|
+
return prior;
|
|
28
|
+
}
|
|
29
|
+
if (guard.status !== "resuming" || binding.status !== "prepared")
|
|
30
|
+
throw new AppError("recovery_state_changed", "Recovery acknowledgement is no longer awaiting dispatch", 1);
|
|
31
|
+
const { state, process: record } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: run.id });
|
|
32
|
+
if ((state.daemon_id ?? state.config?.daemonId) !== binding.daemon_id || record.state !== "running" || !processIsAlive(record))
|
|
33
|
+
throw new AppError("recovery_runtime_mismatch", "Recovery requires its live replacement daemon", 1);
|
|
34
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
35
|
+
if (metadata.state_dir !== input.stateDir || metadata.run_generation !== guard.generation || metadata.dispatch_barrier !== "run-generation@1" || !["subject", "merge"].includes(metadata.role ?? ""))
|
|
36
|
+
throw new AppError("recovery_runtime_mismatch", "Replacement daemon lacks this recovery generation's native dispatch barrier", 1);
|
|
37
|
+
const prompt = `Resume this retained RUN in its existing native Session. Your first tool call must be this exact standalone command:\n\n${acceptanceCommand(context, run.id, guard.recovery_id, run.project_root)}\n\nFollow the verified recovery packet returned by that command. Preserve completed Work and existing HITL; do not start a successor Stage yourself. Stop at the Stage or declared Work-graph boundary.\n`;
|
|
38
|
+
const dispatch = { process_id: record.id, owner_id: record.owner_id, state_dir: input.stateDir, daemon_id: binding.daemon_id, operation_id: input.operationId, resume_operation_id: `${input.operationId}:load`, prompt_sha256: crypto.createHash("sha256").update(prompt).digest("hex"), prompt };
|
|
39
|
+
context.db.run("UPDATE run_recovery_bindings SET runtime_dispatch_json = ? WHERE recovery_id = ? AND status = 'prepared' AND runtime_dispatch_json IS NULL", [JSON.stringify(dispatch), binding.recovery_id]);
|
|
40
|
+
context.db.exec("COMMIT");
|
|
41
|
+
return dispatch;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
context.db.exec("ROLLBACK");
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The caller has already checked PID/start/lease and runtime-home identity. */
|
|
49
|
+
export function recoveryRuntimeAdmission(context, record, input) {
|
|
50
|
+
if (!record.project_id || !record.run_id || !input.daemonId)
|
|
51
|
+
return false;
|
|
52
|
+
const guard = recoveryGuard(context, record.project_id, record.run_id);
|
|
53
|
+
if (!guard || !["resuming", "recovered"].includes(guard.status))
|
|
54
|
+
return false;
|
|
55
|
+
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ? AND daemon_id = ?", [guard.recovery_id, input.daemonId]);
|
|
56
|
+
if (!binding?.runtime_dispatch_json)
|
|
57
|
+
return false;
|
|
58
|
+
const dispatch = JSON.parse(binding.runtime_dispatch_json);
|
|
59
|
+
if (dispatch.process_id !== record.id || dispatch.owner_id !== record.owner_id || dispatch.state_dir !== input.stateDir || dispatch.daemon_id !== input.daemonId)
|
|
60
|
+
return false;
|
|
61
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
62
|
+
if (metadata.run_generation !== guard.generation)
|
|
63
|
+
return false;
|
|
64
|
+
if (guard.status === "recovered" && binding.status === "accepted")
|
|
65
|
+
return true;
|
|
66
|
+
if (guard.status === "resuming" && binding.status === "prepared" && input.operation === "session.resume")
|
|
67
|
+
return typeof dispatch.resume_operation_id === "string" && input.operationId === dispatch.resume_operation_id && input.nativeSessionId === binding.provider_session_id;
|
|
68
|
+
return guard.status === "resuming" && binding.status === "prepared" && input.operation === "session.prompt" && input.operationId === dispatch.operation_id && input.nativeSessionId === binding.provider_session_id && input.promptSha256 === dispatch.prompt_sha256;
|
|
69
|
+
}
|