@open-code-review/cli 2.1.0 → 2.2.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.
- package/dist/dashboard/client/assets/{_basePickBy-B3ALyupE.js → _basePickBy-BBPb8BJA.js} +1 -1
- package/dist/dashboard/client/assets/{_baseUniq-b2RALAWc.js → _baseUniq-CFHdos6T.js} +1 -1
- package/dist/dashboard/client/assets/{arc-DcSVvhUd.js → arc-BKGGWA2F.js} +1 -1
- package/dist/dashboard/client/assets/{architectureDiagram-VXUJARFQ-BNUlmSCS.js → architectureDiagram-VXUJARFQ-B_ovNjX1.js} +1 -1
- package/dist/dashboard/client/assets/{blockDiagram-VD42YOAC-BmhiQVwa.js → blockDiagram-VD42YOAC-C2M-avVp.js} +1 -1
- package/dist/dashboard/client/assets/{c4Diagram-YG6GDRKO-jyJ3WOv5.js → c4Diagram-YG6GDRKO-BtOBpAzH.js} +1 -1
- package/dist/dashboard/client/assets/channel-rgw7C1e7.js +1 -0
- package/dist/dashboard/client/assets/{chunk-4BX2VUAB-x1dQU_s3.js → chunk-4BX2VUAB-Cz2EbHPl.js} +1 -1
- package/dist/dashboard/client/assets/{chunk-55IACEB6-CwbsE2XQ.js → chunk-55IACEB6-C8xpXw9G.js} +1 -1
- package/dist/dashboard/client/assets/{chunk-B4BG7PRW-BaE7c-ti.js → chunk-B4BG7PRW-BSRfOovX.js} +1 -1
- package/dist/dashboard/client/assets/{chunk-DI55MBZ5-Bw5PUaMK.js → chunk-DI55MBZ5-CEUbYQWn.js} +1 -1
- package/dist/dashboard/client/assets/{chunk-FMBD7UC4-B7cF6P3s.js → chunk-FMBD7UC4-5xWP6GRj.js} +1 -1
- package/dist/dashboard/client/assets/{chunk-QN33PNHL-OY4evNHd.js → chunk-QN33PNHL-DfNCVcy8.js} +1 -1
- package/dist/dashboard/client/assets/{chunk-QZHKN3VN-BpjQwIWz.js → chunk-QZHKN3VN--OdToKKu.js} +1 -1
- package/dist/dashboard/client/assets/{chunk-TZMSLE5B-D8b_Oq9B.js → chunk-TZMSLE5B-B_0K0Qso.js} +1 -1
- package/dist/dashboard/client/assets/classDiagram-2ON5EDUG-DTGi7d9X.js +1 -0
- package/dist/dashboard/client/assets/classDiagram-v2-WZHVMYZB-DTGi7d9X.js +1 -0
- package/dist/dashboard/client/assets/clone-Cz7hswqi.js +1 -0
- package/dist/dashboard/client/assets/{cose-bilkent-S5V4N54A-C-sfP8PN.js → cose-bilkent-S5V4N54A-Cc_Dmnxz.js} +1 -1
- package/dist/dashboard/client/assets/{dagre-6UL2VRFP-Cqfo0NRg.js → dagre-6UL2VRFP-DaAfvUXU.js} +1 -1
- package/dist/dashboard/client/assets/{diagram-PSM6KHXK-BR3ppxqI.js → diagram-PSM6KHXK-7idwN0rC.js} +1 -1
- package/dist/dashboard/client/assets/{diagram-QEK2KX5R-Dvcx6x3R.js → diagram-QEK2KX5R-D9j9H13n.js} +1 -1
- package/dist/dashboard/client/assets/{diagram-S2PKOQOG-DoyBLnVN.js → diagram-S2PKOQOG-SMF5SB0K.js} +1 -1
- package/dist/dashboard/client/assets/{erDiagram-Q2GNP2WA-hy77l1cL.js → erDiagram-Q2GNP2WA-EVJ4Qa2F.js} +1 -1
- package/dist/dashboard/client/assets/{flowDiagram-NV44I4VS-Bz0B1rKM.js → flowDiagram-NV44I4VS-tZ7SFE77.js} +1 -1
- package/dist/dashboard/client/assets/{ganttDiagram-JELNMOA3-CLgrZPoC.js → ganttDiagram-JELNMOA3-DFSqguY7.js} +1 -1
- package/dist/dashboard/client/assets/{gitGraphDiagram-V2S2FVAM-DwJ-1f-v.js → gitGraphDiagram-V2S2FVAM-CqHdP3HE.js} +1 -1
- package/dist/dashboard/client/assets/{graph-DDBMM_t2.js → graph-C0XnkNkk.js} +1 -1
- package/dist/dashboard/client/assets/{index-Cr9yEo_B.js → index-C3NEq704.js} +133 -138
- package/dist/dashboard/client/assets/index-CzxeSSaQ.css +1 -0
- package/dist/dashboard/client/assets/{infoDiagram-HS3SLOUP-Bhn1FmAk.js → infoDiagram-HS3SLOUP-DlXZo9U2.js} +1 -1
- package/dist/dashboard/client/assets/{journeyDiagram-XKPGCS4Q-CzGbjX1y.js → journeyDiagram-XKPGCS4Q-CgC8_7eN.js} +1 -1
- package/dist/dashboard/client/assets/{kanban-definition-3W4ZIXB7-Da77-WYk.js → kanban-definition-3W4ZIXB7-BMAw_jNp.js} +1 -1
- package/dist/dashboard/client/assets/{layout-CVwSB-GS.js → layout-XjM3Q-ka.js} +1 -1
- package/dist/dashboard/client/assets/{linear-CTRAc5Jn.js → linear-CMUrrr1X.js} +1 -1
- package/dist/dashboard/client/assets/{mermaid-renderer-Bjo170ax.js → mermaid-renderer-D2jYNs7K.js} +4 -4
- package/dist/dashboard/client/assets/{mindmap-definition-VGOIOE7T-B55C2odl.js → mindmap-definition-VGOIOE7T-CL4hv-vg.js} +1 -1
- package/dist/dashboard/client/assets/{pieDiagram-ADFJNKIX-5lrQLrSz.js → pieDiagram-ADFJNKIX-DTqv-1h1.js} +1 -1
- package/dist/dashboard/client/assets/{quadrantDiagram-AYHSOK5B-Bg55gC30.js → quadrantDiagram-AYHSOK5B-BpFlSW9N.js} +1 -1
- package/dist/dashboard/client/assets/{requirementDiagram-UZGBJVZJ-CyR4YFJY.js → requirementDiagram-UZGBJVZJ-BqYqqXL4.js} +1 -1
- package/dist/dashboard/client/assets/{sankeyDiagram-TZEHDZUN-BVWKr9_-.js → sankeyDiagram-TZEHDZUN-kEI9kntR.js} +1 -1
- package/dist/dashboard/client/assets/{sequenceDiagram-WL72ISMW-D0AJg_tE.js → sequenceDiagram-WL72ISMW-Cnu_1j-N.js} +1 -1
- package/dist/dashboard/client/assets/{stateDiagram-FKZM4ZOC-BuHpTgim.js → stateDiagram-FKZM4ZOC-BoC-rqoG.js} +1 -1
- package/dist/dashboard/client/assets/stateDiagram-v2-4FDKWEC3-COR3QD3v.js +1 -0
- package/dist/dashboard/client/assets/{timeline-definition-IT6M3QCI-LDhpAmDd.js → timeline-definition-IT6M3QCI-CXMWuzDL.js} +1 -1
- package/dist/dashboard/client/assets/{treemap-GDKQZRPO-Dd4gjvUl.js → treemap-GDKQZRPO-o9ZFgpbJ.js} +1 -1
- package/dist/dashboard/client/assets/{xychartDiagram-PRI3JC2R-B9RDod39.js → xychartDiagram-PRI3JC2R-CfIuUpeA.js} +1 -1
- package/dist/dashboard/client/index.html +2 -2
- package/dist/dashboard/server.js +1031 -426
- package/dist/index.js +1252 -268
- package/dist/lib/db/index.js +485 -24
- package/dist/lib/runtime-config.js +29 -13
- package/dist/lib/state/index.js +2196 -0
- package/package.json +8 -2
- package/dist/dashboard/client/assets/channel-D3J8-GF_.js +0 -1
- package/dist/dashboard/client/assets/classDiagram-2ON5EDUG-tkFUL-1Y.js +0 -1
- package/dist/dashboard/client/assets/classDiagram-v2-WZHVMYZB-tkFUL-1Y.js +0 -1
- package/dist/dashboard/client/assets/clone-CkY5ajLr.js +0 -1
- package/dist/dashboard/client/assets/index-Z1pPudAt.css +0 -1
- package/dist/dashboard/client/assets/stateDiagram-v2-4FDKWEC3-DwAPhteN.js +0 -1
|
@@ -0,0 +1,2196 @@
|
|
|
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
|
+
};
|