@yemi33/minions 0.1.2114 → 0.1.2116

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/bin/minions.js CHANGED
@@ -34,6 +34,27 @@
34
34
  * minions uninstall --confirm Remove Minions and uninstall package
35
35
  */
36
36
 
37
+ // Phase 9.4 (issue #3035 hardening): self-reexec on Node 22.x without
38
+ // --experimental-sqlite so the CLI PARENT process can load `node:sqlite`
39
+ // for in-process store reads (status, doctor, etc.). On Node 24+ this is a
40
+ // no-op (sqlite is unflagged). Must run BEFORE any `require('engine/*')` —
41
+ // engine modules pull in `node:sqlite` transitively via `engine/db`.
42
+ (function _ensureSqliteFlagSelfReexec() {
43
+ const major = parseInt(String(process.versions.node).split('.')[0], 10);
44
+ if (!Number.isFinite(major) || major < 22 || major >= 24) return;
45
+ const opts = String(process.env.NODE_OPTIONS || '');
46
+ if (opts.includes('--experimental-sqlite')) return;
47
+ if (process.env.MINIONS_FORCE_JSON === '1') return; // explicit opt-out
48
+ const { spawnSync } = require('child_process');
49
+ const nextOpts = (opts + ' --experimental-sqlite').trim();
50
+ const res = spawnSync(
51
+ process.execPath,
52
+ ['--experimental-sqlite', __filename, ...process.argv.slice(2)],
53
+ { stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: nextOpts } }
54
+ );
55
+ process.exit(res.status == null ? 1 : res.status);
56
+ })();
57
+
37
58
  const fs = require('fs');
38
59
  const path = require('path');
39
60
  const os = require('os');
package/engine/cleanup.js CHANGED
@@ -1410,12 +1410,19 @@ async function runCleanup(config, verbose = false) {
1410
1410
  } catch (e) { log('warn', 'clean stale PID files: ' + e.message); }
1411
1411
 
1412
1412
  // 13. Prune test-results.json — keep last 200 entries
1413
+ // Phase 9.3: route through shared.mutateTestResults so the read+trim+write
1414
+ // is atomic under the file lock (previously a safeJsonArr → safeWrite race).
1413
1415
  try {
1414
1416
  const testResultsPath = path.join(ENGINE_DIR, 'test-results.json');
1415
- const results = shared.safeJsonArr(testResultsPath);
1416
1417
  const TEST_RESULTS_CAP = 200;
1417
- if (results.length > TEST_RESULTS_CAP) {
1418
- safeWrite(testResultsPath, results.slice(-TEST_RESULTS_CAP));
1418
+ // Fast path: skip lock acquisition entirely when the file is already
1419
+ // under cap. The atomic re-check inside the mutator handles the race.
1420
+ const probe = shared.safeJsonArr(testResultsPath);
1421
+ if (probe.length > TEST_RESULTS_CAP) {
1422
+ shared.mutateTestResults((curr) => {
1423
+ if (curr.length > TEST_RESULTS_CAP) return curr.slice(-TEST_RESULTS_CAP);
1424
+ return undefined; // already trimmed by a concurrent writer
1425
+ });
1419
1426
  }
1420
1427
  } catch { /* optional — file may not exist */ }
1421
1428
 
@@ -849,7 +849,7 @@ function classifyToKnowledgeBase(items, config) {
849
849
  const dir = path.join(KNOWLEDGE_DIR, cat);
850
850
  if (fs.existsSync(dir)) count += fs.readdirSync(dir).length;
851
851
  }
852
- safeWrite(path.join(ENGINE_DIR, 'kb-checkpoint.json'), JSON.stringify({ count, updatedAt: ts() }));
852
+ shared.mutateKbCheckpoint(() => ({ count, updatedAt: ts() }));
853
853
  } catch (err) { log('warn', `KB checkpoint: ${err.message}`); }
854
854
  }
855
855
 
@@ -35,17 +35,24 @@ function runMigrations(db) {
35
35
  )
36
36
  `);
37
37
 
38
- const currentRow = db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version').get();
39
- const current = Number(currentRow.v) || 0;
40
38
  const migrations = _loadMigrations();
41
39
 
42
40
  for (const m of migrations) {
43
41
  if (typeof m.version !== 'number' || typeof m.up !== 'function') {
44
42
  throw new Error(`engine/db/migrate: migration missing { version, up }: ${JSON.stringify(Object.keys(m))}`);
45
43
  }
46
- if (m.version <= current) continue;
47
- db.exec('BEGIN');
44
+ // Concurrent-startup safety: take the SQLite write lock BEFORE re-reading
45
+ // schema_version. With plain `BEGIN` (deferred), two processes booting at
46
+ // the same time can both observe schema_version=N, both decide to apply
47
+ // N+1, and one then fails with "table already exists" while the other
48
+ // succeeds — leaving the loser stuck on `_dbInitError` for the lifetime
49
+ // of its process. `BEGIN IMMEDIATE` serializes the version check itself
50
+ // so the second arrival simply sees the updated schema_version and skips.
51
+ db.exec('BEGIN IMMEDIATE');
48
52
  try {
53
+ const currentRow = db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version').get();
54
+ const current = Number(currentRow.v) || 0;
55
+ if (m.version <= current) { db.exec('COMMIT'); continue; }
49
56
  // eslint-disable-next-line no-console
50
57
  console.log(`[db-migrate] Applying v${m.version}: ${m.description || '(no description)'}`);
51
58
  m.up(db, { fs, path });
@@ -0,0 +1,61 @@
1
+ // engine/db/migrations/010-pr-links.js
2
+ //
3
+ // Phase 9.1: move `engine/pr-links.json` into SQL.
4
+ //
5
+ // pr-links.json is the *fallback* PR → work-item linkage store. The
6
+ // primary source is `pull_requests.prdItems[]` (managed via the
7
+ // pull-requests-store). Stray PR IDs that aren't covered by a project
8
+ // pull-requests record still need to be discoverable — that's what this
9
+ // table holds.
10
+ //
11
+ // Shape: { [prId]: <value> } where <value> is typically an array of item IDs
12
+ // but may also be a bare string from legacy writes. Readers normalize.
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ function _resolveMinionsDir() {
18
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
19
+ if (envHome) return envHome;
20
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
21
+ }
22
+
23
+ function _readJsonOr(filePath, fallback) {
24
+ try {
25
+ const raw = fs.readFileSync(filePath, 'utf8');
26
+ return JSON.parse(raw);
27
+ } catch { return fallback; }
28
+ }
29
+
30
+ module.exports = {
31
+ version: 10,
32
+ description: 'pr_links: schema + pr-links.json backfill (fallback static store)',
33
+ up(db) {
34
+ db.exec(`
35
+ CREATE TABLE pr_links (
36
+ pr_id TEXT PRIMARY KEY,
37
+ data TEXT NOT NULL,
38
+ updated_at INTEGER NOT NULL
39
+ );
40
+ `);
41
+
42
+ const minionsDir = _resolveMinionsDir();
43
+ if (!minionsDir) return;
44
+ const now = Date.now();
45
+ let inserted = 0;
46
+
47
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'pr-links.json'), null);
48
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
49
+ const ins = db.prepare(`INSERT INTO pr_links (pr_id, data, updated_at) VALUES (?, ?, ?)`);
50
+ for (const [prId, value] of Object.entries(raw)) {
51
+ try {
52
+ ins.run(String(prId), JSON.stringify(value), now);
53
+ inserted += 1;
54
+ } catch { /* duplicate / corrupt — skip */ }
55
+ }
56
+ }
57
+
58
+ // eslint-disable-next-line no-console
59
+ console.log(`[db-migrate] v10: backfilled ${inserted} pr-links rows; pr-links.json kept as dual-write mirror`);
60
+ },
61
+ };
@@ -0,0 +1,116 @@
1
+ // engine/db/migrations/011-remaining-state.js
2
+ //
3
+ // Phase 9.2: finish the JSON→SQL migration. Four remaining engine state
4
+ // files move into their own tables; the JSON files are kept as dual-write
5
+ // mirrors for back-compat.
6
+ //
7
+ // engine/cooldowns.json -> cooldowns
8
+ // engine/pending-rebases.json -> pending_rebases
9
+ // engine/cc-sessions.json -> cc_sessions
10
+ // engine/doc-sessions.json -> doc_sessions
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ function _resolveMinionsDir() {
16
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
17
+ if (envHome) return envHome;
18
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
19
+ }
20
+
21
+ function _readJsonOr(filePath, fallback) {
22
+ try {
23
+ const raw = fs.readFileSync(filePath, 'utf8');
24
+ return JSON.parse(raw);
25
+ } catch { return fallback; }
26
+ }
27
+
28
+ module.exports = {
29
+ version: 11,
30
+ description: 'cooldowns + pending_rebases + cc_sessions + doc_sessions',
31
+ up(db) {
32
+ db.exec(`
33
+ CREATE TABLE cooldowns (
34
+ key TEXT PRIMARY KEY,
35
+ data TEXT NOT NULL,
36
+ updated_at INTEGER NOT NULL
37
+ );
38
+
39
+ CREATE TABLE pending_rebases (
40
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ data TEXT NOT NULL,
42
+ updated_at INTEGER NOT NULL
43
+ );
44
+
45
+ CREATE TABLE cc_sessions (
46
+ id TEXT PRIMARY KEY,
47
+ data TEXT NOT NULL,
48
+ updated_at INTEGER NOT NULL
49
+ );
50
+
51
+ CREATE TABLE doc_sessions (
52
+ key TEXT PRIMARY KEY,
53
+ data TEXT NOT NULL,
54
+ updated_at INTEGER NOT NULL
55
+ );
56
+ `);
57
+
58
+ const minionsDir = _resolveMinionsDir();
59
+ if (!minionsDir) return;
60
+ const now = Date.now();
61
+ let inserted = 0;
62
+
63
+ // ── cooldowns ──────────────────────────────────────────────────────────
64
+ {
65
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'cooldowns.json'), null);
66
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
67
+ const ins = db.prepare(`INSERT INTO cooldowns (key, data, updated_at) VALUES (?, ?, ?)`);
68
+ for (const [k, v] of Object.entries(raw)) {
69
+ try { ins.run(String(k), JSON.stringify(v), now); inserted += 1; }
70
+ catch { /* duplicate / corrupt — skip */ }
71
+ }
72
+ }
73
+ }
74
+
75
+ // ── pending_rebases ────────────────────────────────────────────────────
76
+ {
77
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'pending-rebases.json'), null);
78
+ if (Array.isArray(raw)) {
79
+ const ins = db.prepare(`INSERT INTO pending_rebases (data, updated_at) VALUES (?, ?)`);
80
+ for (const entry of raw) {
81
+ if (!entry || typeof entry !== 'object') continue;
82
+ try { ins.run(JSON.stringify(entry), now); inserted += 1; }
83
+ catch { /* corrupt — skip */ }
84
+ }
85
+ }
86
+ }
87
+
88
+ // ── cc_sessions ────────────────────────────────────────────────────────
89
+ {
90
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'cc-sessions.json'), null);
91
+ if (Array.isArray(raw)) {
92
+ const ins = db.prepare(`INSERT INTO cc_sessions (id, data, updated_at) VALUES (?, ?, ?)`);
93
+ for (const entry of raw) {
94
+ if (!entry || typeof entry !== 'object' || !entry.id) continue;
95
+ try { ins.run(String(entry.id), JSON.stringify(entry), now); inserted += 1; }
96
+ catch { /* duplicate id — skip */ }
97
+ }
98
+ }
99
+ }
100
+
101
+ // ── doc_sessions ───────────────────────────────────────────────────────
102
+ {
103
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'doc-sessions.json'), null);
104
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
105
+ const ins = db.prepare(`INSERT INTO doc_sessions (key, data, updated_at) VALUES (?, ?, ?)`);
106
+ for (const [k, v] of Object.entries(raw)) {
107
+ try { ins.run(String(k), JSON.stringify(v), now); inserted += 1; }
108
+ catch { /* duplicate / corrupt — skip */ }
109
+ }
110
+ }
111
+ }
112
+
113
+ // eslint-disable-next-line no-console
114
+ console.log(`[db-migrate] v11: backfilled ${inserted} small-state rows; JSON files kept as dual-write mirrors`);
115
+ },
116
+ };
@@ -288,8 +288,10 @@ function _writeSweepState(state) {
288
288
  // can distinguish "still running" from "runner crashed". When this module is
289
289
  // imported by the detached runner, process.pid is the runner's pid — which
290
290
  // is exactly what we want.
291
+ // Phase 9.3: route through shared.mutateKbSweepState (file-locked RMW) so
292
+ // the dashboard process + the detached runner can't race each other.
291
293
  const augmented = { pid: process.pid, ...state };
292
- try { safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify(augmented)); } catch { /* ignore */ }
294
+ try { shared.mutateKbSweepState(() => augmented); } catch { /* ignore */ }
293
295
  }
294
296
 
295
297
  /**
@@ -416,10 +418,10 @@ function reconcileSweepStateOnBoot(opts = {}) {
416
418
  error: `sweep abandoned: ${reason}`,
417
419
  reconciliationReason: reason,
418
420
  };
419
- // Direct safeWrite (NOT _writeSweepState) so the original pid is preserved
420
- // for forensics — _writeSweepState would overwrite it with this process's
421
- // pid, destroying the diagnostic value of the record.
422
- try { safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify(failedState)); } catch { /* ignore */ }
421
+ // Direct mutateKbSweepState (NOT _writeSweepState) so the original pid is
422
+ // preserved for forensics — _writeSweepState would overwrite it with this
423
+ // process's pid, destroying the diagnostic value of the record.
424
+ try { shared.mutateKbSweepState(() => failedState); } catch { /* ignore */ }
423
425
  return {
424
426
  scanned: 1, released: 1, reason,
425
427
  prevStatus: state.status, prevPid: pid,
@@ -551,7 +553,7 @@ async function _runKbSweepImpl(opts = {}) {
551
553
  summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
552
554
 
553
555
  if (!opts.dryRun) {
554
- try { safeWrite(path.join(ENGINE_DIR, 'kb-swept.json'), JSON.stringify({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
556
+ try { shared.mutateKbSwept(() => ({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
555
557
  try { queries.invalidateKnowledgeBaseCache(); } catch { /* ignore */ }
556
558
  }
557
559
  return summary;
@@ -587,7 +589,7 @@ function spawnSweepRunnerDetached(opts = {}) {
587
589
  const sweepToken = `${startedAt}-${Math.random().toString(36).slice(2, 8)}`;
588
590
 
589
591
  try {
590
- safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify({
592
+ shared.mutateKbSweepState(() => ({
591
593
  status: 'starting', startedAt, startedAtIso: new Date().toISOString(),
592
594
  sweepToken, pid: null,
593
595
  }));
@@ -637,14 +639,18 @@ function spawnSweepRunnerDetached(opts = {}) {
637
639
  }
638
640
  if (logFdNum != null) try { fsLocal.closeSync(logFdNum); } catch { /* ignore */ }
639
641
 
642
+ // Phase 9.3: atomic starting→in-flight transition under the file lock
643
+ // (was previously a safeJson→safeWrite race).
640
644
  try {
641
- const current = safeJson(KB_SWEEP_STATE_PATH);
642
- if (current && current.status === 'starting' && current.sweepToken === sweepToken) {
643
- safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify({
644
- status: 'in-flight', startedAt, startedAtIso: new Date().toISOString(),
645
- sweepToken, pid: proc.pid,
646
- }));
647
- }
645
+ shared.mutateKbSweepState((current) => {
646
+ if (current && current.status === 'starting' && current.sweepToken === sweepToken) {
647
+ return {
648
+ status: 'in-flight', startedAt, startedAtIso: new Date().toISOString(),
649
+ sweepToken, pid: proc.pid,
650
+ };
651
+ }
652
+ return undefined; // unchanged → skipWriteIfUnchanged suppresses the write
653
+ });
648
654
  } catch { /* best-effort */ }
649
655
 
650
656
  proc.unref();
package/engine/shared.js CHANGED
@@ -65,6 +65,16 @@ const COOLDOWNS_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
65
65
  // process-lifetime (state/pid/ownerToken). See ENGINE_DEFAULTS.abandonedReconciliationVersion
66
66
  // for the first consumer.
67
67
  const ENGINE_STATE_PATH = path.join(ENGINE_DIR, 'state.json');
68
+ // Phase 9.3: wrap raw safeWrite callers for kb-checkpoint, kb-swept,
69
+ // kb-sweep-state, and test-results so they go through mutateJsonFileLocked
70
+ // (the same path mutateControl / mutateEngineState / mutateCooldowns use).
71
+ // This sets them up for SQL routing via _SMALL_STATE_MUTATE_ROUTES later, and
72
+ // in the case of kb-sweep-state.json closes a real cross-process race (the
73
+ // dashboard process and the detached kb-sweep-runner child both write it).
74
+ const KB_CHECKPOINT_PATH = path.join(ENGINE_DIR, 'kb-checkpoint.json');
75
+ const KB_SWEPT_PATH = path.join(ENGINE_DIR, 'kb-swept.json');
76
+ const KB_SWEEP_STATE_PATH = path.join(ENGINE_DIR, 'kb-sweep-state.json');
77
+ const TEST_RESULTS_PATH = path.join(ENGINE_DIR, 'test-results.json');
68
78
  const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
69
79
  const PINNED_ITEMS_PATH = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
70
80
  const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
@@ -532,6 +542,26 @@ function _routeJsonReadToSql(p) {
532
542
  const store = require('./small-state-store');
533
543
  return { value: store.readQaSessions() };
534
544
  }
545
+ if (norm.endsWith('/engine/pr-links.json')) {
546
+ const store = require('./small-state-store');
547
+ return { value: store.readPrLinks() };
548
+ }
549
+ if (norm.endsWith('/engine/cooldowns.json')) {
550
+ const store = require('./small-state-store');
551
+ return { value: store.readCooldowns() };
552
+ }
553
+ if (norm.endsWith('/engine/pending-rebases.json')) {
554
+ const store = require('./small-state-store');
555
+ return { value: store.readPendingRebases() };
556
+ }
557
+ if (norm.endsWith('/engine/cc-sessions.json')) {
558
+ const store = require('./small-state-store');
559
+ return { value: store.readCcSessions() };
560
+ }
561
+ if (norm.endsWith('/engine/doc-sessions.json')) {
562
+ const store = require('./small-state-store');
563
+ return { value: store.readDocSessions() };
564
+ }
535
565
  // Per-project work-items.json — match `/projects/<name>/work-items.json`.
536
566
  // When SQL has no rows for the scope AND the JSON file is absent on
537
567
  // disk, preserve the legacy "file missing → null" semantic. This guards
@@ -582,9 +612,9 @@ function _routeJsonReadToSql(p) {
582
612
  *
583
613
  * **SQL routing (Phase 9):** when the path matches a migrated state file
584
614
  * (work-items, pull-requests, dispatch, metrics, watches, schedule-runs,
585
- * pipeline-runs, managed-processes, worktree-pool, qa-runs, qa-sessions),
586
- * the read is served from SQLite via `_routeJsonReadToSql` — the JSON file
587
- * on disk is no longer authoritative.
615
+ * pipeline-runs, managed-processes, worktree-pool, qa-runs, qa-sessions,
616
+ * pr-links), the read is served from SQLite via `_routeJsonReadToSql` — the
617
+ * JSON file on disk is no longer authoritative.
588
618
  *
589
619
  * Counterpart: `safeJsonNoRestore` for terminal artifacts and "missing == gone"
590
620
  * reads (cooldowns, archived PRDs, ephemeral session state) where reviving a
@@ -1319,13 +1349,60 @@ function withFileLock(lockPath, fn, {
1319
1349
  throw lastErr;
1320
1350
  }
1321
1351
 
1352
+ // Route table for small-state files migrated to SQL stores. Each entry
1353
+ // declares the SQL store function to invoke and the expected default JSON
1354
+ // shape (for the "ensure file exists" fallback below).
1355
+ const _SMALL_STATE_MUTATE_ROUTES = {
1356
+ 'cooldowns.json': { fn: 'applyCooldownsMutation', mirror: '_mirrorCooldownsJson', defaultShape: 'object' },
1357
+ 'pending-rebases.json': { fn: 'applyPendingRebasesMutation', mirror: '_mirrorPendingRebasesJson', defaultShape: 'array' },
1358
+ 'cc-sessions.json': { fn: 'applyCcSessionsMutation', mirror: '_mirrorCcSessionsJson', defaultShape: 'array' },
1359
+ 'doc-sessions.json': { fn: 'applyDocSessionsMutation', mirror: '_mirrorDocSessionsJson', defaultShape: 'object' },
1360
+ 'pr-links.json': { fn: 'applyPrLinksMutation', mirror: '_mirrorPrLinksJson', defaultShape: 'object' },
1361
+ };
1362
+
1322
1363
  function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1323
1364
  const baseName = path.basename(filePath);
1324
- if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json') return null;
1365
+ const smallRoute = _SMALL_STATE_MUTATE_ROUTES[baseName];
1366
+ if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json' && !smallRoute) return null;
1325
1367
  const fpNorm = String(filePath).replace(/\\/g, '/');
1326
1368
  const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
1327
1369
  const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/' + baseName;
1328
1370
  if (!insideMinionsDir) return null;
1371
+
1372
+ // Small-state files live exclusively under <MINIONS_DIR>/engine/<baseName>.
1373
+ // Don't hijack writes to ad-hoc paths that happen to share the basename.
1374
+ if (smallRoute) {
1375
+ if (!fpNorm.endsWith('/engine/' + baseName)) return null;
1376
+ let result;
1377
+ try {
1378
+ const store = require('./small-state-store');
1379
+ const out = store[smallRoute.fn]((data) => {
1380
+ const next = mutateFn(data);
1381
+ if (onWrote) onWrote();
1382
+ return next;
1383
+ });
1384
+ result = out && Object.prototype.hasOwnProperty.call(out, 'result') ? out.result : undefined;
1385
+ try { store[smallRoute.mirror](filePath); } catch { /* mirror best-effort */ }
1386
+ } catch (e) {
1387
+ // SQLite unavailable / table missing — fall through to JSON path.
1388
+ const msg = e && e.message ? String(e.message) : '';
1389
+ if (/SQLite unavailable|no such table|not a database|node:sqlite/i.test(msg)) {
1390
+ return null;
1391
+ }
1392
+ throw e;
1393
+ }
1394
+ if (!fs.existsSync(filePath)) {
1395
+ try {
1396
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1397
+ const fallback = smallRoute.defaultShape === 'array'
1398
+ ? (Array.isArray(result) ? result : [])
1399
+ : (result && typeof result === 'object' && !Array.isArray(result) ? result : {});
1400
+ safeWrite(filePath, fallback);
1401
+ } catch { /* best-effort */ }
1402
+ }
1403
+ return { routed: result };
1404
+ }
1405
+
1329
1406
  let result;
1330
1407
  if (baseName === 'work-items.json') {
1331
1408
  result = mutateWorkItems(filePath, (arr) => {
@@ -1447,6 +1524,45 @@ function mutateCooldowns(mutator) {
1447
1524
  }, { defaultValue: {}, skipWriteIfUnchanged: true });
1448
1525
  }
1449
1526
 
1527
+ // Phase 9.3 (W-mp48wxqw / SQL-canonicalization prep): lock-safe RMW wrappers
1528
+ // for the small JSON state files that were still going through raw safeWrite.
1529
+ // Pattern mirrors mutateControl / mutateEngineState / mutateCooldowns — so a
1530
+ // future SQL migration is a mechanical _SMALL_STATE_MUTATE_ROUTES addition.
1531
+
1532
+ // KB classification checkpoint counter (consolidation.js post-classify).
1533
+ function mutateKbCheckpoint(mutator) {
1534
+ return mutateJsonFileLocked(KB_CHECKPOINT_PATH, (data) => {
1535
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1536
+ return mutator(data) || data;
1537
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1538
+ }
1539
+
1540
+ // KB last-sweep summary (kb-sweep.js post-sweep, dashboard reader).
1541
+ function mutateKbSwept(mutator) {
1542
+ return mutateJsonFileLocked(KB_SWEPT_PATH, (data) => {
1543
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1544
+ return mutator(data) || data;
1545
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1546
+ }
1547
+
1548
+ // KB sweep state machine. Written by BOTH the dashboard process AND the
1549
+ // detached kb-sweep-runner child — wrapping under mutateJsonFileLocked makes
1550
+ // cross-process writes file-locked (previously a real race via raw safeWrite).
1551
+ function mutateKbSweepState(mutator) {
1552
+ return mutateJsonFileLocked(KB_SWEEP_STATE_PATH, (data) => {
1553
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1554
+ return mutator(data) || data;
1555
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
1556
+ }
1557
+
1558
+ // Test results history (capped at TEST_RESULTS_CAP by cleanup.js).
1559
+ function mutateTestResults(mutator) {
1560
+ return mutateJsonFileLocked(TEST_RESULTS_PATH, (data) => {
1561
+ if (!Array.isArray(data)) data = [];
1562
+ return mutator(data) || data;
1563
+ }, { defaultValue: [], skipWriteIfUnchanged: true });
1564
+ }
1565
+
1450
1566
  let _uidCounter = 0;
1451
1567
 
1452
1568
  /**
@@ -4988,9 +5104,19 @@ function getPrLinks() {
4988
5104
  mergePrLinkItems(links, pr.id, pr.prdItems || []);
4989
5105
  }
4990
5106
  } catch { /* SQL unavailable — skip primary source */ }
4991
- // Fallback: static pr-links.json for entries not covered above
5107
+ // Fallback: pr_links SQL store (was static pr-links.json file). Phase 9.1
5108
+ // routes both reads and writes through small-state-store; the JSON file is
5109
+ // kept as a dual-write mirror. SQL failures fall through to the JSON file.
5110
+ let static_ = null;
4992
5111
  try {
4993
- const static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8'));
5112
+ const store = require('./small-state-store');
5113
+ static_ = store.readPrLinks();
5114
+ } catch { static_ = null; }
5115
+ if (!static_ || Object.keys(static_).length === 0) {
5116
+ try { static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8')); }
5117
+ catch { static_ = null; }
5118
+ }
5119
+ if (static_ && typeof static_ === 'object' && !Array.isArray(static_)) {
4994
5120
  for (const [k, v] of Object.entries(static_)) {
4995
5121
  const canonical = parseCanonicalPrId(k);
4996
5122
  let normalizedKey = canonical ? `${canonical.scope}#${canonical.prNumber}` : k;
@@ -5004,7 +5130,7 @@ function getPrLinks() {
5004
5130
  }
5005
5131
  if (!links[normalizedKey]) mergePrLinkItems(links, normalizedKey, v);
5006
5132
  }
5007
- } catch { /* missing */ }
5133
+ }
5008
5134
  return links;
5009
5135
  }
5010
5136
 
@@ -5030,7 +5156,7 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
5030
5156
  const effectivePrId = canonicalPrId || prId;
5031
5157
  if (!effectivePrId || !itemId) return;
5032
5158
  const legacyPrId = String(prId || '');
5033
- mutateJsonFileLocked(PR_LINKS_PATH, (links) => {
5159
+ const mutator = (links) => {
5034
5160
  if (!links || Array.isArray(links) || typeof links !== 'object') links = {};
5035
5161
  const mergedCurrent = new Set(normalizePrLinkItems(links[effectivePrId]));
5036
5162
  if (legacyPrId && legacyPrId !== effectivePrId && links[legacyPrId]) {
@@ -5040,7 +5166,24 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
5040
5166
  if (!mergedCurrent.has(itemId)) mergedCurrent.add(itemId);
5041
5167
  links[effectivePrId] = [...mergedCurrent];
5042
5168
  return links;
5043
- }, { defaultValue: {} });
5169
+ };
5170
+ // Phase 9.1: pr-links is SQL-canonical via small-state-store; the JSON file
5171
+ // is a dual-write mirror. SQLite failures fall through to the legacy JSON
5172
+ // path so older installs without --experimental-sqlite still work.
5173
+ let routedViaSql = false;
5174
+ try {
5175
+ const store = require('./small-state-store');
5176
+ store.applyPrLinksMutation(mutator);
5177
+ try { store._mirrorPrLinksJson(); } catch { /* mirror best-effort */ }
5178
+ routedViaSql = true;
5179
+ } catch (e) {
5180
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
5181
+ throw e;
5182
+ }
5183
+ }
5184
+ if (!routedViaSql) {
5185
+ mutateJsonFileLocked(PR_LINKS_PATH, mutator, { defaultValue: {} });
5186
+ }
5044
5187
 
5045
5188
  if (!project) return;
5046
5189
  const prPath = projectPrPath(project);
@@ -5944,6 +6087,10 @@ module.exports = {
5944
6087
  mutateEngineState, // W-mp60tw0u000j3931
5945
6088
  readEngineState, // W-mp60tw0u000j3931
5946
6089
  mutateCooldowns,
6090
+ mutateKbCheckpoint, // Phase 9.3
6091
+ mutateKbSwept, // Phase 9.3
6092
+ mutateKbSweepState, // Phase 9.3
6093
+ mutateTestResults, // Phase 9.3
5947
6094
  mutateWorkItems,
5948
6095
  reopenWorkItem,
5949
6096
  mutatePullRequests,
@@ -829,6 +829,550 @@ function _mirrorQaSessionsJson(filePath) {
829
829
  } catch { /* mirror best-effort */ }
830
830
  }
831
831
 
832
+ // ─── pr_links ───────────────────────────────────────────────────────────────
833
+ // Shape: { [prId]: <value> } where <value> is typically array of item IDs
834
+ // but may also be a bare string (legacy). Readers normalize.
835
+
836
+ let _prLinksHash = null;
837
+
838
+ function _hydratePrLinks(db) {
839
+ const fp = _resolveFilePath('pr-links.json');
840
+ const raw = _readJson(fp) || {};
841
+ db.prepare('DELETE FROM pr_links').run();
842
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
843
+ const now = Date.now();
844
+ const ins = db.prepare('INSERT INTO pr_links (pr_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(pr_id) DO NOTHING');
845
+ for (const [prId, value] of Object.entries(raw)) {
846
+ ins.run(String(prId), JSON.stringify(value), now);
847
+ }
848
+ }
849
+
850
+ function _resyncPrLinksIfDiverged(db) {
851
+ const fp = _resolveFilePath('pr-links.json');
852
+ const currentHash = _fileContentHash(fp);
853
+ if (currentHash == null) return;
854
+ if (_prLinksHash != null && currentHash === _prLinksHash) return;
855
+ if (_prLinksHash == null) {
856
+ const sqlHas = db.prepare('SELECT 1 FROM pr_links LIMIT 1').get();
857
+ if (sqlHas) {
858
+ _prLinksHash = currentHash;
859
+ return;
860
+ }
861
+ }
862
+ _hydratePrLinks(db);
863
+ _prLinksHash = currentHash;
864
+ }
865
+
866
+ function _readPrLinksFromSql(db) {
867
+ const rows = db.prepare('SELECT pr_id, data FROM pr_links').all();
868
+ const out = {};
869
+ for (const row of rows) {
870
+ try { out[row.pr_id] = JSON.parse(row.data); }
871
+ catch { /* skip malformed */ }
872
+ }
873
+ return out;
874
+ }
875
+
876
+ function readPrLinks() {
877
+ const { getDb } = require('./db');
878
+ let db;
879
+ try { db = getDb(); }
880
+ catch { return _readJson(_resolveFilePath('pr-links.json')) || {}; }
881
+ try { _resyncPrLinksIfDiverged(db); }
882
+ catch { /* table may be missing on a stale install — fall back to JSON */ }
883
+ let out;
884
+ try { out = _readPrLinksFromSql(db); }
885
+ catch { return _readJson(_resolveFilePath('pr-links.json')) || {}; }
886
+ if (Object.keys(out).length === 0) {
887
+ const fallback = _readJson(_resolveFilePath('pr-links.json'));
888
+ if (fallback && Object.keys(fallback).length > 0) return fallback;
889
+ return {};
890
+ }
891
+ return out;
892
+ }
893
+
894
+ function applyPrLinksMutation(mutator) {
895
+ const { getDb, withTransaction } = require('./db');
896
+ let db;
897
+ try { db = getDb(); }
898
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
899
+
900
+ return withTransaction(db, () => {
901
+ _resyncPrLinksIfDiverged(db);
902
+ const before = _readPrLinksFromSql(db);
903
+ const beforeSnap = JSON.parse(JSON.stringify(before));
904
+ const next = mutator(before);
905
+ const after = (next === undefined || next === null) ? before : next;
906
+ if (!after || typeof after !== 'object' || Array.isArray(after)) {
907
+ return { wrote: false, result: beforeSnap };
908
+ }
909
+ const afterIds = new Set(Object.keys(after));
910
+ const beforeIds = new Set(Object.keys(beforeSnap));
911
+ let wrote = false;
912
+ const now = Date.now();
913
+ const upsert = db.prepare(`
914
+ INSERT INTO pr_links (pr_id, data, updated_at)
915
+ VALUES (?, ?, ?)
916
+ ON CONFLICT(pr_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
917
+ `);
918
+ const del = db.prepare('DELETE FROM pr_links WHERE pr_id = ?');
919
+ for (const id of afterIds) {
920
+ if (!beforeIds.has(id) || JSON.stringify(beforeSnap[id]) !== JSON.stringify(after[id])) {
921
+ upsert.run(id, JSON.stringify(after[id]), now);
922
+ wrote = true;
923
+ }
924
+ }
925
+ for (const id of beforeIds) {
926
+ if (!afterIds.has(id)) { del.run(id); wrote = true; }
927
+ }
928
+ return { wrote, result: after };
929
+ });
930
+ }
931
+
932
+ function _mirrorPrLinksJson(filePath) {
933
+ try {
934
+ const shared = require('./shared');
935
+ const { getDb } = require('./db');
936
+ const obj = _readPrLinksFromSql(getDb());
937
+ const target = filePath || _resolveFilePath('pr-links.json');
938
+ shared.safeWrite(target, obj);
939
+ const h = _fileContentHash(target);
940
+ if (h != null) _prLinksHash = h;
941
+ } catch { /* mirror best-effort */ }
942
+ }
943
+
944
+ // ─── cooldowns ─────────────────────────────────────────────────────────────
945
+ // Shape: { [key]: { timestamp, failures, ... } }
946
+ // SQL: row per key.
947
+
948
+ let _cooldownsHash = null;
949
+
950
+ function _hydrateCooldowns(db) {
951
+ const fp = _resolveFilePath('cooldowns.json');
952
+ const raw = _readJson(fp) || {};
953
+ db.prepare('DELETE FROM cooldowns').run();
954
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
955
+ const now = Date.now();
956
+ const ins = db.prepare('INSERT INTO cooldowns (key, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO NOTHING');
957
+ for (const [key, value] of Object.entries(raw)) {
958
+ ins.run(String(key), JSON.stringify(value), now);
959
+ }
960
+ }
961
+
962
+ function _resyncCooldownsIfDiverged(db) {
963
+ const fp = _resolveFilePath('cooldowns.json');
964
+ const currentHash = _fileContentHash(fp);
965
+ if (currentHash == null) return;
966
+ if (_cooldownsHash != null && currentHash === _cooldownsHash) return;
967
+ if (_cooldownsHash == null) {
968
+ const sqlHas = db.prepare('SELECT 1 FROM cooldowns LIMIT 1').get();
969
+ if (sqlHas) { _cooldownsHash = currentHash; return; }
970
+ }
971
+ _hydrateCooldowns(db);
972
+ _cooldownsHash = currentHash;
973
+ }
974
+
975
+ function _readCooldownsFromSql(db) {
976
+ const rows = db.prepare('SELECT key, data FROM cooldowns').all();
977
+ const out = {};
978
+ for (const row of rows) {
979
+ try { out[row.key] = JSON.parse(row.data); }
980
+ catch { /* skip malformed */ }
981
+ }
982
+ return out;
983
+ }
984
+
985
+ function readCooldowns() {
986
+ const { getDb } = require('./db');
987
+ let db;
988
+ try { db = getDb(); }
989
+ catch { return _readJson(_resolveFilePath('cooldowns.json')) || {}; }
990
+ try { _resyncCooldownsIfDiverged(db); }
991
+ catch { /* table may be missing on stale install */ }
992
+ let out;
993
+ try { out = _readCooldownsFromSql(db); }
994
+ catch { return _readJson(_resolveFilePath('cooldowns.json')) || {}; }
995
+ if (Object.keys(out).length === 0) {
996
+ const fallback = _readJson(_resolveFilePath('cooldowns.json'));
997
+ if (fallback && Object.keys(fallback).length > 0) return fallback;
998
+ return {};
999
+ }
1000
+ return out;
1001
+ }
1002
+
1003
+ function applyCooldownsMutation(mutator) {
1004
+ const { getDb, withTransaction } = require('./db');
1005
+ let db;
1006
+ try { db = getDb(); }
1007
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
1008
+
1009
+ return withTransaction(db, () => {
1010
+ _resyncCooldownsIfDiverged(db);
1011
+ const before = _readCooldownsFromSql(db);
1012
+ const beforeSnap = JSON.parse(JSON.stringify(before));
1013
+ const next = mutator(before);
1014
+ const after = (next === undefined || next === null) ? before : next;
1015
+ if (!after || typeof after !== 'object' || Array.isArray(after)) {
1016
+ return { wrote: false, result: beforeSnap };
1017
+ }
1018
+ const afterIds = new Set(Object.keys(after));
1019
+ const beforeIds = new Set(Object.keys(beforeSnap));
1020
+ let wrote = false;
1021
+ const now = Date.now();
1022
+ const upsert = db.prepare(`
1023
+ INSERT INTO cooldowns (key, data, updated_at)
1024
+ VALUES (?, ?, ?)
1025
+ ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
1026
+ `);
1027
+ const del = db.prepare('DELETE FROM cooldowns WHERE key = ?');
1028
+ for (const id of afterIds) {
1029
+ if (!beforeIds.has(id) || JSON.stringify(beforeSnap[id]) !== JSON.stringify(after[id])) {
1030
+ upsert.run(id, JSON.stringify(after[id]), now);
1031
+ wrote = true;
1032
+ }
1033
+ }
1034
+ for (const id of beforeIds) {
1035
+ if (!afterIds.has(id)) { del.run(id); wrote = true; }
1036
+ }
1037
+ return { wrote, result: after };
1038
+ });
1039
+ }
1040
+
1041
+ function _mirrorCooldownsJson(filePath) {
1042
+ try {
1043
+ const shared = require('./shared');
1044
+ const { getDb } = require('./db');
1045
+ const obj = _readCooldownsFromSql(getDb());
1046
+ const target = filePath || _resolveFilePath('cooldowns.json');
1047
+ shared.safeWrite(target, obj);
1048
+ const h = _fileContentHash(target);
1049
+ if (h != null) _cooldownsHash = h;
1050
+ } catch { /* mirror best-effort */ }
1051
+ }
1052
+
1053
+ // ─── pending_rebases ───────────────────────────────────────────────────────
1054
+ // Shape: [ { prId, branch, projectName, mergedItemId, queuedAt, attempts, ... }, ... ]
1055
+ // SQL: row per array entry (rowid auto-increment preserves insertion order).
1056
+ // No stable natural key, so mutations are full clear+insert.
1057
+
1058
+ let _pendingRebasesHash = null;
1059
+
1060
+ function _hydratePendingRebases(db) {
1061
+ const fp = _resolveFilePath('pending-rebases.json');
1062
+ const raw = _readJson(fp) || [];
1063
+ db.prepare('DELETE FROM pending_rebases').run();
1064
+ if (!Array.isArray(raw)) return;
1065
+ const now = Date.now();
1066
+ const ins = db.prepare('INSERT INTO pending_rebases (data, updated_at) VALUES (?, ?)');
1067
+ for (const entry of raw) {
1068
+ if (!entry || typeof entry !== 'object') continue;
1069
+ ins.run(JSON.stringify(entry), now);
1070
+ }
1071
+ }
1072
+
1073
+ function _resyncPendingRebasesIfDiverged(db) {
1074
+ const fp = _resolveFilePath('pending-rebases.json');
1075
+ const currentHash = _fileContentHash(fp);
1076
+ if (currentHash == null) return;
1077
+ if (_pendingRebasesHash != null && currentHash === _pendingRebasesHash) return;
1078
+ if (_pendingRebasesHash == null) {
1079
+ const sqlHas = db.prepare('SELECT 1 FROM pending_rebases LIMIT 1').get();
1080
+ if (sqlHas) { _pendingRebasesHash = currentHash; return; }
1081
+ }
1082
+ _hydratePendingRebases(db);
1083
+ _pendingRebasesHash = currentHash;
1084
+ }
1085
+
1086
+ function _readPendingRebasesFromSql(db) {
1087
+ const rows = db.prepare('SELECT data FROM pending_rebases ORDER BY seq').all();
1088
+ const out = [];
1089
+ for (const row of rows) {
1090
+ try { out.push(JSON.parse(row.data)); }
1091
+ catch { /* skip malformed */ }
1092
+ }
1093
+ return out;
1094
+ }
1095
+
1096
+ function readPendingRebases() {
1097
+ const { getDb } = require('./db');
1098
+ let db;
1099
+ try { db = getDb(); }
1100
+ catch { return _readJson(_resolveFilePath('pending-rebases.json')) || []; }
1101
+ try { _resyncPendingRebasesIfDiverged(db); }
1102
+ catch { /* table missing */ }
1103
+ let out;
1104
+ try { out = _readPendingRebasesFromSql(db); }
1105
+ catch { return _readJson(_resolveFilePath('pending-rebases.json')) || []; }
1106
+ if (out.length === 0) {
1107
+ const fallback = _readJson(_resolveFilePath('pending-rebases.json'));
1108
+ if (Array.isArray(fallback) && fallback.length > 0) return fallback;
1109
+ return [];
1110
+ }
1111
+ return out;
1112
+ }
1113
+
1114
+ function applyPendingRebasesMutation(mutator) {
1115
+ const { getDb, withTransaction } = require('./db');
1116
+ let db;
1117
+ try { db = getDb(); }
1118
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
1119
+
1120
+ return withTransaction(db, () => {
1121
+ _resyncPendingRebasesIfDiverged(db);
1122
+ const before = _readPendingRebasesFromSql(db);
1123
+ const beforeSnap = JSON.parse(JSON.stringify(before));
1124
+ const next = mutator(before);
1125
+ const after = (next === undefined || next === null) ? before : next;
1126
+ if (!Array.isArray(after)) {
1127
+ return { wrote: false, result: beforeSnap };
1128
+ }
1129
+ let wrote = false;
1130
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
1131
+ const now = Date.now();
1132
+ db.prepare('DELETE FROM pending_rebases').run();
1133
+ const ins = db.prepare('INSERT INTO pending_rebases (data, updated_at) VALUES (?, ?)');
1134
+ for (const entry of after) {
1135
+ if (!entry || typeof entry !== 'object') continue;
1136
+ ins.run(JSON.stringify(entry), now);
1137
+ }
1138
+ wrote = true;
1139
+ }
1140
+ return { wrote, result: after };
1141
+ });
1142
+ }
1143
+
1144
+ function _mirrorPendingRebasesJson(filePath) {
1145
+ try {
1146
+ const shared = require('./shared');
1147
+ const { getDb } = require('./db');
1148
+ const arr = _readPendingRebasesFromSql(getDb());
1149
+ const target = filePath || _resolveFilePath('pending-rebases.json');
1150
+ shared.safeWrite(target, arr);
1151
+ const h = _fileContentHash(target);
1152
+ if (h != null) _pendingRebasesHash = h;
1153
+ } catch { /* mirror best-effort */ }
1154
+ }
1155
+
1156
+ // ─── cc_sessions ───────────────────────────────────────────────────────────
1157
+ // Shape: [ { id, sessionId, title, _promptHash, lastActiveAt, ... }, ... ]
1158
+ // SQL: row per id (each entry has a unique `id` tab key).
1159
+
1160
+ let _ccSessionsHash = null;
1161
+
1162
+ function _hydrateCcSessions(db) {
1163
+ const fp = _resolveFilePath('cc-sessions.json');
1164
+ const raw = _readJson(fp) || [];
1165
+ db.prepare('DELETE FROM cc_sessions').run();
1166
+ if (!Array.isArray(raw)) return;
1167
+ const now = Date.now();
1168
+ const ins = db.prepare('INSERT INTO cc_sessions (id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING');
1169
+ for (const entry of raw) {
1170
+ if (!entry || typeof entry !== 'object' || !entry.id) continue;
1171
+ ins.run(String(entry.id), JSON.stringify(entry), now);
1172
+ }
1173
+ }
1174
+
1175
+ function _resyncCcSessionsIfDiverged(db) {
1176
+ const fp = _resolveFilePath('cc-sessions.json');
1177
+ const currentHash = _fileContentHash(fp);
1178
+ if (currentHash == null) return;
1179
+ if (_ccSessionsHash != null && currentHash === _ccSessionsHash) return;
1180
+ if (_ccSessionsHash == null) {
1181
+ const sqlHas = db.prepare('SELECT 1 FROM cc_sessions LIMIT 1').get();
1182
+ if (sqlHas) { _ccSessionsHash = currentHash; return; }
1183
+ }
1184
+ _hydrateCcSessions(db);
1185
+ _ccSessionsHash = currentHash;
1186
+ }
1187
+
1188
+ function _readCcSessionsFromSql(db) {
1189
+ const rows = db.prepare('SELECT data FROM cc_sessions ORDER BY updated_at, id').all();
1190
+ const out = [];
1191
+ for (const row of rows) {
1192
+ try { out.push(JSON.parse(row.data)); }
1193
+ catch { /* skip malformed */ }
1194
+ }
1195
+ return out;
1196
+ }
1197
+
1198
+ function readCcSessions() {
1199
+ const { getDb } = require('./db');
1200
+ let db;
1201
+ try { db = getDb(); }
1202
+ catch { return _readJson(_resolveFilePath('cc-sessions.json')) || []; }
1203
+ try { _resyncCcSessionsIfDiverged(db); }
1204
+ catch { /* table missing */ }
1205
+ let out;
1206
+ try { out = _readCcSessionsFromSql(db); }
1207
+ catch { return _readJson(_resolveFilePath('cc-sessions.json')) || []; }
1208
+ if (out.length === 0) {
1209
+ const fallback = _readJson(_resolveFilePath('cc-sessions.json'));
1210
+ if (Array.isArray(fallback) && fallback.length > 0) return fallback;
1211
+ return [];
1212
+ }
1213
+ return out;
1214
+ }
1215
+
1216
+ function applyCcSessionsMutation(mutator) {
1217
+ const { getDb, withTransaction } = require('./db');
1218
+ let db;
1219
+ try { db = getDb(); }
1220
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
1221
+
1222
+ return withTransaction(db, () => {
1223
+ _resyncCcSessionsIfDiverged(db);
1224
+ const before = _readCcSessionsFromSql(db);
1225
+ const beforeSnap = JSON.parse(JSON.stringify(before));
1226
+ const next = mutator(before);
1227
+ const after = (next === undefined || next === null) ? before : next;
1228
+ if (!Array.isArray(after)) {
1229
+ return { wrote: false, result: beforeSnap };
1230
+ }
1231
+ const beforeById = new Map(beforeSnap.filter(e => e && e.id).map(e => [String(e.id), e]));
1232
+ const afterById = new Map(after.filter(e => e && e.id).map(e => [String(e.id), e]));
1233
+ let wrote = false;
1234
+ const now = Date.now();
1235
+ const upsert = db.prepare(`
1236
+ INSERT INTO cc_sessions (id, data, updated_at)
1237
+ VALUES (?, ?, ?)
1238
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
1239
+ `);
1240
+ const del = db.prepare('DELETE FROM cc_sessions WHERE id = ?');
1241
+ for (const [id, entry] of afterById) {
1242
+ const prior = beforeById.get(id);
1243
+ if (!prior || JSON.stringify(prior) !== JSON.stringify(entry)) {
1244
+ upsert.run(id, JSON.stringify(entry), now);
1245
+ wrote = true;
1246
+ }
1247
+ }
1248
+ for (const id of beforeById.keys()) {
1249
+ if (!afterById.has(id)) { del.run(id); wrote = true; }
1250
+ }
1251
+ return { wrote, result: after };
1252
+ });
1253
+ }
1254
+
1255
+ function _mirrorCcSessionsJson(filePath) {
1256
+ try {
1257
+ const shared = require('./shared');
1258
+ const { getDb } = require('./db');
1259
+ const arr = _readCcSessionsFromSql(getDb());
1260
+ const target = filePath || _resolveFilePath('cc-sessions.json');
1261
+ shared.safeWrite(target, arr);
1262
+ const h = _fileContentHash(target);
1263
+ if (h != null) _ccSessionsHash = h;
1264
+ } catch { /* mirror best-effort */ }
1265
+ }
1266
+
1267
+ // ─── doc_sessions ──────────────────────────────────────────────────────────
1268
+ // Shape: { [filePath]: { sessionId, lastActiveAt, turnCount, ... } }
1269
+ // SQL: row per filePath key.
1270
+
1271
+ let _docSessionsHash = null;
1272
+
1273
+ function _hydrateDocSessions(db) {
1274
+ const fp = _resolveFilePath('doc-sessions.json');
1275
+ const raw = _readJson(fp) || {};
1276
+ db.prepare('DELETE FROM doc_sessions').run();
1277
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
1278
+ const now = Date.now();
1279
+ const ins = db.prepare('INSERT INTO doc_sessions (key, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO NOTHING');
1280
+ for (const [key, value] of Object.entries(raw)) {
1281
+ ins.run(String(key), JSON.stringify(value), now);
1282
+ }
1283
+ }
1284
+
1285
+ function _resyncDocSessionsIfDiverged(db) {
1286
+ const fp = _resolveFilePath('doc-sessions.json');
1287
+ const currentHash = _fileContentHash(fp);
1288
+ if (currentHash == null) return;
1289
+ if (_docSessionsHash != null && currentHash === _docSessionsHash) return;
1290
+ if (_docSessionsHash == null) {
1291
+ const sqlHas = db.prepare('SELECT 1 FROM doc_sessions LIMIT 1').get();
1292
+ if (sqlHas) { _docSessionsHash = currentHash; return; }
1293
+ }
1294
+ _hydrateDocSessions(db);
1295
+ _docSessionsHash = currentHash;
1296
+ }
1297
+
1298
+ function _readDocSessionsFromSql(db) {
1299
+ const rows = db.prepare('SELECT key, data FROM doc_sessions').all();
1300
+ const out = {};
1301
+ for (const row of rows) {
1302
+ try { out[row.key] = JSON.parse(row.data); }
1303
+ catch { /* skip malformed */ }
1304
+ }
1305
+ return out;
1306
+ }
1307
+
1308
+ function readDocSessions() {
1309
+ const { getDb } = require('./db');
1310
+ let db;
1311
+ try { db = getDb(); }
1312
+ catch { return _readJson(_resolveFilePath('doc-sessions.json')) || {}; }
1313
+ try { _resyncDocSessionsIfDiverged(db); }
1314
+ catch { /* table missing */ }
1315
+ let out;
1316
+ try { out = _readDocSessionsFromSql(db); }
1317
+ catch { return _readJson(_resolveFilePath('doc-sessions.json')) || {}; }
1318
+ if (Object.keys(out).length === 0) {
1319
+ const fallback = _readJson(_resolveFilePath('doc-sessions.json'));
1320
+ if (fallback && Object.keys(fallback).length > 0) return fallback;
1321
+ return {};
1322
+ }
1323
+ return out;
1324
+ }
1325
+
1326
+ function applyDocSessionsMutation(mutator) {
1327
+ const { getDb, withTransaction } = require('./db');
1328
+ let db;
1329
+ try { db = getDb(); }
1330
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
1331
+
1332
+ return withTransaction(db, () => {
1333
+ _resyncDocSessionsIfDiverged(db);
1334
+ const before = _readDocSessionsFromSql(db);
1335
+ const beforeSnap = JSON.parse(JSON.stringify(before));
1336
+ const next = mutator(before);
1337
+ const after = (next === undefined || next === null) ? before : next;
1338
+ if (!after || typeof after !== 'object' || Array.isArray(after)) {
1339
+ return { wrote: false, result: beforeSnap };
1340
+ }
1341
+ const afterIds = new Set(Object.keys(after));
1342
+ const beforeIds = new Set(Object.keys(beforeSnap));
1343
+ let wrote = false;
1344
+ const now = Date.now();
1345
+ const upsert = db.prepare(`
1346
+ INSERT INTO doc_sessions (key, data, updated_at)
1347
+ VALUES (?, ?, ?)
1348
+ ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
1349
+ `);
1350
+ const del = db.prepare('DELETE FROM doc_sessions WHERE key = ?');
1351
+ for (const id of afterIds) {
1352
+ if (!beforeIds.has(id) || JSON.stringify(beforeSnap[id]) !== JSON.stringify(after[id])) {
1353
+ upsert.run(id, JSON.stringify(after[id]), now);
1354
+ wrote = true;
1355
+ }
1356
+ }
1357
+ for (const id of beforeIds) {
1358
+ if (!afterIds.has(id)) { del.run(id); wrote = true; }
1359
+ }
1360
+ return { wrote, result: after };
1361
+ });
1362
+ }
1363
+
1364
+ function _mirrorDocSessionsJson(filePath) {
1365
+ try {
1366
+ const shared = require('./shared');
1367
+ const { getDb } = require('./db');
1368
+ const obj = _readDocSessionsFromSql(getDb());
1369
+ const target = filePath || _resolveFilePath('doc-sessions.json');
1370
+ shared.safeWrite(target, obj);
1371
+ const h = _fileContentHash(target);
1372
+ if (h != null) _docSessionsHash = h;
1373
+ } catch { /* mirror best-effort */ }
1374
+ }
1375
+
832
1376
  // ─── Test seam ─────────────────────────────────────────────────────────────
833
1377
 
834
1378
  function _resetAllForTest() {
@@ -841,6 +1385,11 @@ function _resetAllForTest() {
841
1385
  db.exec('DELETE FROM worktree_pool');
842
1386
  try { db.exec('DELETE FROM qa_runs'); } catch { /* migration not applied */ }
843
1387
  try { db.exec('DELETE FROM qa_sessions'); } catch { /* migration not applied */ }
1388
+ try { db.exec('DELETE FROM pr_links'); } catch { /* migration not applied */ }
1389
+ try { db.exec('DELETE FROM cooldowns'); } catch { /* migration not applied */ }
1390
+ try { db.exec('DELETE FROM pending_rebases'); } catch { /* migration not applied */ }
1391
+ try { db.exec('DELETE FROM cc_sessions'); } catch { /* migration not applied */ }
1392
+ try { db.exec('DELETE FROM doc_sessions'); } catch { /* migration not applied */ }
844
1393
  } catch { /* not initialized */ }
845
1394
  _scheduleRunsHash = null;
846
1395
  _pipelineRunsHash = null;
@@ -848,6 +1397,11 @@ function _resetAllForTest() {
848
1397
  _worktreePoolHash = null;
849
1398
  _qaRunsHash = null;
850
1399
  _qaSessionsHash = null;
1400
+ _prLinksHash = null;
1401
+ _cooldownsHash = null;
1402
+ _pendingRebasesHash = null;
1403
+ _ccSessionsHash = null;
1404
+ _docSessionsHash = null;
851
1405
  }
852
1406
 
853
1407
  module.exports = {
@@ -875,6 +1429,26 @@ module.exports = {
875
1429
  readQaSessions,
876
1430
  applyQaSessionsMutation,
877
1431
  _mirrorQaSessionsJson,
1432
+ // pr_links
1433
+ readPrLinks,
1434
+ applyPrLinksMutation,
1435
+ _mirrorPrLinksJson,
1436
+ // cooldowns
1437
+ readCooldowns,
1438
+ applyCooldownsMutation,
1439
+ _mirrorCooldownsJson,
1440
+ // pending_rebases
1441
+ readPendingRebases,
1442
+ applyPendingRebasesMutation,
1443
+ _mirrorPendingRebasesJson,
1444
+ // cc_sessions
1445
+ readCcSessions,
1446
+ applyCcSessionsMutation,
1447
+ _mirrorCcSessionsJson,
1448
+ // doc_sessions
1449
+ readDocSessions,
1450
+ applyDocSessionsMutation,
1451
+ _mirrorDocSessionsJson,
878
1452
  // test seam
879
1453
  _resetAllForTest,
880
1454
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2114",
3
+ "version": "0.1.2116",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"