@yemi33/minions 0.1.2072 → 0.1.2073
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/metrics-store.js +0 -0
- package/engine/pull-requests-store.js +30 -22
- package/engine/shared.js +38 -1
- package/engine/watches-store.js +259 -0
- package/engine/watches.js +12 -16
- package/engine/work-items-store.js +33 -35
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Architecture, design proposals, and lifecycle references for people working on t
|
|
|
23
23
|
- [plan-lifecycle.md](plan-lifecycle.md) — Full plan pipeline from `/plan` through PRD materialization, dispatch with dependency gating, verify task, and human archive.
|
|
24
24
|
- [pr-comment-followup.md](pr-comment-followup.md) — PR-comment follow-up dispatch contract: fix/review agents may spin off a new WI via `POST /api/work-items` with `meta.pr_followup` instead of broadening the current PR or rebutting the comment.
|
|
25
25
|
- [pr-review-fix-loop.md](pr-review-fix-loop.md) — How the engine moves a PR from creation through review, fix dispatch, and re-review, including stale-status guards.
|
|
26
|
+
- [qa-runbook-lifecycle.md](qa-runbook-lifecycle.md) — End-to-end QA runbook lifecycle (W-mpeiwz6k0005bf34): runbook + run-record storage, `POST /api/qa/runbooks/run` dispatch into the `qa-validate` playbook, artifact contract, and how the `/qa` page mirrors managed-spawn observability.
|
|
26
27
|
- [qa-runbooks.md](qa-runbooks.md) — Per-project QA runbook schema, storage layout (`projects/<name>/runbooks/<id>.json`), CRUD endpoints, run-record lifecycle, and the `qa-validate` agent sidecar contract.
|
|
27
28
|
- [rfc-completion-json.md](rfc-completion-json.md) — RFC for replacing stdout regex-scraping with a structured `completion.json` control-plane protocol.
|
|
28
29
|
- [runtime-adapters.md](runtime-adapters.md) — Runtime adapter contract (`engine/runtimes/*`): how the engine talks to Claude Code, Copilot CLI, and future CLIs through a single capability-flagged interface.
|
package/docs/auto-discovery.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Auto-Discovery & Execution Pipeline
|
|
2
2
|
|
|
3
|
-
> Last verified: 2026-05-
|
|
3
|
+
> Last verified: 2026-05-28 against `engine.js` `tickInner()` and `routing.md`.
|
|
4
4
|
|
|
5
5
|
How the minions engine finds work and dispatches agents automatically.
|
|
6
6
|
|
|
@@ -15,6 +15,7 @@ tick()
|
|
|
15
15
|
1b. checkIdleThreshold() Notify on excessive agent idleness
|
|
16
16
|
1c. meetingTimeouts() Advance round-based meetings whose timer fired
|
|
17
17
|
2. consolidateInbox() Merge learnings into notes.md (Haiku-powered)
|
|
18
|
+
2.1 autoSweepKb() Periodic KB sweep (opt-in via engine.autoConsolidateMemory, 4h cadence)
|
|
18
19
|
2.5 runCleanup() Periodic cleanup (every 10 ticks ≈ 10min)
|
|
19
20
|
2.52 sweepKeepProcesses() keep_processes TTL/dead-PID sweep (every 30 ticks)
|
|
20
21
|
2.53 sweepManagedSpawn() managed_spawn TTL/dead-PID/log-rotate sweep (every 30 ticks)
|
package/docs/kb-sweep.md
CHANGED
|
@@ -114,6 +114,14 @@ Sweep state is mirrored to `engine/kb-sweep-state.json` so the dashboard can rec
|
|
|
114
114
|
|
|
115
115
|
Memory still wins when present; the disk file is a fallback (source: [`engine/kb-sweep.js:298-320`](../engine/kb-sweep.js#L298), [`dashboard.js:4296-4354`](../dashboard.js#L4296)). A separate `engine/kb-swept.json` is written after each successful sweep with the human-readable summary line shown by the dashboard's "swept N days ago" badge (source: [`engine/kb-sweep.js:407`](../engine/kb-sweep.js#L407)).
|
|
116
116
|
|
|
117
|
+
## Automatic Periodic Sweep (opt-in)
|
|
118
|
+
|
|
119
|
+
The engine tick loop can also auto-spawn the KB sweep without dashboard interaction. Gated by `engine.autoConsolidateMemory` (default `false` — source: [`engine/shared.js:1835`](../engine/shared.js#L1835)):
|
|
120
|
+
|
|
121
|
+
- When `engine.autoConsolidateMemory: true`, every tick the engine consults `shouldAutoSweep()` from [`engine/kb-sweep.js`](../engine/kb-sweep.js) and, when the 4-hour cadence has elapsed since the last completion, calls `spawnSweepRunnerDetached()` to fire-and-forget a fresh `engine/kb-sweep-runner.js` process (source: [`engine.js`](../engine.js) tick step 2.1).
|
|
122
|
+
- The inbox→`notes.md` consolidation runs every tick *regardless* of this flag via `consolidateInbox()`; `autoConsolidateMemory` controls **only** the heavier `knowledge/` sweep.
|
|
123
|
+
- The detached runner survives `minions restart` (same pattern used by the manual trigger), and the in-flight guard above prevents overlap with manual sweeps.
|
|
124
|
+
|
|
117
125
|
## Dashboard UI
|
|
118
126
|
|
|
119
127
|
The KB page surfaces sweep state in two places:
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// engine/db/migrations/007-watches.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 6: move engine/watches.json into a `watches` table.
|
|
4
|
+
//
|
|
5
|
+
// watches.json is an array of watch jobs polled every 3 ticks. Most
|
|
6
|
+
// installs carry only a handful (operator-defined notifications, plan-
|
|
7
|
+
// completion gates, etc), but the table is still indexed by status +
|
|
8
|
+
// target_type so dashboards / future filters can do "show active PR
|
|
9
|
+
// watches" without pulling the whole array.
|
|
10
|
+
//
|
|
11
|
+
// Schema is the same hybrid pattern Phases 2/3/5 use: typed projection
|
|
12
|
+
// columns for the hot filters, a `data` TEXT blob for everything else.
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
|
|
17
|
+
function _resolveMinionsDir() {
|
|
18
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
19
|
+
if (envHome) return envHome;
|
|
20
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _toMs(v) {
|
|
24
|
+
if (v == null) return null;
|
|
25
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
26
|
+
const parsed = Date.parse(v);
|
|
27
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
version: 7,
|
|
32
|
+
description: 'watches: schema + watches.json backfill',
|
|
33
|
+
up(db) {
|
|
34
|
+
db.exec(`
|
|
35
|
+
CREATE TABLE watches (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
target TEXT,
|
|
38
|
+
target_type TEXT,
|
|
39
|
+
condition TEXT,
|
|
40
|
+
status TEXT NOT NULL,
|
|
41
|
+
owner TEXT,
|
|
42
|
+
created_at INTEGER,
|
|
43
|
+
last_checked INTEGER,
|
|
44
|
+
last_triggered INTEGER,
|
|
45
|
+
data TEXT NOT NULL,
|
|
46
|
+
updated_at INTEGER NOT NULL
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX idx_watches_status ON watches(status);
|
|
49
|
+
CREATE INDEX idx_watches_target_type ON watches(target_type);
|
|
50
|
+
CREATE INDEX idx_watches_target ON watches(target);
|
|
51
|
+
`);
|
|
52
|
+
|
|
53
|
+
const minionsDir = _resolveMinionsDir();
|
|
54
|
+
if (!minionsDir) return;
|
|
55
|
+
const watchesPath = path.join(minionsDir, 'engine', 'watches.json');
|
|
56
|
+
if (!fs.existsSync(watchesPath)) return;
|
|
57
|
+
|
|
58
|
+
let raw;
|
|
59
|
+
try { raw = JSON.parse(fs.readFileSync(watchesPath, 'utf8')); }
|
|
60
|
+
catch (e) {
|
|
61
|
+
throw new Error(`engine/db/007-watches: cannot parse watches.json: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(raw)) return;
|
|
64
|
+
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const insert = db.prepare(`
|
|
67
|
+
INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
|
|
68
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
69
|
+
`);
|
|
70
|
+
|
|
71
|
+
let inserted = 0;
|
|
72
|
+
for (const w of raw) {
|
|
73
|
+
if (!w || typeof w !== 'object' || !w.id) continue;
|
|
74
|
+
try {
|
|
75
|
+
insert.run(
|
|
76
|
+
String(w.id),
|
|
77
|
+
typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
|
|
78
|
+
w.targetType || null,
|
|
79
|
+
w.condition || null,
|
|
80
|
+
String(w.status || 'active'),
|
|
81
|
+
w.owner || null,
|
|
82
|
+
_toMs(w.created_at),
|
|
83
|
+
_toMs(w.last_checked),
|
|
84
|
+
_toMs(w.last_triggered),
|
|
85
|
+
JSON.stringify(w),
|
|
86
|
+
now,
|
|
87
|
+
);
|
|
88
|
+
inserted += 1;
|
|
89
|
+
} catch { /* duplicate id — skip defensively */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// eslint-disable-next-line no-console
|
|
93
|
+
console.log(`[db-migrate] v7: backfilled ${inserted} watches; watches.json kept as dual-write mirror`);
|
|
94
|
+
},
|
|
95
|
+
};
|
package/engine/metrics-store.js
CHANGED
|
Binary file
|
|
@@ -62,15 +62,16 @@ function _readJsonArrayFallback(scope) {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
const _lastMirrorByScope = new Map();
|
|
65
|
+
// Content-hash fingerprint per scope: same-length swaps (timestamps,
|
|
66
|
+
// reviewStatus enums) collide on (mtime, size) but never on SHA-1.
|
|
67
|
+
const _lastMirrorHashByScope = new Map();
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
const crypto = require('crypto');
|
|
70
|
+
|
|
71
|
+
function _fileContentHash(filePath) {
|
|
71
72
|
try {
|
|
72
|
-
const
|
|
73
|
-
return
|
|
73
|
+
const buf = fs.readFileSync(filePath);
|
|
74
|
+
return crypto.createHash('sha1').update(buf).digest('hex');
|
|
74
75
|
}
|
|
75
76
|
catch { return null; }
|
|
76
77
|
}
|
|
@@ -105,23 +106,19 @@ function _hydrateScopeFromJson(db, scope) {
|
|
|
105
106
|
|
|
106
107
|
function _resyncScopeIfJsonDiverged(db, scope) {
|
|
107
108
|
const jsonPath = _filePathForScope(scope);
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (lastMirror != null
|
|
114
|
-
&& current.mtime === lastMirror.mtime
|
|
115
|
-
&& current.size === lastMirror.size) return;
|
|
116
|
-
if (lastMirror == null) {
|
|
109
|
+
const currentHash = _fileContentHash(jsonPath);
|
|
110
|
+
const lastHash = _lastMirrorHashByScope.get(scope);
|
|
111
|
+
if (currentHash == null) return;
|
|
112
|
+
if (lastHash != null && currentHash === lastHash) return;
|
|
113
|
+
if (lastHash == null) {
|
|
117
114
|
const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
|
|
118
115
|
if (sqlHas) {
|
|
119
|
-
|
|
116
|
+
_lastMirrorHashByScope.set(scope, currentHash);
|
|
120
117
|
return;
|
|
121
118
|
}
|
|
122
119
|
}
|
|
123
120
|
_hydrateScopeFromJson(db, scope);
|
|
124
|
-
|
|
121
|
+
_lastMirrorHashByScope.set(scope, currentHash);
|
|
125
122
|
}
|
|
126
123
|
|
|
127
124
|
function readPullRequestsForScope(scope) {
|
|
@@ -269,12 +266,23 @@ function applyPullRequestsMutation(scope, mutator) {
|
|
|
269
266
|
function _mirrorJsonFromSql(scope, filePath) {
|
|
270
267
|
try {
|
|
271
268
|
const shared = require('./shared');
|
|
272
|
-
const
|
|
273
|
-
|
|
269
|
+
const { getDb } = require('./db');
|
|
270
|
+
// Read SQL directly for this scope — bypass JSON fallback so a
|
|
271
|
+
// mutation that empties SQL for the scope doesn't resurrect stale
|
|
272
|
+
// JSON content.
|
|
273
|
+
const db = getDb();
|
|
274
|
+
const rows = db.prepare('SELECT data FROM pull_requests WHERE scope = ? ORDER BY rowid').all(scope);
|
|
275
|
+
const items = [];
|
|
276
|
+
for (const row of rows) {
|
|
277
|
+
const pr = _parseRow(row);
|
|
278
|
+
if (!pr) continue;
|
|
279
|
+
if (pr._scope) delete pr._scope;
|
|
280
|
+
items.push(pr);
|
|
281
|
+
}
|
|
274
282
|
const target = filePath || _filePathForScope(scope);
|
|
275
283
|
shared.safeWrite(target, items);
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
284
|
+
const h = _fileContentHash(target);
|
|
285
|
+
if (h != null) _lastMirrorHashByScope.set(scope, h);
|
|
278
286
|
} catch {
|
|
279
287
|
// Mirror failures are non-fatal — SQL has already committed.
|
|
280
288
|
}
|
package/engine/shared.js
CHANGED
|
@@ -2744,6 +2744,43 @@ const WATCH_ACTION_TYPE = {
|
|
|
2744
2744
|
RESUME_PLAN: 'resume-plan',
|
|
2745
2745
|
};
|
|
2746
2746
|
|
|
2747
|
+
/**
|
|
2748
|
+
* Route a watches mutation through the SQL store. Same shape as
|
|
2749
|
+
* mutateWorkItems / mutatePullRequests: mutator receives the watches
|
|
2750
|
+
* array, mutates in place or returns a replacement, and the store
|
|
2751
|
+
* diffs by id. Falls back to the legacy mutateJsonFileLocked path on
|
|
2752
|
+
* SQLite failure.
|
|
2753
|
+
*/
|
|
2754
|
+
function mutateWatches(mutator) {
|
|
2755
|
+
const watchesPath = path.join(MINIONS_DIR, 'engine', 'watches.json');
|
|
2756
|
+
try {
|
|
2757
|
+
const store = require('./watches-store');
|
|
2758
|
+
const { wrote, result } = store.applyWatchesMutation((arr) => {
|
|
2759
|
+
if (!Array.isArray(arr)) arr = [];
|
|
2760
|
+
return mutator(arr) || arr;
|
|
2761
|
+
});
|
|
2762
|
+
if (wrote) {
|
|
2763
|
+
try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
|
|
2764
|
+
try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
|
|
2765
|
+
}
|
|
2766
|
+
return result;
|
|
2767
|
+
} catch (e) {
|
|
2768
|
+
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
2769
|
+
throw e;
|
|
2770
|
+
}
|
|
2771
|
+
// SQLite unavailable — fall through to legacy JSON path.
|
|
2772
|
+
}
|
|
2773
|
+
return mutateJsonFileLocked(watchesPath, (data) => {
|
|
2774
|
+
if (!Array.isArray(data)) data = [];
|
|
2775
|
+
return mutator(data) || data;
|
|
2776
|
+
}, {
|
|
2777
|
+
defaultValue: [],
|
|
2778
|
+
onWrote: () => {
|
|
2779
|
+
try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
|
|
2780
|
+
},
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2747
2784
|
/**
|
|
2748
2785
|
* Route a metrics mutation through the SQL store with a JSON dual-write
|
|
2749
2786
|
* mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
|
|
@@ -5225,7 +5262,7 @@ module.exports = {
|
|
|
5225
5262
|
runtimeConfigWarnings,
|
|
5226
5263
|
projectWorkSourceWarnings,
|
|
5227
5264
|
backfillProjectWorkSourceDefaults,
|
|
5228
|
-
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, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
|
|
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,
|
|
5229
5266
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
|
|
5230
5267
|
WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
|
|
5231
5268
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// engine/watches-store.js — SQL-backed implementation of engine/watches.json.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the Phase 2/3 work_items / pull_requests stores: a single
|
|
4
|
+
// table (no per-scope split — watches.json is a flat array, not
|
|
5
|
+
// per-project), diff-then-apply mutator, JSON dual-write mirror with
|
|
6
|
+
// (mtime, size) fingerprint for external-edit detection.
|
|
7
|
+
//
|
|
8
|
+
// readWatches() -> [watch, watch, ...]
|
|
9
|
+
// applyWatchesMutation(fn) -> diff-then-apply, returns { wrote, result }
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
function _toMs(v) {
|
|
15
|
+
if (v == null) return null;
|
|
16
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
17
|
+
const parsed = Date.parse(v);
|
|
18
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _watchesFilePath() {
|
|
22
|
+
const shared = require('./shared');
|
|
23
|
+
return path.join(shared.MINIONS_DIR, 'engine', 'watches.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _parseRow(row) {
|
|
27
|
+
if (!row || !row.data) return null;
|
|
28
|
+
try { return JSON.parse(row.data); }
|
|
29
|
+
catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _readJsonArrayFallback() {
|
|
33
|
+
const fp = _watchesFilePath();
|
|
34
|
+
let raw;
|
|
35
|
+
try { raw = fs.readFileSync(fp, 'utf8'); }
|
|
36
|
+
catch { return []; }
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
40
|
+
} catch (e) {
|
|
41
|
+
try {
|
|
42
|
+
// eslint-disable-next-line no-console
|
|
43
|
+
console.warn(`[watches-store] corrupt JSON in ${fp}: ${e.message}`);
|
|
44
|
+
} catch { /* console may be wrapped */ }
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Content-hash fingerprint — (mtime, size) is too coarse: same-size
|
|
50
|
+
// content swaps (e.g. last_checked timestamps that always have the same
|
|
51
|
+
// byte length) inside the same ms tick collide on both axes. Hashing the
|
|
52
|
+
// bytes is the unambiguous signal. SHA-1 on a few-KB file is sub-ms.
|
|
53
|
+
let _lastMirrorHash = null;
|
|
54
|
+
|
|
55
|
+
const crypto = require('crypto');
|
|
56
|
+
|
|
57
|
+
function _fileContentHash(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
const buf = fs.readFileSync(filePath);
|
|
60
|
+
return crypto.createHash('sha1').update(buf).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _hydrateFromJson(db) {
|
|
66
|
+
const items = _readJsonArrayFallback();
|
|
67
|
+
db.prepare('DELETE FROM watches').run();
|
|
68
|
+
if (items.length === 0) return;
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const ins = db.prepare(`
|
|
71
|
+
INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
73
|
+
ON CONFLICT(id) DO NOTHING
|
|
74
|
+
`);
|
|
75
|
+
for (const w of items) {
|
|
76
|
+
if (!w || !w.id) continue;
|
|
77
|
+
ins.run(
|
|
78
|
+
String(w.id),
|
|
79
|
+
typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
|
|
80
|
+
w.targetType || null,
|
|
81
|
+
w.condition || null,
|
|
82
|
+
String(w.status || 'active'),
|
|
83
|
+
w.owner || null,
|
|
84
|
+
_toMs(w.created_at),
|
|
85
|
+
_toMs(w.last_checked),
|
|
86
|
+
_toMs(w.last_triggered),
|
|
87
|
+
JSON.stringify(w),
|
|
88
|
+
now,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _resyncIfJsonDiverged(db) {
|
|
94
|
+
const jsonPath = _watchesFilePath();
|
|
95
|
+
const currentHash = _fileContentHash(jsonPath);
|
|
96
|
+
if (currentHash == null) return;
|
|
97
|
+
if (_lastMirrorHash != null && currentHash === _lastMirrorHash) return;
|
|
98
|
+
if (_lastMirrorHash == null) {
|
|
99
|
+
const sqlHas = db.prepare('SELECT 1 FROM watches LIMIT 1').get();
|
|
100
|
+
if (sqlHas) {
|
|
101
|
+
_lastMirrorHash = currentHash;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
_hydrateFromJson(db);
|
|
106
|
+
_lastMirrorHash = currentHash;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Read all watch rows from SQL only. No JSON fallback — used by the
|
|
110
|
+
// mirror writer to avoid resurrecting deleted rows: after the mutation
|
|
111
|
+
// deletes a watch from SQL but BEFORE the mirror lands, the JSON still
|
|
112
|
+
// holds the pre-mutation content, so a fallback read would round-trip
|
|
113
|
+
// the deleted row right back into JSON.
|
|
114
|
+
function _readWatchesFromSqlOnly(db) {
|
|
115
|
+
const rows = db.prepare('SELECT data FROM watches ORDER BY rowid').all();
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
const w = _parseRow(row);
|
|
119
|
+
if (w) out.push(w);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readWatches() {
|
|
125
|
+
const { getDb } = require('./db');
|
|
126
|
+
let db;
|
|
127
|
+
try { db = getDb(); }
|
|
128
|
+
catch { return _readJsonArrayFallback(); }
|
|
129
|
+
|
|
130
|
+
_resyncIfJsonDiverged(db);
|
|
131
|
+
|
|
132
|
+
const out = _readWatchesFromSqlOnly(db);
|
|
133
|
+
if (out.length === 0) {
|
|
134
|
+
// SQL is empty for first-time hydrate: trust the JSON if it has
|
|
135
|
+
// content. After hydrate ran, SQL is empty iff the user really has
|
|
136
|
+
// no watches.
|
|
137
|
+
const fallback = _readJsonArrayFallback();
|
|
138
|
+
if (fallback.length > 0) return fallback;
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _indexById(arr) {
|
|
145
|
+
const out = new Map();
|
|
146
|
+
for (const w of arr) {
|
|
147
|
+
if (!w || !w.id) continue;
|
|
148
|
+
out.set(String(w.id), w);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _computeWatchesDiff(before, after) {
|
|
154
|
+
const beforeMap = _indexById(before);
|
|
155
|
+
const afterMap = _indexById(after);
|
|
156
|
+
const toUpsert = [];
|
|
157
|
+
const toDelete = [];
|
|
158
|
+
for (const [id, w] of afterMap) {
|
|
159
|
+
const prev = beforeMap.get(id);
|
|
160
|
+
if (!prev) { toUpsert.push(w); continue; }
|
|
161
|
+
if (JSON.stringify(prev) !== JSON.stringify(w)) toUpsert.push(w);
|
|
162
|
+
}
|
|
163
|
+
for (const [id] of beforeMap) {
|
|
164
|
+
if (!afterMap.has(id)) toDelete.push(id);
|
|
165
|
+
}
|
|
166
|
+
return { toUpsert, toDelete };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _applyWatchesDiff(db, diff) {
|
|
170
|
+
if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const upsertStmt = db.prepare(`
|
|
173
|
+
INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
|
|
174
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
175
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
176
|
+
target = excluded.target,
|
|
177
|
+
target_type = excluded.target_type,
|
|
178
|
+
condition = excluded.condition,
|
|
179
|
+
status = excluded.status,
|
|
180
|
+
owner = excluded.owner,
|
|
181
|
+
created_at = excluded.created_at,
|
|
182
|
+
last_checked = excluded.last_checked,
|
|
183
|
+
last_triggered = excluded.last_triggered,
|
|
184
|
+
data = excluded.data,
|
|
185
|
+
updated_at = excluded.updated_at
|
|
186
|
+
`);
|
|
187
|
+
const deleteStmt = db.prepare('DELETE FROM watches WHERE id = ?');
|
|
188
|
+
|
|
189
|
+
for (const w of diff.toUpsert) {
|
|
190
|
+
upsertStmt.run(
|
|
191
|
+
String(w.id),
|
|
192
|
+
typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
|
|
193
|
+
w.targetType || null,
|
|
194
|
+
w.condition || null,
|
|
195
|
+
String(w.status || 'active'),
|
|
196
|
+
w.owner || null,
|
|
197
|
+
_toMs(w.created_at),
|
|
198
|
+
_toMs(w.last_checked),
|
|
199
|
+
_toMs(w.last_triggered),
|
|
200
|
+
JSON.stringify(w),
|
|
201
|
+
now,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
for (const id of diff.toDelete) {
|
|
205
|
+
deleteStmt.run(id);
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function applyWatchesMutation(mutator) {
|
|
211
|
+
const { getDb, withTransaction } = require('./db');
|
|
212
|
+
let db;
|
|
213
|
+
try { db = getDb(); }
|
|
214
|
+
catch (e) {
|
|
215
|
+
throw new Error(`engine/watches-store: SQLite unavailable (${e.message}); cannot mutate watches`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return withTransaction(db, () => {
|
|
219
|
+
_resyncIfJsonDiverged(db);
|
|
220
|
+
const before = readWatches();
|
|
221
|
+
const beforeSnapshot = JSON.parse(JSON.stringify(before));
|
|
222
|
+
const next = mutator(before);
|
|
223
|
+
const after = (next === undefined || next === null)
|
|
224
|
+
? before
|
|
225
|
+
: (Array.isArray(next) ? next : before);
|
|
226
|
+
const diff = _computeWatchesDiff(beforeSnapshot, after);
|
|
227
|
+
const wrote = _applyWatchesDiff(db, diff);
|
|
228
|
+
return { wrote, result: after };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _mirrorJsonFromSql(filePath) {
|
|
233
|
+
try {
|
|
234
|
+
const shared = require('./shared');
|
|
235
|
+
const { getDb } = require('./db');
|
|
236
|
+
// Read SQL directly — bypass the JSON fallback so a deletion that
|
|
237
|
+
// leaves SQL empty doesn't resurrect from the stale JSON content.
|
|
238
|
+
const items = _readWatchesFromSqlOnly(getDb());
|
|
239
|
+
const target = filePath || _watchesFilePath();
|
|
240
|
+
shared.safeWrite(target, items);
|
|
241
|
+
const h = _fileContentHash(target);
|
|
242
|
+
if (h != null) _lastMirrorHash = h;
|
|
243
|
+
} catch {
|
|
244
|
+
// Mirror failures are non-fatal — SQL has already committed.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _resetWatchesTableForTest() {
|
|
249
|
+
const { getDb } = require('./db');
|
|
250
|
+
try { getDb().exec('DELETE FROM watches'); } catch { /* not initialized */ }
|
|
251
|
+
_lastMirrorHash = null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
readWatches,
|
|
256
|
+
applyWatchesMutation,
|
|
257
|
+
_mirrorJsonFromSql,
|
|
258
|
+
_resetWatchesTableForTest,
|
|
259
|
+
};
|
package/engine/watches.js
CHANGED
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
const fs = require('fs');
|
|
68
68
|
const path = require('path');
|
|
69
69
|
const shared = require('./shared');
|
|
70
|
-
const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
|
|
70
|
+
const { safeJsonArr, mutateJsonFileLocked, mutateWatches, ts, uid, log, writeToInbox,
|
|
71
71
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
|
|
72
72
|
const watchActions = require('./watch-actions');
|
|
73
73
|
|
|
@@ -230,11 +230,10 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
|
|
|
230
230
|
_history: [],
|
|
231
231
|
};
|
|
232
232
|
|
|
233
|
-
|
|
234
|
-
if (!Array.isArray(watches)) watches = [];
|
|
233
|
+
mutateWatches((watches) => {
|
|
235
234
|
watches.push(watch);
|
|
236
235
|
return watches;
|
|
237
|
-
}
|
|
236
|
+
});
|
|
238
237
|
|
|
239
238
|
log('info', `Watch created: ${watch.id} → ${watch.target} (${watch.condition})`);
|
|
240
239
|
return watch;
|
|
@@ -264,8 +263,7 @@ function updateWatch(id, updates) {
|
|
|
264
263
|
if (reqErr) throw new Error(reqErr);
|
|
265
264
|
}
|
|
266
265
|
let found = null;
|
|
267
|
-
|
|
268
|
-
if (!Array.isArray(watches)) return watches;
|
|
266
|
+
mutateWatches((watches) => {
|
|
269
267
|
const watch = watches.find(w => w.id === id);
|
|
270
268
|
if (!watch) return watches;
|
|
271
269
|
// Only allow safe field updates
|
|
@@ -275,7 +273,7 @@ function updateWatch(id, updates) {
|
|
|
275
273
|
}
|
|
276
274
|
found = { ...watch };
|
|
277
275
|
return watches;
|
|
278
|
-
}
|
|
276
|
+
});
|
|
279
277
|
return found;
|
|
280
278
|
}
|
|
281
279
|
|
|
@@ -287,15 +285,14 @@ function updateWatch(id, updates) {
|
|
|
287
285
|
function deleteWatch(id) {
|
|
288
286
|
if (!id) return false;
|
|
289
287
|
let deleted = false;
|
|
290
|
-
|
|
291
|
-
if (!Array.isArray(watches)) return watches;
|
|
288
|
+
mutateWatches((watches) => {
|
|
292
289
|
const idx = watches.findIndex(w => w.id === id);
|
|
293
290
|
if (idx >= 0) {
|
|
294
291
|
watches.splice(idx, 1);
|
|
295
292
|
deleted = true;
|
|
296
293
|
}
|
|
297
294
|
return watches;
|
|
298
|
-
}
|
|
295
|
+
});
|
|
299
296
|
if (deleted) log('info', `Watch deleted: ${id}`);
|
|
300
297
|
return deleted;
|
|
301
298
|
}
|
|
@@ -413,8 +410,8 @@ function checkWatches(config, state) {
|
|
|
413
410
|
const notifications = [];
|
|
414
411
|
const actionsToRun = [];
|
|
415
412
|
|
|
416
|
-
|
|
417
|
-
if (
|
|
413
|
+
mutateWatches((watches) => {
|
|
414
|
+
if (watches.length === 0) return watches;
|
|
418
415
|
|
|
419
416
|
for (const watch of watches) {
|
|
420
417
|
try {
|
|
@@ -539,7 +536,7 @@ function checkWatches(config, state) {
|
|
|
539
536
|
}
|
|
540
537
|
|
|
541
538
|
return watches;
|
|
542
|
-
}
|
|
539
|
+
});
|
|
543
540
|
|
|
544
541
|
// Fire notifications outside the lock — writeToInbox does disk I/O
|
|
545
542
|
for (const n of notifications) {
|
|
@@ -591,8 +588,7 @@ async function _runActionTask(task) {
|
|
|
591
588
|
// can inspect each step's result. Single-action watches keep the legacy
|
|
592
589
|
// `dispatchedItemId` shortcut for back-compat.
|
|
593
590
|
try {
|
|
594
|
-
|
|
595
|
-
if (!Array.isArray(watches)) return watches;
|
|
591
|
+
mutateWatches((watches) => {
|
|
596
592
|
const w = watches.find(x => x.id === task.watchId);
|
|
597
593
|
if (w) {
|
|
598
594
|
if (isChain) {
|
|
@@ -639,7 +635,7 @@ async function _runActionTask(task) {
|
|
|
639
635
|
}
|
|
640
636
|
}
|
|
641
637
|
return watches;
|
|
642
|
-
}
|
|
638
|
+
});
|
|
643
639
|
} catch (err) {
|
|
644
640
|
log('warn', `Watch ${task.watchId} action result persist failed: ${err.message}`);
|
|
645
641
|
}
|
|
@@ -116,28 +116,19 @@ function readWorkItemsForScope(scope) {
|
|
|
116
116
|
// scope is bounded by the JSON's row count.
|
|
117
117
|
function _resyncScopeIfJsonDiverged(db, scope) {
|
|
118
118
|
const jsonPath = _filePathForScope(scope);
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// mtime-only checks miss on Windows (ms-rounded mtimeMs collides).
|
|
125
|
-
// Resync is idempotent (DELETE + bulk re-INSERT from JSON).
|
|
126
|
-
if (lastMirror != null
|
|
127
|
-
&& current.mtime === lastMirror.mtime
|
|
128
|
-
&& current.size === lastMirror.size) return;
|
|
129
|
-
// No mirror record but JSON exists: first-time hydrate iff SQL is empty
|
|
130
|
-
// for this scope (avoid trampling a freshly-backfilled migration state
|
|
131
|
-
// on the very first read).
|
|
132
|
-
if (lastMirror == null) {
|
|
119
|
+
const currentHash = _fileContentHash(jsonPath);
|
|
120
|
+
const lastHash = _lastMirrorHashByScope.get(scope);
|
|
121
|
+
if (currentHash == null) return;
|
|
122
|
+
if (lastHash != null && currentHash === lastHash) return;
|
|
123
|
+
if (lastHash == null) {
|
|
133
124
|
const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
|
|
134
125
|
if (sqlHas) {
|
|
135
|
-
|
|
126
|
+
_lastMirrorHashByScope.set(scope, currentHash);
|
|
136
127
|
return;
|
|
137
128
|
}
|
|
138
129
|
}
|
|
139
130
|
_hydrateScopeFromJson(db, scope);
|
|
140
|
-
|
|
131
|
+
_lastMirrorHashByScope.set(scope, currentHash);
|
|
141
132
|
}
|
|
142
133
|
|
|
143
134
|
// Read all rows across all scopes — used by queries.getWorkItems which
|
|
@@ -243,18 +234,18 @@ function _applyWorkItemsDiff(db, diff) {
|
|
|
243
234
|
// wrote — true iff at least one INSERT/UPDATE/DELETE landed
|
|
244
235
|
// result — the post-mutation array (legacy return shape of
|
|
245
236
|
// mutateJsonFileLocked)
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
// — content swaps almost always change byte length.
|
|
252
|
-
const _lastMirrorByScope = new Map();
|
|
237
|
+
// Content-hash fingerprint of the JSON mirror per scope, so external
|
|
238
|
+
// edits are detected unambiguously. (mtime, size) is too coarse: same-
|
|
239
|
+
// length timestamp swaps (last_checked / last_triggered / completed_at)
|
|
240
|
+
// preserve both axes. SHA-1 is sub-ms on a few-hundred-KB file.
|
|
241
|
+
const _lastMirrorHashByScope = new Map();
|
|
253
242
|
|
|
254
|
-
|
|
243
|
+
const crypto = require('crypto');
|
|
244
|
+
|
|
245
|
+
function _fileContentHash(filePath) {
|
|
255
246
|
try {
|
|
256
|
-
const
|
|
257
|
-
return
|
|
247
|
+
const buf = fs.readFileSync(filePath);
|
|
248
|
+
return crypto.createHash('sha1').update(buf).digest('hex');
|
|
258
249
|
}
|
|
259
250
|
catch { return null; }
|
|
260
251
|
}
|
|
@@ -325,17 +316,24 @@ function applyWorkItemsMutation(scope, mutator) {
|
|
|
325
316
|
function _mirrorJsonFromSql(scope, filePath) {
|
|
326
317
|
try {
|
|
327
318
|
const shared = require('./shared');
|
|
328
|
-
const
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
319
|
+
const { getDb } = require('./db');
|
|
320
|
+
// Read SQL directly for this scope — bypass the JSON fallback so a
|
|
321
|
+
// mutation that empties SQL for the scope doesn't resurrect stale
|
|
322
|
+
// JSON content. (See pull-requests-store and watches-store for the
|
|
323
|
+
// same defense.)
|
|
324
|
+
const db = getDb();
|
|
325
|
+
const rows = db.prepare('SELECT data FROM work_items WHERE scope = ? ORDER BY rowid').all(scope);
|
|
326
|
+
const items = [];
|
|
327
|
+
for (const row of rows) {
|
|
328
|
+
const wi = _parseRow(row);
|
|
329
|
+
if (!wi) continue;
|
|
330
|
+
if (wi._source) delete wi._source;
|
|
331
|
+
items.push(wi);
|
|
332
|
+
}
|
|
332
333
|
const target = filePath || _filePathForScope(scope);
|
|
333
334
|
shared.safeWrite(target, items);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// catches them.
|
|
337
|
-
const fp = _statFingerprint(target);
|
|
338
|
-
if (fp != null) _lastMirrorByScope.set(scope, fp);
|
|
335
|
+
const h = _fileContentHash(target);
|
|
336
|
+
if (h != null) _lastMirrorHashByScope.set(scope, h);
|
|
339
337
|
} catch {
|
|
340
338
|
// Mirror failures are non-fatal: SQL has already committed.
|
|
341
339
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2073",
|
|
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"
|