@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.
@@ -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
+ };
@@ -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.mutateJsonFileLocked(statePath, (data) => {
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.mutateJsonFileLocked(statePath, (data) => {
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.mutateJsonFileLocked(statePath, (data) => {
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.mutateJsonFileLocked(statePath, (data) => {
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.mutateJsonFileLocked(statePath, (data) => {
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.mutateJsonFileLocked(statePath, (data) => {
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.mutateJsonFileLocked(statePath, (data) => {
1443
+ shared.mutateManagedProcesses((data) => {
1444
1444
  if (!data || typeof data !== 'object' || !Array.isArray(data.specs)) {
1445
1445
  return _initialStateShape();
1446
1446
  }
@@ -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
- mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
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
- }, { defaultValue: {} });
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
- mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
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
- }, { defaultValue: {} });
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
- mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
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
- }, { defaultValue: {} });
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`);
@@ -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
- mutateJsonFileLocked(SCHEDULE_RUNS_PATH, (runs) => {
197
+ mutateScheduleRuns((runs) => {
198
198
  entry = writeScheduleRunEntry(runs, scheduleId, workItemId);
199
199
  return runs;
200
- }, { defaultValue: {} });
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
- mutateJsonFileLocked(SCHEDULE_RUNS_PATH, (runs) => {
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
- }, { defaultValue: {} });
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
+ };
@@ -61,12 +61,16 @@ function readPool() {
61
61
  }
62
62
 
63
63
  function mutateWorktreePool(mutator) {
64
- return mutateJsonFileLocked(getPoolPath(), (data) => {
64
+ // Phase 7: route through shared.mutateWorktreePool (SQL-backed store +
65
+ // JSON mirror). Defensive wrappers around `data.entries` kept for the
66
+ // legacy fallback path inside the helper (when SQLite is unavailable
67
+ // and we fall through to mutateJsonFileLocked).
68
+ return shared.mutateWorktreePool((data) => {
65
69
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = { entries: [] };
66
70
  if (!Array.isArray(data.entries)) data.entries = [];
67
71
  const next = mutator(data);
68
72
  return next === undefined ? data : next;
69
- }, { defaultValue: { entries: [] }, skipWriteIfUnchanged: true });
73
+ });
70
74
  }
71
75
 
72
76
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2073",
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"