@open-code-review/cli 1.11.0 → 2.1.0

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 (57) hide show
  1. package/README.md +10 -4
  2. package/dist/dashboard/client/assets/{_basePickBy-D8RU9s_y.js → _basePickBy-B3ALyupE.js} +1 -1
  3. package/dist/dashboard/client/assets/{_baseUniq-CjVeYx1J.js → _baseUniq-b2RALAWc.js} +1 -1
  4. package/dist/dashboard/client/assets/{arc-DsFstmf9.js → arc-DcSVvhUd.js} +1 -1
  5. package/dist/dashboard/client/assets/{architectureDiagram-VXUJARFQ-iNJB-g1N.js → architectureDiagram-VXUJARFQ-BNUlmSCS.js} +1 -1
  6. package/dist/dashboard/client/assets/{blockDiagram-VD42YOAC-Zp2Aw0zR.js → blockDiagram-VD42YOAC-BmhiQVwa.js} +1 -1
  7. package/dist/dashboard/client/assets/{c4Diagram-YG6GDRKO-BGppUmwT.js → c4Diagram-YG6GDRKO-jyJ3WOv5.js} +1 -1
  8. package/dist/dashboard/client/assets/channel-D3J8-GF_.js +1 -0
  9. package/dist/dashboard/client/assets/{chunk-4BX2VUAB-CZcRxeE4.js → chunk-4BX2VUAB-x1dQU_s3.js} +1 -1
  10. package/dist/dashboard/client/assets/{chunk-55IACEB6-CVdL59yY.js → chunk-55IACEB6-CwbsE2XQ.js} +1 -1
  11. package/dist/dashboard/client/assets/{chunk-B4BG7PRW-CFPp6g6e.js → chunk-B4BG7PRW-BaE7c-ti.js} +1 -1
  12. package/dist/dashboard/client/assets/{chunk-DI55MBZ5-DH9BzE6I.js → chunk-DI55MBZ5-Bw5PUaMK.js} +1 -1
  13. package/dist/dashboard/client/assets/{chunk-FMBD7UC4-DZ2DTwqS.js → chunk-FMBD7UC4-B7cF6P3s.js} +1 -1
  14. package/dist/dashboard/client/assets/{chunk-QN33PNHL-DODPm0CR.js → chunk-QN33PNHL-OY4evNHd.js} +1 -1
  15. package/dist/dashboard/client/assets/{chunk-QZHKN3VN-CNI_LxUf.js → chunk-QZHKN3VN-BpjQwIWz.js} +1 -1
  16. package/dist/dashboard/client/assets/{chunk-TZMSLE5B-sxZQF02c.js → chunk-TZMSLE5B-D8b_Oq9B.js} +1 -1
  17. package/dist/dashboard/client/assets/classDiagram-2ON5EDUG-tkFUL-1Y.js +1 -0
  18. package/dist/dashboard/client/assets/classDiagram-v2-WZHVMYZB-tkFUL-1Y.js +1 -0
  19. package/dist/dashboard/client/assets/clone-CkY5ajLr.js +1 -0
  20. package/dist/dashboard/client/assets/{cose-bilkent-S5V4N54A-BHa2lABH.js → cose-bilkent-S5V4N54A-C-sfP8PN.js} +1 -1
  21. package/dist/dashboard/client/assets/{dagre-6UL2VRFP-CvCLBtkz.js → dagre-6UL2VRFP-Cqfo0NRg.js} +1 -1
  22. package/dist/dashboard/client/assets/{diagram-PSM6KHXK-Cklwd4YA.js → diagram-PSM6KHXK-BR3ppxqI.js} +1 -1
  23. package/dist/dashboard/client/assets/{diagram-QEK2KX5R-3bDERTbp.js → diagram-QEK2KX5R-Dvcx6x3R.js} +1 -1
  24. package/dist/dashboard/client/assets/{diagram-S2PKOQOG-DbiWlPc6.js → diagram-S2PKOQOG-DoyBLnVN.js} +1 -1
  25. package/dist/dashboard/client/assets/{erDiagram-Q2GNP2WA-BQa_VNbt.js → erDiagram-Q2GNP2WA-hy77l1cL.js} +1 -1
  26. package/dist/dashboard/client/assets/{flowDiagram-NV44I4VS-BDaJyl9N.js → flowDiagram-NV44I4VS-Bz0B1rKM.js} +1 -1
  27. package/dist/dashboard/client/assets/{ganttDiagram-JELNMOA3-DsTnleSr.js → ganttDiagram-JELNMOA3-CLgrZPoC.js} +1 -1
  28. package/dist/dashboard/client/assets/{gitGraphDiagram-V2S2FVAM-BRuBadgn.js → gitGraphDiagram-V2S2FVAM-DwJ-1f-v.js} +1 -1
  29. package/dist/dashboard/client/assets/{graph-CYYqXm9c.js → graph-DDBMM_t2.js} +1 -1
  30. package/dist/dashboard/client/assets/{index-eZMoytob.js → index-Cr9yEo_B.js} +123 -123
  31. package/dist/dashboard/client/assets/{infoDiagram-HS3SLOUP-CHnA8k7H.js → infoDiagram-HS3SLOUP-Bhn1FmAk.js} +1 -1
  32. package/dist/dashboard/client/assets/{journeyDiagram-XKPGCS4Q-CAXR1-Ju.js → journeyDiagram-XKPGCS4Q-CzGbjX1y.js} +1 -1
  33. package/dist/dashboard/client/assets/{kanban-definition-3W4ZIXB7-Clf3HfHz.js → kanban-definition-3W4ZIXB7-Da77-WYk.js} +1 -1
  34. package/dist/dashboard/client/assets/{layout-DQPaNqnO.js → layout-CVwSB-GS.js} +1 -1
  35. package/dist/dashboard/client/assets/{linear-qUnNXvWB.js → linear-CTRAc5Jn.js} +1 -1
  36. package/dist/dashboard/client/assets/{mermaid-renderer-C7Se8vjl.js → mermaid-renderer-Bjo170ax.js} +4 -4
  37. package/dist/dashboard/client/assets/{mindmap-definition-VGOIOE7T-DBIdG0OR.js → mindmap-definition-VGOIOE7T-B55C2odl.js} +1 -1
  38. package/dist/dashboard/client/assets/{pieDiagram-ADFJNKIX-DXAIiG6W.js → pieDiagram-ADFJNKIX-5lrQLrSz.js} +1 -1
  39. package/dist/dashboard/client/assets/{quadrantDiagram-AYHSOK5B-D4yAxif0.js → quadrantDiagram-AYHSOK5B-Bg55gC30.js} +1 -1
  40. package/dist/dashboard/client/assets/{requirementDiagram-UZGBJVZJ-D27ME1VO.js → requirementDiagram-UZGBJVZJ-CyR4YFJY.js} +1 -1
  41. package/dist/dashboard/client/assets/{sankeyDiagram-TZEHDZUN-BeEaA_QM.js → sankeyDiagram-TZEHDZUN-BVWKr9_-.js} +1 -1
  42. package/dist/dashboard/client/assets/{sequenceDiagram-WL72ISMW-GTI12qU0.js → sequenceDiagram-WL72ISMW-D0AJg_tE.js} +1 -1
  43. package/dist/dashboard/client/assets/{stateDiagram-FKZM4ZOC-ClSoeZM0.js → stateDiagram-FKZM4ZOC-BuHpTgim.js} +1 -1
  44. package/dist/dashboard/client/assets/stateDiagram-v2-4FDKWEC3-DwAPhteN.js +1 -0
  45. package/dist/dashboard/client/assets/{timeline-definition-IT6M3QCI-cj5d_Kyh.js → timeline-definition-IT6M3QCI-LDhpAmDd.js} +1 -1
  46. package/dist/dashboard/client/assets/{treemap-GDKQZRPO-BrRT1igb.js → treemap-GDKQZRPO-Dd4gjvUl.js} +1 -1
  47. package/dist/dashboard/client/assets/{xychartDiagram-PRI3JC2R-DlzGitHh.js → xychartDiagram-PRI3JC2R-B9RDod39.js} +1 -1
  48. package/dist/dashboard/client/index.html +1 -1
  49. package/dist/dashboard/server.js +1232 -656
  50. package/dist/index.js +1905 -711
  51. package/dist/lib/db/index.js +794 -100
  52. package/package.json +3 -5
  53. package/dist/dashboard/client/assets/channel-C8plpfdz.js +0 -1
  54. package/dist/dashboard/client/assets/classDiagram-2ON5EDUG-Dqn6u1oQ.js +0 -1
  55. package/dist/dashboard/client/assets/classDiagram-v2-WZHVMYZB-Dqn6u1oQ.js +0 -1
  56. package/dist/dashboard/client/assets/clone-BQ8hOLqM.js +0 -1
  57. package/dist/dashboard/client/assets/stateDiagram-v2-4FDKWEC3-Bim3s-dq.js +0 -1
@@ -1,9 +1,209 @@
1
1
  // src/lib/db/index.ts
2
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
3
- import { dirname as dirname2, join as join2 } from "node:path";
2
+ import {
3
+ existsSync as existsSync3,
4
+ mkdirSync as mkdirSync2,
5
+ copyFileSync,
6
+ statSync,
7
+ mkdtempSync,
8
+ rmSync
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { dirname as dirname3, join as join3 } from "node:path";
12
+
13
+ // src/lib/db/engine.ts
4
14
  import { createRequire } from "node:module";
5
- import { spawnSync } from "node:child_process";
6
- import initSqlJs from "sql.js";
15
+
16
+ // src/lib/runtime-checks.ts
17
+ var NODE_FLOOR = { major: 22, minor: 5 };
18
+ function isSupportedNode(version) {
19
+ const [major = 0, minor = 0] = version.split(".").map((n) => Number.parseInt(n, 10) || 0);
20
+ return major > NODE_FLOOR.major || major === NODE_FLOOR.major && minor >= NODE_FLOOR.minor;
21
+ }
22
+ function nodeVersionGuardMessage(version) {
23
+ return `
24
+ Open Code Review requires Node.js >= ${NODE_FLOOR.major}.${NODE_FLOOR.minor} (it uses Node's built-in SQLite, \`node:sqlite\`).
25
+ You have Node ${version}. Upgrade Node (e.g. \`nvm install 22 && nvm use 22\`) and re-run.
26
+
27
+ `;
28
+ }
29
+ function isSuppressibleSqliteWarning(warning) {
30
+ const message = typeof warning === "string" ? warning : warning?.message;
31
+ return typeof message === "string" && message.includes("SQLite is an experimental feature");
32
+ }
33
+
34
+ // src/lib/db/engine.ts
35
+ var SQLITE_BUSY = 5;
36
+ var SQLITE_BUSY_SNAPSHOT = 261;
37
+ var BUSY_RETRY_ATTEMPTS = 5;
38
+ var BUSY_RETRY_BACKOFF_MS = 50;
39
+ var savepointName = (depth) => `ocr_sp_${depth}`;
40
+ var nodeRequire = createRequire(import.meta.url);
41
+ var _preconditionsApplied = false;
42
+ function applyEnginePreconditions() {
43
+ if (_preconditionsApplied) return;
44
+ _preconditionsApplied = true;
45
+ const originalEmitWarning = process.emitWarning.bind(process);
46
+ process.emitWarning = (warning, ...args) => {
47
+ if (isSuppressibleSqliteWarning(warning)) return;
48
+ originalEmitWarning(warning, ...args);
49
+ };
50
+ }
51
+ var _DatabaseSyncCtor;
52
+ function newDatabase(path) {
53
+ if (!_DatabaseSyncCtor) {
54
+ applyEnginePreconditions();
55
+ try {
56
+ _DatabaseSyncCtor = nodeRequire("node:sqlite").DatabaseSync;
57
+ } catch (e) {
58
+ if (!isSupportedNode(process.versions.node)) {
59
+ throw new Error(nodeVersionGuardMessage(process.versions.node).trim());
60
+ }
61
+ throw e;
62
+ }
63
+ }
64
+ return new _DatabaseSyncCtor(path);
65
+ }
66
+ function isBusyError(e) {
67
+ const errcode = e?.errcode;
68
+ return errcode === SQLITE_BUSY || errcode === SQLITE_BUSY_SNAPSHOT;
69
+ }
70
+ var SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
71
+ function sleepSync(ms) {
72
+ Atomics.wait(SLEEP_BUF, 0, 0, ms);
73
+ }
74
+ var NodeSqliteAdapter = class {
75
+ raw;
76
+ /**
77
+ * Transaction nesting depth. `node:sqlite` has no transaction helper, so we
78
+ * drive `BEGIN IMMEDIATE` ourselves and use SAVEPOINTs for nested calls
79
+ * (better-sqlite3 did this automatically). 0 = no transaction open.
80
+ */
81
+ txnDepth = 0;
82
+ constructor(db) {
83
+ this.raw = db;
84
+ }
85
+ exec(sql, params) {
86
+ const stmt = this.raw.prepare(sql);
87
+ const cols = stmt.columns();
88
+ if (cols.length === 0) {
89
+ stmt.run(...params ?? []);
90
+ return [];
91
+ }
92
+ stmt.setReturnArrays(true);
93
+ const values = stmt.all(...params ?? []);
94
+ return values.length > 0 ? [{ columns: cols.map((c) => c.name), values }] : [];
95
+ }
96
+ run(sql, params) {
97
+ if (params !== void 0) {
98
+ this.raw.prepare(sql).run(...params);
99
+ return;
100
+ }
101
+ this.raw.exec(sql);
102
+ }
103
+ prepare(sql) {
104
+ return this.raw.prepare(sql);
105
+ }
106
+ transaction(fn) {
107
+ return this.txnDepth > 0 ? this.runNested(fn) : this.runOuter(fn);
108
+ }
109
+ /**
110
+ * Nested call: a SAVEPOINT within the outer transaction's write lock. No
111
+ * busy-retry — the outer transaction already holds the lock. The savepoint
112
+ * lets the inner block roll back independently while the outer continues.
113
+ */
114
+ runNested(fn) {
115
+ const name = savepointName(this.txnDepth);
116
+ this.raw.exec(`SAVEPOINT ${name}`);
117
+ this.txnDepth++;
118
+ try {
119
+ const result = fn();
120
+ this.raw.exec(`RELEASE ${name}`);
121
+ return result;
122
+ } catch (e) {
123
+ try {
124
+ this.raw.exec(`ROLLBACK TO ${name}`);
125
+ this.raw.exec(`RELEASE ${name}`);
126
+ } catch {
127
+ }
128
+ throw e;
129
+ } finally {
130
+ this.txnDepth--;
131
+ }
132
+ }
133
+ /**
134
+ * Outer transaction: `BEGIN IMMEDIATE` acquires the write lock up front so
135
+ * cross-process writers serialize cleanly under WAL instead of failing late
136
+ * on upgrade. `busy_timeout` covers most contention; a bounded synchronous
137
+ * retry absorbs the residual SQLITE_BUSY (another connection holds the lock
138
+ * past the timeout, or BUSY_SNAPSHOT). Non-busy errors and the final attempt
139
+ * re-throw so genuine failures propagate.
140
+ */
141
+ runOuter(fn) {
142
+ for (let attempt = 0; attempt < BUSY_RETRY_ATTEMPTS; attempt++) {
143
+ try {
144
+ return this.runOnce(fn);
145
+ } catch (e) {
146
+ if (!isBusyError(e) || attempt === BUSY_RETRY_ATTEMPTS - 1) throw e;
147
+ sleepSync(BUSY_RETRY_BACKOFF_MS);
148
+ }
149
+ }
150
+ throw new Error("transaction retry budget exhausted");
151
+ }
152
+ /** One `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK` lifecycle. */
153
+ runOnce(fn) {
154
+ this.raw.exec("BEGIN IMMEDIATE");
155
+ this.txnDepth = 1;
156
+ try {
157
+ const result = fn();
158
+ this.raw.exec("COMMIT");
159
+ return result;
160
+ } catch (e) {
161
+ try {
162
+ this.raw.exec("ROLLBACK");
163
+ } catch {
164
+ }
165
+ throw e;
166
+ } finally {
167
+ this.txnDepth = 0;
168
+ }
169
+ }
170
+ pragma(source) {
171
+ this.raw.exec(`PRAGMA ${source}`);
172
+ return void 0;
173
+ }
174
+ close() {
175
+ try {
176
+ this.raw.exec("PRAGMA wal_checkpoint(TRUNCATE)");
177
+ } catch {
178
+ }
179
+ try {
180
+ this.raw.close();
181
+ } catch (e) {
182
+ const message = e?.message ?? "";
183
+ if (!/database is not open/i.test(message)) throw e;
184
+ }
185
+ }
186
+ };
187
+ function probeEngine() {
188
+ try {
189
+ const db = newDatabase(":memory:");
190
+ db.exec("PRAGMA journal_mode = WAL");
191
+ db.exec("CREATE TABLE _probe(x); INSERT INTO _probe VALUES (1);");
192
+ const row = db.prepare("SELECT sqlite_version() AS v").get();
193
+ db.close();
194
+ return { ok: true, version: row.v };
195
+ } catch (e) {
196
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
197
+ }
198
+ }
199
+ function openEngine(dbPath) {
200
+ const native = newDatabase(dbPath);
201
+ native.exec("PRAGMA journal_mode = WAL");
202
+ native.exec("PRAGMA foreign_keys = ON");
203
+ native.exec("PRAGMA busy_timeout = 5000");
204
+ native.exec("PRAGMA synchronous = NORMAL");
205
+ return new NodeSqliteAdapter(native);
206
+ }
7
207
 
8
208
  // src/lib/db/migrations.ts
9
209
  var MIGRATIONS = [
@@ -327,8 +527,157 @@ var MIGRATIONS = [
327
527
  DROP INDEX IF EXISTS idx_agent_sessions_status_heartbeat;
328
528
  DROP TABLE IF EXISTS agent_sessions;
329
529
  `
530
+ },
531
+ {
532
+ version: 12,
533
+ description: "Event-sourced lifecycle hardening: event_type taxonomy guard, sweep indexes, session_completeness view",
534
+ sql: `
535
+ -- \u2500\u2500 Indexes for the now-periodic stale-session sweep + round derivation \u2500\u2500
536
+ -- The sweep filters sessions by status and rolls up MAX(created_at) per
537
+ -- session over the event log; deriveNextRound does MAX(round). Index both.
538
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
539
+ CREATE INDEX IF NOT EXISTS idx_events_session_created
540
+ ON orchestration_events(session_id, created_at);
541
+
542
+ -- \u2500\u2500 Event-type taxonomy guard \u2500\u2500
543
+ -- orchestration_events.event_type is the spine of all lifecycle
544
+ -- derivation. A typo (e.g. 'round_complete' vs 'round_completed') would
545
+ -- silently break deriveNextRound and the completeness view. SQLite cannot
546
+ -- add a CHECK to an existing column without a table rebuild, so enforce
547
+ -- the closed vocabulary with a BEFORE INSERT trigger instead.
548
+ CREATE TRIGGER IF NOT EXISTS trg_events_known_type
549
+ BEFORE INSERT ON orchestration_events
550
+ WHEN NEW.event_type NOT IN (
551
+ 'session_created', 'session_resumed', 'round_started', 'phase_transition',
552
+ 'round_completed', 'map_completed', 'session_closed', 'session_aborted',
553
+ 'session_auto_closed_stale', 'session_synced', 'session_legacy_import'
554
+ )
555
+ BEGIN
556
+ SELECT RAISE(ABORT, 'unknown orchestration_events.event_type');
557
+ END;
558
+
559
+ -- \u2500\u2500 Close-guard (DB backstop for the completion invariant) \u2500\u2500
560
+ -- A session cannot transition active \u2192 closed unless its current
561
+ -- round/run has a terminal artifact event, OR an explicit reason event
562
+ -- (abort / auto-close-stale / sync / legacy-import) is present. Only a
563
+ -- *silent* premature close is banned \u2014 every legitimate non-artifact
564
+ -- close carries a reason event and passes. App-level guards in
565
+ -- stateClose/finish are the primary check; this makes the illegal state
566
+ -- unrepresentable even via raw SQL.
567
+ --
568
+ -- DEFENCE-IN-DEPTH NOTE (intentional, documented gap): the reason-event
569
+ -- branch below (event_type IN (...)) is NOT round-scoped \u2014 a reason event
570
+ -- recorded for an earlier round would also satisfy a later close. The
571
+ -- app-level guards ARE round-scoped (hasCompletionInvariant checks the
572
+ -- current round/run), so the precise check lives in the application; this
573
+ -- trigger is a coarse backstop against a *silent* premature close via raw
574
+ -- SQL. Tightening it to be round-scoped would require a new migration
575
+ -- (this v12 trigger is append-only and already shipped); the residual
576
+ -- risk is a non-artifact close carrying a stale reason event, which is
577
+ -- still an explicit, audited terminal \u2014 not the failure mode this guards.
578
+ CREATE TRIGGER IF NOT EXISTS trg_sessions_close_guard
579
+ BEFORE UPDATE OF status ON sessions
580
+ WHEN NEW.status = 'closed' AND OLD.status <> 'closed'
581
+ AND NOT EXISTS (
582
+ SELECT 1 FROM orchestration_events e
583
+ WHERE e.session_id = NEW.id
584
+ AND (
585
+ (NEW.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = NEW.current_round)
586
+ OR (NEW.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = NEW.current_map_run)
587
+ OR e.event_type IN ('session_aborted','session_auto_closed_stale','session_synced','session_legacy_import')
588
+ )
589
+ )
590
+ BEGIN
591
+ SELECT RAISE(ABORT, 'cannot close session without a completed round/run or an explicit reason event');
592
+ END;
593
+
594
+ -- \u2500\u2500 session_completeness view \u2500\u2500
595
+ -- The published contract for "is this session actually complete, and if
596
+ -- not, what's missing". Completion is DERIVED from the event log, never a
597
+ -- mutable flag: a session is complete iff it is closed AND a terminal
598
+ -- artifact event exists for its current round/run. The dashboard's
599
+ -- outcome derivation and the agent 'status' command read this view, so
600
+ -- they cannot disagree.
601
+ --
602
+ -- completeness_state is an INTENTIONAL HYBRID: it combines the mutable
603
+ -- status column (marked_closed) with append-only event evidence (the
604
+ -- terminal artifact event). This is sound precisely because the
605
+ -- close-guard trigger above makes the status column trustworthy \u2014 a row
606
+ -- can only reach status='closed' with a completed round/run or an
607
+ -- explicit reason event \u2014 so reading the column is not a regression to
608
+ -- the old "mutable flag that could lie" model.
609
+ --
610
+ -- completeness_state:
611
+ -- 'complete' \u2014 closed + terminal artifact for current round/run
612
+ -- 'closed_without_artifact' \u2014 closed but no terminal artifact (the
613
+ -- "completed too soon" condition)
614
+ -- 'in_flight' \u2014 open with a dependent process still running
615
+ -- 'open_no_artifact' \u2014 open, no in-flight dependents
616
+ CREATE VIEW IF NOT EXISTS session_completeness AS
617
+ SELECT
618
+ s.id AS session_id,
619
+ s.workflow_type AS workflow_type,
620
+ s.status AS status,
621
+ s.current_round AS current_round,
622
+ s.current_map_run AS current_map_run,
623
+ CASE WHEN EXISTS (
624
+ SELECT 1 FROM orchestration_events e
625
+ WHERE e.session_id = s.id
626
+ AND (
627
+ (s.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = s.current_round)
628
+ OR (s.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = s.current_map_run)
629
+ )
630
+ ) THEN 1 ELSE 0 END AS has_terminal_artifact,
631
+ CASE WHEN s.status = 'closed' THEN 1 ELSE 0 END AS marked_closed,
632
+ CASE WHEN NOT EXISTS (
633
+ SELECT 1 FROM command_executions ce
634
+ WHERE ce.workflow_id = s.id AND ce.finished_at IS NULL
635
+ ) THEN 1 ELSE 0 END AS dependents_settled,
636
+ CASE
637
+ WHEN s.status = 'closed' AND EXISTS (
638
+ SELECT 1 FROM orchestration_events e
639
+ WHERE e.session_id = s.id
640
+ AND (
641
+ (s.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = s.current_round)
642
+ OR (s.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = s.current_map_run)
643
+ )
644
+ ) THEN 'complete'
645
+ WHEN s.status = 'closed' THEN 'closed_without_artifact'
646
+ WHEN EXISTS (
647
+ SELECT 1 FROM command_executions ce
648
+ WHERE ce.workflow_id = s.id AND ce.finished_at IS NULL
649
+ ) THEN 'in_flight'
650
+ ELSE 'open_no_artifact'
651
+ END AS completeness_state
652
+ FROM sessions s;
653
+ `
654
+ },
655
+ {
656
+ version: 13,
657
+ description: "Retire dead parent_id column on command_executions (never written; row kind is derived from command)",
658
+ // parent_id was reserved for an AI-instance → dashboard-spawn lineage link
659
+ // that was never wired (no writer, no reader). A process's KIND (supervisor
660
+ // / reviewer-instance / utility) is derived from columns that are always
661
+ // present (command + last_heartbeat_at), so the dead lineage column and its
662
+ // all-NULL index are removed. Re-add a wired parent_id alongside a real
663
+ // consumer (e.g. a parent→child tree view) if lineage is ever needed.
664
+ //
665
+ // Imperative + guarded so the DROP COLUMN (which SQLite can't express as
666
+ // IF EXISTS) is idempotent under re-application.
667
+ run: (db) => {
668
+ if (!columnExists(db, "command_executions", "parent_id")) return;
669
+ db.run("DROP INDEX IF EXISTS idx_command_executions_parent;");
670
+ db.run("ALTER TABLE command_executions DROP COLUMN parent_id;");
671
+ }
330
672
  }
331
673
  ];
674
+ function columnExists(db, table, column) {
675
+ const result = db.exec(`PRAGMA table_info(${table})`);
676
+ const first = result[0];
677
+ if (!first) return false;
678
+ const nameIdx = first.columns.indexOf("name");
679
+ return first.values.some((row) => row[nameIdx] === column);
680
+ }
332
681
  function ensureSchemaVersionTable(db) {
333
682
  db.run(`
334
683
  CREATE TABLE IF NOT EXISTS schema_version (
@@ -338,6 +687,10 @@ function ensureSchemaVersionTable(db) {
338
687
  );
339
688
  `);
340
689
  }
690
+ function getSchemaVersion(db) {
691
+ ensureSchemaVersionTable(db);
692
+ return getCurrentVersion(db);
693
+ }
341
694
  function getCurrentVersion(db) {
342
695
  const result = db.exec(
343
696
  "SELECT MAX(version) as v FROM schema_version"
@@ -355,9 +708,10 @@ function runMigrations(db) {
355
708
  if (migration.version <= currentVersion) {
356
709
  continue;
357
710
  }
358
- db.run("BEGIN TRANSACTION;");
711
+ db.run("BEGIN IMMEDIATE;");
359
712
  try {
360
- db.run(migration.sql);
713
+ if (migration.sql) db.run(migration.sql);
714
+ migration.run?.(db);
361
715
  db.run(
362
716
  "INSERT INTO schema_version (version, description) VALUES (?, ?);",
363
717
  [migration.version, migration.description]
@@ -370,6 +724,10 @@ function runMigrations(db) {
370
724
  }
371
725
  }
372
726
 
727
+ // src/lib/db/reconcile.ts
728
+ import { existsSync } from "node:fs";
729
+ import { isAbsolute, join, dirname } from "node:path";
730
+
373
731
  // src/lib/db/result-mapper.ts
374
732
  function resultToRows(result) {
375
733
  if (result.length === 0 || !result[0]) {
@@ -497,11 +855,196 @@ function getLatestEventId(db) {
497
855
  const val = result[0]?.values[0]?.[0];
498
856
  return typeof val === "number" ? val : 0;
499
857
  }
858
+ function commitReasonClose(db, sessionId, reasonEvent, projectionUpdates) {
859
+ db.transaction(() => {
860
+ insertEvent(db, { session_id: sessionId, ...reasonEvent });
861
+ updateSession(db, sessionId, projectionUpdates);
862
+ });
863
+ }
500
864
 
501
- // src/lib/db/agent-sessions.ts
502
- var ORPHAN_EXIT_CODE = -3;
865
+ // src/lib/db/reconcile.ts
866
+ var DEFAULT_STALE_THRESHOLD_SECONDS = 7 * 24 * 60 * 60;
867
+ function hasTerminalArtifactEvent(db, sessionId, workflowType, currentRound, currentMapRun) {
868
+ const eventType = workflowType === "map" ? "map_completed" : "round_completed";
869
+ const round = workflowType === "map" ? currentMapRun : currentRound;
870
+ const r = db.exec(
871
+ `SELECT 1 FROM orchestration_events
872
+ WHERE session_id = ? AND event_type = ? AND round = ? LIMIT 1`,
873
+ [sessionId, eventType, round]
874
+ );
875
+ return (r[0]?.values.length ?? 0) > 0;
876
+ }
877
+ function hasReasonEvent(db, sessionId) {
878
+ const r = db.exec(
879
+ `SELECT 1 FROM orchestration_events
880
+ WHERE session_id = ?
881
+ AND event_type IN ('session_aborted','session_auto_closed_stale','session_synced','session_legacy_import')
882
+ LIMIT 1`,
883
+ [sessionId]
884
+ );
885
+ return (r[0]?.values.length ?? 0) > 0;
886
+ }
887
+ function lastEventAgeSeconds(db, sessionId) {
888
+ const r = db.exec(
889
+ `SELECT (julianday('now') - julianday(MAX(created_at))) * 86400
890
+ FROM orchestration_events WHERE session_id = ?`,
891
+ [sessionId]
892
+ );
893
+ const v = r[0]?.values[0]?.[0];
894
+ return typeof v === "number" ? v : null;
895
+ }
896
+ function hasInFlightDependents(db, sessionId) {
897
+ const r = db.exec(
898
+ `SELECT 1 FROM command_executions
899
+ WHERE workflow_id = ? AND finished_at IS NULL LIMIT 1`,
900
+ [sessionId]
901
+ );
902
+ return (r[0]?.values.length ?? 0) > 0;
903
+ }
904
+ function resolveSessionDir(ocrDir, sessionDir) {
905
+ if (!sessionDir) return null;
906
+ if (isAbsolute(sessionDir)) return sessionDir;
907
+ return join(dirname(ocrDir), sessionDir);
908
+ }
909
+ function reconcileLegacyState(db, ocrDir, opts = {}) {
910
+ const dryRun = opts.dryRun ?? false;
911
+ const threshold = opts.staleThresholdSeconds ?? DEFAULT_STALE_THRESHOLD_SECONDS;
912
+ const actions = [];
913
+ for (const s of getAllSessions(db)) {
914
+ const dir = resolveSessionDir(ocrDir, s.session_dir);
915
+ if (s.status === "closed") {
916
+ if (hasTerminalArtifactEvent(db, s.id, s.workflow_type, s.current_round, s.current_map_run) || hasReasonEvent(db, s.id)) {
917
+ continue;
918
+ }
919
+ const reviewFinal = s.workflow_type === "review" && dir ? existsSync(join(dir, "rounds", `round-${s.current_round}`, "final.md")) : false;
920
+ const mapFinal = s.workflow_type === "map" && dir ? existsSync(join(dir, "map", "runs", `run-${s.current_map_run}`, "map.md")) : false;
921
+ if (reviewFinal) {
922
+ actions.push({
923
+ sessionId: s.id,
924
+ kind: "synthesize-round-completed",
925
+ detail: `final.md present for round ${s.current_round}; synthesizing round_completed`
926
+ });
927
+ if (!dryRun) {
928
+ insertEvent(db, {
929
+ session_id: s.id,
930
+ event_type: "round_completed",
931
+ phase: "synthesis",
932
+ phase_number: 7,
933
+ round: s.current_round,
934
+ metadata: JSON.stringify({ source: "reconciled", synthesized_from: "final.md" })
935
+ });
936
+ }
937
+ } else if (mapFinal) {
938
+ actions.push({
939
+ sessionId: s.id,
940
+ kind: "synthesize-map-completed",
941
+ detail: `map.md present for run ${s.current_map_run}; synthesizing map_completed`
942
+ });
943
+ if (!dryRun) {
944
+ insertEvent(db, {
945
+ session_id: s.id,
946
+ event_type: "map_completed",
947
+ phase: "synthesis",
948
+ phase_number: 5,
949
+ round: s.current_map_run,
950
+ metadata: JSON.stringify({ source: "reconciled", synthesized_from: "map.md" })
951
+ });
952
+ }
953
+ } else {
954
+ actions.push({
955
+ sessionId: s.id,
956
+ kind: "grandfather",
957
+ detail: "no provable artifact; recording session_legacy_import"
958
+ });
959
+ if (!dryRun) {
960
+ insertEvent(db, {
961
+ session_id: s.id,
962
+ event_type: "session_legacy_import",
963
+ phase: "complete",
964
+ metadata: JSON.stringify({ source: "reconciled" })
965
+ });
966
+ }
967
+ }
968
+ continue;
969
+ }
970
+ const age = lastEventAgeSeconds(db, s.id);
971
+ const stale = (age === null || age > threshold) && !hasInFlightDependents(db, s.id);
972
+ if (stale) {
973
+ actions.push({
974
+ sessionId: s.id,
975
+ kind: "stale-close",
976
+ detail: age === null ? "active with no events and no in-flight dependents" : `active, last event ${Math.round(age / 86400)}d ago, no in-flight dependents`
977
+ });
978
+ if (!dryRun) {
979
+ commitReasonClose(
980
+ db,
981
+ s.id,
982
+ {
983
+ event_type: "session_auto_closed_stale",
984
+ phase: "complete",
985
+ metadata: JSON.stringify({ source: "reconciled", threshold_seconds: threshold })
986
+ },
987
+ { status: "closed", current_phase: "complete" }
988
+ );
989
+ }
990
+ }
991
+ }
992
+ return { dryRun, actions };
993
+ }
994
+
995
+ // src/lib/db/liveness.ts
996
+ var PID_REUSE_GUARD_MS = 24 * 60 * 60 * 1e3;
997
+ function defaultIsAlive(pid) {
998
+ try {
999
+ process.kill(pid, 0);
1000
+ return true;
1001
+ } catch (err) {
1002
+ return !(err instanceof Error && "code" in err && err.code === "ESRCH");
1003
+ }
1004
+ }
1005
+ function sqliteUtcMs(ts) {
1006
+ const sqliteShape = ts.includes(" ");
1007
+ return new Date(sqliteShape ? ts.replace(" ", "T") + "Z" : ts).getTime();
1008
+ }
1009
+
1010
+ // src/lib/state/exit-codes.ts
1011
+ var STATE_EXIT = {
1012
+ OK: 0,
1013
+ USAGE: 2,
1014
+ AMBIGUOUS: 3,
1015
+ NOT_FOUND: 4,
1016
+ ILLEGAL_TRANSITION: 5,
1017
+ INVARIANT_UNMET: 6,
1018
+ SCHEMA_INVALID: 7,
1019
+ /** Database was locked past the bounded retry budget (SQLITE_BUSY). */
1020
+ BUSY: 8
1021
+ };
1022
+ var StateError = class extends Error {
1023
+ constructor(code, message) {
1024
+ super(message);
1025
+ this.code = code;
1026
+ this.name = "StateError";
1027
+ }
1028
+ };
503
1029
  var CANCELLED_EXIT_CODE = -2;
1030
+ var ORPHAN_EXIT_CODE = -3;
1031
+ var CASCADE_CLOSE_EXIT_CODE = -4;
1032
+
1033
+ // src/lib/db/agent-sessions.ts
504
1034
  var NOTE_ORPHAN_PREFIX = "orphaned by liveness sweep";
1035
+ var INSTANCE_COMMAND = "session-instance";
1036
+ function cascadeTerminateExecutions(db, workflowId, exitCode, note) {
1037
+ db.run(
1038
+ `UPDATE command_executions
1039
+ SET finished_at = datetime('now'),
1040
+ exit_code = ?,
1041
+ pid = NULL,
1042
+ notes = COALESCE(notes || char(10), '') || ?
1043
+ WHERE workflow_id = ?
1044
+ AND finished_at IS NULL`,
1045
+ [exitCode, note, workflowId]
1046
+ );
1047
+ }
505
1048
  function rowToAgentSession(row) {
506
1049
  return {
507
1050
  // The OCR-owned id is the `uid` column. Fall back to the integer
@@ -516,6 +1059,7 @@ function rowToAgentSession(row) {
516
1059
  resolved_model: row.resolved_model,
517
1060
  phase: null,
518
1061
  status: deriveStatus(row),
1062
+ kind: rowKind(row),
519
1063
  pid: row.pid,
520
1064
  started_at: row.started_at,
521
1065
  last_heartbeat_at: row.last_heartbeat_at ?? row.started_at,
@@ -529,7 +1073,9 @@ function deriveStatus(row) {
529
1073
  return "running";
530
1074
  }
531
1075
  if (row.exit_code === ORPHAN_EXIT_CODE) return "orphaned";
532
- if (row.exit_code === CANCELLED_EXIT_CODE) return "cancelled";
1076
+ if (row.exit_code === CANCELLED_EXIT_CODE || row.exit_code === CASCADE_CLOSE_EXIT_CODE) {
1077
+ return "cancelled";
1078
+ }
533
1079
  if (row.exit_code === 0) return "done";
534
1080
  return "crashed";
535
1081
  }
@@ -545,7 +1091,7 @@ function insertAgentSession(db, params) {
545
1091
  pid = null,
546
1092
  notes = null
547
1093
  } = params;
548
- const command = persona && instance_index !== null ? `session-instance:${persona}-${instance_index}` : "session-instance";
1094
+ const command = persona && instance_index !== null ? `${INSTANCE_COMMAND}:${persona}-${instance_index}` : INSTANCE_COMMAND;
549
1095
  db.run(
550
1096
  `INSERT INTO command_executions
551
1097
  (uid, command, args, workflow_id, vendor, persona, instance_index, name,
@@ -750,38 +1296,112 @@ function updateAgentSession(db, id, params) {
750
1296
  values
751
1297
  );
752
1298
  }
753
- function sweepStaleAgentSessions(db, thresholdSeconds) {
754
- const staleSql = `
755
- SELECT uid, id FROM command_executions
756
- WHERE finished_at IS NULL
757
- AND last_heartbeat_at IS NOT NULL
758
- AND (julianday('now') - julianday(last_heartbeat_at)) * 86400 > ?
759
- `;
760
- const stale = resultToRows(
761
- db.exec(staleSql, [thresholdSeconds])
1299
+ function sweepStaleAgentSessions(db, thresholdSeconds, isAlive = defaultIsAlive) {
1300
+ const candidates = resultToRows(
1301
+ db.exec(
1302
+ `SELECT uid, id, pid, started_at, workflow_id, command, last_heartbeat_at
1303
+ FROM command_executions
1304
+ WHERE finished_at IS NULL
1305
+ AND pid IS NOT NULL
1306
+ AND last_heartbeat_at IS NOT NULL
1307
+ AND (julianday('now') - julianday(last_heartbeat_at)) * 86400 > ?`,
1308
+ [thresholdSeconds]
1309
+ )
762
1310
  );
763
- if (stale.length === 0) {
764
- return { orphanedIds: [] };
1311
+ if (candidates.length === 0) {
1312
+ return { orphanedIds: [], cascadedWorkflowIds: [] };
1313
+ }
1314
+ const reuseCutoffMs = Date.now() - PID_REUSE_GUARD_MS;
1315
+ const dead = candidates.filter((row) => {
1316
+ if (row.pid === null) return false;
1317
+ if (sqliteUtcMs(row.started_at) < reuseCutoffMs) return false;
1318
+ return !isAlive(row.pid);
1319
+ });
1320
+ if (dead.length === 0) {
1321
+ return { orphanedIds: [], cascadedWorkflowIds: [] };
765
1322
  }
766
1323
  const note = `${NOTE_ORPHAN_PREFIX} (threshold ${thresholdSeconds}s)`;
767
- db.run(
768
- `UPDATE command_executions
769
- SET finished_at = datetime('now'),
770
- exit_code = ?,
771
- notes = COALESCE(notes || char(10), '') || ?
772
- WHERE finished_at IS NULL
773
- AND last_heartbeat_at IS NOT NULL
774
- AND (julianday('now') - julianday(last_heartbeat_at)) * 86400 > ?`,
775
- [ORPHAN_EXIT_CODE, note, thresholdSeconds]
776
- );
1324
+ const placeholders = dead.map(() => "?").join(", ");
1325
+ const cascadedWorkflowIds = [];
1326
+ db.transaction(() => {
1327
+ db.run(
1328
+ `UPDATE command_executions
1329
+ SET finished_at = datetime('now'),
1330
+ exit_code = ?,
1331
+ pid = NULL,
1332
+ notes = COALESCE(notes || char(10), '') || ?
1333
+ WHERE id IN (${placeholders})
1334
+ AND finished_at IS NULL`,
1335
+ [ORPHAN_EXIT_CODE, note, ...dead.map((r) => r.id)]
1336
+ );
1337
+ for (const row of dead) {
1338
+ if (row.workflow_id && rowKind(row) === "supervisor") {
1339
+ cascadeTerminateExecutions(
1340
+ db,
1341
+ row.workflow_id,
1342
+ CASCADE_CLOSE_EXIT_CODE,
1343
+ "cascade-closed: workflow process orphaned by liveness sweep"
1344
+ );
1345
+ cascadedWorkflowIds.push(row.workflow_id);
1346
+ }
1347
+ }
1348
+ });
777
1349
  return {
778
- orphanedIds: stale.map((row) => row.uid ?? String(row.id))
1350
+ orphanedIds: dead.map((r) => r.uid ?? String(r.id)),
1351
+ cascadedWorkflowIds
779
1352
  };
780
1353
  }
1354
+ function rowKind(row) {
1355
+ if (row.command === INSTANCE_COMMAND || row.command.startsWith(`${INSTANCE_COMMAND}:`)) {
1356
+ return "instance";
1357
+ }
1358
+ return row.last_heartbeat_at == null ? "utility" : "supervisor";
1359
+ }
1360
+ function sweepStaleSessions(db, thresholdSeconds) {
1361
+ const sql = `
1362
+ SELECT s.id
1363
+ FROM sessions s
1364
+ LEFT JOIN (
1365
+ SELECT session_id, MAX(created_at) AS last_event_at
1366
+ FROM orchestration_events
1367
+ GROUP BY session_id
1368
+ ) e ON e.session_id = s.id
1369
+ WHERE s.status = 'active'
1370
+ AND (
1371
+ e.last_event_at IS NULL
1372
+ OR (julianday('now') - julianday(e.last_event_at)) * 86400 > ?
1373
+ )
1374
+ AND NOT EXISTS (
1375
+ SELECT 1 FROM command_executions ce
1376
+ WHERE ce.workflow_id = s.id
1377
+ AND ce.finished_at IS NULL
1378
+ )
1379
+ `;
1380
+ const rows = resultToRows(db.exec(sql, [thresholdSeconds]));
1381
+ if (rows.length === 0) {
1382
+ return { closedSessionIds: [] };
1383
+ }
1384
+ for (const row of rows) {
1385
+ commitReasonClose(
1386
+ db,
1387
+ row.id,
1388
+ {
1389
+ event_type: "session_auto_closed_stale",
1390
+ phase: "complete",
1391
+ metadata: JSON.stringify({
1392
+ reason: "no events past threshold; no in-flight dependents",
1393
+ threshold_seconds: thresholdSeconds
1394
+ })
1395
+ },
1396
+ { status: "closed", current_phase: "complete" }
1397
+ );
1398
+ }
1399
+ return { closedSessionIds: rows.map((r) => r.id) };
1400
+ }
781
1401
 
782
1402
  // src/lib/db/command-log.ts
783
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
784
- import { dirname, join } from "node:path";
1403
+ import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1404
+ import { dirname as dirname2, join as join2 } from "node:path";
785
1405
  import { randomUUID } from "node:crypto";
786
1406
  var CACHE_DIR = ".cache";
787
1407
  var FILENAME = "command-history.jsonl";
@@ -792,16 +1412,16 @@ function generateCommandUid() {
792
1412
  return randomUUID();
793
1413
  }
794
1414
  function cacheDir(ocrDir) {
795
- return join(ocrDir, "data", CACHE_DIR);
1415
+ return join2(ocrDir, "data", CACHE_DIR);
796
1416
  }
797
1417
  function commandLogPath(ocrDir) {
798
- return join(cacheDir(ocrDir), FILENAME);
1418
+ return join2(cacheDir(ocrDir), FILENAME);
799
1419
  }
800
1420
  function appendCommandLog(ocrDir, entry) {
801
1421
  try {
802
1422
  const filePath = commandLogPath(ocrDir);
803
- const dir = dirname(filePath);
804
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1423
+ const dir = dirname2(filePath);
1424
+ if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
805
1425
  const line = JSON.stringify(entry) + "\n";
806
1426
  appendFileSync(filePath, line, { encoding: "utf-8" });
807
1427
  if (approxLineCount >= 0) approxLineCount++;
@@ -811,7 +1431,7 @@ function appendCommandLog(ocrDir, entry) {
811
1431
  }
812
1432
  function readCommandLog(ocrDir) {
813
1433
  const filePath = commandLogPath(ocrDir);
814
- if (!existsSync(filePath)) return [];
1434
+ if (!existsSync2(filePath)) return [];
815
1435
  const content = readFileSync(filePath, "utf-8");
816
1436
  const entries = [];
817
1437
  for (const line of content.split("\n")) {
@@ -877,93 +1497,117 @@ function rotateIfNeeded(filePath) {
877
1497
  }
878
1498
 
879
1499
  // src/lib/db/index.ts
880
- var connections = /* @__PURE__ */ new Map();
881
- function locateWasm() {
882
- const require2 = createRequire(import.meta.url);
883
- const sqlJsPath = require2.resolve("sql.js");
884
- return join2(dirname2(sqlJsPath), "sql-wasm.wasm");
1500
+ var V2_SCHEMA_VERSION = 12;
1501
+ function maybeSnapshotBeforeUpgrade(db, dbPath, fromVersion) {
1502
+ if (fromVersion < 1 || fromVersion >= V2_SCHEMA_VERSION) return null;
1503
+ const bakPath = `${dbPath}.bak.v${fromVersion}`;
1504
+ if (existsSync3(bakPath)) return bakPath;
1505
+ try {
1506
+ if (!existsSync3(dbPath) || statSync(dbPath).size === 0) return null;
1507
+ db.pragma("wal_checkpoint(TRUNCATE)");
1508
+ copyFileSync(dbPath, bakPath);
1509
+ return bakPath;
1510
+ } catch {
1511
+ return null;
1512
+ }
885
1513
  }
886
- function applyPragmas(db) {
887
- db.run("PRAGMA foreign_keys = ON;");
888
- db.run("PRAGMA journal_mode = WAL;");
889
- db.run("PRAGMA busy_timeout = 5000;");
1514
+ function formatUpgradeNotice(bakPath, reconcile) {
1515
+ const lines = [
1516
+ "Storage upgraded to v2.0 \u2014 durable SQLite engine (WAL), event-sourced lifecycle."
1517
+ ];
1518
+ if (bakPath) {
1519
+ lines.push(` A backup of your previous database was saved to: ${bakPath}`);
1520
+ }
1521
+ const repairs = (reconcile?.actions ?? []).filter((a) => a.kind !== "ok");
1522
+ if (repairs.length > 0) {
1523
+ const n = (kind) => repairs.filter((a) => a.kind === kind).length;
1524
+ const parts = [];
1525
+ const finalized = n("synthesize-round-completed") + n("synthesize-map-completed");
1526
+ if (finalized > 0) parts.push(`${finalized} finalized from artifacts`);
1527
+ if (n("grandfather") > 0) parts.push(`${n("grandfather")} grandfathered`);
1528
+ if (n("stale-close") > 0) parts.push(`${n("stale-close")} stale closed`);
1529
+ lines.push(
1530
+ ` Reconciled ${repairs.length} legacy session(s): ${parts.join(", ")}.`
1531
+ );
1532
+ }
1533
+ lines.push(" Run `ocr doctor` to verify the storage engine.");
1534
+ return lines.map((l) => `[ocr] ${l}`).join("\n");
890
1535
  }
1536
+ var connections = /* @__PURE__ */ new Map();
891
1537
  async function openDatabase(dbPath) {
892
1538
  const cached = connections.get(dbPath);
893
1539
  if (cached) {
894
1540
  return cached;
895
1541
  }
896
- const wasmBuffer = readFileSync2(locateWasm());
897
- const wasmBinary = wasmBuffer.buffer.slice(
898
- wasmBuffer.byteOffset,
899
- wasmBuffer.byteOffset + wasmBuffer.byteLength
900
- );
901
- const SQL = await initSqlJs({
902
- wasmBinary
903
- });
904
- let db;
905
- if (existsSync2(dbPath)) {
906
- const fileBuffer = readFileSync2(dbPath);
907
- db = new SQL.Database(fileBuffer);
908
- } else {
909
- db = new SQL.Database();
1542
+ const dir = dirname3(dbPath);
1543
+ if (!existsSync3(dir)) {
1544
+ mkdirSync2(dir, { recursive: true });
910
1545
  }
911
- applyPragmas(db);
1546
+ const db = openEngine(dbPath);
912
1547
  connections.set(dbPath, db);
913
1548
  return db;
914
1549
  }
915
- function saveDatabase(db, dbPath) {
916
- const data = db.export();
917
- const dir = dirname2(dbPath);
918
- if (!existsSync2(dir)) {
919
- mkdirSync2(dir, { recursive: true });
920
- }
921
- const tmpPath = `${dbPath}.${process.pid}.tmp`;
922
- writeFileSync2(tmpPath, Buffer.from(data));
923
- renameSync2(tmpPath, dbPath);
924
- }
925
1550
  async function getDb(ocrDir) {
926
- const dbPath = join2(ocrDir, "data", "ocr.db");
1551
+ const dbPath = join3(ocrDir, "data", "ocr.db");
927
1552
  return openDatabase(dbPath);
928
1553
  }
929
1554
  async function ensureDatabase(ocrDir) {
930
- const dataDir = join2(ocrDir, "data");
931
- if (!existsSync2(dataDir)) {
1555
+ const dataDir = join3(ocrDir, "data");
1556
+ if (!existsSync3(dataDir)) {
932
1557
  mkdirSync2(dataDir, { recursive: true });
933
1558
  }
934
- const dbPath = join2(dataDir, "ocr.db");
1559
+ const dbPath = join3(dataDir, "ocr.db");
935
1560
  const db = await openDatabase(dbPath);
1561
+ let before = 0;
1562
+ try {
1563
+ before = getSchemaVersion(db);
1564
+ } catch {
1565
+ before = 0;
1566
+ }
1567
+ const isLegacyUpgrade = before >= 1 && before < V2_SCHEMA_VERSION;
1568
+ const bakPath = maybeSnapshotBeforeUpgrade(db, dbPath, before);
936
1569
  runMigrations(db);
937
- saveDatabase(db, dbPath);
1570
+ let reconcile;
1571
+ if (before < V2_SCHEMA_VERSION) {
1572
+ try {
1573
+ reconcile = reconcileLegacyState(db, ocrDir);
1574
+ } catch (err) {
1575
+ console.error(
1576
+ `[ocr] legacy reconciliation skipped: ${err instanceof Error ? err.message : String(err)}`
1577
+ );
1578
+ }
1579
+ }
1580
+ if (isLegacyUpgrade) {
1581
+ const notice = formatUpgradeNotice(bakPath, reconcile);
1582
+ if (notice) console.error(notice);
1583
+ }
938
1584
  return db;
939
1585
  }
940
1586
  function walCheckpointTruncate(dbPath) {
941
- if (!existsSync2(dbPath)) {
1587
+ if (!existsSync3(dbPath)) {
942
1588
  return "skipped";
943
1589
  }
944
- try {
945
- const probe = spawnSync("sqlite3", ["-version"], {
946
- stdio: "ignore",
947
- timeout: 2e3
948
- });
949
- if (probe.status !== 0) {
950
- return "skipped";
1590
+ const cached = connections.get(dbPath);
1591
+ if (cached) {
1592
+ try {
1593
+ cached.pragma("wal_checkpoint(TRUNCATE)");
1594
+ return "checkpointed";
1595
+ } catch {
1596
+ return "failed";
951
1597
  }
952
- } catch {
953
- return "skipped";
954
1598
  }
1599
+ let transient;
955
1600
  try {
956
- const result = spawnSync(
957
- "sqlite3",
958
- [dbPath, "PRAGMA wal_checkpoint(TRUNCATE);"],
959
- {
960
- stdio: "ignore",
961
- timeout: 5e3
962
- }
963
- );
964
- return result.status === 0 ? "checkpointed" : "failed";
1601
+ transient = openEngine(dbPath);
1602
+ transient.pragma("wal_checkpoint(TRUNCATE)");
1603
+ return "checkpointed";
965
1604
  } catch {
966
1605
  return "failed";
1606
+ } finally {
1607
+ try {
1608
+ transient?.close();
1609
+ } catch {
1610
+ }
967
1611
  }
968
1612
  }
969
1613
  function closeDatabase(dbPath) {
@@ -979,17 +1623,61 @@ function closeAllDatabases() {
979
1623
  connections.delete(path);
980
1624
  }
981
1625
  }
1626
+ function probeWrite() {
1627
+ let dir;
1628
+ try {
1629
+ dir = mkdtempSync(join3(tmpdir(), "ocr-probe-"));
1630
+ const db = openEngine(join3(dir, "probe.db"));
1631
+ try {
1632
+ db.run("CREATE TABLE _probe_write (id INTEGER PRIMARY KEY, v TEXT)");
1633
+ db.transaction(() => {
1634
+ db.run("INSERT INTO _probe_write (v) VALUES (?)", ["written-in-txn"]);
1635
+ });
1636
+ const value = db.exec("SELECT v FROM _probe_write")[0]?.values[0]?.[0];
1637
+ if (value !== "written-in-txn") {
1638
+ return { ok: false, error: `unexpected probe value: ${String(value)}` };
1639
+ }
1640
+ return { ok: true };
1641
+ } finally {
1642
+ db.close();
1643
+ }
1644
+ } catch (e) {
1645
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1646
+ } finally {
1647
+ if (dir) rmDirBestEffort(dir);
1648
+ }
1649
+ }
1650
+ function rmDirBestEffort(dir) {
1651
+ for (let attempt = 0; attempt < 3; attempt++) {
1652
+ try {
1653
+ rmSync(dir, { recursive: true, force: true });
1654
+ return;
1655
+ } catch {
1656
+ if (attempt === 2) return;
1657
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
1658
+ }
1659
+ }
1660
+ }
982
1661
  export {
1662
+ CANCELLED_EXIT_CODE,
1663
+ CASCADE_CLOSE_EXIT_CODE,
983
1664
  MIGRATIONS,
1665
+ ORPHAN_EXIT_CODE,
1666
+ PID_REUSE_GUARD_MS,
1667
+ STATE_EXIT,
1668
+ StateError,
984
1669
  appendCommandLog,
985
- applyPragmas,
986
1670
  bindVendorSessionIdOpportunistically,
987
1671
  bumpAgentSessionHeartbeat,
988
1672
  cacheDir,
1673
+ cascadeTerminateExecutions,
989
1674
  closeAllDatabases,
990
1675
  closeDatabase,
991
1676
  commandLogPath,
1677
+ commitReasonClose,
1678
+ defaultIsAlive,
992
1679
  ensureDatabase,
1680
+ formatUpgradeNotice,
993
1681
  generateCommandUid,
994
1682
  getAgentSession,
995
1683
  getAllSessions,
@@ -998,24 +1686,30 @@ export {
998
1686
  getLatestActiveSession,
999
1687
  getLatestAgentSessionWithVendorId,
1000
1688
  getLatestEventId,
1689
+ getSchemaVersion,
1001
1690
  getSession,
1002
1691
  insertAgentSession,
1003
1692
  insertEvent,
1004
1693
  insertSession,
1694
+ isBusyError,
1005
1695
  linkDashboardInvocationToWorkflow,
1006
1696
  listAgentSessionsForWorkflow,
1007
- locateWasm,
1008
1697
  openDatabase,
1698
+ probeEngine,
1699
+ probeWrite,
1009
1700
  readCommandLog,
1701
+ reconcileLegacyState,
1010
1702
  recordVendorSessionIdForExecution,
1011
1703
  replayCommandLog,
1012
1704
  resultToRow,
1013
1705
  resultToRows,
1706
+ rowKind,
1014
1707
  runMigrations,
1015
- saveDatabase,
1016
1708
  setAgentSessionStatus,
1017
1709
  setAgentSessionVendorId,
1710
+ sqliteUtcMs,
1018
1711
  sweepStaleAgentSessions,
1712
+ sweepStaleSessions,
1019
1713
  updateAgentSession,
1020
1714
  updateSession,
1021
1715
  walCheckpointTruncate