@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100

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.
Files changed (153) hide show
  1. package/CHANGELOG.md +561 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +91 -15
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1506 -334
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  11. package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
  12. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  13. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  14. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  15. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  16. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
  19. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  21. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  22. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  23. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  24. package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
  25. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
  26. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  27. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
  28. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  29. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  30. package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  33. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  34. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  35. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  36. package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
  37. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  38. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  39. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  40. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  41. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  42. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  43. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  44. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  45. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  46. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  47. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  48. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  49. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  50. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  51. package/dist/runtime/context.js +3 -1
  52. package/dist/schemas/agent-profile.schema.json +1 -1
  53. package/dist/schemas/code-review-result.schema.json +2 -2
  54. package/dist/schemas/code-work-batch.schema.json +4 -3
  55. package/dist/schemas/code-work-result.schema.json +4 -4
  56. package/dist/schemas/harness-config.schema.json +23 -0
  57. package/dist/schemas/plan-review-decision.schema.json +1 -1
  58. package/dist/schemas/plan-review-result.schema.json +2 -2
  59. package/dist/schemas/run-control-receipt.schema.json +99 -0
  60. package/dist/schemas/run-control-request.schema.json +36 -0
  61. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  62. package/dist/services/canon.js +11 -8
  63. package/dist/services/cleanup.js +75 -21
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +676 -79
  66. package/dist/services/codex-hook-delivery.js +28 -0
  67. package/dist/services/command-context.js +80 -0
  68. package/dist/services/config.js +11 -8
  69. package/dist/services/continuation-outcome.js +20 -0
  70. package/dist/services/controller-fanout.js +134 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +125 -80
  73. package/dist/services/eval-snapshots.js +838 -74
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +110 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +68 -0
  78. package/dist/services/hooks.js +609 -310
  79. package/dist/services/lanes.js +62 -53
  80. package/dist/services/lifecycle-command.js +103 -17
  81. package/dist/services/lifecycle-contract.js +30 -0
  82. package/dist/services/lifecycle-invocations.js +1128 -0
  83. package/dist/services/managed-daemon-binding.js +38 -0
  84. package/dist/services/managed-processes.js +425 -0
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +78 -27
  87. package/dist/services/migrations.js +14 -9
  88. package/dist/services/native-daemon-history.js +49 -0
  89. package/dist/services/native-session-control.js +39 -0
  90. package/dist/services/plan-runtime.js +19 -8
  91. package/dist/services/plans.js +28 -25
  92. package/dist/services/portable-refs.js +57 -0
  93. package/dist/services/projects.js +18 -5
  94. package/dist/services/prompts.js +27 -18
  95. package/dist/services/protocols.js +64 -20
  96. package/dist/services/recovery-observation-budget.js +61 -0
  97. package/dist/services/recovery-snapshot-database.js +107 -0
  98. package/dist/services/repair-intents.js +40 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +363 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +222 -0
  103. package/dist/services/run-controller-capture.js +136 -0
  104. package/dist/services/run-controller-process.js +189 -0
  105. package/dist/services/run-controller-recovery.js +222 -0
  106. package/dist/services/run-controller-state.js +36 -0
  107. package/dist/services/run-controller.js +803 -0
  108. package/dist/services/run-engine-bindings.js +20 -62
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-projection.js +10 -8
  112. package/dist/services/run-recovery-runtime.js +69 -0
  113. package/dist/services/run-recovery.js +343 -0
  114. package/dist/services/runs.js +437 -81
  115. package/dist/services/runtime-budget.js +439 -0
  116. package/dist/services/runtime-command.js +43 -0
  117. package/dist/services/runtime-scope-capture.js +50 -0
  118. package/dist/services/runtime-scope-control.js +450 -0
  119. package/dist/services/runtime-scope-resume.js +565 -0
  120. package/dist/services/runtime-scope-stop.js +99 -0
  121. package/dist/services/runtime-scope-worker.js +243 -0
  122. package/dist/services/runtime-service.js +97 -0
  123. package/dist/services/schema-validation.js +21 -19
  124. package/dist/services/session-identity.js +19 -0
  125. package/dist/services/sessions.js +61 -80
  126. package/dist/services/stage-blocker.js +17 -7
  127. package/dist/services/stage-context.js +44 -18
  128. package/dist/services/stage-lifecycle.js +112 -108
  129. package/dist/services/stage-pause.js +117 -73
  130. package/dist/services/stage-work-graph.js +35 -0
  131. package/dist/services/usage.js +108 -132
  132. package/dist/services/vnext-code-review.js +245 -98
  133. package/dist/services/vnext-code.js +355 -257
  134. package/dist/services/vnext-execution-profile.js +5 -3
  135. package/dist/services/vnext-fanout.js +166 -26
  136. package/dist/services/vnext-merge.js +413 -147
  137. package/dist/services/vnext-plan-review.js +182 -109
  138. package/dist/services/vnext-plan.js +199 -82
  139. package/dist/services/vnext-protocolize.js +133 -60
  140. package/dist/services/vnext-specify.js +94 -71
  141. package/dist/services/work-registry.js +813 -160
  142. package/dist/services/workspace-bootstrap.js +76 -0
  143. package/dist/services/worktrees.js +85 -35
  144. package/dist/shared/entity-references.js +8 -0
  145. package/dist/shared/errors.js +12 -0
  146. package/dist/storage/database.js +569 -16
  147. package/dist/storage/paths.js +17 -4
  148. package/dist/storage/work-references.js +23 -0
  149. package/dist/storage/writer-contract.js +77 -0
  150. package/dist/storage/writer-migration.js +102 -0
  151. package/package.json +24 -13
  152. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
  153. 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
- export function findRunHome(ddFlowHome, projectRoot, runIdOrAlias) {
6
- const projectsRoot = path.join(ddFlowHome, "projects");
7
- if (!fs.existsSync(projectsRoot))
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 expectedRoot = realpathOrResolve(projectRoot);
10
- const matches = [];
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,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
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
4
+ import { publicSessionIdentity } from "./session-identity.js";
4
5
  export function refreshRunSessionProjection(context, projectId, runId) {
5
6
  const run = context.db.get("SELECT id, flow_kind, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
6
7
  if (!run)
@@ -9,7 +10,8 @@ export function refreshRunSessionProjection(context, projectId, runId) {
9
10
  const sessionRows = context.db.all(`SELECT session_id, harness, provider_session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
10
11
  created_at, updated_at, stopped_at, coverage_units_json
11
12
  FROM sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]);
12
- const sessions = sessionRows.map(sessionProjection);
13
+ const sessionIdentities = new Map(sessionRows.map((row) => [row.session_id, publicSessionIdentity(row)]));
14
+ const sessions = sessionRows.map((row) => sessionProjection(row, sessionIdentities));
13
15
  const workRows = context.db.all("SELECT work_id, parent_work_id, status, depends_on_json, launch_policy, started_at, completed_at FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]);
14
16
  const works = workRows.map((work) => ({ work_id: work.work_id, parent_work_id: work.parent_work_id, status: work.status, depends_on: parseStringArray(work.depends_on_json), launch_policy: work.launch_policy, started_at: work.started_at, completed_at: work.completed_at }));
15
17
  const rootWorkId = workRows.find((work) => work.parent_work_id === null)?.work_id ?? null;
@@ -123,10 +125,12 @@ function readPlanId(planPath) {
123
125
  return "unknown-plan";
124
126
  }
125
127
  function workerProjection(context, projectId, runId) {
126
- return Object.fromEntries(context.db.all("SELECT job_id, plan_item_id, status, worker_session_id FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY job_id", [projectId, runId]).map((job) => [job.job_id, {
128
+ return Object.fromEntries(context.db.all(`SELECT j.job_id, j.plan_item_id, j.status, j.worker_session_id, s.harness, s.provider_session_id
129
+ FROM flow_jobs j LEFT JOIN sessions s ON s.project_id = j.project_id AND s.session_id = j.worker_session_id
130
+ WHERE j.project_id = ? AND j.run_id = ? ORDER BY j.job_id`, [projectId, runId]).map((job) => [job.job_id, {
127
131
  units: [job.plan_item_id],
128
132
  status: { pending: "registered", done: "completed" }[job.status] ?? job.status,
129
- ...(job.worker_session_id ? { session_id: job.worker_session_id } : {})
133
+ ...(job.worker_session_id && job.harness ? { session: publicSessionIdentity({ harness: job.harness, provider_session_id: job.provider_session_id, session_id: job.worker_session_id }) } : {})
130
134
  }]));
131
135
  }
132
136
  function parseStringArray(value) {
@@ -138,12 +142,10 @@ function parseStringArray(value) {
138
142
  return [];
139
143
  }
140
144
  }
141
- function sessionProjection(row) {
145
+ function sessionProjection(row, identities) {
142
146
  return {
143
- session_id: row.session_id,
144
- harness: row.harness,
145
- provider_session_id: row.provider_session_id,
146
- parent_session_id: row.parent_session_id,
147
+ session: publicSessionIdentity(row),
148
+ ...(row.parent_session_id ? { parent_session: identities.get(row.parent_session_id) ?? null } : {}),
147
149
  role: row.role,
148
150
  session_kind: row.session_kind,
149
151
  work_id: row.worker_id,
@@ -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
+ }