@deksden-com/dd-flow-cli 0.9.0-beta.103 → 0.9.0-beta.105

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 (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/run-cli.js +32 -11
  4. package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
  5. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
  6. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
  7. package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -0
  8. package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -1
  9. package/dist/harness-runtime/lib/managed-daemon.mjs +11 -6
  10. package/dist/protocol/local-files.js +18 -9
  11. package/dist/runtime/context.js +2 -2
  12. package/dist/services/cleanup.js +81 -32
  13. package/dist/services/code-checks.js +18 -19
  14. package/dist/services/continuation-outcome.js +1 -1
  15. package/dist/services/controller-fanout.js +3 -3
  16. package/dist/services/eval-snapshots.js +69 -41
  17. package/dist/services/execution-policy.js +12 -1
  18. package/dist/services/hooks.js +150 -42
  19. package/dist/services/lanes.js +23 -18
  20. package/dist/services/lifecycle-command.js +11 -2
  21. package/dist/services/lifecycle-invocations.js +291 -54
  22. package/dist/services/managed-processes.js +35 -23
  23. package/dist/services/merge-queue.js +71 -67
  24. package/dist/services/merge-server.js +3 -11
  25. package/dist/services/migrations.js +28 -12
  26. package/dist/services/plan-runtime.js +8 -4
  27. package/dist/services/projects.js +3 -11
  28. package/dist/services/protocols.js +98 -68
  29. package/dist/services/recovery-snapshot-database.js +43 -8
  30. package/dist/services/run-control-worker.js +15 -32
  31. package/dist/services/run-control.js +3 -17
  32. package/dist/services/run-controller-capture.js +58 -27
  33. package/dist/services/run-controller-process.js +21 -30
  34. package/dist/services/run-controller-recovery.js +36 -37
  35. package/dist/services/run-controller.js +81 -86
  36. package/dist/services/run-fork.js +10 -6
  37. package/dist/services/run-projection.js +19 -64
  38. package/dist/services/run-recovery-runtime.js +24 -14
  39. package/dist/services/run-recovery.js +56 -54
  40. package/dist/services/runs.js +218 -160
  41. package/dist/services/runtime-budget.js +19 -45
  42. package/dist/services/runtime-scope-capture.js +8 -6
  43. package/dist/services/runtime-scope-control.js +49 -39
  44. package/dist/services/runtime-scope-resume.js +134 -122
  45. package/dist/services/runtime-scope-worker.js +48 -49
  46. package/dist/services/scope-writer-cleanup.js +26 -0
  47. package/dist/services/stage-context.js +6 -1
  48. package/dist/services/stage-lifecycle.js +1 -1
  49. package/dist/services/stage-pause.js +13 -29
  50. package/dist/services/stage-report-renderer.js +116 -6
  51. package/dist/services/usage.js +2 -9
  52. package/dist/services/vnext-code-review.js +10 -7
  53. package/dist/services/vnext-code.js +9 -17
  54. package/dist/services/vnext-merge.js +64 -21
  55. package/dist/services/vnext-plan-review.js +43 -30
  56. package/dist/services/vnext-plan.js +28 -24
  57. package/dist/services/vnext-protocolize.js +183 -20
  58. package/dist/services/vnext-specify.js +27 -19
  59. package/dist/services/work-registry.js +229 -150
  60. package/dist/storage/database.js +286 -56
  61. package/dist/storage/foreign-key-transaction.js +39 -0
  62. package/dist/storage/immutable-input.js +45 -0
  63. package/dist/storage/import-guard.js +102 -0
  64. package/dist/storage/native-cleanup.js +52 -0
  65. package/dist/storage/projection-lock.js +73 -0
  66. package/dist/storage/writer-contract.js +3 -3
  67. package/dist/storage/writer-migration.js +24 -41
  68. package/package.json +1 -1
  69. package/tools/repair-paused-run-status.mjs +48 -23
@@ -1,10 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
+ import { channel } from "node:diagnostics_channel";
4
5
  import { createRequire } from "node:module";
5
6
  import { ensureDir } from "./paths.js";
6
7
  import { AppError } from "../shared/errors.js";
7
8
  import { executeStoreSchema, readStoreWriterContract, registerStoreWriter } from "./writer-contract.js";
9
+ import { assertImportAdmission } from "./import-guard.js";
10
+ import { withStoreCleanup, rollbackStoreTransaction } from "./native-cleanup.js";
8
11
  const workContinuationStorageSql = `
9
12
  CREATE TABLE IF NOT EXISTS stage_work_memberships (
10
13
  project_id TEXT NOT NULL, run_id TEXT NOT NULL, work_id TEXT NOT NULL REFERENCES works(work_id),
@@ -17,9 +20,73 @@ const workContinuationStorageSql = `
17
20
  payload_json TEXT NOT NULL, work_id TEXT NOT NULL REFERENCES works(work_id), created_at TEXT NOT NULL
18
21
  );
19
22
  `;
23
+ function closeAfterOpenFailure(db, error) {
24
+ return withStoreCleanup(() => { throw error; }, () => { if (db.isOpen)
25
+ db.close(); });
26
+ }
20
27
  const require = createRequire(import.meta.url);
21
28
  const { DatabaseSync } = require("node:sqlite");
22
29
  const resourceDatabases = new Map();
30
+ const publicationScopes = [];
31
+ /** Defer committed cross-store projections until the surrounding writer exits. */
32
+ export function withDeferredPublications(operation) {
33
+ const pending = [];
34
+ publicationScopes.push(pending);
35
+ let value;
36
+ let primary;
37
+ let failed = false;
38
+ try {
39
+ value = operation();
40
+ if (value && typeof value.then === "function")
41
+ throw new AppError("write_transaction_async", "Deferred publication scope must be synchronous", 1);
42
+ }
43
+ catch (error) {
44
+ primary = error;
45
+ failed = true;
46
+ }
47
+ publicationScopes.pop();
48
+ const parent = publicationScopes.at(-1);
49
+ if (parent)
50
+ parent.push(...pending);
51
+ else {
52
+ try {
53
+ executePublications(pending);
54
+ }
55
+ catch (publicationError) {
56
+ if (failed && primary instanceof AppError)
57
+ throw new AppError(primary.code, primary.message, primary.exitCode, { ...primary.details, effect: "partial", committed_publications: pending.length, publication_pending: true, publication_error: errorDetails(publicationError) });
58
+ if (failed)
59
+ throw new AppError("cross_store_failed", "Cross-store mutation failed and committed projections need reconciliation", 1, { effect: "partial", cause: errorDetails(primary), committed_publications: pending.length, publication_pending: true, publication_error: errorDetails(publicationError) });
60
+ throw publicationError;
61
+ }
62
+ }
63
+ if (failed && pending.length && primary instanceof AppError)
64
+ throw new AppError(primary.code, primary.message, primary.exitCode, { ...primary.details, effect: "partial", committed_publications: pending.length });
65
+ if (failed && pending.length)
66
+ throw new AppError("cross_store_failed", "Cross-store mutation failed after a committed inner write", 1, { effect: "partial", cause: errorDetails(primary), committed_publications: pending.length });
67
+ if (failed)
68
+ throw primary;
69
+ return value;
70
+ }
71
+ function executePublications(pending) {
72
+ let firstError;
73
+ let failed = false;
74
+ for (const operation of pending) {
75
+ try {
76
+ operation();
77
+ }
78
+ catch (error) {
79
+ if (!failed)
80
+ firstError = error;
81
+ failed = true;
82
+ }
83
+ }
84
+ if (failed) {
85
+ if (firstError instanceof AppError && firstError.details.effect === "committed")
86
+ throw firstError;
87
+ throw new AppError("publication_pending", "SQL committed but a required projection needs reconciliation", 1, { effect: "committed", publication_pending: true, cause: errorDetails(firstError) });
88
+ }
89
+ }
23
90
  /** Invocation authority is prepared once with the runtime schema. Native
24
91
  * hooks must only verify this table; they never create it on the hot path. */
25
92
  export function ensureLifecycleInvocationStorage(db) {
@@ -32,6 +99,11 @@ export function ensureLifecycleInvocationStorage(db) {
32
99
  );
33
100
  CREATE INDEX IF NOT EXISTS idx_lifecycle_invocations_scope
34
101
  ON lifecycle_invocations(scope_json, fingerprint, created_at);
102
+ CREATE TABLE IF NOT EXISTS lifecycle_packet_assignments (
103
+ packet_sha256 TEXT NOT NULL,
104
+ invocation_id TEXT NOT NULL REFERENCES lifecycle_invocations(id),
105
+ PRIMARY KEY(packet_sha256, invocation_id)
106
+ );
35
107
  `);
36
108
  }
37
109
  /** RUN authority stays in its existing store; process leases stay in runtime.sqlite. */
@@ -174,6 +246,8 @@ export function ensureVnextWorkStorage(db) {
174
246
  prompt_path TEXT NOT NULL,
175
247
  result_path TEXT,
176
248
  start_receipt_json TEXT,
249
+ start_publication_status TEXT,
250
+ result_receipt_json TEXT,
177
251
  created_at TEXT NOT NULL,
178
252
  updated_at TEXT NOT NULL,
179
253
  completed_at TEXT,
@@ -230,6 +304,10 @@ export function ensureVnextWorkStorage(db) {
230
304
  const columns = db.all("PRAGMA table_info(work_sessions)");
231
305
  if (!columns.some((column) => column.name === "start_receipt_json"))
232
306
  db.execSchema("ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
307
+ if (!columns.some((column) => column.name === "start_publication_status"))
308
+ db.execSchema("ALTER TABLE work_sessions ADD COLUMN start_publication_status TEXT");
309
+ if (!columns.some((column) => column.name === "result_receipt_json"))
310
+ db.execSchema("ALTER TABLE work_sessions ADD COLUMN result_receipt_json TEXT");
233
311
  });
234
312
  }
235
313
  /** Durable intent for a provider-root external Work. It is deliberately
@@ -253,7 +331,9 @@ export function ensureWorkLaunchStorage(db) {
253
331
  CREATE INDEX IF NOT EXISTS idx_work_launches_status ON work_launches(status, updated_at DESC);
254
332
  `);
255
333
  }
256
- export function getDatabase(ddFlowHome, mode = "initialize") {
334
+ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId) {
335
+ if (mode !== "read_existing")
336
+ assertImportAdmission(ddFlowHome, importPermitId);
257
337
  const dbPath = path.join(ddFlowHome, "db.sqlite");
258
338
  const exists = fs.existsSync(dbPath);
259
339
  if (mode === "read_existing" && !exists) {
@@ -270,16 +350,14 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
270
350
  registerStoreWriter(db);
271
351
  }
272
352
  catch (error) {
273
- db.close();
274
- throw error;
353
+ return closeAfterOpenFailure(db, error);
275
354
  }
276
355
  }
277
356
  try {
278
357
  configureDatabase(db, mode);
279
358
  }
280
359
  catch (error) {
281
- db.close();
282
- throw error;
360
+ return closeAfterOpenFailure(db, error);
283
361
  }
284
362
  // Internal additive schema changes must be available to every write command.
285
363
  // Higher-level Memory Bank migrations remain explicit in services/migrations.
@@ -300,8 +378,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
300
378
  }
301
379
  }
302
380
  catch (error) {
303
- db.close();
304
- throw error;
381
+ return closeAfterOpenFailure(db, error);
305
382
  }
306
383
  }
307
384
  if (mode === "read_existing")
@@ -316,7 +393,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
316
393
  * preparation error instead of an implicit migration race.
317
394
  */
318
395
  function assertHookStoreReady(db) {
319
- const required = ["projects", "hook_events", "codex_session_bindings", "sessions", "flow_session_segments", "lifecycle_invocations"];
396
+ const required = ["projects", "hook_events", "codex_session_bindings", "sessions", "flow_session_segments", "lifecycle_invocations", "lifecycle_packet_assignments"];
320
397
  const missing = required.filter((table) => !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table));
321
398
  if (missing.length)
322
399
  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." });
@@ -346,7 +423,7 @@ export function withResourceReadSnapshot(resourceHome, read) {
346
423
  if (!cached && !fs.existsSync(dbPath))
347
424
  return read(emptyReadOnlyDatabase(dbPath));
348
425
  const db = cached ?? new DatabaseSync(dbPath, { readOnly: true });
349
- try {
426
+ const operation = () => {
350
427
  if (!cached)
351
428
  db.exec("PRAGMA busy_timeout = 4000; PRAGMA query_only = ON");
352
429
  // A second process can observe SQLite's file before the initializing
@@ -359,31 +436,22 @@ export function withResourceReadSnapshot(resourceHome, read) {
359
436
  const reader = cached
360
437
  ? { ...emptyReadOnlyDatabase(dbPath), get: cached.get, all: cached.all }
361
438
  : databaseFacade(dbPath, false, db);
362
- // SAVEPOINT also composes with an existing owner's transaction. The
363
- // callback sees its uncommitted state but never receives write capability.
364
- db.exec("SAVEPOINT resource_read_snapshot");
365
- try {
366
- const result = read(reader);
367
- db.exec("RELEASE resource_read_snapshot");
368
- return result;
369
- }
370
- catch (error) {
371
- db.exec("ROLLBACK TO resource_read_snapshot");
372
- db.exec("RELEASE resource_read_snapshot");
373
- throw error;
374
- }
375
- }
376
- finally {
377
- if (!cached)
378
- db.close?.();
379
- }
439
+ // The facade owns savepoint cleanup and poisons a connection when rollback
440
+ // fails; the callback only receives the read-only view.
441
+ return (cached ?? reader).readSnapshot(() => read(reader));
442
+ };
443
+ if (cached)
444
+ return operation();
445
+ const native = db;
446
+ return withStoreCleanup(operation, () => { if (native.isOpen)
447
+ native.close(); });
380
448
  }
381
449
  export function getResourceDatabase(resourceHome, mode = "initialize") {
382
450
  const dbPath = path.join(resourceHome, "runtime.sqlite");
383
451
  const cached = resourceDatabases.get(dbPath);
384
- if (mode === "read_existing") {
452
+ if (mode === "read_existing" || mode === "read_isolated") {
385
453
  // Reuse a current transaction's view without exposing its write capability.
386
- if (cached)
454
+ if (cached && mode === "read_existing")
387
455
  return { ...emptyReadOnlyDatabase(dbPath), get: cached.get, all: cached.all };
388
456
  if (!fs.existsSync(dbPath))
389
457
  return emptyReadOnlyDatabase(dbPath);
@@ -396,8 +464,7 @@ export function getResourceDatabase(resourceHome, mode = "initialize") {
396
464
  }
397
465
  }
398
466
  catch (error) {
399
- db.close();
400
- throw error;
467
+ return closeAfterOpenFailure(db, error);
401
468
  }
402
469
  return databaseFacade(dbPath, false, db);
403
470
  }
@@ -410,15 +477,13 @@ export function getResourceDatabase(resourceHome, mode = "initialize") {
410
477
  registerStoreWriter(db);
411
478
  }
412
479
  catch (error) {
413
- db.close();
414
- throw error;
480
+ return closeAfterOpenFailure(db, error);
415
481
  }
416
482
  try {
417
483
  configureDatabase(db, "initialize");
418
484
  }
419
485
  catch (error) {
420
- db.close();
421
- throw error;
486
+ return closeAfterOpenFailure(db, error);
422
487
  }
423
488
  db.exec("PRAGMA foreign_keys = ON");
424
489
  const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
@@ -465,8 +530,7 @@ export function getResourceDatabase(resourceHome, mode = "initialize") {
465
530
  `, fresh);
466
531
  }
467
532
  catch (error) {
468
- db.close();
469
- throw error;
533
+ return closeAfterOpenFailure(db, error);
470
534
  }
471
535
  const database = databaseFacade(dbPath, true, db, () => {
472
536
  resourceDatabases.delete(dbPath);
@@ -483,6 +547,8 @@ function emptyReadOnlyDatabase(dbPath) {
483
547
  execSchema: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
484
548
  beginWriteTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
485
549
  writeTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
550
+ readSnapshot: (operation) => operation(),
551
+ afterCommit: () => { throw new AppError("read_only_storage", "Read-only access cannot publish absent dd-flow storage", 1, { path: dbPath }); },
486
552
  run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
487
553
  get: () => undefined,
488
554
  all: () => []
@@ -491,11 +557,31 @@ function emptyReadOnlyDatabase(dbPath) {
491
557
  function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
492
558
  let reserved = false;
493
559
  let poisoned = false;
560
+ let closed = false;
561
+ const closeOnce = () => { if (closed || !db.isOpen) {
562
+ closed = true;
563
+ return;
564
+ } closed = true; close(); };
494
565
  let savepoint = 0;
495
- const assertUsable = () => { if (poisoned)
496
- throw new AppError("write_transaction_unresolved", "Transaction cleanup failed; reopen the connection before continuing", 1); };
566
+ let afterCommit = [];
567
+ let manualFrames = [];
568
+ let readDepth = 0;
569
+ const writerTiming = channel("dd-flow.sqlite.writer");
570
+ const publishCommitted = () => {
571
+ const pending = afterCommit;
572
+ afterCommit = [];
573
+ const scope = publicationScopes.at(-1);
574
+ if (scope)
575
+ scope.push(...pending);
576
+ else
577
+ executePublications(pending);
578
+ };
579
+ const assertUsable = () => { if (poisoned || closed || !db.isOpen)
580
+ throw new AppError("write_transaction_unresolved", "Transaction cleanup failed or the connection is closed; reopen the connection before continuing", 1); };
497
581
  const beginWriteTransaction = () => {
498
582
  assertUsable();
583
+ if (readDepth)
584
+ throw new AppError("read_snapshot_write", "Cannot reserve a writer inside a read snapshot", 1);
499
585
  if (!writable)
500
586
  throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
501
587
  db.exec("BEGIN IMMEDIATE");
@@ -507,20 +593,32 @@ function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
507
593
  }
508
594
  catch (cleanup) {
509
595
  poisoned = true;
596
+ let closeError;
597
+ try {
598
+ closeOnce();
599
+ }
600
+ catch (error) {
601
+ closeError = errorDetails(error);
602
+ }
603
+ const secondary = { transaction_cleanup_error: errorDetails(cleanup), ...(closeError ? { transaction_close_error: closeError } : {}) };
510
604
  if (original instanceof AppError)
511
- throw new AppError(original.code, original.message, original.exitCode, { ...original.details, transaction_cleanup_error: errorDetails(cleanup) });
512
- throw new AppError("write_transaction_failed", "Write transaction failed and SQLite cleanup also failed", 1, { cause: errorDetails(original), transaction_cleanup_error: errorDetails(cleanup) });
605
+ throw new AppError(original.code, original.message, original.exitCode, { ...original.details, ...secondary });
606
+ throw new AppError("write_transaction_failed", "Write transaction failed and SQLite cleanup also failed", 1, { cause: errorDetails(original), ...secondary });
513
607
  }
514
608
  throw original;
515
609
  };
516
- const writeTransaction = (operation) => {
610
+ const writeTransaction = (operation, operationName = "writeTransaction") => {
517
611
  assertUsable();
612
+ if (readDepth)
613
+ throw new AppError("read_snapshot_write", "Cannot reserve a writer inside a read snapshot", 1);
518
614
  if (!writable)
519
615
  throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
520
616
  if (db.isTransaction && !reserved)
521
617
  throw new AppError("write_transaction_unowned", "Cannot write inside an unowned transaction or read snapshot", 1);
522
618
  const nested = db.isTransaction;
619
+ const callbackLength = afterCommit.length;
523
620
  const marker = `dd_flow_write_${++savepoint}`;
621
+ const startedAt = performance.now();
524
622
  try {
525
623
  if (nested)
526
624
  db.exec(`SAVEPOINT ${marker}`);
@@ -542,9 +640,17 @@ function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
542
640
  db.exec(`RELEASE ${marker}`);
543
641
  else
544
642
  db.exec("COMMIT");
643
+ phase = "committed";
644
+ if (!nested && writerTiming.hasSubscribers)
645
+ writerTiming.publish({ operation: operationName, wall_time_ms: performance.now() - startedAt, path: dbPath });
646
+ if (!nested)
647
+ publishCommitted();
545
648
  return value;
546
649
  }
547
650
  catch (error) {
651
+ if (phase === "committed")
652
+ throw error;
653
+ afterCommit.length = callbackLength;
548
654
  try {
549
655
  return rollback(nested ? `ROLLBACK TO ${marker}; RELEASE ${marker}` : "ROLLBACK", error);
550
656
  }
@@ -555,28 +661,134 @@ function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
555
661
  }
556
662
  }
557
663
  finally {
558
- if (!db.isTransaction)
664
+ if (closed || !db.isOpen || !db.isTransaction) {
559
665
  reserved = false;
666
+ if (phase !== "committed") {
667
+ afterCommit = [];
668
+ manualFrames = [];
669
+ }
670
+ }
671
+ }
672
+ };
673
+ const readSnapshot = (operation) => {
674
+ assertUsable();
675
+ const nested = db.isTransaction;
676
+ const marker = `dd_flow_read_${++savepoint}`;
677
+ const callbackLength = afterCommit.length;
678
+ try {
679
+ db.exec(nested ? `SAVEPOINT ${marker}` : "BEGIN");
680
+ }
681
+ catch (error) {
682
+ throw new AppError("read_snapshot_failed", "Cannot start SQLite read snapshot", 1, { cause: errorDetails(error) });
683
+ }
684
+ readDepth += 1;
685
+ let phase = "body";
686
+ try {
687
+ const value = operation();
688
+ if (value && typeof value.then === "function") {
689
+ void Promise.resolve(value).catch(() => { });
690
+ throw new AppError("read_snapshot_async", "Read snapshot callback must be synchronous", 1);
691
+ }
692
+ phase = "commit";
693
+ db.exec(nested ? `RELEASE ${marker}` : "COMMIT");
694
+ phase = "committed";
695
+ return value;
696
+ }
697
+ catch (error) {
698
+ if (phase === "committed")
699
+ throw error;
700
+ afterCommit.length = callbackLength;
701
+ return rollback(nested ? `ROLLBACK TO ${marker}; RELEASE ${marker}` : "ROLLBACK", error);
702
+ }
703
+ finally {
704
+ readDepth -= 1;
705
+ }
706
+ };
707
+ const applyManualBoundary = (sql) => {
708
+ for (const statement of sql.split(";")) {
709
+ const save = /^\s*SAVEPOINT\s+([\w-]+)/i.exec(statement);
710
+ if (save) {
711
+ manualFrames.push({ name: save[1], length: afterCommit.length });
712
+ continue;
713
+ }
714
+ const rollbackTo = /^\s*ROLLBACK\s+TO(?:\s+SAVEPOINT)?\s+([\w-]+)/i.exec(statement);
715
+ if (rollbackTo) {
716
+ const frame = manualFrames.findLastIndex((item) => item.name === rollbackTo[1]);
717
+ if (frame >= 0) {
718
+ afterCommit.length = manualFrames[frame].length;
719
+ manualFrames.length = frame + 1;
720
+ }
721
+ continue;
722
+ }
723
+ const release = /^\s*RELEASE(?:\s+SAVEPOINT)?\s+([\w-]+)/i.exec(statement);
724
+ if (release) {
725
+ const frame = manualFrames.findLastIndex((item) => item.name === release[1]);
726
+ if (frame >= 0)
727
+ manualFrames.length = frame;
728
+ continue;
729
+ }
730
+ if (/^\s*ROLLBACK\b/i.test(statement)) {
731
+ afterCommit = [];
732
+ manualFrames = [];
733
+ continue;
734
+ }
735
+ if (/^\s*COMMIT\b/i.test(statement)) {
736
+ manualFrames = [];
737
+ publishCommitted();
738
+ }
560
739
  }
561
740
  };
562
741
  return {
563
742
  path: dbPath,
564
743
  writable,
565
- close,
566
- exec: (sql) => { assertUsable(); try {
567
- db.exec(sql);
568
- }
569
- finally {
570
- if (!db.isTransaction)
571
- reserved = false;
572
- } },
744
+ close: closeOnce,
745
+ exec: (sql) => {
746
+ assertUsable();
747
+ if (readDepth)
748
+ throw new AppError("read_snapshot_write", "Cannot execute SQL inside a read snapshot", 1);
749
+ try {
750
+ db.exec(sql);
751
+ applyManualBoundary(sql);
752
+ }
753
+ catch (error) {
754
+ if (/^\s*ROLLBACK\b/i.test(sql)) {
755
+ poisoned = true;
756
+ try {
757
+ closeOnce();
758
+ }
759
+ catch { /* Keep the primary SQLite failure. */ }
760
+ }
761
+ throw error;
762
+ }
763
+ finally {
764
+ if (closed || !db.isOpen || !db.isTransaction) {
765
+ reserved = false;
766
+ if (!/^\s*COMMIT\b/i.test(sql)) {
767
+ afterCommit = [];
768
+ manualFrames = [];
769
+ }
770
+ }
771
+ }
772
+ },
573
773
  execSchema: (sql) => { assertUsable(); if (db.isTransaction && !reserved)
574
774
  throw new AppError("write_transaction_unowned", "Cannot change schema inside an unowned transaction", 1); executeStoreSchema(db, sql); },
575
775
  beginWriteTransaction,
576
776
  writeTransaction,
577
- run: (sql, params = []) => { assertUsable(); return db.prepare(sql).run(...params); },
578
- get: (sql, params = []) => db.prepare(sql).get(...params),
579
- all: (sql, params = []) => db.prepare(sql).all(...params)
777
+ readSnapshot,
778
+ afterCommit: (operation) => { assertUsable(); if (readDepth)
779
+ throw new AppError("read_snapshot_write", "Cannot queue publication inside a read snapshot", 1); if (db.isTransaction)
780
+ afterCommit.push(operation);
781
+ else {
782
+ const scope = publicationScopes.at(-1);
783
+ if (scope)
784
+ scope.push(operation);
785
+ else
786
+ executePublications([operation]);
787
+ } },
788
+ run: (sql, params = []) => { assertUsable(); if (readDepth)
789
+ throw new AppError("read_snapshot_write", "Cannot write inside a read snapshot", 1); return db.prepare(sql).run(...params); },
790
+ get: (sql, params = []) => { assertUsable(); return db.prepare(sql).get(...params); },
791
+ all: (sql, params = []) => { assertUsable(); return db.prepare(sql).all(...params); }
580
792
  };
581
793
  }
582
794
  function errorDetails(error) {
@@ -640,6 +852,7 @@ function migrate(db) {
640
852
  CREATE TABLE IF NOT EXISTS plan_bindings (
641
853
  project_id TEXT NOT NULL,
642
854
  protocol_id TEXT NOT NULL,
855
+ plan_id TEXT,
643
856
  plan_revision INTEGER NOT NULL,
644
857
  plan_sha256 TEXT NOT NULL,
645
858
  plan_path TEXT NOT NULL,
@@ -1221,6 +1434,22 @@ function migrate(db) {
1221
1434
  ensureColumn(db, "usage", "source_mtime_ms", "ALTER TABLE usage ADD COLUMN source_mtime_ms INTEGER");
1222
1435
  db.prepare("UPDATE usage SET cache_read_input_tokens = cached_input_tokens WHERE cache_read_input_tokens IS NULL AND cached_input_tokens IS NOT NULL").run();
1223
1436
  ensureColumn(db, "runs", "run_home_path", "ALTER TABLE runs ADD COLUMN run_home_path TEXT");
1437
+ ensureColumn(db, "plan_bindings", "plan_id", "ALTER TABLE plan_bindings ADD COLUMN plan_id TEXT");
1438
+ // Historical RUN projections already retain the canonical identifier. Use
1439
+ // those SQL bytes to backfill without opening plan files under the writer.
1440
+ for (const row of db.prepare("SELECT project_id, protocol_id FROM plan_bindings WHERE plan_id IS NULL").all()) {
1441
+ const runs = db.prepare("SELECT index_json FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ? ORDER BY updated_at DESC").all(row.project_id, row.protocol_id);
1442
+ for (const run of runs) {
1443
+ try {
1444
+ const planId = JSON.parse(run.index_json).plan_ref?.plan_id;
1445
+ if (typeof planId !== "string" || !planId || planId === "unknown-plan")
1446
+ continue;
1447
+ db.prepare("UPDATE plan_bindings SET plan_id = ? WHERE project_id = ? AND protocol_id = ? AND plan_id IS NULL").run(planId, row.project_id, row.protocol_id);
1448
+ break;
1449
+ }
1450
+ catch { /* A malformed historical RUN cannot establish plan identity. */ }
1451
+ }
1452
+ }
1224
1453
  ensureColumn(db, "runs", "run_root", "ALTER TABLE runs ADD COLUMN run_root TEXT");
1225
1454
  db.prepare("UPDATE runs SET run_root = COALESCE(run_root, run_home_path, run_dir) WHERE run_root IS NULL OR run_root = ''").run();
1226
1455
  ensureColumn(db, "merge_requests", "accepted_tree", "ALTER TABLE merge_requests ADD COLUMN accepted_tree TEXT");
@@ -1237,6 +1466,8 @@ function migrate(db) {
1237
1466
  ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
1238
1467
  ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
1239
1468
  ensureColumn(db, "work_sessions", "start_receipt_json", "ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
1469
+ ensureColumn(db, "work_sessions", "start_publication_status", "ALTER TABLE work_sessions ADD COLUMN start_publication_status TEXT");
1470
+ ensureColumn(db, "work_sessions", "result_receipt_json", "ALTER TABLE work_sessions ADD COLUMN result_receipt_json TEXT");
1240
1471
  ensureColumn(db, "check_receipts", "declaration_id", "ALTER TABLE check_receipts ADD COLUMN declaration_id TEXT NOT NULL DEFAULT 'CHK-LEGACY'");
1241
1472
  ensureColumn(db, "check_receipts", "workspace_fingerprint", "ALTER TABLE check_receipts ADD COLUMN workspace_fingerprint TEXT NOT NULL DEFAULT ''");
1242
1473
  ensureColumn(db, "check_receipts", "artifacts_json", "ALTER TABLE check_receipts ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'");
@@ -1329,8 +1560,7 @@ function migrateSessionStorageKeys(db, dbPath) {
1329
1560
  db.exec("COMMIT");
1330
1561
  }
1331
1562
  catch (error) {
1332
- db.exec("ROLLBACK");
1333
- throw error;
1563
+ return rollbackStoreTransaction(db, "ROLLBACK", error);
1334
1564
  }
1335
1565
  }
1336
1566
  function storageSessionKey(harness, native) {
@@ -0,0 +1,39 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ /** Relocation changes related keys together; restore connection policy even
3
+ * when BEGIN or rollback fails, and never reuse a connection with unknown FK mode. */
4
+ export function foreignKeyTransaction(db, operation) {
5
+ const foreignKeys = db.get("PRAGMA foreign_keys").foreign_keys;
6
+ let value, primary, failed = false, committed = false;
7
+ try {
8
+ db.exec("PRAGMA foreign_keys = OFF");
9
+ if (db.get("PRAGMA foreign_keys")?.foreign_keys !== 0)
10
+ throw new AppError("foreign_key_transaction_active", "Key relocation requires its own connection transaction", 1);
11
+ value = db.writeTransaction(() => { db.afterCommit(() => { committed = true; }); return operation(); });
12
+ }
13
+ catch (error) {
14
+ primary = error;
15
+ failed = true;
16
+ }
17
+ try {
18
+ db.exec(`PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"}`);
19
+ if (db.get("PRAGMA foreign_keys")?.foreign_keys !== foreignKeys)
20
+ throw new Error("Foreign-key enforcement was not restored");
21
+ }
22
+ catch (restore) {
23
+ const details = { foreign_keys_restore_error: String(restore), effect: committed ? "committed" : "unknown" };
24
+ try {
25
+ db.close?.();
26
+ }
27
+ catch (close) {
28
+ details.connection_close_error = String(close);
29
+ }
30
+ if (primary instanceof AppError)
31
+ Object.assign(primary.details, details);
32
+ else
33
+ primary = new AppError("foreign_key_restoration_failed", primary instanceof Error ? primary.message : "Cannot restore foreign-key enforcement", 1, { ...details, ...(failed ? { cause: String(primary) } : {}) });
34
+ failed = true;
35
+ }
36
+ if (failed)
37
+ throw primary;
38
+ return value;
39
+ }
@@ -0,0 +1,45 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ /** Durable immutable candidate; only a later SQL receipt grants it authority. */
6
+ export function publishImmutableInput(file, bytes, conflictCode) {
7
+ const expected = crypto.createHash("sha256").update(bytes).digest("hex");
8
+ const verify = () => {
9
+ const stat = fs.lstatSync(file);
10
+ if (!stat.isFile() || stat.isSymbolicLink() || crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== expected)
11
+ throw new AppError(conflictCode, "Immutable input differs from the accepted bytes", 1, { path: file, expected_sha256: expected });
12
+ };
13
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
14
+ try {
15
+ fs.writeFileSync(temporary, bytes, { flag: "wx", mode: 0o600 });
16
+ const descriptor = fs.openSync(temporary, "r");
17
+ try {
18
+ fs.fsyncSync(descriptor);
19
+ }
20
+ finally {
21
+ fs.closeSync(descriptor);
22
+ }
23
+ try {
24
+ fs.linkSync(temporary, file);
25
+ }
26
+ catch (error) {
27
+ if (error.code !== "EEXIST")
28
+ throw error;
29
+ verify();
30
+ }
31
+ const directory = fs.openSync(path.dirname(file), "r");
32
+ try {
33
+ fs.fsyncSync(directory);
34
+ }
35
+ finally {
36
+ fs.closeSync(directory);
37
+ }
38
+ }
39
+ finally {
40
+ try {
41
+ fs.rmSync(temporary, { force: true });
42
+ }
43
+ catch { /* Unreferenced staging garbage must not mask the accepted input or primary error. */ }
44
+ }
45
+ }