@yemi33/minions 0.1.2114 → 0.1.2115

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.
@@ -0,0 +1,61 @@
1
+ // engine/db/migrations/010-pr-links.js
2
+ //
3
+ // Phase 9.1: move `engine/pr-links.json` into SQL.
4
+ //
5
+ // pr-links.json is the *fallback* PR → work-item linkage store. The
6
+ // primary source is `pull_requests.prdItems[]` (managed via the
7
+ // pull-requests-store). Stray PR IDs that aren't covered by a project
8
+ // pull-requests record still need to be discoverable — that's what this
9
+ // table holds.
10
+ //
11
+ // Shape: { [prId]: <value> } where <value> is typically an array of item IDs
12
+ // but may also be a bare string from legacy writes. Readers normalize.
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ function _resolveMinionsDir() {
18
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
19
+ if (envHome) return envHome;
20
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
21
+ }
22
+
23
+ function _readJsonOr(filePath, fallback) {
24
+ try {
25
+ const raw = fs.readFileSync(filePath, 'utf8');
26
+ return JSON.parse(raw);
27
+ } catch { return fallback; }
28
+ }
29
+
30
+ module.exports = {
31
+ version: 10,
32
+ description: 'pr_links: schema + pr-links.json backfill (fallback static store)',
33
+ up(db) {
34
+ db.exec(`
35
+ CREATE TABLE pr_links (
36
+ pr_id TEXT PRIMARY KEY,
37
+ data TEXT NOT NULL,
38
+ updated_at INTEGER NOT NULL
39
+ );
40
+ `);
41
+
42
+ const minionsDir = _resolveMinionsDir();
43
+ if (!minionsDir) return;
44
+ const now = Date.now();
45
+ let inserted = 0;
46
+
47
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'pr-links.json'), null);
48
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
49
+ const ins = db.prepare(`INSERT INTO pr_links (pr_id, data, updated_at) VALUES (?, ?, ?)`);
50
+ for (const [prId, value] of Object.entries(raw)) {
51
+ try {
52
+ ins.run(String(prId), JSON.stringify(value), now);
53
+ inserted += 1;
54
+ } catch { /* duplicate / corrupt — skip */ }
55
+ }
56
+ }
57
+
58
+ // eslint-disable-next-line no-console
59
+ console.log(`[db-migrate] v10: backfilled ${inserted} pr-links rows; pr-links.json kept as dual-write mirror`);
60
+ },
61
+ };
package/engine/shared.js CHANGED
@@ -532,6 +532,10 @@ function _routeJsonReadToSql(p) {
532
532
  const store = require('./small-state-store');
533
533
  return { value: store.readQaSessions() };
534
534
  }
535
+ if (norm.endsWith('/engine/pr-links.json')) {
536
+ const store = require('./small-state-store');
537
+ return { value: store.readPrLinks() };
538
+ }
535
539
  // Per-project work-items.json — match `/projects/<name>/work-items.json`.
536
540
  // When SQL has no rows for the scope AND the JSON file is absent on
537
541
  // disk, preserve the legacy "file missing → null" semantic. This guards
@@ -582,9 +586,9 @@ function _routeJsonReadToSql(p) {
582
586
  *
583
587
  * **SQL routing (Phase 9):** when the path matches a migrated state file
584
588
  * (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.
589
+ * pipeline-runs, managed-processes, worktree-pool, qa-runs, qa-sessions,
590
+ * pr-links), the read is served from SQLite via `_routeJsonReadToSql` — the
591
+ * JSON file on disk is no longer authoritative.
588
592
  *
589
593
  * Counterpart: `safeJsonNoRestore` for terminal artifacts and "missing == gone"
590
594
  * reads (cooldowns, archived PRDs, ephemeral session state) where reviving a
@@ -4988,9 +4992,19 @@ function getPrLinks() {
4988
4992
  mergePrLinkItems(links, pr.id, pr.prdItems || []);
4989
4993
  }
4990
4994
  } catch { /* SQL unavailable — skip primary source */ }
4991
- // Fallback: static pr-links.json for entries not covered above
4995
+ // Fallback: pr_links SQL store (was static pr-links.json file). Phase 9.1
4996
+ // routes both reads and writes through small-state-store; the JSON file is
4997
+ // kept as a dual-write mirror. SQL failures fall through to the JSON file.
4998
+ let static_ = null;
4992
4999
  try {
4993
- const static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8'));
5000
+ const store = require('./small-state-store');
5001
+ static_ = store.readPrLinks();
5002
+ } catch { static_ = null; }
5003
+ if (!static_ || Object.keys(static_).length === 0) {
5004
+ try { static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8')); }
5005
+ catch { static_ = null; }
5006
+ }
5007
+ if (static_ && typeof static_ === 'object' && !Array.isArray(static_)) {
4994
5008
  for (const [k, v] of Object.entries(static_)) {
4995
5009
  const canonical = parseCanonicalPrId(k);
4996
5010
  let normalizedKey = canonical ? `${canonical.scope}#${canonical.prNumber}` : k;
@@ -5004,7 +5018,7 @@ function getPrLinks() {
5004
5018
  }
5005
5019
  if (!links[normalizedKey]) mergePrLinkItems(links, normalizedKey, v);
5006
5020
  }
5007
- } catch { /* missing */ }
5021
+ }
5008
5022
  return links;
5009
5023
  }
5010
5024
 
@@ -5030,7 +5044,7 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
5030
5044
  const effectivePrId = canonicalPrId || prId;
5031
5045
  if (!effectivePrId || !itemId) return;
5032
5046
  const legacyPrId = String(prId || '');
5033
- mutateJsonFileLocked(PR_LINKS_PATH, (links) => {
5047
+ const mutator = (links) => {
5034
5048
  if (!links || Array.isArray(links) || typeof links !== 'object') links = {};
5035
5049
  const mergedCurrent = new Set(normalizePrLinkItems(links[effectivePrId]));
5036
5050
  if (legacyPrId && legacyPrId !== effectivePrId && links[legacyPrId]) {
@@ -5040,7 +5054,24 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
5040
5054
  if (!mergedCurrent.has(itemId)) mergedCurrent.add(itemId);
5041
5055
  links[effectivePrId] = [...mergedCurrent];
5042
5056
  return links;
5043
- }, { defaultValue: {} });
5057
+ };
5058
+ // Phase 9.1: pr-links is SQL-canonical via small-state-store; the JSON file
5059
+ // is a dual-write mirror. SQLite failures fall through to the legacy JSON
5060
+ // path so older installs without --experimental-sqlite still work.
5061
+ let routedViaSql = false;
5062
+ try {
5063
+ const store = require('./small-state-store');
5064
+ store.applyPrLinksMutation(mutator);
5065
+ try { store._mirrorPrLinksJson(); } catch { /* mirror best-effort */ }
5066
+ routedViaSql = true;
5067
+ } catch (e) {
5068
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
5069
+ throw e;
5070
+ }
5071
+ }
5072
+ if (!routedViaSql) {
5073
+ mutateJsonFileLocked(PR_LINKS_PATH, mutator, { defaultValue: {} });
5074
+ }
5044
5075
 
5045
5076
  if (!project) return;
5046
5077
  const prPath = projectPrPath(project);
@@ -829,6 +829,118 @@ function _mirrorQaSessionsJson(filePath) {
829
829
  } catch { /* mirror best-effort */ }
830
830
  }
831
831
 
832
+ // ─── pr_links ───────────────────────────────────────────────────────────────
833
+ // Shape: { [prId]: <value> } where <value> is typically array of item IDs
834
+ // but may also be a bare string (legacy). Readers normalize.
835
+
836
+ let _prLinksHash = null;
837
+
838
+ function _hydratePrLinks(db) {
839
+ const fp = _resolveFilePath('pr-links.json');
840
+ const raw = _readJson(fp) || {};
841
+ db.prepare('DELETE FROM pr_links').run();
842
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
843
+ const now = Date.now();
844
+ const ins = db.prepare('INSERT INTO pr_links (pr_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(pr_id) DO NOTHING');
845
+ for (const [prId, value] of Object.entries(raw)) {
846
+ ins.run(String(prId), JSON.stringify(value), now);
847
+ }
848
+ }
849
+
850
+ function _resyncPrLinksIfDiverged(db) {
851
+ const fp = _resolveFilePath('pr-links.json');
852
+ const currentHash = _fileContentHash(fp);
853
+ if (currentHash == null) return;
854
+ if (_prLinksHash != null && currentHash === _prLinksHash) return;
855
+ if (_prLinksHash == null) {
856
+ const sqlHas = db.prepare('SELECT 1 FROM pr_links LIMIT 1').get();
857
+ if (sqlHas) {
858
+ _prLinksHash = currentHash;
859
+ return;
860
+ }
861
+ }
862
+ _hydratePrLinks(db);
863
+ _prLinksHash = currentHash;
864
+ }
865
+
866
+ function _readPrLinksFromSql(db) {
867
+ const rows = db.prepare('SELECT pr_id, data FROM pr_links').all();
868
+ const out = {};
869
+ for (const row of rows) {
870
+ try { out[row.pr_id] = JSON.parse(row.data); }
871
+ catch { /* skip malformed */ }
872
+ }
873
+ return out;
874
+ }
875
+
876
+ function readPrLinks() {
877
+ const { getDb } = require('./db');
878
+ let db;
879
+ try { db = getDb(); }
880
+ catch { return _readJson(_resolveFilePath('pr-links.json')) || {}; }
881
+ try { _resyncPrLinksIfDiverged(db); }
882
+ catch { /* table may be missing on a stale install — fall back to JSON */ }
883
+ let out;
884
+ try { out = _readPrLinksFromSql(db); }
885
+ catch { return _readJson(_resolveFilePath('pr-links.json')) || {}; }
886
+ if (Object.keys(out).length === 0) {
887
+ const fallback = _readJson(_resolveFilePath('pr-links.json'));
888
+ if (fallback && Object.keys(fallback).length > 0) return fallback;
889
+ return {};
890
+ }
891
+ return out;
892
+ }
893
+
894
+ function applyPrLinksMutation(mutator) {
895
+ const { getDb, withTransaction } = require('./db');
896
+ let db;
897
+ try { db = getDb(); }
898
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
899
+
900
+ return withTransaction(db, () => {
901
+ _resyncPrLinksIfDiverged(db);
902
+ const before = _readPrLinksFromSql(db);
903
+ const beforeSnap = JSON.parse(JSON.stringify(before));
904
+ const next = mutator(before);
905
+ const after = (next === undefined || next === null) ? before : next;
906
+ if (!after || typeof after !== 'object' || Array.isArray(after)) {
907
+ return { wrote: false, result: beforeSnap };
908
+ }
909
+ const afterIds = new Set(Object.keys(after));
910
+ const beforeIds = new Set(Object.keys(beforeSnap));
911
+ let wrote = false;
912
+ const now = Date.now();
913
+ const upsert = db.prepare(`
914
+ INSERT INTO pr_links (pr_id, data, updated_at)
915
+ VALUES (?, ?, ?)
916
+ ON CONFLICT(pr_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
917
+ `);
918
+ const del = db.prepare('DELETE FROM pr_links WHERE pr_id = ?');
919
+ for (const id of afterIds) {
920
+ if (!beforeIds.has(id) || JSON.stringify(beforeSnap[id]) !== JSON.stringify(after[id])) {
921
+ upsert.run(id, JSON.stringify(after[id]), now);
922
+ wrote = true;
923
+ }
924
+ }
925
+ for (const id of beforeIds) {
926
+ if (!afterIds.has(id)) { del.run(id); wrote = true; }
927
+ }
928
+ return { wrote, result: after };
929
+ });
930
+ }
931
+
932
+ function _mirrorPrLinksJson(filePath) {
933
+ try {
934
+ const shared = require('./shared');
935
+ const { getDb } = require('./db');
936
+ const obj = _readPrLinksFromSql(getDb());
937
+ const target = filePath || _resolveFilePath('pr-links.json');
938
+ shared.safeWrite(target, obj);
939
+ const h = _fileContentHash(target);
940
+ if (h != null) _prLinksHash = h;
941
+ } catch { /* mirror best-effort */ }
942
+ }
943
+
832
944
  // ─── Test seam ─────────────────────────────────────────────────────────────
833
945
 
834
946
  function _resetAllForTest() {
@@ -841,6 +953,7 @@ function _resetAllForTest() {
841
953
  db.exec('DELETE FROM worktree_pool');
842
954
  try { db.exec('DELETE FROM qa_runs'); } catch { /* migration not applied */ }
843
955
  try { db.exec('DELETE FROM qa_sessions'); } catch { /* migration not applied */ }
956
+ try { db.exec('DELETE FROM pr_links'); } catch { /* migration not applied */ }
844
957
  } catch { /* not initialized */ }
845
958
  _scheduleRunsHash = null;
846
959
  _pipelineRunsHash = null;
@@ -848,6 +961,7 @@ function _resetAllForTest() {
848
961
  _worktreePoolHash = null;
849
962
  _qaRunsHash = null;
850
963
  _qaSessionsHash = null;
964
+ _prLinksHash = null;
851
965
  }
852
966
 
853
967
  module.exports = {
@@ -875,6 +989,10 @@ module.exports = {
875
989
  readQaSessions,
876
990
  applyQaSessionsMutation,
877
991
  _mirrorQaSessionsJson,
992
+ // pr_links
993
+ readPrLinks,
994
+ applyPrLinksMutation,
995
+ _mirrorPrLinksJson,
878
996
  // test seam
879
997
  _resetAllForTest,
880
998
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2114",
3
+ "version": "0.1.2115",
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"