@yemi33/minions 0.1.2073 → 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/engine/db/migrations/008-small-state.js +155 -0
- package/engine/managed-spawn.js +7 -7
- package/engine/pipeline.js +7 -9
- package/engine/scheduler.js +6 -5
- package/engine/shared.js +64 -1
- package/engine/small-state-store.js +560 -0
- package/engine/worktree-pool.js +6 -2
- package/package.json +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// engine/db/migrations/008-small-state.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 7: move the four remaining small state files into SQL in one
|
|
4
|
+
// migration. Each gets its own table — the files are too dissimilar to
|
|
5
|
+
// share schemas — but they ship together because they're all simple,
|
|
6
|
+
// low-traffic singletons.
|
|
7
|
+
//
|
|
8
|
+
// engine/schedule-runs.json -> schedule_runs
|
|
9
|
+
// engine/pipeline-runs.json -> pipeline_runs
|
|
10
|
+
// engine/managed-processes.json -> managed_processes
|
|
11
|
+
// engine/worktree-pool.json -> worktree_pool
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
|
|
16
|
+
function _resolveMinionsDir() {
|
|
17
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
18
|
+
if (envHome) return envHome;
|
|
19
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _toMs(v) {
|
|
23
|
+
if (v == null) return null;
|
|
24
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
25
|
+
const parsed = Date.parse(v);
|
|
26
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _readJsonOr(filePath, fallback) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
} catch { return fallback; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
version: 8,
|
|
38
|
+
description: 'schedule_runs + pipeline_runs + managed_processes + worktree_pool',
|
|
39
|
+
up(db) {
|
|
40
|
+
db.exec(`
|
|
41
|
+
CREATE TABLE schedule_runs (
|
|
42
|
+
schedule_id TEXT PRIMARY KEY,
|
|
43
|
+
data TEXT NOT NULL,
|
|
44
|
+
updated_at INTEGER NOT NULL
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE pipeline_runs (
|
|
48
|
+
pipeline_id TEXT NOT NULL,
|
|
49
|
+
run_id TEXT NOT NULL,
|
|
50
|
+
status TEXT,
|
|
51
|
+
started_at INTEGER,
|
|
52
|
+
data TEXT NOT NULL,
|
|
53
|
+
updated_at INTEGER NOT NULL,
|
|
54
|
+
PRIMARY KEY (pipeline_id, run_id)
|
|
55
|
+
);
|
|
56
|
+
CREATE INDEX idx_pipeline_runs_status ON pipeline_runs(status);
|
|
57
|
+
CREATE INDEX idx_pipeline_runs_pipeline ON pipeline_runs(pipeline_id);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE managed_processes (
|
|
60
|
+
name TEXT PRIMARY KEY,
|
|
61
|
+
data TEXT NOT NULL,
|
|
62
|
+
updated_at INTEGER NOT NULL
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE worktree_pool (
|
|
66
|
+
entry_id TEXT PRIMARY KEY,
|
|
67
|
+
data TEXT NOT NULL,
|
|
68
|
+
updated_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
const minionsDir = _resolveMinionsDir();
|
|
73
|
+
if (!minionsDir) return;
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
let inserted = 0;
|
|
76
|
+
|
|
77
|
+
// ── schedule_runs ──────────────────────────────────────────────────────
|
|
78
|
+
{
|
|
79
|
+
const raw = _readJsonOr(path.join(minionsDir, 'engine', 'schedule-runs.json'), null);
|
|
80
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
81
|
+
const ins = db.prepare(`INSERT INTO schedule_runs (schedule_id, data, updated_at) VALUES (?, ?, ?)`);
|
|
82
|
+
for (const [scheduleId, value] of Object.entries(raw)) {
|
|
83
|
+
try {
|
|
84
|
+
ins.run(scheduleId, JSON.stringify(value), now);
|
|
85
|
+
inserted += 1;
|
|
86
|
+
} catch { /* duplicate / corrupt — skip */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── pipeline_runs ──────────────────────────────────────────────────────
|
|
92
|
+
{
|
|
93
|
+
const raw = _readJsonOr(path.join(minionsDir, 'engine', 'pipeline-runs.json'), null);
|
|
94
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
95
|
+
const ins = db.prepare(`
|
|
96
|
+
INSERT INTO pipeline_runs (pipeline_id, run_id, status, started_at, data, updated_at)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
98
|
+
`);
|
|
99
|
+
for (const [pipelineId, runs] of Object.entries(raw)) {
|
|
100
|
+
if (!Array.isArray(runs)) continue;
|
|
101
|
+
for (const run of runs) {
|
|
102
|
+
if (!run || !run.runId) continue;
|
|
103
|
+
try {
|
|
104
|
+
ins.run(
|
|
105
|
+
pipelineId,
|
|
106
|
+
String(run.runId),
|
|
107
|
+
run.status || null,
|
|
108
|
+
_toMs(run.startedAt),
|
|
109
|
+
JSON.stringify(run),
|
|
110
|
+
now,
|
|
111
|
+
);
|
|
112
|
+
inserted += 1;
|
|
113
|
+
} catch { /* duplicate / corrupt — skip */ }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── managed_processes ──────────────────────────────────────────────────
|
|
120
|
+
{
|
|
121
|
+
const raw = _readJsonOr(path.join(minionsDir, 'engine', 'managed-processes.json'), null);
|
|
122
|
+
if (raw && Array.isArray(raw.specs)) {
|
|
123
|
+
const ins = db.prepare(`INSERT INTO managed_processes (name, data, updated_at) VALUES (?, ?, ?)`);
|
|
124
|
+
for (const spec of raw.specs) {
|
|
125
|
+
if (!spec || typeof spec !== 'object' || !spec.name) continue;
|
|
126
|
+
try {
|
|
127
|
+
ins.run(String(spec.name), JSON.stringify(spec), now);
|
|
128
|
+
inserted += 1;
|
|
129
|
+
} catch { /* duplicate name — skip */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── worktree_pool ──────────────────────────────────────────────────────
|
|
135
|
+
{
|
|
136
|
+
const raw = _readJsonOr(path.join(minionsDir, 'engine', 'worktree-pool.json'), null);
|
|
137
|
+
if (raw && Array.isArray(raw.entries)) {
|
|
138
|
+
const ins = db.prepare(`INSERT INTO worktree_pool (entry_id, data, updated_at) VALUES (?, ?, ?)`);
|
|
139
|
+
for (const entry of raw.entries) {
|
|
140
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
141
|
+
// Worktree pool entries don't have an obvious unique id field;
|
|
142
|
+
// use the worktree path (always unique within a single pool).
|
|
143
|
+
const id = entry.path || entry.id || JSON.stringify(entry).slice(0, 64);
|
|
144
|
+
try {
|
|
145
|
+
ins.run(String(id), JSON.stringify(entry), now);
|
|
146
|
+
inserted += 1;
|
|
147
|
+
} catch { /* duplicate — skip */ }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.log(`[db-migrate] v8: backfilled ${inserted} small-state rows; JSON files kept as dual-write mirrors`);
|
|
154
|
+
},
|
|
155
|
+
};
|
package/engine/managed-spawn.js
CHANGED
|
@@ -850,7 +850,7 @@ function _toStateRecord(spec, runtime, ctx) {
|
|
|
850
850
|
function recordManagedSpec(spec, runtime, ctx) {
|
|
851
851
|
if (!spec || !spec.name) throw new Error('recordManagedSpec: spec.name required');
|
|
852
852
|
const statePath = _getStatePath();
|
|
853
|
-
shared.
|
|
853
|
+
shared.mutateManagedProcesses((data) => {
|
|
854
854
|
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
|
|
855
855
|
data = _initialStateShape();
|
|
856
856
|
}
|
|
@@ -865,7 +865,7 @@ function recordManagedSpec(spec, runtime, ctx) {
|
|
|
865
865
|
function recordManagedBatch(items, ctx) {
|
|
866
866
|
if (!Array.isArray(items) || items.length === 0) return;
|
|
867
867
|
const statePath = _getStatePath();
|
|
868
|
-
shared.
|
|
868
|
+
shared.mutateManagedProcesses((data) => {
|
|
869
869
|
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
|
|
870
870
|
data = _initialStateShape();
|
|
871
871
|
}
|
|
@@ -886,7 +886,7 @@ function removeManagedSpec(name) {
|
|
|
886
886
|
if (typeof name !== 'string' || name.length === 0) return;
|
|
887
887
|
let killPid = null;
|
|
888
888
|
const statePath = _getStatePath();
|
|
889
|
-
shared.
|
|
889
|
+
shared.mutateManagedProcesses((data) => {
|
|
890
890
|
if (!data || !Array.isArray(data.specs)) return data;
|
|
891
891
|
const idx = data.specs.findIndex(s => s && s.name === name);
|
|
892
892
|
if (idx < 0) return data;
|
|
@@ -1033,7 +1033,7 @@ async function runHealthcheck(spec) {
|
|
|
1033
1033
|
// lock — this acquires its own.
|
|
1034
1034
|
function _markHealthy(name, now) {
|
|
1035
1035
|
const statePath = _getStatePath();
|
|
1036
|
-
shared.
|
|
1036
|
+
shared.mutateManagedProcesses((data) => {
|
|
1037
1037
|
if (!data || !Array.isArray(data.specs)) return data;
|
|
1038
1038
|
const rec = data.specs.find(s => s && s.name === name);
|
|
1039
1039
|
if (!rec) return data;
|
|
@@ -1334,7 +1334,7 @@ function _runManagedReconcile(opts) {
|
|
|
1334
1334
|
const statePath = _getStatePath();
|
|
1335
1335
|
const ttlPidsToKill = [];
|
|
1336
1336
|
const survivors = []; // [{name, log_path}] post-mutation, used for log rotation + bootReconcile probes
|
|
1337
|
-
shared.
|
|
1337
|
+
shared.mutateManagedProcesses((data) => {
|
|
1338
1338
|
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
|
|
1339
1339
|
stats.malformed++;
|
|
1340
1340
|
return _initialStateShape();
|
|
@@ -1387,7 +1387,7 @@ function sweepManagedSpawn(opts) {
|
|
|
1387
1387
|
// to (re)establish initial truth on engine restart).
|
|
1388
1388
|
function _markBootProbe(name, result) {
|
|
1389
1389
|
const statePath = _getStatePath();
|
|
1390
|
-
shared.
|
|
1390
|
+
shared.mutateManagedProcesses((data) => {
|
|
1391
1391
|
if (!data || !Array.isArray(data.specs)) return data;
|
|
1392
1392
|
const rec = data.specs.find(s => s && s.name === name);
|
|
1393
1393
|
if (!rec) return data;
|
|
@@ -1440,7 +1440,7 @@ function removeManagedSpecsForProject(projectName) {
|
|
|
1440
1440
|
const statePath = _getStatePath();
|
|
1441
1441
|
let toKill = [];
|
|
1442
1442
|
let logPaths = [];
|
|
1443
|
-
shared.
|
|
1443
|
+
shared.mutateManagedProcesses((data) => {
|
|
1444
1444
|
if (!data || typeof data !== 'object' || !Array.isArray(data.specs)) {
|
|
1445
1445
|
return _initialStateShape();
|
|
1446
1446
|
}
|
package/engine/pipeline.js
CHANGED
|
@@ -8,7 +8,7 @@ const fs = require('fs');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const queries = require('./queries');
|
|
11
|
-
const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
11
|
+
const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
12
12
|
const routing = require('./routing');
|
|
13
13
|
const http = require('http');
|
|
14
14
|
const { parseCronExpr, shouldRunNow } = require('./scheduler');
|
|
@@ -90,18 +90,16 @@ function startRun(pipelineId, pipeline) {
|
|
|
90
90
|
const run = { runId, pipelineId, startedAt: ts(), status: PIPELINE_STATUS.RUNNING, stages };
|
|
91
91
|
|
|
92
92
|
let alreadyActive = false;
|
|
93
|
-
|
|
93
|
+
mutatePipelineRuns((data) => {
|
|
94
94
|
if (!data[pipelineId]) data[pipelineId] = [];
|
|
95
|
-
// Guard: skip if there's already an active run (prevents race between ticks)
|
|
96
95
|
if (data[pipelineId].some(r => r.status === PIPELINE_STATUS.RUNNING || r.status === PIPELINE_STATUS.PAUSED)) {
|
|
97
96
|
alreadyActive = true;
|
|
98
97
|
return data;
|
|
99
98
|
}
|
|
100
|
-
// Keep last 10 runs per pipeline
|
|
101
99
|
if (data[pipelineId].length >= 10) data[pipelineId] = data[pipelineId].slice(-9);
|
|
102
100
|
data[pipelineId].push(run);
|
|
103
101
|
return data;
|
|
104
|
-
}
|
|
102
|
+
});
|
|
105
103
|
|
|
106
104
|
if (alreadyActive) {
|
|
107
105
|
log('info', `Pipeline ${pipelineId}: skipped startRun — active run already exists`);
|
|
@@ -118,14 +116,14 @@ function updateRunStage(pipelineId, runId, stageId, updates) {
|
|
|
118
116
|
throw new Error(`updateRunStage: invalid status '${updates.status}' (expected one of: ${validStatuses.join('|')})`);
|
|
119
117
|
}
|
|
120
118
|
}
|
|
121
|
-
|
|
119
|
+
mutatePipelineRuns((data) => {
|
|
122
120
|
const runs = data[pipelineId] || [];
|
|
123
121
|
const run = runs.find(r => r.runId === runId);
|
|
124
122
|
if (run && run.stages[stageId]) {
|
|
125
123
|
Object.assign(run.stages[stageId], updates);
|
|
126
124
|
}
|
|
127
125
|
return data;
|
|
128
|
-
}
|
|
126
|
+
});
|
|
129
127
|
}
|
|
130
128
|
|
|
131
129
|
function completeRun(pipelineId, runId, status) {
|
|
@@ -135,7 +133,7 @@ function completeRun(pipelineId, runId, status) {
|
|
|
135
133
|
// never shows "in progress" for a stage inside a closed run.
|
|
136
134
|
const TERMINAL = new Set([PIPELINE_STATUS.COMPLETED, PIPELINE_STATUS.FAILED, PIPELINE_STATUS.STOPPED]);
|
|
137
135
|
const anomalies = [];
|
|
138
|
-
|
|
136
|
+
mutatePipelineRuns((data) => {
|
|
139
137
|
const runs = data[pipelineId] || [];
|
|
140
138
|
const run = runs.find(r => r.runId === runId);
|
|
141
139
|
if (run) {
|
|
@@ -158,7 +156,7 @@ function completeRun(pipelineId, runId, status) {
|
|
|
158
156
|
}
|
|
159
157
|
}
|
|
160
158
|
return data;
|
|
161
|
-
}
|
|
159
|
+
});
|
|
162
160
|
log('info', `Pipeline ${pipelineId}: run ${runId} → ${status}`);
|
|
163
161
|
if (anomalies.length > 0) {
|
|
164
162
|
log('warn', `Pipeline ${pipelineId}: run ${runId} closed COMPLETED with non-terminal stages [${anomalies.join(', ')}] — forced to completed`);
|
package/engine/scheduler.js
CHANGED
|
@@ -25,7 +25,7 @@ const fs = require('fs');
|
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const shared = require('./shared');
|
|
27
27
|
const routing = require('./routing');
|
|
28
|
-
const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS, WORK_TYPE } = shared;
|
|
28
|
+
const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateStamp, WI_STATUS, WORK_TYPE } = shared;
|
|
29
29
|
|
|
30
30
|
const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
|
|
31
31
|
|
|
@@ -194,10 +194,10 @@ function writeScheduleRunEntry(runs, scheduleId, workItemId) {
|
|
|
194
194
|
|
|
195
195
|
function recordScheduleRun(scheduleId, workItemId) {
|
|
196
196
|
let entry = null;
|
|
197
|
-
|
|
197
|
+
mutateScheduleRuns((runs) => {
|
|
198
198
|
entry = writeScheduleRunEntry(runs, scheduleId, workItemId);
|
|
199
199
|
return runs;
|
|
200
|
-
}
|
|
200
|
+
});
|
|
201
201
|
return entry;
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -212,7 +212,7 @@ function discoverScheduledWork(config) {
|
|
|
212
212
|
|
|
213
213
|
// Use file-locked mutation to prevent race conditions on rapid calls
|
|
214
214
|
const work = [];
|
|
215
|
-
|
|
215
|
+
mutateScheduleRuns((runs) => {
|
|
216
216
|
for (const sched of schedules) {
|
|
217
217
|
if (!sched.id || !sched.cron || !sched.title) continue;
|
|
218
218
|
if (sched.enabled === false) continue; // strict false check — undefined/null default to enabled per schema
|
|
@@ -236,7 +236,8 @@ function discoverScheduledWork(config) {
|
|
|
236
236
|
// the double-fire window alongside the same-minute guard (W-mo3zu273f8tm).
|
|
237
237
|
writeScheduleRunEntry(runs, sched.id, workItem.id);
|
|
238
238
|
}
|
|
239
|
-
|
|
239
|
+
return runs;
|
|
240
|
+
});
|
|
240
241
|
|
|
241
242
|
return work;
|
|
242
243
|
}
|
package/engine/shared.js
CHANGED
|
@@ -2744,6 +2744,69 @@ const WATCH_ACTION_TYPE = {
|
|
|
2744
2744
|
RESUME_PLAN: 'resume-plan',
|
|
2745
2745
|
};
|
|
2746
2746
|
|
|
2747
|
+
/**
|
|
2748
|
+
* Phase 7 — small state file mutators. Each routes through the
|
|
2749
|
+
* small-state-store, mirrors back to its JSON file, and emits a topic
|
|
2750
|
+
* event on real writes. SQLite failure falls through to the legacy
|
|
2751
|
+
* mutateJsonFileLocked path.
|
|
2752
|
+
*/
|
|
2753
|
+
function _smallStateMutator({ filePath, applyMutation, mirror, topic, defaultValue }) {
|
|
2754
|
+
return (mutator) => {
|
|
2755
|
+
try {
|
|
2756
|
+
const store = require('./small-state-store');
|
|
2757
|
+
const { wrote, result } = store[applyMutation]((obj) => mutator(obj) || obj);
|
|
2758
|
+
if (wrote) {
|
|
2759
|
+
try { store[mirror](filePath); } catch { /* mirror best-effort */ }
|
|
2760
|
+
try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
|
|
2761
|
+
}
|
|
2762
|
+
return result;
|
|
2763
|
+
} catch (e) {
|
|
2764
|
+
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) throw e;
|
|
2765
|
+
}
|
|
2766
|
+
return mutateJsonFileLocked(filePath, (data) => {
|
|
2767
|
+
if (data == null) data = defaultValue();
|
|
2768
|
+
return mutator(data) || data;
|
|
2769
|
+
}, {
|
|
2770
|
+
defaultValue: defaultValue(),
|
|
2771
|
+
onWrote: () => {
|
|
2772
|
+
try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
|
|
2773
|
+
},
|
|
2774
|
+
});
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
const mutateScheduleRuns = _smallStateMutator({
|
|
2779
|
+
filePath: path.join(MINIONS_DIR, 'engine', 'schedule-runs.json'),
|
|
2780
|
+
applyMutation: 'applyScheduleRunsMutation',
|
|
2781
|
+
mirror: '_mirrorScheduleRunsJson',
|
|
2782
|
+
topic: 'schedule_runs',
|
|
2783
|
+
defaultValue: () => ({}),
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
const mutatePipelineRuns = _smallStateMutator({
|
|
2787
|
+
filePath: path.join(MINIONS_DIR, 'engine', 'pipeline-runs.json'),
|
|
2788
|
+
applyMutation: 'applyPipelineRunsMutation',
|
|
2789
|
+
mirror: '_mirrorPipelineRunsJson',
|
|
2790
|
+
topic: 'pipeline_runs',
|
|
2791
|
+
defaultValue: () => ({}),
|
|
2792
|
+
});
|
|
2793
|
+
|
|
2794
|
+
const mutateManagedProcesses = _smallStateMutator({
|
|
2795
|
+
filePath: path.join(MINIONS_DIR, 'engine', 'managed-processes.json'),
|
|
2796
|
+
applyMutation: 'applyManagedProcessesMutation',
|
|
2797
|
+
mirror: '_mirrorManagedProcessesJson',
|
|
2798
|
+
topic: 'managed_processes',
|
|
2799
|
+
defaultValue: () => ({ specs: [] }),
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2802
|
+
const mutateWorktreePool = _smallStateMutator({
|
|
2803
|
+
filePath: path.join(MINIONS_DIR, 'engine', 'worktree-pool.json'),
|
|
2804
|
+
applyMutation: 'applyWorktreePoolMutation',
|
|
2805
|
+
mirror: '_mirrorWorktreePoolJson',
|
|
2806
|
+
topic: 'worktree_pool',
|
|
2807
|
+
defaultValue: () => ({ entries: [] }),
|
|
2808
|
+
});
|
|
2809
|
+
|
|
2747
2810
|
/**
|
|
2748
2811
|
* Route a watches mutation through the SQL store. Same shape as
|
|
2749
2812
|
* mutateWorkItems / mutatePullRequests: mutator receives the watches
|
|
@@ -5262,7 +5325,7 @@ module.exports = {
|
|
|
5262
5325
|
runtimeConfigWarnings,
|
|
5263
5326
|
projectWorkSourceWarnings,
|
|
5264
5327
|
backfillProjectWorkSourceDefaults,
|
|
5265
|
-
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, mutateWatches, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
|
|
5328
|
+
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, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
|
|
5266
5329
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
|
|
5267
5330
|
WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
|
|
5268
5331
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
@@ -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
|
+
};
|
package/engine/worktree-pool.js
CHANGED
|
@@ -61,12 +61,16 @@ function readPool() {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function mutateWorktreePool(mutator) {
|
|
64
|
-
|
|
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
|
-
}
|
|
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.
|
|
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"
|