@open-code-review/cli 2.2.1 → 2.4.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 (64) hide show
  1. package/README.md +9 -0
  2. package/dist/dashboard/client/assets/{_basePickBy-BAlGnwHG.js → _basePickBy-CyrHyeyN.js} +1 -1
  3. package/dist/dashboard/client/assets/{_baseUniq-CoauyOeL.js → _baseUniq-Bg7NJSGS.js} +1 -1
  4. package/dist/dashboard/client/assets/{arc-DtS0aHfP.js → arc-zDGAKMur.js} +1 -1
  5. package/dist/dashboard/client/assets/{architectureDiagram-VXUJARFQ-CnWmtRTh.js → architectureDiagram-VXUJARFQ-BxlGxm0Q.js} +1 -1
  6. package/dist/dashboard/client/assets/{blockDiagram-VD42YOAC-DgPp4oGV.js → blockDiagram-VD42YOAC-BskTNyX5.js} +1 -1
  7. package/dist/dashboard/client/assets/{c4Diagram-YG6GDRKO--LV4qQaE.js → c4Diagram-YG6GDRKO-Dr9QQ-dn.js} +1 -1
  8. package/dist/dashboard/client/assets/channel-BUnm_-UQ.js +1 -0
  9. package/dist/dashboard/client/assets/{chunk-4BX2VUAB-BRglpc7Z.js → chunk-4BX2VUAB-xq9xoCTv.js} +1 -1
  10. package/dist/dashboard/client/assets/{chunk-55IACEB6-Bgx06_CV.js → chunk-55IACEB6-DYdXYVh5.js} +1 -1
  11. package/dist/dashboard/client/assets/{chunk-B4BG7PRW-D6HN3Yiy.js → chunk-B4BG7PRW-BGAyFRFS.js} +1 -1
  12. package/dist/dashboard/client/assets/{chunk-DI55MBZ5-NH9EgN9T.js → chunk-DI55MBZ5-C5ul9stk.js} +1 -1
  13. package/dist/dashboard/client/assets/{chunk-FMBD7UC4-xriO6WNP.js → chunk-FMBD7UC4-BSaPo2xa.js} +1 -1
  14. package/dist/dashboard/client/assets/{chunk-QN33PNHL-CV1h6_Zl.js → chunk-QN33PNHL-CyzabUv0.js} +1 -1
  15. package/dist/dashboard/client/assets/{chunk-QZHKN3VN-CV4VzxNq.js → chunk-QZHKN3VN-CceRbxt_.js} +1 -1
  16. package/dist/dashboard/client/assets/{chunk-TZMSLE5B-isdklocW.js → chunk-TZMSLE5B-Bjg9IoOQ.js} +1 -1
  17. package/dist/dashboard/client/assets/classDiagram-2ON5EDUG-D_fkmNvU.js +1 -0
  18. package/dist/dashboard/client/assets/classDiagram-v2-WZHVMYZB-D_fkmNvU.js +1 -0
  19. package/dist/dashboard/client/assets/clone-DTyrNOLZ.js +1 -0
  20. package/dist/dashboard/client/assets/{cose-bilkent-S5V4N54A-CCzlFSJf.js → cose-bilkent-S5V4N54A-DEdXBrCt.js} +1 -1
  21. package/dist/dashboard/client/assets/{dagre-6UL2VRFP-DVN3PkjZ.js → dagre-6UL2VRFP-DRdIiP58.js} +1 -1
  22. package/dist/dashboard/client/assets/{diagram-PSM6KHXK-SzJVoSsb.js → diagram-PSM6KHXK-Bo7Q2VlK.js} +1 -1
  23. package/dist/dashboard/client/assets/{diagram-QEK2KX5R-CgGn7ts-.js → diagram-QEK2KX5R-2Fmc2o5x.js} +1 -1
  24. package/dist/dashboard/client/assets/{diagram-S2PKOQOG-Bz1ukSx8.js → diagram-S2PKOQOG-5WE8f0p7.js} +1 -1
  25. package/dist/dashboard/client/assets/{erDiagram-Q2GNP2WA-CpstUTMZ.js → erDiagram-Q2GNP2WA-DD-iXWd_.js} +1 -1
  26. package/dist/dashboard/client/assets/{flowDiagram-NV44I4VS-aYVydGhp.js → flowDiagram-NV44I4VS-CCWo8Ue9.js} +1 -1
  27. package/dist/dashboard/client/assets/{ganttDiagram-JELNMOA3-Cb2DUSRk.js → ganttDiagram-JELNMOA3-CNY4d5UK.js} +1 -1
  28. package/dist/dashboard/client/assets/{gitGraphDiagram-V2S2FVAM-BUOnwA2w.js → gitGraphDiagram-V2S2FVAM-Dq5SBEJJ.js} +1 -1
  29. package/dist/dashboard/client/assets/{graph-4X5ddhLp.js → graph-BTt9lokK.js} +1 -1
  30. package/dist/dashboard/client/assets/{index-CKWqYAfu.js → index-B0k81q2b.js} +138 -138
  31. package/dist/dashboard/client/assets/index-Czwdh6UA.css +1 -0
  32. package/dist/dashboard/client/assets/{infoDiagram-HS3SLOUP-BlMqcrwm.js → infoDiagram-HS3SLOUP-AnKZja-G.js} +1 -1
  33. package/dist/dashboard/client/assets/{journeyDiagram-XKPGCS4Q-DF2ew7ju.js → journeyDiagram-XKPGCS4Q-nC-_WjPN.js} +1 -1
  34. package/dist/dashboard/client/assets/{kanban-definition-3W4ZIXB7-BKQMx0-n.js → kanban-definition-3W4ZIXB7-BEY73sWU.js} +1 -1
  35. package/dist/dashboard/client/assets/{layout-DNcn2g9w.js → layout-D4DfNpzH.js} +1 -1
  36. package/dist/dashboard/client/assets/{linear-Bqy9gvqb.js → linear-ZpGvKjeP.js} +1 -1
  37. package/dist/dashboard/client/assets/{mermaid-renderer-dJ71wgld.js → mermaid-renderer-BCDxmS9g.js} +4 -4
  38. package/dist/dashboard/client/assets/{mindmap-definition-VGOIOE7T-BARc8sqJ.js → mindmap-definition-VGOIOE7T-MzAaKESA.js} +1 -1
  39. package/dist/dashboard/client/assets/{pieDiagram-ADFJNKIX-CULlNZTd.js → pieDiagram-ADFJNKIX-B_X1kySF.js} +1 -1
  40. package/dist/dashboard/client/assets/{quadrantDiagram-AYHSOK5B-BJEZPVe9.js → quadrantDiagram-AYHSOK5B-CMoIEMLN.js} +1 -1
  41. package/dist/dashboard/client/assets/{requirementDiagram-UZGBJVZJ-BhMsmUIs.js → requirementDiagram-UZGBJVZJ-v4CRsn1w.js} +1 -1
  42. package/dist/dashboard/client/assets/{sankeyDiagram-TZEHDZUN-BYbNgogG.js → sankeyDiagram-TZEHDZUN-CPcyN8Jj.js} +1 -1
  43. package/dist/dashboard/client/assets/{sequenceDiagram-WL72ISMW-MoM_NwWk.js → sequenceDiagram-WL72ISMW-CTg0Vx1H.js} +1 -1
  44. package/dist/dashboard/client/assets/{stateDiagram-FKZM4ZOC-ditrlbM3.js → stateDiagram-FKZM4ZOC-BMWBN6Nq.js} +1 -1
  45. package/dist/dashboard/client/assets/stateDiagram-v2-4FDKWEC3-C9Jk1xd0.js +1 -0
  46. package/dist/dashboard/client/assets/{timeline-definition-IT6M3QCI-DOAJyjuz.js → timeline-definition-IT6M3QCI-B8xFcSGb.js} +1 -1
  47. package/dist/dashboard/client/assets/{treemap-GDKQZRPO-BBJkjnJl.js → treemap-GDKQZRPO-HQQuGl9w.js} +1 -1
  48. package/dist/dashboard/client/assets/{xychartDiagram-PRI3JC2R-CPW4s5vm.js → xychartDiagram-PRI3JC2R-Drz0SW3I.js} +1 -1
  49. package/dist/dashboard/client/index.html +2 -2
  50. package/dist/dashboard/server.js +926 -461
  51. package/dist/index.js +1344 -323
  52. package/package.json +5 -38
  53. package/dist/dashboard/client/assets/channel-BU2129fl.js +0 -1
  54. package/dist/dashboard/client/assets/classDiagram-2ON5EDUG-CVftFGiR.js +0 -1
  55. package/dist/dashboard/client/assets/classDiagram-v2-WZHVMYZB-CVftFGiR.js +0 -1
  56. package/dist/dashboard/client/assets/clone-DC6LEEC5.js +0 -1
  57. package/dist/dashboard/client/assets/index-CzxeSSaQ.css +0 -1
  58. package/dist/dashboard/client/assets/stateDiagram-v2-4FDKWEC3-SqoG2LCn.js +0 -1
  59. package/dist/lib/db/index.js +0 -2177
  60. package/dist/lib/models.js +0 -160
  61. package/dist/lib/runtime-config.js +0 -55
  62. package/dist/lib/state/index.js +0 -2196
  63. package/dist/lib/team-config.js +0 -175
  64. package/dist/lib/vendor-resume.js +0 -31
@@ -1,2196 +0,0 @@
1
- // src/lib/state/index.ts
2
- import {
3
- existsSync as existsSync3,
4
- mkdirSync as mkdirSync2,
5
- readdirSync,
6
- readFileSync,
7
- statSync as statSync2,
8
- writeFileSync
9
- } from "node:fs";
10
-
11
- // src/lib/db/index.ts
12
- import {
13
- existsSync as existsSync2,
14
- mkdirSync,
15
- copyFileSync,
16
- statSync,
17
- mkdtempSync,
18
- rmSync
19
- } from "node:fs";
20
- import { dirname as dirname2, join as join2 } from "node:path";
21
-
22
- // src/lib/db/engine.ts
23
- import { createRequire } from "node:module";
24
-
25
- // src/lib/runtime-checks.ts
26
- var NODE_FLOOR = { major: 22, minor: 5 };
27
- function isSupportedNode(version) {
28
- const [major = 0, minor = 0] = version.split(".").map((n) => Number.parseInt(n, 10) || 0);
29
- return major > NODE_FLOOR.major || major === NODE_FLOOR.major && minor >= NODE_FLOOR.minor;
30
- }
31
- function nodeVersionGuardMessage(version) {
32
- return `
33
- Open Code Review requires Node.js >= ${NODE_FLOOR.major}.${NODE_FLOOR.minor} (it uses Node's built-in SQLite, \`node:sqlite\`).
34
- You have Node ${version}. Upgrade Node (e.g. \`nvm install 22 && nvm use 22\`) and re-run.
35
-
36
- `;
37
- }
38
- function isSuppressibleSqliteWarning(warning) {
39
- const message = typeof warning === "string" ? warning : warning?.message;
40
- return typeof message === "string" && message.includes("SQLite is an experimental feature");
41
- }
42
-
43
- // src/lib/db/engine.ts
44
- var SQLITE_BUSY = 5;
45
- var SQLITE_BUSY_SNAPSHOT = 261;
46
- var BUSY_RETRY_ATTEMPTS = 5;
47
- var BUSY_RETRY_BACKOFF_MS = 50;
48
- var savepointName = (depth) => `ocr_sp_${depth}`;
49
- var nodeRequire = createRequire(import.meta.url);
50
- var _preconditionsApplied = false;
51
- function applyEnginePreconditions() {
52
- if (_preconditionsApplied) return;
53
- _preconditionsApplied = true;
54
- const originalEmitWarning = process.emitWarning.bind(process);
55
- process.emitWarning = (warning, ...args) => {
56
- if (isSuppressibleSqliteWarning(warning)) return;
57
- originalEmitWarning(warning, ...args);
58
- };
59
- }
60
- var _DatabaseSyncCtor;
61
- function newDatabase(path) {
62
- if (!_DatabaseSyncCtor) {
63
- applyEnginePreconditions();
64
- try {
65
- _DatabaseSyncCtor = nodeRequire("node:sqlite").DatabaseSync;
66
- } catch (e) {
67
- if (!isSupportedNode(process.versions.node)) {
68
- throw new Error(nodeVersionGuardMessage(process.versions.node).trim());
69
- }
70
- throw e;
71
- }
72
- }
73
- return new _DatabaseSyncCtor(path);
74
- }
75
- function isBusyError(e) {
76
- const errcode = e?.errcode;
77
- return errcode === SQLITE_BUSY || errcode === SQLITE_BUSY_SNAPSHOT;
78
- }
79
- var SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
80
- function sleepSync(ms) {
81
- Atomics.wait(SLEEP_BUF, 0, 0, ms);
82
- }
83
- var NodeSqliteAdapter = class {
84
- raw;
85
- /**
86
- * Transaction nesting depth. `node:sqlite` has no transaction helper, so we
87
- * drive `BEGIN IMMEDIATE` ourselves and use SAVEPOINTs for nested calls
88
- * (better-sqlite3 did this automatically). 0 = no transaction open.
89
- */
90
- txnDepth = 0;
91
- constructor(db) {
92
- this.raw = db;
93
- }
94
- exec(sql, params) {
95
- const stmt = this.raw.prepare(sql);
96
- const cols = stmt.columns();
97
- if (cols.length === 0) {
98
- stmt.run(...params ?? []);
99
- return [];
100
- }
101
- stmt.setReturnArrays(true);
102
- const values = stmt.all(...params ?? []);
103
- return values.length > 0 ? [{ columns: cols.map((c) => c.name), values }] : [];
104
- }
105
- run(sql, params) {
106
- if (params !== void 0) {
107
- this.raw.prepare(sql).run(...params);
108
- return;
109
- }
110
- this.raw.exec(sql);
111
- }
112
- prepare(sql) {
113
- return this.raw.prepare(sql);
114
- }
115
- transaction(fn) {
116
- return this.txnDepth > 0 ? this.runNested(fn) : this.runOuter(fn);
117
- }
118
- /**
119
- * Nested call: a SAVEPOINT within the outer transaction's write lock. No
120
- * busy-retry — the outer transaction already holds the lock. The savepoint
121
- * lets the inner block roll back independently while the outer continues.
122
- */
123
- runNested(fn) {
124
- const name = savepointName(this.txnDepth);
125
- this.raw.exec(`SAVEPOINT ${name}`);
126
- this.txnDepth++;
127
- try {
128
- const result = fn();
129
- this.raw.exec(`RELEASE ${name}`);
130
- return result;
131
- } catch (e) {
132
- try {
133
- this.raw.exec(`ROLLBACK TO ${name}`);
134
- this.raw.exec(`RELEASE ${name}`);
135
- } catch {
136
- }
137
- throw e;
138
- } finally {
139
- this.txnDepth--;
140
- }
141
- }
142
- /**
143
- * Outer transaction: `BEGIN IMMEDIATE` acquires the write lock up front so
144
- * cross-process writers serialize cleanly under WAL instead of failing late
145
- * on upgrade. `busy_timeout` covers most contention; a bounded synchronous
146
- * retry absorbs the residual SQLITE_BUSY (another connection holds the lock
147
- * past the timeout, or BUSY_SNAPSHOT). Non-busy errors and the final attempt
148
- * re-throw so genuine failures propagate.
149
- */
150
- runOuter(fn) {
151
- for (let attempt = 0; attempt < BUSY_RETRY_ATTEMPTS; attempt++) {
152
- try {
153
- return this.runOnce(fn);
154
- } catch (e) {
155
- if (!isBusyError(e) || attempt === BUSY_RETRY_ATTEMPTS - 1) throw e;
156
- sleepSync(BUSY_RETRY_BACKOFF_MS);
157
- }
158
- }
159
- throw new Error("transaction retry budget exhausted");
160
- }
161
- /** One `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK` lifecycle. */
162
- runOnce(fn) {
163
- this.raw.exec("BEGIN IMMEDIATE");
164
- this.txnDepth = 1;
165
- try {
166
- const result = fn();
167
- this.raw.exec("COMMIT");
168
- return result;
169
- } catch (e) {
170
- try {
171
- this.raw.exec("ROLLBACK");
172
- } catch {
173
- }
174
- throw e;
175
- } finally {
176
- this.txnDepth = 0;
177
- }
178
- }
179
- pragma(source) {
180
- this.raw.exec(`PRAGMA ${source}`);
181
- return void 0;
182
- }
183
- close() {
184
- try {
185
- this.raw.exec("PRAGMA wal_checkpoint(TRUNCATE)");
186
- } catch {
187
- }
188
- try {
189
- this.raw.close();
190
- } catch (e) {
191
- const message = e?.message ?? "";
192
- if (!/database is not open/i.test(message)) throw e;
193
- }
194
- }
195
- };
196
- function openEngine(dbPath) {
197
- const native = newDatabase(dbPath);
198
- native.exec("PRAGMA journal_mode = WAL");
199
- native.exec("PRAGMA foreign_keys = ON");
200
- native.exec("PRAGMA busy_timeout = 5000");
201
- native.exec("PRAGMA synchronous = NORMAL");
202
- return new NodeSqliteAdapter(native);
203
- }
204
-
205
- // src/lib/db/migrations.ts
206
- var MIGRATIONS = [
207
- {
208
- version: 1,
209
- description: "Initial schema \u2014 sessions, events, artifacts, user state",
210
- sql: `
211
- -- Layer 1: Workflow State
212
-
213
- CREATE TABLE sessions (
214
- id TEXT PRIMARY KEY,
215
- branch TEXT NOT NULL,
216
- status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'closed')),
217
- workflow_type TEXT NOT NULL CHECK(workflow_type IN ('review', 'map')),
218
- current_phase TEXT NOT NULL DEFAULT 'context',
219
- phase_number INTEGER NOT NULL DEFAULT 1,
220
- current_round INTEGER NOT NULL DEFAULT 1,
221
- current_map_run INTEGER NOT NULL DEFAULT 1,
222
- started_at TEXT NOT NULL DEFAULT (datetime('now')),
223
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
224
- session_dir TEXT NOT NULL
225
- );
226
-
227
- CREATE TABLE orchestration_events (
228
- id INTEGER PRIMARY KEY AUTOINCREMENT,
229
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
230
- event_type TEXT NOT NULL,
231
- phase TEXT,
232
- phase_number INTEGER,
233
- round INTEGER,
234
- metadata TEXT,
235
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
236
- );
237
- CREATE INDEX idx_events_session ON orchestration_events(session_id);
238
- CREATE INDEX idx_events_type ON orchestration_events(event_type);
239
-
240
- -- Layer 2: Artifacts
241
-
242
- CREATE TABLE review_rounds (
243
- id INTEGER PRIMARY KEY AUTOINCREMENT,
244
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
245
- round_number INTEGER NOT NULL,
246
- verdict TEXT,
247
- blocker_count INTEGER DEFAULT 0,
248
- suggestion_count INTEGER DEFAULT 0,
249
- should_fix_count INTEGER DEFAULT 0,
250
- final_md_path TEXT,
251
- parsed_at TEXT,
252
- UNIQUE(session_id, round_number)
253
- );
254
-
255
- CREATE TABLE reviewer_outputs (
256
- id INTEGER PRIMARY KEY AUTOINCREMENT,
257
- round_id INTEGER NOT NULL REFERENCES review_rounds(id) ON DELETE CASCADE,
258
- reviewer_type TEXT NOT NULL,
259
- instance_number INTEGER NOT NULL DEFAULT 1,
260
- file_path TEXT NOT NULL,
261
- finding_count INTEGER DEFAULT 0,
262
- parsed_at TEXT,
263
- UNIQUE(round_id, reviewer_type, instance_number)
264
- );
265
-
266
- CREATE TABLE review_findings (
267
- id INTEGER PRIMARY KEY AUTOINCREMENT,
268
- reviewer_output_id INTEGER NOT NULL REFERENCES reviewer_outputs(id) ON DELETE CASCADE,
269
- title TEXT NOT NULL,
270
- severity TEXT NOT NULL CHECK(severity IN ('critical', 'high', 'medium', 'low', 'info')),
271
- file_path TEXT,
272
- line_start INTEGER,
273
- line_end INTEGER,
274
- summary TEXT,
275
- is_blocker INTEGER NOT NULL DEFAULT 0,
276
- parsed_at TEXT
277
- );
278
- CREATE INDEX idx_findings_severity ON review_findings(severity);
279
-
280
- CREATE TABLE markdown_artifacts (
281
- id INTEGER PRIMARY KEY AUTOINCREMENT,
282
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
283
- artifact_type TEXT NOT NULL,
284
- round_number INTEGER,
285
- file_path TEXT NOT NULL,
286
- content TEXT NOT NULL,
287
- parsed_at TEXT NOT NULL DEFAULT (datetime('now')),
288
- UNIQUE(session_id, artifact_type, round_number, file_path)
289
- );
290
-
291
- CREATE TABLE map_runs (
292
- id INTEGER PRIMARY KEY AUTOINCREMENT,
293
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
294
- run_number INTEGER NOT NULL,
295
- file_count INTEGER DEFAULT 0,
296
- map_md_path TEXT,
297
- parsed_at TEXT,
298
- UNIQUE(session_id, run_number)
299
- );
300
-
301
- CREATE TABLE map_sections (
302
- id INTEGER PRIMARY KEY AUTOINCREMENT,
303
- map_run_id INTEGER NOT NULL REFERENCES map_runs(id) ON DELETE CASCADE,
304
- section_number INTEGER NOT NULL,
305
- title TEXT NOT NULL,
306
- description TEXT,
307
- file_count INTEGER DEFAULT 0,
308
- display_order INTEGER NOT NULL DEFAULT 0,
309
- UNIQUE(map_run_id, section_number)
310
- );
311
-
312
- CREATE TABLE map_files (
313
- id INTEGER PRIMARY KEY AUTOINCREMENT,
314
- section_id INTEGER NOT NULL REFERENCES map_sections(id) ON DELETE CASCADE,
315
- file_path TEXT NOT NULL,
316
- role TEXT,
317
- lines_added INTEGER DEFAULT 0,
318
- lines_deleted INTEGER DEFAULT 0,
319
- display_order INTEGER NOT NULL DEFAULT 0,
320
- UNIQUE(section_id, file_path)
321
- );
322
-
323
- -- Layer 3: User Interaction
324
-
325
- CREATE TABLE user_file_progress (
326
- id INTEGER PRIMARY KEY AUTOINCREMENT,
327
- map_file_id INTEGER NOT NULL REFERENCES map_files(id) ON DELETE CASCADE,
328
- is_reviewed INTEGER NOT NULL DEFAULT 0,
329
- reviewed_at TEXT,
330
- UNIQUE(map_file_id)
331
- );
332
-
333
- CREATE TABLE user_finding_progress (
334
- id INTEGER PRIMARY KEY AUTOINCREMENT,
335
- finding_id INTEGER NOT NULL REFERENCES review_findings(id) ON DELETE CASCADE,
336
- status TEXT NOT NULL DEFAULT 'unread' CHECK(status IN ('unread', 'read', 'acknowledged', 'fixed', 'wont_fix')),
337
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
338
- UNIQUE(finding_id)
339
- );
340
-
341
- CREATE TABLE user_notes (
342
- id INTEGER PRIMARY KEY AUTOINCREMENT,
343
- target_type TEXT NOT NULL CHECK(target_type IN ('session', 'round', 'finding', 'run', 'section', 'file')),
344
- target_id TEXT NOT NULL,
345
- content TEXT NOT NULL,
346
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
347
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
348
- );
349
-
350
- CREATE TABLE command_executions (
351
- id INTEGER PRIMARY KEY AUTOINCREMENT,
352
- command TEXT NOT NULL,
353
- args TEXT,
354
- exit_code INTEGER,
355
- started_at TEXT NOT NULL DEFAULT (datetime('now')),
356
- finished_at TEXT,
357
- output TEXT
358
- );
359
- `
360
- },
361
- {
362
- version: 2,
363
- description: "Add chat conversations, messages, and round progress tables",
364
- sql: `
365
- CREATE TABLE IF NOT EXISTS chat_conversations (
366
- id TEXT PRIMARY KEY,
367
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
368
- target_type TEXT NOT NULL CHECK(target_type IN ('map_run', 'review_round')),
369
- target_id INTEGER NOT NULL,
370
- claude_session_id TEXT,
371
- status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'expired')),
372
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
373
- last_active_at TEXT NOT NULL DEFAULT (datetime('now'))
374
- );
375
-
376
- CREATE TABLE IF NOT EXISTS chat_messages (
377
- id INTEGER PRIMARY KEY AUTOINCREMENT,
378
- conversation_id TEXT NOT NULL REFERENCES chat_conversations(id) ON DELETE CASCADE,
379
- role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
380
- content TEXT NOT NULL,
381
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
382
- );
383
-
384
- CREATE TABLE IF NOT EXISTS user_round_progress (
385
- id INTEGER PRIMARY KEY AUTOINCREMENT,
386
- round_id INTEGER NOT NULL REFERENCES review_rounds(id) ON DELETE CASCADE,
387
- status TEXT NOT NULL DEFAULT 'needs_review'
388
- CHECK(status IN ('needs_review', 'in_progress', 'changes_made', 'acknowledged', 'dismissed')),
389
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
390
- UNIQUE(round_id)
391
- );
392
- `
393
- },
394
- {
395
- version: 3,
396
- description: "Add PID tracking to command_executions for orphan process cleanup",
397
- sql: `
398
- ALTER TABLE command_executions ADD COLUMN pid INTEGER;
399
- `
400
- },
401
- {
402
- version: 4,
403
- description: "Add is_detached flag to command_executions for process group kill strategy",
404
- sql: `
405
- ALTER TABLE command_executions ADD COLUMN is_detached INTEGER NOT NULL DEFAULT 0;
406
- `
407
- },
408
- {
409
- version: 5,
410
- description: "Change orchestration_events FK to RESTRICT to protect audit trail",
411
- sql: `
412
- CREATE TABLE orchestration_events_new (
413
- id INTEGER PRIMARY KEY AUTOINCREMENT,
414
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE RESTRICT,
415
- event_type TEXT NOT NULL,
416
- phase TEXT,
417
- phase_number INTEGER,
418
- round INTEGER,
419
- metadata TEXT,
420
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
421
- );
422
- INSERT INTO orchestration_events_new SELECT * FROM orchestration_events;
423
- DROP TABLE orchestration_events;
424
- ALTER TABLE orchestration_events_new RENAME TO orchestration_events;
425
- CREATE INDEX idx_events_session ON orchestration_events(session_id);
426
- CREATE INDEX idx_events_type ON orchestration_events(event_type);
427
- `
428
- },
429
- {
430
- version: 6,
431
- description: "Add orchestrator-first columns to review_rounds for round-meta.json support",
432
- sql: `
433
- ALTER TABLE review_rounds ADD COLUMN source TEXT DEFAULT NULL;
434
- ALTER TABLE review_rounds ADD COLUMN reviewer_count INTEGER DEFAULT 0;
435
- ALTER TABLE review_rounds ADD COLUMN total_finding_count INTEGER DEFAULT 0;
436
- `
437
- },
438
- {
439
- version: 7,
440
- description: "Add category column to review_findings for blocker/should_fix/suggestion classification",
441
- sql: `
442
- ALTER TABLE review_findings ADD COLUMN category TEXT DEFAULT NULL;
443
- `
444
- },
445
- {
446
- version: 8,
447
- description: "Add orchestrator-first columns to map_runs for map-meta.json support",
448
- sql: `
449
- ALTER TABLE map_runs ADD COLUMN source TEXT DEFAULT NULL;
450
- ALTER TABLE map_runs ADD COLUMN section_count INTEGER DEFAULT 0;
451
- `
452
- },
453
- {
454
- version: 9,
455
- description: "Add uid column to command_executions for JSONL-backed recovery",
456
- sql: `
457
- ALTER TABLE command_executions ADD COLUMN uid TEXT;
458
- CREATE UNIQUE INDEX idx_command_executions_uid ON command_executions(uid);
459
- `
460
- },
461
- {
462
- version: 10,
463
- description: "Add agent_sessions journal for per-instance lifecycle tracking",
464
- sql: `
465
- CREATE TABLE agent_sessions (
466
- id TEXT PRIMARY KEY,
467
- workflow_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE RESTRICT,
468
- vendor TEXT NOT NULL,
469
- vendor_session_id TEXT,
470
- persona TEXT,
471
- instance_index INTEGER,
472
- name TEXT,
473
- resolved_model TEXT,
474
- phase TEXT,
475
- status TEXT NOT NULL CHECK(status IN ('spawning', 'running', 'done', 'crashed', 'cancelled', 'orphaned')),
476
- pid INTEGER,
477
- started_at TEXT NOT NULL DEFAULT (datetime('now')),
478
- last_heartbeat_at TEXT NOT NULL DEFAULT (datetime('now')),
479
- ended_at TEXT,
480
- exit_code INTEGER,
481
- notes TEXT
482
- );
483
- CREATE INDEX idx_agent_sessions_workflow ON agent_sessions(workflow_id);
484
- CREATE INDEX idx_agent_sessions_status_heartbeat ON agent_sessions(status, last_heartbeat_at);
485
- `
486
- },
487
- {
488
- version: 11,
489
- description: "Unify agent_sessions into command_executions \u2014 every spawned process is one execution row",
490
- sql: `
491
- -- Extend command_executions with the journaling fields previously on agent_sessions.
492
- -- A NULL workflow_id is allowed because some commands (e.g. sync-reviewers,
493
- -- create-reviewer) don't tie to a review workflow. Existing rows get NULL by default.
494
- ALTER TABLE command_executions ADD COLUMN workflow_id TEXT REFERENCES sessions(id) ON DELETE RESTRICT;
495
- -- parent_id = the dashboard-spawn that's the "Tech Lead" parent of an AI-spawned
496
- -- session-instance row. NULL for top-level dashboard spawns.
497
- ALTER TABLE command_executions ADD COLUMN parent_id INTEGER REFERENCES command_executions(id);
498
- -- Vendor metadata (claude | opencode | gemini | \u2026). NULL for non-AI commands.
499
- ALTER TABLE command_executions ADD COLUMN vendor TEXT;
500
- -- The underlying CLI's own session id, captured from stream events.
501
- -- Used for resume / handoff. Hidden from users (ocr exposes its own id only).
502
- ALTER TABLE command_executions ADD COLUMN vendor_session_id TEXT;
503
- -- Persona/instance metadata for AI sub-agents (set when the AI calls
504
- -- ocr session start-instance). NULL for the parent dashboard spawn.
505
- ALTER TABLE command_executions ADD COLUMN persona TEXT;
506
- ALTER TABLE command_executions ADD COLUMN instance_index INTEGER;
507
- ALTER TABLE command_executions ADD COLUMN name TEXT;
508
- -- Resolved model string passed to --model post-alias-expansion.
509
- ALTER TABLE command_executions ADD COLUMN resolved_model TEXT;
510
- -- Liveness heartbeat. Bumped on every state event the AI emits.
511
- -- Stale rows past the threshold are reclassified to orphaned (exit_code=-3).
512
- ALTER TABLE command_executions ADD COLUMN last_heartbeat_at TEXT;
513
- -- Free-form annotations (sweep notes, host-CLI capability warnings, etc).
514
- ALTER TABLE command_executions ADD COLUMN notes TEXT;
515
- CREATE INDEX idx_command_executions_workflow ON command_executions(workflow_id);
516
- CREATE INDEX idx_command_executions_parent ON command_executions(parent_id);
517
- CREATE INDEX idx_command_executions_heartbeat ON command_executions(last_heartbeat_at);
518
-
519
- -- The agent_sessions table is retired. Phase 1 was a parallel journal that
520
- -- this migration consolidates. We drop the table outright \u2014 the only existing
521
- -- consumers are the cli helpers and tests, which are updated alongside this
522
- -- migration. No production deployments have agent_sessions data worth migrating.
523
- DROP INDEX IF EXISTS idx_agent_sessions_workflow;
524
- DROP INDEX IF EXISTS idx_agent_sessions_status_heartbeat;
525
- DROP TABLE IF EXISTS agent_sessions;
526
- `
527
- },
528
- {
529
- version: 12,
530
- description: "Event-sourced lifecycle hardening: event_type taxonomy guard, sweep indexes, session_completeness view",
531
- sql: `
532
- -- \u2500\u2500 Indexes for the now-periodic stale-session sweep + round derivation \u2500\u2500
533
- -- The sweep filters sessions by status and rolls up MAX(created_at) per
534
- -- session over the event log; deriveNextRound does MAX(round). Index both.
535
- CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
536
- CREATE INDEX IF NOT EXISTS idx_events_session_created
537
- ON orchestration_events(session_id, created_at);
538
-
539
- -- \u2500\u2500 Event-type taxonomy guard \u2500\u2500
540
- -- orchestration_events.event_type is the spine of all lifecycle
541
- -- derivation. A typo (e.g. 'round_complete' vs 'round_completed') would
542
- -- silently break deriveNextRound and the completeness view. SQLite cannot
543
- -- add a CHECK to an existing column without a table rebuild, so enforce
544
- -- the closed vocabulary with a BEFORE INSERT trigger instead.
545
- CREATE TRIGGER IF NOT EXISTS trg_events_known_type
546
- BEFORE INSERT ON orchestration_events
547
- WHEN NEW.event_type NOT IN (
548
- 'session_created', 'session_resumed', 'round_started', 'phase_transition',
549
- 'round_completed', 'map_completed', 'session_closed', 'session_aborted',
550
- 'session_auto_closed_stale', 'session_synced', 'session_legacy_import'
551
- )
552
- BEGIN
553
- SELECT RAISE(ABORT, 'unknown orchestration_events.event_type');
554
- END;
555
-
556
- -- \u2500\u2500 Close-guard (DB backstop for the completion invariant) \u2500\u2500
557
- -- A session cannot transition active \u2192 closed unless its current
558
- -- round/run has a terminal artifact event, OR an explicit reason event
559
- -- (abort / auto-close-stale / sync / legacy-import) is present. Only a
560
- -- *silent* premature close is banned \u2014 every legitimate non-artifact
561
- -- close carries a reason event and passes. App-level guards in
562
- -- stateClose/finish are the primary check; this makes the illegal state
563
- -- unrepresentable even via raw SQL.
564
- --
565
- -- DEFENCE-IN-DEPTH NOTE (intentional, documented gap): the reason-event
566
- -- branch below (event_type IN (...)) is NOT round-scoped \u2014 a reason event
567
- -- recorded for an earlier round would also satisfy a later close. The
568
- -- app-level guards ARE round-scoped (hasCompletionInvariant checks the
569
- -- current round/run), so the precise check lives in the application; this
570
- -- trigger is a coarse backstop against a *silent* premature close via raw
571
- -- SQL. Tightening it to be round-scoped would require a new migration
572
- -- (this v12 trigger is append-only and already shipped); the residual
573
- -- risk is a non-artifact close carrying a stale reason event, which is
574
- -- still an explicit, audited terminal \u2014 not the failure mode this guards.
575
- CREATE TRIGGER IF NOT EXISTS trg_sessions_close_guard
576
- BEFORE UPDATE OF status ON sessions
577
- WHEN NEW.status = 'closed' AND OLD.status <> 'closed'
578
- AND NOT EXISTS (
579
- SELECT 1 FROM orchestration_events e
580
- WHERE e.session_id = NEW.id
581
- AND (
582
- (NEW.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = NEW.current_round)
583
- OR (NEW.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = NEW.current_map_run)
584
- OR e.event_type IN ('session_aborted','session_auto_closed_stale','session_synced','session_legacy_import')
585
- )
586
- )
587
- BEGIN
588
- SELECT RAISE(ABORT, 'cannot close session without a completed round/run or an explicit reason event');
589
- END;
590
-
591
- -- \u2500\u2500 session_completeness view \u2500\u2500
592
- -- The published contract for "is this session actually complete, and if
593
- -- not, what's missing". Completion is DERIVED from the event log, never a
594
- -- mutable flag: a session is complete iff it is closed AND a terminal
595
- -- artifact event exists for its current round/run. The dashboard's
596
- -- outcome derivation and the agent 'status' command read this view, so
597
- -- they cannot disagree.
598
- --
599
- -- completeness_state is an INTENTIONAL HYBRID: it combines the mutable
600
- -- status column (marked_closed) with append-only event evidence (the
601
- -- terminal artifact event). This is sound precisely because the
602
- -- close-guard trigger above makes the status column trustworthy \u2014 a row
603
- -- can only reach status='closed' with a completed round/run or an
604
- -- explicit reason event \u2014 so reading the column is not a regression to
605
- -- the old "mutable flag that could lie" model.
606
- --
607
- -- completeness_state:
608
- -- 'complete' \u2014 closed + terminal artifact for current round/run
609
- -- 'closed_without_artifact' \u2014 closed but no terminal artifact (the
610
- -- "completed too soon" condition)
611
- -- 'in_flight' \u2014 open with a dependent process still running
612
- -- 'open_no_artifact' \u2014 open, no in-flight dependents
613
- CREATE VIEW IF NOT EXISTS session_completeness AS
614
- SELECT
615
- s.id AS session_id,
616
- s.workflow_type AS workflow_type,
617
- s.status AS status,
618
- s.current_round AS current_round,
619
- s.current_map_run AS current_map_run,
620
- CASE WHEN EXISTS (
621
- SELECT 1 FROM orchestration_events e
622
- WHERE e.session_id = s.id
623
- AND (
624
- (s.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = s.current_round)
625
- OR (s.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = s.current_map_run)
626
- )
627
- ) THEN 1 ELSE 0 END AS has_terminal_artifact,
628
- CASE WHEN s.status = 'closed' THEN 1 ELSE 0 END AS marked_closed,
629
- CASE WHEN NOT EXISTS (
630
- SELECT 1 FROM command_executions ce
631
- WHERE ce.workflow_id = s.id AND ce.finished_at IS NULL
632
- ) THEN 1 ELSE 0 END AS dependents_settled,
633
- CASE
634
- WHEN s.status = 'closed' AND EXISTS (
635
- SELECT 1 FROM orchestration_events e
636
- WHERE e.session_id = s.id
637
- AND (
638
- (s.workflow_type = 'review' AND e.event_type = 'round_completed' AND e.round = s.current_round)
639
- OR (s.workflow_type = 'map' AND e.event_type = 'map_completed' AND e.round = s.current_map_run)
640
- )
641
- ) THEN 'complete'
642
- WHEN s.status = 'closed' THEN 'closed_without_artifact'
643
- WHEN EXISTS (
644
- SELECT 1 FROM command_executions ce
645
- WHERE ce.workflow_id = s.id AND ce.finished_at IS NULL
646
- ) THEN 'in_flight'
647
- ELSE 'open_no_artifact'
648
- END AS completeness_state
649
- FROM sessions s;
650
- `
651
- },
652
- {
653
- version: 13,
654
- description: "Retire dead parent_id column on command_executions (never written; row kind is derived from command)",
655
- // parent_id was reserved for an AI-instance → dashboard-spawn lineage link
656
- // that was never wired (no writer, no reader). A process's KIND (supervisor
657
- // / reviewer-instance / utility) is derived from columns that are always
658
- // present (command + last_heartbeat_at), so the dead lineage column and its
659
- // all-NULL index are removed. Re-add a wired parent_id alongside a real
660
- // consumer (e.g. a parent→child tree view) if lineage is ever needed.
661
- //
662
- // Imperative + guarded so the DROP COLUMN (which SQLite can't express as
663
- // IF EXISTS) is idempotent under re-application.
664
- run: (db) => {
665
- if (!columnExists(db, "command_executions", "parent_id")) return;
666
- db.run("DROP INDEX IF EXISTS idx_command_executions_parent;");
667
- db.run("ALTER TABLE command_executions DROP COLUMN parent_id;");
668
- }
669
- },
670
- {
671
- version: 14,
672
- description: "Self-heal markdown_artifacts duplication: collapse NULL-round duplicate rows and add a NULL-safe unique index so the dedup bug cannot recur",
673
- // The table's `UNIQUE(session_id, artifact_type, round_number, file_path)`
674
- // never deduped session-level artifacts because SQLite treats NULL ≠ NULL,
675
- // and the writer used `INSERT OR REPLACE` — so every re-parse of a
676
- // NULL-round artifact (context.md, map.md, …) appended a duplicate (one
677
- // context.md reached 775 identical rows, ~177 MB). The writer is now an
678
- // explicit UPDATE-or-INSERT; this migration heals existing DBs and adds a
679
- // NULL-collapsing unique index as a DB-level backstop.
680
- //
681
- // Orphan-row sweep (FK-dangling children from the pre-FK-enforcement era)
682
- // is intentionally NOT done here — it needs `PRAGMA foreign_keys = OFF`,
683
- // which is a no-op inside the migration transaction. `ocr db doctor --fix`
684
- // performs it outside a transaction.
685
- run: (db) => {
686
- db.run(`
687
- DELETE FROM markdown_artifacts
688
- WHERE rowid NOT IN (
689
- SELECT MAX(rowid) FROM markdown_artifacts
690
- GROUP BY session_id, artifact_type, IFNULL(round_number, -1), file_path
691
- )
692
- `);
693
- db.run(`
694
- CREATE UNIQUE INDEX IF NOT EXISTS idx_markdown_artifacts_logical
695
- ON markdown_artifacts(session_id, artifact_type, IFNULL(round_number, -1), file_path)
696
- `);
697
- }
698
- }
699
- ];
700
- function columnExists(db, table, column) {
701
- const result = db.exec(`PRAGMA table_info(${table})`);
702
- const first = result[0];
703
- if (!first) return false;
704
- const nameIdx = first.columns.indexOf("name");
705
- return first.values.some((row) => row[nameIdx] === column);
706
- }
707
- function ensureSchemaVersionTable(db) {
708
- db.run(`
709
- CREATE TABLE IF NOT EXISTS schema_version (
710
- version INTEGER PRIMARY KEY,
711
- applied_at TEXT NOT NULL DEFAULT (datetime('now')),
712
- description TEXT NOT NULL
713
- );
714
- `);
715
- }
716
- function getSchemaVersion(db) {
717
- ensureSchemaVersionTable(db);
718
- return getCurrentVersion(db);
719
- }
720
- function getCurrentVersion(db) {
721
- const result = db.exec(
722
- "SELECT MAX(version) as v FROM schema_version"
723
- );
724
- if (result.length === 0 || result[0]?.values.length === 0) {
725
- return 0;
726
- }
727
- const val = result[0]?.values[0]?.[0];
728
- return typeof val === "number" ? val : 0;
729
- }
730
- function runMigrations(db) {
731
- ensureSchemaVersionTable(db);
732
- const currentVersion = getCurrentVersion(db);
733
- for (const migration of MIGRATIONS) {
734
- if (migration.version <= currentVersion) {
735
- continue;
736
- }
737
- db.run("BEGIN IMMEDIATE;");
738
- try {
739
- if (migration.sql) db.run(migration.sql);
740
- migration.run?.(db);
741
- db.run(
742
- "INSERT INTO schema_version (version, description) VALUES (?, ?);",
743
- [migration.version, migration.description]
744
- );
745
- db.run("COMMIT;");
746
- } catch (error) {
747
- db.run("ROLLBACK;");
748
- throw error;
749
- }
750
- }
751
- }
752
-
753
- // src/lib/db/reconcile.ts
754
- import { existsSync } from "node:fs";
755
- import { isAbsolute, join, dirname } from "node:path";
756
-
757
- // src/lib/db/result-mapper.ts
758
- function resultToRows(result) {
759
- if (result.length === 0 || !result[0]) {
760
- return [];
761
- }
762
- const { columns, values } = result[0];
763
- return values.map((row) => {
764
- const obj = {};
765
- for (let i = 0; i < columns.length; i++) {
766
- obj[columns[i]] = row[i];
767
- }
768
- return obj;
769
- });
770
- }
771
- function resultToRow(result) {
772
- const rows = resultToRows(result);
773
- return rows[0];
774
- }
775
-
776
- // src/lib/db/queries.ts
777
- function insertSession(db, params) {
778
- const {
779
- id,
780
- branch,
781
- workflow_type,
782
- current_phase = "context",
783
- phase_number = 1,
784
- current_round = 1,
785
- current_map_run = 1,
786
- session_dir
787
- } = params;
788
- db.run(
789
- `INSERT INTO sessions (id, branch, workflow_type, current_phase, phase_number, current_round, current_map_run, session_dir)
790
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
791
- [id, branch, workflow_type, current_phase, phase_number, current_round, current_map_run, session_dir]
792
- );
793
- }
794
- function updateSession(db, id, params) {
795
- const setClauses = [];
796
- const values = [];
797
- if (params.status !== void 0) {
798
- setClauses.push("status = ?");
799
- values.push(params.status);
800
- }
801
- if (params.current_phase !== void 0) {
802
- setClauses.push("current_phase = ?");
803
- values.push(params.current_phase);
804
- }
805
- if (params.phase_number !== void 0) {
806
- setClauses.push("phase_number = ?");
807
- values.push(params.phase_number);
808
- }
809
- if (params.current_round !== void 0) {
810
- setClauses.push("current_round = ?");
811
- values.push(params.current_round);
812
- }
813
- if (params.current_map_run !== void 0) {
814
- setClauses.push("current_map_run = ?");
815
- values.push(params.current_map_run);
816
- }
817
- if (setClauses.length === 0) {
818
- return;
819
- }
820
- setClauses.push("updated_at = datetime('now')");
821
- values.push(id);
822
- db.run(
823
- `UPDATE sessions SET ${setClauses.join(", ")} WHERE id = ?`,
824
- values
825
- );
826
- }
827
- function getSession(db, id) {
828
- return resultToRow(
829
- db.exec("SELECT * FROM sessions WHERE id = ?", [id])
830
- );
831
- }
832
- function getLatestActiveSession(db) {
833
- return resultToRow(
834
- db.exec(
835
- "SELECT * FROM sessions WHERE status = 'active' ORDER BY started_at DESC LIMIT 1"
836
- )
837
- );
838
- }
839
- function getAllSessions(db) {
840
- return resultToRows(
841
- db.exec("SELECT * FROM sessions ORDER BY started_at DESC")
842
- );
843
- }
844
- function insertEvent(db, params) {
845
- const {
846
- session_id,
847
- event_type,
848
- phase,
849
- phase_number,
850
- round,
851
- metadata
852
- } = params;
853
- db.run(
854
- `INSERT INTO orchestration_events (session_id, event_type, phase, phase_number, round, metadata)
855
- VALUES (?, ?, ?, ?, ?, ?)`,
856
- [
857
- session_id,
858
- event_type,
859
- phase ?? null,
860
- phase_number ?? null,
861
- round ?? null,
862
- metadata ?? null
863
- ]
864
- );
865
- }
866
- function getEventsForSession(db, sessionId) {
867
- return resultToRows(
868
- db.exec(
869
- "SELECT * FROM orchestration_events WHERE session_id = ? ORDER BY id ASC",
870
- [sessionId]
871
- )
872
- );
873
- }
874
- function commitReasonClose(db, sessionId, reasonEvent, projectionUpdates) {
875
- db.transaction(() => {
876
- insertEvent(db, { session_id: sessionId, ...reasonEvent });
877
- updateSession(db, sessionId, projectionUpdates);
878
- });
879
- }
880
-
881
- // src/lib/db/reconcile.ts
882
- var DEFAULT_STALE_THRESHOLD_SECONDS = 7 * 24 * 60 * 60;
883
- function hasTerminalArtifactEvent(db, sessionId, workflowType, currentRound, currentMapRun) {
884
- const eventType = workflowType === "map" ? "map_completed" : "round_completed";
885
- const round = workflowType === "map" ? currentMapRun : currentRound;
886
- const r = db.exec(
887
- `SELECT 1 FROM orchestration_events
888
- WHERE session_id = ? AND event_type = ? AND round = ? LIMIT 1`,
889
- [sessionId, eventType, round]
890
- );
891
- return (r[0]?.values.length ?? 0) > 0;
892
- }
893
- function hasReasonEvent(db, sessionId) {
894
- const r = db.exec(
895
- `SELECT 1 FROM orchestration_events
896
- WHERE session_id = ?
897
- AND event_type IN ('session_aborted','session_auto_closed_stale','session_synced','session_legacy_import')
898
- LIMIT 1`,
899
- [sessionId]
900
- );
901
- return (r[0]?.values.length ?? 0) > 0;
902
- }
903
- function lastEventAgeSeconds(db, sessionId) {
904
- const r = db.exec(
905
- `SELECT (julianday('now') - julianday(MAX(created_at))) * 86400
906
- FROM orchestration_events WHERE session_id = ?`,
907
- [sessionId]
908
- );
909
- const v = r[0]?.values[0]?.[0];
910
- return typeof v === "number" ? v : null;
911
- }
912
- function hasInFlightDependents(db, sessionId) {
913
- const r = db.exec(
914
- `SELECT 1 FROM command_executions
915
- WHERE workflow_id = ? AND finished_at IS NULL LIMIT 1`,
916
- [sessionId]
917
- );
918
- return (r[0]?.values.length ?? 0) > 0;
919
- }
920
- function resolveSessionDir(ocrDir, sessionDir) {
921
- if (!sessionDir) return null;
922
- if (isAbsolute(sessionDir)) return sessionDir;
923
- return join(dirname(ocrDir), sessionDir);
924
- }
925
- function reconcileLegacyState(db, ocrDir, opts = {}) {
926
- const dryRun = opts.dryRun ?? false;
927
- const threshold = opts.staleThresholdSeconds ?? DEFAULT_STALE_THRESHOLD_SECONDS;
928
- const actions = [];
929
- for (const s of getAllSessions(db)) {
930
- const dir = resolveSessionDir(ocrDir, s.session_dir);
931
- if (s.status === "closed") {
932
- if (hasTerminalArtifactEvent(db, s.id, s.workflow_type, s.current_round, s.current_map_run) || hasReasonEvent(db, s.id)) {
933
- continue;
934
- }
935
- const reviewFinal = s.workflow_type === "review" && dir ? existsSync(join(dir, "rounds", `round-${s.current_round}`, "final.md")) : false;
936
- const mapFinal = s.workflow_type === "map" && dir ? existsSync(join(dir, "map", "runs", `run-${s.current_map_run}`, "map.md")) : false;
937
- if (reviewFinal) {
938
- actions.push({
939
- sessionId: s.id,
940
- kind: "synthesize-round-completed",
941
- detail: `final.md present for round ${s.current_round}; synthesizing round_completed`
942
- });
943
- if (!dryRun) {
944
- insertEvent(db, {
945
- session_id: s.id,
946
- event_type: "round_completed",
947
- phase: "synthesis",
948
- phase_number: 7,
949
- round: s.current_round,
950
- metadata: JSON.stringify({ source: "reconciled", synthesized_from: "final.md" })
951
- });
952
- }
953
- } else if (mapFinal) {
954
- actions.push({
955
- sessionId: s.id,
956
- kind: "synthesize-map-completed",
957
- detail: `map.md present for run ${s.current_map_run}; synthesizing map_completed`
958
- });
959
- if (!dryRun) {
960
- insertEvent(db, {
961
- session_id: s.id,
962
- event_type: "map_completed",
963
- phase: "synthesis",
964
- phase_number: 5,
965
- round: s.current_map_run,
966
- metadata: JSON.stringify({ source: "reconciled", synthesized_from: "map.md" })
967
- });
968
- }
969
- } else {
970
- actions.push({
971
- sessionId: s.id,
972
- kind: "grandfather",
973
- detail: "no provable artifact; recording session_legacy_import"
974
- });
975
- if (!dryRun) {
976
- insertEvent(db, {
977
- session_id: s.id,
978
- event_type: "session_legacy_import",
979
- phase: "complete",
980
- metadata: JSON.stringify({ source: "reconciled" })
981
- });
982
- }
983
- }
984
- continue;
985
- }
986
- const age = lastEventAgeSeconds(db, s.id);
987
- const stale = (age === null || age > threshold) && !hasInFlightDependents(db, s.id);
988
- if (stale) {
989
- actions.push({
990
- sessionId: s.id,
991
- kind: "stale-close",
992
- 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`
993
- });
994
- if (!dryRun) {
995
- commitReasonClose(
996
- db,
997
- s.id,
998
- {
999
- event_type: "session_auto_closed_stale",
1000
- phase: "complete",
1001
- metadata: JSON.stringify({ source: "reconciled", threshold_seconds: threshold })
1002
- },
1003
- { status: "closed", current_phase: "complete" }
1004
- );
1005
- }
1006
- }
1007
- }
1008
- return { dryRun, actions };
1009
- }
1010
-
1011
- // src/lib/db/liveness.ts
1012
- var PID_REUSE_GUARD_MS = 24 * 60 * 60 * 1e3;
1013
-
1014
- // src/lib/state/exit-codes.ts
1015
- var STATE_EXIT = {
1016
- OK: 0,
1017
- USAGE: 2,
1018
- AMBIGUOUS: 3,
1019
- NOT_FOUND: 4,
1020
- ILLEGAL_TRANSITION: 5,
1021
- INVARIANT_UNMET: 6,
1022
- SCHEMA_INVALID: 7,
1023
- /** Database was locked past the bounded retry budget (SQLITE_BUSY). */
1024
- BUSY: 8
1025
- };
1026
- var StateError = class extends Error {
1027
- constructor(code, message) {
1028
- super(message);
1029
- this.code = code;
1030
- this.name = "StateError";
1031
- }
1032
- };
1033
- var CANCELLED_EXIT_CODE = -2;
1034
- var ORPHAN_EXIT_CODE = -3;
1035
- var CASCADE_CLOSE_EXIT_CODE = -4;
1036
- var WATCHDOG_DEADLINE_EXIT_CODE = -5;
1037
-
1038
- // src/lib/db/agent-sessions.ts
1039
- function cascadeTerminateExecutions(db, workflowId, exitCode, note) {
1040
- db.run(
1041
- `UPDATE command_executions
1042
- SET finished_at = datetime('now'),
1043
- exit_code = ?,
1044
- pid = NULL,
1045
- notes = COALESCE(notes || char(10), '') || ?
1046
- WHERE workflow_id = ?
1047
- AND finished_at IS NULL`,
1048
- [exitCode, note, workflowId]
1049
- );
1050
- }
1051
-
1052
- // ../shared/platform/src/index.ts
1053
- import {
1054
- execFile,
1055
- execFileSync,
1056
- spawn
1057
- } from "node:child_process";
1058
- import { promisify } from "node:util";
1059
- var execFilePromise = promisify(execFile);
1060
- var isWindows = process.platform === "win32";
1061
-
1062
- // src/lib/db/maintenance.ts
1063
- var ONE_HOUR_MS = 60 * 60 * 1e3;
1064
- var SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1e3;
1065
-
1066
- // src/lib/db/index.ts
1067
- var V2_SCHEMA_VERSION = 12;
1068
- function maybeSnapshotBeforeUpgrade(db, dbPath, fromVersion) {
1069
- if (fromVersion < 1 || fromVersion >= V2_SCHEMA_VERSION) return null;
1070
- const bakPath = `${dbPath}.bak.v${fromVersion}`;
1071
- if (existsSync2(bakPath)) return bakPath;
1072
- try {
1073
- if (!existsSync2(dbPath) || statSync(dbPath).size === 0) return null;
1074
- db.pragma("wal_checkpoint(TRUNCATE)");
1075
- copyFileSync(dbPath, bakPath);
1076
- return bakPath;
1077
- } catch {
1078
- return null;
1079
- }
1080
- }
1081
- function formatUpgradeNotice(bakPath, reconcile) {
1082
- const lines = [
1083
- "Storage upgraded to v2.0 \u2014 durable SQLite engine (WAL), event-sourced lifecycle."
1084
- ];
1085
- if (bakPath) {
1086
- lines.push(` A backup of your previous database was saved to: ${bakPath}`);
1087
- }
1088
- const repairs = (reconcile?.actions ?? []).filter((a) => a.kind !== "ok");
1089
- if (repairs.length > 0) {
1090
- const n = (kind) => repairs.filter((a) => a.kind === kind).length;
1091
- const parts = [];
1092
- const finalized = n("synthesize-round-completed") + n("synthesize-map-completed");
1093
- if (finalized > 0) parts.push(`${finalized} finalized from artifacts`);
1094
- if (n("grandfather") > 0) parts.push(`${n("grandfather")} grandfathered`);
1095
- if (n("stale-close") > 0) parts.push(`${n("stale-close")} stale closed`);
1096
- lines.push(
1097
- ` Reconciled ${repairs.length} legacy session(s): ${parts.join(", ")}.`
1098
- );
1099
- }
1100
- lines.push(" Run `ocr doctor` to verify the storage engine.");
1101
- return lines.map((l) => `[ocr] ${l}`).join("\n");
1102
- }
1103
- var connections = /* @__PURE__ */ new Map();
1104
- async function openDatabase(dbPath) {
1105
- const cached = connections.get(dbPath);
1106
- if (cached) {
1107
- return cached;
1108
- }
1109
- const dir = dirname2(dbPath);
1110
- if (!existsSync2(dir)) {
1111
- mkdirSync(dir, { recursive: true });
1112
- }
1113
- const db = openEngine(dbPath);
1114
- connections.set(dbPath, db);
1115
- return db;
1116
- }
1117
- async function ensureDatabase(ocrDir) {
1118
- const dataDir = join2(ocrDir, "data");
1119
- if (!existsSync2(dataDir)) {
1120
- mkdirSync(dataDir, { recursive: true });
1121
- }
1122
- const dbPath = join2(dataDir, "ocr.db");
1123
- const db = await openDatabase(dbPath);
1124
- let before = 0;
1125
- try {
1126
- before = getSchemaVersion(db);
1127
- } catch {
1128
- before = 0;
1129
- }
1130
- const isLegacyUpgrade = before >= 1 && before < V2_SCHEMA_VERSION;
1131
- const bakPath = maybeSnapshotBeforeUpgrade(db, dbPath, before);
1132
- runMigrations(db);
1133
- let reconcile;
1134
- if (before < V2_SCHEMA_VERSION) {
1135
- try {
1136
- reconcile = reconcileLegacyState(db, ocrDir);
1137
- } catch (err) {
1138
- console.error(
1139
- `[ocr] legacy reconciliation skipped: ${err instanceof Error ? err.message : String(err)}`
1140
- );
1141
- }
1142
- }
1143
- if (isLegacyUpgrade) {
1144
- const notice = formatUpgradeNotice(bakPath, reconcile);
1145
- if (notice) console.error(notice);
1146
- }
1147
- return db;
1148
- }
1149
-
1150
- // src/lib/state/index.ts
1151
- import { join as join3 } from "node:path";
1152
-
1153
- // src/lib/state/phase-graph.ts
1154
- var REVIEW_PHASE_NUMBERS = {
1155
- context: 1,
1156
- "change-context": 2,
1157
- analysis: 3,
1158
- reviews: 4,
1159
- aggregation: 5,
1160
- discourse: 6,
1161
- synthesis: 7,
1162
- complete: 8
1163
- };
1164
- var MAP_PHASE_NUMBERS = {
1165
- "map-context": 1,
1166
- topology: 2,
1167
- "flow-analysis": 3,
1168
- "requirements-mapping": 4,
1169
- synthesis: 5,
1170
- complete: 6
1171
- };
1172
- function phaseNumberFor(workflowType, phase) {
1173
- const map = workflowType === "map" ? MAP_PHASE_NUMBERS : REVIEW_PHASE_NUMBERS;
1174
- const n = map[phase];
1175
- if (n === void 0) {
1176
- throw new StateError(
1177
- STATE_EXIT.ILLEGAL_TRANSITION,
1178
- `Invalid phase "${phase}" for workflow_type "${workflowType}". Valid: ${Object.keys(map).join(", ")}`
1179
- );
1180
- }
1181
- return n;
1182
- }
1183
- var REVIEW_PHASE_GRAPH = {
1184
- context: ["change-context"],
1185
- "change-context": ["analysis"],
1186
- analysis: ["reviews"],
1187
- reviews: ["aggregation"],
1188
- aggregation: ["discourse"],
1189
- discourse: ["synthesis"],
1190
- synthesis: ["complete"],
1191
- complete: ["context"]
1192
- };
1193
- var MAP_PHASE_GRAPH = {
1194
- "map-context": ["topology"],
1195
- topology: ["flow-analysis"],
1196
- "flow-analysis": ["requirements-mapping"],
1197
- "requirements-mapping": ["synthesis"],
1198
- synthesis: ["complete"],
1199
- complete: ["map-context"]
1200
- };
1201
- function graphFor(workflowType) {
1202
- return workflowType === "review" ? REVIEW_PHASE_GRAPH : MAP_PHASE_GRAPH;
1203
- }
1204
- function initialPhaseFor(workflowType) {
1205
- return workflowType === "map" ? "map-context" : "context";
1206
- }
1207
- function validatePhaseTransition(workflowType, source, target, isRoundBoundary) {
1208
- const graph = graphFor(workflowType);
1209
- if (!(target in graph)) {
1210
- const validPhases = Object.keys(graph).join(", ");
1211
- throw new StateError(
1212
- STATE_EXIT.ILLEGAL_TRANSITION,
1213
- `Invalid phase "${target}" for workflow_type "${workflowType}". Valid phases: ${validPhases}`
1214
- );
1215
- }
1216
- if (source === target) return;
1217
- if (isRoundBoundary) {
1218
- const initial = initialPhaseFor(workflowType);
1219
- if (target === initial) return;
1220
- throw new StateError(
1221
- STATE_EXIT.ILLEGAL_TRANSITION,
1222
- `Illegal round-boundary transition: a new round/run must reset to "${initial}", not "${target}".`
1223
- );
1224
- }
1225
- const allowed = graph[source];
1226
- if (!allowed || !allowed.includes(target)) {
1227
- throw new StateError(
1228
- STATE_EXIT.ILLEGAL_TRANSITION,
1229
- `Illegal phase transition: ${source} \u2192 ${target}. From "${source}", only ${allowed && allowed.length > 0 ? allowed.join(", ") : "(no edges)"} are reachable. Pass --current-round to start a new round if the workflow is resetting.`
1230
- );
1231
- }
1232
- }
1233
-
1234
- // src/lib/state/meta-util.ts
1235
- var DEFAULT_METADATA_MAX_LEN = 4096;
1236
- function sanitizeMetadataString(s, opts = {}) {
1237
- const maxLen = opts.maxLen ?? DEFAULT_METADATA_MAX_LEN;
1238
- let out = s.replace(/[\x00-\x08\x0b-\x1f]/g, "");
1239
- out = out.replace(/^\s*\[ocr\]\s*/i, "");
1240
- if (out.length > maxLen) out = out.slice(0, maxLen);
1241
- return out;
1242
- }
1243
-
1244
- // src/lib/state/round-meta.ts
1245
- var VALID_CATEGORIES = /* @__PURE__ */ new Set(["blocker", "should_fix", "suggestion", "style"]);
1246
- var VALID_SEVERITIES = /* @__PURE__ */ new Set(["critical", "high", "medium", "low", "info"]);
1247
- function validateRoundMeta(meta) {
1248
- if (!meta || typeof meta !== "object") {
1249
- throw new Error("round-meta.json must be a JSON object");
1250
- }
1251
- const obj = meta;
1252
- if (obj.schema_version !== 1) {
1253
- throw new Error(
1254
- `Unsupported schema_version: ${String(obj.schema_version)}. Expected 1.`
1255
- );
1256
- }
1257
- if (typeof obj.verdict !== "string" || obj.verdict.trim().length === 0) {
1258
- throw new Error("round-meta.json must contain a non-empty verdict string");
1259
- }
1260
- obj.verdict = sanitizeMetadataString(obj.verdict);
1261
- if (!Array.isArray(obj.reviewers)) {
1262
- throw new Error("round-meta.json must contain a reviewers array");
1263
- }
1264
- for (const reviewer of obj.reviewers) {
1265
- if (!reviewer || typeof reviewer !== "object") {
1266
- throw new Error("Each reviewer must be an object");
1267
- }
1268
- const r = reviewer;
1269
- if (typeof r.type !== "string") {
1270
- throw new Error("Each reviewer must have a type string");
1271
- }
1272
- if (typeof r.instance !== "number") {
1273
- throw new Error("Each reviewer must have an instance number");
1274
- }
1275
- if (!Array.isArray(r.findings)) {
1276
- throw new Error(`Reviewer ${r.type}-${r.instance} must have a findings array`);
1277
- }
1278
- for (const finding of r.findings) {
1279
- if (!finding || typeof finding !== "object") {
1280
- throw new Error("Each finding must be an object");
1281
- }
1282
- const f = finding;
1283
- if (typeof f.title !== "string" || f.title.trim().length === 0) {
1284
- throw new Error("Each finding must have a non-empty title");
1285
- }
1286
- f.title = sanitizeMetadataString(f.title);
1287
- if (typeof f.category !== "string" || !VALID_CATEGORIES.has(f.category)) {
1288
- throw new Error(
1289
- `Finding "${f.title}" has invalid category: "${String(f.category)}". Must be one of: ${[...VALID_CATEGORIES].join(", ")}`
1290
- );
1291
- }
1292
- if (typeof f.severity !== "string" || !VALID_SEVERITIES.has(f.severity)) {
1293
- throw new Error(
1294
- `Finding "${f.title}" has invalid severity: "${String(f.severity)}". Must be one of: ${[...VALID_SEVERITIES].join(", ")}`
1295
- );
1296
- }
1297
- if (typeof f.summary !== "string") {
1298
- throw new Error(`Finding "${f.title}" must have a summary string`);
1299
- }
1300
- f.summary = sanitizeMetadataString(f.summary);
1301
- if (f.file_path !== void 0 && typeof f.file_path !== "string") {
1302
- throw new Error(`Finding "${f.title}" has invalid file_path: expected string`);
1303
- }
1304
- if (f.line_start !== void 0 && typeof f.line_start !== "number") {
1305
- throw new Error(`Finding "${f.title}" has invalid line_start: expected number`);
1306
- }
1307
- if (f.line_end !== void 0 && typeof f.line_end !== "number") {
1308
- throw new Error(`Finding "${f.title}" has invalid line_end: expected number`);
1309
- }
1310
- if (f.flagged_by !== void 0 && !Array.isArray(f.flagged_by)) {
1311
- throw new Error(`Finding "${f.title}" has invalid flagged_by: expected array`);
1312
- }
1313
- }
1314
- }
1315
- if (obj.synthesis_counts !== void 0) {
1316
- if (!obj.synthesis_counts || typeof obj.synthesis_counts !== "object") {
1317
- throw new Error("synthesis_counts must be an object");
1318
- }
1319
- const sc = obj.synthesis_counts;
1320
- if (typeof sc.blockers !== "number" || sc.blockers < 0) {
1321
- throw new Error("synthesis_counts.blockers must be a non-negative number");
1322
- }
1323
- if (typeof sc.should_fix !== "number" || sc.should_fix < 0) {
1324
- throw new Error("synthesis_counts.should_fix must be a non-negative number");
1325
- }
1326
- if (typeof sc.suggestions !== "number" || sc.suggestions < 0) {
1327
- throw new Error("synthesis_counts.suggestions must be a non-negative number");
1328
- }
1329
- }
1330
- return meta;
1331
- }
1332
- function computeRoundCounts(meta) {
1333
- const allFindings = [];
1334
- for (const reviewer of meta.reviewers) {
1335
- allFindings.push(...reviewer.findings);
1336
- }
1337
- const sc = meta.synthesis_counts;
1338
- return {
1339
- blockerCount: sc ? sc.blockers : allFindings.filter((f) => f.category === "blocker").length,
1340
- shouldFixCount: sc ? sc.should_fix : allFindings.filter((f) => f.category === "should_fix").length,
1341
- suggestionCount: sc ? sc.suggestions : allFindings.filter((f) => f.category === "suggestion").length,
1342
- reviewerCount: meta.reviewers.length,
1343
- totalFindingCount: allFindings.length
1344
- };
1345
- }
1346
-
1347
- // src/lib/state/map-meta.ts
1348
- function validateMapMeta(meta) {
1349
- if (!meta || typeof meta !== "object") {
1350
- throw new Error("map-meta.json must be a JSON object");
1351
- }
1352
- const obj = meta;
1353
- if (obj.schema_version !== 1) {
1354
- throw new Error(
1355
- `Unsupported schema_version: ${String(obj.schema_version)}. Expected 1.`
1356
- );
1357
- }
1358
- if (!Array.isArray(obj.sections)) {
1359
- throw new Error("map-meta.json must contain a sections array");
1360
- }
1361
- for (const section of obj.sections) {
1362
- if (!section || typeof section !== "object") {
1363
- throw new Error("Each section must be an object");
1364
- }
1365
- const s = section;
1366
- if (typeof s.section_number !== "number") {
1367
- throw new Error("Each section must have a section_number");
1368
- }
1369
- if (typeof s.title !== "string" || s.title.trim().length === 0) {
1370
- throw new Error("Each section must have a non-empty title");
1371
- }
1372
- s.title = sanitizeMetadataString(s.title);
1373
- if (s.description !== void 0) {
1374
- if (typeof s.description !== "string") {
1375
- throw new Error(`Section "${s.title}" description must be a string if provided`);
1376
- }
1377
- s.description = sanitizeMetadataString(s.description);
1378
- }
1379
- if (!Array.isArray(s.files)) {
1380
- throw new Error(`Section "${s.title}" must have a files array`);
1381
- }
1382
- for (const file of s.files) {
1383
- if (!file || typeof file !== "object") {
1384
- throw new Error("Each file must be an object");
1385
- }
1386
- const f = file;
1387
- if (typeof f.file_path !== "string" || f.file_path.trim().length === 0) {
1388
- throw new Error("Each file must have a non-empty file_path");
1389
- }
1390
- if (typeof f.role !== "string") {
1391
- throw new Error(`File "${f.file_path}" must have a role string`);
1392
- }
1393
- f.role = sanitizeMetadataString(f.role);
1394
- if (typeof f.lines_added !== "number") {
1395
- throw new Error(`File "${f.file_path}" must have a lines_added number`);
1396
- }
1397
- if (typeof f.lines_deleted !== "number") {
1398
- throw new Error(`File "${f.file_path}" must have a lines_deleted number`);
1399
- }
1400
- }
1401
- }
1402
- if (obj.dependencies !== void 0 && !Array.isArray(obj.dependencies)) {
1403
- throw new Error("map-meta.json dependencies must be an array if provided");
1404
- }
1405
- return meta;
1406
- }
1407
- function computeMapCounts(meta) {
1408
- return {
1409
- sectionCount: meta.sections.length,
1410
- fileCount: meta.sections.reduce((sum, s) => sum + s.files.length, 0)
1411
- };
1412
- }
1413
-
1414
- // src/lib/state/projection.ts
1415
- var REASON_EVENT_TYPES = [
1416
- "session_aborted",
1417
- "session_auto_closed_stale",
1418
- "session_synced",
1419
- "session_legacy_import"
1420
- ];
1421
- var TERMINAL_EVENT_TYPES = /* @__PURE__ */ new Set([
1422
- "session_closed",
1423
- ...REASON_EVENT_TYPES
1424
- ]);
1425
- function rebuildSessionProjection(db, sessionId) {
1426
- const events = getEventsForSession(db, sessionId);
1427
- if (events.length === 0) return null;
1428
- const acc = {
1429
- status: "active",
1430
- current_phase: "context",
1431
- phase_number: 1,
1432
- current_round: 1,
1433
- current_map_run: 1
1434
- };
1435
- for (const e of events) {
1436
- switch (e.event_type) {
1437
- case "session_created":
1438
- case "session_resumed":
1439
- case "round_started":
1440
- acc.status = "active";
1441
- if (e.phase) acc.current_phase = e.phase;
1442
- if (e.phase_number != null) acc.phase_number = e.phase_number;
1443
- if (e.round != null) acc.current_round = e.round;
1444
- break;
1445
- case "phase_transition":
1446
- if (e.phase) acc.current_phase = e.phase;
1447
- if (e.phase_number != null) acc.phase_number = e.phase_number;
1448
- if (e.round != null) acc.current_round = e.round;
1449
- break;
1450
- case "round_completed":
1451
- if (e.round != null && e.round >= acc.current_round) {
1452
- acc.current_round = e.round;
1453
- }
1454
- break;
1455
- case "map_completed":
1456
- if (e.round != null) acc.current_map_run = e.round;
1457
- break;
1458
- default:
1459
- if (TERMINAL_EVENT_TYPES.has(e.event_type)) {
1460
- acc.status = "closed";
1461
- acc.current_phase = "complete";
1462
- if (e.phase_number != null) acc.phase_number = e.phase_number;
1463
- }
1464
- }
1465
- }
1466
- return acc;
1467
- }
1468
- function hasCompletionInvariant(db, session) {
1469
- const eventType = session.workflow_type === "map" ? "map_completed" : "round_completed";
1470
- const round = session.workflow_type === "map" ? session.current_map_run : session.current_round;
1471
- const r = db.exec(
1472
- `SELECT 1 FROM orchestration_events
1473
- WHERE session_id = ? AND event_type = ? AND round = ? LIMIT 1`,
1474
- [session.id, eventType, round]
1475
- );
1476
- return (r[0]?.values.length ?? 0) > 0;
1477
- }
1478
- function getCompletenessState(db, sessionId) {
1479
- const r = db.exec(
1480
- "SELECT completeness_state FROM session_completeness WHERE session_id = ?",
1481
- [sessionId]
1482
- );
1483
- return r[0]?.values[0]?.[0] ?? null;
1484
- }
1485
-
1486
- // src/lib/state/index.ts
1487
- function deriveNextRound(db, sessionId, fallbackRound) {
1488
- const result = db.exec(
1489
- `SELECT MAX(round) FROM orchestration_events
1490
- WHERE session_id = ? AND event_type = 'round_completed'`,
1491
- [sessionId]
1492
- );
1493
- const max = result[0]?.values[0]?.[0];
1494
- if (typeof max === "number") return max + 1;
1495
- return fallbackRound;
1496
- }
1497
- function hasArtifacts(dir) {
1498
- try {
1499
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1500
- if (entry.isDirectory()) {
1501
- if (hasArtifacts(join3(dir, entry.name))) return true;
1502
- } else if (/\.(md|json)$/.test(entry.name)) {
1503
- return true;
1504
- }
1505
- }
1506
- } catch {
1507
- }
1508
- return false;
1509
- }
1510
- function readJsonFromSource(params) {
1511
- if (params.source === "file") {
1512
- if (!existsSync3(params.filePath)) {
1513
- throw new StateError(STATE_EXIT.NOT_FOUND, `File not found: ${params.filePath}`);
1514
- }
1515
- return readFileSync(params.filePath, "utf-8");
1516
- }
1517
- return params.data;
1518
- }
1519
- function parseRawJson(raw, label) {
1520
- try {
1521
- return JSON.parse(raw);
1522
- } catch (err) {
1523
- throw new StateError(
1524
- STATE_EXIT.SCHEMA_INVALID,
1525
- `Failed to parse ${label}: ${err instanceof Error ? err.message : "invalid JSON"}`
1526
- );
1527
- }
1528
- }
1529
- async function stateInit(params) {
1530
- const { sessionId, branch, workflowType, sessionDir, ocrDir } = params;
1531
- const db = await ensureDatabase(ocrDir);
1532
- const existing = getSession(db, sessionId);
1533
- if (existing) {
1534
- if (existing.workflow_type !== workflowType) {
1535
- throw new StateError(
1536
- STATE_EXIT.USAGE,
1537
- `Cannot re-open session ${sessionId} as workflow_type "${workflowType}": existing workflow_type is "${existing.workflow_type}". Maps and reviews have disjoint phase graphs.`
1538
- );
1539
- }
1540
- const nextRound = deriveNextRound(db, sessionId, existing.current_round);
1541
- const initialPhase2 = workflowType === "map" ? "map-context" : "context";
1542
- db.transaction(() => {
1543
- updateSession(db, sessionId, {
1544
- status: "active",
1545
- current_phase: initialPhase2,
1546
- phase_number: 1,
1547
- current_round: nextRound
1548
- });
1549
- insertEvent(db, {
1550
- session_id: sessionId,
1551
- event_type: nextRound > (existing.current_round ?? 1) ? "round_started" : "session_resumed",
1552
- phase: initialPhase2,
1553
- phase_number: 1,
1554
- round: nextRound
1555
- });
1556
- });
1557
- return sessionId;
1558
- }
1559
- const initialPhase = workflowType === "map" ? "map-context" : "context";
1560
- db.transaction(() => {
1561
- insertSession(db, {
1562
- id: sessionId,
1563
- branch,
1564
- workflow_type: workflowType,
1565
- current_phase: initialPhase,
1566
- phase_number: 1,
1567
- current_round: 1,
1568
- current_map_run: 1,
1569
- session_dir: sessionDir
1570
- });
1571
- insertEvent(db, {
1572
- session_id: sessionId,
1573
- event_type: "session_created",
1574
- phase: initialPhase,
1575
- phase_number: 1,
1576
- round: 1
1577
- });
1578
- });
1579
- return sessionId;
1580
- }
1581
- async function stateTransition(params, db) {
1582
- const { sessionId, phase, phaseNumber, round, mapRun, ocrDir } = params;
1583
- db ??= await ensureDatabase(ocrDir);
1584
- const existing = getSession(db, sessionId);
1585
- if (!existing) {
1586
- throw new StateError(STATE_EXIT.NOT_FOUND, `Session not found: ${sessionId}`);
1587
- }
1588
- const previousRound = existing.current_round;
1589
- const previousMapRun = existing.current_map_run;
1590
- const isRoundBoundary = round !== void 0 && round !== previousRound || mapRun !== void 0 && mapRun !== previousMapRun;
1591
- validatePhaseTransition(
1592
- existing.workflow_type,
1593
- existing.current_phase,
1594
- phase,
1595
- isRoundBoundary
1596
- );
1597
- db.transaction(() => {
1598
- updateSession(db, sessionId, {
1599
- current_phase: phase,
1600
- phase_number: phaseNumber,
1601
- ...round !== void 0 ? { current_round: round } : {},
1602
- ...mapRun !== void 0 ? { current_map_run: mapRun } : {}
1603
- });
1604
- insertEvent(db, {
1605
- session_id: sessionId,
1606
- event_type: "phase_transition",
1607
- phase,
1608
- phase_number: phaseNumber,
1609
- round: round ?? existing.current_round
1610
- });
1611
- if (round !== void 0 && round !== previousRound) {
1612
- insertEvent(db, {
1613
- session_id: sessionId,
1614
- event_type: "round_started",
1615
- phase,
1616
- phase_number: phaseNumber,
1617
- round
1618
- });
1619
- }
1620
- });
1621
- }
1622
- async function stateClose(params) {
1623
- const { sessionId, ocrDir, abort } = params;
1624
- const db = await ensureDatabase(ocrDir);
1625
- const existing = getSession(db, sessionId);
1626
- if (!existing) {
1627
- throw new StateError(STATE_EXIT.NOT_FOUND, `Session not found: ${sessionId}`);
1628
- }
1629
- if (existing.status === "closed") {
1630
- console.error(`[ocr] Session already closed: ${sessionId}`);
1631
- return;
1632
- }
1633
- if (!abort && !hasCompletionInvariant(db, existing)) {
1634
- const what = existing.workflow_type === "map" ? `map run ${existing.current_map_run} has no map_completed event` : `round ${existing.current_round} has no round_completed event`;
1635
- throw new StateError(
1636
- STATE_EXIT.INVARIANT_UNMET,
1637
- `Cannot close session ${sessionId}: ${what}. Run 'ocr state complete-round' to finalize it, or pass --abort to record an abandoned session.`
1638
- );
1639
- }
1640
- const note = "closed by parent workflow close";
1641
- db.transaction(() => {
1642
- if (abort) {
1643
- insertEvent(db, {
1644
- session_id: sessionId,
1645
- event_type: "session_aborted",
1646
- phase: existing.current_phase,
1647
- phase_number: existing.phase_number,
1648
- round: existing.current_round
1649
- });
1650
- }
1651
- updateSession(db, sessionId, {
1652
- status: "closed",
1653
- current_phase: "complete"
1654
- });
1655
- if (!abort) {
1656
- insertEvent(db, {
1657
- session_id: sessionId,
1658
- event_type: "session_closed",
1659
- phase: "complete",
1660
- phase_number: existing.phase_number,
1661
- round: existing.current_round
1662
- });
1663
- }
1664
- cascadeTerminateExecutions(db, sessionId, CASCADE_CLOSE_EXIT_CODE, note);
1665
- });
1666
- }
1667
- async function reconcileWorkflowOnExit(ocrDir, sessionId, db) {
1668
- db ??= await ensureDatabase(ocrDir);
1669
- const existing = getSession(db, sessionId);
1670
- if (!existing) return "not-found";
1671
- if (existing.status === "closed") return "already-closed";
1672
- if (!hasCompletionInvariant(db, existing)) return "incomplete";
1673
- if (hasInFlightDependents(db, sessionId)) return "in-flight";
1674
- await stateClose({ sessionId, ocrDir, abort: false });
1675
- return "closed";
1676
- }
1677
- async function reconcileCompletedSessions(ocrDir) {
1678
- const db = await ensureDatabase(ocrDir);
1679
- const closed = [];
1680
- for (const s of getAllSessions(db)) {
1681
- if (s.status !== "active") continue;
1682
- const outcome = await reconcileWorkflowOnExit(ocrDir, s.id, db);
1683
- if (outcome === "closed") closed.push(s.id);
1684
- }
1685
- return closed;
1686
- }
1687
- async function stateShow(ocrDir, sessionId) {
1688
- let db;
1689
- try {
1690
- db = await ensureDatabase(ocrDir);
1691
- } catch {
1692
- return null;
1693
- }
1694
- const session = sessionId ? getSession(db, sessionId) : getLatestActiveSession(db);
1695
- if (!session) {
1696
- return null;
1697
- }
1698
- const events = getEventsForSession(db, session.id);
1699
- return {
1700
- session: {
1701
- id: session.id,
1702
- branch: session.branch,
1703
- status: session.status,
1704
- workflow_type: session.workflow_type,
1705
- current_phase: session.current_phase,
1706
- phase_number: session.phase_number,
1707
- current_round: session.current_round,
1708
- current_map_run: session.current_map_run,
1709
- started_at: session.started_at,
1710
- updated_at: session.updated_at
1711
- },
1712
- events: events.map((e) => ({
1713
- id: e.id,
1714
- event_type: e.event_type,
1715
- phase: e.phase,
1716
- phase_number: e.phase_number,
1717
- round: e.round,
1718
- metadata: e.metadata,
1719
- created_at: e.created_at
1720
- }))
1721
- };
1722
- }
1723
- async function stateList(ocrDir) {
1724
- let db;
1725
- try {
1726
- db = await ensureDatabase(ocrDir);
1727
- } catch {
1728
- return [];
1729
- }
1730
- const sessions = getAllSessions(db);
1731
- return sessions.map((s) => ({
1732
- id: s.id,
1733
- branch: s.branch,
1734
- status: s.status,
1735
- workflow_type: s.workflow_type,
1736
- current_phase: s.current_phase,
1737
- phase_number: s.phase_number,
1738
- current_round: s.current_round,
1739
- current_map_run: s.current_map_run,
1740
- started_at: s.started_at,
1741
- updated_at: s.updated_at
1742
- }));
1743
- }
1744
- function resolveSession(db, explicitId) {
1745
- if (explicitId) {
1746
- const s = getSession(db, explicitId);
1747
- if (!s) throw new StateError(STATE_EXIT.NOT_FOUND, `Session not found: ${explicitId}`);
1748
- return {
1749
- id: s.id,
1750
- session_dir: s.session_dir,
1751
- current_round: s.current_round,
1752
- current_map_run: s.current_map_run,
1753
- workflow_type: s.workflow_type,
1754
- status: s.status,
1755
- current_phase: s.current_phase,
1756
- phase_number: s.phase_number,
1757
- branch: s.branch,
1758
- decision: "explicit"
1759
- };
1760
- }
1761
- const uid = process.env["OCR_DASHBOARD_EXECUTION_UID"];
1762
- if (uid) {
1763
- const result = db.exec(
1764
- "SELECT workflow_id FROM command_executions WHERE uid = ?",
1765
- [uid]
1766
- );
1767
- const workflowId = result[0]?.values[0]?.[0];
1768
- if (workflowId) {
1769
- const s = getSession(db, workflowId);
1770
- if (s) {
1771
- return {
1772
- id: s.id,
1773
- session_dir: s.session_dir,
1774
- current_round: s.current_round,
1775
- current_map_run: s.current_map_run,
1776
- workflow_type: s.workflow_type,
1777
- status: s.status,
1778
- current_phase: s.current_phase,
1779
- phase_number: s.phase_number,
1780
- branch: s.branch,
1781
- decision: "dashboard-uid"
1782
- };
1783
- }
1784
- }
1785
- }
1786
- const activeRows = db.exec(
1787
- `SELECT id, session_dir, current_round, current_map_run, workflow_type,
1788
- status, current_phase, phase_number, branch
1789
- FROM sessions
1790
- WHERE status = 'active'
1791
- ORDER BY started_at DESC`
1792
- );
1793
- const rows = activeRows[0]?.values ?? [];
1794
- if (rows.length === 0) throw new StateError(STATE_EXIT.NOT_FOUND, "No active session found");
1795
- if (rows.length > 1) {
1796
- const ids = rows.map((r) => r[0]);
1797
- throw new StateError(
1798
- STATE_EXIT.AMBIGUOUS,
1799
- `Ambiguous auto-detect: ${rows.length} active sessions exist. Pass --session-id explicitly. Candidates: ${ids.join(", ")}`
1800
- );
1801
- }
1802
- const row = rows[0];
1803
- return {
1804
- id: row[0],
1805
- session_dir: row[1],
1806
- current_round: row[2],
1807
- current_map_run: row[3],
1808
- workflow_type: row[4],
1809
- status: row[5],
1810
- current_phase: row[6],
1811
- phase_number: row[7],
1812
- branch: row[8],
1813
- decision: "latest-active"
1814
- };
1815
- }
1816
- function announceResolveDecision(r) {
1817
- if (r.decision === "explicit") return;
1818
- const path = r.decision === "dashboard-uid" ? "via OCR_DASHBOARD_EXECUTION_UID" : "via latest-active";
1819
- console.error(`[ocr] Auto-detected session: ${r.id} (${path})`);
1820
- }
1821
- async function resolveActiveSession(ocrDir, explicitId) {
1822
- const db = await ensureDatabase(ocrDir);
1823
- const result = resolveSession(db, explicitId);
1824
- announceResolveDecision(result);
1825
- return {
1826
- id: result.id,
1827
- sessionDir: result.session_dir,
1828
- decision: result.decision
1829
- };
1830
- }
1831
- async function stateBegin(params) {
1832
- const id = await stateInit(params);
1833
- const db = await ensureDatabase(params.ocrDir);
1834
- const s = getSession(db, id);
1835
- return {
1836
- schema_version: 1,
1837
- session_id: id,
1838
- round: s?.current_round ?? 1,
1839
- phase: s?.current_phase ?? "context",
1840
- completeness: getCompletenessState(db, id)
1841
- };
1842
- }
1843
- async function stateAdvance(params) {
1844
- const db = await ensureDatabase(params.ocrDir);
1845
- const existing = getSession(db, params.sessionId);
1846
- if (!existing) {
1847
- throw new StateError(STATE_EXIT.NOT_FOUND, `Session not found: ${params.sessionId}`);
1848
- }
1849
- const phaseNumber = phaseNumberFor(existing.workflow_type, params.phase);
1850
- await stateTransition(
1851
- {
1852
- sessionId: params.sessionId,
1853
- phase: params.phase,
1854
- phaseNumber,
1855
- round: params.round,
1856
- mapRun: params.mapRun,
1857
- ocrDir: params.ocrDir
1858
- },
1859
- db
1860
- );
1861
- }
1862
- async function stateCompleteRound(params) {
1863
- const { ocrDir } = params;
1864
- const db = await ensureDatabase(ocrDir);
1865
- let meta;
1866
- let counts;
1867
- try {
1868
- const rawJsonString = readJsonFromSource(params);
1869
- const label = params.source === "file" ? params.filePath : "stdin";
1870
- meta = validateRoundMeta(parseRawJson(rawJsonString, label));
1871
- counts = computeRoundCounts(meta);
1872
- } catch (e) {
1873
- throw new StateError(
1874
- STATE_EXIT.SCHEMA_INVALID,
1875
- e instanceof Error ? e.message : "invalid round metadata"
1876
- );
1877
- }
1878
- const resolved = resolveSession(db, params.sessionId);
1879
- const roundNumber = params.round ?? resolved.current_round;
1880
- const roundMetaPath = join3(
1881
- resolved.session_dir,
1882
- "rounds",
1883
- `round-${roundNumber}`,
1884
- "round-meta.json"
1885
- );
1886
- const already = db.exec(
1887
- `SELECT 1 FROM orchestration_events
1888
- WHERE session_id = ? AND event_type = 'round_completed' AND round = ? LIMIT 1`,
1889
- [resolved.id, roundNumber]
1890
- );
1891
- if ((already[0]?.values.length ?? 0) > 0) {
1892
- return { sessionId: resolved.id, round: roundNumber, metaPath: roundMetaPath, schema_version: 1 };
1893
- }
1894
- if (resolved.current_phase !== "synthesis") {
1895
- throw new StateError(
1896
- STATE_EXIT.INVARIANT_UNMET,
1897
- `Cannot complete round: workflow is at "${resolved.current_phase}", not "synthesis". Advance through the phases first.`
1898
- );
1899
- }
1900
- if (params.requireFinal) {
1901
- const finalPath = join3(resolved.session_dir, "rounds", `round-${roundNumber}`, "final.md");
1902
- if (!existsSync3(finalPath)) {
1903
- throw new StateError(
1904
- STATE_EXIT.INVARIANT_UNMET,
1905
- `Cannot complete round: --require-final set but ${finalPath} is missing.`
1906
- );
1907
- }
1908
- }
1909
- let metaPath;
1910
- if (params.source === "stdin") {
1911
- const roundDir = join3(resolved.session_dir, "rounds", `round-${roundNumber}`);
1912
- mkdirSync2(roundDir, { recursive: true });
1913
- metaPath = roundMetaPath;
1914
- writeFileSync(metaPath, JSON.stringify(meta, null, 2));
1915
- }
1916
- db.transaction(() => {
1917
- insertEvent(db, {
1918
- session_id: resolved.id,
1919
- event_type: "round_completed",
1920
- phase: "synthesis",
1921
- phase_number: 7,
1922
- round: roundNumber,
1923
- metadata: JSON.stringify({
1924
- verdict: meta.verdict,
1925
- blocker_count: counts.blockerCount,
1926
- should_fix_count: counts.shouldFixCount,
1927
- suggestion_count: counts.suggestionCount,
1928
- reviewer_count: counts.reviewerCount,
1929
- total_finding_count: counts.totalFindingCount,
1930
- source: "orchestrator"
1931
- })
1932
- });
1933
- if (roundNumber >= resolved.current_round) {
1934
- updateSession(db, resolved.id, { current_round: roundNumber });
1935
- }
1936
- validatePhaseTransition("review", resolved.current_phase, "complete", false);
1937
- updateSession(db, resolved.id, { current_phase: "complete", phase_number: 8 });
1938
- insertEvent(db, {
1939
- session_id: resolved.id,
1940
- event_type: "phase_transition",
1941
- phase: "complete",
1942
- phase_number: 8,
1943
- round: roundNumber
1944
- });
1945
- });
1946
- return { sessionId: resolved.id, round: roundNumber, metaPath, schema_version: 1 };
1947
- }
1948
- async function stateCompleteMap(params) {
1949
- const { ocrDir } = params;
1950
- const db = await ensureDatabase(ocrDir);
1951
- let meta;
1952
- let counts;
1953
- try {
1954
- const rawJsonString = readJsonFromSource(params);
1955
- const label = params.source === "file" ? params.filePath : "stdin";
1956
- meta = validateMapMeta(parseRawJson(rawJsonString, label));
1957
- counts = computeMapCounts(meta);
1958
- } catch (e) {
1959
- throw new StateError(
1960
- STATE_EXIT.SCHEMA_INVALID,
1961
- e instanceof Error ? e.message : "invalid map metadata"
1962
- );
1963
- }
1964
- const resolved = resolveSession(db, params.sessionId);
1965
- const mapRunNumber = params.mapRun ?? resolved.current_map_run;
1966
- const mapMetaPath = join3(
1967
- resolved.session_dir,
1968
- "map",
1969
- "runs",
1970
- `run-${mapRunNumber}`,
1971
- "map-meta.json"
1972
- );
1973
- const already = db.exec(
1974
- `SELECT 1 FROM orchestration_events
1975
- WHERE session_id = ? AND event_type = 'map_completed' AND round = ? LIMIT 1`,
1976
- [resolved.id, mapRunNumber]
1977
- );
1978
- if ((already[0]?.values.length ?? 0) > 0) {
1979
- return { sessionId: resolved.id, mapRun: mapRunNumber, metaPath: mapMetaPath, schema_version: 1 };
1980
- }
1981
- if (resolved.current_phase !== "synthesis") {
1982
- throw new StateError(
1983
- STATE_EXIT.INVARIANT_UNMET,
1984
- `Cannot complete map: workflow is at "${resolved.current_phase}", not "synthesis". Advance first.`
1985
- );
1986
- }
1987
- let metaPath;
1988
- if (params.source === "stdin") {
1989
- const runDir = join3(resolved.session_dir, "map", "runs", `run-${mapRunNumber}`);
1990
- mkdirSync2(runDir, { recursive: true });
1991
- metaPath = mapMetaPath;
1992
- writeFileSync(metaPath, JSON.stringify(meta, null, 2));
1993
- }
1994
- db.transaction(() => {
1995
- insertEvent(db, {
1996
- session_id: resolved.id,
1997
- event_type: "map_completed",
1998
- phase: "synthesis",
1999
- phase_number: 5,
2000
- round: mapRunNumber,
2001
- metadata: JSON.stringify({
2002
- section_count: counts.sectionCount,
2003
- file_count: counts.fileCount,
2004
- source: "orchestrator"
2005
- })
2006
- });
2007
- validatePhaseTransition("map", resolved.current_phase, "complete", false);
2008
- updateSession(db, resolved.id, { current_phase: "complete", phase_number: 6 });
2009
- insertEvent(db, {
2010
- session_id: resolved.id,
2011
- event_type: "phase_transition",
2012
- phase: "complete",
2013
- phase_number: 6,
2014
- round: mapRunNumber
2015
- });
2016
- });
2017
- return { sessionId: resolved.id, mapRun: mapRunNumber, metaPath, schema_version: 1 };
2018
- }
2019
- async function stateStatus(ocrDir, sessionId) {
2020
- const db = await ensureDatabase(ocrDir);
2021
- const resolved = resolveSession(db, sessionId);
2022
- const view = db.exec(
2023
- `SELECT completeness_state, has_terminal_artifact, marked_closed, dependents_settled
2024
- FROM session_completeness WHERE session_id = ?`,
2025
- [resolved.id]
2026
- );
2027
- const row = view[0]?.values[0];
2028
- const completenessState = row?.[0] ?? null;
2029
- const hasTerminalArtifact = row?.[1] === 1;
2030
- let nextAction;
2031
- let nextActionKind;
2032
- switch (completenessState) {
2033
- case "complete":
2034
- nextAction = "none \u2014 session is complete";
2035
- nextActionKind = "none";
2036
- break;
2037
- case "closed_without_artifact":
2038
- nextAction = "re-open and finalize: this session was closed without a completed round/run";
2039
- nextActionKind = "reopen";
2040
- break;
2041
- case "in_flight":
2042
- nextAction = "wait for in-flight agent processes to finish";
2043
- nextActionKind = "wait";
2044
- break;
2045
- default:
2046
- if (hasTerminalArtifact) {
2047
- nextAction = "run 'ocr state finish' to close the workflow";
2048
- nextActionKind = "finish";
2049
- } else if (resolved.current_phase === "synthesis") {
2050
- nextAction = "pipe round metadata to 'ocr state complete-round --stdin'";
2051
- nextActionKind = "complete_round";
2052
- } else {
2053
- nextAction = "advance through the phases, then 'ocr state complete-round'";
2054
- nextActionKind = "advance";
2055
- }
2056
- }
2057
- return {
2058
- schema_version: 1,
2059
- session_id: resolved.id,
2060
- workflow_type: resolved.workflow_type,
2061
- status: resolved.status,
2062
- current_phase: resolved.current_phase,
2063
- current_round: resolved.current_round,
2064
- current_map_run: resolved.current_map_run,
2065
- completeness_state: completenessState,
2066
- has_terminal_artifact: hasTerminalArtifact,
2067
- marked_closed: row?.[2] === 1,
2068
- dependents_settled: row?.[3] === 1,
2069
- next_action: nextAction,
2070
- next_action_kind: nextActionKind
2071
- };
2072
- }
2073
- async function stateSync(ocrDir) {
2074
- const db = await ensureDatabase(ocrDir);
2075
- const sessionsRoot = join3(ocrDir, "sessions");
2076
- if (!existsSync3(sessionsRoot)) {
2077
- return 0;
2078
- }
2079
- const entries = readdirSync(sessionsRoot).filter((name) => {
2080
- const fullPath = join3(sessionsRoot, name);
2081
- return statSync2(fullPath).isDirectory();
2082
- });
2083
- let synced = 0;
2084
- for (const dirName of entries) {
2085
- const dirPath = join3(sessionsRoot, dirName);
2086
- const existing = getSession(db, dirName);
2087
- if (existing) {
2088
- continue;
2089
- }
2090
- if (!hasArtifacts(dirPath)) {
2091
- continue;
2092
- }
2093
- const hasRoundsDir = existsSync3(join3(dirPath, "rounds"));
2094
- const hasMapDir = existsSync3(join3(dirPath, "map"));
2095
- const workflowType = hasMapDir && !hasRoundsDir ? "map" : "review";
2096
- const branchMatch = dirName.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
2097
- const branch = branchMatch?.[1] ?? dirName;
2098
- let inferredPhase = "context";
2099
- let inferredPhaseNumber = 1;
2100
- let inferredRound = 1;
2101
- let inferredMapRun = 1;
2102
- if (workflowType === "review") {
2103
- const roundsDir = join3(dirPath, "rounds");
2104
- if (existsSync3(roundsDir)) {
2105
- const roundDirs = readdirSync(roundsDir).filter((d) => /^round-\d+$/.test(d)).map((d) => parseInt(d.replace("round-", ""), 10)).filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
2106
- const latestRoundNum = roundDirs[roundDirs.length - 1];
2107
- if (latestRoundNum !== void 0) {
2108
- inferredRound = latestRoundNum;
2109
- if (existsSync3(
2110
- join3(roundsDir, `round-${latestRoundNum}`, "final.md")
2111
- )) {
2112
- inferredPhase = "complete";
2113
- inferredPhaseNumber = 8;
2114
- }
2115
- }
2116
- }
2117
- } else if (workflowType === "map") {
2118
- const runsDir = join3(dirPath, "map", "runs");
2119
- if (existsSync3(runsDir)) {
2120
- const runDirs = readdirSync(runsDir).filter((d) => /^run-\d+$/.test(d)).map((d) => parseInt(d.replace("run-", ""), 10)).filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
2121
- const latestRunNum = runDirs[runDirs.length - 1];
2122
- if (latestRunNum !== void 0) {
2123
- inferredMapRun = latestRunNum;
2124
- if (existsSync3(join3(runsDir, `run-${latestRunNum}`, "map.md"))) {
2125
- inferredPhase = "complete";
2126
- inferredPhaseNumber = 6;
2127
- }
2128
- }
2129
- }
2130
- }
2131
- insertSession(db, {
2132
- id: dirName,
2133
- branch,
2134
- workflow_type: workflowType,
2135
- current_phase: inferredPhase,
2136
- phase_number: inferredPhaseNumber,
2137
- current_round: inferredRound,
2138
- current_map_run: inferredMapRun,
2139
- session_dir: dirPath
2140
- });
2141
- commitReasonClose(
2142
- db,
2143
- dirName,
2144
- {
2145
- event_type: "session_synced",
2146
- phase: inferredPhase,
2147
- phase_number: 1,
2148
- metadata: JSON.stringify({ source: "filesystem_backfill" })
2149
- },
2150
- { status: "closed" }
2151
- );
2152
- synced++;
2153
- }
2154
- return synced;
2155
- }
2156
- export {
2157
- CANCELLED_EXIT_CODE,
2158
- CASCADE_CLOSE_EXIT_CODE,
2159
- MAP_PHASE_NUMBERS,
2160
- ORPHAN_EXIT_CODE,
2161
- REASON_EVENT_TYPES,
2162
- REVIEW_PHASE_NUMBERS,
2163
- STATE_EXIT,
2164
- StateError,
2165
- TERMINAL_EVENT_TYPES,
2166
- WATCHDOG_DEADLINE_EXIT_CODE,
2167
- announceResolveDecision,
2168
- commitReasonClose,
2169
- computeMapCounts,
2170
- computeRoundCounts,
2171
- getCompletenessState,
2172
- graphFor,
2173
- hasCompletionInvariant,
2174
- initialPhaseFor,
2175
- phaseNumberFor,
2176
- rebuildSessionProjection,
2177
- reconcileCompletedSessions,
2178
- reconcileWorkflowOnExit,
2179
- resolveActiveSession,
2180
- resolveSession,
2181
- sanitizeMetadataString,
2182
- stateAdvance,
2183
- stateBegin,
2184
- stateClose,
2185
- stateCompleteMap,
2186
- stateCompleteRound,
2187
- stateInit,
2188
- stateList,
2189
- stateShow,
2190
- stateStatus,
2191
- stateSync,
2192
- stateTransition,
2193
- validateMapMeta,
2194
- validatePhaseTransition,
2195
- validateRoundMeta
2196
- };