@yemi33/minions 0.1.2111 → 0.1.2112
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/engine/dispatch.js +4 -5
- package/engine/projects.js +5 -0
- package/engine/pull-requests-store.js +9 -0
- package/engine/queries.js +36 -149
- package/engine/shared.js +178 -25
- package/engine/work-items-store.js +19 -0
- package/package.json +1 -1
package/engine/dispatch.js
CHANGED
|
@@ -90,11 +90,10 @@ function mutateDispatch(mutator) {
|
|
|
90
90
|
if (wrote) {
|
|
91
91
|
try { require('./queries').invalidateDispatchCache(); } catch {}
|
|
92
92
|
try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// every successful mutation, never independently mutated.
|
|
93
|
+
// Mirror back to dispatch.json for tests + tools that fs.readFileSync
|
|
94
|
+
// the file directly. SQL is the source of truth; the JSON file is
|
|
95
|
+
// regenerated from SQL on every successful mutation, never independently
|
|
96
|
+
// mutated. Cheap to delete once those direct-JSON readers are gone.
|
|
98
97
|
try { store._mirrorJsonFromSql(); } catch { /* best-effort */ }
|
|
99
98
|
}
|
|
100
99
|
return result;
|
package/engine/projects.js
CHANGED
|
@@ -420,6 +420,11 @@ function removeProject(target, options = {}) {
|
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
// 9. Drop SQL rows for the project's scope so safeJson shim reads no longer
|
|
424
|
+
// surface the removed project after the JSON file is archived.
|
|
425
|
+
try { require('./work-items-store').dropScope(project.name); } catch { /* best-effort */ }
|
|
426
|
+
try { require('./pull-requests-store').dropScope(project.name); } catch { /* best-effort */ }
|
|
427
|
+
|
|
423
428
|
summary.ok = true;
|
|
424
429
|
return summary;
|
|
425
430
|
}
|
|
@@ -293,11 +293,20 @@ function _resetPullRequestsTableForTest() {
|
|
|
293
293
|
try { getDb().exec('DELETE FROM pull_requests'); } catch { /* not initialized */ }
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
function dropScope(scope) {
|
|
297
|
+
try {
|
|
298
|
+
const { getDb } = require('./db');
|
|
299
|
+
getDb().prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
|
|
300
|
+
_lastMirrorHashByScope.delete(scope);
|
|
301
|
+
} catch { /* db unavailable */ }
|
|
302
|
+
}
|
|
303
|
+
|
|
296
304
|
module.exports = {
|
|
297
305
|
scopeForFilePath,
|
|
298
306
|
readPullRequestsForScope,
|
|
299
307
|
readAllPullRequests,
|
|
300
308
|
applyPullRequestsMutation,
|
|
309
|
+
dropScope,
|
|
301
310
|
_filePathForScope,
|
|
302
311
|
_mirrorJsonFromSql,
|
|
303
312
|
_resetPullRequestsTableForTest,
|
package/engine/queries.js
CHANGED
|
@@ -181,17 +181,9 @@ function getDispatch() {
|
|
|
181
181
|
// to be when dispatch.json could be 1+ MB.
|
|
182
182
|
const now = Date.now();
|
|
183
183
|
if (_dispatchCache && (now - _dispatchCacheAt) < 2000) return _dispatchCache;
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// operators rolling back to a pre-migration release can rename it back.
|
|
188
|
-
try {
|
|
189
|
-
const store = require('./dispatch-store');
|
|
190
|
-
const sectioned = store.readDispatchSectioned();
|
|
191
|
-
_dispatchCache = sectioned;
|
|
192
|
-
} catch {
|
|
193
|
-
_dispatchCache = readJsonNoRestore(DISPATCH_PATH) || { pending: [], active: [], completed: [] };
|
|
194
|
-
}
|
|
184
|
+
// SQL is the canonical (and only) dispatch store after Phase 9.
|
|
185
|
+
const store = require('./dispatch-store');
|
|
186
|
+
_dispatchCache = store.readDispatchSectioned();
|
|
195
187
|
_dispatchCacheAt = now;
|
|
196
188
|
return _dispatchCache;
|
|
197
189
|
}
|
|
@@ -361,37 +353,15 @@ function getNotesWithMeta() {
|
|
|
361
353
|
}
|
|
362
354
|
|
|
363
355
|
function getEngineLog() {
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
// Falls back to the JSON mirror on SQL failure or when the table is
|
|
368
|
-
// empty (fresh install / pre-migration).
|
|
369
|
-
try {
|
|
370
|
-
const store = require('./logs-store');
|
|
371
|
-
const sqlEntries = store.readRecentLogsChronological(50);
|
|
372
|
-
if (Array.isArray(sqlEntries) && sqlEntries.length > 0) return sqlEntries;
|
|
373
|
-
} catch { /* fall through to JSON */ }
|
|
374
|
-
|
|
375
|
-
const logJson = safeRead(shared.currentLogPath());
|
|
376
|
-
if (!logJson) return [];
|
|
377
|
-
try {
|
|
378
|
-
const entries = JSON.parse(logJson);
|
|
379
|
-
const arr = Array.isArray(entries) ? entries : (entries.entries || []);
|
|
380
|
-
return arr.slice(-50);
|
|
381
|
-
} catch { return []; }
|
|
356
|
+
// SQL is the canonical (and only) log store after Phase 9.
|
|
357
|
+
const store = require('./logs-store');
|
|
358
|
+
return store.readRecentLogsChronological(50) || [];
|
|
382
359
|
}
|
|
383
360
|
|
|
384
361
|
function getMetrics() {
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
let metrics;
|
|
389
|
-
try {
|
|
390
|
-
const store = require('./metrics-store');
|
|
391
|
-
metrics = store.readMetrics() || {};
|
|
392
|
-
} catch {
|
|
393
|
-
metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
|
|
394
|
-
}
|
|
362
|
+
// SQL is the canonical (and only) metrics store after Phase 9.
|
|
363
|
+
const store = require('./metrics-store');
|
|
364
|
+
const metrics = store.readMetrics() || {};
|
|
395
365
|
|
|
396
366
|
for (const [agentId, m] of Object.entries(metrics)) {
|
|
397
367
|
if (agentId.startsWith('_')) continue;
|
|
@@ -704,82 +674,33 @@ function getPullRequests(config) {
|
|
|
704
674
|
const allPrs = [];
|
|
705
675
|
const seenIds = new Set();
|
|
706
676
|
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if (!
|
|
720
|
-
const scope = pr._scope;
|
|
721
|
-
delete pr._scope;
|
|
722
|
-
if (scope === 'central') {
|
|
723
|
-
pr._project = 'central';
|
|
724
|
-
} else {
|
|
725
|
-
const project = projectByName.get(scope);
|
|
726
|
-
if (!project) continue; // orphan/removed project — don't surface
|
|
727
|
-
const base = project.prUrlBase || '';
|
|
728
|
-
if (!pr.url && base) {
|
|
729
|
-
const prNumber = shared.getPrNumber(pr);
|
|
730
|
-
if (prNumber != null) pr.url = base + prNumber;
|
|
731
|
-
}
|
|
732
|
-
shared.normalizePrRecords([pr], project);
|
|
733
|
-
pr._project = project.name || 'Project';
|
|
734
|
-
}
|
|
735
|
-
// Issue #2969 — surface paused PR-fix causes on the enriched record so
|
|
736
|
-
// dashboards / API consumers can render a chip without re-parsing
|
|
737
|
-
// _noOpFixes themselves.
|
|
738
|
-
pr._pausedCauses = shared.getPrPausedCauses(pr);
|
|
739
|
-
allPrs.push(pr);
|
|
740
|
-
seenIds.add(pr.id);
|
|
741
|
-
}
|
|
742
|
-
} else {
|
|
743
|
-
// SQL empty or unavailable — fall back to per-file JSON read. Matches
|
|
744
|
-
// the original layout: per-project dirs first, central last.
|
|
745
|
-
let projectDirs = [];
|
|
746
|
-
try {
|
|
747
|
-
projectDirs = fs.readdirSync(path.join(MINIONS_DIR, 'projects'), { withFileTypes: true })
|
|
748
|
-
.filter(d => d.isDirectory() && !d.name.startsWith('.')).map(d => d.name);
|
|
749
|
-
} catch { /* projects dir missing */ }
|
|
750
|
-
for (const dirName of projectDirs) {
|
|
751
|
-
const project = projectByName.get(dirName);
|
|
752
|
-
if (!project) continue;
|
|
753
|
-
const prPath = projectPrPath(project);
|
|
754
|
-
const prs = readJsonNoRestore(prPath);
|
|
755
|
-
if (!Array.isArray(prs)) continue;
|
|
756
|
-
shared.normalizePrRecords(prs, project);
|
|
677
|
+
// SQL is the canonical (and only) PR store after Phase 9.
|
|
678
|
+
const store = require('./pull-requests-store');
|
|
679
|
+
const sqlPrs = store.readAllPullRequests() || [];
|
|
680
|
+
|
|
681
|
+
for (const pr of sqlPrs) {
|
|
682
|
+
if (!pr?.id || seenIds.has(pr.id)) continue;
|
|
683
|
+
const scope = pr._scope;
|
|
684
|
+
delete pr._scope;
|
|
685
|
+
if (scope === 'central') {
|
|
686
|
+
pr._project = 'central';
|
|
687
|
+
} else {
|
|
688
|
+
const project = projectByName.get(scope);
|
|
689
|
+
if (!project) continue; // orphan/removed project — don't surface
|
|
757
690
|
const base = project.prUrlBase || '';
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
if (
|
|
761
|
-
const prNumber = shared.getPrNumber(pr);
|
|
762
|
-
if (prNumber != null) pr.url = base + prNumber;
|
|
763
|
-
}
|
|
764
|
-
pr._project = project.name || 'Project';
|
|
765
|
-
// Issue #2969 — surface paused PR-fix causes (see SQL path above).
|
|
766
|
-
pr._pausedCauses = shared.getPrPausedCauses(pr);
|
|
767
|
-
allPrs.push(pr);
|
|
768
|
-
seenIds.add(pr.id);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
const centralPrs = readJsonNoRestore(path.join(MINIONS_DIR, 'pull-requests.json'));
|
|
772
|
-
if (centralPrs) {
|
|
773
|
-
shared.normalizePrRecords(centralPrs, null);
|
|
774
|
-
for (const pr of centralPrs) {
|
|
775
|
-
if (!pr?.id || seenIds.has(pr.id)) continue;
|
|
776
|
-
pr._project = 'central';
|
|
777
|
-
// Issue #2969 — surface paused PR-fix causes (see SQL path above).
|
|
778
|
-
pr._pausedCauses = shared.getPrPausedCauses(pr);
|
|
779
|
-
allPrs.push(pr);
|
|
780
|
-
seenIds.add(pr.id);
|
|
691
|
+
if (!pr.url && base) {
|
|
692
|
+
const prNumber = shared.getPrNumber(pr);
|
|
693
|
+
if (prNumber != null) pr.url = base + prNumber;
|
|
781
694
|
}
|
|
695
|
+
shared.normalizePrRecords([pr], project);
|
|
696
|
+
pr._project = project.name || 'Project';
|
|
782
697
|
}
|
|
698
|
+
// Issue #2969 — surface paused PR-fix causes on the enriched record so
|
|
699
|
+
// dashboards / API consumers can render a chip without re-parsing
|
|
700
|
+
// _noOpFixes themselves.
|
|
701
|
+
pr._pausedCauses = shared.getPrPausedCauses(pr);
|
|
702
|
+
allPrs.push(pr);
|
|
703
|
+
seenIds.add(pr.id);
|
|
783
704
|
}
|
|
784
705
|
allPrs.sort((a, b) => {
|
|
785
706
|
// W-mpej044m00076d63: sort by the full ISO `created` timestamp DESC so
|
|
@@ -1352,43 +1273,9 @@ function getWorkItems(config) {
|
|
|
1352
1273
|
const projects = getProjects(config);
|
|
1353
1274
|
let allItems = [];
|
|
1354
1275
|
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
// fresh install + a node:sqlite-broken environment both keep working.
|
|
1359
|
-
try {
|
|
1360
|
-
const store = require('./work-items-store');
|
|
1361
|
-
const sqlItems = store.readAllWorkItems();
|
|
1362
|
-
if (Array.isArray(sqlItems) && sqlItems.length > 0) {
|
|
1363
|
-
allItems = sqlItems;
|
|
1364
|
-
}
|
|
1365
|
-
} catch { /* fall through to JSON */ }
|
|
1366
|
-
|
|
1367
|
-
if (allItems.length === 0) {
|
|
1368
|
-
// Central work items
|
|
1369
|
-
const centralData = safeRead(path.join(MINIONS_DIR, 'work-items.json'));
|
|
1370
|
-
if (centralData) {
|
|
1371
|
-
try {
|
|
1372
|
-
for (const item of JSON.parse(centralData)) {
|
|
1373
|
-
item._source = 'central';
|
|
1374
|
-
allItems.push(item);
|
|
1375
|
-
}
|
|
1376
|
-
} catch {}
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
// Per-project work items
|
|
1380
|
-
for (const project of projects) {
|
|
1381
|
-
const data = safeRead(projectWorkItemsPath(project));
|
|
1382
|
-
if (data) {
|
|
1383
|
-
try {
|
|
1384
|
-
for (const item of JSON.parse(data)) {
|
|
1385
|
-
item._source = project.name || 'project';
|
|
1386
|
-
allItems.push(item);
|
|
1387
|
-
}
|
|
1388
|
-
} catch {}
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1276
|
+
// SQL is the canonical (and only) work-items store after Phase 9.
|
|
1277
|
+
const store = require('./work-items-store');
|
|
1278
|
+
allItems = store.readAllWorkItems() || [];
|
|
1392
1279
|
|
|
1393
1280
|
// Cross-reference with dispatch (fill in agent from active dispatch if missing on work item)
|
|
1394
1281
|
const dispatch = getDispatch();
|
package/engine/shared.js
CHANGED
|
@@ -467,6 +467,107 @@ function safeReadDir(dir) {
|
|
|
467
467
|
try { return fs.readdirSync(dir); } catch { return []; }
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
+
// ── SQL-routing shim for migrated state files ──────────────────────────────
|
|
471
|
+
//
|
|
472
|
+
// Phase 9 (post-Phase 8 cleanup): every state file that has a SQL backing
|
|
473
|
+
// (dispatches, work-items, pull-requests, metrics, watches, schedule-runs,
|
|
474
|
+
// pipeline-runs, managed-processes, worktree-pool, qa-runs, qa-sessions)
|
|
475
|
+
// becomes SQL-only at the read API surface. The JSON sidecar files stop
|
|
476
|
+
// being mirror-written; this shim makes existing `safeJson*` callers
|
|
477
|
+
// transparently read from SQL so we don't have to convert ~40 call sites
|
|
478
|
+
// in engine.js / dashboard.js / cleanup.js / etc.
|
|
479
|
+
//
|
|
480
|
+
// Matching is by path suffix rather than full-prefix against MINIONS_DIR,
|
|
481
|
+
// because tests using MINIONS_TEST_DIR re-resolve the dir but use the same
|
|
482
|
+
// suffix pattern. Returns `{value}` on hit, `null` on miss; callers fall
|
|
483
|
+
// through to the legacy disk read.
|
|
484
|
+
//
|
|
485
|
+
// Per-project work-items / pull-requests need scope extraction from the
|
|
486
|
+
// path. `<MINIONS_DIR>/projects/<name>/work-items.json` → scope `<name>`;
|
|
487
|
+
// `<MINIONS_DIR>/work-items.json` → scope `central`.
|
|
488
|
+
function _routeJsonReadToSql(p) {
|
|
489
|
+
if (!p || typeof p !== 'string') return null;
|
|
490
|
+
const norm = p.replace(/\\/g, '/');
|
|
491
|
+
// Only route reads from paths inside MINIONS_DIR. Tests that use
|
|
492
|
+
// createTmpDir() write to ad-hoc temp paths that shouldn't be hijacked
|
|
493
|
+
// by the SQL stores (the path-suffix patterns below would otherwise
|
|
494
|
+
// match e.g. `<tmp>/pull-requests.json` and return empty SQL data).
|
|
495
|
+
const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
|
|
496
|
+
if (!norm.startsWith(minionsNorm + '/') && norm !== minionsNorm) return null;
|
|
497
|
+
let m;
|
|
498
|
+
try {
|
|
499
|
+
if (norm.endsWith('/engine/dispatch.json')) {
|
|
500
|
+
const store = require('./dispatch-store');
|
|
501
|
+
return { value: store.readDispatchSectioned() };
|
|
502
|
+
}
|
|
503
|
+
if (norm.endsWith('/engine/metrics.json')) {
|
|
504
|
+
const store = require('./metrics-store');
|
|
505
|
+
return { value: store.readMetrics() };
|
|
506
|
+
}
|
|
507
|
+
if (norm.endsWith('/engine/watches.json')) {
|
|
508
|
+
const store = require('./watches-store');
|
|
509
|
+
return { value: store.readWatches() };
|
|
510
|
+
}
|
|
511
|
+
if (norm.endsWith('/engine/schedule-runs.json')) {
|
|
512
|
+
const store = require('./small-state-store');
|
|
513
|
+
return { value: store.readScheduleRuns() };
|
|
514
|
+
}
|
|
515
|
+
if (norm.endsWith('/engine/pipeline-runs.json')) {
|
|
516
|
+
const store = require('./small-state-store');
|
|
517
|
+
return { value: store.readPipelineRuns() };
|
|
518
|
+
}
|
|
519
|
+
if (norm.endsWith('/engine/managed-processes.json')) {
|
|
520
|
+
const store = require('./small-state-store');
|
|
521
|
+
return { value: store.readManagedProcesses() };
|
|
522
|
+
}
|
|
523
|
+
if (norm.endsWith('/engine/worktree-pool.json')) {
|
|
524
|
+
const store = require('./small-state-store');
|
|
525
|
+
return { value: store.readWorktreePool() };
|
|
526
|
+
}
|
|
527
|
+
if (norm.endsWith('/engine/qa-runs.json')) {
|
|
528
|
+
const store = require('./small-state-store');
|
|
529
|
+
return { value: store.readQaRuns() };
|
|
530
|
+
}
|
|
531
|
+
if (norm.endsWith('/engine/qa-sessions.json')) {
|
|
532
|
+
const store = require('./small-state-store');
|
|
533
|
+
return { value: store.readQaSessions() };
|
|
534
|
+
}
|
|
535
|
+
// Per-project work-items.json — match `/projects/<name>/work-items.json`.
|
|
536
|
+
// When SQL has no rows for the scope AND the JSON file is absent on
|
|
537
|
+
// disk, preserve the legacy "file missing → null" semantic. This guards
|
|
538
|
+
// the resurrection-bug regression (engine-helper-coverage.test.js):
|
|
539
|
+
// safeJson(path-to-removed-project) must return null, not `[]`.
|
|
540
|
+
if ((m = norm.match(/\/projects\/([^/]+)\/work-items\.json$/))) {
|
|
541
|
+
const store = require('./work-items-store');
|
|
542
|
+
const v = store.readWorkItemsForScope(m[1]);
|
|
543
|
+
if ((!Array.isArray(v) || v.length === 0) && !fs.existsSync(p)) return null;
|
|
544
|
+
return { value: v };
|
|
545
|
+
}
|
|
546
|
+
// Per-project pull-requests.json
|
|
547
|
+
if ((m = norm.match(/\/projects\/([^/]+)\/pull-requests\.json$/))) {
|
|
548
|
+
const store = require('./pull-requests-store');
|
|
549
|
+
const v = store.readPullRequestsForScope(m[1]);
|
|
550
|
+
if ((!Array.isArray(v) || v.length === 0) && !fs.existsSync(p)) return null;
|
|
551
|
+
return { value: v };
|
|
552
|
+
}
|
|
553
|
+
// Central work-items.json — `<MINIONS_DIR>/work-items.json`. Must not
|
|
554
|
+
// collide with `/projects/<name>/work-items.json` (handled above).
|
|
555
|
+
if (/(^|\/)work-items\.json$/.test(norm) && !/\/projects\//.test(norm)) {
|
|
556
|
+
const store = require('./work-items-store');
|
|
557
|
+
return { value: store.readWorkItemsForScope('central') };
|
|
558
|
+
}
|
|
559
|
+
// Central pull-requests.json
|
|
560
|
+
if (/(^|\/)pull-requests\.json$/.test(norm) && !/\/projects\//.test(norm)) {
|
|
561
|
+
const store = require('./pull-requests-store');
|
|
562
|
+
return { value: store.readPullRequestsForScope('central') };
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
// SQLite unavailable / store load failure — fall through to disk read.
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
470
571
|
/**
|
|
471
572
|
* Read a JSON file with **automatic restore from `.backup` sidecar** on
|
|
472
573
|
* missing/corrupt primary. Intended for live, mutable state files
|
|
@@ -479,11 +580,26 @@ function safeReadDir(dir) {
|
|
|
479
580
|
* to the primary path (best-effort). This protects live state from torn
|
|
480
581
|
* writes / interrupted saves.
|
|
481
582
|
*
|
|
583
|
+
* **SQL routing (Phase 9):** when the path matches a migrated state file
|
|
584
|
+
* (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.
|
|
588
|
+
*
|
|
482
589
|
* Counterpart: `safeJsonNoRestore` for terminal artifacts and "missing == gone"
|
|
483
590
|
* reads (cooldowns, archived PRDs, ephemeral session state) where reviving a
|
|
484
591
|
* stale `.backup` is actively harmful. See its JSDoc for selection guidance.
|
|
485
592
|
*/
|
|
486
593
|
function safeJson(p) {
|
|
594
|
+
// Internal opt-out (positional second arg from mutateJsonFileLocked):
|
|
595
|
+
// when truthy, skip the SQL-routing shim and do a raw disk read. Used
|
|
596
|
+
// when the caller already holds the file lock and needs the on-disk
|
|
597
|
+
// bytes as the source of truth (so the read+write pair is atomic).
|
|
598
|
+
const skipSqlRouting = arguments.length > 1 && arguments[1] && arguments[1].skipSqlRouting;
|
|
599
|
+
if (!skipSqlRouting) {
|
|
600
|
+
const routed = _routeJsonReadToSql(p);
|
|
601
|
+
if (routed) return routed.value;
|
|
602
|
+
}
|
|
487
603
|
// Split the read from the parse so we can distinguish "file missing" (normal
|
|
488
604
|
// pre-create state — silent) from "file present but corrupt JSON" (real
|
|
489
605
|
// integrity failure — must log). Without this split a `JSON.parse(read)` in
|
|
@@ -1203,19 +1319,68 @@ function withFileLock(lockPath, fn, {
|
|
|
1203
1319
|
throw lastErr;
|
|
1204
1320
|
}
|
|
1205
1321
|
|
|
1322
|
+
function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
|
|
1323
|
+
const baseName = path.basename(filePath);
|
|
1324
|
+
if (baseName !== 'work-items.json' && baseName !== 'pull-requests.json') return null;
|
|
1325
|
+
const fpNorm = String(filePath).replace(/\\/g, '/');
|
|
1326
|
+
const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
|
|
1327
|
+
const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/' + baseName;
|
|
1328
|
+
if (!insideMinionsDir) return null;
|
|
1329
|
+
let result;
|
|
1330
|
+
if (baseName === 'work-items.json') {
|
|
1331
|
+
result = mutateWorkItems(filePath, (arr) => {
|
|
1332
|
+
const out = mutateFn(arr);
|
|
1333
|
+
if (onWrote) onWrote();
|
|
1334
|
+
return out;
|
|
1335
|
+
});
|
|
1336
|
+
} else {
|
|
1337
|
+
// pull-requests.json: preserve legacy-ID normalization the JSON path
|
|
1338
|
+
// applied before invoking the mutator (parity with mutateJsonFileLocked
|
|
1339
|
+
// body at the pull-requests.json normalization site).
|
|
1340
|
+
const project = resolveProjectForPrPath(filePath);
|
|
1341
|
+
result = mutatePullRequests(filePath, (arr) => {
|
|
1342
|
+
if (Array.isArray(arr)) normalizePrRecords(arr, project);
|
|
1343
|
+
const out = mutateFn(arr);
|
|
1344
|
+
if (onWrote) onWrote();
|
|
1345
|
+
return out;
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
// SQL-store mirror is gated on `wrote`; an empty-no-op mutator leaves the
|
|
1349
|
+
// JSON file uncreated. The JSON path always wrote (including for empty
|
|
1350
|
+
// arrays via ensureProjectStateFiles), so guarantee a JSON file exists.
|
|
1351
|
+
if (!fs.existsSync(filePath)) {
|
|
1352
|
+
try {
|
|
1353
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1354
|
+
safeWrite(filePath, Array.isArray(result) ? result : []);
|
|
1355
|
+
} catch { /* best-effort */ }
|
|
1356
|
+
}
|
|
1357
|
+
return { routed: result };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1206
1360
|
function mutateJsonFileLocked(filePath, mutateFn, {
|
|
1207
1361
|
defaultValue = {},
|
|
1208
1362
|
lockRetries,
|
|
1209
1363
|
lockRetryBackoffMs,
|
|
1210
1364
|
skipWriteIfUnchanged = false,
|
|
1211
1365
|
onWrote = null,
|
|
1366
|
+
_skipSqlRouting = false,
|
|
1212
1367
|
} = {}) {
|
|
1213
1368
|
const lockPath = `${filePath}.lock`;
|
|
1214
1369
|
const retries = lockRetries ?? ENGINE_DEFAULTS.lockRetries;
|
|
1215
1370
|
const retryBackoffMs = lockRetryBackoffMs ?? ENGINE_DEFAULTS.lockRetryBackoffMs;
|
|
1371
|
+
// Phase 9 SQL-routing: when targeting work-items.json or pull-requests.json
|
|
1372
|
+
// under MINIONS_DIR, delegate to the SQL-store wrappers so SQL stays
|
|
1373
|
+
// canonical. Without this, JSON-only edits leak past the SQL mirror and
|
|
1374
|
+
// divergence-detection short-circuits leave SQL readers stale.
|
|
1375
|
+
// `_skipSqlRouting: true` is the internal opt-out used by the SQL store's
|
|
1376
|
+
// own SQLite-failure fallback to avoid recursion.
|
|
1377
|
+
if (!_skipSqlRouting) {
|
|
1378
|
+
const routed = _tryRouteMutateToSql(filePath, mutateFn, onWrote);
|
|
1379
|
+
if (routed) return routed.routed;
|
|
1380
|
+
}
|
|
1216
1381
|
return withFileLock(lockPath, () => {
|
|
1217
1382
|
const fileExists = fs.existsSync(filePath);
|
|
1218
|
-
let data = safeJson(filePath);
|
|
1383
|
+
let data = safeJson(filePath, { skipSqlRouting: true });
|
|
1219
1384
|
const parsedInvalid = fileExists && data === null;
|
|
1220
1385
|
if (data === null || typeof data !== 'object') data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
|
|
1221
1386
|
// Normalize BEFORE taking the baseline snapshot so that both `beforeSerialized`
|
|
@@ -2935,7 +3100,7 @@ function _smallStateMutator({ filePath, applyMutation, mirror, topic, defaultVal
|
|
|
2935
3100
|
const store = require('./small-state-store');
|
|
2936
3101
|
const { wrote, result } = store[applyMutation]((obj) => mutator(obj) || obj);
|
|
2937
3102
|
if (wrote) {
|
|
2938
|
-
try { store[mirror](filePath); } catch { /* mirror best-effort */ }
|
|
3103
|
+
try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
|
|
2939
3104
|
try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
|
|
2940
3105
|
}
|
|
2941
3106
|
return result;
|
|
@@ -3005,14 +3170,6 @@ function _qaDualWriteEnabled() {
|
|
|
3005
3170
|
|
|
3006
3171
|
function _qaMutator({ filePath, applyMutation, mirror, topic }) {
|
|
3007
3172
|
return (mutator) => {
|
|
3008
|
-
// Cross-process serialization: SQLite's BEGIN IMMEDIATE already
|
|
3009
|
-
// serializes the table write across processes, but the JSON sidecar
|
|
3010
|
-
// mirror is best-effort and can race — two concurrent processes can
|
|
3011
|
-
// each snapshot SQL and write the JSON, and an older snapshot can
|
|
3012
|
-
// overwrite a newer one. Wrap SQL apply + mirror in a single file
|
|
3013
|
-
// lock on the JSON path's .lock file so multi-process writers
|
|
3014
|
-
// serialize through the mirror, matching the legacy fully-locked
|
|
3015
|
-
// semantics that test/unit/qa-runs.test.js asserts.
|
|
3016
3173
|
return withFileLock(filePath + '.lock', () => {
|
|
3017
3174
|
try {
|
|
3018
3175
|
const store = require('./small-state-store');
|
|
@@ -3022,7 +3179,7 @@ function _qaMutator({ filePath, applyMutation, mirror, topic }) {
|
|
|
3022
3179
|
});
|
|
3023
3180
|
if (wrote) {
|
|
3024
3181
|
if (_qaDualWriteEnabled()) {
|
|
3025
|
-
try { store[mirror](filePath); } catch { /* mirror best-effort */ }
|
|
3182
|
+
try { if (mirror && typeof store[mirror] === 'function') store[mirror](filePath); } catch { /* mirror best-effort */ }
|
|
3026
3183
|
}
|
|
3027
3184
|
try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
|
|
3028
3185
|
}
|
|
@@ -3081,7 +3238,6 @@ function mutateWatches(mutator) {
|
|
|
3081
3238
|
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
3082
3239
|
throw e;
|
|
3083
3240
|
}
|
|
3084
|
-
// SQLite unavailable — fall through to legacy JSON path.
|
|
3085
3241
|
}
|
|
3086
3242
|
return mutateJsonFileLocked(watchesPath, (data) => {
|
|
3087
3243
|
if (!Array.isArray(data)) data = [];
|
|
@@ -3120,7 +3276,6 @@ function mutateMetrics(mutator) {
|
|
|
3120
3276
|
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
3121
3277
|
throw e;
|
|
3122
3278
|
}
|
|
3123
|
-
// SQLite unavailable — fall through to legacy JSON path.
|
|
3124
3279
|
}
|
|
3125
3280
|
return mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
3126
3281
|
if (!metrics || typeof metrics !== 'object') metrics = {};
|
|
@@ -5292,8 +5447,9 @@ function listProcessReachable(rootPids, allProcesses = null) {
|
|
|
5292
5447
|
* @param {Function} mutator - Receives the array, mutates in place or returns new value
|
|
5293
5448
|
*/
|
|
5294
5449
|
function mutateWorkItems(filePath, mutator) {
|
|
5295
|
-
// Phase 2 SQL path. Route through work-items-store so
|
|
5296
|
-
//
|
|
5450
|
+
// Phase 2 SQL path → Phase 9 SQL-only. Route through work-items-store so
|
|
5451
|
+
// SQL is the canonical (and only) store; the legacy JSON mirror was
|
|
5452
|
+
// retired in Phase 9.
|
|
5297
5453
|
//
|
|
5298
5454
|
// The SQL store identifies records by `scope` (central or project name)
|
|
5299
5455
|
// derived from the file path's last two segments. Ad-hoc file paths
|
|
@@ -5316,7 +5472,7 @@ function mutateWorkItems(filePath, mutator) {
|
|
|
5316
5472
|
return mutator(items) || items;
|
|
5317
5473
|
});
|
|
5318
5474
|
if (wrote) {
|
|
5319
|
-
try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror
|
|
5475
|
+
try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
|
|
5320
5476
|
try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
|
|
5321
5477
|
}
|
|
5322
5478
|
try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
|
|
@@ -5335,6 +5491,7 @@ function mutateWorkItems(filePath, mutator) {
|
|
|
5335
5491
|
}, {
|
|
5336
5492
|
defaultValue: [],
|
|
5337
5493
|
skipWriteIfUnchanged: true,
|
|
5494
|
+
_skipSqlRouting: true,
|
|
5338
5495
|
onWrote: () => {
|
|
5339
5496
|
try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
|
|
5340
5497
|
},
|
|
@@ -5363,11 +5520,9 @@ function reopenWorkItem(wi) {
|
|
|
5363
5520
|
* @param {Function} mutator - Receives the array, mutates in place or returns new value
|
|
5364
5521
|
*/
|
|
5365
5522
|
function mutatePullRequests(filePath, mutator) {
|
|
5366
|
-
// Phase 3 SQL path
|
|
5367
|
-
//
|
|
5368
|
-
//
|
|
5369
|
-
// event only on real writes. Ad-hoc tmp paths (legacy tests using
|
|
5370
|
-
// createTmpDir) and SQLite failures fall through to the JSON path.
|
|
5523
|
+
// Phase 3 SQL path → Phase 9 SQL-only. Route through pull-requests-store
|
|
5524
|
+
// when filePath sits under MINIONS_DIR. Ad-hoc tmp paths (legacy tests
|
|
5525
|
+
// using createTmpDir) and SQLite failures fall through to the JSON path.
|
|
5371
5526
|
const fpNorm = String(filePath).replace(/\\/g, '/');
|
|
5372
5527
|
const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
|
|
5373
5528
|
const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/pull-requests.json';
|
|
@@ -5380,7 +5535,7 @@ function mutatePullRequests(filePath, mutator) {
|
|
|
5380
5535
|
return mutator(prs) || prs;
|
|
5381
5536
|
});
|
|
5382
5537
|
if (wrote) {
|
|
5383
|
-
try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror
|
|
5538
|
+
try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
|
|
5384
5539
|
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
5385
5540
|
}
|
|
5386
5541
|
return result;
|
|
@@ -5398,9 +5553,7 @@ function mutatePullRequests(filePath, mutator) {
|
|
|
5398
5553
|
}, {
|
|
5399
5554
|
defaultValue: [],
|
|
5400
5555
|
skipWriteIfUnchanged: true,
|
|
5401
|
-
|
|
5402
|
-
// short-circuit no-op mutations; suppress the event in that case so the
|
|
5403
|
-
// dashboard cache-version doesn't bump for nothing.
|
|
5556
|
+
_skipSqlRouting: true,
|
|
5404
5557
|
onWrote: () => {
|
|
5405
5558
|
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
5406
5559
|
},
|
|
@@ -127,6 +127,13 @@ function _resyncScopeIfJsonDiverged(db, scope) {
|
|
|
127
127
|
return;
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
if (lastHash == null) {
|
|
131
|
+
const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
|
|
132
|
+
if (sqlHas) {
|
|
133
|
+
_lastMirrorHashByScope.set(scope, currentHash);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
130
137
|
_hydrateScopeFromJson(db, scope);
|
|
131
138
|
_lastMirrorHashByScope.set(scope, currentHash);
|
|
132
139
|
}
|
|
@@ -345,11 +352,23 @@ function _resetWorkItemsTableForTest() {
|
|
|
345
352
|
try { getDb().exec('DELETE FROM work_items'); } catch { /* not initialized */ }
|
|
346
353
|
}
|
|
347
354
|
|
|
355
|
+
// Drop every SQL row for `scope` and forget its mirror-hash. Called by
|
|
356
|
+
// removeProject after the JSON file is archived so safeJson shim reads no
|
|
357
|
+
// longer surface the removed project's records.
|
|
358
|
+
function dropScope(scope) {
|
|
359
|
+
try {
|
|
360
|
+
const { getDb } = require('./db');
|
|
361
|
+
getDb().prepare('DELETE FROM work_items WHERE scope = ?').run(scope);
|
|
362
|
+
_lastMirrorHashByScope.delete(scope);
|
|
363
|
+
} catch { /* db unavailable */ }
|
|
364
|
+
}
|
|
365
|
+
|
|
348
366
|
module.exports = {
|
|
349
367
|
scopeForFilePath,
|
|
350
368
|
readWorkItemsForScope,
|
|
351
369
|
readAllWorkItems,
|
|
352
370
|
applyWorkItemsMutation,
|
|
371
|
+
dropScope,
|
|
353
372
|
_filePathForScope,
|
|
354
373
|
_mirrorJsonFromSql,
|
|
355
374
|
_resetWorkItemsTableForTest,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2112",
|
|
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"
|