@yemi33/minions 0.1.2065 → 0.1.2067

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.
@@ -17,6 +17,7 @@
17
17
 
18
18
  const registry = new Map();
19
19
  registry.set('claude', require('./claude'));
20
+ registry.set('codex', require('./codex'));
20
21
  registry.set('copilot', require('./copilot'));
21
22
 
22
23
  /**
package/engine/shared.js CHANGED
@@ -1088,7 +1088,8 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1088
1088
  defaultValue = {},
1089
1089
  lockRetries,
1090
1090
  lockRetryBackoffMs,
1091
- skipWriteIfUnchanged = false
1091
+ skipWriteIfUnchanged = false,
1092
+ onWrote = null,
1092
1093
  } = {}) {
1093
1094
  const lockPath = `${filePath}.lock`;
1094
1095
  const retries = lockRetries ?? ENGINE_DEFAULTS.lockRetries;
@@ -1110,6 +1111,13 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1110
1111
  const backupPath = filePath + '.backup';
1111
1112
  try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1112
1113
  safeWrite(filePath, finalData);
1114
+ // Side-effect hook fired only when an actual write happened. Callers
1115
+ // use this to emit cache-invalidation signals (events table row) so
1116
+ // skip-write-unchanged paths (dedup, idempotent no-op POSTs) don't
1117
+ // produce spurious cache busts.
1118
+ if (typeof onWrote === 'function') {
1119
+ try { onWrote(finalData); } catch { /* hook errors must not break the write */ }
1120
+ }
1113
1121
  }
1114
1122
  return finalData;
1115
1123
  }, { retries, retryBackoffMs });
@@ -4564,14 +4572,53 @@ function listProcessReachable(rootPids, allProcesses = null) {
4564
4572
  * @param {Function} mutator - Receives the array, mutates in place or returns new value
4565
4573
  */
4566
4574
  function mutateWorkItems(filePath, mutator) {
4575
+ // Phase 2 SQL path. Route through work-items-store so SQL is the source
4576
+ // of truth; mirror back to the JSON file for legacy direct-readers.
4577
+ //
4578
+ // The SQL store identifies records by `scope` (central or project name)
4579
+ // derived from the file path's last two segments. Ad-hoc file paths
4580
+ // outside the MINIONS_DIR layout (e.g. tests using createTmpDir()) can't
4581
+ // be mapped to a stable scope, so we short-circuit to the legacy
4582
+ // JSON path for those. Production callers always use
4583
+ // shared.projectWorkItemsPath(p) / MINIONS_DIR/work-items.json.
4584
+ //
4585
+ // SQLite failures fall through to the legacy JSON path below — keeps a
4586
+ // node:sqlite-broken install fully functional.
4587
+ const fpNorm = String(filePath).replace(/\\/g, '/');
4588
+ const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
4589
+ const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/work-items.json';
4590
+ if (insideMinionsDir) {
4591
+ try {
4592
+ const store = require('./work-items-store');
4593
+ const scope = store.scopeForFilePath(filePath);
4594
+ const { wrote, result } = store.applyWorkItemsMutation(scope, (items) => {
4595
+ if (!Array.isArray(items)) items = [];
4596
+ return mutator(items) || items;
4597
+ });
4598
+ if (wrote) {
4599
+ try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror is best-effort */ }
4600
+ try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
4601
+ }
4602
+ try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
4603
+ return result;
4604
+ } catch (e) {
4605
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
4606
+ throw e;
4607
+ }
4608
+ // Fall through to the legacy JSON path on SQLite errors only.
4609
+ }
4610
+ }
4611
+
4567
4612
  const result = mutateJsonFileLocked(filePath, (data) => {
4568
4613
  if (!Array.isArray(data)) data = [];
4569
4614
  return mutator(data) || data;
4570
- }, { defaultValue: [], skipWriteIfUnchanged: true });
4571
- // Invalidate the read cache so the next getWorkItems() sees fresh data
4572
- // (W-mpodww9h000b460a). Mirrors dispatch.js's invalidateDispatchCache
4573
- // call after mutateDispatch. Lazy-required to avoid the queries→shared
4574
- // require cycle.
4615
+ }, {
4616
+ defaultValue: [],
4617
+ skipWriteIfUnchanged: true,
4618
+ onWrote: () => {
4619
+ try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
4620
+ },
4621
+ });
4575
4622
  try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
4576
4623
  return result;
4577
4624
  }
@@ -4599,7 +4646,12 @@ function mutatePullRequests(filePath, mutator) {
4599
4646
  return mutateJsonFileLocked(filePath, (data) => {
4600
4647
  if (!Array.isArray(data)) data = [];
4601
4648
  return mutator(data) || data;
4602
- }, { defaultValue: [] });
4649
+ }, {
4650
+ defaultValue: [],
4651
+ onWrote: () => {
4652
+ try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
4653
+ },
4654
+ });
4603
4655
  }
4604
4656
 
4605
4657
  /**
@@ -0,0 +1,351 @@
1
+ // engine/work-items-store.js — SQL-backed implementation of the
2
+ // per-file work-items array. Same external contract as the legacy
3
+ // JSON-file reader:
4
+ //
5
+ // readWorkItemsForScope(scope) -> [wi, wi, ...]
6
+ // applyWorkItemsMutation(scope, fn) -> runs fn against the array,
7
+ // diffs by id, applies in one
8
+ // transaction, returns
9
+ // { wrote, result }.
10
+ //
11
+ // `scope` is 'central' for the top-level work-items.json or a project
12
+ // name for projects/<name>/work-items.json. Mirrors the file convention
13
+ // established by migration 003.
14
+ //
15
+ // All callers go through shared.mutateWorkItems and
16
+ // queries.getWorkItems — those are thin wrappers over the helpers here.
17
+
18
+ const path = require('path');
19
+ const fs = require('fs');
20
+
21
+ function _toMs(v) {
22
+ if (v == null) return null;
23
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
24
+ const parsed = Date.parse(v);
25
+ return Number.isFinite(parsed) ? parsed : null;
26
+ }
27
+
28
+ // Map an absolute filePath → scope key. The store doesn't know about the
29
+ // caller's file layout; we derive scope from the path's last two segments
30
+ // because every work-items file in this codebase sits at either
31
+ // <root>/work-items.json (central) or <root>/projects/<name>/work-items.json.
32
+ function scopeForFilePath(filePath) {
33
+ const norm = String(filePath).replace(/\\/g, '/');
34
+ const m = norm.match(/projects\/([^/]+)\/work-items\.json$/);
35
+ if (m) return m[1];
36
+ // Anything else — including the central work-items.json under MINIONS_DIR
37
+ // and any one-off test fixture file — lands in 'central'. Tests that
38
+ // need to keep multiple fixture files distinct should put them under a
39
+ // synthetic projects/<name>/ subtree.
40
+ return 'central';
41
+ }
42
+
43
+ // JSON fallback path resolution. Used both for the empty-SQL fallback
44
+ // (test seeding pattern from Phase 1) and for the post-write JSON mirror.
45
+ function _filePathForScope(scope) {
46
+ const shared = require('./shared');
47
+ if (scope === 'central') {
48
+ return path.join(shared.MINIONS_DIR, 'work-items.json');
49
+ }
50
+ return path.join(shared.MINIONS_DIR, 'projects', scope, 'work-items.json');
51
+ }
52
+
53
+ function _parseRow(row) {
54
+ if (!row || !row.data) return null;
55
+ try { return JSON.parse(row.data); }
56
+ catch { return null; }
57
+ }
58
+
59
+ function _readJsonArrayFallback(scope) {
60
+ const fp = _filePathForScope(scope);
61
+ let raw;
62
+ try { raw = fs.readFileSync(fp, 'utf8'); }
63
+ catch { return []; }
64
+ try {
65
+ const parsed = JSON.parse(raw);
66
+ return Array.isArray(parsed) ? parsed : [];
67
+ } catch (e) {
68
+ try {
69
+ // eslint-disable-next-line no-console
70
+ console.warn(`[work-items-store] corrupt JSON in ${fp}: ${e.message}`);
71
+ } catch { /* console may be wrapped in tests */ }
72
+ return [];
73
+ }
74
+ }
75
+
76
+ function readWorkItemsForScope(scope) {
77
+ const { getDb } = require('./db');
78
+ let db;
79
+ try { db = getDb(); }
80
+ catch { return _readJsonArrayFallback(scope); }
81
+
82
+ _resyncScopeIfJsonDiverged(db, scope);
83
+
84
+ const rows = db.prepare(`
85
+ SELECT data FROM work_items
86
+ WHERE scope = ?
87
+ ORDER BY rowid
88
+ `).all(scope);
89
+
90
+ if (rows.length === 0) {
91
+ // Same fallback shape as dispatch-store: SQL empty AND JSON has
92
+ // content means a test seeded via fs.writeFileSync (legacy helper)
93
+ // or a fresh install pre-migration. Returning the JSON keeps those
94
+ // call sites working without touching every helper.
95
+ const fallback = _readJsonArrayFallback(scope);
96
+ if (fallback.length > 0) return fallback;
97
+ return [];
98
+ }
99
+
100
+ const out = [];
101
+ for (const row of rows) {
102
+ const wi = _parseRow(row);
103
+ if (wi) out.push(wi);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ // If the JSON mirror's mtime advanced since our last mirror write, the
109
+ // file was rewritten outside the store (test cleanup + safeWrite, manual
110
+ // operator edit, external tool). Treat the JSON as canonical and rebuild
111
+ // SQL for that scope from it. Called from BOTH read and write paths so
112
+ // readers don't see stale SQL state and writers don't compute diffs
113
+ // against a SQL snapshot that no longer matches what callers expect.
114
+ //
115
+ // Safe to call inside `withTransaction` — DELETE + INSERT against a
116
+ // scope is bounded by the JSON's row count.
117
+ function _resyncScopeIfJsonDiverged(db, scope) {
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
123
+ // No mirror record but JSON exists: first-time hydrate iff SQL is empty
124
+ // for this scope (avoid trampling a freshly-backfilled migration state
125
+ // on the very first read).
126
+ if (lastMirrorMtime == null) {
127
+ const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
128
+ 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);
132
+ return;
133
+ }
134
+ }
135
+ _hydrateScopeFromJson(db, scope);
136
+ _lastMirrorMtimeByScope.set(scope, currentMtime);
137
+ }
138
+
139
+ // Read all rows across all scopes — used by queries.getWorkItems which
140
+ // needs to surface central + every project's items in a single shot,
141
+ // tagged with their source scope.
142
+ function readAllWorkItems() {
143
+ const { getDb } = require('./db');
144
+ let db;
145
+ try { db = getDb(); }
146
+ catch { return null; } // signal: SQL unavailable, caller must fall back
147
+
148
+ // Pick up any external JSON edits for every scope SQL knows about.
149
+ // Also resync 'central' explicitly so first-time reads on a JSON-only
150
+ // install hydrate the central scope. Per-project scopes get checked
151
+ // lazily on next read or mutation of that scope.
152
+ try { _resyncScopeIfJsonDiverged(db, 'central'); } catch {}
153
+ const knownScopes = db.prepare('SELECT DISTINCT scope FROM work_items').all().map(r => r.scope);
154
+ for (const s of knownScopes) {
155
+ if (s === 'central') continue;
156
+ try { _resyncScopeIfJsonDiverged(db, s); } catch {}
157
+ }
158
+
159
+ const rows = db.prepare('SELECT data, scope FROM work_items ORDER BY rowid').all();
160
+ const out = [];
161
+ for (const row of rows) {
162
+ const wi = _parseRow(row);
163
+ if (!wi) continue;
164
+ // Preserve the legacy `_source` convention from queries.getWorkItems.
165
+ wi._source = row.scope;
166
+ out.push(wi);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function _indexById(arr) {
172
+ const out = new Map();
173
+ for (const wi of arr) {
174
+ if (!wi || !wi.id) continue;
175
+ out.set(String(wi.id), wi);
176
+ }
177
+ return out;
178
+ }
179
+
180
+ function _computeWorkItemsDiff(scope, before, after) {
181
+ const beforeMap = _indexById(before);
182
+ const afterMap = _indexById(after);
183
+ const toUpsert = [];
184
+ const toDelete = [];
185
+ for (const [id, wi] of afterMap) {
186
+ const prev = beforeMap.get(id);
187
+ if (!prev) { toUpsert.push(wi); continue; }
188
+ if (JSON.stringify(prev) !== JSON.stringify(wi)) toUpsert.push(wi);
189
+ }
190
+ for (const [id] of beforeMap) {
191
+ if (!afterMap.has(id)) toDelete.push(id);
192
+ }
193
+ return { scope, toUpsert, toDelete };
194
+ }
195
+
196
+ function _applyWorkItemsDiff(db, diff) {
197
+ if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
198
+ const now = Date.now();
199
+ const upsertStmt = db.prepare(`
200
+ INSERT INTO work_items (id, scope, status, type, agent, parent_id, created_at, completed_at, data, updated_at)
201
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
202
+ ON CONFLICT(scope, id) DO UPDATE SET
203
+ status = excluded.status,
204
+ type = excluded.type,
205
+ agent = excluded.agent,
206
+ parent_id = excluded.parent_id,
207
+ created_at = excluded.created_at,
208
+ completed_at = excluded.completed_at,
209
+ data = excluded.data,
210
+ updated_at = excluded.updated_at
211
+ `);
212
+ const deleteStmt = db.prepare('DELETE FROM work_items WHERE scope = ? AND id = ?');
213
+
214
+ for (const wi of diff.toUpsert) {
215
+ upsertStmt.run(
216
+ String(wi.id),
217
+ diff.scope,
218
+ String(wi.status || 'pending'),
219
+ wi.type || null,
220
+ wi.dispatched_to || wi.agent || null,
221
+ wi.parent_id || null,
222
+ _toMs(wi.created_at),
223
+ _toMs(wi.completed_at || wi.completedAt),
224
+ JSON.stringify(wi),
225
+ now,
226
+ );
227
+ }
228
+ for (const id of diff.toDelete) {
229
+ deleteStmt.run(diff.scope, id);
230
+ }
231
+ return true;
232
+ }
233
+
234
+ // Apply a mutation to the work-items array for a given scope. Wrapped in
235
+ // a single transaction so the diff and apply can't race against a
236
+ // concurrent reader/writer.
237
+ //
238
+ // Returns { wrote, result }:
239
+ // wrote — true iff at least one INSERT/UPDATE/DELETE landed
240
+ // result — the post-mutation array (legacy return shape of
241
+ // 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();
250
+
251
+ function _statMtimeMs(filePath) {
252
+ try { return fs.statSync(filePath).mtimeMs; }
253
+ catch { return null; }
254
+ }
255
+
256
+ function _hydrateScopeFromJson(db, scope) {
257
+ const jsonItems = _readJsonArrayFallback(scope);
258
+ // DELETE before re-insert: callers that wrote a smaller JSON file
259
+ // (test cleanup() removing items) must end up with a smaller SQL state.
260
+ db.prepare('DELETE FROM work_items WHERE scope = ?').run(scope);
261
+ if (jsonItems.length === 0) return;
262
+ const now = Date.now();
263
+ const ins = db.prepare(`
264
+ INSERT INTO work_items (id, scope, status, type, agent, parent_id, created_at, completed_at, data, updated_at)
265
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
266
+ ON CONFLICT(scope, id) DO NOTHING
267
+ `);
268
+ for (const wi of jsonItems) {
269
+ if (!wi || !wi.id) continue;
270
+ ins.run(
271
+ String(wi.id),
272
+ scope,
273
+ String(wi.status || 'pending'),
274
+ wi.type || null,
275
+ wi.dispatched_to || wi.agent || null,
276
+ wi.parent_id || null,
277
+ _toMs(wi.created_at),
278
+ _toMs(wi.completed_at || wi.completedAt),
279
+ JSON.stringify(wi),
280
+ now,
281
+ );
282
+ }
283
+ }
284
+
285
+ function applyWorkItemsMutation(scope, mutator) {
286
+ const { getDb, withTransaction } = require('./db');
287
+ let db;
288
+ try { db = getDb(); }
289
+ catch (e) {
290
+ throw new Error(`engine/work-items-store: SQLite unavailable (${e.message}); cannot mutate work_items`);
291
+ }
292
+
293
+ return withTransaction(db, () => {
294
+ // Re-hydrate SQL from JSON if the file was touched outside the
295
+ // store (external edit, test re-seed, first-time hydrate after a
296
+ // fresh install). Without this, the diff would compare against a
297
+ // stale SQL snapshot and the mirror write would overwrite the JSON
298
+ // with that stale state.
299
+ _resyncScopeIfJsonDiverged(db, scope);
300
+
301
+ const before = readWorkItemsForScope(scope);
302
+ // Strip the _source decoration before handing to the mutator — it's
303
+ // added by readAllWorkItems only and would round-trip as garbage into
304
+ // the data column otherwise.
305
+ for (const wi of before) { if (wi && wi._source) delete wi._source; }
306
+ const beforeSnapshot = JSON.parse(JSON.stringify(before));
307
+ const next = mutator(before);
308
+ const after = (next === undefined || next === null)
309
+ ? before
310
+ : (Array.isArray(next) ? next : before);
311
+ const diff = _computeWorkItemsDiff(scope, beforeSnapshot, after);
312
+ const wrote = _applyWorkItemsDiff(db, diff);
313
+ return { wrote, result: after };
314
+ });
315
+ }
316
+
317
+ // Mirror SQL → JSON file. Phase 2 keeps the file as a read-only derivative
318
+ // so legacy direct-readers keep working; SQL is the source of truth.
319
+ function _mirrorJsonFromSql(scope, filePath) {
320
+ try {
321
+ const shared = require('./shared');
322
+ const items = readWorkItemsForScope(scope);
323
+ // Strip _source if present — only added by readAllWorkItems, but
324
+ // belt-and-suspenders since the mirror is observable.
325
+ for (const wi of items) { if (wi && wi._source) delete wi._source; }
326
+ const target = filePath || _filePathForScope(scope);
327
+ 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);
332
+ } catch {
333
+ // Mirror failures are non-fatal: SQL has already committed.
334
+ }
335
+ }
336
+
337
+ // Test seam.
338
+ function _resetWorkItemsTableForTest() {
339
+ const { getDb } = require('./db');
340
+ try { getDb().exec('DELETE FROM work_items'); } catch { /* not initialized */ }
341
+ }
342
+
343
+ module.exports = {
344
+ scopeForFilePath,
345
+ readWorkItemsForScope,
346
+ readAllWorkItems,
347
+ applyWorkItemsMutation,
348
+ _filePathForScope,
349
+ _mirrorJsonFromSql,
350
+ _resetWorkItemsTableForTest,
351
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2065",
3
+ "version": "0.1.2067",
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"
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "homepage": "https://yemi33.github.io/minions/",
62
62
  "engines": {
63
- "node": ">=18"
63
+ "node": ">=22.5"
64
64
  },
65
65
  "dependencies": {
66
66
  "@azure-devops/mcp": "2.7.0"