@deksden-com/dd-flow-cli 0.9.0-beta.8 → 0.9.0-beta.80
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 +396 -0
- package/README.md +65 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +81 -9
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1342 -375
- package/dist/cli.js +6 -2
- 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.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -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 +130 -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.d.mts +1 -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/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/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +57 -13
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +543 -96
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +116 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +750 -73
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +553 -285
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +87 -7
- package/dist/services/lifecycle-invocations.js +828 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +283 -27
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +73 -28
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +64 -20
- 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 +222 -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 +36 -0
- package/dist/services/run-controller.js +779 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +342 -0
- package/dist/services/runs.js +368 -74
- package/dist/services/runtime-budget.js +434 -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 +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +99 -102
- package/dist/services/stage-pause.js +65 -23
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +189 -85
- package/dist/services/vnext-code.js +257 -243
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +134 -19
- package/dist/services/vnext-merge.js +285 -102
- package/dist/services/vnext-plan-review.js +148 -94
- package/dist/services/vnext-plan.js +141 -72
- package/dist/services/vnext-protocolize.js +129 -56
- package/dist/services/vnext-specify.js +87 -64
- package/dist/services/work-registry.js +707 -156
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +444 -32
- package/dist/storage/paths.js +17 -4
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +11 -4
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -86,7 +86,7 @@ function isRunEngineBinding(value) {
|
|
|
86
86
|
&& typeof record.run_id === "string"
|
|
87
87
|
&& typeof record.project_root === "string"
|
|
88
88
|
&& typeof record.bound_at === "string"
|
|
89
|
-
&& ["run_creation", "legacy_recovery"].includes(String(record.source))
|
|
89
|
+
&& ["run_creation", "legacy_recovery", "fork_upgrade"].includes(String(record.source))
|
|
90
90
|
&& typeof record.reason === "string"
|
|
91
91
|
&& Boolean(engine && typeof engine === "object" && !Array.isArray(engine)
|
|
92
92
|
&& typeof engine.package_name === "string"
|
|
@@ -0,0 +1,138 @@
|
|
|
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, prepareForkEngine } from "./engines.js";
|
|
8
|
+
import { restoreEvalRunSnapshot, prepareEvalRunSnapshot, prepareEvalRunRestore } from "./eval-snapshots.js";
|
|
9
|
+
export function prepareRunFork(input) {
|
|
10
|
+
const requestId = requireId(input.requestId, "request-id");
|
|
11
|
+
const snapshot = canonicalPath(input.snapshot, false);
|
|
12
|
+
const output = canonicalPath(input.output, false);
|
|
13
|
+
if (snapshot === output || snapshot.startsWith(`${output}${path.sep}`) || output.startsWith(`${snapshot}${path.sep}`)) {
|
|
14
|
+
throw new AppError("fork_path_conflict", "Fork output must not contain or be contained by its source snapshot", 2, { snapshot, output });
|
|
15
|
+
}
|
|
16
|
+
const manifestFile = path.join(snapshot, "snapshot.json");
|
|
17
|
+
const intentFile = path.join(output, ".fork-intent.json");
|
|
18
|
+
const receiptFile = path.join(output, "fork.json");
|
|
19
|
+
const engineVersion = requireText(input.engineVersion, "engine-version");
|
|
20
|
+
if (fs.existsSync(receiptFile)) {
|
|
21
|
+
const receipt = readJson(receiptFile);
|
|
22
|
+
const prior = receipt?.intent;
|
|
23
|
+
if (!prior || prior.request_id !== requestId || prior.snapshot !== snapshot || prior.engine_version !== engineVersion || prior.integrity_checksum !== (input.integrityChecksum ?? null))
|
|
24
|
+
throw new AppError("fork_request_conflict", "Fork output already belongs to another request", 1, { output });
|
|
25
|
+
return { receipt, output };
|
|
26
|
+
}
|
|
27
|
+
prepareForkEngine(engineVersion, input.integrityChecksum);
|
|
28
|
+
const manifest = prepareEvalRunSnapshot(snapshot);
|
|
29
|
+
if (manifest.schema_id !== "dd-flow/eval-run-snapshot@5" || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
|
|
30
|
+
throw new AppError("fork_checkpoint_invalid", "Fork requires a sealed stage-entry snapshot", 2, { snapshot });
|
|
31
|
+
}
|
|
32
|
+
const intent = {
|
|
33
|
+
schema_id: "dd-flow/run-fork-intent@1",
|
|
34
|
+
request_id: requestId,
|
|
35
|
+
snapshot,
|
|
36
|
+
snapshot_sha256: fileHash(manifestFile),
|
|
37
|
+
engine_version: engineVersion,
|
|
38
|
+
integrity_checksum: input.integrityChecksum ?? null,
|
|
39
|
+
target_stage: manifest.stage_entry
|
|
40
|
+
};
|
|
41
|
+
assertForkOutput(output, intentFile, intent);
|
|
42
|
+
return { manifest, snapshot, output, intent, intentFile, receiptFile, requestId };
|
|
43
|
+
}
|
|
44
|
+
function assertForkOutput(output, intentFile, intent) {
|
|
45
|
+
if (fs.existsSync(output) && !fs.statSync(output).isDirectory())
|
|
46
|
+
throw new AppError("fork_output_not_empty", "Fork output must be a directory", 2, { output });
|
|
47
|
+
if (fs.existsSync(output) && fs.readdirSync(output).length > 0) {
|
|
48
|
+
if (!fs.existsSync(intentFile) || hash(readJson(intentFile).intent) !== hash(intent))
|
|
49
|
+
throw new AppError("fork_output_not_empty", "Fork output must be empty", 2, { output });
|
|
50
|
+
const unknown = fs.readdirSync(output).filter(name => ![".fork-intent.json", "project", "dd-flow-home"].includes(name));
|
|
51
|
+
if (unknown.length)
|
|
52
|
+
throw new AppError("fork_retry_inspect_required", "Fork output contains data not owned by the interrupted fork", 1, { output, unknown });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function forkRunFromSnapshot(context, input, prepared = prepareRunFork(input)) {
|
|
56
|
+
if ("receipt" in prepared)
|
|
57
|
+
return prepared.receipt;
|
|
58
|
+
const { manifest, snapshot, output, intent, intentFile, receiptFile, requestId } = prepared;
|
|
59
|
+
assertForkOutput(output, intentFile, intent);
|
|
60
|
+
for (const name of ["project", "dd-flow-home"])
|
|
61
|
+
fs.rmSync(path.join(output, name), { recursive: true, force: true });
|
|
62
|
+
fs.mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
63
|
+
writeJsonAtomic(intentFile, { schema_id: "dd-flow/run-fork-intent@1", intent });
|
|
64
|
+
const projectRoot = path.join(output, "project");
|
|
65
|
+
const home = path.join(output, "dd-flow-home");
|
|
66
|
+
fs.mkdirSync(projectRoot, { recursive: true, mode: 0o700 });
|
|
67
|
+
const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
68
|
+
const restoreInput = { snapshot, projectRoot };
|
|
69
|
+
const restored = restoreEvalRunSnapshot(targetContext, restoreInput, prepareEvalRunRestore(targetContext, restoreInput, manifest));
|
|
70
|
+
const restoredContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
71
|
+
try {
|
|
72
|
+
const runHome = String(restored.run_home);
|
|
73
|
+
const previousBinding = readJson(path.join(runHome, "engine-binding.json"));
|
|
74
|
+
const lineage = {
|
|
75
|
+
schema_id: "dd-flow/run-fork-lineage@1",
|
|
76
|
+
request_id: requestId,
|
|
77
|
+
source_snapshot: snapshot,
|
|
78
|
+
source_snapshot_sha256: intent.snapshot_sha256,
|
|
79
|
+
source_run_id: manifest.run_id,
|
|
80
|
+
target_stage: manifest.stage_entry,
|
|
81
|
+
inherited_engine: previousBinding,
|
|
82
|
+
created_at: restoredContext.now()
|
|
83
|
+
};
|
|
84
|
+
writeJsonAtomic(path.join(runHome, "fork-lineage.json"), lineage);
|
|
85
|
+
// Install the immutable artifact belonging to the currently executing
|
|
86
|
+
// CLI into the new home. Its exact version/checksum were checked before
|
|
87
|
+
// any interrupted-copy cleanup; snapshots exclude installed engines.
|
|
88
|
+
installCurrentEngine(restoredContext, { forRouting: true });
|
|
89
|
+
const binding = upgradeForkedRunEngine(restoredContext, {
|
|
90
|
+
projectRoot,
|
|
91
|
+
runId: String(restored.run_id),
|
|
92
|
+
engineVersion: intent.engine_version,
|
|
93
|
+
...(intent.integrity_checksum ? { integrityChecksum: intent.integrity_checksum } : {}),
|
|
94
|
+
reason: `Fork ${requestId} from sealed checkpoint ${intent.snapshot_sha256}.`
|
|
95
|
+
});
|
|
96
|
+
const receipt = {
|
|
97
|
+
ok: true,
|
|
98
|
+
schema_id: "dd-flow/run-fork@1",
|
|
99
|
+
status: "ready",
|
|
100
|
+
intent,
|
|
101
|
+
fork: {
|
|
102
|
+
project_root: projectRoot,
|
|
103
|
+
dd_flow_home: home,
|
|
104
|
+
run_id: restored.run_id,
|
|
105
|
+
run_home: runHome,
|
|
106
|
+
workspace_root: restored.workspace_root,
|
|
107
|
+
target_stage: manifest.stage_entry,
|
|
108
|
+
engine: binding.engine,
|
|
109
|
+
lineage_file: path.join(runHome, "fork-lineage.json")
|
|
110
|
+
},
|
|
111
|
+
next_command: `dd-flow stage start ${restored.run_id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
|
|
112
|
+
};
|
|
113
|
+
writeJsonAtomic(receiptFile, receipt);
|
|
114
|
+
return receipt;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
restoredContext.db.close?.();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function readJson(file) {
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw new AppError("fork_checkpoint_invalid", "Fork metadata is not valid JSON", 2, { file, cause: String(error) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function writeJsonAtomic(file, value) {
|
|
129
|
+
const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
130
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
131
|
+
fs.renameSync(temporary, file);
|
|
132
|
+
}
|
|
133
|
+
function hash(value) { return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
|
134
|
+
function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
135
|
+
function requireText(value, name) { const text = value.trim(); if (!text)
|
|
136
|
+
throw new AppError("usage", `Missing required value: ${name}`, 2); return text; }
|
|
137
|
+
function requireId(value, name) { const text = requireText(value, name); if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(text))
|
|
138
|
+
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
|
+
}
|