@yemi33/minions 0.1.2068 → 0.1.2070

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,129 @@
1
+ // engine/db/migrations/004-pull-requests.js
2
+ //
3
+ // Phase 3: move pull-requests.json (central + per-project) into a
4
+ // `pull_requests` table.
5
+ //
6
+ // Same hybrid schema as `work_items`: typed projection columns for hot
7
+ // filters (scope/status/agent/branch/timestamps), plus a `data` TEXT
8
+ // column holding the full record JSON so future fields land transparently.
9
+ //
10
+ // `scope` keys the file the record came from: 'central' for
11
+ // <MINIONS_DIR>/pull-requests.json, otherwise the project name (matches
12
+ // the directory under projects/<name>/). Per-scope writes diff against
13
+ // that scope's rows so a mutation on one project's file never touches
14
+ // another's.
15
+ //
16
+ // Backfill: walk central + every projects/<name>/pull-requests.json. The
17
+ // JSON files stay on disk as a dual-write mirror so legacy direct-readers
18
+ // continue to function while we migrate them in Phase 3.5.
19
+
20
+ const path = require('path');
21
+ const fs = require('fs');
22
+
23
+ function _resolveMinionsDir() {
24
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
25
+ if (envHome) return envHome;
26
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
27
+ }
28
+
29
+ function _toMs(v) {
30
+ if (v == null) return null;
31
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
32
+ const parsed = Date.parse(v);
33
+ return Number.isFinite(parsed) ? parsed : null;
34
+ }
35
+
36
+ function _readJsonArray(filePath) {
37
+ try {
38
+ const raw = fs.readFileSync(filePath, 'utf8');
39
+ const parsed = JSON.parse(raw);
40
+ return Array.isArray(parsed) ? parsed : [];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ function _listProjectDirs(minionsDir) {
47
+ const projectsRoot = path.join(minionsDir, 'projects');
48
+ let entries;
49
+ try { entries = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
50
+ catch { return []; }
51
+ const out = [];
52
+ for (const e of entries) {
53
+ if (!e.isDirectory()) continue;
54
+ if (e.name.startsWith('.')) continue; // .archived + any other hidden sidecars
55
+ out.push(e.name);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ module.exports = {
61
+ version: 4,
62
+ description: 'pull_requests: schema + pull-requests.json backfill (central + per-project)',
63
+ up(db) {
64
+ db.exec(`
65
+ CREATE TABLE pull_requests (
66
+ id TEXT NOT NULL,
67
+ scope TEXT NOT NULL,
68
+ status TEXT NOT NULL,
69
+ review_status TEXT,
70
+ agent TEXT,
71
+ branch TEXT,
72
+ pr_number INTEGER,
73
+ created_at INTEGER,
74
+ last_polled_at INTEGER,
75
+ data TEXT NOT NULL,
76
+ updated_at INTEGER NOT NULL,
77
+ PRIMARY KEY (scope, id)
78
+ );
79
+ CREATE INDEX idx_pr_status ON pull_requests(status);
80
+ CREATE INDEX idx_pr_scope_status ON pull_requests(scope, status);
81
+ CREATE INDEX idx_pr_agent ON pull_requests(agent);
82
+ CREATE INDEX idx_pr_branch ON pull_requests(branch);
83
+ `);
84
+
85
+ const minionsDir = _resolveMinionsDir();
86
+ if (!minionsDir) return;
87
+
88
+ const insert = db.prepare(`
89
+ INSERT INTO pull_requests (id, scope, status, review_status, agent, branch, pr_number, created_at, last_polled_at, data, updated_at)
90
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91
+ `);
92
+
93
+ const now = Date.now();
94
+ let inserted = 0;
95
+
96
+ const backfillFile = (scope, filePath) => {
97
+ const rows = _readJsonArray(filePath);
98
+ for (const pr of rows) {
99
+ if (!pr || typeof pr !== 'object' || !pr.id) continue;
100
+ try {
101
+ insert.run(
102
+ String(pr.id),
103
+ scope,
104
+ String(pr.status || 'active'),
105
+ pr.reviewStatus || null,
106
+ pr.agent || null,
107
+ pr.branch || null,
108
+ Number.isFinite(pr.prNumber) ? pr.prNumber : null,
109
+ _toMs(pr.created),
110
+ _toMs(pr.lastPolledAt || pr.lastPolled),
111
+ JSON.stringify(pr),
112
+ now,
113
+ );
114
+ inserted += 1;
115
+ } catch {
116
+ // Duplicate (scope, id) — skip. Defensive against malformed files.
117
+ }
118
+ }
119
+ };
120
+
121
+ backfillFile('central', path.join(minionsDir, 'pull-requests.json'));
122
+ for (const projectName of _listProjectDirs(minionsDir)) {
123
+ backfillFile(projectName, path.join(minionsDir, 'projects', projectName, 'pull-requests.json'));
124
+ }
125
+
126
+ // eslint-disable-next-line no-console
127
+ console.log(`[db-migrate] v4: backfilled ${inserted} pull requests; pull-requests.json files kept as dual-write mirrors`);
128
+ },
129
+ };
@@ -0,0 +1,39 @@
1
+ // engine/db/migrations/005-logs.js
2
+ //
3
+ // Phase 4: replace engine/log.json with a `logs` table.
4
+ //
5
+ // Logs are append-only with a 2500-entry ring buffer (the legacy JSON
6
+ // path trimmed to 2000 when length hit 2500). The table preserves that
7
+ // behaviour:
8
+ // - Each log() emits one row via INSERT.
9
+ // - After insert, a tail-trim DELETEs everything older than the most
10
+ // recent N (default 2500, configurable via ENGINE_DEFAULTS.logCapEntries).
11
+ //
12
+ // Schema is intentionally narrow: an auto-incrementing id (preserves
13
+ // insertion order for the ring-buffer trim), the timestamp in ms (so the
14
+ // dashboard can do range queries without a TEXT parse), a level/message
15
+ // pair the dashboard already keys on, and a `meta` JSON blob for any
16
+ // extra fields the caller passed.
17
+ //
18
+ // No backfill — engine/log.json is ephemeral and the next tick will
19
+ // start populating SQL fresh. The legacy file stays on disk as a
20
+ // dual-write mirror (test fixtures + getEngineLog fallback) until
21
+ // Phase 4.5 migrates the readers.
22
+
23
+ module.exports = {
24
+ version: 5,
25
+ description: 'logs: append-only table for engine log entries',
26
+ up(db) {
27
+ db.exec(`
28
+ CREATE TABLE logs (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ ts_ms INTEGER NOT NULL,
31
+ level TEXT NOT NULL,
32
+ message TEXT NOT NULL,
33
+ meta TEXT
34
+ );
35
+ CREATE INDEX idx_logs_ts ON logs(ts_ms DESC);
36
+ CREATE INDEX idx_logs_level_ts ON logs(level, ts_ms DESC);
37
+ `);
38
+ },
39
+ };
@@ -0,0 +1,106 @@
1
+ // engine/db/migrations/006-metrics.js
2
+ //
3
+ // Phase 5: move engine/metrics.json into a `metrics` table.
4
+ //
5
+ // metrics.json's shape is heterogeneous: a flat object whose top-level
6
+ // keys mix per-agent counters (regular agent ids) with reserved keys
7
+ // (`_engine`, `_daily`, `_contextPressure`). The SQL projection uses a
8
+ // single table with a (kind, key) primary key so each top-level entry
9
+ // becomes one row:
10
+ //
11
+ // kind | key | data (JSON blob)
12
+ // --------------------+--------------------+-----------------------------
13
+ // agent | dallas | { tasksCompleted, ... }
14
+ // agent | ripley | { ... }
15
+ // engine_category | command-center | { calls, costUsd, ... }
16
+ // engine_category | doc-chat | { ... }
17
+ // daily | 2026-05-28 | { costUsd, tasks, ... }
18
+ // context_pressure | current | { ... }
19
+ //
20
+ // Engineless extensions land transparently in `data` — no schema bump
21
+ // required to add a new counter field per category. New top-level kinds
22
+ // (e.g. `pipeline`) only need a new sentinel in the reserved-key list
23
+ // inside the store; the table schema is forward-compatible.
24
+ //
25
+ // Backfill: read metrics.json once, split by reserved-key sentinel,
26
+ // INSERT one row per entry. The JSON file stays on disk as a dual-write
27
+ // mirror so legacy direct-readers (none in engine, a few in tests) keep
28
+ // working.
29
+
30
+ const path = require('path');
31
+ const fs = require('fs');
32
+
33
+ function _resolveMinionsDir() {
34
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
35
+ if (envHome) return envHome;
36
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
37
+ }
38
+
39
+ const RESERVED_KEYS = new Map([
40
+ ['_engine', 'engine_category'],
41
+ ['_daily', 'daily'],
42
+ ['_contextPressure', 'context_pressure'],
43
+ ]);
44
+
45
+ module.exports = {
46
+ version: 6,
47
+ description: 'metrics: schema + metrics.json backfill',
48
+ up(db) {
49
+ db.exec(`
50
+ CREATE TABLE metrics (
51
+ kind TEXT NOT NULL,
52
+ key TEXT NOT NULL,
53
+ data TEXT NOT NULL,
54
+ updated_at INTEGER NOT NULL,
55
+ PRIMARY KEY (kind, key)
56
+ );
57
+ CREATE INDEX idx_metrics_kind ON metrics(kind);
58
+ `);
59
+
60
+ const minionsDir = _resolveMinionsDir();
61
+ if (!minionsDir) return;
62
+ const metricsPath = path.join(minionsDir, 'engine', 'metrics.json');
63
+ if (!fs.existsSync(metricsPath)) return;
64
+
65
+ let raw;
66
+ try { raw = JSON.parse(fs.readFileSync(metricsPath, 'utf8')); }
67
+ catch (e) {
68
+ throw new Error(`engine/db/006-metrics: cannot parse metrics.json: ${e.message}`);
69
+ }
70
+ if (!raw || typeof raw !== 'object') return;
71
+
72
+ const insert = db.prepare(`
73
+ INSERT INTO metrics (kind, key, data, updated_at)
74
+ VALUES (?, ?, ?, ?)
75
+ `);
76
+
77
+ const now = Date.now();
78
+ let inserted = 0;
79
+
80
+ for (const [topKey, topValue] of Object.entries(raw)) {
81
+ const reservedKind = RESERVED_KEYS.get(topKey);
82
+ if (reservedKind) {
83
+ if (reservedKind === 'context_pressure') {
84
+ // Single-blob reserved key — store as one row keyed 'current'.
85
+ insert.run('context_pressure', 'current', JSON.stringify(topValue), now);
86
+ inserted += 1;
87
+ continue;
88
+ }
89
+ // Nested reserved key (_engine / _daily) — one row per inner key.
90
+ if (topValue && typeof topValue === 'object') {
91
+ for (const [innerKey, innerValue] of Object.entries(topValue)) {
92
+ insert.run(reservedKind, innerKey, JSON.stringify(innerValue), now);
93
+ inserted += 1;
94
+ }
95
+ }
96
+ continue;
97
+ }
98
+ // Regular agent counter row.
99
+ insert.run('agent', topKey, JSON.stringify(topValue), now);
100
+ inserted += 1;
101
+ }
102
+
103
+ // eslint-disable-next-line no-console
104
+ console.log(`[db-migrate] v6: backfilled ${inserted} metrics rows; metrics.json kept as dual-write mirror`);
105
+ },
106
+ };
@@ -1788,12 +1788,12 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1788
1788
  // Track reviewer for metrics purposes (separate file, separate lock)
1789
1789
  const authorAgentId = (reviewPr.agent || '').toLowerCase();
1790
1790
  if (authorAgentId && config.agents?.[authorAgentId]) {
1791
- shared.mutateJsonFileLocked(path.join(ENGINE_DIR, 'metrics.json'), (metrics) => {
1791
+ shared.mutateMetrics((metrics) => {
1792
1792
  if (!metrics[authorAgentId]) metrics[authorAgentId] = { ...DEFAULT_AGENT_METRICS };
1793
1793
  if (!metrics[agentId]) metrics[agentId] = { ...DEFAULT_AGENT_METRICS };
1794
1794
  metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
1795
1795
  return metrics;
1796
- }, { defaultValue: {} });
1796
+ });
1797
1797
  }
1798
1798
 
1799
1799
  log('info', `Updated ${reviewPr.id} → minions review: ${postReviewStatus || 'waiting'} by ${reviewerName}`);
@@ -2559,8 +2559,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
2559
2559
 
2560
2560
  const agentId = (pr.agent || '').toLowerCase();
2561
2561
  if (agentId && config.agents?.[agentId]) {
2562
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
2563
- mutateJsonFileLocked(metricsPath, (metrics) => {
2562
+ shared.mutateMetrics((metrics) => {
2564
2563
  if (!metrics[agentId]) metrics[agentId] = { ...DEFAULT_AGENT_METRICS };
2565
2564
  metrics[agentId].prsMerged = (metrics[agentId].prsMerged || 0) + 1;
2566
2565
  return metrics;
package/engine/llm.js CHANGED
@@ -103,8 +103,8 @@ function flushMetricsBuffer() {
103
103
  _pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
104
104
 
105
105
  try {
106
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
107
- mutateJsonFileLocked(metricsPath, (metrics) => {
106
+ const { mutateMetrics } = require('./shared');
107
+ mutateMetrics((metrics) => {
108
108
  if (!metrics._engine) metrics._engine = {};
109
109
  for (const [category, delta] of Object.entries(pending.engine)) {
110
110
  if (!metrics._engine[category]) {
@@ -0,0 +1,190 @@
1
+ // engine/logs-store.js — SQL-backed implementation of engine/log.json.
2
+ //
3
+ // Logs differ from work_items / pull_requests / dispatches: they are
4
+ // pure append-only with a tail-cap, so the store exposes a different
5
+ // shape:
6
+ //
7
+ // appendLogsToDbFile(dbFilePath, entries)
8
+ // → bulk INSERT into the `logs` table at the given state.db path,
9
+ // then trim the head if the table exceeded the cap. Routes per
10
+ // dbFilePath so test isolation (each MINIONS_TEST_DIR has its own
11
+ // state.db) keeps working — the legacy `_logPath` capture-at-write
12
+ // pattern was the only correct way to keep test logs out of
13
+ // production storage, and we preserve the same idea here.
14
+ //
15
+ // readRecentLogs(n, { level? })
16
+ // → SELECT ... ORDER BY id DESC LIMIT n against the singleton DB.
17
+ // Honors MINIONS_TEST_DIR / MINIONS_HOME automatically through
18
+ // engine/db.getDb().
19
+ //
20
+ // All writes happen through the per-path appender; the singleton is only
21
+ // used for reads. This way a deferred buffer flush after a test ends
22
+ // can't accidentally drop entries into the production state.db.
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ // Per-dbPath connection cache. Avoids close+reopen thrash on the
28
+ // singleton when buffered entries are routed to multiple test state.db
29
+ // files in quick succession. Connections are closed via
30
+ // closeAllLogWriterConnections() at process shutdown.
31
+ const _connCache = new Map();
32
+
33
+ function _ensureDir(filePath) {
34
+ try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } catch { /* exists */ }
35
+ }
36
+
37
+ function _openWriter(dbFilePath) {
38
+ let conn = _connCache.get(dbFilePath);
39
+ if (conn) return conn;
40
+ _ensureDir(dbFilePath);
41
+ // Use the same migration runner / pragma setup as the singleton, but
42
+ // against an explicit path. Re-running runMigrations against an
43
+ // already-migrated DB is a no-op (the version row gates it), so this
44
+ // is safe regardless of whether the singleton has touched the file.
45
+ const { DatabaseSync } = require('node:sqlite');
46
+ const db = new DatabaseSync(dbFilePath);
47
+ db.exec('PRAGMA journal_mode = WAL');
48
+ db.exec('PRAGMA foreign_keys = ON');
49
+ db.exec('PRAGMA synchronous = NORMAL');
50
+ db.exec('PRAGMA busy_timeout = 5000');
51
+ const { runMigrations } = require('./db/migrate');
52
+ runMigrations(db);
53
+ conn = {
54
+ db,
55
+ insertStmt: db.prepare('INSERT INTO logs (ts_ms, level, message, meta) VALUES (?, ?, ?, ?)'),
56
+ countStmt: db.prepare('SELECT COUNT(*) AS n FROM logs'),
57
+ };
58
+ _connCache.set(dbFilePath, conn);
59
+ return conn;
60
+ }
61
+
62
+ function _toMs(v) {
63
+ if (v == null) return Date.now();
64
+ if (typeof v === 'number') return Number.isFinite(v) ? v : Date.now();
65
+ const parsed = Date.parse(v);
66
+ return Number.isFinite(parsed) ? parsed : Date.now();
67
+ }
68
+
69
+ // Strip the routing-only fields (timestamp, level, message, _logPath,
70
+ // _dbPath) so `meta` only contains caller-provided extras.
71
+ const _BUILTIN_KEYS = new Set(['timestamp', 'level', 'message', '_logPath', '_dbPath']);
72
+ function _extractMeta(entry) {
73
+ const meta = {};
74
+ for (const k of Object.keys(entry)) {
75
+ if (_BUILTIN_KEYS.has(k)) continue;
76
+ meta[k] = entry[k];
77
+ }
78
+ return Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
79
+ }
80
+
81
+ // Append a batch of log entries to a specific state.db file.
82
+ //
83
+ // Bulk-wrapped in a transaction so a partial failure rolls back cleanly
84
+ // and disk fsync only fires once per batch (matters at the 50-entry
85
+ // buffer threshold under high load).
86
+ //
87
+ // After insert, if the table exceeds the ring-buffer cap, trim the
88
+ // oldest entries down to logCapTrimTo. Trim is bounded — only runs when
89
+ // we've actually overflowed, not on every batch.
90
+ function appendLogsToDbFile(dbFilePath, entries) {
91
+ if (!entries || entries.length === 0) return 0;
92
+ let conn;
93
+ try { conn = _openWriter(dbFilePath); }
94
+ catch (e) {
95
+ // Logging must never crash the caller — swallow and bail.
96
+ return 0;
97
+ }
98
+
99
+ const shared = require('./shared');
100
+ const cap = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.logCapEntries) || 2500;
101
+ const trimTo = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.logCapTrimTo) || 2000;
102
+
103
+ let inserted = 0;
104
+ try {
105
+ conn.db.exec('BEGIN IMMEDIATE');
106
+ for (const entry of entries) {
107
+ const tsMs = _toMs(entry.timestamp);
108
+ const level = String(entry.level || 'info');
109
+ const message = String(entry.message != null ? entry.message : '');
110
+ const meta = _extractMeta(entry);
111
+ conn.insertStmt.run(tsMs, level, message, meta);
112
+ inserted += 1;
113
+ }
114
+ const total = conn.countStmt.get().n;
115
+ if (total > cap) {
116
+ // Trim the oldest rows. `id` is monotonic via AUTOINCREMENT, so
117
+ // ORDER BY id ASC + LIMIT (total - trimTo) deletes exactly the
118
+ // overflow. Single DELETE; no rowid scan required.
119
+ const toRemove = total - trimTo;
120
+ conn.db.prepare(`
121
+ DELETE FROM logs
122
+ WHERE id IN (SELECT id FROM logs ORDER BY id ASC LIMIT ?)
123
+ `).run(toRemove);
124
+ }
125
+ conn.db.exec('COMMIT');
126
+ } catch {
127
+ try { conn.db.exec('ROLLBACK'); } catch { /* best-effort */ }
128
+ return 0;
129
+ }
130
+
131
+ // Emit a single events row so dashboards / cache invalidators wake up
132
+ // once per batch instead of per entry. Routed against the singleton
133
+ // (not per-dbPath) because events are a cross-cutting concern keyed
134
+ // off the engine's active state.db.
135
+ try { require('./db-events').emitStateEvent('logs', { batch: inserted }); } catch { /* optional */ }
136
+ return inserted;
137
+ }
138
+
139
+ // Read the most recent N entries from the singleton DB (test-aware
140
+ // because getDb() re-resolves MINIONS_TEST_DIR on every call).
141
+ //
142
+ // Returns entries in NEW-EST-FIRST order — matches what the dashboard
143
+ // expects when slicing the tail of the array. Optional `level` filter
144
+ // uses the level index.
145
+ function readRecentLogs(n, opts) {
146
+ const limit = Math.max(1, Math.min(10000, Number(n) || 50));
147
+ const level = opts && opts.level ? String(opts.level) : null;
148
+ const { getDb } = require('./db');
149
+ let db;
150
+ try { db = getDb(); }
151
+ catch { return []; }
152
+ const rows = level
153
+ ? db.prepare('SELECT ts_ms, level, message, meta FROM logs WHERE level = ? ORDER BY id DESC LIMIT ?').all(level, limit)
154
+ : db.prepare('SELECT ts_ms, level, message, meta FROM logs ORDER BY id DESC LIMIT ?').all(limit);
155
+
156
+ const out = [];
157
+ for (const row of rows) {
158
+ const entry = {
159
+ timestamp: new Date(row.ts_ms).toISOString(),
160
+ level: row.level,
161
+ message: row.message,
162
+ };
163
+ if (row.meta) {
164
+ try { Object.assign(entry, JSON.parse(row.meta)); } catch { /* skip malformed meta */ }
165
+ }
166
+ out.push(entry);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // Convenience: same shape as the legacy `JSON.parse(log.json).slice(-N)`
172
+ // — chronological order (oldest first). Inverts the readRecentLogs DESC
173
+ // output so existing callers can drop in without re-sorting.
174
+ function readRecentLogsChronological(n, opts) {
175
+ return readRecentLogs(n, opts).reverse();
176
+ }
177
+
178
+ function closeAllLogWriterConnections() {
179
+ for (const [, conn] of _connCache) {
180
+ try { conn.db.close(); } catch { /* already closed */ }
181
+ }
182
+ _connCache.clear();
183
+ }
184
+
185
+ module.exports = {
186
+ appendLogsToDbFile,
187
+ readRecentLogs,
188
+ readRecentLogsChronological,
189
+ closeAllLogWriterConnections,
190
+ };
Binary file
@@ -0,0 +1,296 @@
1
+ // engine/pull-requests-store.js — SQL-backed implementation of the
2
+ // per-file pull-requests array. Same external contract as the legacy
3
+ // JSON-file reader, modeled directly on work-items-store.js.
4
+ //
5
+ // readPullRequestsForScope(scope) -> [pr, pr, ...]
6
+ // applyPullRequestsMutation(scope, fn) -> diff-then-apply,
7
+ // returns { wrote, result }
8
+ //
9
+ // `scope` is 'central' for the top-level pull-requests.json or a project
10
+ // name for projects/<name>/pull-requests.json.
11
+ //
12
+ // External-edit detection mirrors work-items-store: per-scope last-mirror
13
+ // mtime is recorded after every write; if the JSON file's mtime advances
14
+ // outside our knowledge (test re-seed, operator hand-edit), we treat the
15
+ // JSON as canonical and rebuild SQL before the next read or mutation.
16
+
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+
20
+ function _toMs(v) {
21
+ if (v == null) return null;
22
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
23
+ const parsed = Date.parse(v);
24
+ return Number.isFinite(parsed) ? parsed : null;
25
+ }
26
+
27
+ function scopeForFilePath(filePath) {
28
+ const norm = String(filePath).replace(/\\/g, '/');
29
+ const m = norm.match(/projects\/([^/]+)\/pull-requests\.json$/);
30
+ if (m) return m[1];
31
+ return 'central';
32
+ }
33
+
34
+ function _filePathForScope(scope) {
35
+ const shared = require('./shared');
36
+ if (scope === 'central') {
37
+ return path.join(shared.MINIONS_DIR, 'pull-requests.json');
38
+ }
39
+ return path.join(shared.MINIONS_DIR, 'projects', scope, 'pull-requests.json');
40
+ }
41
+
42
+ function _parseRow(row) {
43
+ if (!row || !row.data) return null;
44
+ try { return JSON.parse(row.data); }
45
+ catch { return null; }
46
+ }
47
+
48
+ function _readJsonArrayFallback(scope) {
49
+ const fp = _filePathForScope(scope);
50
+ let raw;
51
+ try { raw = fs.readFileSync(fp, 'utf8'); }
52
+ catch { return []; }
53
+ try {
54
+ const parsed = JSON.parse(raw);
55
+ return Array.isArray(parsed) ? parsed : [];
56
+ } catch (e) {
57
+ try {
58
+ // eslint-disable-next-line no-console
59
+ console.warn(`[pull-requests-store] corrupt JSON in ${fp}: ${e.message}`);
60
+ } catch { /* console may be wrapped in tests */ }
61
+ return [];
62
+ }
63
+ }
64
+
65
+ // Track (mtime, size) per scope so back-to-back writes inside the same
66
+ // ms tick still get detected as external edits (mtime-only checks miss
67
+ // them on Windows because NTFS reports ms-rounded mtimeMs).
68
+ const _lastMirrorByScope = new Map();
69
+
70
+ function _statFingerprint(filePath) {
71
+ try {
72
+ const st = fs.statSync(filePath);
73
+ return { mtime: st.mtimeMs, size: st.size };
74
+ }
75
+ catch { return null; }
76
+ }
77
+
78
+ function _hydrateScopeFromJson(db, scope) {
79
+ const jsonItems = _readJsonArrayFallback(scope);
80
+ db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
81
+ if (jsonItems.length === 0) return;
82
+ const now = Date.now();
83
+ const ins = db.prepare(`
84
+ INSERT INTO pull_requests (id, scope, status, review_status, agent, branch, pr_number, created_at, last_polled_at, data, updated_at)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
86
+ ON CONFLICT(scope, id) DO NOTHING
87
+ `);
88
+ for (const pr of jsonItems) {
89
+ if (!pr || !pr.id) continue;
90
+ ins.run(
91
+ String(pr.id),
92
+ scope,
93
+ String(pr.status || 'active'),
94
+ pr.reviewStatus || null,
95
+ pr.agent || null,
96
+ pr.branch || null,
97
+ Number.isFinite(pr.prNumber) ? pr.prNumber : null,
98
+ _toMs(pr.created),
99
+ _toMs(pr.lastPolledAt || pr.lastPolled),
100
+ JSON.stringify(pr),
101
+ now,
102
+ );
103
+ }
104
+ }
105
+
106
+ function _resyncScopeIfJsonDiverged(db, scope) {
107
+ const jsonPath = _filePathForScope(scope);
108
+ const current = _statFingerprint(jsonPath);
109
+ const lastMirror = _lastMirrorByScope.get(scope);
110
+ if (current == null) return;
111
+ // In-sync iff BOTH mtime AND size match. Size catches same-ms-tick
112
+ // external writes that mtime alone misses. Resync is idempotent.
113
+ if (lastMirror != null
114
+ && current.mtime === lastMirror.mtime
115
+ && current.size === lastMirror.size) return;
116
+ if (lastMirror == null) {
117
+ const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
118
+ if (sqlHas) {
119
+ _lastMirrorByScope.set(scope, current);
120
+ return;
121
+ }
122
+ }
123
+ _hydrateScopeFromJson(db, scope);
124
+ _lastMirrorByScope.set(scope, current);
125
+ }
126
+
127
+ function readPullRequestsForScope(scope) {
128
+ const { getDb } = require('./db');
129
+ let db;
130
+ try { db = getDb(); }
131
+ catch { return _readJsonArrayFallback(scope); }
132
+
133
+ _resyncScopeIfJsonDiverged(db, scope);
134
+
135
+ const rows = db.prepare(`
136
+ SELECT data FROM pull_requests
137
+ WHERE scope = ?
138
+ ORDER BY rowid
139
+ `).all(scope);
140
+
141
+ if (rows.length === 0) {
142
+ const fallback = _readJsonArrayFallback(scope);
143
+ if (fallback.length > 0) return fallback;
144
+ return [];
145
+ }
146
+
147
+ const out = [];
148
+ for (const row of rows) {
149
+ const pr = _parseRow(row);
150
+ if (pr) out.push(pr);
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function readAllPullRequests() {
156
+ const { getDb } = require('./db');
157
+ let db;
158
+ try { db = getDb(); }
159
+ catch { return null; }
160
+
161
+ try { _resyncScopeIfJsonDiverged(db, 'central'); } catch {}
162
+ const knownScopes = db.prepare('SELECT DISTINCT scope FROM pull_requests').all().map(r => r.scope);
163
+ for (const s of knownScopes) {
164
+ if (s === 'central') continue;
165
+ try { _resyncScopeIfJsonDiverged(db, s); } catch {}
166
+ }
167
+
168
+ const rows = db.prepare('SELECT data, scope FROM pull_requests ORDER BY rowid').all();
169
+ const out = [];
170
+ for (const row of rows) {
171
+ const pr = _parseRow(row);
172
+ if (!pr) continue;
173
+ pr._scope = row.scope;
174
+ out.push(pr);
175
+ }
176
+ return out;
177
+ }
178
+
179
+ function _indexById(arr) {
180
+ const out = new Map();
181
+ for (const pr of arr) {
182
+ if (!pr || !pr.id) continue;
183
+ out.set(String(pr.id), pr);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function _computePullRequestsDiff(scope, before, after) {
189
+ const beforeMap = _indexById(before);
190
+ const afterMap = _indexById(after);
191
+ const toUpsert = [];
192
+ const toDelete = [];
193
+ for (const [id, pr] of afterMap) {
194
+ const prev = beforeMap.get(id);
195
+ if (!prev) { toUpsert.push(pr); continue; }
196
+ if (JSON.stringify(prev) !== JSON.stringify(pr)) toUpsert.push(pr);
197
+ }
198
+ for (const [id] of beforeMap) {
199
+ if (!afterMap.has(id)) toDelete.push(id);
200
+ }
201
+ return { scope, toUpsert, toDelete };
202
+ }
203
+
204
+ function _applyPullRequestsDiff(db, diff) {
205
+ if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
206
+ const now = Date.now();
207
+ const upsertStmt = db.prepare(`
208
+ INSERT INTO pull_requests (id, scope, status, review_status, agent, branch, pr_number, created_at, last_polled_at, data, updated_at)
209
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
210
+ ON CONFLICT(scope, id) DO UPDATE SET
211
+ status = excluded.status,
212
+ review_status = excluded.review_status,
213
+ agent = excluded.agent,
214
+ branch = excluded.branch,
215
+ pr_number = excluded.pr_number,
216
+ created_at = excluded.created_at,
217
+ last_polled_at = excluded.last_polled_at,
218
+ data = excluded.data,
219
+ updated_at = excluded.updated_at
220
+ `);
221
+ const deleteStmt = db.prepare('DELETE FROM pull_requests WHERE scope = ? AND id = ?');
222
+
223
+ for (const pr of diff.toUpsert) {
224
+ upsertStmt.run(
225
+ String(pr.id),
226
+ diff.scope,
227
+ String(pr.status || 'active'),
228
+ pr.reviewStatus || null,
229
+ pr.agent || null,
230
+ pr.branch || null,
231
+ Number.isFinite(pr.prNumber) ? pr.prNumber : null,
232
+ _toMs(pr.created),
233
+ _toMs(pr.lastPolledAt || pr.lastPolled),
234
+ JSON.stringify(pr),
235
+ now,
236
+ );
237
+ }
238
+ for (const id of diff.toDelete) {
239
+ deleteStmt.run(diff.scope, id);
240
+ }
241
+ return true;
242
+ }
243
+
244
+ function applyPullRequestsMutation(scope, mutator) {
245
+ const { getDb, withTransaction } = require('./db');
246
+ let db;
247
+ try { db = getDb(); }
248
+ catch (e) {
249
+ throw new Error(`engine/pull-requests-store: SQLite unavailable (${e.message}); cannot mutate pull_requests`);
250
+ }
251
+
252
+ return withTransaction(db, () => {
253
+ _resyncScopeIfJsonDiverged(db, scope);
254
+ const before = readPullRequestsForScope(scope);
255
+ // Strip the _scope decoration that readAllPullRequests adds, in case
256
+ // a caller round-trips records from there back through here.
257
+ for (const pr of before) { if (pr && pr._scope) delete pr._scope; }
258
+ const beforeSnapshot = JSON.parse(JSON.stringify(before));
259
+ const next = mutator(before);
260
+ const after = (next === undefined || next === null)
261
+ ? before
262
+ : (Array.isArray(next) ? next : before);
263
+ const diff = _computePullRequestsDiff(scope, beforeSnapshot, after);
264
+ const wrote = _applyPullRequestsDiff(db, diff);
265
+ return { wrote, result: after };
266
+ });
267
+ }
268
+
269
+ function _mirrorJsonFromSql(scope, filePath) {
270
+ try {
271
+ const shared = require('./shared');
272
+ const items = readPullRequestsForScope(scope);
273
+ for (const pr of items) { if (pr && pr._scope) delete pr._scope; }
274
+ const target = filePath || _filePathForScope(scope);
275
+ shared.safeWrite(target, items);
276
+ const fp = _statFingerprint(target);
277
+ if (fp != null) _lastMirrorByScope.set(scope, fp);
278
+ } catch {
279
+ // Mirror failures are non-fatal — SQL has already committed.
280
+ }
281
+ }
282
+
283
+ function _resetPullRequestsTableForTest() {
284
+ const { getDb } = require('./db');
285
+ try { getDb().exec('DELETE FROM pull_requests'); } catch { /* not initialized */ }
286
+ }
287
+
288
+ module.exports = {
289
+ scopeForFilePath,
290
+ readPullRequestsForScope,
291
+ readAllPullRequests,
292
+ applyPullRequestsMutation,
293
+ _filePathForScope,
294
+ _mirrorJsonFromSql,
295
+ _resetPullRequestsTableForTest,
296
+ };
package/engine/queries.js CHANGED
@@ -327,8 +327,17 @@ function getNotesWithMeta() {
327
327
  }
328
328
 
329
329
  function getEngineLog() {
330
- // Use the lazy log-path resolver so test isolation (MINIONS_TEST_DIR) is
331
- // honored even when this module's require cache wasn't busted.
330
+ // Phase 4: prefer the SQL store. readRecentLogsChronological returns
331
+ // entries oldest-first (same shape as the legacy `.slice(-50)`), and is
332
+ // test-aware via engine/db.getDb() which re-resolves MINIONS_TEST_DIR.
333
+ // Falls back to the JSON mirror on SQL failure or when the table is
334
+ // empty (fresh install / pre-migration).
335
+ try {
336
+ const store = require('./logs-store');
337
+ const sqlEntries = store.readRecentLogsChronological(50);
338
+ if (Array.isArray(sqlEntries) && sqlEntries.length > 0) return sqlEntries;
339
+ } catch { /* fall through to JSON */ }
340
+
332
341
  const logJson = safeRead(shared.currentLogPath());
333
342
  if (!logJson) return [];
334
343
  try {
@@ -339,7 +348,16 @@ function getEngineLog() {
339
348
  }
340
349
 
341
350
  function getMetrics() {
342
- const metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
351
+ // Phase 5: prefer the SQL store. Falls back to the JSON mirror on
352
+ // SQLite failure or when the table is empty (fresh install /
353
+ // pre-migration). Returns the legacy flat object shape.
354
+ let metrics;
355
+ try {
356
+ const store = require('./metrics-store');
357
+ metrics = store.readMetrics() || {};
358
+ } catch {
359
+ metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
360
+ }
343
361
 
344
362
  for (const [agentId, m] of Object.entries(metrics)) {
345
363
  if (agentId.startsWith('_')) continue;
@@ -651,45 +669,74 @@ function getPullRequests(config) {
651
669
  const projectByName = new Map(projects.map(p => [p.name, p]));
652
670
  const allPrs = [];
653
671
  const seenIds = new Set();
654
- // Single pass over projects/* intersected with the configured project list.
655
- // Filesystem dirs not in CONFIG.projects (e.g. a leftover .minions/projects/
656
- // <removedName>/ recreated by a stale code path between archive and reload)
657
- // are skipped so removed projects can't resurrect themselves through the
658
- // status payload. Hidden dirs (.archived sidecar) are also skipped defensively
659
- // they live as siblings under projects/ and would otherwise be enumerated.
660
- let projectDirs = [];
672
+
673
+ // Phase 3: prefer the SQL store. Returns every scope's PRs tagged with
674
+ // `_scope`; we re-map to `_project` for backward compatibility with
675
+ // downstream consumers, intersecting with the configured project list
676
+ // to suppress orphan/removed projects (matches legacy behaviour).
677
+ let sqlPrs = null;
661
678
  try {
662
- projectDirs = fs.readdirSync(path.join(MINIONS_DIR, 'projects'), { withFileTypes: true })
663
- .filter(d => d.isDirectory() && !d.name.startsWith('.')).map(d => d.name);
664
- } catch { /* projects dir missing */ }
665
- for (const dirName of projectDirs) {
666
- const project = projectByName.get(dirName);
667
- if (!project) continue; // unconfigured/removed — don't surface
668
- const prPath = projectPrPath(project);
669
- const prs = readJsonNoRestore(prPath);
670
- if (!Array.isArray(prs)) continue;
671
- shared.normalizePrRecords(prs, project);
672
- const base = project.prUrlBase || '';
673
- for (const pr of prs) {
679
+ const store = require('./pull-requests-store');
680
+ sqlPrs = store.readAllPullRequests();
681
+ } catch { /* fall through to JSON */ }
682
+
683
+ if (Array.isArray(sqlPrs) && sqlPrs.length > 0) {
684
+ for (const pr of sqlPrs) {
674
685
  if (!pr?.id || seenIds.has(pr.id)) continue;
675
- if (!pr.url && base) {
676
- const prNumber = shared.getPrNumber(pr);
677
- if (prNumber != null) pr.url = base + prNumber;
686
+ const scope = pr._scope;
687
+ delete pr._scope;
688
+ if (scope === 'central') {
689
+ pr._project = 'central';
690
+ } else {
691
+ const project = projectByName.get(scope);
692
+ if (!project) continue; // orphan/removed project — don't surface
693
+ const base = project.prUrlBase || '';
694
+ if (!pr.url && base) {
695
+ const prNumber = shared.getPrNumber(pr);
696
+ if (prNumber != null) pr.url = base + prNumber;
697
+ }
698
+ shared.normalizePrRecords([pr], project);
699
+ pr._project = project.name || 'Project';
678
700
  }
679
- pr._project = project.name || 'Project';
680
701
  allPrs.push(pr);
681
702
  seenIds.add(pr.id);
682
703
  }
683
- }
684
- // Central pull-requests.jsonmanually linked PRs without a project
685
- const centralPrs = readJsonNoRestore(path.join(MINIONS_DIR, 'pull-requests.json'));
686
- if (centralPrs) {
687
- shared.normalizePrRecords(centralPrs, null);
688
- for (const pr of centralPrs) {
689
- if (!pr?.id || seenIds.has(pr.id)) continue;
690
- pr._project = 'central';
691
- allPrs.push(pr);
692
- seenIds.add(pr.id);
704
+ } else {
705
+ // SQL empty or unavailable fall back to per-file JSON read. Matches
706
+ // the original layout: per-project dirs first, central last.
707
+ let projectDirs = [];
708
+ try {
709
+ projectDirs = fs.readdirSync(path.join(MINIONS_DIR, 'projects'), { withFileTypes: true })
710
+ .filter(d => d.isDirectory() && !d.name.startsWith('.')).map(d => d.name);
711
+ } catch { /* projects dir missing */ }
712
+ for (const dirName of projectDirs) {
713
+ const project = projectByName.get(dirName);
714
+ if (!project) continue;
715
+ const prPath = projectPrPath(project);
716
+ const prs = readJsonNoRestore(prPath);
717
+ if (!Array.isArray(prs)) continue;
718
+ shared.normalizePrRecords(prs, project);
719
+ const base = project.prUrlBase || '';
720
+ for (const pr of prs) {
721
+ if (!pr?.id || seenIds.has(pr.id)) continue;
722
+ if (!pr.url && base) {
723
+ const prNumber = shared.getPrNumber(pr);
724
+ if (prNumber != null) pr.url = base + prNumber;
725
+ }
726
+ pr._project = project.name || 'Project';
727
+ allPrs.push(pr);
728
+ seenIds.add(pr.id);
729
+ }
730
+ }
731
+ const centralPrs = readJsonNoRestore(path.join(MINIONS_DIR, 'pull-requests.json'));
732
+ if (centralPrs) {
733
+ shared.normalizePrRecords(centralPrs, null);
734
+ for (const pr of centralPrs) {
735
+ if (!pr?.id || seenIds.has(pr.id)) continue;
736
+ pr._project = 'central';
737
+ allPrs.push(pr);
738
+ seenIds.add(pr.id);
739
+ }
693
740
  }
694
741
  }
695
742
  allPrs.sort((a, b) => {
package/engine/shared.js CHANGED
@@ -285,6 +285,17 @@ function log(level, msg, meta = {}) {
285
285
  writable: true,
286
286
  configurable: true,
287
287
  });
288
+ // Capture the SQL state.db path too — same write-time semantics so a
289
+ // deferred buffer flush after the test ends still routes the entry to
290
+ // the test's state.db (not the production one). Path resolution mirrors
291
+ // engine/db.js but inlined to avoid the require cycle that loading the
292
+ // db module here would create on module init.
293
+ Object.defineProperty(entry, '_dbPath', {
294
+ value: _currentLogDbPath(),
295
+ enumerable: false,
296
+ writable: true,
297
+ configurable: true,
298
+ });
288
299
 
289
300
  _logBuffer.push(entry);
290
301
 
@@ -337,6 +348,19 @@ function _currentLogPath() {
337
348
  return path.join(root, 'engine', 'log.json');
338
349
  }
339
350
 
351
+ // Resolve the SQL state.db path at write time — same isolation contract
352
+ // as _currentLogPath. MINIONS_LOG_PATH only redirects the JSON sidecar;
353
+ // the SQL store stays attached to MINIONS_TEST_DIR / MINIONS_HOME because
354
+ // it shares the file with the rest of the engine state (dispatches,
355
+ // work_items, pull_requests).
356
+ function _currentLogDbPath() {
357
+ if (process.env.MINIONS_TEST_DIR) {
358
+ return path.join(path.resolve(process.env.MINIONS_TEST_DIR), 'engine', 'state.db');
359
+ }
360
+ const root = process.env.MINIONS_HOME ? path.resolve(process.env.MINIONS_HOME) : path.resolve(__dirname, '..');
361
+ return path.join(root, 'engine', 'state.db');
362
+ }
363
+
340
364
  // MINIONS_TEST_DIR-aware engine dir for cache files (runtime caps, models,
341
365
  // spawn-debug.log). Returns `<MINIONS_TEST_DIR>/engine` under tests; otherwise
342
366
  // the caller's source-adjacent fallback (typically `__dirname`-derived). Never
@@ -366,23 +390,44 @@ function openUrlInBrowser(url) {
366
390
  function _flushLogBuffer() {
367
391
  if (_logBuffer.length === 0) return;
368
392
  const drained = _logBuffer.splice(0);
369
- // Group entries by their captured _logPath so test-originated entries always
370
- // land in the test dir's log.json even if MINIONS_TEST_DIR has been cleared
371
- // by the time we flush. Entries without _logPath fall back to current path
372
- // (eg. direct _logBuffer.push() from tests).
373
- const fallbackPath = _currentLogPath();
374
- const byPath = new Map();
393
+ // Group entries by their captured (_logPath, _dbPath) so test-originated
394
+ // entries always land in the test dir's storage even if MINIONS_TEST_DIR
395
+ // has been cleared by the time we flush. Entries without captured paths
396
+ // fall back to the current resolved values (eg. direct _logBuffer.push()
397
+ // from tests).
398
+ const fallbackJsonPath = _currentLogPath();
399
+ const fallbackDbPath = _currentLogDbPath();
400
+ const byJsonPath = new Map();
401
+ const byDbPath = new Map();
375
402
  for (const raw of drained) {
376
- const target = raw._logPath || fallbackPath;
403
+ const jsonTarget = raw._logPath || fallbackJsonPath;
404
+ const dbTarget = raw._dbPath || fallbackDbPath;
377
405
  // SEC-09 defense-in-depth: redact again at flush time so any direct
378
406
  // `_logBuffer.push(entry)` callers (tests, future paths) can't leak secrets.
379
407
  const entry = redactSecrets(raw);
380
408
  // Strip the routing-only metadata before persisting.
381
409
  delete entry._logPath;
382
- if (!byPath.has(target)) byPath.set(target, []);
383
- byPath.get(target).push(entry);
410
+ delete entry._dbPath;
411
+ if (!byJsonPath.has(jsonTarget)) byJsonPath.set(jsonTarget, []);
412
+ byJsonPath.get(jsonTarget).push(entry);
413
+ if (!byDbPath.has(dbTarget)) byDbPath.set(dbTarget, []);
414
+ byDbPath.get(dbTarget).push(entry);
384
415
  }
385
- for (const [target, entries] of byPath) {
416
+
417
+ // SQL append (Phase 4 source of truth). Per-path append so test
418
+ // state.db files stay isolated; logging must never crash the caller, so
419
+ // appendLogsToDbFile already swallows its own errors.
420
+ try {
421
+ const store = require('./logs-store');
422
+ for (const [dbPath, entries] of byDbPath) {
423
+ store.appendLogsToDbFile(dbPath, entries);
424
+ }
425
+ } catch { /* logs-store unavailable (node<22.5) — JSON mirror still works */ }
426
+
427
+ // JSON mirror — kept for backward compat with test fixtures that
428
+ // safeJson(log.json) directly, and the legacy getEngineLog fallback.
429
+ // Phase 4.5 will retire this once readers move to readRecentLogs().
430
+ for (const [target, entries] of byJsonPath) {
386
431
  try {
387
432
  mutateJsonFileLocked(target, (logData) => {
388
433
  if (!Array.isArray(logData)) logData = logData?.entries || [];
@@ -1806,6 +1851,8 @@ const ENGINE_DEFAULTS = {
1806
1851
  versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
1807
1852
  logFlushInterval: 5000, // 5s — how often to flush buffered log entries to disk
1808
1853
  logBufferSize: 50, // flush immediately when buffer exceeds this many entries
1854
+ logCapEntries: 2500, // ring-buffer cap on `logs` SQL table; trims to logCapEntries-logCapTrimTo on overflow
1855
+ logCapTrimTo: 2000, // size to trim back to when cap is hit (matches legacy JSON splice 2500→2000)
1809
1856
  lockRetries: 0, // no retries — single 5s timeout window with 25ms polling (200 attempts) is sufficient; stale lock recovery at 60s handles crashes
1810
1857
  lockRetryBackoffMs: 500, // base backoff between lock retries (doubles each attempt: 500ms, 1s, 2s, ...)
1811
1858
  buildFixGracePeriod: 600000, // 10min — wait for CI to run after a verified build-fix push before re-dispatching
@@ -2643,13 +2690,52 @@ const WATCH_ACTION_TYPE = {
2643
2690
  RESUME_PLAN: 'resume-plan',
2644
2691
  };
2645
2692
 
2693
+ /**
2694
+ * Route a metrics mutation through the SQL store with a JSON dual-write
2695
+ * mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
2696
+ * receives the full legacy-shape metrics object, mutates in place or
2697
+ * returns a replacement, and the store diffs by (kind, key) row.
2698
+ *
2699
+ * Falls back to the legacy mutateJsonFileLocked path on SQLite failure
2700
+ * so a node:sqlite-broken install keeps recording metrics.
2701
+ */
2702
+ function mutateMetrics(mutator) {
2703
+ const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
2704
+ try {
2705
+ const store = require('./metrics-store');
2706
+ const { wrote, result } = store.applyMetricsMutation((m) => {
2707
+ if (!m || typeof m !== 'object') m = {};
2708
+ return mutator(m) || m;
2709
+ });
2710
+ if (wrote) {
2711
+ try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
2712
+ try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
2713
+ }
2714
+ return result;
2715
+ } catch (e) {
2716
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
2717
+ throw e;
2718
+ }
2719
+ // SQLite unavailable — fall through to legacy JSON path.
2720
+ }
2721
+ return mutateJsonFileLocked(metricsPath, (metrics) => {
2722
+ if (!metrics || typeof metrics !== 'object') metrics = {};
2723
+ return mutator(metrics) || metrics;
2724
+ }, {
2725
+ defaultValue: {},
2726
+ onWrote: () => {
2727
+ try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
2728
+ },
2729
+ });
2730
+ }
2731
+
2646
2732
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
2647
2733
  function trackReviewMetric(pr, newReviewStatus, config) {
2648
2734
  if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
2649
2735
  const authorId = (pr.agent || '').toLowerCase();
2650
2736
  if (!authorId || !config?.agents?.[authorId]) return;
2651
2737
  try {
2652
- mutateJsonFileLocked(path.join(MINIONS_DIR, 'engine', 'metrics.json'), (metrics) => {
2738
+ mutateMetrics((metrics) => {
2653
2739
  if (!metrics[authorId]) metrics[authorId] = { ...DEFAULT_AGENT_METRICS };
2654
2740
  if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
2655
2741
  else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
@@ -4643,6 +4729,35 @@ function reopenWorkItem(wi) {
4643
4729
  * @param {Function} mutator - Receives the array, mutates in place or returns new value
4644
4730
  */
4645
4731
  function mutatePullRequests(filePath, mutator) {
4732
+ // Phase 3 SQL path. Same shape as Phase 2's mutateWorkItems: route
4733
+ // through the pull-requests-store when filePath sits under MINIONS_DIR,
4734
+ // mirror back to JSON for legacy direct-readers, emit a pull_requests
4735
+ // event only on real writes. Ad-hoc tmp paths (legacy tests using
4736
+ // createTmpDir) and SQLite failures fall through to the JSON path.
4737
+ const fpNorm = String(filePath).replace(/\\/g, '/');
4738
+ const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
4739
+ const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/pull-requests.json';
4740
+ if (insideMinionsDir) {
4741
+ try {
4742
+ const store = require('./pull-requests-store');
4743
+ const scope = store.scopeForFilePath(filePath);
4744
+ const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
4745
+ if (!Array.isArray(prs)) prs = [];
4746
+ return mutator(prs) || prs;
4747
+ });
4748
+ if (wrote) {
4749
+ try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror is best-effort */ }
4750
+ try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
4751
+ }
4752
+ return result;
4753
+ } catch (e) {
4754
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
4755
+ throw e;
4756
+ }
4757
+ // Fall through to legacy JSON path on SQLite errors only.
4758
+ }
4759
+ }
4760
+
4646
4761
  return mutateJsonFileLocked(filePath, (data) => {
4647
4762
  if (!Array.isArray(data)) data = [];
4648
4763
  return mutator(data) || data;
@@ -5051,7 +5166,7 @@ module.exports = {
5051
5166
  runtimeConfigWarnings,
5052
5167
  projectWorkSourceWarnings,
5053
5168
  backfillProjectWorkSourceDefaults,
5054
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5169
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, mutateMetrics, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5055
5170
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
5056
5171
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
5057
5172
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
@@ -116,24 +116,28 @@ function readWorkItemsForScope(scope) {
116
116
  // scope is bounded by the JSON's row count.
117
117
  function _resyncScopeIfJsonDiverged(db, scope) {
118
118
  const jsonPath = _filePathForScope(scope);
119
- const currentMtime = _statMtimeMs(jsonPath);
120
- const lastMirrorMtime = _lastMirrorMtimeByScope.get(scope);
121
- if (currentMtime == null) return; // JSON absent → nothing to resync
122
- if (lastMirrorMtime != null && Math.abs(currentMtime - lastMirrorMtime) <= 1) return; // in sync
119
+ const current = _statFingerprint(jsonPath);
120
+ const lastMirror = _lastMirrorByScope.get(scope);
121
+ if (current == null) return; // JSON absent → nothing to resync
122
+ // In-sync iff BOTH mtime AND size match. Either differing signals an
123
+ // external write — including the same-ms-tick safeWrite pattern that
124
+ // mtime-only checks miss on Windows (ms-rounded mtimeMs collides).
125
+ // Resync is idempotent (DELETE + bulk re-INSERT from JSON).
126
+ if (lastMirror != null
127
+ && current.mtime === lastMirror.mtime
128
+ && current.size === lastMirror.size) return;
123
129
  // No mirror record but JSON exists: first-time hydrate iff SQL is empty
124
130
  // for this scope (avoid trampling a freshly-backfilled migration state
125
131
  // on the very first read).
126
- if (lastMirrorMtime == null) {
132
+ if (lastMirror == null) {
127
133
  const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
128
134
  if (sqlHas) {
129
- // Record current mtime as the baseline so the next divergence is
130
- // detected, but don't rebuild — migration already populated SQL.
131
- _lastMirrorMtimeByScope.set(scope, currentMtime);
135
+ _lastMirrorByScope.set(scope, current);
132
136
  return;
133
137
  }
134
138
  }
135
139
  _hydrateScopeFromJson(db, scope);
136
- _lastMirrorMtimeByScope.set(scope, currentMtime);
140
+ _lastMirrorByScope.set(scope, current);
137
141
  }
138
142
 
139
143
  // Read all rows across all scopes — used by queries.getWorkItems which
@@ -239,17 +243,19 @@ function _applyWorkItemsDiff(db, diff) {
239
243
  // wrote — true iff at least one INSERT/UPDATE/DELETE landed
240
244
  // result — the post-mutation array (legacy return shape of
241
245
  // mutateJsonFileLocked)
242
- // In-process record of the JSON mirror mtime we last wrote, keyed by
243
- // scope. Used to detect external JSON edits between mutations — both
244
- // (a) tests that clean up + re-seed via fs.writeFileSync, and
245
- // (b) operators that hand-edit work-items.json in production. When the
246
- // current JSON mtime differs from what we recorded, the JSON has been
247
- // rewritten outside the store; we treat the JSON as canonical and
248
- // re-hydrate SQL from it before computing the diff.
249
- const _lastMirrorMtimeByScope = new Map();
246
+ // In-process record of (mtime, size) of the JSON mirror we last wrote,
247
+ // keyed by scope. Two-field fingerprint catches the same-ms-tick write
248
+ // pattern that mtime-only checks miss on Windows: NTFS reports
249
+ // ms-rounded mtimeMs, so back-to-back writes inside the same tick look
250
+ // identical to "no change" by mtime alone. File size catches the rest
251
+ // content swaps almost always change byte length.
252
+ const _lastMirrorByScope = new Map();
250
253
 
251
- function _statMtimeMs(filePath) {
252
- try { return fs.statSync(filePath).mtimeMs; }
254
+ function _statFingerprint(filePath) {
255
+ try {
256
+ const st = fs.statSync(filePath);
257
+ return { mtime: st.mtimeMs, size: st.size };
258
+ }
253
259
  catch { return null; }
254
260
  }
255
261
 
@@ -325,10 +331,11 @@ function _mirrorJsonFromSql(scope, filePath) {
325
331
  for (const wi of items) { if (wi && wi._source) delete wi._source; }
326
332
  const target = filePath || _filePathForScope(scope);
327
333
  shared.safeWrite(target, items);
328
- // Record the mtime we just wrote so the next mutation can detect
329
- // an external edit (mtime advanced while we weren't looking).
330
- const m = _statMtimeMs(target);
331
- if (m != null) _lastMirrorMtimeByScope.set(scope, m);
334
+ // Record (mtime, size) we just wrote so the next mutation can detect
335
+ // an external edit. mtime alone misses same-ms-tick writes; size
336
+ // catches them.
337
+ const fp = _statFingerprint(target);
338
+ if (fp != null) _lastMirrorByScope.set(scope, fp);
332
339
  } catch {
333
340
  // Mirror failures are non-fatal: SQL has already committed.
334
341
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2068",
3
+ "version": "0.1.2070",
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"