@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
@@ -18413,7 +18413,7 @@ var require_view = __commonJS({
18413
18413
  var dirname14 = path2.dirname;
18414
18414
  var basename4 = path2.basename;
18415
18415
  var extname = path2.extname;
18416
- var join18 = path2.join;
18416
+ var join19 = path2.join;
18417
18417
  var resolve3 = path2.resolve;
18418
18418
  module.exports = View;
18419
18419
  function View(name, options) {
@@ -18461,12 +18461,12 @@ var require_view = __commonJS({
18461
18461
  };
18462
18462
  View.prototype.resolve = function resolve4(dir, file) {
18463
18463
  var ext = this.ext;
18464
- var path3 = join18(dir, file);
18464
+ var path3 = join19(dir, file);
18465
18465
  var stat = tryStat(path3);
18466
18466
  if (stat && stat.isFile()) {
18467
18467
  return path3;
18468
18468
  }
18469
- path3 = join18(dir, basename4(file, ext), "index" + ext);
18469
+ path3 = join19(dir, basename4(file, ext), "index" + ext);
18470
18470
  stat = tryStat(path3);
18471
18471
  if (stat && stat.isFile()) {
18472
18472
  return path3;
@@ -19099,7 +19099,7 @@ var require_send = __commonJS({
19099
19099
  var Stream = __require("stream");
19100
19100
  var util = __require("util");
19101
19101
  var extname = path2.extname;
19102
- var join18 = path2.join;
19102
+ var join19 = path2.join;
19103
19103
  var normalize = path2.normalize;
19104
19104
  var resolve3 = path2.resolve;
19105
19105
  var sep = path2.sep;
@@ -19318,7 +19318,7 @@ var require_send = __commonJS({
19318
19318
  return res;
19319
19319
  }
19320
19320
  parts = path3.split(sep);
19321
- path3 = normalize(join18(root, path3));
19321
+ path3 = normalize(join19(root, path3));
19322
19322
  } else {
19323
19323
  if (UP_PATH_REGEXP.test(path3)) {
19324
19324
  debug('malicious path "%s"', path3);
@@ -19453,7 +19453,7 @@ var require_send = __commonJS({
19453
19453
  if (err) return self.onStatError(err);
19454
19454
  return self.error(404);
19455
19455
  }
19456
- var p = join18(path3, self._index[i]);
19456
+ var p = join19(path3, self._index[i]);
19457
19457
  debug('stat "%s"', p);
19458
19458
  fs6.stat(p, function(err2, stat) {
19459
19459
  if (err2) return next(err2);
@@ -21876,7 +21876,7 @@ var require_response = __commonJS({
21876
21876
  var encodeUrl = require_encodeurl();
21877
21877
  var escapeHtml = require_escape_html();
21878
21878
  var http = __require("http");
21879
- var isAbsolute2 = require_utils2().isAbsolute;
21879
+ var isAbsolute3 = require_utils2().isAbsolute;
21880
21880
  var onFinished = require_on_finished();
21881
21881
  var path2 = __require("path");
21882
21882
  var statuses = require_statuses();
@@ -22082,7 +22082,7 @@ var require_response = __commonJS({
22082
22082
  done = options;
22083
22083
  opts = {};
22084
22084
  }
22085
- if (!opts.root && !isAbsolute2(path3)) {
22085
+ if (!opts.root && !isAbsolute3(path3)) {
22086
22086
  throw new TypeError("path must be absolute or specify root to res.sendFile");
22087
22087
  }
22088
22088
  var pathname = encodeURI(path3);
@@ -30483,8 +30483,8 @@ var init_open = __esm({
30483
30483
  // src/server/index.ts
30484
30484
  var import_express15 = __toESM(require_express2(), 1);
30485
30485
  import { createServer } from "node:http";
30486
- import { existsSync as existsSync15, readFileSync as readFileSync15, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, mkdirSync as mkdirSync7 } from "node:fs";
30487
- import { join as join17, dirname as dirname13, resolve as resolve2 } from "node:path";
30486
+ import { existsSync as existsSync15, readFileSync as readFileSync12, writeFileSync as writeFileSync5, unlinkSync as unlinkSync3, mkdirSync as mkdirSync6 } from "node:fs";
30487
+ import { join as join18, dirname as dirname13, resolve as resolve2 } from "node:path";
30488
30488
  import { fileURLToPath as fileURLToPath3 } from "node:url";
30489
30489
  import { randomBytes } from "node:crypto";
30490
30490
  import { Server as SocketIOServer } from "socket.io";
@@ -30509,17 +30509,199 @@ function resolveOcrDir(startDir) {
30509
30509
  }
30510
30510
  }
30511
30511
 
30512
- // src/server/db.ts
30513
- import { existsSync as existsSync4, readFileSync as readFileSync3, renameSync as renameSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "node:fs";
30514
- import { join as join4, dirname as dirname4 } from "node:path";
30515
- import initSqlJs2 from "sql.js";
30516
-
30517
30512
  // ../cli/src/lib/db/index.ts
30518
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
30519
- import { dirname as dirname3, join as join3 } from "node:path";
30513
+ import {
30514
+ existsSync as existsSync4,
30515
+ mkdirSync as mkdirSync2,
30516
+ copyFileSync,
30517
+ statSync,
30518
+ mkdtempSync,
30519
+ rmSync
30520
+ } from "node:fs";
30521
+ import { dirname as dirname4, join as join4 } from "node:path";
30522
+
30523
+ // ../cli/src/lib/db/engine.ts
30520
30524
  import { createRequire } from "node:module";
30521
- import { spawnSync } from "node:child_process";
30522
- import initSqlJs from "sql.js";
30525
+
30526
+ // ../cli/src/lib/runtime-checks.ts
30527
+ var NODE_FLOOR = { major: 22, minor: 5 };
30528
+ function isSupportedNode(version) {
30529
+ const [major = 0, minor = 0] = version.split(".").map((n) => Number.parseInt(n, 10) || 0);
30530
+ return major > NODE_FLOOR.major || major === NODE_FLOOR.major && minor >= NODE_FLOOR.minor;
30531
+ }
30532
+ function nodeVersionGuardMessage(version) {
30533
+ return `
30534
+ Open Code Review requires Node.js >= ${NODE_FLOOR.major}.${NODE_FLOOR.minor} (it uses Node's built-in SQLite, \`node:sqlite\`).
30535
+ You have Node ${version}. Upgrade Node (e.g. \`nvm install 22 && nvm use 22\`) and re-run.
30536
+
30537
+ `;
30538
+ }
30539
+ function isSuppressibleSqliteWarning(warning) {
30540
+ const message = typeof warning === "string" ? warning : warning?.message;
30541
+ return typeof message === "string" && message.includes("SQLite is an experimental feature");
30542
+ }
30543
+
30544
+ // ../cli/src/lib/db/engine.ts
30545
+ var SQLITE_BUSY = 5;
30546
+ var SQLITE_BUSY_SNAPSHOT = 261;
30547
+ var BUSY_RETRY_ATTEMPTS = 5;
30548
+ var BUSY_RETRY_BACKOFF_MS = 50;
30549
+ var savepointName = (depth) => `ocr_sp_${depth}`;
30550
+ var nodeRequire = createRequire(import.meta.url);
30551
+ var _preconditionsApplied = false;
30552
+ function applyEnginePreconditions() {
30553
+ if (_preconditionsApplied) return;
30554
+ _preconditionsApplied = true;
30555
+ const originalEmitWarning = process.emitWarning.bind(process);
30556
+ process.emitWarning = (warning, ...args) => {
30557
+ if (isSuppressibleSqliteWarning(warning)) return;
30558
+ originalEmitWarning(warning, ...args);
30559
+ };
30560
+ }
30561
+ var _DatabaseSyncCtor;
30562
+ function newDatabase(path2) {
30563
+ if (!_DatabaseSyncCtor) {
30564
+ applyEnginePreconditions();
30565
+ try {
30566
+ _DatabaseSyncCtor = nodeRequire("node:sqlite").DatabaseSync;
30567
+ } catch (e) {
30568
+ if (!isSupportedNode(process.versions.node)) {
30569
+ throw new Error(nodeVersionGuardMessage(process.versions.node).trim());
30570
+ }
30571
+ throw e;
30572
+ }
30573
+ }
30574
+ return new _DatabaseSyncCtor(path2);
30575
+ }
30576
+ function isBusyError(e) {
30577
+ const errcode = e?.errcode;
30578
+ return errcode === SQLITE_BUSY || errcode === SQLITE_BUSY_SNAPSHOT;
30579
+ }
30580
+ var SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
30581
+ function sleepSync(ms) {
30582
+ Atomics.wait(SLEEP_BUF, 0, 0, ms);
30583
+ }
30584
+ var NodeSqliteAdapter = class {
30585
+ raw;
30586
+ /**
30587
+ * Transaction nesting depth. `node:sqlite` has no transaction helper, so we
30588
+ * drive `BEGIN IMMEDIATE` ourselves and use SAVEPOINTs for nested calls
30589
+ * (better-sqlite3 did this automatically). 0 = no transaction open.
30590
+ */
30591
+ txnDepth = 0;
30592
+ constructor(db) {
30593
+ this.raw = db;
30594
+ }
30595
+ exec(sql, params) {
30596
+ const stmt = this.raw.prepare(sql);
30597
+ const cols = stmt.columns();
30598
+ if (cols.length === 0) {
30599
+ stmt.run(...params ?? []);
30600
+ return [];
30601
+ }
30602
+ stmt.setReturnArrays(true);
30603
+ const values = stmt.all(...params ?? []);
30604
+ return values.length > 0 ? [{ columns: cols.map((c) => c.name), values }] : [];
30605
+ }
30606
+ run(sql, params) {
30607
+ if (params !== void 0) {
30608
+ this.raw.prepare(sql).run(...params);
30609
+ return;
30610
+ }
30611
+ this.raw.exec(sql);
30612
+ }
30613
+ prepare(sql) {
30614
+ return this.raw.prepare(sql);
30615
+ }
30616
+ transaction(fn) {
30617
+ return this.txnDepth > 0 ? this.runNested(fn) : this.runOuter(fn);
30618
+ }
30619
+ /**
30620
+ * Nested call: a SAVEPOINT within the outer transaction's write lock. No
30621
+ * busy-retry — the outer transaction already holds the lock. The savepoint
30622
+ * lets the inner block roll back independently while the outer continues.
30623
+ */
30624
+ runNested(fn) {
30625
+ const name = savepointName(this.txnDepth);
30626
+ this.raw.exec(`SAVEPOINT ${name}`);
30627
+ this.txnDepth++;
30628
+ try {
30629
+ const result = fn();
30630
+ this.raw.exec(`RELEASE ${name}`);
30631
+ return result;
30632
+ } catch (e) {
30633
+ try {
30634
+ this.raw.exec(`ROLLBACK TO ${name}`);
30635
+ this.raw.exec(`RELEASE ${name}`);
30636
+ } catch {
30637
+ }
30638
+ throw e;
30639
+ } finally {
30640
+ this.txnDepth--;
30641
+ }
30642
+ }
30643
+ /**
30644
+ * Outer transaction: `BEGIN IMMEDIATE` acquires the write lock up front so
30645
+ * cross-process writers serialize cleanly under WAL instead of failing late
30646
+ * on upgrade. `busy_timeout` covers most contention; a bounded synchronous
30647
+ * retry absorbs the residual SQLITE_BUSY (another connection holds the lock
30648
+ * past the timeout, or BUSY_SNAPSHOT). Non-busy errors and the final attempt
30649
+ * re-throw so genuine failures propagate.
30650
+ */
30651
+ runOuter(fn) {
30652
+ for (let attempt = 0; attempt < BUSY_RETRY_ATTEMPTS; attempt++) {
30653
+ try {
30654
+ return this.runOnce(fn);
30655
+ } catch (e) {
30656
+ if (!isBusyError(e) || attempt === BUSY_RETRY_ATTEMPTS - 1) throw e;
30657
+ sleepSync(BUSY_RETRY_BACKOFF_MS);
30658
+ }
30659
+ }
30660
+ throw new Error("transaction retry budget exhausted");
30661
+ }
30662
+ /** One `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK` lifecycle. */
30663
+ runOnce(fn) {
30664
+ this.raw.exec("BEGIN IMMEDIATE");
30665
+ this.txnDepth = 1;
30666
+ try {
30667
+ const result = fn();
30668
+ this.raw.exec("COMMIT");
30669
+ return result;
30670
+ } catch (e) {
30671
+ try {
30672
+ this.raw.exec("ROLLBACK");
30673
+ } catch {
30674
+ }
30675
+ throw e;
30676
+ } finally {
30677
+ this.txnDepth = 0;
30678
+ }
30679
+ }
30680
+ pragma(source) {
30681
+ this.raw.exec(`PRAGMA ${source}`);
30682
+ return void 0;
30683
+ }
30684
+ close() {
30685
+ try {
30686
+ this.raw.exec("PRAGMA wal_checkpoint(TRUNCATE)");
30687
+ } catch {
30688
+ }
30689
+ try {
30690
+ this.raw.close();
30691
+ } catch (e) {
30692
+ const message = e?.message ?? "";
30693
+ if (!/database is not open/i.test(message)) throw e;
30694
+ }
30695
+ }
30696
+ };
30697
+ function openEngine(dbPath) {
30698
+ const native = newDatabase(dbPath);
30699
+ native.exec("PRAGMA journal_mode = WAL");
30700
+ native.exec("PRAGMA foreign_keys = ON");
30701
+ native.exec("PRAGMA busy_timeout = 5000");
30702
+ native.exec("PRAGMA synchronous = NORMAL");
30703
+ return new NodeSqliteAdapter(native);
30704
+ }
30523
30705
 
30524
30706
  // ../cli/src/lib/db/migrations.ts
30525
30707
  var MIGRATIONS = [
@@ -30843,8 +31025,157 @@ var MIGRATIONS = [
30843
31025
  DROP INDEX IF EXISTS idx_agent_sessions_status_heartbeat;
30844
31026
  DROP TABLE IF EXISTS agent_sessions;
30845
31027
  `
31028
+ },
31029
+ {
31030
+ version: 12,
31031
+ description: "Event-sourced lifecycle hardening: event_type taxonomy guard, sweep indexes, session_completeness view",
31032
+ sql: `
31033
+ -- \u2500\u2500 Indexes for the now-periodic stale-session sweep + round derivation \u2500\u2500
31034
+ -- The sweep filters sessions by status and rolls up MAX(created_at) per
31035
+ -- session over the event log; deriveNextRound does MAX(round). Index both.
31036
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
31037
+ CREATE INDEX IF NOT EXISTS idx_events_session_created
31038
+ ON orchestration_events(session_id, created_at);
31039
+
31040
+ -- \u2500\u2500 Event-type taxonomy guard \u2500\u2500
31041
+ -- orchestration_events.event_type is the spine of all lifecycle
31042
+ -- derivation. A typo (e.g. 'round_complete' vs 'round_completed') would
31043
+ -- silently break deriveNextRound and the completeness view. SQLite cannot
31044
+ -- add a CHECK to an existing column without a table rebuild, so enforce
31045
+ -- the closed vocabulary with a BEFORE INSERT trigger instead.
31046
+ CREATE TRIGGER IF NOT EXISTS trg_events_known_type
31047
+ BEFORE INSERT ON orchestration_events
31048
+ WHEN NEW.event_type NOT IN (
31049
+ 'session_created', 'session_resumed', 'round_started', 'phase_transition',
31050
+ 'round_completed', 'map_completed', 'session_closed', 'session_aborted',
31051
+ 'session_auto_closed_stale', 'session_synced', 'session_legacy_import'
31052
+ )
31053
+ BEGIN
31054
+ SELECT RAISE(ABORT, 'unknown orchestration_events.event_type');
31055
+ END;
31056
+
31057
+ -- \u2500\u2500 Close-guard (DB backstop for the completion invariant) \u2500\u2500
31058
+ -- A session cannot transition active \u2192 closed unless its current
31059
+ -- round/run has a terminal artifact event, OR an explicit reason event
31060
+ -- (abort / auto-close-stale / sync / legacy-import) is present. Only a
31061
+ -- *silent* premature close is banned \u2014 every legitimate non-artifact
31062
+ -- close carries a reason event and passes. App-level guards in
31063
+ -- stateClose/finish are the primary check; this makes the illegal state
31064
+ -- unrepresentable even via raw SQL.
31065
+ --
31066
+ -- DEFENCE-IN-DEPTH NOTE (intentional, documented gap): the reason-event
31067
+ -- branch below (event_type IN (...)) is NOT round-scoped \u2014 a reason event
31068
+ -- recorded for an earlier round would also satisfy a later close. The
31069
+ -- app-level guards ARE round-scoped (hasCompletionInvariant checks the
31070
+ -- current round/run), so the precise check lives in the application; this
31071
+ -- trigger is a coarse backstop against a *silent* premature close via raw
31072
+ -- SQL. Tightening it to be round-scoped would require a new migration
31073
+ -- (this v12 trigger is append-only and already shipped); the residual
31074
+ -- risk is a non-artifact close carrying a stale reason event, which is
31075
+ -- still an explicit, audited terminal \u2014 not the failure mode this guards.
31076
+ CREATE TRIGGER IF NOT EXISTS trg_sessions_close_guard
31077
+ BEFORE UPDATE OF status ON sessions
31078
+ WHEN NEW.status = 'closed' AND OLD.status <> 'closed'
31079
+ AND NOT EXISTS (
31080
+ SELECT 1 FROM orchestration_events e
31081
+ WHERE e.session_id = NEW.id
31082
+ AND (
31083
+ (NEW.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = NEW.current_round)
31084
+ OR (NEW.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = NEW.current_map_run)
31085
+ OR e.event_type IN ('session_aborted','session_auto_closed_stale','session_synced','session_legacy_import')
31086
+ )
31087
+ )
31088
+ BEGIN
31089
+ SELECT RAISE(ABORT, 'cannot close session without a completed round/run or an explicit reason event');
31090
+ END;
31091
+
31092
+ -- \u2500\u2500 session_completeness view \u2500\u2500
31093
+ -- The published contract for "is this session actually complete, and if
31094
+ -- not, what's missing". Completion is DERIVED from the event log, never a
31095
+ -- mutable flag: a session is complete iff it is closed AND a terminal
31096
+ -- artifact event exists for its current round/run. The dashboard's
31097
+ -- outcome derivation and the agent 'status' command read this view, so
31098
+ -- they cannot disagree.
31099
+ --
31100
+ -- completeness_state is an INTENTIONAL HYBRID: it combines the mutable
31101
+ -- status column (marked_closed) with append-only event evidence (the
31102
+ -- terminal artifact event). This is sound precisely because the
31103
+ -- close-guard trigger above makes the status column trustworthy \u2014 a row
31104
+ -- can only reach status='closed' with a completed round/run or an
31105
+ -- explicit reason event \u2014 so reading the column is not a regression to
31106
+ -- the old "mutable flag that could lie" model.
31107
+ --
31108
+ -- completeness_state:
31109
+ -- 'complete' \u2014 closed + terminal artifact for current round/run
31110
+ -- 'closed_without_artifact' \u2014 closed but no terminal artifact (the
31111
+ -- "completed too soon" condition)
31112
+ -- 'in_flight' \u2014 open with a dependent process still running
31113
+ -- 'open_no_artifact' \u2014 open, no in-flight dependents
31114
+ CREATE VIEW IF NOT EXISTS session_completeness AS
31115
+ SELECT
31116
+ s.id AS session_id,
31117
+ s.workflow_type AS workflow_type,
31118
+ s.status AS status,
31119
+ s.current_round AS current_round,
31120
+ s.current_map_run AS current_map_run,
31121
+ CASE WHEN EXISTS (
31122
+ SELECT 1 FROM orchestration_events e
31123
+ WHERE e.session_id = s.id
31124
+ AND (
31125
+ (s.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = s.current_round)
31126
+ OR (s.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = s.current_map_run)
31127
+ )
31128
+ ) THEN 1 ELSE 0 END AS has_terminal_artifact,
31129
+ CASE WHEN s.status = 'closed' THEN 1 ELSE 0 END AS marked_closed,
31130
+ CASE WHEN NOT EXISTS (
31131
+ SELECT 1 FROM command_executions ce
31132
+ WHERE ce.workflow_id = s.id AND ce.finished_at IS NULL
31133
+ ) THEN 1 ELSE 0 END AS dependents_settled,
31134
+ CASE
31135
+ WHEN s.status = 'closed' AND EXISTS (
31136
+ SELECT 1 FROM orchestration_events e
31137
+ WHERE e.session_id = s.id
31138
+ AND (
31139
+ (s.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = s.current_round)
31140
+ OR (s.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = s.current_map_run)
31141
+ )
31142
+ ) THEN 'complete'
31143
+ WHEN s.status = 'closed' THEN 'closed_without_artifact'
31144
+ WHEN EXISTS (
31145
+ SELECT 1 FROM command_executions ce
31146
+ WHERE ce.workflow_id = s.id AND ce.finished_at IS NULL
31147
+ ) THEN 'in_flight'
31148
+ ELSE 'open_no_artifact'
31149
+ END AS completeness_state
31150
+ FROM sessions s;
31151
+ `
31152
+ },
31153
+ {
31154
+ version: 13,
31155
+ description: "Retire dead parent_id column on command_executions (never written; row kind is derived from command)",
31156
+ // parent_id was reserved for an AI-instance → dashboard-spawn lineage link
31157
+ // that was never wired (no writer, no reader). A process's KIND (supervisor
31158
+ // / reviewer-instance / utility) is derived from columns that are always
31159
+ // present (command + last_heartbeat_at), so the dead lineage column and its
31160
+ // all-NULL index are removed. Re-add a wired parent_id alongside a real
31161
+ // consumer (e.g. a parent→child tree view) if lineage is ever needed.
31162
+ //
31163
+ // Imperative + guarded so the DROP COLUMN (which SQLite can't express as
31164
+ // IF EXISTS) is idempotent under re-application.
31165
+ run: (db) => {
31166
+ if (!columnExists(db, "command_executions", "parent_id")) return;
31167
+ db.run("DROP INDEX IF EXISTS idx_command_executions_parent;");
31168
+ db.run("ALTER TABLE command_executions DROP COLUMN parent_id;");
31169
+ }
30846
31170
  }
30847
31171
  ];
31172
+ function columnExists(db, table, column) {
31173
+ const result = db.exec(`PRAGMA table_info(${table})`);
31174
+ const first = result[0];
31175
+ if (!first) return false;
31176
+ const nameIdx = first.columns.indexOf("name");
31177
+ return first.values.some((row) => row[nameIdx] === column);
31178
+ }
30848
31179
  function ensureSchemaVersionTable(db) {
30849
31180
  db.run(`
30850
31181
  CREATE TABLE IF NOT EXISTS schema_version (
@@ -30854,6 +31185,10 @@ function ensureSchemaVersionTable(db) {
30854
31185
  );
30855
31186
  `);
30856
31187
  }
31188
+ function getSchemaVersion(db) {
31189
+ ensureSchemaVersionTable(db);
31190
+ return getCurrentVersion(db);
31191
+ }
30857
31192
  function getCurrentVersion(db) {
30858
31193
  const result = db.exec(
30859
31194
  "SELECT MAX(version) as v FROM schema_version"
@@ -30871,9 +31206,10 @@ function runMigrations(db) {
30871
31206
  if (migration.version <= currentVersion) {
30872
31207
  continue;
30873
31208
  }
30874
- db.run("BEGIN TRANSACTION;");
31209
+ db.run("BEGIN IMMEDIATE;");
30875
31210
  try {
30876
- db.run(migration.sql);
31211
+ if (migration.sql) db.run(migration.sql);
31212
+ migration.run?.(db);
30877
31213
  db.run(
30878
31214
  "INSERT INTO schema_version (version, description) VALUES (?, ?);",
30879
31215
  [migration.version, migration.description]
@@ -30886,6 +31222,10 @@ function runMigrations(db) {
30886
31222
  }
30887
31223
  }
30888
31224
 
31225
+ // ../cli/src/lib/db/reconcile.ts
31226
+ import { existsSync as existsSync2 } from "node:fs";
31227
+ import { isAbsolute, join as join2, dirname as dirname2 } from "node:path";
31228
+
30889
31229
  // ../cli/src/lib/db/result-mapper.ts
30890
31230
  function resultToRows(result) {
30891
31231
  if (result.length === 0 || !result[0]) {
@@ -30906,16 +31246,260 @@ function resultToRow(result) {
30906
31246
  }
30907
31247
 
30908
31248
  // ../cli/src/lib/db/queries.ts
31249
+ function insertSession(db, params) {
31250
+ const {
31251
+ id,
31252
+ branch,
31253
+ workflow_type,
31254
+ current_phase = "context",
31255
+ phase_number = 1,
31256
+ current_round = 1,
31257
+ current_map_run = 1,
31258
+ session_dir
31259
+ } = params;
31260
+ db.run(
31261
+ `INSERT INTO sessions (id, branch, workflow_type, current_phase, phase_number, current_round, current_map_run, session_dir)
31262
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
31263
+ [id, branch, workflow_type, current_phase, phase_number, current_round, current_map_run, session_dir]
31264
+ );
31265
+ }
31266
+ function updateSession(db, id, params) {
31267
+ const setClauses = [];
31268
+ const values = [];
31269
+ if (params.status !== void 0) {
31270
+ setClauses.push("status = ?");
31271
+ values.push(params.status);
31272
+ }
31273
+ if (params.current_phase !== void 0) {
31274
+ setClauses.push("current_phase = ?");
31275
+ values.push(params.current_phase);
31276
+ }
31277
+ if (params.phase_number !== void 0) {
31278
+ setClauses.push("phase_number = ?");
31279
+ values.push(params.phase_number);
31280
+ }
31281
+ if (params.current_round !== void 0) {
31282
+ setClauses.push("current_round = ?");
31283
+ values.push(params.current_round);
31284
+ }
31285
+ if (params.current_map_run !== void 0) {
31286
+ setClauses.push("current_map_run = ?");
31287
+ values.push(params.current_map_run);
31288
+ }
31289
+ if (setClauses.length === 0) {
31290
+ return;
31291
+ }
31292
+ setClauses.push("updated_at = datetime('now')");
31293
+ values.push(id);
31294
+ db.run(
31295
+ `UPDATE sessions SET ${setClauses.join(", ")} WHERE id = ?`,
31296
+ values
31297
+ );
31298
+ }
30909
31299
  function getSession(db, id) {
30910
31300
  return resultToRow(
30911
31301
  db.exec("SELECT * FROM sessions WHERE id = ?", [id])
30912
31302
  );
30913
31303
  }
31304
+ function getAllSessions(db) {
31305
+ return resultToRows(
31306
+ db.exec("SELECT * FROM sessions ORDER BY started_at DESC")
31307
+ );
31308
+ }
31309
+ function insertEvent(db, params) {
31310
+ const {
31311
+ session_id,
31312
+ event_type,
31313
+ phase,
31314
+ phase_number,
31315
+ round,
31316
+ metadata
31317
+ } = params;
31318
+ db.run(
31319
+ `INSERT INTO orchestration_events (session_id, event_type, phase, phase_number, round, metadata)
31320
+ VALUES (?, ?, ?, ?, ?, ?)`,
31321
+ [
31322
+ session_id,
31323
+ event_type,
31324
+ phase ?? null,
31325
+ phase_number ?? null,
31326
+ round ?? null,
31327
+ metadata ?? null
31328
+ ]
31329
+ );
31330
+ }
31331
+ function commitReasonClose(db, sessionId, reasonEvent, projectionUpdates) {
31332
+ db.transaction(() => {
31333
+ insertEvent(db, { session_id: sessionId, ...reasonEvent });
31334
+ updateSession(db, sessionId, projectionUpdates);
31335
+ });
31336
+ }
30914
31337
 
30915
- // ../cli/src/lib/db/agent-sessions.ts
30916
- var ORPHAN_EXIT_CODE = -3;
31338
+ // ../cli/src/lib/db/reconcile.ts
31339
+ var DEFAULT_STALE_THRESHOLD_SECONDS = 7 * 24 * 60 * 60;
31340
+ function hasTerminalArtifactEvent(db, sessionId, workflowType, currentRound, currentMapRun) {
31341
+ const eventType = workflowType === "map" ? "map_completed" : "round_completed";
31342
+ const round = workflowType === "map" ? currentMapRun : currentRound;
31343
+ const r = db.exec(
31344
+ `SELECT 1 FROM orchestration_events
31345
+ WHERE session_id = ? AND event_type = ? AND round = ? LIMIT 1`,
31346
+ [sessionId, eventType, round]
31347
+ );
31348
+ return (r[0]?.values.length ?? 0) > 0;
31349
+ }
31350
+ function hasReasonEvent(db, sessionId) {
31351
+ const r = db.exec(
31352
+ `SELECT 1 FROM orchestration_events
31353
+ WHERE session_id = ?
31354
+ AND event_type IN ('session_aborted','session_auto_closed_stale','session_synced','session_legacy_import')
31355
+ LIMIT 1`,
31356
+ [sessionId]
31357
+ );
31358
+ return (r[0]?.values.length ?? 0) > 0;
31359
+ }
31360
+ function lastEventAgeSeconds(db, sessionId) {
31361
+ const r = db.exec(
31362
+ `SELECT (julianday('now') - julianday(MAX(created_at))) * 86400
31363
+ FROM orchestration_events WHERE session_id = ?`,
31364
+ [sessionId]
31365
+ );
31366
+ const v = r[0]?.values[0]?.[0];
31367
+ return typeof v === "number" ? v : null;
31368
+ }
31369
+ function hasInFlightDependents(db, sessionId) {
31370
+ const r = db.exec(
31371
+ `SELECT 1 FROM command_executions
31372
+ WHERE workflow_id = ? AND finished_at IS NULL LIMIT 1`,
31373
+ [sessionId]
31374
+ );
31375
+ return (r[0]?.values.length ?? 0) > 0;
31376
+ }
31377
+ function resolveSessionDir(ocrDir, sessionDir) {
31378
+ if (!sessionDir) return null;
31379
+ if (isAbsolute(sessionDir)) return sessionDir;
31380
+ return join2(dirname2(ocrDir), sessionDir);
31381
+ }
31382
+ function reconcileLegacyState(db, ocrDir, opts = {}) {
31383
+ const dryRun = opts.dryRun ?? false;
31384
+ const threshold = opts.staleThresholdSeconds ?? DEFAULT_STALE_THRESHOLD_SECONDS;
31385
+ const actions = [];
31386
+ for (const s of getAllSessions(db)) {
31387
+ const dir = resolveSessionDir(ocrDir, s.session_dir);
31388
+ if (s.status === "closed") {
31389
+ if (hasTerminalArtifactEvent(db, s.id, s.workflow_type, s.current_round, s.current_map_run) || hasReasonEvent(db, s.id)) {
31390
+ continue;
31391
+ }
31392
+ const reviewFinal = s.workflow_type === "review" && dir ? existsSync2(join2(dir, "rounds", `round-${s.current_round}`, "final.md")) : false;
31393
+ const mapFinal = s.workflow_type === "map" && dir ? existsSync2(join2(dir, "map", "runs", `run-${s.current_map_run}`, "map.md")) : false;
31394
+ if (reviewFinal) {
31395
+ actions.push({
31396
+ sessionId: s.id,
31397
+ kind: "synthesize-round-completed",
31398
+ detail: `final.md present for round ${s.current_round}; synthesizing round_completed`
31399
+ });
31400
+ if (!dryRun) {
31401
+ insertEvent(db, {
31402
+ session_id: s.id,
31403
+ event_type: "round_completed",
31404
+ phase: "synthesis",
31405
+ phase_number: 7,
31406
+ round: s.current_round,
31407
+ metadata: JSON.stringify({ source: "reconciled", synthesized_from: "final.md" })
31408
+ });
31409
+ }
31410
+ } else if (mapFinal) {
31411
+ actions.push({
31412
+ sessionId: s.id,
31413
+ kind: "synthesize-map-completed",
31414
+ detail: `map.md present for run ${s.current_map_run}; synthesizing map_completed`
31415
+ });
31416
+ if (!dryRun) {
31417
+ insertEvent(db, {
31418
+ session_id: s.id,
31419
+ event_type: "map_completed",
31420
+ phase: "synthesis",
31421
+ phase_number: 5,
31422
+ round: s.current_map_run,
31423
+ metadata: JSON.stringify({ source: "reconciled", synthesized_from: "map.md" })
31424
+ });
31425
+ }
31426
+ } else {
31427
+ actions.push({
31428
+ sessionId: s.id,
31429
+ kind: "grandfather",
31430
+ detail: "no provable artifact; recording session_legacy_import"
31431
+ });
31432
+ if (!dryRun) {
31433
+ insertEvent(db, {
31434
+ session_id: s.id,
31435
+ event_type: "session_legacy_import",
31436
+ phase: "complete",
31437
+ metadata: JSON.stringify({ source: "reconciled" })
31438
+ });
31439
+ }
31440
+ }
31441
+ continue;
31442
+ }
31443
+ const age = lastEventAgeSeconds(db, s.id);
31444
+ const stale = (age === null || age > threshold) && !hasInFlightDependents(db, s.id);
31445
+ if (stale) {
31446
+ actions.push({
31447
+ sessionId: s.id,
31448
+ kind: "stale-close",
31449
+ 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`
31450
+ });
31451
+ if (!dryRun) {
31452
+ commitReasonClose(
31453
+ db,
31454
+ s.id,
31455
+ {
31456
+ event_type: "session_auto_closed_stale",
31457
+ phase: "complete",
31458
+ metadata: JSON.stringify({ source: "reconciled", threshold_seconds: threshold })
31459
+ },
31460
+ { status: "closed", current_phase: "complete" }
31461
+ );
31462
+ }
31463
+ }
31464
+ }
31465
+ return { dryRun, actions };
31466
+ }
31467
+
31468
+ // ../cli/src/lib/db/liveness.ts
31469
+ var PID_REUSE_GUARD_MS = 24 * 60 * 60 * 1e3;
31470
+ function defaultIsAlive(pid) {
31471
+ try {
31472
+ process.kill(pid, 0);
31473
+ return true;
31474
+ } catch (err) {
31475
+ return !(err instanceof Error && "code" in err && err.code === "ESRCH");
31476
+ }
31477
+ }
31478
+ function sqliteUtcMs(ts) {
31479
+ const sqliteShape = ts.includes(" ");
31480
+ return new Date(sqliteShape ? ts.replace(" ", "T") + "Z" : ts).getTime();
31481
+ }
31482
+
31483
+ // ../cli/src/lib/state/exit-codes.ts
30917
31484
  var CANCELLED_EXIT_CODE = -2;
31485
+ var ORPHAN_EXIT_CODE = -3;
31486
+ var CASCADE_CLOSE_EXIT_CODE = -4;
31487
+
31488
+ // ../cli/src/lib/db/agent-sessions.ts
30918
31489
  var NOTE_ORPHAN_PREFIX = "orphaned by liveness sweep";
31490
+ var INSTANCE_COMMAND = "session-instance";
31491
+ function cascadeTerminateExecutions(db, workflowId, exitCode, note) {
31492
+ db.run(
31493
+ `UPDATE command_executions
31494
+ SET finished_at = datetime('now'),
31495
+ exit_code = ?,
31496
+ pid = NULL,
31497
+ notes = COALESCE(notes || char(10), '') || ?
31498
+ WHERE workflow_id = ?
31499
+ AND finished_at IS NULL`,
31500
+ [exitCode, note, workflowId]
31501
+ );
31502
+ }
30919
31503
  function rowToAgentSession(row) {
30920
31504
  return {
30921
31505
  // The OCR-owned id is the `uid` column. Fall back to the integer
@@ -30930,6 +31514,7 @@ function rowToAgentSession(row) {
30930
31514
  resolved_model: row.resolved_model,
30931
31515
  phase: null,
30932
31516
  status: deriveStatus(row),
31517
+ kind: rowKind(row),
30933
31518
  pid: row.pid,
30934
31519
  started_at: row.started_at,
30935
31520
  last_heartbeat_at: row.last_heartbeat_at ?? row.started_at,
@@ -30943,7 +31528,9 @@ function deriveStatus(row) {
30943
31528
  return "running";
30944
31529
  }
30945
31530
  if (row.exit_code === ORPHAN_EXIT_CODE) return "orphaned";
30946
- if (row.exit_code === CANCELLED_EXIT_CODE) return "cancelled";
31531
+ if (row.exit_code === CANCELLED_EXIT_CODE || row.exit_code === CASCADE_CLOSE_EXIT_CODE) {
31532
+ return "cancelled";
31533
+ }
30947
31534
  if (row.exit_code === 0) return "done";
30948
31535
  return "crashed";
30949
31536
  }
@@ -30988,38 +31575,112 @@ function linkDashboardInvocationToWorkflow(db, dashboardUid, workflowId) {
30988
31575
  [workflowId, dashboardUid]
30989
31576
  );
30990
31577
  }
30991
- function sweepStaleAgentSessions(db, thresholdSeconds) {
30992
- const staleSql = `
30993
- SELECT uid, id FROM command_executions
30994
- WHERE finished_at IS NULL
30995
- AND last_heartbeat_at IS NOT NULL
30996
- AND (julianday('now') - julianday(last_heartbeat_at)) * 86400 > ?
30997
- `;
30998
- const stale = resultToRows(
30999
- db.exec(staleSql, [thresholdSeconds])
31578
+ function sweepStaleAgentSessions(db, thresholdSeconds, isAlive = defaultIsAlive) {
31579
+ const candidates = resultToRows(
31580
+ db.exec(
31581
+ `SELECT uid, id, pid, started_at, workflow_id, command, last_heartbeat_at
31582
+ FROM command_executions
31583
+ WHERE finished_at IS NULL
31584
+ AND pid IS NOT NULL
31585
+ AND last_heartbeat_at IS NOT NULL
31586
+ AND (julianday('now') - julianday(last_heartbeat_at)) * 86400 > ?`,
31587
+ [thresholdSeconds]
31588
+ )
31000
31589
  );
31001
- if (stale.length === 0) {
31002
- return { orphanedIds: [] };
31590
+ if (candidates.length === 0) {
31591
+ return { orphanedIds: [], cascadedWorkflowIds: [] };
31592
+ }
31593
+ const reuseCutoffMs = Date.now() - PID_REUSE_GUARD_MS;
31594
+ const dead = candidates.filter((row) => {
31595
+ if (row.pid === null) return false;
31596
+ if (sqliteUtcMs(row.started_at) < reuseCutoffMs) return false;
31597
+ return !isAlive(row.pid);
31598
+ });
31599
+ if (dead.length === 0) {
31600
+ return { orphanedIds: [], cascadedWorkflowIds: [] };
31003
31601
  }
31004
31602
  const note = `${NOTE_ORPHAN_PREFIX} (threshold ${thresholdSeconds}s)`;
31005
- db.run(
31006
- `UPDATE command_executions
31007
- SET finished_at = datetime('now'),
31008
- exit_code = ?,
31009
- notes = COALESCE(notes || char(10), '') || ?
31010
- WHERE finished_at IS NULL
31011
- AND last_heartbeat_at IS NOT NULL
31012
- AND (julianday('now') - julianday(last_heartbeat_at)) * 86400 > ?`,
31013
- [ORPHAN_EXIT_CODE, note, thresholdSeconds]
31014
- );
31603
+ const placeholders = dead.map(() => "?").join(", ");
31604
+ const cascadedWorkflowIds = [];
31605
+ db.transaction(() => {
31606
+ db.run(
31607
+ `UPDATE command_executions
31608
+ SET finished_at = datetime('now'),
31609
+ exit_code = ?,
31610
+ pid = NULL,
31611
+ notes = COALESCE(notes || char(10), '') || ?
31612
+ WHERE id IN (${placeholders})
31613
+ AND finished_at IS NULL`,
31614
+ [ORPHAN_EXIT_CODE, note, ...dead.map((r) => r.id)]
31615
+ );
31616
+ for (const row of dead) {
31617
+ if (row.workflow_id && rowKind(row) === "supervisor") {
31618
+ cascadeTerminateExecutions(
31619
+ db,
31620
+ row.workflow_id,
31621
+ CASCADE_CLOSE_EXIT_CODE,
31622
+ "cascade-closed: workflow process orphaned by liveness sweep"
31623
+ );
31624
+ cascadedWorkflowIds.push(row.workflow_id);
31625
+ }
31626
+ }
31627
+ });
31015
31628
  return {
31016
- orphanedIds: stale.map((row) => row.uid ?? String(row.id))
31629
+ orphanedIds: dead.map((r) => r.uid ?? String(r.id)),
31630
+ cascadedWorkflowIds
31017
31631
  };
31018
31632
  }
31633
+ function rowKind(row) {
31634
+ if (row.command === INSTANCE_COMMAND || row.command.startsWith(`${INSTANCE_COMMAND}:`)) {
31635
+ return "instance";
31636
+ }
31637
+ return row.last_heartbeat_at == null ? "utility" : "supervisor";
31638
+ }
31639
+ function sweepStaleSessions(db, thresholdSeconds) {
31640
+ const sql = `
31641
+ SELECT s.id
31642
+ FROM sessions s
31643
+ LEFT JOIN (
31644
+ SELECT session_id, MAX(created_at) AS last_event_at
31645
+ FROM orchestration_events
31646
+ GROUP BY session_id
31647
+ ) e ON e.session_id = s.id
31648
+ WHERE s.status = 'active'
31649
+ AND (
31650
+ e.last_event_at IS NULL
31651
+ OR (julianday('now') - julianday(e.last_event_at)) * 86400 > ?
31652
+ )
31653
+ AND NOT EXISTS (
31654
+ SELECT 1 FROM command_executions ce
31655
+ WHERE ce.workflow_id = s.id
31656
+ AND ce.finished_at IS NULL
31657
+ )
31658
+ `;
31659
+ const rows = resultToRows(db.exec(sql, [thresholdSeconds]));
31660
+ if (rows.length === 0) {
31661
+ return { closedSessionIds: [] };
31662
+ }
31663
+ for (const row of rows) {
31664
+ commitReasonClose(
31665
+ db,
31666
+ row.id,
31667
+ {
31668
+ event_type: "session_auto_closed_stale",
31669
+ phase: "complete",
31670
+ metadata: JSON.stringify({
31671
+ reason: "no events past threshold; no in-flight dependents",
31672
+ threshold_seconds: thresholdSeconds
31673
+ })
31674
+ },
31675
+ { status: "closed", current_phase: "complete" }
31676
+ );
31677
+ }
31678
+ return { closedSessionIds: rows.map((r) => r.id) };
31679
+ }
31019
31680
 
31020
31681
  // ../cli/src/lib/db/command-log.ts
31021
- import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
31022
- import { dirname as dirname2, join as join2 } from "node:path";
31682
+ import { appendFileSync, existsSync as existsSync3, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
31683
+ import { dirname as dirname3, join as join3 } from "node:path";
31023
31684
  import { randomUUID } from "node:crypto";
31024
31685
  var CACHE_DIR = ".cache";
31025
31686
  var FILENAME = "command-history.jsonl";
@@ -31030,16 +31691,16 @@ function generateCommandUid() {
31030
31691
  return randomUUID();
31031
31692
  }
31032
31693
  function cacheDir(ocrDir) {
31033
- return join2(ocrDir, "data", CACHE_DIR);
31694
+ return join3(ocrDir, "data", CACHE_DIR);
31034
31695
  }
31035
31696
  function commandLogPath(ocrDir) {
31036
- return join2(cacheDir(ocrDir), FILENAME);
31697
+ return join3(cacheDir(ocrDir), FILENAME);
31037
31698
  }
31038
31699
  function appendCommandLog(ocrDir, entry) {
31039
31700
  try {
31040
31701
  const filePath = commandLogPath(ocrDir);
31041
- const dir = dirname2(filePath);
31042
- if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
31702
+ const dir = dirname3(filePath);
31703
+ if (!existsSync3(dir)) mkdirSync(dir, { recursive: true });
31043
31704
  const line = JSON.stringify(entry) + "\n";
31044
31705
  appendFileSync(filePath, line, { encoding: "utf-8" });
31045
31706
  if (approxLineCount >= 0) approxLineCount++;
@@ -31049,7 +31710,7 @@ function appendCommandLog(ocrDir, entry) {
31049
31710
  }
31050
31711
  function readCommandLog(ocrDir) {
31051
31712
  const filePath = commandLogPath(ocrDir);
31052
- if (!existsSync2(filePath)) return [];
31713
+ if (!existsSync3(filePath)) return [];
31053
31714
  const content = readFileSync(filePath, "utf-8");
31054
31715
  const entries = [];
31055
31716
  for (const line of content.split("\n")) {
@@ -31115,99 +31776,137 @@ function rotateIfNeeded(filePath) {
31115
31776
  }
31116
31777
 
31117
31778
  // ../cli/src/lib/db/index.ts
31118
- function locateWasm() {
31119
- const require2 = createRequire(import.meta.url);
31120
- const sqlJsPath = require2.resolve("sql.js");
31121
- return join3(dirname3(sqlJsPath), "sql-wasm.wasm");
31779
+ var V2_SCHEMA_VERSION = 12;
31780
+ function maybeSnapshotBeforeUpgrade(db, dbPath, fromVersion) {
31781
+ if (fromVersion < 1 || fromVersion >= V2_SCHEMA_VERSION) return null;
31782
+ const bakPath = `${dbPath}.bak.v${fromVersion}`;
31783
+ if (existsSync4(bakPath)) return bakPath;
31784
+ try {
31785
+ if (!existsSync4(dbPath) || statSync(dbPath).size === 0) return null;
31786
+ db.pragma("wal_checkpoint(TRUNCATE)");
31787
+ copyFileSync(dbPath, bakPath);
31788
+ return bakPath;
31789
+ } catch {
31790
+ return null;
31791
+ }
31122
31792
  }
31123
- function applyPragmas(db) {
31124
- db.run("PRAGMA foreign_keys = ON;");
31125
- db.run("PRAGMA journal_mode = WAL;");
31126
- db.run("PRAGMA busy_timeout = 5000;");
31793
+ function formatUpgradeNotice(bakPath, reconcile) {
31794
+ const lines = [
31795
+ "Storage upgraded to v2.0 \u2014 durable SQLite engine (WAL), event-sourced lifecycle."
31796
+ ];
31797
+ if (bakPath) {
31798
+ lines.push(` A backup of your previous database was saved to: ${bakPath}`);
31799
+ }
31800
+ const repairs = (reconcile?.actions ?? []).filter((a) => a.kind !== "ok");
31801
+ if (repairs.length > 0) {
31802
+ const n = (kind) => repairs.filter((a) => a.kind === kind).length;
31803
+ const parts = [];
31804
+ const finalized = n("synthesize-round-completed") + n("synthesize-map-completed");
31805
+ if (finalized > 0) parts.push(`${finalized} finalized from artifacts`);
31806
+ if (n("grandfather") > 0) parts.push(`${n("grandfather")} grandfathered`);
31807
+ if (n("stale-close") > 0) parts.push(`${n("stale-close")} stale closed`);
31808
+ lines.push(
31809
+ ` Reconciled ${repairs.length} legacy session(s): ${parts.join(", ")}.`
31810
+ );
31811
+ }
31812
+ lines.push(" Run `ocr doctor` to verify the storage engine.");
31813
+ return lines.map((l) => `[ocr] ${l}`).join("\n");
31127
31814
  }
31128
- function walCheckpointTruncate(dbPath) {
31129
- if (!existsSync3(dbPath)) {
31130
- return "skipped";
31815
+ var connections = /* @__PURE__ */ new Map();
31816
+ async function openDatabase(dbPath) {
31817
+ const cached = connections.get(dbPath);
31818
+ if (cached) {
31819
+ return cached;
31820
+ }
31821
+ const dir = dirname4(dbPath);
31822
+ if (!existsSync4(dir)) {
31823
+ mkdirSync2(dir, { recursive: true });
31131
31824
  }
31825
+ const db = openEngine(dbPath);
31826
+ connections.set(dbPath, db);
31827
+ return db;
31828
+ }
31829
+ async function ensureDatabase(ocrDir) {
31830
+ const dataDir = join4(ocrDir, "data");
31831
+ if (!existsSync4(dataDir)) {
31832
+ mkdirSync2(dataDir, { recursive: true });
31833
+ }
31834
+ const dbPath = join4(dataDir, "ocr.db");
31835
+ const db = await openDatabase(dbPath);
31836
+ let before = 0;
31132
31837
  try {
31133
- const probe = spawnSync("sqlite3", ["-version"], {
31134
- stdio: "ignore",
31135
- timeout: 2e3
31136
- });
31137
- if (probe.status !== 0) {
31138
- return "skipped";
31139
- }
31838
+ before = getSchemaVersion(db);
31140
31839
  } catch {
31840
+ before = 0;
31841
+ }
31842
+ const isLegacyUpgrade = before >= 1 && before < V2_SCHEMA_VERSION;
31843
+ const bakPath = maybeSnapshotBeforeUpgrade(db, dbPath, before);
31844
+ runMigrations(db);
31845
+ let reconcile;
31846
+ if (before < V2_SCHEMA_VERSION) {
31847
+ try {
31848
+ reconcile = reconcileLegacyState(db, ocrDir);
31849
+ } catch (err) {
31850
+ console.error(
31851
+ `[ocr] legacy reconciliation skipped: ${err instanceof Error ? err.message : String(err)}`
31852
+ );
31853
+ }
31854
+ }
31855
+ if (isLegacyUpgrade) {
31856
+ const notice = formatUpgradeNotice(bakPath, reconcile);
31857
+ if (notice) console.error(notice);
31858
+ }
31859
+ return db;
31860
+ }
31861
+ function walCheckpointTruncate(dbPath) {
31862
+ if (!existsSync4(dbPath)) {
31141
31863
  return "skipped";
31142
31864
  }
31865
+ const cached = connections.get(dbPath);
31866
+ if (cached) {
31867
+ try {
31868
+ cached.pragma("wal_checkpoint(TRUNCATE)");
31869
+ return "checkpointed";
31870
+ } catch {
31871
+ return "failed";
31872
+ }
31873
+ }
31874
+ let transient;
31143
31875
  try {
31144
- const result = spawnSync(
31145
- "sqlite3",
31146
- [dbPath, "PRAGMA wal_checkpoint(TRUNCATE);"],
31147
- {
31148
- stdio: "ignore",
31149
- timeout: 5e3
31150
- }
31151
- );
31152
- return result.status === 0 ? "checkpointed" : "failed";
31876
+ transient = openEngine(dbPath);
31877
+ transient.pragma("wal_checkpoint(TRUNCATE)");
31878
+ return "checkpointed";
31153
31879
  } catch {
31154
31880
  return "failed";
31881
+ } finally {
31882
+ try {
31883
+ transient?.close();
31884
+ } catch {
31885
+ }
31886
+ }
31887
+ }
31888
+ function closeDatabase(dbPath) {
31889
+ const db = connections.get(dbPath);
31890
+ if (db) {
31891
+ db.close();
31892
+ connections.delete(dbPath);
31155
31893
  }
31156
31894
  }
31157
31895
 
31158
31896
  // src/server/db.ts
31897
+ import { join as join5 } from "node:path";
31159
31898
  var cachedDb = null;
31160
31899
  var cachedDbPath = null;
31161
- var preSaveHook = null;
31162
- var postSaveHook = null;
31163
- function registerSaveHooks(preSave, postSave) {
31164
- preSaveHook = preSave;
31165
- postSaveHook = postSave;
31166
- }
31167
31900
  async function openDb(ocrDir) {
31168
- const dbPath = join4(ocrDir, "data", "ocr.db");
31169
- if (cachedDb && cachedDbPath === dbPath) {
31170
- return cachedDb;
31171
- }
31172
- const wasmBuffer = readFileSync3(locateWasm());
31173
- const wasmBinary = wasmBuffer.buffer.slice(
31174
- wasmBuffer.byteOffset,
31175
- wasmBuffer.byteOffset + wasmBuffer.byteLength
31176
- );
31177
- const SQL = await initSqlJs2({ wasmBinary });
31178
- let db;
31179
- if (existsSync4(dbPath)) {
31180
- const fileBuffer = readFileSync3(dbPath);
31181
- db = new SQL.Database(fileBuffer);
31182
- } else {
31183
- const dataDir = dirname4(dbPath);
31184
- if (!existsSync4(dataDir)) {
31185
- mkdirSync3(dataDir, { recursive: true });
31186
- }
31187
- db = new SQL.Database();
31188
- }
31189
- applyPragmas(db);
31190
- runMigrations(db);
31901
+ const dbPath = join5(ocrDir, "data", "ocr.db");
31902
+ const db = await ensureDatabase(ocrDir);
31191
31903
  cachedDb = db;
31192
31904
  cachedDbPath = dbPath;
31193
31905
  return db;
31194
31906
  }
31195
- function saveDb(db, ocrDir) {
31196
- preSaveHook?.();
31197
- const dbPath = join4(ocrDir, "data", "ocr.db");
31198
- const data = db.export();
31199
- const dir = dirname4(dbPath);
31200
- if (!existsSync4(dir)) {
31201
- mkdirSync3(dir, { recursive: true });
31202
- }
31203
- const tmpPath = `${dbPath}.${process.pid}.tmp`;
31204
- writeFileSync3(tmpPath, Buffer.from(data));
31205
- renameSync3(tmpPath, dbPath);
31206
- postSaveHook?.();
31207
- }
31208
31907
  function closeDb() {
31209
- if (cachedDb) {
31210
- cachedDb.close();
31908
+ if (cachedDbPath) {
31909
+ closeDatabase(cachedDbPath);
31211
31910
  cachedDb = null;
31212
31911
  cachedDbPath = null;
31213
31912
  }
@@ -31431,7 +32130,11 @@ function deleteNote(db, noteId) {
31431
32130
  function getCommandHistory(db, limit = 50) {
31432
32131
  return resultToRows(
31433
32132
  db.exec(
31434
- "SELECT * FROM command_executions ORDER BY started_at DESC LIMIT ?",
32133
+ `SELECT ce.*, sc.completeness_state AS workflow_completeness
32134
+ FROM command_executions ce
32135
+ LEFT JOIN session_completeness sc ON sc.session_id = ce.workflow_id
32136
+ ORDER BY ce.started_at DESC
32137
+ LIMIT ?`,
31435
32138
  [limit]
31436
32139
  )
31437
32140
  );
@@ -32120,34 +32823,7 @@ var VALID_ROUND_STATUSES = /* @__PURE__ */ new Set([
32120
32823
  "acknowledged",
32121
32824
  "dismissed"
32122
32825
  ]);
32123
- var saveTimer = null;
32124
- var pendingDb = null;
32125
- var pendingOcrDir = null;
32126
- function debouncedSave(db, ocrDir) {
32127
- pendingDb = db;
32128
- pendingOcrDir = ocrDir;
32129
- if (saveTimer) clearTimeout(saveTimer);
32130
- saveTimer = setTimeout(() => {
32131
- if (pendingDb && pendingOcrDir) {
32132
- saveDb(pendingDb, pendingOcrDir);
32133
- }
32134
- saveTimer = null;
32135
- pendingDb = null;
32136
- pendingOcrDir = null;
32137
- }, 500);
32138
- }
32139
- function flushSave() {
32140
- if (saveTimer) {
32141
- clearTimeout(saveTimer);
32142
- saveTimer = null;
32143
- }
32144
- if (pendingDb && pendingOcrDir) {
32145
- saveDb(pendingDb, pendingOcrDir);
32146
- pendingDb = null;
32147
- pendingOcrDir = null;
32148
- }
32149
- }
32150
- function createProgressRouter(db, ocrDir) {
32826
+ function createProgressRouter(db) {
32151
32827
  const router = (0, import_express5.Router)();
32152
32828
  router.patch("/map-files/:id/progress", (req, res) => {
32153
32829
  try {
@@ -32167,7 +32843,6 @@ function createProgressRouter(db, ocrDir) {
32167
32843
  return;
32168
32844
  }
32169
32845
  upsertFileProgress(db, fileId, isReviewed);
32170
- debouncedSave(db, ocrDir);
32171
32846
  const progress = getFileProgress(db, fileId);
32172
32847
  res.json(progress);
32173
32848
  } catch (err) {
@@ -32183,7 +32858,6 @@ function createProgressRouter(db, ocrDir) {
32183
32858
  return;
32184
32859
  }
32185
32860
  deleteFileProgress(db, fileId);
32186
- debouncedSave(db, ocrDir);
32187
32861
  res.status(200).json({ deleted: true });
32188
32862
  } catch (err) {
32189
32863
  console.error("Failed to clear file progress:", err);
@@ -32211,7 +32885,6 @@ function createProgressRouter(db, ocrDir) {
32211
32885
  return;
32212
32886
  }
32213
32887
  upsertFindingProgress(db, findingId, status);
32214
- debouncedSave(db, ocrDir);
32215
32888
  const progress = getFindingProgress(db, findingId);
32216
32889
  res.json(progress);
32217
32890
  } catch (err) {
@@ -32227,7 +32900,6 @@ function createProgressRouter(db, ocrDir) {
32227
32900
  return;
32228
32901
  }
32229
32902
  deleteFindingProgress(db, findingId);
32230
- debouncedSave(db, ocrDir);
32231
32903
  res.status(200).json({ deleted: true });
32232
32904
  } catch (err) {
32233
32905
  console.error("Failed to clear finding progress:", err);
@@ -32255,7 +32927,6 @@ function createProgressRouter(db, ocrDir) {
32255
32927
  return;
32256
32928
  }
32257
32929
  upsertRoundProgress(db, roundId, status);
32258
- debouncedSave(db, ocrDir);
32259
32930
  const progress = getRoundProgress(db, roundId);
32260
32931
  res.json(progress);
32261
32932
  } catch (err) {
@@ -32271,7 +32942,6 @@ function createProgressRouter(db, ocrDir) {
32271
32942
  return;
32272
32943
  }
32273
32944
  deleteRoundProgress(db, roundId);
32274
- debouncedSave(db, ocrDir);
32275
32945
  res.status(200).json({ deleted: true });
32276
32946
  } catch (err) {
32277
32947
  console.error("Failed to clear round progress:", err);
@@ -32291,7 +32961,7 @@ var VALID_TARGET_TYPES = /* @__PURE__ */ new Set([
32291
32961
  "section",
32292
32962
  "file"
32293
32963
  ]);
32294
- function createNotesRouter(db, ocrDir) {
32964
+ function createNotesRouter(db) {
32295
32965
  const router = (0, import_express6.Router)();
32296
32966
  router.get("/", (req, res) => {
32297
32967
  try {
@@ -32330,7 +33000,6 @@ function createNotesRouter(db, ocrDir) {
32330
33000
  return;
32331
33001
  }
32332
33002
  const noteId = insertNote(db, target_type, target_id, content);
32333
- saveDb(db, ocrDir);
32334
33003
  const note = getNote(db, noteId);
32335
33004
  res.status(201).json(note);
32336
33005
  } catch (err) {
@@ -32356,7 +33025,6 @@ function createNotesRouter(db, ocrDir) {
32356
33025
  return;
32357
33026
  }
32358
33027
  updateNote(db, noteId, content);
32359
- saveDb(db, ocrDir);
32360
33028
  const note = getNote(db, noteId);
32361
33029
  res.json(note);
32362
33030
  } catch (err) {
@@ -32377,7 +33045,6 @@ function createNotesRouter(db, ocrDir) {
32377
33045
  return;
32378
33046
  }
32379
33047
  deleteNote(db, noteId);
32380
- saveDb(db, ocrDir);
32381
33048
  res.status(200).json({ deleted: true });
32382
33049
  } catch (err) {
32383
33050
  console.error("Failed to delete note:", err);
@@ -32442,12 +33109,44 @@ function spawnBinary(binary, args, opts) {
32442
33109
  }
32443
33110
 
32444
33111
  // src/server/socket/command-runner.ts
32445
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, unlinkSync, mkdirSync as mkdirSync5, existsSync as existsSync7 } from "node:fs";
32446
- import { dirname as dirname6, join as join9 } from "node:path";
33112
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync4, existsSync as existsSync7 } from "node:fs";
33113
+ import { dirname as dirname6, join as join10 } from "node:path";
33114
+
33115
+ // src/server/services/command-outcome.ts
33116
+ function deriveCommandOutcome(exitCode, completeness) {
33117
+ if (exitCode === null) return null;
33118
+ if (exitCode === CANCELLED_EXIT_CODE || exitCode === CASCADE_CLOSE_EXIT_CODE) {
33119
+ return "cancelled";
33120
+ }
33121
+ if (exitCode !== 0) return "failed";
33122
+ if (completeness === null || completeness === "complete") return "success";
33123
+ return "incomplete";
33124
+ }
33125
+ function deriveCancellationReason(exitCode) {
33126
+ if (exitCode === CANCELLED_EXIT_CODE) return "user";
33127
+ if (exitCode === CASCADE_CLOSE_EXIT_CODE) return "cascade";
33128
+ return null;
33129
+ }
33130
+ function getWorkflowCompletenessForExecution(db, executionId) {
33131
+ const result = db.exec(
33132
+ `SELECT sc.completeness_state
33133
+ FROM command_executions ce
33134
+ LEFT JOIN session_completeness sc ON sc.session_id = ce.workflow_id
33135
+ WHERE ce.id = ?`,
33136
+ [executionId]
33137
+ );
33138
+ const row = result[0]?.values[0];
33139
+ if (!row) return null;
33140
+ const state = row[0];
33141
+ if (state === "complete" || state === "closed_without_artifact" || state === "in_flight" || state === "open_no_artifact") {
33142
+ return state;
33143
+ }
33144
+ return null;
33145
+ }
32447
33146
 
32448
33147
  // src/server/services/ai-cli/index.ts
32449
- import { readFileSync as readFileSync5 } from "node:fs";
32450
- import { join as join7 } from "node:path";
33148
+ import { readFileSync as readFileSync3 } from "node:fs";
33149
+ import { join as join8 } from "node:path";
32451
33150
 
32452
33151
  // src/server/socket/env.ts
32453
33152
  var ENV_ALLOWLIST = [
@@ -32937,19 +33636,19 @@ function extractToolOutput(part) {
32937
33636
  import {
32938
33637
  createWriteStream,
32939
33638
  existsSync as existsSync5,
32940
- mkdirSync as mkdirSync4,
32941
- readFileSync as readFileSync4
33639
+ mkdirSync as mkdirSync3,
33640
+ readFileSync as readFileSync2
32942
33641
  } from "node:fs";
32943
- import { join as join5 } from "node:path";
33642
+ import { join as join6 } from "node:path";
32944
33643
  function eventsDir(ocrDir) {
32945
- const dir = join5(ocrDir, "data", "events");
33644
+ const dir = join6(ocrDir, "data", "events");
32946
33645
  if (!existsSync5(dir)) {
32947
- mkdirSync4(dir, { recursive: true });
33646
+ mkdirSync3(dir, { recursive: true });
32948
33647
  }
32949
33648
  return dir;
32950
33649
  }
32951
33650
  function eventJournalPath(ocrDir, executionId) {
32952
- return join5(eventsDir(ocrDir), `${executionId}.jsonl`);
33651
+ return join6(eventsDir(ocrDir), `${executionId}.jsonl`);
32953
33652
  }
32954
33653
  var EventJournalAppender = class {
32955
33654
  stream;
@@ -32989,7 +33688,7 @@ function readEventJournal(ocrDir, executionId) {
32989
33688
  if (!existsSync5(path2)) return [];
32990
33689
  let raw;
32991
33690
  try {
32992
- raw = readFileSync4(path2, "utf-8");
33691
+ raw = readFileSync2(path2, "utf-8");
32993
33692
  } catch {
32994
33693
  return [];
32995
33694
  }
@@ -33008,7 +33707,7 @@ function readEventJournal(ocrDir, executionId) {
33008
33707
 
33009
33708
  // src/server/services/ai-cli/helpers.ts
33010
33709
  import { tmpdir } from "node:os";
33011
- import { join as join6 } from "node:path";
33710
+ import { join as join7 } from "node:path";
33012
33711
  function formatToolDetail(tool, input) {
33013
33712
  switch (tool) {
33014
33713
  case "Read":
@@ -33032,13 +33731,13 @@ function formatToolDetail(tool, input) {
33032
33731
  return `Using ${tool}`;
33033
33732
  }
33034
33733
  }
33035
- var TEMP_BASE = join6(tmpdir(), "ocr-ai-prompts");
33734
+ var TEMP_BASE = join7(tmpdir(), "ocr-ai-prompts");
33036
33735
 
33037
33736
  // src/server/services/ai-cli/index.ts
33038
33737
  function readAiCliPreference(ocrDir) {
33039
33738
  try {
33040
- const configPath = join7(ocrDir, "config.yaml");
33041
- const content = readFileSync5(configPath, "utf-8");
33739
+ const configPath = join8(ocrDir, "config.yaml");
33740
+ const content = readFileSync3(configPath, "utf-8");
33042
33741
  const match = content.match(/^\s*ai_cli:\s*(\S+)/m);
33043
33742
  const value = match?.[1] ?? "auto";
33044
33743
  if (value === "claude" || value === "opencode" || value === "off") return value;
@@ -33148,19 +33847,19 @@ var AiCliService = class {
33148
33847
 
33149
33848
  // src/server/socket/cli-resolver.ts
33150
33849
  import { existsSync as existsSync6 } from "node:fs";
33151
- import { dirname as dirname5, join as join8 } from "node:path";
33850
+ import { dirname as dirname5, join as join9 } from "node:path";
33152
33851
  import { fileURLToPath } from "node:url";
33153
33852
  var __dirname = dirname5(fileURLToPath(import.meta.url));
33154
33853
  function resolveLocalCli() {
33155
- const parentDir = join8(__dirname, "..");
33156
- const bundledCli = join8(parentDir, "index.js");
33157
- if (existsSync6(bundledCli) && existsSync6(join8(parentDir, "dashboard", "server.js"))) {
33854
+ const parentDir = join9(__dirname, "..");
33855
+ const bundledCli = join9(parentDir, "index.js");
33856
+ if (existsSync6(bundledCli) && existsSync6(join9(parentDir, "dashboard", "server.js"))) {
33158
33857
  return bundledCli;
33159
33858
  }
33160
33859
  let dir = __dirname;
33161
33860
  for (let i = 0; i < 8; i++) {
33162
- if (existsSync6(join8(dir, "nx.json"))) {
33163
- const candidate = join8(dir, "packages", "cli", "dist", "index.js");
33861
+ if (existsSync6(join9(dir, "nx.json"))) {
33862
+ const candidate = join9(dir, "packages", "cli", "dist", "index.js");
33164
33863
  if (existsSync6(candidate)) return candidate;
33165
33864
  break;
33166
33865
  }
@@ -33286,8 +33985,8 @@ function buildPrompt(opts) {
33286
33985
  "",
33287
33986
  "Examples:",
33288
33987
  `- Instead of \`ocr state show\`, run: \`node ${localCli} state show\``,
33289
- `- Instead of \`ocr state init ...\`, run: \`node ${localCli} state init ...\``,
33290
- `- Instead of \`ocr state transition ...\`, run: \`node ${localCli} state transition ...\``,
33988
+ `- Instead of \`ocr state begin ...\`, run: \`node ${localCli} state begin ...\``,
33989
+ `- Instead of \`ocr state advance ...\`, run: \`node ${localCli} state advance ...\``,
33291
33990
  "",
33292
33991
  "This applies to every `ocr` invocation. Do NOT use bare `ocr` commands."
33293
33992
  );
@@ -33297,7 +33996,7 @@ function buildPrompt(opts) {
33297
33996
  "",
33298
33997
  "## Dashboard Linkage (REQUIRED for terminal handoff)",
33299
33998
  "",
33300
- 'You are running inside the OCR dashboard. To enable the "Pick up in terminal" affordance for this review, your first `ocr state init` invocation MUST include this flag:',
33999
+ 'You are running inside the OCR dashboard. To enable the "Pick up in terminal" affordance for this review, your first `ocr state begin` invocation MUST include this flag:',
33301
34000
  "",
33302
34001
  "```",
33303
34002
  `--dashboard-uid ${executionUid}`,
@@ -33306,7 +34005,7 @@ function buildPrompt(opts) {
33306
34005
  "Full example:",
33307
34006
  "",
33308
34007
  "```",
33309
- `node ${localCli} state init --session-id <id> --branch <branch> --workflow-type review --dashboard-uid ${executionUid}`,
34008
+ `node ${localCli} state begin --session-id <id> --branch <branch> --workflow-type review --dashboard-uid ${executionUid}`,
33310
34009
  "```",
33311
34010
  "",
33312
34011
  "Without this flag the dashboard cannot link your review session to its execution row, and the resume command will not be available."
@@ -33353,17 +34052,17 @@ function extractPerInstanceModels(subArgs) {
33353
34052
  var MAX_CONCURRENT = 3;
33354
34053
  var activeCommands = /* @__PURE__ */ new Map();
33355
34054
  function spawnMarkerPath(ocrDir) {
33356
- return join9(ocrDir, "data", "dashboard-active-spawn.json");
34055
+ return join10(ocrDir, "data", "dashboard-active-spawn.json");
33357
34056
  }
33358
34057
  function writeSpawnMarker(ocrDir, executionUid, pid) {
33359
- const dataDir = join9(ocrDir, "data");
33360
- if (!existsSync7(dataDir)) mkdirSync5(dataDir, { recursive: true });
34058
+ const dataDir = join10(ocrDir, "data");
34059
+ if (!existsSync7(dataDir)) mkdirSync4(dataDir, { recursive: true });
33361
34060
  const payload = JSON.stringify({
33362
34061
  execution_uid: executionUid,
33363
34062
  pid,
33364
34063
  started_at: (/* @__PURE__ */ new Date()).toISOString()
33365
34064
  });
33366
- writeFileSync4(spawnMarkerPath(ocrDir), payload, { mode: 384 });
34065
+ writeFileSync2(spawnMarkerPath(ocrDir), payload, { mode: 384 });
33367
34066
  }
33368
34067
  function clearSpawnMarker(ocrDir) {
33369
34068
  try {
@@ -33571,10 +34270,10 @@ function spawnAiCommand(io2, _socket, db, ocrDir, executionId, baseCommand, subA
33571
34270
  io2.emit("command:output", { execution_id: executionId, content: warning });
33572
34271
  }
33573
34272
  }
33574
- const commandMdPath = join9(ocrDir, "commands", `${baseCommand}.md`);
34273
+ const commandMdPath = join10(ocrDir, "commands", `${baseCommand}.md`);
33575
34274
  let commandContent;
33576
34275
  try {
33577
- commandContent = readFileSync6(commandMdPath, "utf-8");
34276
+ commandContent = readFileSync4(commandMdPath, "utf-8");
33578
34277
  } catch {
33579
34278
  const content = `Error: Could not read command file at ${commandMdPath}
33580
34279
  `;
@@ -33805,7 +34504,9 @@ function finishExecution(io2, db, ocrDir, executionId, code, output) {
33805
34504
  WHERE id = ?`,
33806
34505
  [code, finishedAt, output, executionId]
33807
34506
  );
33808
- saveDb(db, ocrDir);
34507
+ const completeness = getWorkflowCompletenessForExecution(db, executionId);
34508
+ const outcome = deriveCommandOutcome(code, completeness);
34509
+ const cancellationReason = deriveCancellationReason(code);
33809
34510
  if (entry?.uid) {
33810
34511
  appendCommandLog(ocrDir, {
33811
34512
  v: 1,
@@ -33824,7 +34525,9 @@ function finishExecution(io2, db, ocrDir, executionId, code, output) {
33824
34525
  io2.emit("command:finished", {
33825
34526
  execution_id: executionId,
33826
34527
  exitCode: code,
33827
- finished_at: finishedAt
34528
+ finished_at: finishedAt,
34529
+ outcome,
34530
+ cancellation_reason: cancellationReason
33828
34531
  });
33829
34532
  activeCommands.delete(executionId);
33830
34533
  }
@@ -33874,10 +34577,21 @@ function createCommandsRouter(db, ocrDir) {
33874
34577
  router.get("/history", (req, res) => {
33875
34578
  try {
33876
34579
  const limit = parseInt(req.query["limit"], 10) || 50;
33877
- const history = getCommandHistory(db, limit).map((row) => ({
33878
- ...row,
33879
- duration_ms: row.finished_at && row.started_at ? new Date(row.finished_at).getTime() - new Date(row.started_at).getTime() : null
33880
- }));
34580
+ const history = getCommandHistory(db, limit).map((row) => {
34581
+ const { workflow_completeness, ...persisted } = row;
34582
+ return {
34583
+ ...persisted,
34584
+ duration_ms: row.finished_at && row.started_at ? new Date(row.finished_at).getTime() - new Date(row.started_at).getTime() : null,
34585
+ // Derived from (exit_code, event-sourced workflow completeness) —
34586
+ // single source of truth shared with the live `command:finished`
34587
+ // socket event.
34588
+ outcome: deriveCommandOutcome(row.exit_code, workflow_completeness),
34589
+ // Orthogonal discriminator within the 'cancelled' bucket so the
34590
+ // client distinguishes a user cancel from a cascade close without
34591
+ // reaching past `outcome` to match a magic exit-code number.
34592
+ cancellation_reason: deriveCancellationReason(row.exit_code)
34593
+ };
34594
+ });
33881
34595
  res.json(history);
33882
34596
  } catch (err) {
33883
34597
  console.error("Failed to fetch command history:", err);
@@ -33903,8 +34617,8 @@ function createCommandsRouter(db, ocrDir) {
33903
34617
 
33904
34618
  // src/server/routes/config.ts
33905
34619
  var import_express9 = __toESM(require_express2(), 1);
33906
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
33907
- import { join as join10, dirname as dirname7, basename } from "node:path";
34620
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
34621
+ import { join as join11, dirname as dirname7, basename } from "node:path";
33908
34622
  var VALID_IDES = ["vscode", "cursor", "windsurf", "jetbrains", "sublime"];
33909
34623
  function detectIde() {
33910
34624
  const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? "";
@@ -33921,8 +34635,8 @@ function detectIde() {
33921
34635
  }
33922
34636
  function readIdeFromConfig(ocrDir) {
33923
34637
  try {
33924
- const configPath = join10(ocrDir, "config.yaml");
33925
- const content = readFileSync7(configPath, "utf-8");
34638
+ const configPath = join11(ocrDir, "config.yaml");
34639
+ const content = readFileSync5(configPath, "utf-8");
33926
34640
  const match = content.match(/^\s*ide:\s*(\S+)/m);
33927
34641
  return match?.[1] ?? "auto";
33928
34642
  } catch {
@@ -33968,8 +34682,8 @@ function createConfigRouter(ocrDir, aiCliService) {
33968
34682
  return;
33969
34683
  }
33970
34684
  try {
33971
- const configPath = join10(ocrDir, "config.yaml");
33972
- let content = readFileSync7(configPath, "utf-8");
34685
+ const configPath = join11(ocrDir, "config.yaml");
34686
+ let content = readFileSync5(configPath, "utf-8");
33973
34687
  if (content.match(/^\s*ide:\s*\S+/m)) {
33974
34688
  content = content.replace(/^(\s*ide:\s*)\S+/m, `$1${ide}`);
33975
34689
  } else if (content.includes("dashboard:")) {
@@ -33981,7 +34695,7 @@ dashboard:
33981
34695
  ide: ${ide}
33982
34696
  `;
33983
34697
  }
33984
- writeFileSync5(configPath, content);
34698
+ writeFileSync3(configPath, content);
33985
34699
  res.json({ ide });
33986
34700
  } catch (err) {
33987
34701
  console.error("Failed to update config:", err);
@@ -33993,7 +34707,7 @@ dashboard:
33993
34707
 
33994
34708
  // src/server/routes/chat.ts
33995
34709
  var import_express10 = __toESM(require_express2(), 1);
33996
- function createChatRouter(db, ocrDir) {
34710
+ function createChatRouter(db) {
33997
34711
  const router = (0, import_express10.Router)();
33998
34712
  router.get("/:id/chat", (req, res) => {
33999
34713
  try {
@@ -34046,7 +34760,6 @@ function createChatRouter(db, ocrDir) {
34046
34760
  return;
34047
34761
  }
34048
34762
  deleteConversation(db, conversationId);
34049
- saveDb(db, ocrDir);
34050
34763
  res.status(200).json({ deleted: true });
34051
34764
  } catch (err) {
34052
34765
  console.error("Failed to delete conversation:", err);
@@ -34058,15 +34771,15 @@ function createChatRouter(db, ocrDir) {
34058
34771
 
34059
34772
  // src/server/routes/reviewers.ts
34060
34773
  var import_express11 = __toESM(require_express2(), 1);
34061
- import { readFileSync as readFileSync8, existsSync as existsSync8, watch } from "node:fs";
34062
- import { join as join11 } from "node:path";
34774
+ import { readFileSync as readFileSync6, existsSync as existsSync8, watch } from "node:fs";
34775
+ import { join as join12 } from "node:path";
34063
34776
  function readReviewersMeta(ocrDir) {
34064
- const metaPath = join11(ocrDir, "reviewers-meta.json");
34777
+ const metaPath = join12(ocrDir, "reviewers-meta.json");
34065
34778
  if (!existsSync8(metaPath)) {
34066
34779
  return { reviewers: [], defaults: [] };
34067
34780
  }
34068
34781
  try {
34069
- const raw = readFileSync8(metaPath, "utf-8");
34782
+ const raw = readFileSync6(metaPath, "utf-8");
34070
34783
  const meta = JSON.parse(raw);
34071
34784
  const reviewers = meta.reviewers ?? [];
34072
34785
  const defaults = reviewers.filter((r) => r.is_default).map((r) => r.id);
@@ -34087,13 +34800,13 @@ function createReviewersRouter(ocrDir) {
34087
34800
  res.status(400).json({ error: "Invalid reviewer ID" });
34088
34801
  return;
34089
34802
  }
34090
- const filePath = join11(ocrDir, "skills", "references", "reviewers", `${id}.md`);
34803
+ const filePath = join12(ocrDir, "skills", "references", "reviewers", `${id}.md`);
34091
34804
  if (!existsSync8(filePath)) {
34092
34805
  res.status(404).json({ error: "Reviewer not found", id });
34093
34806
  return;
34094
34807
  }
34095
34808
  try {
34096
- const content = readFileSync8(filePath, "utf-8");
34809
+ const content = readFileSync6(filePath, "utf-8");
34097
34810
  res.json({ id, content });
34098
34811
  } catch {
34099
34812
  res.status(500).json({ error: "Failed to read reviewer file", id });
@@ -34102,7 +34815,7 @@ function createReviewersRouter(ocrDir) {
34102
34815
  return router;
34103
34816
  }
34104
34817
  function watchReviewersMeta(ocrDir, io2) {
34105
- const metaPath = join11(ocrDir, "reviewers-meta.json");
34818
+ const metaPath = join12(ocrDir, "reviewers-meta.json");
34106
34819
  let watcher = null;
34107
34820
  let debounce;
34108
34821
  try {
@@ -34178,18 +34891,18 @@ function createHandoffRouter(sessionCapture, ocrDir, syncFromDisk = () => {
34178
34891
 
34179
34892
  // src/server/routes/team.ts
34180
34893
  var import_express14 = __toESM(require_express2(), 1);
34181
- import { spawnSync as spawnSync2 } from "node:child_process";
34894
+ import { spawnSync } from "node:child_process";
34182
34895
 
34183
34896
  // ../cli/src/lib/team-config.ts
34184
34897
  var import_yaml = __toESM(require_dist(), 1);
34185
- import { existsSync as existsSync9, readFileSync as readFileSync9 } from "node:fs";
34186
- import { join as join12 } from "node:path";
34898
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
34899
+ import { join as join13 } from "node:path";
34187
34900
  function loadTeamConfig(ocrDir) {
34188
- const configPath = join12(ocrDir, "config.yaml");
34901
+ const configPath = join13(ocrDir, "config.yaml");
34189
34902
  if (!existsSync9(configPath)) {
34190
34903
  return { team: [], aliases: {}, defaultModel: null };
34191
34904
  }
34192
- const content = readFileSync9(configPath, "utf-8");
34905
+ const content = readFileSync7(configPath, "utf-8");
34193
34906
  return parseTeamConfigYaml(content);
34194
34907
  }
34195
34908
  function parseTeamConfigYaml(content) {
@@ -34470,7 +35183,7 @@ function createTeamRouter(ocrDir) {
34470
35183
  return;
34471
35184
  }
34472
35185
  try {
34473
- const result = spawnSync2("ocr", ["team", "set", "--stdin"], {
35186
+ const result = spawnSync("ocr", ["team", "set", "--stdin"], {
34474
35187
  input: JSON.stringify(body.team),
34475
35188
  encoding: "utf-8",
34476
35189
  cwd: ocrDir.replace(/\/\.ocr$/, ""),
@@ -34623,7 +35336,6 @@ function createSessionCaptureService(deps) {
34623
35336
  return;
34624
35337
  }
34625
35338
  recordVendorSessionIdForExecution(db, executionId, vendorSessionId);
34626
- saveDb(db, ocrDir);
34627
35339
  } catch (err) {
34628
35340
  console.error(
34629
35341
  `[session-capture] recordSessionId failed for execution ${executionId} \u2192 ${vendorSessionId}:`,
@@ -34634,7 +35346,6 @@ function createSessionCaptureService(deps) {
34634
35346
  function linkInvocationToWorkflow(uid, workflowId) {
34635
35347
  try {
34636
35348
  linkDashboardInvocationToWorkflow(db, uid, workflowId);
34637
- saveDb(db, ocrDir);
34638
35349
  } catch (err) {
34639
35350
  console.error("[session-capture] linkInvocationToWorkflow failed:", err);
34640
35351
  }
@@ -34791,8 +35502,8 @@ function buildDiagnostics(input) {
34791
35502
  }
34792
35503
 
34793
35504
  // src/server/services/filesystem-sync.ts
34794
- import { readdirSync, readFileSync as readFileSync10, statSync, existsSync as existsSync10 } from "node:fs";
34795
- import { join as join13, basename as basename2, dirname as dirname9, relative } from "node:path";
35505
+ import { readdirSync, readFileSync as readFileSync8, statSync as statSync2, existsSync as existsSync10 } from "node:fs";
35506
+ import { join as join14, basename as basename2, dirname as dirname9, relative } from "node:path";
34796
35507
  import { watch as watch2 } from "chokidar";
34797
35508
 
34798
35509
  // src/server/services/parsers/reviewer-parser.ts
@@ -34986,15 +35697,13 @@ function queryScalar(db, sql, params = []) {
34986
35697
  return result[0].values[0]?.[0] ?? null;
34987
35698
  }
34988
35699
  var FilesystemSync = class {
34989
- constructor(db, sessionsDir, io2, onSync) {
35700
+ constructor(db, sessionsDir, io2) {
34990
35701
  this.db = db;
34991
35702
  this.sessionsDir = sessionsDir;
34992
35703
  this.io = io2;
34993
- this.onSync = onSync;
34994
35704
  }
34995
35705
  watcher = null;
34996
35706
  debounceTimers = /* @__PURE__ */ new Map();
34997
- onSync;
34998
35707
  // ── 6.1: Full Scan ──
34999
35708
  async fullScan() {
35000
35709
  if (!existsSync10(this.sessionsDir)) return;
@@ -35002,13 +35711,13 @@ var FilesystemSync = class {
35002
35711
  for (const entry of entries) {
35003
35712
  if (!entry.isDirectory()) continue;
35004
35713
  const sessionId = entry.name;
35005
- const sessionDir = join13(this.sessionsDir, sessionId);
35714
+ const sessionDir = join14(this.sessionsDir, sessionId);
35006
35715
  this.syncSession(sessionId, sessionDir);
35007
35716
  }
35008
35717
  }
35009
35718
  syncSession(sessionId, sessionDir) {
35010
35719
  this.ensureSessionRow(sessionId, sessionDir);
35011
- const roundsDir = join13(sessionDir, "rounds");
35720
+ const roundsDir = join14(sessionDir, "rounds");
35012
35721
  if (existsSync10(roundsDir)) {
35013
35722
  const rounds = readdirSync(roundsDir, { withFileTypes: true });
35014
35723
  for (const roundEntry of rounds) {
@@ -35016,34 +35725,34 @@ var FilesystemSync = class {
35016
35725
  const roundMatch = roundEntry.name.match(/^round-(\d+)$/);
35017
35726
  if (!roundMatch) continue;
35018
35727
  const roundNumber = parseInt(roundMatch[1] ?? "0", 10);
35019
- const roundDir = join13(roundsDir, roundEntry.name);
35020
- const reviewsDir = join13(roundDir, "reviews");
35728
+ const roundDir = join14(roundsDir, roundEntry.name);
35729
+ const reviewsDir = join14(roundDir, "reviews");
35021
35730
  if (existsSync10(reviewsDir)) {
35022
35731
  const reviewFiles = readdirSync(reviewsDir).filter((f) => f.endsWith(".md"));
35023
35732
  for (const reviewFile of reviewFiles) {
35024
- const filePath = join13(reviewsDir, reviewFile);
35733
+ const filePath = join14(reviewsDir, reviewFile);
35025
35734
  this.processReviewerOutput(sessionId, roundNumber, filePath, reviewFile);
35026
35735
  }
35027
35736
  }
35028
- const roundMetaPath = join13(roundDir, "round-meta.json");
35737
+ const roundMetaPath = join14(roundDir, "round-meta.json");
35029
35738
  if (existsSync10(roundMetaPath)) {
35030
35739
  this.processRoundMeta(sessionId, roundNumber, roundMetaPath);
35031
35740
  }
35032
- const finalPath = join13(roundDir, "final.md");
35741
+ const finalPath = join14(roundDir, "final.md");
35033
35742
  if (existsSync10(finalPath)) {
35034
35743
  this.processFinalMd(sessionId, roundNumber, finalPath);
35035
35744
  }
35036
- const finalHumanPath = join13(roundDir, "final-human.md");
35745
+ const finalHumanPath = join14(roundDir, "final-human.md");
35037
35746
  if (existsSync10(finalHumanPath)) {
35038
35747
  this.processGenericArtifact(sessionId, "final-human", finalHumanPath, roundNumber);
35039
35748
  }
35040
- const discoursePath = join13(roundDir, "discourse.md");
35749
+ const discoursePath = join14(roundDir, "discourse.md");
35041
35750
  if (existsSync10(discoursePath)) {
35042
35751
  this.processGenericArtifact(sessionId, "discourse", discoursePath, roundNumber);
35043
35752
  }
35044
35753
  }
35045
35754
  }
35046
- const mapDir = join13(sessionDir, "map", "runs");
35755
+ const mapDir = join14(sessionDir, "map", "runs");
35047
35756
  if (existsSync10(mapDir)) {
35048
35757
  const runs = readdirSync(mapDir, { withFileTypes: true });
35049
35758
  for (const runEntry of runs) {
@@ -35051,12 +35760,12 @@ var FilesystemSync = class {
35051
35760
  const runMatch = runEntry.name.match(/^run-(\d+)$/);
35052
35761
  if (!runMatch) continue;
35053
35762
  const runNumber = parseInt(runMatch[1] ?? "0", 10);
35054
- const runDir = join13(mapDir, runEntry.name);
35055
- const mapMetaPath = join13(runDir, "map-meta.json");
35763
+ const runDir = join14(mapDir, runEntry.name);
35764
+ const mapMetaPath = join14(runDir, "map-meta.json");
35056
35765
  if (existsSync10(mapMetaPath)) {
35057
35766
  this.processMapMeta(sessionId, runNumber, mapMetaPath);
35058
35767
  }
35059
- const mapPath = join13(runDir, "map.md");
35768
+ const mapPath = join14(runDir, "map.md");
35060
35769
  if (existsSync10(mapPath)) {
35061
35770
  this.processMapMd(sessionId, runNumber, mapPath);
35062
35771
  }
@@ -35066,7 +35775,7 @@ var FilesystemSync = class {
35066
35775
  ["requirements-mapping.md", "requirements-mapping"]
35067
35776
  ];
35068
35777
  for (const [fileName, artifactType] of mapArtifacts) {
35069
- const filePath = join13(runDir, fileName);
35778
+ const filePath = join14(runDir, fileName);
35070
35779
  if (existsSync10(filePath)) {
35071
35780
  this.processGenericArtifact(sessionId, artifactType, filePath, void 0, runNumber);
35072
35781
  }
@@ -35078,7 +35787,7 @@ var FilesystemSync = class {
35078
35787
  ["discovered-standards.md", "discovered-standards"]
35079
35788
  ];
35080
35789
  for (const [fileName, artifactType] of sessionArtifacts) {
35081
- const filePath = join13(sessionDir, fileName);
35790
+ const filePath = join14(sessionDir, fileName);
35082
35791
  if (existsSync10(filePath)) {
35083
35792
  this.processGenericArtifact(sessionId, artifactType, filePath);
35084
35793
  }
@@ -35088,16 +35797,16 @@ var FilesystemSync = class {
35088
35797
  ensureSessionRow(sessionId, sessionDir) {
35089
35798
  const branchMatch = sessionId.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
35090
35799
  const branch = branchMatch?.[1] ?? "unknown";
35091
- const hasRoundsDir = existsSync10(join13(sessionDir, "rounds"));
35092
- const hasMapDir = existsSync10(join13(sessionDir, "map"));
35800
+ const hasRoundsDir = existsSync10(join14(sessionDir, "rounds"));
35801
+ const hasMapDir = existsSync10(join14(sessionDir, "map"));
35093
35802
  const workflowType = hasMapDir && !hasRoundsDir ? "map" : "review";
35094
35803
  let currentRound = 1;
35095
35804
  if (hasRoundsDir) {
35096
- const roundDirs = readdirSync(join13(sessionDir, "rounds")).filter((d) => d.match(/^round-\d+$/));
35805
+ const roundDirs = readdirSync(join14(sessionDir, "rounds")).filter((d) => d.match(/^round-\d+$/));
35097
35806
  currentRound = Math.max(1, roundDirs.length);
35098
35807
  }
35099
35808
  let currentMapRun = 1;
35100
- const mapRunsDir = join13(sessionDir, "map", "runs");
35809
+ const mapRunsDir = join14(sessionDir, "map", "runs");
35101
35810
  if (existsSync10(mapRunsDir)) {
35102
35811
  const runDirs = readdirSync(mapRunsDir).filter((d) => d.match(/^run-\d+$/));
35103
35812
  currentMapRun = Math.max(1, runDirs.length);
@@ -35106,40 +35815,40 @@ var FilesystemSync = class {
35106
35815
  let phaseNumber = 1;
35107
35816
  let status = "closed";
35108
35817
  if (workflowType === "review" && hasRoundsDir) {
35109
- const roundDir = join13(sessionDir, "rounds", `round-${currentRound}`);
35110
- if (existsSync10(join13(roundDir, "final.md"))) {
35818
+ const roundDir = join14(sessionDir, "rounds", `round-${currentRound}`);
35819
+ if (existsSync10(join14(roundDir, "final.md"))) {
35111
35820
  phase = "complete";
35112
35821
  phaseNumber = 8;
35113
35822
  status = "closed";
35114
- } else if (existsSync10(join13(roundDir, "discourse.md"))) {
35823
+ } else if (existsSync10(join14(roundDir, "discourse.md"))) {
35115
35824
  phase = "synthesis";
35116
35825
  phaseNumber = 7;
35117
- } else if (existsSync10(join13(roundDir, "reviews")) && readdirSync(join13(roundDir, "reviews")).filter((f) => f.endsWith(".md")).length > 0) {
35826
+ } else if (existsSync10(join14(roundDir, "reviews")) && readdirSync(join14(roundDir, "reviews")).filter((f) => f.endsWith(".md")).length > 0) {
35118
35827
  phase = "reviews";
35119
35828
  phaseNumber = 4;
35120
- } else if (existsSync10(join13(sessionDir, "context.md"))) {
35829
+ } else if (existsSync10(join14(sessionDir, "context.md"))) {
35121
35830
  phase = "analysis";
35122
35831
  phaseNumber = 3;
35123
- } else if (existsSync10(join13(sessionDir, "discovered-standards.md"))) {
35832
+ } else if (existsSync10(join14(sessionDir, "discovered-standards.md"))) {
35124
35833
  phase = "change-context";
35125
35834
  phaseNumber = 2;
35126
35835
  }
35127
35836
  } else if (workflowType === "map" && hasMapDir) {
35128
- const runDir = join13(mapRunsDir, `run-${currentMapRun}`);
35129
- if (existsSync10(join13(runDir, "map.md"))) {
35837
+ const runDir = join14(mapRunsDir, `run-${currentMapRun}`);
35838
+ if (existsSync10(join14(runDir, "map.md"))) {
35130
35839
  phase = "complete";
35131
35840
  phaseNumber = 6;
35132
35841
  status = "closed";
35133
- } else if (existsSync10(join13(runDir, "requirements-mapping.md"))) {
35842
+ } else if (existsSync10(join14(runDir, "requirements-mapping.md"))) {
35134
35843
  phase = "synthesis";
35135
35844
  phaseNumber = 5;
35136
- } else if (existsSync10(join13(runDir, "flow-analysis.md"))) {
35845
+ } else if (existsSync10(join14(runDir, "flow-analysis.md"))) {
35137
35846
  phase = "requirements-mapping";
35138
35847
  phaseNumber = 4;
35139
- } else if (existsSync10(join13(runDir, "topology.md"))) {
35848
+ } else if (existsSync10(join14(runDir, "topology.md"))) {
35140
35849
  phase = "flow-analysis";
35141
35850
  phaseNumber = 3;
35142
- } else if (existsSync10(join13(sessionDir, "discovered-standards.md"))) {
35851
+ } else if (existsSync10(join14(sessionDir, "discovered-standards.md"))) {
35143
35852
  phase = "topology";
35144
35853
  phaseNumber = 2;
35145
35854
  }
@@ -35153,14 +35862,40 @@ var FilesystemSync = class {
35153
35862
  );
35154
35863
  } else {
35155
35864
  if (!this.hasArtifacts(sessionDir)) return;
35156
- this.db.run(
35157
- `INSERT INTO sessions (id, branch, workflow_type, current_phase, phase_number, current_round, current_map_run, session_dir, status)
35158
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
35159
- [sessionId, branch, workflowType, phase, phaseNumber, currentRound, currentMapRun, sessionDir, status]
35160
- );
35865
+ this.db.transaction(() => {
35866
+ insertSession(this.db, {
35867
+ id: sessionId,
35868
+ branch,
35869
+ workflow_type: workflowType,
35870
+ current_phase: phase,
35871
+ phase_number: phaseNumber,
35872
+ current_round: currentRound,
35873
+ current_map_run: currentMapRun,
35874
+ session_dir: sessionDir
35875
+ });
35876
+ insertEvent(this.db, {
35877
+ session_id: sessionId,
35878
+ event_type: "session_created",
35879
+ phase,
35880
+ phase_number: 1,
35881
+ round: 1
35882
+ });
35883
+ });
35884
+ if (status === "closed") {
35885
+ commitReasonClose(
35886
+ this.db,
35887
+ sessionId,
35888
+ {
35889
+ event_type: "session_synced",
35890
+ phase,
35891
+ phase_number: phaseNumber,
35892
+ metadata: JSON.stringify({ source: "filesystem_backfill" })
35893
+ },
35894
+ { status: "closed", current_phase: phase, phase_number: phaseNumber }
35895
+ );
35896
+ }
35161
35897
  this.io?.emit("session:created", { id: sessionId, branch, workflow_type: workflowType, status, current_phase: phase });
35162
35898
  }
35163
- this.onSync?.();
35164
35899
  }
35165
35900
  // ── Artifact Check ──
35166
35901
  /** Returns true if the directory contains at least one .md or .json file (recursively). */
@@ -35168,7 +35903,7 @@ var FilesystemSync = class {
35168
35903
  try {
35169
35904
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
35170
35905
  if (entry.isDirectory()) {
35171
- if (this.hasArtifacts(join13(dir, entry.name))) return true;
35906
+ if (this.hasArtifacts(join14(dir, entry.name))) return true;
35172
35907
  } else if (/\.(md|json)$/.test(entry.name)) {
35173
35908
  return true;
35174
35909
  }
@@ -35181,7 +35916,7 @@ var FilesystemSync = class {
35181
35916
  shouldSkip(filePath, existingParsedAt) {
35182
35917
  if (!existingParsedAt) return false;
35183
35918
  try {
35184
- const mtime = statSync(filePath).mtime;
35919
+ const mtime = statSync2(filePath).mtime;
35185
35920
  const parsedAt = new Date(existingParsedAt);
35186
35921
  return mtime <= parsedAt;
35187
35922
  } catch {
@@ -35218,12 +35953,12 @@ var FilesystemSync = class {
35218
35953
  );
35219
35954
  if (existingRun && this.shouldSkip(filePath, existingRun["parsed_at"] ?? null)) return;
35220
35955
  if (existingRun?.["source"] === "orchestrator") {
35221
- const content2 = readFileSync10(filePath, "utf-8");
35956
+ const content2 = readFileSync8(filePath, "utf-8");
35222
35957
  const action2 = this.upsertMarkdownArtifact(sessionId, "map", filePath, content2, void 0);
35223
35958
  this.emitArtifactEvent(action2, { sessionId, artifactType: "map", filePath });
35224
35959
  return;
35225
35960
  }
35226
- const content = readFileSync10(filePath, "utf-8");
35961
+ const content = readFileSync8(filePath, "utf-8");
35227
35962
  const parsed = parseMapMd(content);
35228
35963
  this.db.run(
35229
35964
  `INSERT OR REPLACE INTO map_runs (session_id, run_number, file_count, map_md_path, parsed_at, source)
@@ -35309,10 +36044,16 @@ var FilesystemSync = class {
35309
36044
  [sessionId]
35310
36045
  );
35311
36046
  if (session && session["workflow_type"] === "map" && (session["current_phase"] !== "complete" || session["phase_number"] < 6)) {
35312
- this.db.run(
35313
- `UPDATE sessions SET current_phase = 'complete', phase_number = 6, status = 'closed', updated_at = datetime('now')
35314
- WHERE id = ?`,
35315
- [sessionId]
36047
+ commitReasonClose(
36048
+ this.db,
36049
+ sessionId,
36050
+ {
36051
+ event_type: "session_synced",
36052
+ phase: "complete",
36053
+ phase_number: 6,
36054
+ metadata: JSON.stringify({ source: "filesystem_backfill" })
36055
+ },
36056
+ { status: "closed", current_phase: "complete", phase_number: 6 }
35316
36057
  );
35317
36058
  this.io?.emit("session:updated", {
35318
36059
  id: sessionId,
@@ -35343,7 +36084,7 @@ var FilesystemSync = class {
35343
36084
  const roundId = roundRow?.["id"];
35344
36085
  if (!roundId) return;
35345
36086
  if (roundRow?.["source"] === "orchestrator") {
35346
- const content2 = readFileSync10(filePath, "utf-8");
36087
+ const content2 = readFileSync8(filePath, "utf-8");
35347
36088
  const action2 = this.upsertMarkdownArtifact(sessionId, "reviewer-output", filePath, content2, roundNumber);
35348
36089
  this.emitArtifactEvent(action2, {
35349
36090
  sessionId,
@@ -35362,7 +36103,7 @@ var FilesystemSync = class {
35362
36103
  [roundId, reviewerType, instanceNumber]
35363
36104
  );
35364
36105
  if (existingOutput && this.shouldSkip(filePath, existingOutput["parsed_at"] ?? null)) return;
35365
- const content = readFileSync10(filePath, "utf-8");
36106
+ const content = readFileSync8(filePath, "utf-8");
35366
36107
  const parsed = parseReviewerOutput(content);
35367
36108
  this.db.run(
35368
36109
  `INSERT OR REPLACE INTO reviewer_outputs (round_id, reviewer_type, instance_number, file_path, finding_count, parsed_at)
@@ -35450,7 +36191,7 @@ var FilesystemSync = class {
35450
36191
  if (existingRound?.["source"] === "orchestrator" && this.shouldSkip(filePath, existingRound["parsed_at"] ?? null)) return;
35451
36192
  let raw;
35452
36193
  try {
35453
- raw = JSON.parse(readFileSync10(filePath, "utf-8"));
36194
+ raw = JSON.parse(readFileSync8(filePath, "utf-8"));
35454
36195
  } catch {
35455
36196
  console.error(`[FilesystemSync] Failed to parse ${filePath}`);
35456
36197
  return;
@@ -35501,7 +36242,7 @@ var FilesystemSync = class {
35501
36242
  const reviewerType = reviewer.type ?? "unknown";
35502
36243
  const instanceNumber = reviewer.instance ?? 1;
35503
36244
  const findings = reviewer.findings ?? [];
35504
- const reviewerMdPath = join13(roundDir, "reviews", `${reviewerType}-${instanceNumber}.md`);
36245
+ const reviewerMdPath = join14(roundDir, "reviews", `${reviewerType}-${instanceNumber}.md`);
35505
36246
  this.db.run(
35506
36247
  `INSERT OR REPLACE INTO reviewer_outputs (round_id, reviewer_type, instance_number, file_path, finding_count, parsed_at)
35507
36248
  VALUES (?, ?, ?, ?, ?, ?)`,
@@ -35599,7 +36340,7 @@ var FilesystemSync = class {
35599
36340
  if (existingRun?.["source"] === "orchestrator" && this.shouldSkip(filePath, existingRun["parsed_at"] ?? null)) return;
35600
36341
  let raw;
35601
36342
  try {
35602
- raw = JSON.parse(readFileSync10(filePath, "utf-8"));
36343
+ raw = JSON.parse(readFileSync8(filePath, "utf-8"));
35603
36344
  } catch {
35604
36345
  console.error(`[FilesystemSync] Failed to parse ${filePath}`);
35605
36346
  return;
@@ -35727,7 +36468,7 @@ var FilesystemSync = class {
35727
36468
  );
35728
36469
  const isOrchestratorSource = existingRound?.["source"] === "orchestrator";
35729
36470
  if (!isOrchestratorSource && existingRound && this.shouldSkip(filePath, existingRound["parsed_at"] ?? null)) return;
35730
- const content = readFileSync10(filePath, "utf-8");
36471
+ const content = readFileSync8(filePath, "utf-8");
35731
36472
  if (isOrchestratorSource) {
35732
36473
  this.db.run(
35733
36474
  `UPDATE review_rounds SET final_md_path = ?, parsed_at = ?
@@ -35771,10 +36512,16 @@ var FilesystemSync = class {
35771
36512
  [sessionId]
35772
36513
  );
35773
36514
  if (session && (session["current_phase"] !== "complete" || session["phase_number"] < 8)) {
35774
- this.db.run(
35775
- `UPDATE sessions SET current_phase = 'complete', phase_number = 8, status = 'closed', updated_at = datetime('now')
35776
- WHERE id = ?`,
35777
- [sessionId]
36515
+ commitReasonClose(
36516
+ this.db,
36517
+ sessionId,
36518
+ {
36519
+ event_type: "session_synced",
36520
+ phase: "complete",
36521
+ phase_number: 8,
36522
+ metadata: JSON.stringify({ source: "filesystem_backfill" })
36523
+ },
36524
+ { status: "closed", current_phase: "complete", phase_number: 8 }
35778
36525
  );
35779
36526
  this.io?.emit("session:updated", {
35780
36527
  id: sessionId,
@@ -35800,7 +36547,7 @@ var FilesystemSync = class {
35800
36547
  [sessionId, artifactType, relPath]
35801
36548
  );
35802
36549
  if (existing && this.shouldSkip(filePath, existing["parsed_at"] ?? null)) return;
35803
- const content = readFileSync10(filePath, "utf-8");
36550
+ const content = readFileSync8(filePath, "utf-8");
35804
36551
  const action = this.upsertMarkdownArtifact(sessionId, artifactType, filePath, content, roundNumber);
35805
36552
  this.emitArtifactEvent(action, {
35806
36553
  sessionId,
@@ -35847,7 +36594,6 @@ var FilesystemSync = class {
35847
36594
  this.debounceTimers.delete(filePath);
35848
36595
  try {
35849
36596
  this.processChangedFile(filePath);
35850
- this.onSync?.();
35851
36597
  } catch (err) {
35852
36598
  console.error(`[FilesystemSync] Error processing ${filePath}:`, err);
35853
36599
  }
@@ -35859,7 +36605,7 @@ var FilesystemSync = class {
35859
36605
  const parts = relFromSessions.split("/");
35860
36606
  const sessionId = parts[0];
35861
36607
  if (!sessionId) return;
35862
- const sessionDir = join13(this.sessionsDir, sessionId);
36608
+ const sessionDir = join14(this.sessionsDir, sessionId);
35863
36609
  this.ensureSessionRow(sessionId, sessionDir);
35864
36610
  const fileName = basename2(filePath);
35865
36611
  const reviewerMatch = relFromSessions.match(/rounds\/round-(\d+)\/reviews\/(.+\.md)$/);
@@ -35931,53 +36677,52 @@ var FilesystemSync = class {
35931
36677
  };
35932
36678
 
35933
36679
  // src/server/services/db-sync-watcher.ts
35934
- import { existsSync as existsSync11, readFileSync as readFileSync11, statSync as statSync2 } from "node:fs";
36680
+ import { existsSync as existsSync11 } from "node:fs";
35935
36681
  import { dirname as dirname10, basename as basename3 } from "node:path";
35936
36682
  import { watch as watch3 } from "chokidar";
35937
- import initSqlJs3 from "sql.js";
35938
36683
  function col(row, key) {
35939
36684
  return row[key] ?? null;
35940
36685
  }
35941
- var SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "utf-8");
35942
- function isValidSqliteHeader(buf) {
35943
- if (buf.length < SQLITE_MAGIC.length) return false;
35944
- return buf.subarray(0, SQLITE_MAGIC.length).equals(SQLITE_MAGIC);
35945
- }
35946
36686
  var DbSyncWatcher = class {
35947
- constructor(db, dbFilePath, io2, onSync, onSessionInserted) {
36687
+ constructor(db, dbFilePath, io2, onSessionInserted) {
35948
36688
  this.db = db;
35949
36689
  this.dbFilePath = dbFilePath;
35950
36690
  this.io = io2;
35951
- this.onSync = onSync;
35952
36691
  this.onSessionInserted = onSessionInserted;
35953
36692
  }
35954
36693
  watcher = null;
35955
36694
  debounceTimer = null;
35956
- lastMtime = 0;
35957
- wasmBinary = null;
35958
- SQL = null;
36695
+ // Snapshots of last-emitted state, so we only emit on genuine changes.
36696
+ seenSessions = /* @__PURE__ */ new Map();
36697
+ maxEventId = 0;
36698
+ seenCommandRows = /* @__PURE__ */ new Map();
35959
36699
  /**
35960
- * Initialize the WASM runtime (called once at startup).
36700
+ * Prime snapshots from the current database so existing state is not
36701
+ * re-emitted on first watch tick. (No WASM runtime to initialise under
36702
+ * the native engine.)
35961
36703
  */
35962
36704
  async init() {
35963
- const wasmBuffer = readFileSync11(locateWasm());
35964
- this.wasmBinary = wasmBuffer.buffer.slice(
35965
- wasmBuffer.byteOffset,
35966
- wasmBuffer.byteOffset + wasmBuffer.byteLength
35967
- );
35968
- this.SQL = await initSqlJs3({ wasmBinary: this.wasmBinary });
36705
+ this.primeSnapshots();
35969
36706
  }
35970
- /**
35971
- * Start watching the DB file for external changes.
35972
- */
36707
+ primeSnapshots() {
36708
+ for (const row of this.readSessions()) {
36709
+ const id = col(row, "id");
36710
+ if (id) this.seenSessions.set(id, sessionFingerprint(row));
36711
+ }
36712
+ const maxResult = this.db.exec("SELECT MAX(id) FROM orchestration_events");
36713
+ const maxVal = maxResult[0]?.values[0]?.[0];
36714
+ this.maxEventId = typeof maxVal === "number" ? maxVal : 0;
36715
+ for (const row of this.readCommandRows()) {
36716
+ const id = col(row, "id");
36717
+ if (id != null) this.seenCommandRows.set(id, commandFingerprint(row));
36718
+ }
36719
+ }
36720
+ /** Start watching the DB file (and its WAL sidecar) for external writes. */
35973
36721
  startWatching() {
35974
36722
  if (!existsSync11(this.dbFilePath)) return;
35975
- try {
35976
- this.lastMtime = statSync2(this.dbFilePath).mtimeMs;
35977
- } catch {
35978
- }
35979
36723
  const watchDir = dirname10(this.dbFilePath);
35980
- const watchedFile = basename3(this.dbFilePath);
36724
+ const dbFile = basename3(this.dbFilePath);
36725
+ const walFile = `${dbFile}-wal`;
35981
36726
  this.watcher = watch3(watchDir, {
35982
36727
  persistent: true,
35983
36728
  ignoreInitial: true,
@@ -35986,15 +36731,13 @@ var DbSyncWatcher = class {
35986
36731
  interval: 200
35987
36732
  });
35988
36733
  const onAnyEvent = (path2) => {
35989
- if (basename3(path2) === watchedFile) this.debouncedSync();
36734
+ const name = basename3(path2);
36735
+ if (name === dbFile || name === walFile) this.debouncedSync();
35990
36736
  };
35991
36737
  this.watcher.on("change", onAnyEvent);
35992
36738
  this.watcher.on("add", onAnyEvent);
35993
36739
  this.watcher.on("unlink", onAnyEvent);
35994
36740
  }
35995
- /**
35996
- * Stop watching.
35997
- */
35998
36741
  stopWatching() {
35999
36742
  if (this.debounceTimer) {
36000
36743
  clearTimeout(this.debounceTimer);
@@ -36005,9 +36748,6 @@ var DbSyncWatcher = class {
36005
36748
  this.watcher = null;
36006
36749
  }
36007
36750
  }
36008
- /**
36009
- * Debounce sync to avoid rapid reloads during multi-statement writes.
36010
- */
36011
36751
  debouncedSync() {
36012
36752
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
36013
36753
  this.debounceTimer = setTimeout(() => {
@@ -36015,85 +36755,39 @@ var DbSyncWatcher = class {
36015
36755
  }, 300);
36016
36756
  }
36017
36757
  /**
36018
- * Read the on-disk DB and sync CLI-owned tables into the in-memory DB.
36019
- *
36020
- * Only syncs `sessions` and `orchestration_events` these are the tables
36021
- * the CLI writes to via `ocr state` commands. All other tables are
36022
- * dashboard-owned and untouched.
36023
- *
36024
- * Public for direct use; also called automatically via registered save hooks
36025
- * to avoid overwriting CLI changes.
36026
- *
36027
- * Resilience:
36028
- * 1. Validates the SQLite magic header before handing the buffer to
36029
- * sql.js. If the header is missing — e.g. we caught the file
36030
- * mid-atomic-rename and read the temp file or a zero-byte stub —
36031
- * we skip the load and wait for the next watch event. Without this
36032
- * guard, a torn read corrupts sql.js and surfaces as an opaque WASM
36033
- * `memory access out of bounds` exception.
36034
- * 2. Only advances `lastMtime` AFTER a successful load. A failed load
36035
- * leaves the watermark untouched so the next change event retries.
36758
+ * Rescan the live database and emit notifications for anything that
36759
+ * changed since the last scan. Named `syncFromDisk` for call-site
36760
+ * compatibility; under the native engine it performs no merge writes —
36761
+ * it diffs the live connection against cached snapshots and emits.
36036
36762
  */
36037
36763
  syncFromDisk() {
36038
- if (!this.SQL || !existsSync11(this.dbFilePath)) return;
36039
- let currentMtime;
36040
- try {
36041
- currentMtime = statSync2(this.dbFilePath).mtimeMs;
36042
- } catch {
36043
- return;
36044
- }
36045
- if (currentMtime <= this.lastMtime) return;
36046
- let diskDb = null;
36047
36764
  try {
36048
- const fileBuffer = readFileSync11(this.dbFilePath);
36049
- if (!isValidSqliteHeader(fileBuffer)) {
36050
- return;
36051
- }
36052
- diskDb = new this.SQL.Database(fileBuffer);
36053
- this.syncSessions(diskDb);
36054
- this.syncEvents(diskDb);
36055
- this.syncAgentSessions(diskDb);
36056
- this.lastMtime = currentMtime;
36057
- this.onSync?.();
36765
+ this.detectSessionChanges();
36766
+ this.detectNewEvents();
36767
+ this.detectCommandChanges();
36058
36768
  } catch (err) {
36059
- console.error("[DbSyncWatcher] Error syncing from disk:", err);
36060
- } finally {
36061
- diskDb?.close();
36769
+ console.error("[DbSyncWatcher] Error scanning for changes:", err);
36062
36770
  }
36063
36771
  }
36064
- /**
36065
- * Sync the `sessions` table from disk → in-memory.
36066
- * The CLI is authoritative for: current_phase, phase_number, status,
36067
- * current_round, current_map_run, workflow_type, updated_at.
36068
- */
36069
- syncSessions(diskDb) {
36070
- const diskSessions = resultToRows(diskDb.exec("SELECT * FROM sessions"));
36071
- for (const row of diskSessions) {
36772
+ readSessions() {
36773
+ return resultToRows(this.db.exec("SELECT * FROM sessions"));
36774
+ }
36775
+ readCommandRows() {
36776
+ return resultToRows(
36777
+ this.db.exec(
36778
+ `SELECT id, last_heartbeat_at, finished_at, exit_code, workflow_id, vendor_session_id
36779
+ FROM command_executions`
36780
+ )
36781
+ );
36782
+ }
36783
+ detectSessionChanges() {
36784
+ for (const row of this.readSessions()) {
36072
36785
  const id = col(row, "id");
36073
36786
  if (!id) continue;
36074
- const memResult = this.db.exec(
36075
- "SELECT current_phase, phase_number, status, updated_at FROM sessions WHERE id = ?",
36076
- [id]
36077
- );
36078
- const memRows = resultToRows(memResult);
36079
- if (memRows.length === 0) {
36080
- this.db.run(
36081
- `INSERT INTO sessions (id, branch, status, workflow_type, current_phase, phase_number, current_round, current_map_run, started_at, updated_at, session_dir)
36082
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
36083
- [
36084
- col(row, "id"),
36085
- col(row, "branch"),
36086
- col(row, "status"),
36087
- col(row, "workflow_type"),
36088
- col(row, "current_phase"),
36089
- col(row, "phase_number"),
36090
- col(row, "current_round"),
36091
- col(row, "current_map_run"),
36092
- col(row, "started_at"),
36093
- col(row, "updated_at"),
36094
- col(row, "session_dir")
36095
- ]
36096
- );
36787
+ const fp = sessionFingerprint(row);
36788
+ const prev = this.seenSessions.get(id);
36789
+ if (prev === void 0) {
36790
+ this.seenSessions.set(id, fp);
36097
36791
  this.io.emit("session:created", {
36098
36792
  id,
36099
36793
  branch: col(row, "branch"),
@@ -36110,81 +36804,33 @@ var DbSyncWatcher = class {
36110
36804
  } catch (err) {
36111
36805
  console.error("[DbSyncWatcher] onSessionInserted hook failed:", err);
36112
36806
  }
36113
- } else {
36114
- const mem = memRows[0];
36115
- const diskPhase = col(row, "phase_number");
36116
- const memPhase = col(mem, "phase_number");
36117
- const diskStatus = col(row, "status");
36118
- const memStatus = col(mem, "status");
36119
- const diskCurrent = col(row, "current_phase");
36120
- const memCurrent = col(mem, "current_phase");
36121
- if (diskPhase !== memPhase || diskStatus !== memStatus || diskCurrent !== memCurrent) {
36122
- this.db.run(
36123
- `UPDATE sessions
36124
- SET current_phase = ?, phase_number = ?, status = ?,
36125
- current_round = ?, current_map_run = ?, workflow_type = ?,
36126
- updated_at = ?
36127
- WHERE id = ?`,
36128
- [
36129
- col(row, "current_phase"),
36130
- col(row, "phase_number"),
36131
- col(row, "status"),
36132
- col(row, "current_round"),
36133
- col(row, "current_map_run"),
36134
- col(row, "workflow_type"),
36135
- col(row, "updated_at"),
36136
- id
36137
- ]
36138
- );
36139
- this.io.emit("session:updated", {
36140
- id,
36141
- status: col(row, "status"),
36142
- current_phase: col(row, "current_phase"),
36143
- phase_number: col(row, "phase_number")
36144
- });
36145
- }
36807
+ } else if (prev !== fp) {
36808
+ this.seenSessions.set(id, fp);
36809
+ this.io.emit("session:updated", {
36810
+ id,
36811
+ status: col(row, "status"),
36812
+ current_phase: col(row, "current_phase"),
36813
+ phase_number: col(row, "phase_number")
36814
+ });
36146
36815
  }
36147
36816
  }
36148
36817
  }
36149
- /**
36150
- * Sync the `orchestration_events` table from disk → in-memory.
36151
- * Events are append-only, so we INSERT any that don't exist yet.
36152
- */
36153
- syncEvents(diskDb) {
36154
- const diskEvents = resultToRows(
36155
- diskDb.exec("SELECT * FROM orchestration_events ORDER BY id ASC")
36818
+ detectNewEvents() {
36819
+ const rows = resultToRows(
36820
+ this.db.exec(
36821
+ "SELECT * FROM orchestration_events WHERE id > ? ORDER BY id ASC",
36822
+ [this.maxEventId]
36823
+ )
36156
36824
  );
36157
- const newEvents = [];
36825
+ if (rows.length === 0) return;
36158
36826
  const affectedSessions = /* @__PURE__ */ new Set();
36159
- for (const row of diskEvents) {
36827
+ for (const row of rows) {
36160
36828
  const eventId = col(row, "id");
36161
36829
  const sessionId = col(row, "session_id");
36162
- const existing = this.db.exec(
36163
- "SELECT id FROM orchestration_events WHERE id = ?",
36164
- [eventId]
36165
- );
36166
- if (existing.length > 0 && existing[0]?.values.length !== 0) continue;
36167
- this.db.run(
36168
- `INSERT OR IGNORE INTO orchestration_events (id, session_id, event_type, phase, phase_number, round, metadata, created_at)
36169
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
36170
- [
36171
- eventId,
36172
- sessionId,
36173
- col(row, "event_type"),
36174
- col(row, "phase"),
36175
- col(row, "phase_number"),
36176
- col(row, "round"),
36177
- col(row, "metadata"),
36178
- col(row, "created_at")
36179
- ]
36180
- );
36181
- newEvents.push(row);
36182
- affectedSessions.add(sessionId);
36183
- }
36184
- for (const row of newEvents) {
36185
36830
  const eventType = col(row, "event_type");
36186
- const sessionId = col(row, "session_id");
36187
36831
  const metadataStr = col(row, "metadata");
36832
+ if (eventId > this.maxEventId) this.maxEventId = eventId;
36833
+ if (sessionId) affectedSessions.add(sessionId);
36188
36834
  if (eventType === "round_completed") {
36189
36835
  const roundNumber = col(row, "round");
36190
36836
  if (sessionId && roundNumber && metadataStr) {
@@ -36197,98 +36843,33 @@ var DbSyncWatcher = class {
36197
36843
  }
36198
36844
  }
36199
36845
  }
36200
- if (newEvents.length > 0) {
36201
- for (const sessionId of affectedSessions) {
36202
- this.io.to(`session:${sessionId}`).emit("session:events", { session_id: sessionId });
36203
- }
36846
+ for (const sessionId of affectedSessions) {
36847
+ this.io.to(`session:${sessionId}`).emit("session:events", { session_id: sessionId });
36204
36848
  }
36205
36849
  }
36206
- /**
36207
- * Sync the `command_executions` table from disk → in-memory.
36208
- *
36209
- * Migration v11 unified `agent_sessions` into `command_executions`. The
36210
- * journal fields (`vendor`, `vendor_session_id`, `persona`, `resolved_model`,
36211
- * `last_heartbeat_at`, `notes`, …) live on the same table. Rows the CLI
36212
- * writes via `ocr session start-instance` / `bind-vendor-id` / `beat` /
36213
- * `end-instance` flow through atomic file rename, so the disk file is the
36214
- * source of truth.
36215
- *
36216
- * Strategy: full mirror via `INSERT OR REPLACE` keyed on the integer
36217
- * primary key. The table is small enough that copying every row each
36218
- * sync is cheap. Emits `agent_session:updated` with affected workflow ids
36219
- * so the dashboard's query cache invalidates selectively.
36220
- */
36221
- syncAgentSessions(diskDb) {
36222
- const diskRows = resultToRows(
36223
- diskDb.exec("SELECT * FROM command_executions")
36224
- );
36225
- if (diskRows.length === 0) return;
36850
+ detectCommandChanges() {
36226
36851
  const affectedWorkflows = /* @__PURE__ */ new Set();
36227
- let changed = 0;
36228
- for (const row of diskRows) {
36852
+ for (const row of this.readCommandRows()) {
36229
36853
  const id = col(row, "id");
36854
+ if (id == null) continue;
36855
+ const fp = commandFingerprint(row);
36856
+ if (this.seenCommandRows.get(id) === fp) continue;
36857
+ this.seenCommandRows.set(id, fp);
36230
36858
  const workflowId = col(row, "workflow_id");
36231
- const vendorSessionId = col(row, "vendor_session_id");
36232
36859
  const heartbeat = col(row, "last_heartbeat_at");
36233
- const finishedAt = col(row, "finished_at");
36234
- const exitCode = col(row, "exit_code");
36235
- const existing = this.db.exec(
36236
- `SELECT last_heartbeat_at, finished_at, exit_code,
36237
- workflow_id, vendor_session_id
36238
- FROM command_executions WHERE id = ?`,
36239
- [id]
36240
- );
36241
- const existingRow = existing[0]?.values?.[0];
36242
- const sameHeartbeat = (existingRow?.[0] ?? null) === heartbeat;
36243
- const sameFinished = (existingRow?.[1] ?? null) === finishedAt;
36244
- const sameExit = (existingRow?.[2] ?? null) === exitCode;
36245
- const sameWorkflow = (existingRow?.[3] ?? null) === workflowId;
36246
- const sameVendorSession = (existingRow?.[4] ?? null) === vendorSessionId;
36247
- if (existingRow && sameHeartbeat && sameFinished && sameExit && sameWorkflow && sameVendorSession) continue;
36248
- this.db.run(
36249
- `INSERT OR REPLACE INTO command_executions
36250
- (id, uid, command, args, exit_code, started_at, finished_at, output,
36251
- pid, is_detached, workflow_id, parent_id, vendor, vendor_session_id,
36252
- persona, instance_index, name, resolved_model, last_heartbeat_at, notes)
36253
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
36254
- [
36255
- id,
36256
- col(row, "uid"),
36257
- col(row, "command"),
36258
- col(row, "args"),
36259
- exitCode,
36260
- col(row, "started_at"),
36261
- finishedAt,
36262
- col(row, "output"),
36263
- col(row, "pid"),
36264
- col(row, "is_detached"),
36265
- workflowId,
36266
- col(row, "parent_id"),
36267
- col(row, "vendor"),
36268
- col(row, "vendor_session_id"),
36269
- col(row, "persona"),
36270
- col(row, "instance_index"),
36271
- col(row, "name"),
36272
- col(row, "resolved_model"),
36273
- heartbeat,
36274
- col(row, "notes")
36275
- ]
36276
- );
36277
36860
  if (heartbeat !== null && workflowId) {
36278
36861
  affectedWorkflows.add(workflowId);
36279
36862
  }
36280
- changed++;
36281
36863
  }
36282
- if (changed > 0 && affectedWorkflows.size > 0) {
36864
+ if (affectedWorkflows.size > 0) {
36283
36865
  this.io.emit("agent_session:updated", {
36284
36866
  workflow_ids: Array.from(affectedWorkflows)
36285
36867
  });
36286
36868
  }
36287
36869
  }
36288
36870
  /**
36289
- * Process a `round_completed` orchestration event.
36290
- * Upserts review_rounds with orchestrator data for real-time dashboard updates.
36291
- * Idempotent — skips if round already has source='orchestrator'.
36871
+ * Project a `round_completed` event into `review_rounds` (orchestrator
36872
+ * source latch) and emit a room-scoped `round:updated`. Idempotent.
36292
36873
  */
36293
36874
  processRoundCompletedEvent(sessionId, roundNumber, metadataStr) {
36294
36875
  let metadata;
@@ -36306,8 +36887,7 @@ var DbSyncWatcher = class {
36306
36887
  return;
36307
36888
  }
36308
36889
  this.db.run(
36309
- `INSERT OR IGNORE INTO review_rounds (session_id, round_number)
36310
- VALUES (?, ?)`,
36890
+ `INSERT OR IGNORE INTO review_rounds (session_id, round_number) VALUES (?, ?)`,
36311
36891
  [sessionId, roundNumber]
36312
36892
  );
36313
36893
  this.db.run(
@@ -36338,9 +36918,8 @@ var DbSyncWatcher = class {
36338
36918
  });
36339
36919
  }
36340
36920
  /**
36341
- * Process a `map_completed` orchestration event.
36342
- * Upserts map_runs with orchestrator data for real-time dashboard updates.
36343
- * Idempotent — skips if run already has source='orchestrator'.
36921
+ * Project a `map_completed` event into `map_runs` and emit a room-scoped
36922
+ * `map:updated`. Idempotent.
36344
36923
  */
36345
36924
  processMapCompletedEvent(sessionId, runNumber, metadataStr) {
36346
36925
  let metadata;
@@ -36358,8 +36937,7 @@ var DbSyncWatcher = class {
36358
36937
  return;
36359
36938
  }
36360
36939
  this.db.run(
36361
- `INSERT OR IGNORE INTO map_runs (session_id, run_number)
36362
- VALUES (?, ?)`,
36940
+ `INSERT OR IGNORE INTO map_runs (session_id, run_number) VALUES (?, ?)`,
36363
36941
  [sessionId, runNumber]
36364
36942
  );
36365
36943
  this.db.run(
@@ -36382,33 +36960,42 @@ var DbSyncWatcher = class {
36382
36960
  source: "orchestrator"
36383
36961
  });
36384
36962
  }
36385
- /**
36386
- * Record current mtime after the dashboard writes to disk.
36387
- * Called automatically via registered save hooks after saveDb().
36388
- */
36389
- markOwnWrite() {
36390
- try {
36391
- this.lastMtime = statSync2(this.dbFilePath).mtimeMs;
36392
- } catch {
36393
- }
36394
- }
36395
36963
  };
36964
+ function sessionFingerprint(row) {
36965
+ return [
36966
+ col(row, "status"),
36967
+ col(row, "current_phase"),
36968
+ col(row, "phase_number"),
36969
+ col(row, "current_round"),
36970
+ col(row, "current_map_run"),
36971
+ col(row, "updated_at")
36972
+ ].join("|");
36973
+ }
36974
+ function commandFingerprint(row) {
36975
+ return [
36976
+ col(row, "last_heartbeat_at"),
36977
+ col(row, "finished_at"),
36978
+ col(row, "exit_code"),
36979
+ col(row, "workflow_id"),
36980
+ col(row, "vendor_session_id")
36981
+ ].join("|");
36982
+ }
36396
36983
 
36397
36984
  // src/server/socket/chat-handler.ts
36398
36985
  import { dirname as dirname11 } from "node:path";
36399
36986
 
36400
36987
  // src/server/services/chat-context.ts
36401
- import { readFileSync as readFileSync12, readdirSync as readdirSync2, existsSync as existsSync12 } from "node:fs";
36402
- import { join as join14 } from "node:path";
36988
+ import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as existsSync12 } from "node:fs";
36989
+ import { join as join15 } from "node:path";
36403
36990
  function buildChatContext(ocrDir, target) {
36404
- const sessionsDir = join14(ocrDir, "sessions");
36991
+ const sessionsDir = join15(ocrDir, "sessions");
36405
36992
  if (target.type === "map_run") {
36406
36993
  return buildMapRunContext(sessionsDir, target.sessionId, target.runNumber);
36407
36994
  }
36408
36995
  return buildReviewRoundContext(sessionsDir, target.sessionId, target.roundNumber);
36409
36996
  }
36410
36997
  function buildMapRunContext(sessionsDir, sessionId, runNumber) {
36411
- const mapPath = join14(
36998
+ const mapPath = join15(
36412
36999
  sessionsDir,
36413
37000
  sessionId,
36414
37001
  "map",
@@ -36423,7 +37010,7 @@ function buildMapRunContext(sessionsDir, sessionId, runNumber) {
36423
37010
  `Below is the Code Review Map that organizes the changeset into reviewable sections:`
36424
37011
  ];
36425
37012
  if (existsSync12(mapPath)) {
36426
- const content = readFileSync12(mapPath, "utf-8");
37013
+ const content = readFileSync9(mapPath, "utf-8");
36427
37014
  parts.push("");
36428
37015
  parts.push("<map>");
36429
37016
  parts.push(content);
@@ -36435,9 +37022,9 @@ function buildMapRunContext(sessionsDir, sessionId, runNumber) {
36435
37022
  return parts.join("\n");
36436
37023
  }
36437
37024
  function buildReviewRoundContext(sessionsDir, sessionId, roundNumber) {
36438
- const roundDir = join14(sessionsDir, sessionId, "rounds", `round-${roundNumber}`);
36439
- const finalPath = join14(roundDir, "final.md");
36440
- const reviewersDir = join14(roundDir, "reviews");
37025
+ const roundDir = join15(sessionsDir, sessionId, "rounds", `round-${roundNumber}`);
37026
+ const finalPath = join15(roundDir, "final.md");
37027
+ const reviewersDir = join15(roundDir, "reviews");
36441
37028
  const parts = [
36442
37029
  `You are an expert code reviewer assisting with a code review session.`,
36443
37030
  `You are looking at review round #${roundNumber} for session "${sessionId}".`,
@@ -36445,7 +37032,7 @@ function buildReviewRoundContext(sessionsDir, sessionId, roundNumber) {
36445
37032
  `Below are the review artifacts for this round:`
36446
37033
  ];
36447
37034
  if (existsSync12(finalPath)) {
36448
- const content = readFileSync12(finalPath, "utf-8");
37035
+ const content = readFileSync9(finalPath, "utf-8");
36449
37036
  parts.push("");
36450
37037
  parts.push("<final-synthesis>");
36451
37038
  parts.push(content);
@@ -36454,7 +37041,7 @@ function buildReviewRoundContext(sessionsDir, sessionId, roundNumber) {
36454
37041
  if (existsSync12(reviewersDir)) {
36455
37042
  const files = readdirSync2(reviewersDir).filter((f) => f.endsWith(".md")).sort();
36456
37043
  for (const file of files) {
36457
- const content = readFileSync12(join14(reviewersDir, file), "utf-8");
37044
+ const content = readFileSync9(join15(reviewersDir, file), "utf-8");
36458
37045
  const reviewerName = file.replace(/\.md$/, "");
36459
37046
  parts.push("");
36460
37047
  parts.push(`<reviewer name="${reviewerName}">`);
@@ -36521,7 +37108,6 @@ function startTrackedExecution(io2, db, ocrDir, command, args = []) {
36521
37108
  WHERE id = ?`,
36522
37109
  [exitCode, finishedAt, outputBuffer, executionId]
36523
37110
  );
36524
- saveDb(db, ocrDir);
36525
37111
  appendCommandLog(ocrDir, {
36526
37112
  ...baseLogEntry,
36527
37113
  is_detached: trackedIsDetached,
@@ -36551,13 +37137,12 @@ function cleanupChat(conversationId) {
36551
37137
  activeChats.delete(conversationId);
36552
37138
  }
36553
37139
  }
36554
- function resetIdleTimer(conversationId, db, ocrDir) {
37140
+ function resetIdleTimer(conversationId, db) {
36555
37141
  const chat = activeChats.get(conversationId);
36556
37142
  if (chat) {
36557
37143
  clearTimeout(chat.timer);
36558
37144
  chat.timer = setTimeout(() => {
36559
37145
  updateConversationStatus(db, conversationId, "expired");
36560
- saveDb(db, ocrDir);
36561
37146
  cleanupChat(conversationId);
36562
37147
  }, IDLE_TIMEOUT_MS);
36563
37148
  }
@@ -36581,9 +37166,7 @@ function registerChatHandlers(io2, socket, db, ocrDir, aiCliService) {
36581
37166
  return;
36582
37167
  }
36583
37168
  upsertConversation(db, conversationId, sessionId, targetType, targetId);
36584
- saveDb(db, ocrDir);
36585
37169
  insertMessage(db, conversationId, "user", message);
36586
- saveDb(db, ocrDir);
36587
37170
  const conversation = getConversation(db, conversationId);
36588
37171
  const claudeSessionId = conversation?.claude_session_id ?? null;
36589
37172
  let prompt;
@@ -36624,7 +37207,6 @@ User: ${message}`;
36624
37207
  const proc = spawnResult.process;
36625
37208
  const timer = setTimeout(() => {
36626
37209
  updateConversationStatus(db, conversationId, "expired");
36627
- saveDb(db, ocrDir);
36628
37210
  cleanupChat(conversationId);
36629
37211
  }, IDLE_TIMEOUT_MS);
36630
37212
  activeChats.set(conversationId, { process: proc, conversationId, timer });
@@ -36714,7 +37296,6 @@ User: ${message}`;
36714
37296
  if (assistantText.trim()) {
36715
37297
  insertMessage(db, conversationId, "assistant", assistantText.trim());
36716
37298
  }
36717
- saveDb(db, ocrDir);
36718
37299
  if (code === 0) {
36719
37300
  tracker.appendOutput("\n\u2713 Response complete\n");
36720
37301
  tracker.finish(0);
@@ -36730,7 +37311,7 @@ User: ${message}`;
36730
37311
  error: errMsg
36731
37312
  });
36732
37313
  }
36733
- resetIdleTimer(conversationId, db, ocrDir);
37314
+ resetIdleTimer(conversationId, db);
36734
37315
  const chat = activeChats.get(conversationId);
36735
37316
  if (chat) {
36736
37317
  chat.process = null;
@@ -36790,13 +37371,13 @@ function cleanupAllChats() {
36790
37371
  }
36791
37372
 
36792
37373
  // src/server/socket/post-handler.ts
36793
- import { existsSync as existsSync13, mkdirSync as mkdirSync6, readFileSync as readFileSync13, unlinkSync as unlinkSync2, writeFileSync as writeFileSync6 } from "node:fs";
37374
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, readFileSync as readFileSync10, unlinkSync as unlinkSync2, writeFileSync as writeFileSync4 } from "node:fs";
36794
37375
  import { tmpdir as tmpdir2 } from "node:os";
36795
- import { join as join15, dirname as dirname12, isAbsolute } from "node:path";
37376
+ import { join as join16, dirname as dirname12, isAbsolute as isAbsolute2 } from "node:path";
36796
37377
  import { randomUUID as randomUUID2 } from "node:crypto";
36797
- function resolveSessionDir(sessionDir, ocrDir) {
36798
- if (isAbsolute(sessionDir)) return sessionDir;
36799
- return join15(dirname12(ocrDir), sessionDir);
37378
+ function resolveSessionDir2(sessionDir, ocrDir) {
37379
+ if (isAbsolute2(sessionDir)) return sessionDir;
37380
+ return join16(dirname12(ocrDir), sessionDir);
36800
37381
  }
36801
37382
  var BRANCH_PREFIXES = [
36802
37383
  "feat",
@@ -36966,19 +37547,19 @@ function registerPostHandlers(io2, socket, db, ocrDir, aiCliService) {
36966
37547
  socket.emit("post:error", { error: "Session not found" });
36967
37548
  return;
36968
37549
  }
36969
- const sessionDir = session.session_dir ? resolveSessionDir(session.session_dir, ocrDir) : join15(ocrDir, "sessions", sessionId);
36970
- const roundDir = join15(sessionDir, "rounds", `round-${roundNumber}`);
36971
- const finalPath = join15(roundDir, "final.md");
37550
+ const sessionDir = session.session_dir ? resolveSessionDir2(session.session_dir, ocrDir) : join16(ocrDir, "sessions", sessionId);
37551
+ const roundDir = join16(sessionDir, "rounds", `round-${roundNumber}`);
37552
+ const finalPath = join16(roundDir, "final.md");
36972
37553
  if (!existsSync13(finalPath)) {
36973
37554
  socket.emit("post:error", { error: "final.md not found for this round" });
36974
37555
  return;
36975
37556
  }
36976
- const humanReviewPath = join15(roundDir, "final-human.md");
37557
+ const humanReviewPath = join16(roundDir, "final-human.md");
36977
37558
  const repoRoot = dirname12(ocrDir);
36978
- const commandMdPath = join15(ocrDir, "commands", "translate-review-to-single-human.md");
37559
+ const commandMdPath = join16(ocrDir, "commands", "translate-review-to-single-human.md");
36979
37560
  let commandContent;
36980
37561
  try {
36981
- commandContent = readFileSync13(commandMdPath, "utf-8");
37562
+ commandContent = readFileSync10(commandMdPath, "utf-8");
36982
37563
  } catch {
36983
37564
  socket.emit("post:error", {
36984
37565
  error: `Command file not found: ${commandMdPath}. Run \`ocr init\` to set up.`
@@ -37006,8 +37587,8 @@ function registerPostHandlers(io2, socket, db, ocrDir, aiCliService) {
37006
37587
  "",
37007
37588
  "Examples:",
37008
37589
  `- Instead of \`ocr state show\`, run: \`node ${localCli} state show\``,
37009
- `- Instead of \`ocr state init ...\`, run: \`node ${localCli} state init ...\``,
37010
- `- Instead of \`ocr state transition ...\`, run: \`node ${localCli} state transition ...\``,
37590
+ `- Instead of \`ocr state begin ...\`, run: \`node ${localCli} state begin ...\``,
37591
+ `- Instead of \`ocr state advance ...\`, run: \`node ${localCli} state advance ...\``,
37011
37592
  "",
37012
37593
  "This applies to every `ocr` invocation. Do NOT use bare `ocr` commands."
37013
37594
  );
@@ -37060,7 +37641,7 @@ function registerPostHandlers(io2, socket, db, ocrDir, aiCliService) {
37060
37641
  let generatedContent = "";
37061
37642
  if (existsSync13(humanReviewPath)) {
37062
37643
  try {
37063
- generatedContent = readFileSync13(humanReviewPath, "utf-8").trim();
37644
+ generatedContent = readFileSync10(humanReviewPath, "utf-8").trim();
37064
37645
  } catch {
37065
37646
  }
37066
37647
  }
@@ -37135,12 +37716,11 @@ function registerPostHandlers(io2, socket, db, ocrDir, aiCliService) {
37135
37716
  socket.emit("post:save-result", { success: false, error: "Session not found" });
37136
37717
  return;
37137
37718
  }
37138
- const sessionDir = session.session_dir ? resolveSessionDir(session.session_dir, ocrDir) : join15(ocrDir, "sessions", sessionId);
37139
- const roundDir = join15(sessionDir, "rounds", `round-${roundNumber}`);
37140
- mkdirSync6(roundDir, { recursive: true });
37141
- const filePath = join15(roundDir, "final-human.md");
37142
- writeFileSync6(filePath, content, { mode: 420 });
37143
- saveDb(db, ocrDir);
37719
+ const sessionDir = session.session_dir ? resolveSessionDir2(session.session_dir, ocrDir) : join16(ocrDir, "sessions", sessionId);
37720
+ const roundDir = join16(sessionDir, "rounds", `round-${roundNumber}`);
37721
+ mkdirSync5(roundDir, { recursive: true });
37722
+ const filePath = join16(roundDir, "final-human.md");
37723
+ writeFileSync4(filePath, content, { mode: 420 });
37144
37724
  socket.emit("post:save-result", { success: true });
37145
37725
  } catch (err) {
37146
37726
  console.error("Error in post:save handler:", err);
@@ -37166,13 +37746,13 @@ function registerPostHandlers(io2, socket, db, ocrDir, aiCliService) {
37166
37746
  );
37167
37747
  tracker.appendOutput(`\u25B8 Posting review to PR #${prNumber}...
37168
37748
  `);
37169
- const tmpDir = join15(tmpdir2(), "ocr-post-comments");
37749
+ const tmpDir = join16(tmpdir2(), "ocr-post-comments");
37170
37750
  try {
37171
- mkdirSync6(tmpDir, { recursive: true, mode: 448 });
37751
+ mkdirSync5(tmpDir, { recursive: true, mode: 448 });
37172
37752
  } catch {
37173
37753
  }
37174
- const tmpFile = join15(tmpDir, `${randomUUID2()}.md`);
37175
- writeFileSync6(tmpFile, content, { mode: 384 });
37754
+ const tmpFile = join16(tmpDir, `${randomUUID2()}.md`);
37755
+ writeFileSync4(tmpFile, content, { mode: 384 });
37176
37756
  const repoRoot = dirname12(ocrDir);
37177
37757
  try {
37178
37758
  const { stdout } = await execBinaryAsync(
@@ -37217,17 +37797,17 @@ function cleanupAllPostGenerations() {
37217
37797
  }
37218
37798
 
37219
37799
  // ../cli/src/lib/runtime-config.ts
37220
- import { existsSync as existsSync14, readFileSync as readFileSync14 } from "node:fs";
37221
- import { join as join16 } from "node:path";
37800
+ import { existsSync as existsSync14, readFileSync as readFileSync11 } from "node:fs";
37801
+ import { join as join17 } from "node:path";
37222
37802
  var DEFAULT_AGENT_HEARTBEAT_SECONDS = 60;
37223
37803
  function getAgentHeartbeatSeconds(ocrDir) {
37224
- const configPath = join16(ocrDir, "config.yaml");
37804
+ const configPath = join17(ocrDir, "config.yaml");
37225
37805
  if (!existsSync14(configPath)) {
37226
37806
  return DEFAULT_AGENT_HEARTBEAT_SECONDS;
37227
37807
  }
37228
37808
  let content;
37229
37809
  try {
37230
- content = readFileSync14(configPath, "utf-8");
37810
+ content = readFileSync11(configPath, "utf-8");
37231
37811
  } catch {
37232
37812
  return DEFAULT_AGENT_HEARTBEAT_SECONDS;
37233
37813
  }
@@ -37322,23 +37902,23 @@ async function startServer(options = {}) {
37322
37902
  const port = options.port ?? parseInt(process.env.PORT ?? "4173", 10);
37323
37903
  const ocrDir = resolveOcrDir();
37324
37904
  const aiCliService = new AiCliService(ocrDir);
37325
- const dbPathForCheckpoint = join17(ocrDir, "data", "ocr.db");
37905
+ const dbPathForCheckpoint = join18(ocrDir, "data", "ocr.db");
37326
37906
  const walResult = walCheckpointTruncate(dbPathForCheckpoint);
37327
37907
  if (walResult === "checkpointed") {
37328
37908
  console.log(" WAL checkpoint: truncated stale write-ahead-log file");
37329
37909
  }
37330
37910
  const db = await openDb(ocrDir);
37331
- const dataDir = join17(ocrDir, "data");
37332
- const pidFilePath = join17(dataDir, "dashboard.pid");
37333
- const portFilePath = join17(dataDir, "server-port");
37334
- mkdirSync7(dataDir, { recursive: true });
37911
+ const dataDir = join18(ocrDir, "data");
37912
+ const pidFilePath = join18(dataDir, "dashboard.pid");
37913
+ const portFilePath = join18(dataDir, "server-port");
37914
+ mkdirSync6(dataDir, { recursive: true });
37335
37915
  try {
37336
37916
  unlinkSync3(portFilePath);
37337
37917
  } catch {
37338
37918
  }
37339
37919
  if (existsSync15(pidFilePath)) {
37340
37920
  try {
37341
- const oldPid = parseInt(readFileSync15(pidFilePath, "utf-8").trim(), 10);
37921
+ const oldPid = parseInt(readFileSync12(pidFilePath, "utf-8").trim(), 10);
37342
37922
  if (!isNaN(oldPid)) {
37343
37923
  try {
37344
37924
  process.kill(oldPid, 0);
@@ -37351,13 +37931,12 @@ async function startServer(options = {}) {
37351
37931
  } catch {
37352
37932
  }
37353
37933
  }
37354
- writeFileSync7(pidFilePath, String(process.pid), { mode: 384 });
37934
+ writeFileSync5(pidFilePath, String(process.pid), { mode: 384 });
37355
37935
  const cmdCountResult = db.exec("SELECT COUNT(*) as c FROM command_executions");
37356
37936
  const totalCmds = cmdCountResult[0]?.values[0]?.[0] ?? 0;
37357
37937
  if (totalCmds === 0) {
37358
37938
  const recovered = replayCommandLog(db, ocrDir);
37359
37939
  if (recovered > 0) {
37360
- saveDb(db, ocrDir);
37361
37940
  console.log(` Recovered ${recovered} command(s) from JSONL backup`);
37362
37941
  }
37363
37942
  }
@@ -37368,15 +37947,14 @@ async function startServer(options = {}) {
37368
37947
  if (orphanResult.length > 0 && orphanResult[0]) {
37369
37948
  const { columns, values: orphanRows } = orphanResult[0];
37370
37949
  const colIdx = Object.fromEntries(columns.map((c, i) => [c, i]));
37371
- const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
37950
+ const cutoff = Date.now() - PID_REUSE_GUARD_MS;
37372
37951
  let killedCount = 0;
37373
37952
  for (const row of orphanRows) {
37374
37953
  const pid = row[colIdx["pid"]];
37375
37954
  const isDetached = row[colIdx["is_detached"]] === 1;
37376
37955
  const startedAt = row[colIdx["started_at"]];
37377
- if (new Date(startedAt).getTime() < cutoff) continue;
37378
- try {
37379
- process.kill(pid, 0);
37956
+ if (sqliteUtcMs(startedAt) < cutoff) continue;
37957
+ if (defaultIsAlive(pid)) {
37380
37958
  if (isDetached) {
37381
37959
  try {
37382
37960
  process.kill(-pid, "SIGTERM");
@@ -37400,37 +37978,55 @@ async function startServer(options = {}) {
37400
37978
  } catch {
37401
37979
  }
37402
37980
  }, 2e3);
37403
- } catch {
37404
37981
  }
37405
37982
  }
37406
37983
  if (killedCount > 0) {
37407
37984
  console.log(` Cleaned up ${killedCount} orphaned process(es)`);
37408
37985
  }
37409
37986
  }
37410
- const staleResult = db.exec(
37411
- "SELECT COUNT(*) as c FROM command_executions WHERE finished_at IS NULL OR exit_code IS NULL"
37987
+ const legacyResult = db.exec(
37988
+ "SELECT COUNT(*) as c FROM command_executions WHERE finished_at IS NOT NULL AND exit_code IS NULL"
37412
37989
  );
37413
- const staleCount = staleResult[0]?.values[0]?.[0] ?? 0;
37414
- if (staleCount > 0) {
37990
+ const legacyCount = legacyResult[0]?.values[0]?.[0] ?? 0;
37991
+ if (legacyCount > 0) {
37415
37992
  db.run(
37416
37993
  `UPDATE command_executions
37417
- SET exit_code = -2, finished_at = COALESCE(finished_at, datetime('now')),
37994
+ SET exit_code = -2,
37418
37995
  output = COALESCE(output, '') || '
37419
- [Cancelled]',
37420
- pid = NULL
37421
- WHERE finished_at IS NULL OR exit_code IS NULL`
37996
+ [Cancelled]'
37997
+ WHERE finished_at IS NOT NULL AND exit_code IS NULL`
37422
37998
  );
37423
- saveDb(db, ocrDir);
37424
- console.log(` Cleaned up ${staleCount} stale command(s)`);
37999
+ console.log(` Backfilled ${legacyCount} finished command(s) missing an exit code`);
37425
38000
  }
37426
38001
  const heartbeatSeconds = getAgentHeartbeatSeconds(ocrDir);
37427
- const sweepResult = sweepStaleAgentSessions(db, heartbeatSeconds);
37428
- if (sweepResult.orphanedIds.length > 0) {
37429
- saveDb(db, ocrDir);
38002
+ const logAgentSweep = (result) => {
38003
+ if (result.orphanedIds.length === 0) return;
38004
+ const cascaded = result.cascadedWorkflowIds.length;
38005
+ console.log(
38006
+ ` Cleaned up ${result.orphanedIds.length} stale agent session(s) (heartbeat threshold ${heartbeatSeconds}s)` + (cascaded > 0 ? `; cascade-closed dependents of ${cascaded} workflow(s)` : "")
38007
+ );
38008
+ };
38009
+ logAgentSweep(sweepStaleAgentSessions(db, heartbeatSeconds, defaultIsAlive));
38010
+ const STALE_SESSION_THRESHOLD_SECONDS = 7 * 24 * 60 * 60;
38011
+ const staleSessionResult = sweepStaleSessions(
38012
+ db,
38013
+ STALE_SESSION_THRESHOLD_SECONDS
38014
+ );
38015
+ if (staleSessionResult.closedSessionIds.length > 0) {
37430
38016
  console.log(
37431
- ` Cleaned up ${sweepResult.orphanedIds.length} stale agent session(s) (heartbeat threshold ${heartbeatSeconds}s)`
38017
+ ` Auto-closed ${staleSessionResult.closedSessionIds.length} stale active session(s) (threshold 7 days)`
37432
38018
  );
37433
38019
  }
38020
+ const SWEEP_INTERVAL_MS = 5 * 60 * 1e3;
38021
+ const sweepTimer = setInterval(() => {
38022
+ try {
38023
+ logAgentSweep(sweepStaleAgentSessions(db, heartbeatSeconds, defaultIsAlive));
38024
+ sweepStaleSessions(db, STALE_SESSION_THRESHOLD_SECONDS);
38025
+ } catch (err) {
38026
+ console.error("[sweep] periodic sweep failed:", err);
38027
+ }
38028
+ }, SWEEP_INTERVAL_MS);
38029
+ sweepTimer.unref();
37434
38030
  app.get("/api/reviews", (_req, res) => {
37435
38031
  try {
37436
38032
  const rounds = getAllRounds(db).map((r) => ({
@@ -37448,12 +38044,12 @@ async function startServer(options = {}) {
37448
38044
  app.use("/api/sessions", createReviewsRouter(db));
37449
38045
  app.use("/api/sessions", createMapsRouter(db));
37450
38046
  app.use("/api/sessions", createArtifactsRouter(db));
37451
- app.use("/api", createProgressRouter(db, ocrDir));
37452
- app.use("/api/notes", createNotesRouter(db, ocrDir));
38047
+ app.use("/api", createProgressRouter(db));
38048
+ app.use("/api/notes", createNotesRouter(db));
37453
38049
  app.use("/api/stats", createStatsRouter(db));
37454
38050
  app.use("/api/commands", createCommandsRouter(db, ocrDir));
37455
38051
  app.use("/api/config", createConfigRouter(ocrDir, aiCliService));
37456
- app.use("/api/sessions", createChatRouter(db, ocrDir));
38052
+ app.use("/api/sessions", createChatRouter(db));
37457
38053
  app.use("/api/reviewers", createReviewersRouter(ocrDir));
37458
38054
  let pullSync = () => {
37459
38055
  };
@@ -37461,11 +38057,11 @@ async function startServer(options = {}) {
37461
38057
  app.use("/api/agent-sessions", createAgentSessionsRouter(db, () => pullSync()));
37462
38058
  app.use("/api/sessions", createHandoffRouter(sessionCapture, ocrDir, () => pullSync()));
37463
38059
  app.use("/api/team", createTeamRouter(ocrDir));
37464
- const clientDir = join17(__dirname3, "client");
38060
+ const clientDir = join18(__dirname3, "client");
37465
38061
  if (process.env.NODE_ENV === "production" && existsSync15(clientDir)) {
37466
38062
  app.use(import_express15.default.static(clientDir, { index: false }));
37467
- const indexHtmlPath = join17(clientDir, "index.html");
37468
- const rawIndexHtml = existsSync15(indexHtmlPath) ? readFileSync15(indexHtmlPath, "utf-8") : "";
38063
+ const indexHtmlPath = join18(clientDir, "index.html");
38064
+ const rawIndexHtml = existsSync15(indexHtmlPath) ? readFileSync12(indexHtmlPath, "utf-8") : "";
37469
38065
  const tokenScript = `<script>window.__OCR_TOKEN__=${JSON.stringify(AUTH_TOKEN)};</script>`;
37470
38066
  const injectedIndexHtml = rawIndexHtml.replace(
37471
38067
  "</head>",
@@ -37484,16 +38080,13 @@ async function startServer(options = {}) {
37484
38080
  registerChatHandlers(io, socket, db, ocrDir, aiCliService);
37485
38081
  registerPostHandlers(io, socket, db, ocrDir, aiCliService);
37486
38082
  });
37487
- const dbFilePath = join17(ocrDir, "data", "ocr.db");
38083
+ const dbFilePath = join18(ocrDir, "data", "ocr.db");
37488
38084
  const dbSyncWatcher = new DbSyncWatcher(
37489
38085
  db,
37490
38086
  dbFilePath,
37491
38087
  io,
37492
- () => {
37493
- saveDb(db, ocrDir);
37494
- },
37495
38088
  // Auto-link the dashboard's parent execution row when the AI
37496
- // creates a new session via `ocr state init`. Eliminates the
38089
+ // creates a new session via `ocr state begin`. Eliminates the
37497
38090
  // dependency on env-var/flag propagation through the AI's shell.
37498
38091
  (session) => {
37499
38092
  sessionCapture.autoLinkPendingDashboardExecution(session.id);
@@ -37503,14 +38096,9 @@ async function startServer(options = {}) {
37503
38096
  dbSyncWatcher.startWatching();
37504
38097
  pullSync = () => dbSyncWatcher.syncFromDisk();
37505
38098
  console.log(` Watching DB: ${shortenPath(dbFilePath)}`);
37506
- registerSaveHooks(
37507
- () => dbSyncWatcher.syncFromDisk(),
37508
- () => dbSyncWatcher.markOwnWrite()
37509
- );
37510
- const sessionsDir = join17(ocrDir, "sessions");
37511
- const fsSync = new FilesystemSync(db, sessionsDir, io, () => saveDb(db, ocrDir));
38099
+ const sessionsDir = join18(ocrDir, "sessions");
38100
+ const fsSync = new FilesystemSync(db, sessionsDir, io);
37512
38101
  await fsSync.fullScan();
37513
- saveDb(db, ocrDir);
37514
38102
  fsSync.startWatching();
37515
38103
  console.log(` Watching sessions: ${shortenPath(sessionsDir)}`);
37516
38104
  const stopReviewersWatch = watchReviewersMeta(ocrDir, io);
@@ -37547,7 +38135,7 @@ async function startServer(options = {}) {
37547
38135
  if (actualPort !== port) {
37548
38136
  console.log(` Note: using port ${actualPort} (${port} was in use)`);
37549
38137
  }
37550
- writeFileSync7(portFilePath, String(actualPort), { mode: 384 });
38138
+ writeFileSync5(portFilePath, String(actualPort), { mode: 384 });
37551
38139
  console.log(` Server: http://localhost:${actualPort}`);
37552
38140
  console.log(` OCR directory: ${shortenPath(ocrDir)}`);
37553
38141
  console.log();
@@ -37573,7 +38161,7 @@ async function startServer(options = {}) {
37573
38161
  } catch {
37574
38162
  }
37575
38163
  try {
37576
- unlinkSync3(join17(dataDir, "dashboard-active-spawn.json"));
38164
+ unlinkSync3(join18(dataDir, "dashboard-active-spawn.json"));
37577
38165
  } catch {
37578
38166
  }
37579
38167
  try {
@@ -37614,24 +38202,12 @@ async function startServer(options = {}) {
37614
38202
  }
37615
38203
  cleanupAllChats();
37616
38204
  cleanupAllPostGenerations();
37617
- try {
37618
- flushSave();
37619
- } catch {
37620
- }
37621
- try {
37622
- saveDb(db, ocrDir);
37623
- } catch {
37624
- }
37625
38205
  dbSyncWatcher.stopWatching();
37626
38206
  fsSync.stopWatching();
37627
38207
  stopReviewersWatch();
37628
38208
  io.close();
37629
38209
  httpServer.closeAllConnections();
37630
38210
  httpServer.close(() => {
37631
- try {
37632
- saveDb(db, ocrDir);
37633
- } catch {
37634
- }
37635
38211
  closeDb();
37636
38212
  console.log("Server stopped.");
37637
38213
  process.exit(0);