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