@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.
- package/README.md +3 -3
- package/dashboard.js +43 -6
- package/docs/runtime-adapters.md +20 -18
- package/engine/db/index.js +141 -0
- package/engine/db/migrate.js +65 -0
- package/engine/db/migrations/001-init.js +37 -0
- package/engine/db/migrations/002-dispatches.js +113 -0
- package/engine/db/migrations/003-work-items.js +128 -0
- package/engine/db-events.js +40 -0
- package/engine/dispatch-store.js +316 -0
- package/engine/dispatch.js +56 -4
- package/engine/llm.js +4 -3
- package/engine/queries.js +47 -19
- package/engine/restart-health.js +8 -2
- package/engine/runtimes/codex.js +862 -0
- package/engine/runtimes/index.js +1 -0
- package/engine/shared.js +59 -7
- package/engine/work-items-store.js +351 -0
- package/package.json +2 -2
package/engine/runtimes/index.js
CHANGED
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
|
-
}, {
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
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
|
-
}, {
|
|
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.
|
|
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": ">=
|
|
63
|
+
"node": ">=22.5"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@azure-devops/mcp": "2.7.0"
|