@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.
- package/docs/README.md +1 -0
- package/docs/auto-discovery.md +2 -1
- package/docs/kb-sweep.md +8 -0
- package/engine/db/migrations/007-watches.js +95 -0
- package/engine/db/migrations/008-small-state.js +155 -0
- package/engine/managed-spawn.js +7 -7
- package/engine/metrics-store.js +0 -0
- package/engine/pipeline.js +7 -9
- package/engine/pull-requests-store.js +30 -22
- package/engine/scheduler.js +6 -5
- package/engine/shared.js +101 -1
- package/engine/small-state-store.js +560 -0
- package/engine/watches-store.js +259 -0
- package/engine/watches.js +12 -16
- package/engine/work-items-store.js +33 -35
- package/engine/worktree-pool.js +6 -2
- package/package.json +1 -1
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// engine/small-state-store.js — SQL-backed implementations for the
|
|
2
|
+
// four Phase 7 small state files:
|
|
3
|
+
//
|
|
4
|
+
// schedule-runs.json <-> schedule_runs (object → row-per-id)
|
|
5
|
+
// pipeline-runs.json <-> pipeline_runs ({id:[runs]} → row-per-run)
|
|
6
|
+
// managed-processes.json <-> managed_processes ({specs:[]} → row-per-name)
|
|
7
|
+
// worktree-pool.json <-> worktree_pool ({entries:[]} → row-per-entry)
|
|
8
|
+
//
|
|
9
|
+
// They share enough infrastructure (content-hash fingerprint, mirror
|
|
10
|
+
// writer, withTransaction wiring) that it's clearer to keep them in one
|
|
11
|
+
// module — each store is small, and the JSON-shape transformations are
|
|
12
|
+
// the only per-file divergence.
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
function _toMs(v) {
|
|
19
|
+
if (v == null) return null;
|
|
20
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
21
|
+
const parsed = Date.parse(v);
|
|
22
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function _fileContentHash(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
const buf = fs.readFileSync(filePath);
|
|
28
|
+
return crypto.createHash('sha1').update(buf).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _readJson(filePath) {
|
|
34
|
+
let raw;
|
|
35
|
+
try { raw = fs.readFileSync(filePath, 'utf8'); }
|
|
36
|
+
catch { return null; }
|
|
37
|
+
try { return JSON.parse(raw); }
|
|
38
|
+
catch (e) {
|
|
39
|
+
try {
|
|
40
|
+
// eslint-disable-next-line no-console
|
|
41
|
+
console.warn(`[small-state-store] corrupt JSON in ${filePath}: ${e.message}`);
|
|
42
|
+
} catch { /* console wrapped */ }
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _resolveFilePath(rel) {
|
|
48
|
+
const shared = require('./shared');
|
|
49
|
+
return path.join(shared.MINIONS_DIR, 'engine', rel);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── schedule_runs ──────────────────────────────────────────────────────────
|
|
53
|
+
// Shape: { [scheduleId]: { lastRun, lastWorkItemId, lastResult, lastCompletedAt } }
|
|
54
|
+
// SQL: row per scheduleId, data = the value blob.
|
|
55
|
+
|
|
56
|
+
let _scheduleRunsHash = null;
|
|
57
|
+
|
|
58
|
+
function _hydrateScheduleRuns(db) {
|
|
59
|
+
const fp = _resolveFilePath('schedule-runs.json');
|
|
60
|
+
const raw = _readJson(fp) || {};
|
|
61
|
+
db.prepare('DELETE FROM schedule_runs').run();
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const ins = db.prepare('INSERT INTO schedule_runs (schedule_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(schedule_id) DO NOTHING');
|
|
64
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
65
|
+
ins.run(id, JSON.stringify(value), now);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _resyncScheduleRunsIfDiverged(db) {
|
|
70
|
+
const fp = _resolveFilePath('schedule-runs.json');
|
|
71
|
+
const currentHash = _fileContentHash(fp);
|
|
72
|
+
if (currentHash == null) return;
|
|
73
|
+
if (_scheduleRunsHash != null && currentHash === _scheduleRunsHash) return;
|
|
74
|
+
if (_scheduleRunsHash == null) {
|
|
75
|
+
const sqlHas = db.prepare('SELECT 1 FROM schedule_runs LIMIT 1').get();
|
|
76
|
+
if (sqlHas) {
|
|
77
|
+
_scheduleRunsHash = currentHash;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
_hydrateScheduleRuns(db);
|
|
82
|
+
_scheduleRunsHash = currentHash;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _readScheduleRunsFromSql(db) {
|
|
86
|
+
const rows = db.prepare('SELECT schedule_id, data FROM schedule_runs').all();
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
try { out[row.schedule_id] = JSON.parse(row.data); }
|
|
90
|
+
catch { /* skip malformed */ }
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readScheduleRuns() {
|
|
96
|
+
const { getDb } = require('./db');
|
|
97
|
+
let db;
|
|
98
|
+
try { db = getDb(); }
|
|
99
|
+
catch { return _readJson(_resolveFilePath('schedule-runs.json')) || {}; }
|
|
100
|
+
_resyncScheduleRunsIfDiverged(db);
|
|
101
|
+
const out = _readScheduleRunsFromSql(db);
|
|
102
|
+
if (Object.keys(out).length === 0) {
|
|
103
|
+
const fallback = _readJson(_resolveFilePath('schedule-runs.json'));
|
|
104
|
+
if (fallback && Object.keys(fallback).length > 0) return fallback;
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function applyScheduleRunsMutation(mutator) {
|
|
111
|
+
const { getDb, withTransaction } = require('./db');
|
|
112
|
+
let db;
|
|
113
|
+
try { db = getDb(); }
|
|
114
|
+
catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
|
|
115
|
+
|
|
116
|
+
return withTransaction(db, () => {
|
|
117
|
+
_resyncScheduleRunsIfDiverged(db);
|
|
118
|
+
const before = _readScheduleRunsFromSql(db);
|
|
119
|
+
const beforeSnap = JSON.parse(JSON.stringify(before));
|
|
120
|
+
const next = mutator(before);
|
|
121
|
+
const after = (next === undefined || next === null) ? before : next;
|
|
122
|
+
const afterIds = new Set(Object.keys(after));
|
|
123
|
+
const beforeIds = new Set(Object.keys(beforeSnap));
|
|
124
|
+
let wrote = false;
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const upsert = db.prepare(`
|
|
127
|
+
INSERT INTO schedule_runs (schedule_id, data, updated_at)
|
|
128
|
+
VALUES (?, ?, ?)
|
|
129
|
+
ON CONFLICT(schedule_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
|
130
|
+
`);
|
|
131
|
+
const del = db.prepare('DELETE FROM schedule_runs WHERE schedule_id = ?');
|
|
132
|
+
for (const id of afterIds) {
|
|
133
|
+
if (!beforeIds.has(id) || JSON.stringify(beforeSnap[id]) !== JSON.stringify(after[id])) {
|
|
134
|
+
upsert.run(id, JSON.stringify(after[id]), now);
|
|
135
|
+
wrote = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const id of beforeIds) {
|
|
139
|
+
if (!afterIds.has(id)) { del.run(id); wrote = true; }
|
|
140
|
+
}
|
|
141
|
+
return { wrote, result: after };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _mirrorScheduleRunsJson(filePath) {
|
|
146
|
+
try {
|
|
147
|
+
const shared = require('./shared');
|
|
148
|
+
const { getDb } = require('./db');
|
|
149
|
+
const obj = _readScheduleRunsFromSql(getDb());
|
|
150
|
+
const target = filePath || _resolveFilePath('schedule-runs.json');
|
|
151
|
+
shared.safeWrite(target, obj);
|
|
152
|
+
const h = _fileContentHash(target);
|
|
153
|
+
if (h != null) _scheduleRunsHash = h;
|
|
154
|
+
} catch { /* mirror best-effort */ }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── pipeline_runs ──────────────────────────────────────────────────────────
|
|
158
|
+
// Shape: { [pipelineId]: [run, run, ...] }
|
|
159
|
+
// SQL: row per (pipelineId, runId). Reconstruct on read by grouping.
|
|
160
|
+
|
|
161
|
+
let _pipelineRunsHash = null;
|
|
162
|
+
|
|
163
|
+
function _hydratePipelineRuns(db) {
|
|
164
|
+
const fp = _resolveFilePath('pipeline-runs.json');
|
|
165
|
+
const raw = _readJson(fp) || {};
|
|
166
|
+
db.prepare('DELETE FROM pipeline_runs').run();
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
const ins = db.prepare(`
|
|
169
|
+
INSERT INTO pipeline_runs (pipeline_id, run_id, status, started_at, data, updated_at)
|
|
170
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
171
|
+
ON CONFLICT(pipeline_id, run_id) DO NOTHING
|
|
172
|
+
`);
|
|
173
|
+
for (const [pipelineId, runs] of Object.entries(raw)) {
|
|
174
|
+
if (!Array.isArray(runs)) continue;
|
|
175
|
+
for (const run of runs) {
|
|
176
|
+
if (!run || !run.runId) continue;
|
|
177
|
+
ins.run(pipelineId, String(run.runId), run.status || null, _toMs(run.startedAt), JSON.stringify(run), now);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function _resyncPipelineRunsIfDiverged(db) {
|
|
183
|
+
const fp = _resolveFilePath('pipeline-runs.json');
|
|
184
|
+
const currentHash = _fileContentHash(fp);
|
|
185
|
+
if (currentHash == null) return;
|
|
186
|
+
if (_pipelineRunsHash != null && currentHash === _pipelineRunsHash) return;
|
|
187
|
+
if (_pipelineRunsHash == null) {
|
|
188
|
+
const sqlHas = db.prepare('SELECT 1 FROM pipeline_runs LIMIT 1').get();
|
|
189
|
+
if (sqlHas) { _pipelineRunsHash = currentHash; return; }
|
|
190
|
+
}
|
|
191
|
+
_hydratePipelineRuns(db);
|
|
192
|
+
_pipelineRunsHash = currentHash;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function _readPipelineRunsFromSql(db) {
|
|
196
|
+
const rows = db.prepare('SELECT pipeline_id, data FROM pipeline_runs ORDER BY pipeline_id, rowid').all();
|
|
197
|
+
const out = {};
|
|
198
|
+
for (const row of rows) {
|
|
199
|
+
try {
|
|
200
|
+
const run = JSON.parse(row.data);
|
|
201
|
+
if (!out[row.pipeline_id]) out[row.pipeline_id] = [];
|
|
202
|
+
out[row.pipeline_id].push(run);
|
|
203
|
+
} catch { /* skip malformed */ }
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function readPipelineRuns() {
|
|
209
|
+
const { getDb } = require('./db');
|
|
210
|
+
let db;
|
|
211
|
+
try { db = getDb(); }
|
|
212
|
+
catch { return _readJson(_resolveFilePath('pipeline-runs.json')) || {}; }
|
|
213
|
+
_resyncPipelineRunsIfDiverged(db);
|
|
214
|
+
const out = _readPipelineRunsFromSql(db);
|
|
215
|
+
if (Object.keys(out).length === 0) {
|
|
216
|
+
const fallback = _readJson(_resolveFilePath('pipeline-runs.json'));
|
|
217
|
+
if (fallback && Object.keys(fallback).length > 0) return fallback;
|
|
218
|
+
return {};
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function applyPipelineRunsMutation(mutator) {
|
|
224
|
+
const { getDb, withTransaction } = require('./db');
|
|
225
|
+
let db;
|
|
226
|
+
try { db = getDb(); }
|
|
227
|
+
catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
|
|
228
|
+
|
|
229
|
+
return withTransaction(db, () => {
|
|
230
|
+
_resyncPipelineRunsIfDiverged(db);
|
|
231
|
+
const before = _readPipelineRunsFromSql(db);
|
|
232
|
+
const beforeSnap = JSON.parse(JSON.stringify(before));
|
|
233
|
+
const next = mutator(before);
|
|
234
|
+
const after = (next === undefined || next === null) ? before : next;
|
|
235
|
+
|
|
236
|
+
// Index by (pipelineId, runId).
|
|
237
|
+
const flatten = (obj) => {
|
|
238
|
+
const out = new Map();
|
|
239
|
+
for (const [pid, runs] of Object.entries(obj || {})) {
|
|
240
|
+
if (!Array.isArray(runs)) continue;
|
|
241
|
+
for (const run of runs) {
|
|
242
|
+
if (!run || !run.runId) continue;
|
|
243
|
+
out.set(`${pid}|${run.runId}`, { pid, run });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
};
|
|
248
|
+
const beforeMap = flatten(beforeSnap);
|
|
249
|
+
const afterMap = flatten(after);
|
|
250
|
+
|
|
251
|
+
const now = Date.now();
|
|
252
|
+
const upsert = db.prepare(`
|
|
253
|
+
INSERT INTO pipeline_runs (pipeline_id, run_id, status, started_at, data, updated_at)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
255
|
+
ON CONFLICT(pipeline_id, run_id) DO UPDATE SET
|
|
256
|
+
status = excluded.status,
|
|
257
|
+
started_at = excluded.started_at,
|
|
258
|
+
data = excluded.data,
|
|
259
|
+
updated_at = excluded.updated_at
|
|
260
|
+
`);
|
|
261
|
+
const del = db.prepare('DELETE FROM pipeline_runs WHERE pipeline_id = ? AND run_id = ?');
|
|
262
|
+
let wrote = false;
|
|
263
|
+
for (const [key, { pid, run }] of afterMap) {
|
|
264
|
+
const prev = beforeMap.get(key);
|
|
265
|
+
if (!prev || JSON.stringify(prev.run) !== JSON.stringify(run)) {
|
|
266
|
+
upsert.run(pid, String(run.runId), run.status || null, _toMs(run.startedAt), JSON.stringify(run), now);
|
|
267
|
+
wrote = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const [key, { pid, run }] of beforeMap) {
|
|
271
|
+
if (!afterMap.has(key)) { del.run(pid, String(run.runId)); wrote = true; }
|
|
272
|
+
}
|
|
273
|
+
return { wrote, result: after };
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function _mirrorPipelineRunsJson(filePath) {
|
|
278
|
+
try {
|
|
279
|
+
const shared = require('./shared');
|
|
280
|
+
const { getDb } = require('./db');
|
|
281
|
+
const obj = _readPipelineRunsFromSql(getDb());
|
|
282
|
+
const target = filePath || _resolveFilePath('pipeline-runs.json');
|
|
283
|
+
shared.safeWrite(target, obj);
|
|
284
|
+
const h = _fileContentHash(target);
|
|
285
|
+
if (h != null) _pipelineRunsHash = h;
|
|
286
|
+
} catch { /* mirror best-effort */ }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── managed_processes ─────────────────────────────────────────────────────
|
|
290
|
+
// Shape: { specs: [ {name, ...}, ... ] }
|
|
291
|
+
// SQL: row per name.
|
|
292
|
+
|
|
293
|
+
let _managedProcessesHash = null;
|
|
294
|
+
|
|
295
|
+
function _hydrateManagedProcesses(db) {
|
|
296
|
+
const fp = _resolveFilePath('managed-processes.json');
|
|
297
|
+
const raw = _readJson(fp) || {};
|
|
298
|
+
db.prepare('DELETE FROM managed_processes').run();
|
|
299
|
+
const specs = Array.isArray(raw.specs) ? raw.specs : [];
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const ins = db.prepare('INSERT INTO managed_processes (name, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(name) DO NOTHING');
|
|
302
|
+
for (const spec of specs) {
|
|
303
|
+
if (!spec || !spec.name) continue;
|
|
304
|
+
ins.run(String(spec.name), JSON.stringify(spec), now);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function _resyncManagedProcessesIfDiverged(db) {
|
|
309
|
+
const fp = _resolveFilePath('managed-processes.json');
|
|
310
|
+
const currentHash = _fileContentHash(fp);
|
|
311
|
+
if (currentHash == null) return;
|
|
312
|
+
if (_managedProcessesHash != null && currentHash === _managedProcessesHash) return;
|
|
313
|
+
if (_managedProcessesHash == null) {
|
|
314
|
+
const sqlHas = db.prepare('SELECT 1 FROM managed_processes LIMIT 1').get();
|
|
315
|
+
if (sqlHas) { _managedProcessesHash = currentHash; return; }
|
|
316
|
+
}
|
|
317
|
+
_hydrateManagedProcesses(db);
|
|
318
|
+
_managedProcessesHash = currentHash;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function _readManagedProcessesFromSql(db) {
|
|
322
|
+
const rows = db.prepare('SELECT data FROM managed_processes ORDER BY rowid').all();
|
|
323
|
+
const specs = [];
|
|
324
|
+
for (const row of rows) {
|
|
325
|
+
try { specs.push(JSON.parse(row.data)); } catch { /* skip */ }
|
|
326
|
+
}
|
|
327
|
+
return { specs };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function readManagedProcesses() {
|
|
331
|
+
const { getDb } = require('./db');
|
|
332
|
+
let db;
|
|
333
|
+
try { db = getDb(); }
|
|
334
|
+
catch { return _readJson(_resolveFilePath('managed-processes.json')) || { specs: [] }; }
|
|
335
|
+
_resyncManagedProcessesIfDiverged(db);
|
|
336
|
+
const out = _readManagedProcessesFromSql(db);
|
|
337
|
+
if (out.specs.length === 0) {
|
|
338
|
+
const fallback = _readJson(_resolveFilePath('managed-processes.json'));
|
|
339
|
+
if (fallback && Array.isArray(fallback.specs) && fallback.specs.length > 0) return fallback;
|
|
340
|
+
return { specs: [] };
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function applyManagedProcessesMutation(mutator) {
|
|
346
|
+
const { getDb, withTransaction } = require('./db');
|
|
347
|
+
let db;
|
|
348
|
+
try { db = getDb(); }
|
|
349
|
+
catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
|
|
350
|
+
|
|
351
|
+
return withTransaction(db, () => {
|
|
352
|
+
_resyncManagedProcessesIfDiverged(db);
|
|
353
|
+
const before = _readManagedProcessesFromSql(db);
|
|
354
|
+
const beforeSnap = JSON.parse(JSON.stringify(before));
|
|
355
|
+
const next = mutator(before);
|
|
356
|
+
const after = (next === undefined || next === null) ? before : next;
|
|
357
|
+
if (!after || !Array.isArray(after.specs)) after.specs = [];
|
|
358
|
+
|
|
359
|
+
const indexByName = (arr) => {
|
|
360
|
+
const out = new Map();
|
|
361
|
+
for (const spec of arr) {
|
|
362
|
+
if (spec && spec.name) out.set(String(spec.name), spec);
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
};
|
|
366
|
+
const beforeMap = indexByName(beforeSnap.specs);
|
|
367
|
+
const afterMap = indexByName(after.specs);
|
|
368
|
+
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
const upsert = db.prepare(`
|
|
371
|
+
INSERT INTO managed_processes (name, data, updated_at)
|
|
372
|
+
VALUES (?, ?, ?)
|
|
373
|
+
ON CONFLICT(name) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
|
374
|
+
`);
|
|
375
|
+
const del = db.prepare('DELETE FROM managed_processes WHERE name = ?');
|
|
376
|
+
let wrote = false;
|
|
377
|
+
for (const [name, spec] of afterMap) {
|
|
378
|
+
const prev = beforeMap.get(name);
|
|
379
|
+
if (!prev || JSON.stringify(prev) !== JSON.stringify(spec)) {
|
|
380
|
+
upsert.run(name, JSON.stringify(spec), now);
|
|
381
|
+
wrote = true;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const [name] of beforeMap) {
|
|
385
|
+
if (!afterMap.has(name)) { del.run(name); wrote = true; }
|
|
386
|
+
}
|
|
387
|
+
return { wrote, result: after };
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function _mirrorManagedProcessesJson(filePath) {
|
|
392
|
+
try {
|
|
393
|
+
const shared = require('./shared');
|
|
394
|
+
const { getDb } = require('./db');
|
|
395
|
+
const obj = _readManagedProcessesFromSql(getDb());
|
|
396
|
+
const target = filePath || _resolveFilePath('managed-processes.json');
|
|
397
|
+
shared.safeWrite(target, obj);
|
|
398
|
+
const h = _fileContentHash(target);
|
|
399
|
+
if (h != null) _managedProcessesHash = h;
|
|
400
|
+
} catch { /* mirror best-effort */ }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── worktree_pool ─────────────────────────────────────────────────────────
|
|
404
|
+
// Shape: { entries: [ {path, ...}, ... ] }
|
|
405
|
+
// SQL: row per entry, keyed by the worktree path (unique).
|
|
406
|
+
|
|
407
|
+
let _worktreePoolHash = null;
|
|
408
|
+
|
|
409
|
+
function _entryKey(entry) {
|
|
410
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
411
|
+
return entry.path || entry.id || null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function _hydrateWorktreePool(db) {
|
|
415
|
+
const fp = _resolveFilePath('worktree-pool.json');
|
|
416
|
+
const raw = _readJson(fp) || {};
|
|
417
|
+
db.prepare('DELETE FROM worktree_pool').run();
|
|
418
|
+
const entries = Array.isArray(raw.entries) ? raw.entries : [];
|
|
419
|
+
const now = Date.now();
|
|
420
|
+
const ins = db.prepare('INSERT INTO worktree_pool (entry_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(entry_id) DO NOTHING');
|
|
421
|
+
for (const entry of entries) {
|
|
422
|
+
const id = _entryKey(entry);
|
|
423
|
+
if (!id) continue;
|
|
424
|
+
ins.run(String(id), JSON.stringify(entry), now);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function _resyncWorktreePoolIfDiverged(db) {
|
|
429
|
+
const fp = _resolveFilePath('worktree-pool.json');
|
|
430
|
+
const currentHash = _fileContentHash(fp);
|
|
431
|
+
if (currentHash == null) return;
|
|
432
|
+
if (_worktreePoolHash != null && currentHash === _worktreePoolHash) return;
|
|
433
|
+
if (_worktreePoolHash == null) {
|
|
434
|
+
const sqlHas = db.prepare('SELECT 1 FROM worktree_pool LIMIT 1').get();
|
|
435
|
+
if (sqlHas) { _worktreePoolHash = currentHash; return; }
|
|
436
|
+
}
|
|
437
|
+
_hydrateWorktreePool(db);
|
|
438
|
+
_worktreePoolHash = currentHash;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function _readWorktreePoolFromSql(db) {
|
|
442
|
+
const rows = db.prepare('SELECT data FROM worktree_pool ORDER BY rowid').all();
|
|
443
|
+
const entries = [];
|
|
444
|
+
for (const row of rows) {
|
|
445
|
+
try { entries.push(JSON.parse(row.data)); } catch { /* skip */ }
|
|
446
|
+
}
|
|
447
|
+
return { entries };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function readWorktreePool() {
|
|
451
|
+
const { getDb } = require('./db');
|
|
452
|
+
let db;
|
|
453
|
+
try { db = getDb(); }
|
|
454
|
+
catch { return _readJson(_resolveFilePath('worktree-pool.json')) || { entries: [] }; }
|
|
455
|
+
_resyncWorktreePoolIfDiverged(db);
|
|
456
|
+
const out = _readWorktreePoolFromSql(db);
|
|
457
|
+
if (out.entries.length === 0) {
|
|
458
|
+
const fallback = _readJson(_resolveFilePath('worktree-pool.json'));
|
|
459
|
+
if (fallback && Array.isArray(fallback.entries) && fallback.entries.length > 0) return fallback;
|
|
460
|
+
return { entries: [] };
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function applyWorktreePoolMutation(mutator) {
|
|
466
|
+
const { getDb, withTransaction } = require('./db');
|
|
467
|
+
let db;
|
|
468
|
+
try { db = getDb(); }
|
|
469
|
+
catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
|
|
470
|
+
|
|
471
|
+
return withTransaction(db, () => {
|
|
472
|
+
_resyncWorktreePoolIfDiverged(db);
|
|
473
|
+
const before = _readWorktreePoolFromSql(db);
|
|
474
|
+
const beforeSnap = JSON.parse(JSON.stringify(before));
|
|
475
|
+
const next = mutator(before);
|
|
476
|
+
const after = (next === undefined || next === null) ? before : next;
|
|
477
|
+
if (!after || !Array.isArray(after.entries)) after.entries = [];
|
|
478
|
+
|
|
479
|
+
const indexByKey = (arr) => {
|
|
480
|
+
const out = new Map();
|
|
481
|
+
for (const e of arr) {
|
|
482
|
+
const k = _entryKey(e);
|
|
483
|
+
if (k) out.set(String(k), e);
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
};
|
|
487
|
+
const beforeMap = indexByKey(beforeSnap.entries);
|
|
488
|
+
const afterMap = indexByKey(after.entries);
|
|
489
|
+
|
|
490
|
+
const now = Date.now();
|
|
491
|
+
const upsert = db.prepare(`
|
|
492
|
+
INSERT INTO worktree_pool (entry_id, data, updated_at)
|
|
493
|
+
VALUES (?, ?, ?)
|
|
494
|
+
ON CONFLICT(entry_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
|
495
|
+
`);
|
|
496
|
+
const del = db.prepare('DELETE FROM worktree_pool WHERE entry_id = ?');
|
|
497
|
+
let wrote = false;
|
|
498
|
+
for (const [id, entry] of afterMap) {
|
|
499
|
+
const prev = beforeMap.get(id);
|
|
500
|
+
if (!prev || JSON.stringify(prev) !== JSON.stringify(entry)) {
|
|
501
|
+
upsert.run(id, JSON.stringify(entry), now);
|
|
502
|
+
wrote = true;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
for (const [id] of beforeMap) {
|
|
506
|
+
if (!afterMap.has(id)) { del.run(id); wrote = true; }
|
|
507
|
+
}
|
|
508
|
+
return { wrote, result: after };
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function _mirrorWorktreePoolJson(filePath) {
|
|
513
|
+
try {
|
|
514
|
+
const shared = require('./shared');
|
|
515
|
+
const { getDb } = require('./db');
|
|
516
|
+
const obj = _readWorktreePoolFromSql(getDb());
|
|
517
|
+
const target = filePath || _resolveFilePath('worktree-pool.json');
|
|
518
|
+
shared.safeWrite(target, obj);
|
|
519
|
+
const h = _fileContentHash(target);
|
|
520
|
+
if (h != null) _worktreePoolHash = h;
|
|
521
|
+
} catch { /* mirror best-effort */ }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ─── Test seam ─────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
function _resetAllForTest() {
|
|
527
|
+
const { getDb } = require('./db');
|
|
528
|
+
try {
|
|
529
|
+
const db = getDb();
|
|
530
|
+
db.exec('DELETE FROM schedule_runs');
|
|
531
|
+
db.exec('DELETE FROM pipeline_runs');
|
|
532
|
+
db.exec('DELETE FROM managed_processes');
|
|
533
|
+
db.exec('DELETE FROM worktree_pool');
|
|
534
|
+
} catch { /* not initialized */ }
|
|
535
|
+
_scheduleRunsHash = null;
|
|
536
|
+
_pipelineRunsHash = null;
|
|
537
|
+
_managedProcessesHash = null;
|
|
538
|
+
_worktreePoolHash = null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
module.exports = {
|
|
542
|
+
// schedule_runs
|
|
543
|
+
readScheduleRuns,
|
|
544
|
+
applyScheduleRunsMutation,
|
|
545
|
+
_mirrorScheduleRunsJson,
|
|
546
|
+
// pipeline_runs
|
|
547
|
+
readPipelineRuns,
|
|
548
|
+
applyPipelineRunsMutation,
|
|
549
|
+
_mirrorPipelineRunsJson,
|
|
550
|
+
// managed_processes
|
|
551
|
+
readManagedProcesses,
|
|
552
|
+
applyManagedProcessesMutation,
|
|
553
|
+
_mirrorManagedProcessesJson,
|
|
554
|
+
// worktree_pool
|
|
555
|
+
readWorktreePool,
|
|
556
|
+
applyWorktreePoolMutation,
|
|
557
|
+
_mirrorWorktreePoolJson,
|
|
558
|
+
// test seam
|
|
559
|
+
_resetAllForTest,
|
|
560
|
+
};
|