@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.
@@ -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
- // Phase 1 dual-write: mirror the canonical SQL state to dispatch.json
94
- // so legacy direct-readers (engine/queries.js fallback, engine/routing.js,
95
- // test infrastructure that fs.readFileSync's the file) stay in sync.
96
- // SQL is source of truth; the JSON file is regenerated from SQL on
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;
@@ -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
- // Try SQL first (post-Phase 1). If the table doesn't exist (greenfield
185
- // install, Node < 22.5, or DB init failed) fall back to the legacy JSON
186
- // reader. The migration leaves dispatch.json.pre-sql-<ts> on disk so
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
- // Phase 4: prefer the SQL store. readRecentLogsChronological returns
365
- // entries oldest-first (same shape as the legacy `.slice(-50)`), and is
366
- // test-aware via engine/db.getDb() which re-resolves MINIONS_TEST_DIR.
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
- // Phase 5: prefer the SQL store. Falls back to the JSON mirror on
386
- // SQLite failure or when the table is empty (fresh install /
387
- // pre-migration). Returns the legacy flat object shape.
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
- // Phase 3: prefer the SQL store. Returns every scope's PRs tagged with
708
- // `_scope`; we re-map to `_project` for backward compatibility with
709
- // downstream consumers, intersecting with the configured project list
710
- // to suppress orphan/removed projects (matches legacy behaviour).
711
- let sqlPrs = null;
712
- try {
713
- const store = require('./pull-requests-store');
714
- sqlPrs = store.readAllPullRequests();
715
- } catch { /* fall through to JSON */ }
716
-
717
- if (Array.isArray(sqlPrs) && sqlPrs.length > 0) {
718
- for (const pr of sqlPrs) {
719
- if (!pr?.id || seenIds.has(pr.id)) continue;
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
- for (const pr of prs) {
759
- if (!pr?.id || seenIds.has(pr.id)) continue;
760
- if (!pr.url && base) {
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
- // Phase 2: prefer the SQL store. Returns every scope's items tagged
1356
- // with `_source = <scope>`, matching the legacy decoration. Empty SQL
1357
- // table OR a SQLite failure falls through to the JSON path below so a
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 SQL is the source
5296
- // of truth; mirror back to the JSON file for legacy direct-readers.
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 is best-effort */ }
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. Same shape as Phase 2's mutateWorkItems: route
5367
- // through the pull-requests-store when filePath sits under MINIONS_DIR,
5368
- // mirror back to JSON for legacy direct-readers, emit a pull_requests
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 is best-effort */ }
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
- // Emit only when an actual write happened. skipWriteIfUnchanged can
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.2111",
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"