@open-code-review/cli 1.10.4 → 2.0.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 (65) hide show
  1. package/README.md +31 -5
  2. package/dist/dashboard/client/assets/{_basePickBy-DbLJVCA4.js → _basePickBy-B3ALyupE.js} +1 -1
  3. package/dist/dashboard/client/assets/{_baseUniq-IXEG0cJJ.js → _baseUniq-b2RALAWc.js} +1 -1
  4. package/dist/dashboard/client/assets/{arc-lsKxmOJY.js → arc-DcSVvhUd.js} +1 -1
  5. package/dist/dashboard/client/assets/{architectureDiagram-VXUJARFQ-DfMlzFJX.js → architectureDiagram-VXUJARFQ-BNUlmSCS.js} +1 -1
  6. package/dist/dashboard/client/assets/{blockDiagram-VD42YOAC-bSpnd26J.js → blockDiagram-VD42YOAC-BmhiQVwa.js} +1 -1
  7. package/dist/dashboard/client/assets/{c4Diagram-YG6GDRKO-DPYmVhCZ.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-CI9zC4lV.js → chunk-4BX2VUAB-x1dQU_s3.js} +1 -1
  10. package/dist/dashboard/client/assets/{chunk-55IACEB6-BqUdJdx5.js → chunk-55IACEB6-CwbsE2XQ.js} +1 -1
  11. package/dist/dashboard/client/assets/{chunk-B4BG7PRW-DymQrTp-.js → chunk-B4BG7PRW-BaE7c-ti.js} +1 -1
  12. package/dist/dashboard/client/assets/{chunk-DI55MBZ5-lZ_9LKGJ.js → chunk-DI55MBZ5-Bw5PUaMK.js} +1 -1
  13. package/dist/dashboard/client/assets/{chunk-FMBD7UC4-DC5rgLNm.js → chunk-FMBD7UC4-B7cF6P3s.js} +1 -1
  14. package/dist/dashboard/client/assets/{chunk-QN33PNHL-BrygpHrX.js → chunk-QN33PNHL-OY4evNHd.js} +1 -1
  15. package/dist/dashboard/client/assets/{chunk-QZHKN3VN-CWJqBdNg.js → chunk-QZHKN3VN-BpjQwIWz.js} +1 -1
  16. package/dist/dashboard/client/assets/{chunk-TZMSLE5B-BACgM5pG.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-BYvGIfo0.js → cose-bilkent-S5V4N54A-C-sfP8PN.js} +1 -1
  21. package/dist/dashboard/client/assets/{dagre-6UL2VRFP-B1rZyiLJ.js → dagre-6UL2VRFP-Cqfo0NRg.js} +1 -1
  22. package/dist/dashboard/client/assets/{diagram-PSM6KHXK-Dvl5dQMd.js → diagram-PSM6KHXK-BR3ppxqI.js} +1 -1
  23. package/dist/dashboard/client/assets/{diagram-QEK2KX5R-Cmntmhht.js → diagram-QEK2KX5R-Dvcx6x3R.js} +1 -1
  24. package/dist/dashboard/client/assets/{diagram-S2PKOQOG-BqZcpG85.js → diagram-S2PKOQOG-DoyBLnVN.js} +1 -1
  25. package/dist/dashboard/client/assets/{erDiagram-Q2GNP2WA-Cw7BALso.js → erDiagram-Q2GNP2WA-hy77l1cL.js} +1 -1
  26. package/dist/dashboard/client/assets/{flowDiagram-NV44I4VS-B_amTHzQ.js → flowDiagram-NV44I4VS-Bz0B1rKM.js} +1 -1
  27. package/dist/dashboard/client/assets/{ganttDiagram-JELNMOA3-B1j2-sTo.js → ganttDiagram-JELNMOA3-CLgrZPoC.js} +1 -1
  28. package/dist/dashboard/client/assets/{gitGraphDiagram-V2S2FVAM-D5BkfAMt.js → gitGraphDiagram-V2S2FVAM-DwJ-1f-v.js} +1 -1
  29. package/dist/dashboard/client/assets/{graph-B_v15DHv.js → graph-DDBMM_t2.js} +1 -1
  30. package/dist/dashboard/client/assets/index-Cr9yEo_B.js +576 -0
  31. package/dist/dashboard/client/assets/index-Z1pPudAt.css +1 -0
  32. package/dist/dashboard/client/assets/{infoDiagram-HS3SLOUP-C4dtIkj3.js → infoDiagram-HS3SLOUP-Bhn1FmAk.js} +1 -1
  33. package/dist/dashboard/client/assets/{journeyDiagram-XKPGCS4Q-hha4Am8v.js → journeyDiagram-XKPGCS4Q-CzGbjX1y.js} +1 -1
  34. package/dist/dashboard/client/assets/{kanban-definition-3W4ZIXB7-1EY8l7Ng.js → kanban-definition-3W4ZIXB7-Da77-WYk.js} +1 -1
  35. package/dist/dashboard/client/assets/{layout-7SmAbjFT.js → layout-CVwSB-GS.js} +1 -1
  36. package/dist/dashboard/client/assets/{linear-BfjSBezh.js → linear-CTRAc5Jn.js} +1 -1
  37. package/dist/dashboard/client/assets/{mermaid-renderer-PPIt-kY4.js → mermaid-renderer-Bjo170ax.js} +4 -4
  38. package/dist/dashboard/client/assets/{mindmap-definition-VGOIOE7T-BFpjN9LY.js → mindmap-definition-VGOIOE7T-B55C2odl.js} +1 -1
  39. package/dist/dashboard/client/assets/{pieDiagram-ADFJNKIX-GBbQtDBQ.js → pieDiagram-ADFJNKIX-5lrQLrSz.js} +1 -1
  40. package/dist/dashboard/client/assets/{quadrantDiagram-AYHSOK5B-Dm0vOhOw.js → quadrantDiagram-AYHSOK5B-Bg55gC30.js} +1 -1
  41. package/dist/dashboard/client/assets/{requirementDiagram-UZGBJVZJ-BrKONIV8.js → requirementDiagram-UZGBJVZJ-CyR4YFJY.js} +1 -1
  42. package/dist/dashboard/client/assets/{sankeyDiagram-TZEHDZUN-IOobtmDc.js → sankeyDiagram-TZEHDZUN-BVWKr9_-.js} +1 -1
  43. package/dist/dashboard/client/assets/{sequenceDiagram-WL72ISMW-Dnb0bOW5.js → sequenceDiagram-WL72ISMW-D0AJg_tE.js} +1 -1
  44. package/dist/dashboard/client/assets/{stateDiagram-FKZM4ZOC-C9-bf7bn.js → stateDiagram-FKZM4ZOC-BuHpTgim.js} +1 -1
  45. package/dist/dashboard/client/assets/stateDiagram-v2-4FDKWEC3-DwAPhteN.js +1 -0
  46. package/dist/dashboard/client/assets/{timeline-definition-IT6M3QCI-tJogDEHB.js → timeline-definition-IT6M3QCI-LDhpAmDd.js} +1 -1
  47. package/dist/dashboard/client/assets/{treemap-GDKQZRPO-DQY6HADq.js → treemap-GDKQZRPO-Dd4gjvUl.js} +1 -1
  48. package/dist/dashboard/client/assets/{xychartDiagram-PRI3JC2R-DfxeQmTO.js → xychartDiagram-PRI3JC2R-B9RDod39.js} +1 -1
  49. package/dist/dashboard/client/index.html +2 -2
  50. package/dist/dashboard/server.js +10297 -878
  51. package/dist/index.js +10441 -913
  52. package/dist/lib/db/index.js +984 -58
  53. package/dist/lib/models.js +85 -0
  54. package/dist/lib/runtime-config.js +39 -0
  55. package/dist/lib/team-config.js +175 -0
  56. package/dist/lib/vendor-resume.js +31 -0
  57. package/package.json +29 -4
  58. package/dist/dashboard/client/assets/channel-C--wY_Wd.js +0 -1
  59. package/dist/dashboard/client/assets/classDiagram-2ON5EDUG-DoxmMlnf.js +0 -1
  60. package/dist/dashboard/client/assets/classDiagram-v2-WZHVMYZB-DoxmMlnf.js +0 -1
  61. package/dist/dashboard/client/assets/clone-BgvweD4v.js +0 -1
  62. package/dist/dashboard/client/assets/index-UkJZZdYD.js +0 -548
  63. package/dist/dashboard/client/assets/index-Zl---B_3.css +0 -1
  64. package/dist/dashboard/client/assets/stateDiagram-v2-4FDKWEC3-C8Gr4khP.js +0 -1
  65. package/dist/package.json +0 -61
@@ -1,10 +1,88 @@
1
- import { createRequire as _cjsReq } from "module"; const require = _cjsReq(import.meta.url);
2
-
3
1
  // src/lib/db/index.ts
4
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
5
- import { dirname as dirname2, join as join2 } from "node:path";
6
- import { createRequire } from "node:module";
7
- import initSqlJs from "sql.js";
2
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, copyFileSync, statSync } from "node:fs";
3
+ import { dirname as dirname3, join as join3 } from "node:path";
4
+
5
+ // src/lib/db/engine.ts
6
+ import BetterSqlite3 from "better-sqlite3";
7
+ var BUSY_RETRY_ATTEMPTS = 5;
8
+ var BUSY_RETRY_BACKOFF_MS = 50;
9
+ function isBusyError(e) {
10
+ if (e instanceof BetterSqlite3.SqliteError) {
11
+ return e.code === "SQLITE_BUSY" || e.code === "SQLITE_BUSY_SNAPSHOT";
12
+ }
13
+ const code = e?.code;
14
+ return code === "SQLITE_BUSY" || code === "SQLITE_BUSY_SNAPSHOT";
15
+ }
16
+ function sleepSync(ms) {
17
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
18
+ }
19
+ var BetterSqliteAdapter = class {
20
+ raw;
21
+ constructor(db) {
22
+ this.raw = db;
23
+ }
24
+ exec(sql, params) {
25
+ const stmt = this.raw.prepare(sql);
26
+ if (!stmt.reader) {
27
+ stmt.run(...params ?? []);
28
+ return [];
29
+ }
30
+ const columns = stmt.columns().map((c) => c.name);
31
+ const values = stmt.raw().all(...params ?? []);
32
+ return values.length > 0 ? [{ columns, values }] : [];
33
+ }
34
+ run(sql, params) {
35
+ if (params !== void 0) {
36
+ this.raw.prepare(sql).run(...params);
37
+ return;
38
+ }
39
+ this.raw.exec(sql);
40
+ }
41
+ prepare(sql) {
42
+ return this.raw.prepare(sql);
43
+ }
44
+ transaction(fn) {
45
+ const tx = this.raw.transaction(fn);
46
+ for (let attempt = 0; ; attempt++) {
47
+ try {
48
+ return tx.immediate();
49
+ } catch (e) {
50
+ if (!isBusyError(e) || attempt >= BUSY_RETRY_ATTEMPTS - 1) throw e;
51
+ sleepSync(BUSY_RETRY_BACKOFF_MS);
52
+ }
53
+ }
54
+ }
55
+ pragma(source) {
56
+ return this.raw.pragma(source);
57
+ }
58
+ close() {
59
+ try {
60
+ this.raw.pragma("wal_checkpoint(TRUNCATE)");
61
+ } catch {
62
+ }
63
+ this.raw.close();
64
+ }
65
+ };
66
+ function probeEngine() {
67
+ try {
68
+ const db = new BetterSqlite3(":memory:");
69
+ db.pragma("journal_mode = WAL");
70
+ db.exec("CREATE TABLE _probe(x); INSERT INTO _probe VALUES (1);");
71
+ const row = db.prepare("SELECT sqlite_version() AS v").get();
72
+ db.close();
73
+ return { ok: true, version: row.v };
74
+ } catch (e) {
75
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
76
+ }
77
+ }
78
+ function openEngine(dbPath) {
79
+ const native = new BetterSqlite3(dbPath);
80
+ native.pragma("journal_mode = WAL");
81
+ native.pragma("foreign_keys = ON");
82
+ native.pragma("busy_timeout = 5000");
83
+ native.pragma("synchronous = NORMAL");
84
+ return new BetterSqliteAdapter(native);
85
+ }
8
86
 
9
87
  // src/lib/db/migrations.ts
10
88
  var MIGRATIONS = [
@@ -261,8 +339,224 @@ var MIGRATIONS = [
261
339
  ALTER TABLE command_executions ADD COLUMN uid TEXT;
262
340
  CREATE UNIQUE INDEX idx_command_executions_uid ON command_executions(uid);
263
341
  `
342
+ },
343
+ {
344
+ version: 10,
345
+ description: "Add agent_sessions journal for per-instance lifecycle tracking",
346
+ sql: `
347
+ CREATE TABLE agent_sessions (
348
+ id TEXT PRIMARY KEY,
349
+ workflow_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE RESTRICT,
350
+ vendor TEXT NOT NULL,
351
+ vendor_session_id TEXT,
352
+ persona TEXT,
353
+ instance_index INTEGER,
354
+ name TEXT,
355
+ resolved_model TEXT,
356
+ phase TEXT,
357
+ status TEXT NOT NULL CHECK(status IN ('spawning', 'running', 'done', 'crashed', 'cancelled', 'orphaned')),
358
+ pid INTEGER,
359
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
360
+ last_heartbeat_at TEXT NOT NULL DEFAULT (datetime('now')),
361
+ ended_at TEXT,
362
+ exit_code INTEGER,
363
+ notes TEXT
364
+ );
365
+ CREATE INDEX idx_agent_sessions_workflow ON agent_sessions(workflow_id);
366
+ CREATE INDEX idx_agent_sessions_status_heartbeat ON agent_sessions(status, last_heartbeat_at);
367
+ `
368
+ },
369
+ {
370
+ version: 11,
371
+ description: "Unify agent_sessions into command_executions \u2014 every spawned process is one execution row",
372
+ sql: `
373
+ -- Extend command_executions with the journaling fields previously on agent_sessions.
374
+ -- A NULL workflow_id is allowed because some commands (e.g. sync-reviewers,
375
+ -- create-reviewer) don't tie to a review workflow. Existing rows get NULL by default.
376
+ ALTER TABLE command_executions ADD COLUMN workflow_id TEXT REFERENCES sessions(id) ON DELETE RESTRICT;
377
+ -- parent_id = the dashboard-spawn that's the "Tech Lead" parent of an AI-spawned
378
+ -- session-instance row. NULL for top-level dashboard spawns.
379
+ ALTER TABLE command_executions ADD COLUMN parent_id INTEGER REFERENCES command_executions(id);
380
+ -- Vendor metadata (claude | opencode | gemini | \u2026). NULL for non-AI commands.
381
+ ALTER TABLE command_executions ADD COLUMN vendor TEXT;
382
+ -- The underlying CLI's own session id, captured from stream events.
383
+ -- Used for resume / handoff. Hidden from users (ocr exposes its own id only).
384
+ ALTER TABLE command_executions ADD COLUMN vendor_session_id TEXT;
385
+ -- Persona/instance metadata for AI sub-agents (set when the AI calls
386
+ -- ocr session start-instance). NULL for the parent dashboard spawn.
387
+ ALTER TABLE command_executions ADD COLUMN persona TEXT;
388
+ ALTER TABLE command_executions ADD COLUMN instance_index INTEGER;
389
+ ALTER TABLE command_executions ADD COLUMN name TEXT;
390
+ -- Resolved model string passed to --model post-alias-expansion.
391
+ ALTER TABLE command_executions ADD COLUMN resolved_model TEXT;
392
+ -- Liveness heartbeat. Bumped on every state event the AI emits.
393
+ -- Stale rows past the threshold are reclassified to orphaned (exit_code=-3).
394
+ ALTER TABLE command_executions ADD COLUMN last_heartbeat_at TEXT;
395
+ -- Free-form annotations (sweep notes, host-CLI capability warnings, etc).
396
+ ALTER TABLE command_executions ADD COLUMN notes TEXT;
397
+ CREATE INDEX idx_command_executions_workflow ON command_executions(workflow_id);
398
+ CREATE INDEX idx_command_executions_parent ON command_executions(parent_id);
399
+ CREATE INDEX idx_command_executions_heartbeat ON command_executions(last_heartbeat_at);
400
+
401
+ -- The agent_sessions table is retired. Phase 1 was a parallel journal that
402
+ -- this migration consolidates. We drop the table outright \u2014 the only existing
403
+ -- consumers are the cli helpers and tests, which are updated alongside this
404
+ -- migration. No production deployments have agent_sessions data worth migrating.
405
+ DROP INDEX IF EXISTS idx_agent_sessions_workflow;
406
+ DROP INDEX IF EXISTS idx_agent_sessions_status_heartbeat;
407
+ DROP TABLE IF EXISTS agent_sessions;
408
+ `
409
+ },
410
+ {
411
+ version: 12,
412
+ description: "Event-sourced lifecycle hardening: event_type taxonomy guard, sweep indexes, session_completeness view",
413
+ sql: `
414
+ -- \u2500\u2500 Indexes for the now-periodic stale-session sweep + round derivation \u2500\u2500
415
+ -- The sweep filters sessions by status and rolls up MAX(created_at) per
416
+ -- session over the event log; deriveNextRound does MAX(round). Index both.
417
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
418
+ CREATE INDEX IF NOT EXISTS idx_events_session_created
419
+ ON orchestration_events(session_id, created_at);
420
+
421
+ -- \u2500\u2500 Event-type taxonomy guard \u2500\u2500
422
+ -- orchestration_events.event_type is the spine of all lifecycle
423
+ -- derivation. A typo (e.g. 'round_complete' vs 'round_completed') would
424
+ -- silently break deriveNextRound and the completeness view. SQLite cannot
425
+ -- add a CHECK to an existing column without a table rebuild, so enforce
426
+ -- the closed vocabulary with a BEFORE INSERT trigger instead.
427
+ CREATE TRIGGER IF NOT EXISTS trg_events_known_type
428
+ BEFORE INSERT ON orchestration_events
429
+ WHEN NEW.event_type NOT IN (
430
+ 'session_created', 'session_resumed', 'round_started', 'phase_transition',
431
+ 'round_completed', 'map_completed', 'session_closed', 'session_aborted',
432
+ 'session_auto_closed_stale', 'session_synced', 'session_legacy_import'
433
+ )
434
+ BEGIN
435
+ SELECT RAISE(ABORT, 'unknown orchestration_events.event_type');
436
+ END;
437
+
438
+ -- \u2500\u2500 Close-guard (DB backstop for the completion invariant) \u2500\u2500
439
+ -- A session cannot transition active \u2192 closed unless its current
440
+ -- round/run has a terminal artifact event, OR an explicit reason event
441
+ -- (abort / auto-close-stale / sync / legacy-import) is present. Only a
442
+ -- *silent* premature close is banned \u2014 every legitimate non-artifact
443
+ -- close carries a reason event and passes. App-level guards in
444
+ -- stateClose/finish are the primary check; this makes the illegal state
445
+ -- unrepresentable even via raw SQL.
446
+ --
447
+ -- DEFENCE-IN-DEPTH NOTE (intentional, documented gap): the reason-event
448
+ -- branch below (event_type IN (...)) is NOT round-scoped \u2014 a reason event
449
+ -- recorded for an earlier round would also satisfy a later close. The
450
+ -- app-level guards ARE round-scoped (hasCompletionInvariant checks the
451
+ -- current round/run), so the precise check lives in the application; this
452
+ -- trigger is a coarse backstop against a *silent* premature close via raw
453
+ -- SQL. Tightening it to be round-scoped would require a new migration
454
+ -- (this v12 trigger is append-only and already shipped); the residual
455
+ -- risk is a non-artifact close carrying a stale reason event, which is
456
+ -- still an explicit, audited terminal \u2014 not the failure mode this guards.
457
+ CREATE TRIGGER IF NOT EXISTS trg_sessions_close_guard
458
+ BEFORE UPDATE OF status ON sessions
459
+ WHEN NEW.status = 'closed' AND OLD.status <> 'closed'
460
+ AND NOT EXISTS (
461
+ SELECT 1 FROM orchestration_events e
462
+ WHERE e.session_id = NEW.id
463
+ AND (
464
+ (NEW.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = NEW.current_round)
465
+ OR (NEW.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = NEW.current_map_run)
466
+ OR e.event_type IN ('session_aborted','session_auto_closed_stale','session_synced','session_legacy_import')
467
+ )
468
+ )
469
+ BEGIN
470
+ SELECT RAISE(ABORT, 'cannot close session without a completed round/run or an explicit reason event');
471
+ END;
472
+
473
+ -- \u2500\u2500 session_completeness view \u2500\u2500
474
+ -- The published contract for "is this session actually complete, and if
475
+ -- not, what's missing". Completion is DERIVED from the event log, never a
476
+ -- mutable flag: a session is complete iff it is closed AND a terminal
477
+ -- artifact event exists for its current round/run. The dashboard's
478
+ -- outcome derivation and the agent 'status' command read this view, so
479
+ -- they cannot disagree.
480
+ --
481
+ -- completeness_state is an INTENTIONAL HYBRID: it combines the mutable
482
+ -- status column (marked_closed) with append-only event evidence (the
483
+ -- terminal artifact event). This is sound precisely because the
484
+ -- close-guard trigger above makes the status column trustworthy \u2014 a row
485
+ -- can only reach status='closed' with a completed round/run or an
486
+ -- explicit reason event \u2014 so reading the column is not a regression to
487
+ -- the old "mutable flag that could lie" model.
488
+ --
489
+ -- completeness_state:
490
+ -- 'complete' \u2014 closed + terminal artifact for current round/run
491
+ -- 'closed_without_artifact' \u2014 closed but no terminal artifact (the
492
+ -- "completed too soon" condition)
493
+ -- 'in_flight' \u2014 open with a dependent process still running
494
+ -- 'open_no_artifact' \u2014 open, no in-flight dependents
495
+ CREATE VIEW IF NOT EXISTS session_completeness AS
496
+ SELECT
497
+ s.id AS session_id,
498
+ s.workflow_type AS workflow_type,
499
+ s.status AS status,
500
+ s.current_round AS current_round,
501
+ s.current_map_run AS current_map_run,
502
+ CASE WHEN EXISTS (
503
+ SELECT 1 FROM orchestration_events e
504
+ WHERE e.session_id = s.id
505
+ AND (
506
+ (s.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = s.current_round)
507
+ OR (s.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = s.current_map_run)
508
+ )
509
+ ) THEN 1 ELSE 0 END AS has_terminal_artifact,
510
+ CASE WHEN s.status = 'closed' THEN 1 ELSE 0 END AS marked_closed,
511
+ CASE WHEN NOT EXISTS (
512
+ SELECT 1 FROM command_executions ce
513
+ WHERE ce.workflow_id = s.id AND ce.finished_at IS NULL
514
+ ) THEN 1 ELSE 0 END AS dependents_settled,
515
+ CASE
516
+ WHEN s.status = 'closed' AND EXISTS (
517
+ SELECT 1 FROM orchestration_events e
518
+ WHERE e.session_id = s.id
519
+ AND (
520
+ (s.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = s.current_round)
521
+ OR (s.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = s.current_map_run)
522
+ )
523
+ ) THEN 'complete'
524
+ WHEN s.status = 'closed' THEN 'closed_without_artifact'
525
+ WHEN EXISTS (
526
+ SELECT 1 FROM command_executions ce
527
+ WHERE ce.workflow_id = s.id AND ce.finished_at IS NULL
528
+ ) THEN 'in_flight'
529
+ ELSE 'open_no_artifact'
530
+ END AS completeness_state
531
+ FROM sessions s;
532
+ `
533
+ },
534
+ {
535
+ version: 13,
536
+ description: "Retire dead parent_id column on command_executions (never written; row kind is derived from command)",
537
+ // parent_id was reserved for an AI-instance → dashboard-spawn lineage link
538
+ // that was never wired (no writer, no reader). A process's KIND (supervisor
539
+ // / reviewer-instance / utility) is derived from columns that are always
540
+ // present (command + last_heartbeat_at), so the dead lineage column and its
541
+ // all-NULL index are removed. Re-add a wired parent_id alongside a real
542
+ // consumer (e.g. a parent→child tree view) if lineage is ever needed.
543
+ //
544
+ // Imperative + guarded so the DROP COLUMN (which SQLite can't express as
545
+ // IF EXISTS) is idempotent under re-application.
546
+ run: (db) => {
547
+ if (!columnExists(db, "command_executions", "parent_id")) return;
548
+ db.run("DROP INDEX IF EXISTS idx_command_executions_parent;");
549
+ db.run("ALTER TABLE command_executions DROP COLUMN parent_id;");
550
+ }
264
551
  }
265
552
  ];
553
+ function columnExists(db, table, column) {
554
+ const result = db.exec(`PRAGMA table_info(${table})`);
555
+ const first = result[0];
556
+ if (!first) return false;
557
+ const nameIdx = first.columns.indexOf("name");
558
+ return first.values.some((row) => row[nameIdx] === column);
559
+ }
266
560
  function ensureSchemaVersionTable(db) {
267
561
  db.run(`
268
562
  CREATE TABLE IF NOT EXISTS schema_version (
@@ -272,6 +566,10 @@ function ensureSchemaVersionTable(db) {
272
566
  );
273
567
  `);
274
568
  }
569
+ function getSchemaVersion(db) {
570
+ ensureSchemaVersionTable(db);
571
+ return getCurrentVersion(db);
572
+ }
275
573
  function getCurrentVersion(db) {
276
574
  const result = db.exec(
277
575
  "SELECT MAX(version) as v FROM schema_version"
@@ -289,9 +587,10 @@ function runMigrations(db) {
289
587
  if (migration.version <= currentVersion) {
290
588
  continue;
291
589
  }
292
- db.run("BEGIN TRANSACTION;");
590
+ db.run("BEGIN IMMEDIATE;");
293
591
  try {
294
- db.run(migration.sql);
592
+ if (migration.sql) db.run(migration.sql);
593
+ migration.run?.(db);
295
594
  db.run(
296
595
  "INSERT INTO schema_version (version, description) VALUES (?, ?);",
297
596
  [migration.version, migration.description]
@@ -304,6 +603,10 @@ function runMigrations(db) {
304
603
  }
305
604
  }
306
605
 
606
+ // src/lib/db/reconcile.ts
607
+ import { existsSync } from "node:fs";
608
+ import { isAbsolute, join, dirname } from "node:path";
609
+
307
610
  // src/lib/db/result-mapper.ts
308
611
  function resultToRows(result) {
309
612
  if (result.length === 0 || !result[0]) {
@@ -431,10 +734,553 @@ function getLatestEventId(db) {
431
734
  const val = result[0]?.values[0]?.[0];
432
735
  return typeof val === "number" ? val : 0;
433
736
  }
737
+ function commitReasonClose(db, sessionId, reasonEvent, projectionUpdates) {
738
+ db.transaction(() => {
739
+ insertEvent(db, { session_id: sessionId, ...reasonEvent });
740
+ updateSession(db, sessionId, projectionUpdates);
741
+ });
742
+ }
743
+
744
+ // src/lib/db/reconcile.ts
745
+ var DEFAULT_STALE_THRESHOLD_SECONDS = 7 * 24 * 60 * 60;
746
+ function hasTerminalArtifactEvent(db, sessionId, workflowType, currentRound, currentMapRun) {
747
+ const eventType = workflowType === "map" ? "map_completed" : "round_completed";
748
+ const round = workflowType === "map" ? currentMapRun : currentRound;
749
+ const r = db.exec(
750
+ `SELECT 1 FROM orchestration_events
751
+ WHERE session_id = ? AND event_type = ? AND round = ? LIMIT 1`,
752
+ [sessionId, eventType, round]
753
+ );
754
+ return (r[0]?.values.length ?? 0) > 0;
755
+ }
756
+ function hasReasonEvent(db, sessionId) {
757
+ const r = db.exec(
758
+ `SELECT 1 FROM orchestration_events
759
+ WHERE session_id = ?
760
+ AND event_type IN ('session_aborted','session_auto_closed_stale','session_synced','session_legacy_import')
761
+ LIMIT 1`,
762
+ [sessionId]
763
+ );
764
+ return (r[0]?.values.length ?? 0) > 0;
765
+ }
766
+ function lastEventAgeSeconds(db, sessionId) {
767
+ const r = db.exec(
768
+ `SELECT (julianday('now') - julianday(MAX(created_at))) * 86400
769
+ FROM orchestration_events WHERE session_id = ?`,
770
+ [sessionId]
771
+ );
772
+ const v = r[0]?.values[0]?.[0];
773
+ return typeof v === "number" ? v : null;
774
+ }
775
+ function hasInFlightDependents(db, sessionId) {
776
+ const r = db.exec(
777
+ `SELECT 1 FROM command_executions
778
+ WHERE workflow_id = ? AND finished_at IS NULL LIMIT 1`,
779
+ [sessionId]
780
+ );
781
+ return (r[0]?.values.length ?? 0) > 0;
782
+ }
783
+ function resolveSessionDir(ocrDir, sessionDir) {
784
+ if (!sessionDir) return null;
785
+ if (isAbsolute(sessionDir)) return sessionDir;
786
+ return join(dirname(ocrDir), sessionDir);
787
+ }
788
+ function reconcileLegacyState(db, ocrDir, opts = {}) {
789
+ const dryRun = opts.dryRun ?? false;
790
+ const threshold = opts.staleThresholdSeconds ?? DEFAULT_STALE_THRESHOLD_SECONDS;
791
+ const actions = [];
792
+ for (const s of getAllSessions(db)) {
793
+ const dir = resolveSessionDir(ocrDir, s.session_dir);
794
+ if (s.status === "closed") {
795
+ if (hasTerminalArtifactEvent(db, s.id, s.workflow_type, s.current_round, s.current_map_run) || hasReasonEvent(db, s.id)) {
796
+ continue;
797
+ }
798
+ const reviewFinal = s.workflow_type === "review" && dir ? existsSync(join(dir, "rounds", `round-${s.current_round}`, "final.md")) : false;
799
+ const mapFinal = s.workflow_type === "map" && dir ? existsSync(join(dir, "map", "runs", `run-${s.current_map_run}`, "map.md")) : false;
800
+ if (reviewFinal) {
801
+ actions.push({
802
+ sessionId: s.id,
803
+ kind: "synthesize-round-completed",
804
+ detail: `final.md present for round ${s.current_round}; synthesizing round_completed`
805
+ });
806
+ if (!dryRun) {
807
+ insertEvent(db, {
808
+ session_id: s.id,
809
+ event_type: "round_completed",
810
+ phase: "synthesis",
811
+ phase_number: 7,
812
+ round: s.current_round,
813
+ metadata: JSON.stringify({ source: "reconciled", synthesized_from: "final.md" })
814
+ });
815
+ }
816
+ } else if (mapFinal) {
817
+ actions.push({
818
+ sessionId: s.id,
819
+ kind: "synthesize-map-completed",
820
+ detail: `map.md present for run ${s.current_map_run}; synthesizing map_completed`
821
+ });
822
+ if (!dryRun) {
823
+ insertEvent(db, {
824
+ session_id: s.id,
825
+ event_type: "map_completed",
826
+ phase: "synthesis",
827
+ phase_number: 5,
828
+ round: s.current_map_run,
829
+ metadata: JSON.stringify({ source: "reconciled", synthesized_from: "map.md" })
830
+ });
831
+ }
832
+ } else {
833
+ actions.push({
834
+ sessionId: s.id,
835
+ kind: "grandfather",
836
+ detail: "no provable artifact; recording session_legacy_import"
837
+ });
838
+ if (!dryRun) {
839
+ insertEvent(db, {
840
+ session_id: s.id,
841
+ event_type: "session_legacy_import",
842
+ phase: "complete",
843
+ metadata: JSON.stringify({ source: "reconciled" })
844
+ });
845
+ }
846
+ }
847
+ continue;
848
+ }
849
+ const age = lastEventAgeSeconds(db, s.id);
850
+ const stale = (age === null || age > threshold) && !hasInFlightDependents(db, s.id);
851
+ if (stale) {
852
+ actions.push({
853
+ sessionId: s.id,
854
+ kind: "stale-close",
855
+ 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`
856
+ });
857
+ if (!dryRun) {
858
+ commitReasonClose(
859
+ db,
860
+ s.id,
861
+ {
862
+ event_type: "session_auto_closed_stale",
863
+ phase: "complete",
864
+ metadata: JSON.stringify({ source: "reconciled", threshold_seconds: threshold })
865
+ },
866
+ { status: "closed", current_phase: "complete" }
867
+ );
868
+ }
869
+ }
870
+ }
871
+ return { dryRun, actions };
872
+ }
873
+
874
+ // src/lib/db/liveness.ts
875
+ var PID_REUSE_GUARD_MS = 24 * 60 * 60 * 1e3;
876
+ function defaultIsAlive(pid) {
877
+ try {
878
+ process.kill(pid, 0);
879
+ return true;
880
+ } catch (err) {
881
+ return !(err instanceof Error && "code" in err && err.code === "ESRCH");
882
+ }
883
+ }
884
+ function sqliteUtcMs(ts) {
885
+ const sqliteShape = ts.includes(" ");
886
+ return new Date(sqliteShape ? ts.replace(" ", "T") + "Z" : ts).getTime();
887
+ }
888
+
889
+ // src/lib/state/exit-codes.ts
890
+ var STATE_EXIT = {
891
+ OK: 0,
892
+ USAGE: 2,
893
+ AMBIGUOUS: 3,
894
+ NOT_FOUND: 4,
895
+ ILLEGAL_TRANSITION: 5,
896
+ INVARIANT_UNMET: 6,
897
+ SCHEMA_INVALID: 7,
898
+ /** Database was locked past the bounded retry budget (SQLITE_BUSY). */
899
+ BUSY: 8
900
+ };
901
+ var StateError = class extends Error {
902
+ constructor(code, message) {
903
+ super(message);
904
+ this.code = code;
905
+ this.name = "StateError";
906
+ }
907
+ };
908
+ var CANCELLED_EXIT_CODE = -2;
909
+ var ORPHAN_EXIT_CODE = -3;
910
+ var CASCADE_CLOSE_EXIT_CODE = -4;
911
+
912
+ // src/lib/db/agent-sessions.ts
913
+ var NOTE_ORPHAN_PREFIX = "orphaned by liveness sweep";
914
+ var INSTANCE_COMMAND = "session-instance";
915
+ function cascadeTerminateExecutions(db, workflowId, exitCode, note) {
916
+ db.run(
917
+ `UPDATE command_executions
918
+ SET finished_at = datetime('now'),
919
+ exit_code = ?,
920
+ pid = NULL,
921
+ notes = COALESCE(notes || char(10), '') || ?
922
+ WHERE workflow_id = ?
923
+ AND finished_at IS NULL`,
924
+ [exitCode, note, workflowId]
925
+ );
926
+ }
927
+ function rowToAgentSession(row) {
928
+ return {
929
+ // The OCR-owned id is the `uid` column. Fall back to the integer
930
+ // primary key for legacy command_executions rows without a uid.
931
+ id: row.uid ?? String(row.id),
932
+ workflow_id: row.workflow_id ?? "",
933
+ vendor: row.vendor ?? "",
934
+ vendor_session_id: row.vendor_session_id,
935
+ persona: row.persona,
936
+ instance_index: row.instance_index,
937
+ name: row.name,
938
+ resolved_model: row.resolved_model,
939
+ phase: null,
940
+ status: deriveStatus(row),
941
+ kind: rowKind(row),
942
+ pid: row.pid,
943
+ started_at: row.started_at,
944
+ last_heartbeat_at: row.last_heartbeat_at ?? row.started_at,
945
+ ended_at: row.finished_at,
946
+ exit_code: row.exit_code,
947
+ notes: row.notes
948
+ };
949
+ }
950
+ function deriveStatus(row) {
951
+ if (row.finished_at === null) {
952
+ return "running";
953
+ }
954
+ if (row.exit_code === ORPHAN_EXIT_CODE) return "orphaned";
955
+ if (row.exit_code === CANCELLED_EXIT_CODE || row.exit_code === CASCADE_CLOSE_EXIT_CODE) {
956
+ return "cancelled";
957
+ }
958
+ if (row.exit_code === 0) return "done";
959
+ return "crashed";
960
+ }
961
+ function insertAgentSession(db, params) {
962
+ const {
963
+ id,
964
+ workflow_id,
965
+ vendor,
966
+ persona = null,
967
+ instance_index = null,
968
+ name = null,
969
+ resolved_model = null,
970
+ pid = null,
971
+ notes = null
972
+ } = params;
973
+ const command = persona && instance_index !== null ? `${INSTANCE_COMMAND}:${persona}-${instance_index}` : INSTANCE_COMMAND;
974
+ db.run(
975
+ `INSERT INTO command_executions
976
+ (uid, command, args, workflow_id, vendor, persona, instance_index, name,
977
+ resolved_model, pid, notes, last_heartbeat_at)
978
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
979
+ [
980
+ id,
981
+ command,
982
+ null,
983
+ workflow_id,
984
+ vendor,
985
+ persona,
986
+ instance_index,
987
+ name,
988
+ resolved_model,
989
+ pid,
990
+ notes
991
+ ]
992
+ );
993
+ }
994
+ function getAgentSession(db, id) {
995
+ const row = resultToRow(
996
+ db.exec(
997
+ `SELECT * FROM command_executions WHERE uid = ? AND last_heartbeat_at IS NOT NULL`,
998
+ [id]
999
+ )
1000
+ );
1001
+ return row ? rowToAgentSession(row) : void 0;
1002
+ }
1003
+ function listAgentSessionsForWorkflow(db, workflowId) {
1004
+ const rows = resultToRows(
1005
+ db.exec(
1006
+ `SELECT * FROM command_executions
1007
+ WHERE workflow_id = ? AND last_heartbeat_at IS NOT NULL
1008
+ ORDER BY started_at ASC, id ASC`,
1009
+ [workflowId]
1010
+ )
1011
+ );
1012
+ return rows.map(rowToAgentSession);
1013
+ }
1014
+ function getLatestAgentSessionWithVendorId(db, workflowId) {
1015
+ const row = resultToRow(
1016
+ db.exec(
1017
+ `SELECT * FROM command_executions
1018
+ WHERE workflow_id = ? AND vendor_session_id IS NOT NULL
1019
+ ORDER BY started_at DESC, id DESC
1020
+ LIMIT 1`,
1021
+ [workflowId]
1022
+ )
1023
+ );
1024
+ return row ? rowToAgentSession(row) : void 0;
1025
+ }
1026
+ function bumpAgentSessionHeartbeat(db, id) {
1027
+ db.run(
1028
+ `UPDATE command_executions
1029
+ SET last_heartbeat_at = datetime('now')
1030
+ WHERE uid = ?`,
1031
+ [id]
1032
+ );
1033
+ }
1034
+ function setAgentSessionVendorId(db, id, vendorSessionId) {
1035
+ const existing = getAgentSession(db, id);
1036
+ if (!existing) {
1037
+ throw new Error(`Agent session not found: ${id}`);
1038
+ }
1039
+ if (existing.vendor_session_id && existing.vendor_session_id !== vendorSessionId) {
1040
+ throw new Error(
1041
+ `Agent session ${id} already bound to vendor session ${existing.vendor_session_id}; refusing to rebind to ${vendorSessionId}`
1042
+ );
1043
+ }
1044
+ db.run(
1045
+ `UPDATE command_executions
1046
+ SET vendor_session_id = ?,
1047
+ last_heartbeat_at = datetime('now')
1048
+ WHERE uid = ?`,
1049
+ [vendorSessionId, id]
1050
+ );
1051
+ }
1052
+ function bindVendorSessionIdOpportunistically(db, vendorSessionId) {
1053
+ const alreadyBound = resultToRow(
1054
+ db.exec(
1055
+ `SELECT c.uid FROM command_executions c
1056
+ INNER JOIN sessions s ON s.id = c.workflow_id
1057
+ WHERE c.vendor_session_id = ?
1058
+ LIMIT 1`,
1059
+ [vendorSessionId]
1060
+ )
1061
+ );
1062
+ if (alreadyBound?.uid) return alreadyBound.uid;
1063
+ const candidate = resultToRow(
1064
+ db.exec(
1065
+ `SELECT c.uid, c.id FROM command_executions c
1066
+ INNER JOIN sessions s ON s.id = c.workflow_id
1067
+ WHERE c.finished_at IS NULL
1068
+ AND c.vendor_session_id IS NULL
1069
+ AND c.last_heartbeat_at IS NOT NULL
1070
+ AND s.status = 'active'
1071
+ ORDER BY c.started_at DESC, c.id DESC
1072
+ LIMIT 1`
1073
+ )
1074
+ );
1075
+ if (!candidate) return null;
1076
+ db.run(
1077
+ `UPDATE command_executions
1078
+ SET vendor_session_id = ?,
1079
+ last_heartbeat_at = datetime('now')
1080
+ WHERE id = ?`,
1081
+ [vendorSessionId, candidate.id]
1082
+ );
1083
+ return candidate.uid ?? String(candidate.id);
1084
+ }
1085
+ function recordVendorSessionIdForExecution(db, executionId, vendorSessionId) {
1086
+ db.run(
1087
+ `UPDATE command_executions
1088
+ SET vendor_session_id = COALESCE(vendor_session_id, ?),
1089
+ last_heartbeat_at = datetime('now')
1090
+ WHERE id = ?`,
1091
+ [vendorSessionId, executionId]
1092
+ );
1093
+ }
1094
+ function linkDashboardInvocationToWorkflow(db, dashboardUid, workflowId) {
1095
+ db.run(
1096
+ `UPDATE command_executions
1097
+ SET workflow_id = COALESCE(workflow_id, ?),
1098
+ last_heartbeat_at = COALESCE(last_heartbeat_at, datetime('now'))
1099
+ WHERE uid = ?`,
1100
+ [workflowId, dashboardUid]
1101
+ );
1102
+ }
1103
+ function setAgentSessionStatus(db, id, status, options = {}) {
1104
+ const { exitCode, note, setEndedAt } = options;
1105
+ const isTerminal = status === "done" || status === "crashed" || status === "cancelled" || status === "orphaned";
1106
+ const stampEnded = setEndedAt ?? isTerminal;
1107
+ let resolvedExit;
1108
+ if (exitCode !== void 0) {
1109
+ resolvedExit = exitCode;
1110
+ } else if (status === "done") {
1111
+ resolvedExit = 0;
1112
+ } else if (status === "cancelled") {
1113
+ resolvedExit = CANCELLED_EXIT_CODE;
1114
+ } else if (status === "orphaned") {
1115
+ resolvedExit = ORPHAN_EXIT_CODE;
1116
+ } else if (status === "crashed") {
1117
+ resolvedExit = 1;
1118
+ } else {
1119
+ resolvedExit = null;
1120
+ }
1121
+ const finishedClause = stampEnded ? ", finished_at = datetime('now')" : "";
1122
+ if (note !== void 0) {
1123
+ db.run(
1124
+ `UPDATE command_executions
1125
+ SET exit_code = ?,
1126
+ notes = COALESCE(notes || char(10), '') || ?
1127
+ ${finishedClause}
1128
+ WHERE uid = ?`,
1129
+ [resolvedExit, note, id]
1130
+ );
1131
+ } else {
1132
+ db.run(
1133
+ `UPDATE command_executions
1134
+ SET exit_code = ?
1135
+ ${finishedClause}
1136
+ WHERE uid = ?`,
1137
+ [resolvedExit, id]
1138
+ );
1139
+ }
1140
+ }
1141
+ function updateAgentSession(db, id, params) {
1142
+ const setClauses = [];
1143
+ const values = [];
1144
+ if (params.vendor_session_id !== void 0) {
1145
+ setClauses.push("vendor_session_id = ?");
1146
+ values.push(params.vendor_session_id);
1147
+ }
1148
+ if (params.status !== void 0) {
1149
+ setAgentSessionStatus(db, id, params.status, {
1150
+ exitCode: params.exit_code ?? void 0,
1151
+ note: params.notes ?? void 0
1152
+ });
1153
+ return;
1154
+ }
1155
+ if (params.pid !== void 0) {
1156
+ setClauses.push("pid = ?");
1157
+ values.push(params.pid);
1158
+ }
1159
+ if (params.ended_at !== void 0) {
1160
+ setClauses.push("finished_at = ?");
1161
+ values.push(params.ended_at);
1162
+ }
1163
+ if (params.exit_code !== void 0) {
1164
+ setClauses.push("exit_code = ?");
1165
+ values.push(params.exit_code);
1166
+ }
1167
+ if (params.notes !== void 0) {
1168
+ setClauses.push("notes = ?");
1169
+ values.push(params.notes);
1170
+ }
1171
+ if (setClauses.length === 0) return;
1172
+ values.push(id);
1173
+ db.run(
1174
+ `UPDATE command_executions SET ${setClauses.join(", ")} WHERE uid = ?`,
1175
+ values
1176
+ );
1177
+ }
1178
+ function sweepStaleAgentSessions(db, thresholdSeconds, isAlive = defaultIsAlive) {
1179
+ const candidates = resultToRows(
1180
+ db.exec(
1181
+ `SELECT uid, id, pid, started_at, workflow_id, command, last_heartbeat_at
1182
+ FROM command_executions
1183
+ WHERE finished_at IS NULL
1184
+ AND pid IS NOT NULL
1185
+ AND last_heartbeat_at IS NOT NULL
1186
+ AND (julianday('now') - julianday(last_heartbeat_at)) * 86400 > ?`,
1187
+ [thresholdSeconds]
1188
+ )
1189
+ );
1190
+ if (candidates.length === 0) {
1191
+ return { orphanedIds: [], cascadedWorkflowIds: [] };
1192
+ }
1193
+ const reuseCutoffMs = Date.now() - PID_REUSE_GUARD_MS;
1194
+ const dead = candidates.filter((row) => {
1195
+ if (row.pid === null) return false;
1196
+ if (sqliteUtcMs(row.started_at) < reuseCutoffMs) return false;
1197
+ return !isAlive(row.pid);
1198
+ });
1199
+ if (dead.length === 0) {
1200
+ return { orphanedIds: [], cascadedWorkflowIds: [] };
1201
+ }
1202
+ const note = `${NOTE_ORPHAN_PREFIX} (threshold ${thresholdSeconds}s)`;
1203
+ const placeholders = dead.map(() => "?").join(", ");
1204
+ const cascadedWorkflowIds = [];
1205
+ db.transaction(() => {
1206
+ db.run(
1207
+ `UPDATE command_executions
1208
+ SET finished_at = datetime('now'),
1209
+ exit_code = ?,
1210
+ pid = NULL,
1211
+ notes = COALESCE(notes || char(10), '') || ?
1212
+ WHERE id IN (${placeholders})
1213
+ AND finished_at IS NULL`,
1214
+ [ORPHAN_EXIT_CODE, note, ...dead.map((r) => r.id)]
1215
+ );
1216
+ for (const row of dead) {
1217
+ if (row.workflow_id && rowKind(row) === "supervisor") {
1218
+ cascadeTerminateExecutions(
1219
+ db,
1220
+ row.workflow_id,
1221
+ CASCADE_CLOSE_EXIT_CODE,
1222
+ "cascade-closed: workflow process orphaned by liveness sweep"
1223
+ );
1224
+ cascadedWorkflowIds.push(row.workflow_id);
1225
+ }
1226
+ }
1227
+ });
1228
+ return {
1229
+ orphanedIds: dead.map((r) => r.uid ?? String(r.id)),
1230
+ cascadedWorkflowIds
1231
+ };
1232
+ }
1233
+ function rowKind(row) {
1234
+ if (row.command === INSTANCE_COMMAND || row.command.startsWith(`${INSTANCE_COMMAND}:`)) {
1235
+ return "instance";
1236
+ }
1237
+ return row.last_heartbeat_at == null ? "utility" : "supervisor";
1238
+ }
1239
+ function sweepStaleSessions(db, thresholdSeconds) {
1240
+ const sql = `
1241
+ SELECT s.id
1242
+ FROM sessions s
1243
+ LEFT JOIN (
1244
+ SELECT session_id, MAX(created_at) AS last_event_at
1245
+ FROM orchestration_events
1246
+ GROUP BY session_id
1247
+ ) e ON e.session_id = s.id
1248
+ WHERE s.status = 'active'
1249
+ AND (
1250
+ e.last_event_at IS NULL
1251
+ OR (julianday('now') - julianday(e.last_event_at)) * 86400 > ?
1252
+ )
1253
+ AND NOT EXISTS (
1254
+ SELECT 1 FROM command_executions ce
1255
+ WHERE ce.workflow_id = s.id
1256
+ AND ce.finished_at IS NULL
1257
+ )
1258
+ `;
1259
+ const rows = resultToRows(db.exec(sql, [thresholdSeconds]));
1260
+ if (rows.length === 0) {
1261
+ return { closedSessionIds: [] };
1262
+ }
1263
+ for (const row of rows) {
1264
+ commitReasonClose(
1265
+ db,
1266
+ row.id,
1267
+ {
1268
+ event_type: "session_auto_closed_stale",
1269
+ phase: "complete",
1270
+ metadata: JSON.stringify({
1271
+ reason: "no events past threshold; no in-flight dependents",
1272
+ threshold_seconds: thresholdSeconds
1273
+ })
1274
+ },
1275
+ { status: "closed", current_phase: "complete" }
1276
+ );
1277
+ }
1278
+ return { closedSessionIds: rows.map((r) => r.id) };
1279
+ }
434
1280
 
435
1281
  // src/lib/db/command-log.ts
436
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
437
- import { dirname, join } from "node:path";
1282
+ import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1283
+ import { dirname as dirname2, join as join2 } from "node:path";
438
1284
  import { randomUUID } from "node:crypto";
439
1285
  var CACHE_DIR = ".cache";
440
1286
  var FILENAME = "command-history.jsonl";
@@ -445,16 +1291,16 @@ function generateCommandUid() {
445
1291
  return randomUUID();
446
1292
  }
447
1293
  function cacheDir(ocrDir) {
448
- return join(ocrDir, "data", CACHE_DIR);
1294
+ return join2(ocrDir, "data", CACHE_DIR);
449
1295
  }
450
1296
  function commandLogPath(ocrDir) {
451
- return join(cacheDir(ocrDir), FILENAME);
1297
+ return join2(cacheDir(ocrDir), FILENAME);
452
1298
  }
453
1299
  function appendCommandLog(ocrDir, entry) {
454
1300
  try {
455
1301
  const filePath = commandLogPath(ocrDir);
456
- const dir = dirname(filePath);
457
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1302
+ const dir = dirname2(filePath);
1303
+ if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
458
1304
  const line = JSON.stringify(entry) + "\n";
459
1305
  appendFileSync(filePath, line, { encoding: "utf-8" });
460
1306
  if (approxLineCount >= 0) approxLineCount++;
@@ -464,7 +1310,7 @@ function appendCommandLog(ocrDir, entry) {
464
1310
  }
465
1311
  function readCommandLog(ocrDir) {
466
1312
  const filePath = commandLogPath(ocrDir);
467
- if (!existsSync(filePath)) return [];
1313
+ if (!existsSync2(filePath)) return [];
468
1314
  const content = readFileSync(filePath, "utf-8");
469
1315
  const entries = [];
470
1316
  for (const line of content.split("\n")) {
@@ -530,66 +1376,119 @@ function rotateIfNeeded(filePath) {
530
1376
  }
531
1377
 
532
1378
  // src/lib/db/index.ts
533
- var connections = /* @__PURE__ */ new Map();
534
- function locateWasm() {
535
- const require2 = createRequire(import.meta.url);
536
- const sqlJsPath = require2.resolve("sql.js");
537
- return join2(dirname2(sqlJsPath), "sql-wasm.wasm");
1379
+ var V2_SCHEMA_VERSION = 12;
1380
+ function maybeSnapshotBeforeUpgrade(db, dbPath, fromVersion) {
1381
+ if (fromVersion < 1 || fromVersion >= V2_SCHEMA_VERSION) return null;
1382
+ const bakPath = `${dbPath}.bak.v${fromVersion}`;
1383
+ if (existsSync3(bakPath)) return bakPath;
1384
+ try {
1385
+ if (!existsSync3(dbPath) || statSync(dbPath).size === 0) return null;
1386
+ db.pragma("wal_checkpoint(TRUNCATE)");
1387
+ copyFileSync(dbPath, bakPath);
1388
+ return bakPath;
1389
+ } catch {
1390
+ return null;
1391
+ }
538
1392
  }
539
- function applyPragmas(db) {
540
- db.run("PRAGMA foreign_keys = ON;");
541
- db.run("PRAGMA journal_mode = WAL;");
542
- db.run("PRAGMA busy_timeout = 5000;");
1393
+ function formatUpgradeNotice(bakPath, reconcile) {
1394
+ const lines = [
1395
+ "Storage upgraded to v2.0 \u2014 durable SQLite engine (WAL), event-sourced lifecycle."
1396
+ ];
1397
+ if (bakPath) {
1398
+ lines.push(` A backup of your previous database was saved to: ${bakPath}`);
1399
+ }
1400
+ const repairs = (reconcile?.actions ?? []).filter((a) => a.kind !== "ok");
1401
+ if (repairs.length > 0) {
1402
+ const n = (kind) => repairs.filter((a) => a.kind === kind).length;
1403
+ const parts = [];
1404
+ const finalized = n("synthesize-round-completed") + n("synthesize-map-completed");
1405
+ if (finalized > 0) parts.push(`${finalized} finalized from artifacts`);
1406
+ if (n("grandfather") > 0) parts.push(`${n("grandfather")} grandfathered`);
1407
+ if (n("stale-close") > 0) parts.push(`${n("stale-close")} stale closed`);
1408
+ lines.push(
1409
+ ` Reconciled ${repairs.length} legacy session(s): ${parts.join(", ")}.`
1410
+ );
1411
+ }
1412
+ lines.push(" Run `ocr doctor` to verify the storage engine.");
1413
+ return lines.map((l) => `[ocr] ${l}`).join("\n");
543
1414
  }
1415
+ var connections = /* @__PURE__ */ new Map();
544
1416
  async function openDatabase(dbPath) {
545
1417
  const cached = connections.get(dbPath);
546
1418
  if (cached) {
547
1419
  return cached;
548
1420
  }
549
- const wasmBuffer = readFileSync2(locateWasm());
550
- const wasmBinary = wasmBuffer.buffer.slice(
551
- wasmBuffer.byteOffset,
552
- wasmBuffer.byteOffset + wasmBuffer.byteLength
553
- );
554
- const SQL = await initSqlJs({
555
- wasmBinary
556
- });
557
- let db;
558
- if (existsSync2(dbPath)) {
559
- const fileBuffer = readFileSync2(dbPath);
560
- db = new SQL.Database(fileBuffer);
561
- } else {
562
- db = new SQL.Database();
1421
+ const dir = dirname3(dbPath);
1422
+ if (!existsSync3(dir)) {
1423
+ mkdirSync2(dir, { recursive: true });
563
1424
  }
564
- applyPragmas(db);
1425
+ const db = openEngine(dbPath);
565
1426
  connections.set(dbPath, db);
566
1427
  return db;
567
1428
  }
568
- function saveDatabase(db, dbPath) {
569
- const data = db.export();
570
- const dir = dirname2(dbPath);
571
- if (!existsSync2(dir)) {
572
- mkdirSync2(dir, { recursive: true });
573
- }
574
- const tmpPath = `${dbPath}.${process.pid}.tmp`;
575
- writeFileSync2(tmpPath, Buffer.from(data));
576
- renameSync2(tmpPath, dbPath);
577
- }
578
1429
  async function getDb(ocrDir) {
579
- const dbPath = join2(ocrDir, "data", "ocr.db");
1430
+ const dbPath = join3(ocrDir, "data", "ocr.db");
580
1431
  return openDatabase(dbPath);
581
1432
  }
582
1433
  async function ensureDatabase(ocrDir) {
583
- const dataDir = join2(ocrDir, "data");
584
- if (!existsSync2(dataDir)) {
1434
+ const dataDir = join3(ocrDir, "data");
1435
+ if (!existsSync3(dataDir)) {
585
1436
  mkdirSync2(dataDir, { recursive: true });
586
1437
  }
587
- const dbPath = join2(dataDir, "ocr.db");
1438
+ const dbPath = join3(dataDir, "ocr.db");
588
1439
  const db = await openDatabase(dbPath);
1440
+ let before = 0;
1441
+ try {
1442
+ before = getSchemaVersion(db);
1443
+ } catch {
1444
+ before = 0;
1445
+ }
1446
+ const isLegacyUpgrade = before >= 1 && before < V2_SCHEMA_VERSION;
1447
+ const bakPath = maybeSnapshotBeforeUpgrade(db, dbPath, before);
589
1448
  runMigrations(db);
590
- saveDatabase(db, dbPath);
1449
+ let reconcile;
1450
+ if (before < V2_SCHEMA_VERSION) {
1451
+ try {
1452
+ reconcile = reconcileLegacyState(db, ocrDir);
1453
+ } catch (err) {
1454
+ console.error(
1455
+ `[ocr] legacy reconciliation skipped: ${err instanceof Error ? err.message : String(err)}`
1456
+ );
1457
+ }
1458
+ }
1459
+ if (isLegacyUpgrade) {
1460
+ const notice = formatUpgradeNotice(bakPath, reconcile);
1461
+ if (notice) console.error(notice);
1462
+ }
591
1463
  return db;
592
1464
  }
1465
+ function walCheckpointTruncate(dbPath) {
1466
+ if (!existsSync3(dbPath)) {
1467
+ return "skipped";
1468
+ }
1469
+ const cached = connections.get(dbPath);
1470
+ if (cached) {
1471
+ try {
1472
+ cached.pragma("wal_checkpoint(TRUNCATE)");
1473
+ return "checkpointed";
1474
+ } catch {
1475
+ return "failed";
1476
+ }
1477
+ }
1478
+ let transient;
1479
+ try {
1480
+ transient = openEngine(dbPath);
1481
+ transient.pragma("wal_checkpoint(TRUNCATE)");
1482
+ return "checkpointed";
1483
+ } catch {
1484
+ return "failed";
1485
+ } finally {
1486
+ try {
1487
+ transient?.raw.close();
1488
+ } catch {
1489
+ }
1490
+ }
1491
+ }
593
1492
  function closeDatabase(dbPath) {
594
1493
  const db = connections.get(dbPath);
595
1494
  if (db) {
@@ -604,30 +1503,57 @@ function closeAllDatabases() {
604
1503
  }
605
1504
  }
606
1505
  export {
1506
+ CANCELLED_EXIT_CODE,
1507
+ CASCADE_CLOSE_EXIT_CODE,
607
1508
  MIGRATIONS,
1509
+ ORPHAN_EXIT_CODE,
1510
+ PID_REUSE_GUARD_MS,
1511
+ STATE_EXIT,
1512
+ StateError,
608
1513
  appendCommandLog,
609
- applyPragmas,
1514
+ bindVendorSessionIdOpportunistically,
1515
+ bumpAgentSessionHeartbeat,
610
1516
  cacheDir,
1517
+ cascadeTerminateExecutions,
611
1518
  closeAllDatabases,
612
1519
  closeDatabase,
613
1520
  commandLogPath,
1521
+ commitReasonClose,
1522
+ defaultIsAlive,
614
1523
  ensureDatabase,
1524
+ formatUpgradeNotice,
615
1525
  generateCommandUid,
1526
+ getAgentSession,
616
1527
  getAllSessions,
617
1528
  getDb,
618
1529
  getEventsForSession,
619
1530
  getLatestActiveSession,
1531
+ getLatestAgentSessionWithVendorId,
620
1532
  getLatestEventId,
1533
+ getSchemaVersion,
621
1534
  getSession,
1535
+ insertAgentSession,
622
1536
  insertEvent,
623
1537
  insertSession,
624
- locateWasm,
1538
+ isBusyError,
1539
+ linkDashboardInvocationToWorkflow,
1540
+ listAgentSessionsForWorkflow,
625
1541
  openDatabase,
1542
+ probeEngine,
626
1543
  readCommandLog,
1544
+ reconcileLegacyState,
1545
+ recordVendorSessionIdForExecution,
627
1546
  replayCommandLog,
628
1547
  resultToRow,
629
1548
  resultToRows,
1549
+ rowKind,
630
1550
  runMigrations,
631
- saveDatabase,
632
- updateSession
1551
+ setAgentSessionStatus,
1552
+ setAgentSessionVendorId,
1553
+ sqliteUtcMs,
1554
+ sweepStaleAgentSessions,
1555
+ sweepStaleSessions,
1556
+ updateAgentSession,
1557
+ updateSession,
1558
+ walCheckpointTruncate
633
1559
  };