@yemi33/minions 0.1.2115 → 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 +21 -0
- package/engine/cleanup.js +10 -3
- package/engine/consolidation.js +1 -1
- package/engine/db/migrate.js +11 -4
- package/engine/db/migrations/011-remaining-state.js +116 -0
- package/engine/kb-sweep.js +20 -14
- package/engine/shared.js +117 -1
- package/engine/small-state-store.js +456 -0
- package/package.json +1 -1
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
|
-
|
|
1418
|
-
|
|
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
|
|
package/engine/consolidation.js
CHANGED
|
@@ -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
|
-
|
|
852
|
+
shared.mutateKbCheckpoint(() => ({ count, updatedAt: ts() }));
|
|
853
853
|
} catch (err) { log('warn', `KB checkpoint: ${err.message}`); }
|
|
854
854
|
}
|
|
855
855
|
|
package/engine/db/migrate.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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,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
|
+
};
|
package/engine/kb-sweep.js
CHANGED
|
@@ -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 {
|
|
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
|
|
420
|
-
// for forensics — _writeSweepState would overwrite it with this
|
|
421
|
-
// pid, destroying the diagnostic value of the record.
|
|
422
|
-
try {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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');
|
|
@@ -536,6 +546,22 @@ function _routeJsonReadToSql(p) {
|
|
|
536
546
|
const store = require('./small-state-store');
|
|
537
547
|
return { value: store.readPrLinks() };
|
|
538
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
|
+
}
|
|
539
565
|
// Per-project work-items.json — match `/projects/<name>/work-items.json`.
|
|
540
566
|
// When SQL has no rows for the scope AND the JSON file is absent on
|
|
541
567
|
// disk, preserve the legacy "file missing → null" semantic. This guards
|
|
@@ -1323,13 +1349,60 @@ function withFileLock(lockPath, fn, {
|
|
|
1323
1349
|
throw lastErr;
|
|
1324
1350
|
}
|
|
1325
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
|
+
|
|
1326
1363
|
function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
|
|
1327
1364
|
const baseName = path.basename(filePath);
|
|
1328
|
-
|
|
1365
|
+
const smallRoute = _SMALL_STATE_MUTATE_ROUTES[baseName];
|
|
1366
|
+
if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json' && !smallRoute) return null;
|
|
1329
1367
|
const fpNorm = String(filePath).replace(/\\/g, '/');
|
|
1330
1368
|
const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
|
|
1331
1369
|
const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/' + baseName;
|
|
1332
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
|
+
|
|
1333
1406
|
let result;
|
|
1334
1407
|
if (baseName === 'work-items.json') {
|
|
1335
1408
|
result = mutateWorkItems(filePath, (arr) => {
|
|
@@ -1451,6 +1524,45 @@ function mutateCooldowns(mutator) {
|
|
|
1451
1524
|
}, { defaultValue: {}, skipWriteIfUnchanged: true });
|
|
1452
1525
|
}
|
|
1453
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
|
+
|
|
1454
1566
|
let _uidCounter = 0;
|
|
1455
1567
|
|
|
1456
1568
|
/**
|
|
@@ -5975,6 +6087,10 @@ module.exports = {
|
|
|
5975
6087
|
mutateEngineState, // W-mp60tw0u000j3931
|
|
5976
6088
|
readEngineState, // W-mp60tw0u000j3931
|
|
5977
6089
|
mutateCooldowns,
|
|
6090
|
+
mutateKbCheckpoint, // Phase 9.3
|
|
6091
|
+
mutateKbSwept, // Phase 9.3
|
|
6092
|
+
mutateKbSweepState, // Phase 9.3
|
|
6093
|
+
mutateTestResults, // Phase 9.3
|
|
5978
6094
|
mutateWorkItems,
|
|
5979
6095
|
reopenWorkItem,
|
|
5980
6096
|
mutatePullRequests,
|
|
@@ -941,6 +941,438 @@ function _mirrorPrLinksJson(filePath) {
|
|
|
941
941
|
} catch { /* mirror best-effort */ }
|
|
942
942
|
}
|
|
943
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
|
+
|
|
944
1376
|
// ─── Test seam ─────────────────────────────────────────────────────────────
|
|
945
1377
|
|
|
946
1378
|
function _resetAllForTest() {
|
|
@@ -954,6 +1386,10 @@ function _resetAllForTest() {
|
|
|
954
1386
|
try { db.exec('DELETE FROM qa_runs'); } catch { /* migration not applied */ }
|
|
955
1387
|
try { db.exec('DELETE FROM qa_sessions'); } catch { /* migration not applied */ }
|
|
956
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 */ }
|
|
957
1393
|
} catch { /* not initialized */ }
|
|
958
1394
|
_scheduleRunsHash = null;
|
|
959
1395
|
_pipelineRunsHash = null;
|
|
@@ -962,6 +1398,10 @@ function _resetAllForTest() {
|
|
|
962
1398
|
_qaRunsHash = null;
|
|
963
1399
|
_qaSessionsHash = null;
|
|
964
1400
|
_prLinksHash = null;
|
|
1401
|
+
_cooldownsHash = null;
|
|
1402
|
+
_pendingRebasesHash = null;
|
|
1403
|
+
_ccSessionsHash = null;
|
|
1404
|
+
_docSessionsHash = null;
|
|
965
1405
|
}
|
|
966
1406
|
|
|
967
1407
|
module.exports = {
|
|
@@ -993,6 +1433,22 @@ module.exports = {
|
|
|
993
1433
|
readPrLinks,
|
|
994
1434
|
applyPrLinksMutation,
|
|
995
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,
|
|
996
1452
|
// test seam
|
|
997
1453
|
_resetAllForTest,
|
|
998
1454
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
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"
|