@yemi33/minions 0.1.2069 → 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,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]) {
Binary file
@@ -62,10 +62,16 @@ function _readJsonArrayFallback(scope) {
62
62
  }
63
63
  }
64
64
 
65
- const _lastMirrorMtimeByScope = new Map();
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();
66
69
 
67
- function _statMtimeMs(filePath) {
68
- try { return fs.statSync(filePath).mtimeMs; }
70
+ function _statFingerprint(filePath) {
71
+ try {
72
+ const st = fs.statSync(filePath);
73
+ return { mtime: st.mtimeMs, size: st.size };
74
+ }
69
75
  catch { return null; }
70
76
  }
71
77
 
@@ -99,19 +105,23 @@ function _hydrateScopeFromJson(db, scope) {
99
105
 
100
106
  function _resyncScopeIfJsonDiverged(db, scope) {
101
107
  const jsonPath = _filePathForScope(scope);
102
- const currentMtime = _statMtimeMs(jsonPath);
103
- const lastMirrorMtime = _lastMirrorMtimeByScope.get(scope);
104
- if (currentMtime == null) return;
105
- if (lastMirrorMtime != null && Math.abs(currentMtime - lastMirrorMtime) <= 1) return;
106
- if (lastMirrorMtime == null) {
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) {
107
117
  const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
108
118
  if (sqlHas) {
109
- _lastMirrorMtimeByScope.set(scope, currentMtime);
119
+ _lastMirrorByScope.set(scope, current);
110
120
  return;
111
121
  }
112
122
  }
113
123
  _hydrateScopeFromJson(db, scope);
114
- _lastMirrorMtimeByScope.set(scope, currentMtime);
124
+ _lastMirrorByScope.set(scope, current);
115
125
  }
116
126
 
117
127
  function readPullRequestsForScope(scope) {
@@ -263,8 +273,8 @@ function _mirrorJsonFromSql(scope, filePath) {
263
273
  for (const pr of items) { if (pr && pr._scope) delete pr._scope; }
264
274
  const target = filePath || _filePathForScope(scope);
265
275
  shared.safeWrite(target, items);
266
- const m = _statMtimeMs(target);
267
- if (m != null) _lastMirrorMtimeByScope.set(scope, m);
276
+ const fp = _statFingerprint(target);
277
+ if (fp != null) _lastMirrorByScope.set(scope, fp);
268
278
  } catch {
269
279
  // Mirror failures are non-fatal — SQL has already committed.
270
280
  }
package/engine/queries.js CHANGED
@@ -348,7 +348,16 @@ function getEngineLog() {
348
348
  }
349
349
 
350
350
  function getMetrics() {
351
- 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
+ }
352
361
 
353
362
  for (const [agentId, m] of Object.entries(metrics)) {
354
363
  if (agentId.startsWith('_')) continue;
package/engine/shared.js CHANGED
@@ -2690,13 +2690,52 @@ const WATCH_ACTION_TYPE = {
2690
2690
  RESUME_PLAN: 'resume-plan',
2691
2691
  };
2692
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
+
2693
2732
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
2694
2733
  function trackReviewMetric(pr, newReviewStatus, config) {
2695
2734
  if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
2696
2735
  const authorId = (pr.agent || '').toLowerCase();
2697
2736
  if (!authorId || !config?.agents?.[authorId]) return;
2698
2737
  try {
2699
- mutateJsonFileLocked(path.join(MINIONS_DIR, 'engine', 'metrics.json'), (metrics) => {
2738
+ mutateMetrics((metrics) => {
2700
2739
  if (!metrics[authorId]) metrics[authorId] = { ...DEFAULT_AGENT_METRICS };
2701
2740
  if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
2702
2741
  else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
@@ -5127,7 +5166,7 @@ module.exports = {
5127
5166
  runtimeConfigWarnings,
5128
5167
  projectWorkSourceWarnings,
5129
5168
  backfillProjectWorkSourceDefaults,
5130
- 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,
5131
5170
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
5132
5171
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
5133
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.2069",
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"