@yemi33/minions 0.1.2072 → 0.1.2074

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,259 @@
1
+ // engine/watches-store.js — SQL-backed implementation of engine/watches.json.
2
+ //
3
+ // Mirrors the Phase 2/3 work_items / pull_requests stores: a single
4
+ // table (no per-scope split — watches.json is a flat array, not
5
+ // per-project), diff-then-apply mutator, JSON dual-write mirror with
6
+ // (mtime, size) fingerprint for external-edit detection.
7
+ //
8
+ // readWatches() -> [watch, watch, ...]
9
+ // applyWatchesMutation(fn) -> diff-then-apply, returns { wrote, result }
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ function _toMs(v) {
15
+ if (v == null) return null;
16
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
17
+ const parsed = Date.parse(v);
18
+ return Number.isFinite(parsed) ? parsed : null;
19
+ }
20
+
21
+ function _watchesFilePath() {
22
+ const shared = require('./shared');
23
+ return path.join(shared.MINIONS_DIR, 'engine', 'watches.json');
24
+ }
25
+
26
+ function _parseRow(row) {
27
+ if (!row || !row.data) return null;
28
+ try { return JSON.parse(row.data); }
29
+ catch { return null; }
30
+ }
31
+
32
+ function _readJsonArrayFallback() {
33
+ const fp = _watchesFilePath();
34
+ let raw;
35
+ try { raw = fs.readFileSync(fp, 'utf8'); }
36
+ catch { return []; }
37
+ try {
38
+ const parsed = JSON.parse(raw);
39
+ return Array.isArray(parsed) ? parsed : [];
40
+ } catch (e) {
41
+ try {
42
+ // eslint-disable-next-line no-console
43
+ console.warn(`[watches-store] corrupt JSON in ${fp}: ${e.message}`);
44
+ } catch { /* console may be wrapped */ }
45
+ return [];
46
+ }
47
+ }
48
+
49
+ // Content-hash fingerprint — (mtime, size) is too coarse: same-size
50
+ // content swaps (e.g. last_checked timestamps that always have the same
51
+ // byte length) inside the same ms tick collide on both axes. Hashing the
52
+ // bytes is the unambiguous signal. SHA-1 on a few-KB file is sub-ms.
53
+ let _lastMirrorHash = null;
54
+
55
+ const crypto = require('crypto');
56
+
57
+ function _fileContentHash(filePath) {
58
+ try {
59
+ const buf = fs.readFileSync(filePath);
60
+ return crypto.createHash('sha1').update(buf).digest('hex');
61
+ }
62
+ catch { return null; }
63
+ }
64
+
65
+ function _hydrateFromJson(db) {
66
+ const items = _readJsonArrayFallback();
67
+ db.prepare('DELETE FROM watches').run();
68
+ if (items.length === 0) return;
69
+ const now = Date.now();
70
+ const ins = db.prepare(`
71
+ INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
72
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ ON CONFLICT(id) DO NOTHING
74
+ `);
75
+ for (const w of items) {
76
+ if (!w || !w.id) continue;
77
+ ins.run(
78
+ String(w.id),
79
+ typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
80
+ w.targetType || null,
81
+ w.condition || null,
82
+ String(w.status || 'active'),
83
+ w.owner || null,
84
+ _toMs(w.created_at),
85
+ _toMs(w.last_checked),
86
+ _toMs(w.last_triggered),
87
+ JSON.stringify(w),
88
+ now,
89
+ );
90
+ }
91
+ }
92
+
93
+ function _resyncIfJsonDiverged(db) {
94
+ const jsonPath = _watchesFilePath();
95
+ const currentHash = _fileContentHash(jsonPath);
96
+ if (currentHash == null) return;
97
+ if (_lastMirrorHash != null && currentHash === _lastMirrorHash) return;
98
+ if (_lastMirrorHash == null) {
99
+ const sqlHas = db.prepare('SELECT 1 FROM watches LIMIT 1').get();
100
+ if (sqlHas) {
101
+ _lastMirrorHash = currentHash;
102
+ return;
103
+ }
104
+ }
105
+ _hydrateFromJson(db);
106
+ _lastMirrorHash = currentHash;
107
+ }
108
+
109
+ // Read all watch rows from SQL only. No JSON fallback — used by the
110
+ // mirror writer to avoid resurrecting deleted rows: after the mutation
111
+ // deletes a watch from SQL but BEFORE the mirror lands, the JSON still
112
+ // holds the pre-mutation content, so a fallback read would round-trip
113
+ // the deleted row right back into JSON.
114
+ function _readWatchesFromSqlOnly(db) {
115
+ const rows = db.prepare('SELECT data FROM watches ORDER BY rowid').all();
116
+ const out = [];
117
+ for (const row of rows) {
118
+ const w = _parseRow(row);
119
+ if (w) out.push(w);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function readWatches() {
125
+ const { getDb } = require('./db');
126
+ let db;
127
+ try { db = getDb(); }
128
+ catch { return _readJsonArrayFallback(); }
129
+
130
+ _resyncIfJsonDiverged(db);
131
+
132
+ const out = _readWatchesFromSqlOnly(db);
133
+ if (out.length === 0) {
134
+ // SQL is empty for first-time hydrate: trust the JSON if it has
135
+ // content. After hydrate ran, SQL is empty iff the user really has
136
+ // no watches.
137
+ const fallback = _readJsonArrayFallback();
138
+ if (fallback.length > 0) return fallback;
139
+ return [];
140
+ }
141
+ return out;
142
+ }
143
+
144
+ function _indexById(arr) {
145
+ const out = new Map();
146
+ for (const w of arr) {
147
+ if (!w || !w.id) continue;
148
+ out.set(String(w.id), w);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function _computeWatchesDiff(before, after) {
154
+ const beforeMap = _indexById(before);
155
+ const afterMap = _indexById(after);
156
+ const toUpsert = [];
157
+ const toDelete = [];
158
+ for (const [id, w] of afterMap) {
159
+ const prev = beforeMap.get(id);
160
+ if (!prev) { toUpsert.push(w); continue; }
161
+ if (JSON.stringify(prev) !== JSON.stringify(w)) toUpsert.push(w);
162
+ }
163
+ for (const [id] of beforeMap) {
164
+ if (!afterMap.has(id)) toDelete.push(id);
165
+ }
166
+ return { toUpsert, toDelete };
167
+ }
168
+
169
+ function _applyWatchesDiff(db, diff) {
170
+ if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
171
+ const now = Date.now();
172
+ const upsertStmt = db.prepare(`
173
+ INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
174
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
175
+ ON CONFLICT(id) DO UPDATE SET
176
+ target = excluded.target,
177
+ target_type = excluded.target_type,
178
+ condition = excluded.condition,
179
+ status = excluded.status,
180
+ owner = excluded.owner,
181
+ created_at = excluded.created_at,
182
+ last_checked = excluded.last_checked,
183
+ last_triggered = excluded.last_triggered,
184
+ data = excluded.data,
185
+ updated_at = excluded.updated_at
186
+ `);
187
+ const deleteStmt = db.prepare('DELETE FROM watches WHERE id = ?');
188
+
189
+ for (const w of diff.toUpsert) {
190
+ upsertStmt.run(
191
+ String(w.id),
192
+ typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
193
+ w.targetType || null,
194
+ w.condition || null,
195
+ String(w.status || 'active'),
196
+ w.owner || null,
197
+ _toMs(w.created_at),
198
+ _toMs(w.last_checked),
199
+ _toMs(w.last_triggered),
200
+ JSON.stringify(w),
201
+ now,
202
+ );
203
+ }
204
+ for (const id of diff.toDelete) {
205
+ deleteStmt.run(id);
206
+ }
207
+ return true;
208
+ }
209
+
210
+ function applyWatchesMutation(mutator) {
211
+ const { getDb, withTransaction } = require('./db');
212
+ let db;
213
+ try { db = getDb(); }
214
+ catch (e) {
215
+ throw new Error(`engine/watches-store: SQLite unavailable (${e.message}); cannot mutate watches`);
216
+ }
217
+
218
+ return withTransaction(db, () => {
219
+ _resyncIfJsonDiverged(db);
220
+ const before = readWatches();
221
+ const beforeSnapshot = JSON.parse(JSON.stringify(before));
222
+ const next = mutator(before);
223
+ const after = (next === undefined || next === null)
224
+ ? before
225
+ : (Array.isArray(next) ? next : before);
226
+ const diff = _computeWatchesDiff(beforeSnapshot, after);
227
+ const wrote = _applyWatchesDiff(db, diff);
228
+ return { wrote, result: after };
229
+ });
230
+ }
231
+
232
+ function _mirrorJsonFromSql(filePath) {
233
+ try {
234
+ const shared = require('./shared');
235
+ const { getDb } = require('./db');
236
+ // Read SQL directly — bypass the JSON fallback so a deletion that
237
+ // leaves SQL empty doesn't resurrect from the stale JSON content.
238
+ const items = _readWatchesFromSqlOnly(getDb());
239
+ const target = filePath || _watchesFilePath();
240
+ shared.safeWrite(target, items);
241
+ const h = _fileContentHash(target);
242
+ if (h != null) _lastMirrorHash = h;
243
+ } catch {
244
+ // Mirror failures are non-fatal — SQL has already committed.
245
+ }
246
+ }
247
+
248
+ function _resetWatchesTableForTest() {
249
+ const { getDb } = require('./db');
250
+ try { getDb().exec('DELETE FROM watches'); } catch { /* not initialized */ }
251
+ _lastMirrorHash = null;
252
+ }
253
+
254
+ module.exports = {
255
+ readWatches,
256
+ applyWatchesMutation,
257
+ _mirrorJsonFromSql,
258
+ _resetWatchesTableForTest,
259
+ };
package/engine/watches.js CHANGED
@@ -67,7 +67,7 @@
67
67
  const fs = require('fs');
68
68
  const path = require('path');
69
69
  const shared = require('./shared');
70
- const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
70
+ const { safeJsonArr, mutateJsonFileLocked, mutateWatches, ts, uid, log, writeToInbox,
71
71
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
72
72
  const watchActions = require('./watch-actions');
73
73
 
@@ -230,11 +230,10 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
230
230
  _history: [],
231
231
  };
232
232
 
233
- mutateJsonFileLocked(_watchesPath(), (watches) => {
234
- if (!Array.isArray(watches)) watches = [];
233
+ mutateWatches((watches) => {
235
234
  watches.push(watch);
236
235
  return watches;
237
- }, { defaultValue: [] });
236
+ });
238
237
 
239
238
  log('info', `Watch created: ${watch.id} → ${watch.target} (${watch.condition})`);
240
239
  return watch;
@@ -264,8 +263,7 @@ function updateWatch(id, updates) {
264
263
  if (reqErr) throw new Error(reqErr);
265
264
  }
266
265
  let found = null;
267
- mutateJsonFileLocked(_watchesPath(), (watches) => {
268
- if (!Array.isArray(watches)) return watches;
266
+ mutateWatches((watches) => {
269
267
  const watch = watches.find(w => w.id === id);
270
268
  if (!watch) return watches;
271
269
  // Only allow safe field updates
@@ -275,7 +273,7 @@ function updateWatch(id, updates) {
275
273
  }
276
274
  found = { ...watch };
277
275
  return watches;
278
- }, { defaultValue: [] });
276
+ });
279
277
  return found;
280
278
  }
281
279
 
@@ -287,15 +285,14 @@ function updateWatch(id, updates) {
287
285
  function deleteWatch(id) {
288
286
  if (!id) return false;
289
287
  let deleted = false;
290
- mutateJsonFileLocked(_watchesPath(), (watches) => {
291
- if (!Array.isArray(watches)) return watches;
288
+ mutateWatches((watches) => {
292
289
  const idx = watches.findIndex(w => w.id === id);
293
290
  if (idx >= 0) {
294
291
  watches.splice(idx, 1);
295
292
  deleted = true;
296
293
  }
297
294
  return watches;
298
- }, { defaultValue: [] });
295
+ });
299
296
  if (deleted) log('info', `Watch deleted: ${id}`);
300
297
  return deleted;
301
298
  }
@@ -413,8 +410,8 @@ function checkWatches(config, state) {
413
410
  const notifications = [];
414
411
  const actionsToRun = [];
415
412
 
416
- mutateJsonFileLocked(_watchesPath(), (watches) => {
417
- if (!Array.isArray(watches) || watches.length === 0) return watches;
413
+ mutateWatches((watches) => {
414
+ if (watches.length === 0) return watches;
418
415
 
419
416
  for (const watch of watches) {
420
417
  try {
@@ -539,7 +536,7 @@ function checkWatches(config, state) {
539
536
  }
540
537
 
541
538
  return watches;
542
- }, { defaultValue: [] });
539
+ });
543
540
 
544
541
  // Fire notifications outside the lock — writeToInbox does disk I/O
545
542
  for (const n of notifications) {
@@ -591,8 +588,7 @@ async function _runActionTask(task) {
591
588
  // can inspect each step's result. Single-action watches keep the legacy
592
589
  // `dispatchedItemId` shortcut for back-compat.
593
590
  try {
594
- mutateJsonFileLocked(_watchesPath(), (watches) => {
595
- if (!Array.isArray(watches)) return watches;
591
+ mutateWatches((watches) => {
596
592
  const w = watches.find(x => x.id === task.watchId);
597
593
  if (w) {
598
594
  if (isChain) {
@@ -639,7 +635,7 @@ async function _runActionTask(task) {
639
635
  }
640
636
  }
641
637
  return watches;
642
- }, { defaultValue: [] });
638
+ });
643
639
  } catch (err) {
644
640
  log('warn', `Watch ${task.watchId} action result persist failed: ${err.message}`);
645
641
  }
@@ -116,28 +116,19 @@ 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 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;
129
- // No mirror record but JSON exists: first-time hydrate iff SQL is empty
130
- // for this scope (avoid trampling a freshly-backfilled migration state
131
- // on the very first read).
132
- if (lastMirror == null) {
119
+ const currentHash = _fileContentHash(jsonPath);
120
+ const lastHash = _lastMirrorHashByScope.get(scope);
121
+ if (currentHash == null) return;
122
+ if (lastHash != null && currentHash === lastHash) return;
123
+ if (lastHash == null) {
133
124
  const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
134
125
  if (sqlHas) {
135
- _lastMirrorByScope.set(scope, current);
126
+ _lastMirrorHashByScope.set(scope, currentHash);
136
127
  return;
137
128
  }
138
129
  }
139
130
  _hydrateScopeFromJson(db, scope);
140
- _lastMirrorByScope.set(scope, current);
131
+ _lastMirrorHashByScope.set(scope, currentHash);
141
132
  }
142
133
 
143
134
  // Read all rows across all scopes — used by queries.getWorkItems which
@@ -243,18 +234,18 @@ function _applyWorkItemsDiff(db, diff) {
243
234
  // wrote — true iff at least one INSERT/UPDATE/DELETE landed
244
235
  // result — the post-mutation array (legacy return shape of
245
236
  // mutateJsonFileLocked)
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();
237
+ // Content-hash fingerprint of the JSON mirror per scope, so external
238
+ // edits are detected unambiguously. (mtime, size) is too coarse: same-
239
+ // length timestamp swaps (last_checked / last_triggered / completed_at)
240
+ // preserve both axes. SHA-1 is sub-ms on a few-hundred-KB file.
241
+ const _lastMirrorHashByScope = new Map();
253
242
 
254
- function _statFingerprint(filePath) {
243
+ const crypto = require('crypto');
244
+
245
+ function _fileContentHash(filePath) {
255
246
  try {
256
- const st = fs.statSync(filePath);
257
- return { mtime: st.mtimeMs, size: st.size };
247
+ const buf = fs.readFileSync(filePath);
248
+ return crypto.createHash('sha1').update(buf).digest('hex');
258
249
  }
259
250
  catch { return null; }
260
251
  }
@@ -325,17 +316,24 @@ function applyWorkItemsMutation(scope, mutator) {
325
316
  function _mirrorJsonFromSql(scope, filePath) {
326
317
  try {
327
318
  const shared = require('./shared');
328
- const items = readWorkItemsForScope(scope);
329
- // Strip _source if presentonly added by readAllWorkItems, but
330
- // belt-and-suspenders since the mirror is observable.
331
- for (const wi of items) { if (wi && wi._source) delete wi._source; }
319
+ const { getDb } = require('./db');
320
+ // Read SQL directly for this scope bypass the JSON fallback so a
321
+ // mutation that empties SQL for the scope doesn't resurrect stale
322
+ // JSON content. (See pull-requests-store and watches-store for the
323
+ // same defense.)
324
+ const db = getDb();
325
+ const rows = db.prepare('SELECT data FROM work_items WHERE scope = ? ORDER BY rowid').all(scope);
326
+ const items = [];
327
+ for (const row of rows) {
328
+ const wi = _parseRow(row);
329
+ if (!wi) continue;
330
+ if (wi._source) delete wi._source;
331
+ items.push(wi);
332
+ }
332
333
  const target = filePath || _filePathForScope(scope);
333
334
  shared.safeWrite(target, items);
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);
335
+ const h = _fileContentHash(target);
336
+ if (h != null) _lastMirrorHashByScope.set(scope, h);
339
337
  } catch {
340
338
  // Mirror failures are non-fatal: SQL has already committed.
341
339
  }
@@ -61,12 +61,16 @@ function readPool() {
61
61
  }
62
62
 
63
63
  function mutateWorktreePool(mutator) {
64
- return mutateJsonFileLocked(getPoolPath(), (data) => {
64
+ // Phase 7: route through shared.mutateWorktreePool (SQL-backed store +
65
+ // JSON mirror). Defensive wrappers around `data.entries` kept for the
66
+ // legacy fallback path inside the helper (when SQLite is unavailable
67
+ // and we fall through to mutateJsonFileLocked).
68
+ return shared.mutateWorktreePool((data) => {
65
69
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = { entries: [] };
66
70
  if (!Array.isArray(data.entries)) data.entries = [];
67
71
  const next = mutator(data);
68
72
  return next === undefined ? data : next;
69
- }, { defaultValue: { entries: [] }, skipWriteIfUnchanged: true });
73
+ });
70
74
  }
71
75
 
72
76
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2072",
3
+ "version": "0.1.2074",
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"