@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.
Files changed (136) hide show
  1. package/CHANGELOG.md +396 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +300 -0
  5. package/dist/cli/help.js +81 -9
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +115 -0
  8. package/dist/cli/run-cli.js +1342 -375
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  11. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  12. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  13. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  14. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  15. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  16. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  17. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  18. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  19. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  21. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  22. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  23. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  24. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  25. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  26. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  27. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  28. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  29. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  30. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  32. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  33. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  34. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  36. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  37. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  38. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  39. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  40. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  41. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  42. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  43. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  44. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  45. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  46. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  47. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  48. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  49. package/dist/schemas/agent-profile.schema.json +1 -1
  50. package/dist/schemas/code-review-result.schema.json +2 -2
  51. package/dist/schemas/code-work-batch.schema.json +4 -3
  52. package/dist/schemas/code-work-result.schema.json +4 -4
  53. package/dist/schemas/plan-review-result.schema.json +2 -2
  54. package/dist/schemas/run-control-receipt.schema.json +99 -0
  55. package/dist/schemas/run-control-request.schema.json +36 -0
  56. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  57. package/dist/services/canon.js +11 -8
  58. package/dist/services/cleanup.js +57 -13
  59. package/dist/services/cli-operation-classifier.js +22 -24
  60. package/dist/services/code-checks.js +543 -96
  61. package/dist/services/codex-hook-delivery.js +28 -0
  62. package/dist/services/config.js +11 -8
  63. package/dist/services/controller-fanout.js +116 -0
  64. package/dist/services/dashboard.js +103 -31
  65. package/dist/services/engines.js +121 -76
  66. package/dist/services/eval-snapshots.js +750 -73
  67. package/dist/services/execution-policy.js +170 -0
  68. package/dist/services/external-work-launch.js +110 -0
  69. package/dist/services/harness-adapter.js +147 -24
  70. package/dist/services/harness-config.js +2 -0
  71. package/dist/services/hooks.js +553 -285
  72. package/dist/services/lanes.js +61 -53
  73. package/dist/services/lifecycle-command.js +87 -7
  74. package/dist/services/lifecycle-invocations.js +828 -0
  75. package/dist/services/managed-daemon-binding.js +38 -0
  76. package/dist/services/managed-processes.js +283 -27
  77. package/dist/services/merge-queue.js +179 -104
  78. package/dist/services/merge-server.js +73 -28
  79. package/dist/services/migrations.js +14 -9
  80. package/dist/services/native-daemon-history.js +49 -0
  81. package/dist/services/native-session-control.js +39 -0
  82. package/dist/services/plan-runtime.js +19 -8
  83. package/dist/services/plans.js +28 -25
  84. package/dist/services/projects.js +18 -5
  85. package/dist/services/prompts.js +23 -16
  86. package/dist/services/protocols.js +64 -20
  87. package/dist/services/recovery-observation-budget.js +61 -0
  88. package/dist/services/recovery-snapshot-database.js +107 -0
  89. package/dist/services/run-control-receipt.js +56 -0
  90. package/dist/services/run-control-worker.js +363 -0
  91. package/dist/services/run-control.js +876 -0
  92. package/dist/services/run-controller-adapter.js +222 -0
  93. package/dist/services/run-controller-capture.js +134 -0
  94. package/dist/services/run-controller-process.js +189 -0
  95. package/dist/services/run-controller-recovery.js +222 -0
  96. package/dist/services/run-controller-state.js +36 -0
  97. package/dist/services/run-controller.js +779 -0
  98. package/dist/services/run-engine-bindings.js +1 -1
  99. package/dist/services/run-fork.js +138 -0
  100. package/dist/services/run-observations.js +112 -0
  101. package/dist/services/run-recovery-runtime.js +69 -0
  102. package/dist/services/run-recovery.js +342 -0
  103. package/dist/services/runs.js +368 -74
  104. package/dist/services/runtime-budget.js +434 -0
  105. package/dist/services/runtime-scope-capture.js +50 -0
  106. package/dist/services/runtime-scope-control.js +450 -0
  107. package/dist/services/runtime-scope-resume.js +565 -0
  108. package/dist/services/runtime-scope-stop.js +99 -0
  109. package/dist/services/runtime-scope-worker.js +243 -0
  110. package/dist/services/runtime-service.js +97 -0
  111. package/dist/services/schema-validation.js +10 -8
  112. package/dist/services/sessions.js +35 -69
  113. package/dist/services/stage-blocker.js +17 -7
  114. package/dist/services/stage-context.js +44 -18
  115. package/dist/services/stage-lifecycle.js +99 -102
  116. package/dist/services/stage-pause.js +65 -23
  117. package/dist/services/usage.js +37 -93
  118. package/dist/services/vnext-code-review.js +189 -85
  119. package/dist/services/vnext-code.js +257 -243
  120. package/dist/services/vnext-execution-profile.js +5 -3
  121. package/dist/services/vnext-fanout.js +134 -19
  122. package/dist/services/vnext-merge.js +285 -102
  123. package/dist/services/vnext-plan-review.js +148 -94
  124. package/dist/services/vnext-plan.js +141 -72
  125. package/dist/services/vnext-protocolize.js +129 -56
  126. package/dist/services/vnext-specify.js +87 -64
  127. package/dist/services/work-registry.js +707 -156
  128. package/dist/services/workspace-bootstrap.js +76 -0
  129. package/dist/services/worktrees.js +85 -35
  130. package/dist/shared/errors.js +12 -0
  131. package/dist/storage/database.js +444 -32
  132. package/dist/storage/paths.js +17 -4
  133. package/dist/storage/writer-contract.js +77 -0
  134. package/dist/storage/writer-migration.js +102 -0
  135. package/package.json +11 -4
  136. package/tools/repair-paused-run-status.mjs +59 -0
@@ -4,9 +4,124 @@ import crypto from "node:crypto";
4
4
  import { createRequire } from "node:module";
5
5
  import { ensureDir } from "./paths.js";
6
6
  import { AppError } from "../shared/errors.js";
7
+ import { executeStoreSchema, readStoreWriterContract, registerStoreWriter } from "./writer-contract.js";
7
8
  const require = createRequire(import.meta.url);
8
9
  const { DatabaseSync } = require("node:sqlite");
9
10
  const resourceDatabases = new Map();
11
+ /** Invocation authority is prepared once with the runtime schema. Native
12
+ * hooks must only verify this table; they never create it on the hot path. */
13
+ export function ensureLifecycleInvocationStorage(db) {
14
+ db.exec(`
15
+ CREATE TABLE IF NOT EXISTS lifecycle_invocations (
16
+ id TEXT PRIMARY KEY, scope_json TEXT NOT NULL, command TEXT NOT NULL,
17
+ fingerprint TEXT NOT NULL, status TEXT NOT NULL,
18
+ event_key TEXT UNIQUE, identity_json TEXT, deadline INTEGER, outcome_json TEXT,
19
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
20
+ );
21
+ CREATE INDEX IF NOT EXISTS idx_lifecycle_invocations_scope
22
+ ON lifecycle_invocations(scope_json, fingerprint, created_at);
23
+ `);
24
+ }
25
+ /** RUN authority stays in its existing store; process leases stay in runtime.sqlite. */
26
+ export function ensureRunControlStorage(db) {
27
+ db.execSchema(`
28
+ CREATE TABLE IF NOT EXISTS run_controls (
29
+ control_id TEXT PRIMARY KEY,
30
+ project_id TEXT NOT NULL, run_id TEXT NOT NULL,
31
+ request_id TEXT NOT NULL, request_hash TEXT NOT NULL, request_json TEXT NOT NULL,
32
+ recovery_id TEXT NOT NULL REFERENCES run_recovery_guards(recovery_id),
33
+ action TEXT NOT NULL, status TEXT NOT NULL,
34
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
35
+ UNIQUE(project_id, run_id, request_id),
36
+ FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
37
+ );
38
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_control_active
39
+ ON run_controls(project_id, run_id) WHERE status IN ('requested','settled','resuming');
40
+ CREATE TABLE IF NOT EXISTS run_control_admissions (
41
+ control_id TEXT NOT NULL REFERENCES run_controls(control_id),
42
+ work_session_id TEXT NOT NULL REFERENCES work_sessions(id),
43
+ work_id TEXT NOT NULL REFERENCES works(work_id),
44
+ session_id TEXT NOT NULL, admitted_at TEXT NOT NULL,
45
+ PRIMARY KEY(control_id, work_session_id)
46
+ );
47
+ CREATE TABLE IF NOT EXISTS run_control_stage_admissions (
48
+ control_id TEXT NOT NULL REFERENCES run_controls(control_id),
49
+ stage TEXT NOT NULL, attempt TEXT NOT NULL,
50
+ work_session_id TEXT NOT NULL REFERENCES work_sessions(id),
51
+ PRIMARY KEY(control_id, stage)
52
+ );
53
+ CREATE TABLE IF NOT EXISTS run_control_operations (
54
+ operation_id TEXT PRIMARY KEY, control_id TEXT NOT NULL REFERENCES run_controls(control_id),
55
+ target_json TEXT NOT NULL, status TEXT NOT NULL, receipt_json TEXT, error_json TEXT,
56
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
57
+ );
58
+ CREATE TABLE IF NOT EXISTS run_control_workers (
59
+ control_id TEXT PRIMARY KEY REFERENCES run_controls(control_id),
60
+ owner_token TEXT NOT NULL, owner_pid INTEGER,
61
+ status TEXT NOT NULL, process_id TEXT, process_lease_token TEXT,
62
+ resource_home TEXT NOT NULL, state_dir TEXT NOT NULL,
63
+ snapshot_json TEXT, error_json TEXT,
64
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
65
+ );
66
+ `);
67
+ }
68
+ export function ensureRunControllerStorage(db) {
69
+ db.execSchema(`
70
+ CREATE TABLE IF NOT EXISTS run_controllers (
71
+ controller_id TEXT PRIMARY KEY,
72
+ project_id TEXT NOT NULL, run_id TEXT NOT NULL,
73
+ request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
74
+ generation INTEGER NOT NULL, status TEXT NOT NULL,
75
+ owner_token TEXT NOT NULL, process_id TEXT, process_lease_token TEXT,
76
+ owner_pid INTEGER, manifest_json TEXT NOT NULL, state_json TEXT NOT NULL,
77
+ state_dir TEXT NOT NULL, last_error_json TEXT,
78
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
79
+ FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id),
80
+ UNIQUE(project_id, run_id, request_id)
81
+ );
82
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_controller_owner
83
+ ON run_controllers(project_id, run_id)
84
+ WHERE status NOT IN ('completed', 'cancelled', 'superseded');
85
+ CREATE TABLE IF NOT EXISTS run_controller_events (
86
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ controller_id TEXT NOT NULL REFERENCES run_controllers(controller_id),
88
+ event_type TEXT NOT NULL, data_json TEXT NOT NULL, created_at TEXT NOT NULL
89
+ );
90
+ CREATE INDEX IF NOT EXISTS idx_run_controller_events
91
+ ON run_controller_events(controller_id, sequence);
92
+ CREATE TABLE IF NOT EXISTS run_controller_operations (
93
+ operation_id TEXT PRIMARY KEY,
94
+ controller_id TEXT NOT NULL REFERENCES run_controllers(controller_id),
95
+ generation INTEGER NOT NULL, input_hash TEXT NOT NULL,
96
+ input_json TEXT NOT NULL, status TEXT NOT NULL,
97
+ receipt_json TEXT, error_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
98
+ );
99
+ `);
100
+ }
101
+ /** Recovery guards apply to every RUN kind, including custom eval fixtures. */
102
+ export function ensureRecoveryStorage(db) {
103
+ db.exec(`
104
+ CREATE TABLE IF NOT EXISTS run_recovery_guards (
105
+ recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
106
+ );
107
+ CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
108
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
109
+ CREATE TABLE IF NOT EXISTS work_recovery_segments (
110
+ recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
111
+ );
112
+ CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
113
+ CREATE TABLE IF NOT EXISTS run_recovery_bindings (
114
+ recovery_id TEXT PRIMARY KEY REFERENCES run_recovery_guards(recovery_id),
115
+ work_id TEXT NOT NULL REFERENCES works(work_id), prior_work_session_id TEXT NOT NULL,
116
+ session_id TEXT NOT NULL, harness TEXT NOT NULL, provider_session_id TEXT NOT NULL,
117
+ daemon_id TEXT NOT NULL, status TEXT NOT NULL, hook_event_key TEXT,
118
+ created_at TEXT NOT NULL, accepted_at TEXT
119
+ );
120
+ `);
121
+ ensureColumn(db, "run_recovery_bindings", "packet_json", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_json TEXT");
122
+ ensureColumn(db, "run_recovery_bindings", "packet_sha256", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_sha256 TEXT");
123
+ ensureColumn(db, "run_recovery_bindings", "runtime_dispatch_json", "ALTER TABLE run_recovery_bindings ADD COLUMN runtime_dispatch_json TEXT");
124
+ }
10
125
  /** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
11
126
  export function ensureVnextWorkStorage(db) {
12
127
  const legacy = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('vnext_works', 'vnext_agent_turns', 'flow_agent_turns') LIMIT 1");
@@ -16,7 +131,7 @@ export function ensureVnextWorkStorage(db) {
16
131
  if (existing && (!/task TEXT NOT NULL/.test(existing.sql) || /launch_token|adapter_session_id/.test(existing.sql))) {
17
132
  throw new AppError("beta_storage_recreate_required", "This beta requires a fresh DD_FLOW_HOME; the existing works schema is not compatible", 1);
18
133
  }
19
- db.exec(`
134
+ db.execSchema(`
20
135
  CREATE TABLE IF NOT EXISTS works (
21
136
  work_id TEXT PRIMARY KEY,
22
137
  project_id TEXT NOT NULL,
@@ -46,6 +161,7 @@ export function ensureVnextWorkStorage(db) {
46
161
  status TEXT NOT NULL,
47
162
  prompt_path TEXT NOT NULL,
48
163
  result_path TEXT,
164
+ start_receipt_json TEXT,
49
165
  created_at TEXT NOT NULL,
50
166
  updated_at TEXT NOT NULL,
51
167
  completed_at TEXT,
@@ -53,6 +169,16 @@ export function ensureVnextWorkStorage(db) {
53
169
  );
54
170
  CREATE INDEX IF NOT EXISTS idx_work_sessions_work ON work_sessions(work_id, created_at DESC);
55
171
  CREATE UNIQUE INDEX IF NOT EXISTS idx_work_sessions_open ON work_sessions(work_id) WHERE status = 'running';
172
+
173
+ CREATE TABLE IF NOT EXISTS run_recovery_guards (
174
+ recovery_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, generation INTEGER NOT NULL, status TEXT NOT NULL, interruption_json TEXT NOT NULL, settlement_json TEXT, capture_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sealed_at TEXT, resumed_at TEXT, UNIQUE(project_id, run_id, generation), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
175
+ );
176
+ CREATE INDEX IF NOT EXISTS idx_run_recovery_guards_run ON run_recovery_guards(project_id, run_id, generation DESC);
177
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_recovery_guards_active ON run_recovery_guards(project_id, run_id) WHERE status IN ('draining', 'sealed', 'resuming');
178
+ CREATE TABLE IF NOT EXISTS work_recovery_segments (
179
+ recovery_id TEXT NOT NULL, work_id TEXT NOT NULL, generation INTEGER NOT NULL, prior_started_at TEXT, evidence_path TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(recovery_id, work_id), FOREIGN KEY(recovery_id) REFERENCES run_recovery_guards(recovery_id), FOREIGN KEY(work_id) REFERENCES works(work_id)
180
+ );
181
+ CREATE INDEX IF NOT EXISTS idx_work_recovery_segments_work ON work_recovery_segments(work_id, generation DESC);
56
182
  CREATE TABLE IF NOT EXISTS check_receipts (
57
183
  id TEXT PRIMARY KEY,
58
184
  project_id TEXT NOT NULL,
@@ -65,6 +191,9 @@ export function ensureVnextWorkStorage(db) {
65
191
  command TEXT NOT NULL,
66
192
  input_hash TEXT NOT NULL DEFAULT '',
67
193
  verification_epoch TEXT NOT NULL DEFAULT '',
194
+ reservation_sequence INTEGER NOT NULL DEFAULT 0,
195
+ retry_of_receipt_id TEXT,
196
+ retry_reason TEXT,
68
197
  status TEXT NOT NULL,
69
198
  exit_code INTEGER,
70
199
  stdout_path TEXT NOT NULL,
@@ -82,6 +211,34 @@ export function ensureVnextWorkStorage(db) {
82
211
  );
83
212
  CREATE INDEX IF NOT EXISTS idx_check_receipts_run ON check_receipts(project_id, run_id, started_at);
84
213
  `);
214
+ // A committed Work start is authoritative even if publishing its prompt
215
+ // files is interrupted after commit. Keep the exact bytes for repair.
216
+ db.writeTransaction(() => {
217
+ const columns = db.all("PRAGMA table_info(work_sessions)");
218
+ if (!columns.some((column) => column.name === "start_receipt_json"))
219
+ db.execSchema("ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
220
+ });
221
+ }
222
+ /** Durable intent for a provider-root external Work. It is deliberately
223
+ * separate from `work_sessions`: only a trusted hook may bind that Session. */
224
+ export function ensureWorkLaunchStorage(db) {
225
+ db.execSchema(`
226
+ CREATE TABLE IF NOT EXISTS work_launches (
227
+ operation_id TEXT PRIMARY KEY,
228
+ work_id TEXT NOT NULL UNIQUE,
229
+ profile_json TEXT NOT NULL,
230
+ profile_hash TEXT NOT NULL,
231
+ status TEXT NOT NULL,
232
+ state_dir TEXT NOT NULL,
233
+ journal_path TEXT NOT NULL,
234
+ provider_session_id TEXT,
235
+ adapter_receipt_json TEXT,
236
+ created_at TEXT NOT NULL,
237
+ updated_at TEXT NOT NULL,
238
+ FOREIGN KEY(work_id) REFERENCES works(work_id)
239
+ );
240
+ CREATE INDEX IF NOT EXISTS idx_work_launches_status ON work_launches(status, updated_at DESC);
241
+ `);
85
242
  }
86
243
  export function getDatabase(ddFlowHome, mode = "initialize") {
87
244
  const dbPath = path.join(ddFlowHome, "db.sqlite");
@@ -89,42 +246,171 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
89
246
  if (mode === "read_existing" && !exists) {
90
247
  return emptyReadOnlyDatabase(dbPath);
91
248
  }
249
+ if (mode === "hook" && !exists)
250
+ throw new AppError("hook_storage_unprepared", "Prepare the runtime store before starting native hooks", 1);
92
251
  if (!exists || mode === "migrate")
93
252
  ensureDir(ddFlowHome);
94
253
  const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
95
- configureDatabase(db, mode);
254
+ db.exec("PRAGMA busy_timeout = 4000");
255
+ if (mode !== "read_existing") {
256
+ try {
257
+ registerStoreWriter(db);
258
+ }
259
+ catch (error) {
260
+ db.close();
261
+ throw error;
262
+ }
263
+ }
264
+ try {
265
+ configureDatabase(db, mode);
266
+ }
267
+ catch (error) {
268
+ db.close();
269
+ throw error;
270
+ }
96
271
  // Internal additive schema changes must be available to every write command.
97
272
  // Higher-level Memory Bank migrations remain explicit in services/migrations.
98
- if (mode !== "read_existing")
99
- migrate(db, dbPath);
273
+ if (mode !== "read_existing") {
274
+ try {
275
+ const initialize = () => { migrate(db); ensureRecoveryStorage(db); ensureLifecycleInvocationStorage(db); };
276
+ if (mode !== "hook") {
277
+ const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
278
+ if (fresh || readStoreWriterContract(db) !== null)
279
+ executeStoreSchema(db, initialize, fresh);
280
+ else
281
+ initialize();
282
+ // This migration owns a VACUUM backup and a separate data transaction.
283
+ migrateSessionStorageKeys(db, dbPath);
284
+ }
285
+ else {
286
+ assertHookStoreReady(db);
287
+ }
288
+ }
289
+ catch (error) {
290
+ db.close();
291
+ throw error;
292
+ }
293
+ }
100
294
  if (mode === "read_existing")
101
295
  db.exec("PRAGMA query_only = ON");
102
296
  db.exec("PRAGMA foreign_keys = ON");
103
- return {
104
- path: dbPath,
105
- writable: mode !== "read_existing",
106
- close: () => db.close?.(),
107
- exec: (sql) => db.exec(sql),
108
- run: (sql, params = []) => db.prepare(sql).run(...params),
109
- get: (sql, params = []) => db.prepare(sql).get(...params),
110
- all: (sql, params = []) => db.prepare(sql).all(...params)
297
+ return databaseFacade(dbPath, mode !== "read_existing", db);
298
+ }
299
+ /**
300
+ * Hook ingress is a hot, synchronous path. It may write receipts, but it
301
+ * must never perform schema migration or VACUUM while the native host waits.
302
+ * Startup/migration owns this contract; a missing table is an explicit
303
+ * preparation error instead of an implicit migration race.
304
+ */
305
+ function assertHookStoreReady(db) {
306
+ const required = ["projects", "hook_events", "codex_session_bindings", "sessions", "flow_session_segments", "lifecycle_invocations"];
307
+ const missing = required.filter((table) => !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table));
308
+ if (missing.length)
309
+ throw new AppError("hook_storage_unprepared", "Managed store is not prepared for synchronous hook admission", 1, { missing_tables: missing, next_action: "Open the runtime with a normal dd-flow command and complete migration before starting the native Session." });
310
+ // Table presence alone does not establish compatibility with an older home.
311
+ const receiptColumns = {
312
+ hook_events: "id, project_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, event_name, tool_name, event_key, cwd, match_key, transcript_path, status, sanitized_summary, outcome_json, created_at",
313
+ lifecycle_invocations: "id, scope_json, command, fingerprint, status, event_key, identity_json, deadline, outcome_json, created_at, updated_at",
314
+ codex_session_bindings: "session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at"
111
315
  };
316
+ for (const [table, columns] of Object.entries(receiptColumns)) {
317
+ try {
318
+ db.prepare(`SELECT ${columns} FROM ${table} LIMIT 0`);
319
+ }
320
+ catch {
321
+ throw new AppError("hook_storage_unprepared", "Managed receipt schema needs preparation before native hooks start", 1, { table });
322
+ }
323
+ }
112
324
  }
113
325
  /**
114
326
  * Opens the small host-wide resource registry. It is intentionally separate
115
327
  * from a RUN database: a process or a port can outlive the CLI client that
116
328
  * created it, while a RUN database belongs to one flow home.
117
329
  */
118
- export function getResourceDatabase(resourceHome) {
119
- ensureDir(resourceHome);
330
+ export function withResourceReadSnapshot(resourceHome, read) {
120
331
  const dbPath = path.join(resourceHome, "runtime.sqlite");
121
332
  const cached = resourceDatabases.get(dbPath);
333
+ if (!cached && !fs.existsSync(dbPath))
334
+ return read(emptyReadOnlyDatabase(dbPath));
335
+ const db = cached ?? new DatabaseSync(dbPath, { readOnly: true });
336
+ try {
337
+ if (!cached)
338
+ db.exec("PRAGMA busy_timeout = 4000; PRAGMA query_only = ON");
339
+ // A second process can observe SQLite's file before the initializing
340
+ // transaction publishes its schema. Treat that pre-commit view exactly
341
+ // like an absent registry; the writer path remains the serialization
342
+ // boundary and will reopen the committed schema.
343
+ if (!cached && !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get()) {
344
+ return read(emptyReadOnlyDatabase(dbPath));
345
+ }
346
+ const reader = cached
347
+ ? { ...emptyReadOnlyDatabase(dbPath), get: cached.get, all: cached.all }
348
+ : databaseFacade(dbPath, false, db);
349
+ // SAVEPOINT also composes with an existing owner's transaction. The
350
+ // callback sees its uncommitted state but never receives write capability.
351
+ db.exec("SAVEPOINT resource_read_snapshot");
352
+ try {
353
+ const result = read(reader);
354
+ db.exec("RELEASE resource_read_snapshot");
355
+ return result;
356
+ }
357
+ catch (error) {
358
+ db.exec("ROLLBACK TO resource_read_snapshot");
359
+ db.exec("RELEASE resource_read_snapshot");
360
+ throw error;
361
+ }
362
+ }
363
+ finally {
364
+ if (!cached)
365
+ db.close?.();
366
+ }
367
+ }
368
+ export function getResourceDatabase(resourceHome, mode = "initialize") {
369
+ const dbPath = path.join(resourceHome, "runtime.sqlite");
370
+ const cached = resourceDatabases.get(dbPath);
371
+ if (mode === "read_existing") {
372
+ // Reuse a current transaction's view without exposing its write capability.
373
+ if (cached)
374
+ return { ...emptyReadOnlyDatabase(dbPath), get: cached.get, all: cached.all };
375
+ if (!fs.existsSync(dbPath))
376
+ return emptyReadOnlyDatabase(dbPath);
377
+ const db = new DatabaseSync(dbPath, { readOnly: true });
378
+ try {
379
+ db.exec("PRAGMA busy_timeout = 4000; PRAGMA query_only = ON");
380
+ if (!db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get()) {
381
+ db.close();
382
+ return emptyReadOnlyDatabase(dbPath);
383
+ }
384
+ }
385
+ catch (error) {
386
+ db.close();
387
+ throw error;
388
+ }
389
+ return databaseFacade(dbPath, false, db);
390
+ }
391
+ ensureDir(resourceHome);
122
392
  if (cached)
123
393
  return cached;
124
394
  const db = new DatabaseSync(dbPath);
125
- configureDatabase(db, "initialize");
395
+ db.exec("PRAGMA busy_timeout = 4000");
396
+ try {
397
+ registerStoreWriter(db);
398
+ }
399
+ catch (error) {
400
+ db.close();
401
+ throw error;
402
+ }
403
+ try {
404
+ configureDatabase(db, "initialize");
405
+ }
406
+ catch (error) {
407
+ db.close();
408
+ throw error;
409
+ }
126
410
  db.exec("PRAGMA foreign_keys = ON");
127
- db.exec(`
411
+ const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
412
+ try {
413
+ executeStoreSchema(db, `
128
414
  CREATE TABLE IF NOT EXISTS managed_processes (
129
415
  id TEXT PRIMARY KEY,
130
416
  kind TEXT NOT NULL,
@@ -163,16 +449,16 @@ export function getResourceDatabase(resourceHome) {
163
449
  );
164
450
  CREATE INDEX IF NOT EXISTS idx_managed_resources_lease
165
451
  ON managed_resources(lease_expires_at, updated_at);
166
- `);
167
- const database = {
168
- path: dbPath,
169
- writable: true,
170
- close: () => { resourceDatabases.delete(dbPath); db.close?.(); },
171
- exec: (sql) => db.exec(sql),
172
- run: (sql, params = []) => db.prepare(sql).run(...params),
173
- get: (sql, params = []) => db.prepare(sql).get(...params),
174
- all: (sql, params = []) => db.prepare(sql).all(...params)
175
- };
452
+ `, fresh);
453
+ }
454
+ catch (error) {
455
+ db.close();
456
+ throw error;
457
+ }
458
+ const database = databaseFacade(dbPath, true, db, () => {
459
+ resourceDatabases.delete(dbPath);
460
+ db.close?.();
461
+ });
176
462
  resourceDatabases.set(dbPath, database);
177
463
  return database;
178
464
  }
@@ -181,19 +467,131 @@ function emptyReadOnlyDatabase(dbPath) {
181
467
  path: dbPath,
182
468
  writable: false,
183
469
  exec: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
470
+ execSchema: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
471
+ beginWriteTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
472
+ writeTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
184
473
  run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
185
474
  get: () => undefined,
186
475
  all: () => []
187
476
  };
188
477
  }
478
+ function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
479
+ let reserved = false;
480
+ let poisoned = false;
481
+ let savepoint = 0;
482
+ const assertUsable = () => { if (poisoned)
483
+ throw new AppError("write_transaction_unresolved", "Transaction cleanup failed; reopen the connection before continuing", 1); };
484
+ const beginWriteTransaction = () => {
485
+ assertUsable();
486
+ if (!writable)
487
+ throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
488
+ db.exec("BEGIN IMMEDIATE");
489
+ reserved = true;
490
+ };
491
+ const rollback = (sql, original) => {
492
+ try {
493
+ db.exec(sql);
494
+ }
495
+ catch (cleanup) {
496
+ poisoned = true;
497
+ if (original instanceof AppError)
498
+ throw new AppError(original.code, original.message, original.exitCode, { ...original.details, transaction_cleanup_error: errorDetails(cleanup) });
499
+ throw new AppError("write_transaction_failed", "Write transaction failed and SQLite cleanup also failed", 1, { cause: errorDetails(original), transaction_cleanup_error: errorDetails(cleanup) });
500
+ }
501
+ throw original;
502
+ };
503
+ const writeTransaction = (operation) => {
504
+ assertUsable();
505
+ if (!writable)
506
+ throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
507
+ if (db.isTransaction && !reserved)
508
+ throw new AppError("write_transaction_unowned", "Cannot write inside an unowned transaction or read snapshot", 1);
509
+ const nested = db.isTransaction;
510
+ const marker = `dd_flow_write_${++savepoint}`;
511
+ try {
512
+ if (nested)
513
+ db.exec(`SAVEPOINT ${marker}`);
514
+ else
515
+ beginWriteTransaction();
516
+ }
517
+ catch (error) {
518
+ throw new AppError("storage_write_failed", "Cannot reserve SQLite writer", 1, { cause: errorDetails(error), transaction_phase: "begin", transaction_rolled_back: true });
519
+ }
520
+ let phase = "body";
521
+ try {
522
+ const value = operation();
523
+ if (value && typeof value.then === "function") {
524
+ void Promise.resolve(value).catch(() => { });
525
+ throw new AppError("write_transaction_async", "Write transaction callback must be synchronous", 1);
526
+ }
527
+ phase = "commit";
528
+ if (nested)
529
+ db.exec(`RELEASE ${marker}`);
530
+ else
531
+ db.exec("COMMIT");
532
+ return value;
533
+ }
534
+ catch (error) {
535
+ try {
536
+ return rollback(nested ? `ROLLBACK TO ${marker}; RELEASE ${marker}` : "ROLLBACK", error);
537
+ }
538
+ catch (failure) {
539
+ if (failure === error && !(error instanceof AppError) && typeof error === "object" && error !== null && "code" in error && String(error.code).startsWith("ERR_SQLITE"))
540
+ throw new AppError("storage_write_failed", "SQLite write failed", 1, { cause: errorDetails(error), transaction_phase: phase, transaction_rolled_back: true });
541
+ throw failure;
542
+ }
543
+ }
544
+ finally {
545
+ if (!db.isTransaction)
546
+ reserved = false;
547
+ }
548
+ };
549
+ return {
550
+ path: dbPath,
551
+ writable,
552
+ close,
553
+ exec: (sql) => { assertUsable(); try {
554
+ db.exec(sql);
555
+ }
556
+ finally {
557
+ if (!db.isTransaction)
558
+ reserved = false;
559
+ } },
560
+ execSchema: (sql) => { assertUsable(); if (db.isTransaction && !reserved)
561
+ throw new AppError("write_transaction_unowned", "Cannot change schema inside an unowned transaction", 1); executeStoreSchema(db, sql); },
562
+ beginWriteTransaction,
563
+ writeTransaction,
564
+ run: (sql, params = []) => { assertUsable(); return db.prepare(sql).run(...params); },
565
+ get: (sql, params = []) => db.prepare(sql).get(...params),
566
+ all: (sql, params = []) => db.prepare(sql).all(...params)
567
+ };
568
+ }
569
+ function errorDetails(error) {
570
+ const value = error;
571
+ return { message: String(value?.message ?? error), ...(typeof value?.code === "string" ? { code: value.code } : {}), ...(typeof value?.errcode === "number" ? { sqlite_extended_code: value.errcode } : {}) };
572
+ }
189
573
  function configureDatabase(db, mode) {
190
- db.exec("PRAGMA busy_timeout = 4000");
191
574
  if (mode !== "read_existing") {
192
- db.exec("PRAGMA journal_mode = WAL");
575
+ // Concurrent first openers can collide while changing journal mode, even
576
+ // with busy_timeout set. Retrying this configuration does not replay writes.
577
+ const deadline = performance.now() + 4000, pause = new Int32Array(new SharedArrayBuffer(4));
578
+ for (;;) {
579
+ try {
580
+ const current = db.prepare("PRAGMA journal_mode").get();
581
+ if (current.journal_mode !== "wal")
582
+ db.exec("PRAGMA journal_mode = WAL");
583
+ break;
584
+ }
585
+ catch (error) {
586
+ if ((error.errcode ?? 0) % 256 !== 5 || performance.now() >= deadline)
587
+ throw error;
588
+ Atomics.wait(pause, 0, 0, Math.min(25, Math.max(1, deadline - performance.now())));
589
+ }
590
+ }
193
591
  db.exec("PRAGMA synchronous = NORMAL");
194
592
  }
195
593
  }
196
- function migrate(db, dbPath) {
594
+ function migrate(db) {
197
595
  db.exec(`
198
596
  CREATE TABLE IF NOT EXISTS projects (
199
597
  id TEXT PRIMARY KEY,
@@ -574,6 +972,7 @@ function migrate(db, dbPath) {
574
972
  lock_acquired_at TEXT,
575
973
  checkpoint TEXT NOT NULL DEFAULT 'queued',
576
974
  profile_hash TEXT,
975
+ replacement_of_merge_request_id TEXT,
577
976
  adapter_receipt_json TEXT,
578
977
  result_json TEXT,
579
978
  last_error_json TEXT,
@@ -585,8 +984,8 @@ function migrate(db, dbPath) {
585
984
  );
586
985
  CREATE INDEX IF NOT EXISTS idx_merge_requests_fifo
587
986
  ON merge_requests(project_id, status, created_at, merge_request_id);
588
- CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_run
589
- ON merge_requests(project_id, run_id);
987
+ CREATE INDEX IF NOT EXISTS idx_merge_requests_run
988
+ ON merge_requests(project_id, run_id, created_at);
590
989
  CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_active_project
591
990
  ON merge_requests(project_id)
592
991
  WHERE status IN ('active', 'waiting_user', 'action_required', 'recovery_required');
@@ -798,7 +1197,6 @@ function migrate(db, dbPath) {
798
1197
  ensureColumn(db, "sessions", "plan_item_id", "ALTER TABLE sessions ADD COLUMN plan_item_id TEXT");
799
1198
  ensureColumn(db, "sessions", "session_kind", "ALTER TABLE sessions ADD COLUMN session_kind TEXT");
800
1199
  ensureColumn(db, "sessions", "coverage_units_json", "ALTER TABLE sessions ADD COLUMN coverage_units_json TEXT NOT NULL DEFAULT '[]'");
801
- migrateSessionStorageKeys(db, dbPath);
802
1200
  ensureColumn(db, "usage", "cache_read_input_tokens", "ALTER TABLE usage ADD COLUMN cache_read_input_tokens INTEGER");
803
1201
  ensureColumn(db, "usage", "cache_write_input_tokens", "ALTER TABLE usage ADD COLUMN cache_write_input_tokens INTEGER");
804
1202
  ensureColumn(db, "usage", "uncached_input_tokens", "ALTER TABLE usage ADD COLUMN uncached_input_tokens INTEGER");
@@ -811,9 +1209,19 @@ function migrate(db, dbPath) {
811
1209
  ensureColumn(db, "runs", "run_root", "ALTER TABLE runs ADD COLUMN run_root TEXT");
812
1210
  db.prepare("UPDATE runs SET run_root = COALESCE(run_root, run_home_path, run_dir) WHERE run_root IS NULL OR run_root = ''").run();
813
1211
  ensureColumn(db, "merge_requests", "accepted_tree", "ALTER TABLE merge_requests ADD COLUMN accepted_tree TEXT");
1212
+ ensureColumn(db, "merge_requests", "replacement_of_merge_request_id", "ALTER TABLE merge_requests ADD COLUMN replacement_of_merge_request_id TEXT");
1213
+ // A failed integration gate may create a new immutable source candidate in
1214
+ // the same RUN. The historical unique index made that repair path impossible.
1215
+ // Rebuilding an already-current index changes schema/data_version on every
1216
+ // CLI observation and prevents a controller's writer barrier from settling.
1217
+ const mergeRunIndex = db.prepare("PRAGMA index_list(merge_requests)").all().find(index => index.name === "idx_merge_requests_run");
1218
+ if (mergeRunIndex?.unique)
1219
+ db.exec("DROP INDEX idx_merge_requests_run");
1220
+ db.exec("CREATE INDEX IF NOT EXISTS idx_merge_requests_run ON merge_requests(project_id, run_id, created_at)");
814
1221
  ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
815
1222
  ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
816
1223
  ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
1224
+ ensureColumn(db, "work_sessions", "start_receipt_json", "ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
817
1225
  ensureColumn(db, "check_receipts", "declaration_id", "ALTER TABLE check_receipts ADD COLUMN declaration_id TEXT NOT NULL DEFAULT 'CHK-LEGACY'");
818
1226
  ensureColumn(db, "check_receipts", "workspace_fingerprint", "ALTER TABLE check_receipts ADD COLUMN workspace_fingerprint TEXT NOT NULL DEFAULT ''");
819
1227
  ensureColumn(db, "check_receipts", "artifacts_json", "ALTER TABLE check_receipts ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'");
@@ -821,6 +1229,9 @@ function migrate(db, dbPath) {
821
1229
  ensureColumn(db, "check_receipts", "gate", "ALTER TABLE check_receipts ADD COLUMN gate TEXT NOT NULL DEFAULT 'work'");
822
1230
  ensureColumn(db, "check_receipts", "input_hash", "ALTER TABLE check_receipts ADD COLUMN input_hash TEXT NOT NULL DEFAULT ''");
823
1231
  ensureColumn(db, "check_receipts", "verification_epoch", "ALTER TABLE check_receipts ADD COLUMN verification_epoch TEXT NOT NULL DEFAULT ''");
1232
+ ensureColumn(db, "check_receipts", "reservation_sequence", "ALTER TABLE check_receipts ADD COLUMN reservation_sequence INTEGER NOT NULL DEFAULT 0");
1233
+ ensureColumn(db, "check_receipts", "retry_of_receipt_id", "ALTER TABLE check_receipts ADD COLUMN retry_of_receipt_id TEXT");
1234
+ ensureColumn(db, "check_receipts", "retry_reason", "ALTER TABLE check_receipts ADD COLUMN retry_reason TEXT");
824
1235
  ensureColumn(db, "check_receipts", "before_fingerprint", "ALTER TABLE check_receipts ADD COLUMN before_fingerprint TEXT NOT NULL DEFAULT ''");
825
1236
  ensureColumn(db, "check_receipts", "after_fingerprint", "ALTER TABLE check_receipts ADD COLUMN after_fingerprint TEXT NOT NULL DEFAULT ''");
826
1237
  ensureColumn(db, "check_receipts", "profile_hash", "ALTER TABLE check_receipts ADD COLUMN profile_hash TEXT");
@@ -840,6 +1251,7 @@ function migrate(db, dbPath) {
840
1251
  ensureColumn(db, "hook_events", "parent_session_id", "ALTER TABLE hook_events ADD COLUMN parent_session_id TEXT");
841
1252
  ensureColumn(db, "hook_events", "daemon_id", "ALTER TABLE hook_events ADD COLUMN daemon_id TEXT");
842
1253
  ensureColumn(db, "hook_events", "cwd", "ALTER TABLE hook_events ADD COLUMN cwd TEXT");
1254
+ ensureColumn(db, "hook_events", "outcome_json", "ALTER TABLE hook_events ADD COLUMN outcome_json TEXT");
843
1255
  ensureColumn(db, "harness_usage_snapshots", "tool_calls", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_calls INTEGER");
844
1256
  ensureColumn(db, "harness_usage_snapshots", "tool_failures", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_failures INTEGER");
845
1257
  ensureColumn(db, "harness_usage_snapshots", "tool_by_name_json", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_by_name_json TEXT");
@@ -15,6 +15,8 @@ export function resolveProjectRoot(root) {
15
15
  if (!fs.existsSync(absolute)) {
16
16
  throw new AppError("not_found", `Project root does not exist: ${absolute}`, 1);
17
17
  }
18
+ if (!fs.statSync(absolute).isDirectory())
19
+ throw new AppError("validation", "Project root must be a directory", 2, { project_root: absolute });
18
20
  return fs.realpathSync(absolute);
19
21
  }
20
22
  /** Resolve an existing path to its filesystem identity, or normalize a future path. */
@@ -24,10 +26,16 @@ export function canonicalPath(value, mustExist = true) {
24
26
  return fs.realpathSync(absolute);
25
27
  if (mustExist)
26
28
  throw new AppError("not_found", `Path does not exist: ${absolute}`, 1, { path: absolute });
27
- const parent = path.dirname(absolute);
28
- if (!fs.existsSync(parent))
29
- return absolute;
30
- return path.join(fs.realpathSync(parent), path.basename(absolute));
29
+ const missing = [];
30
+ let parent = absolute;
31
+ while (!fs.existsSync(parent)) {
32
+ missing.unshift(path.basename(parent));
33
+ const next = path.dirname(parent);
34
+ if (next === parent)
35
+ return absolute;
36
+ parent = next;
37
+ }
38
+ return path.join(fs.realpathSync(parent), ...missing);
31
39
  }
32
40
  export function assertPathWithin(root, candidate, label = "path") {
33
41
  const canonicalRoot = canonicalPath(root);
@@ -100,6 +108,11 @@ export function stateJsonPath(projectRoot, protocolId) {
100
108
  export function planJsonPath(projectRoot, protocolId) {
101
109
  return path.join(protocolDir(projectRoot, protocolId), "plan.json");
102
110
  }
111
+ /** Project canonical plan takes precedence over a retained workspace location. */
112
+ export function resolveProtocolPlanPath(projectRoot, protocolId, recordedPath) {
113
+ const canonical = planJsonPath(projectRoot, protocolId);
114
+ return fs.existsSync(canonical) ? canonical : recordedPath;
115
+ }
103
116
  export function projectHome(ddFlowHome, projectId) {
104
117
  return path.join(ddFlowHome, "projects", projectId);
105
118
  }