@yemi33/minions 0.1.2064 → 0.1.2066
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/bin/minions.js +74 -0
- package/dashboard.js +43 -6
- package/engine/db/index.js +94 -0
- package/engine/db/migrate.js +65 -0
- package/engine/db/migrations/001-init.js +37 -0
- package/engine/db-events.js +40 -0
- package/engine/dispatch.js +6 -1
- package/engine/shared.js +26 -3
- package/package.json +2 -2
package/bin/minions.js
CHANGED
|
@@ -75,6 +75,58 @@ function killByPort(port) {
|
|
|
75
75
|
|
|
76
76
|
const isPortListening = (port) => getListeningPids(port).length > 0;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Wait until no process is listening on `port`, retrying a kill on each tick
|
|
80
|
+
* for any stragglers that re-appeared (e.g. orphan child the original kill
|
|
81
|
+
* missed, or a process that respawned itself). Returns true when the port is
|
|
82
|
+
* free, false on timeout.
|
|
83
|
+
*
|
|
84
|
+
* Rationale: `taskkill /F` and `SIGKILL` return immediately while the OS does
|
|
85
|
+
* the actual termination asynchronously, and the port doesn't transition to
|
|
86
|
+
* available until the kernel finalises the socket close. Without this wait,
|
|
87
|
+
* the new dashboard race-spawned just after `killByPort` can hit EADDRINUSE
|
|
88
|
+
* and exit silently — the bug pattern that surfaced as "Dashboard failed
|
|
89
|
+
* health check, port=7331 listening=no" while the OLD dashboard PID was
|
|
90
|
+
* still bound to the port.
|
|
91
|
+
*/
|
|
92
|
+
function waitForPortRelease(port, timeoutMs = 10000, pollMs = 200) {
|
|
93
|
+
const start = Date.now();
|
|
94
|
+
while (Date.now() - start < timeoutMs) {
|
|
95
|
+
const pids = getListeningPids(port);
|
|
96
|
+
if (pids.length === 0) return { ok: true, waitedMs: Date.now() - start };
|
|
97
|
+
// Retry the kill — covers the case where the original killByPort missed a
|
|
98
|
+
// sibling listener or a new orphan appeared mid-wait.
|
|
99
|
+
killByPort(port);
|
|
100
|
+
const sleepUntil = Date.now() + pollMs;
|
|
101
|
+
while (Date.now() < sleepUntil) { /* spin */ }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, waitedMs: timeoutMs, stillBound: getListeningPids(port) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wait until `pid` no longer exists. Returns true when the PID is dead, false
|
|
108
|
+
* on timeout. Used to confirm the old engine actually exited before the new
|
|
109
|
+
* one starts writing to control.json — otherwise control.json can flap
|
|
110
|
+
* between "old engine alive (state=stopping)" and "new engine alive
|
|
111
|
+
* (state=running)" and the health check reads the wrong snapshot.
|
|
112
|
+
*/
|
|
113
|
+
function waitForPidDeath(pid, timeoutMs = 5000, pollMs = 100) {
|
|
114
|
+
if (!pid) return { ok: true, waitedMs: 0 };
|
|
115
|
+
const start = Date.now();
|
|
116
|
+
while (Date.now() - start < timeoutMs) {
|
|
117
|
+
try {
|
|
118
|
+
// process.kill(pid, 0) throws if the PID doesn't exist; succeeds (no-op)
|
|
119
|
+
// if it does. Works on both Windows and POSIX.
|
|
120
|
+
process.kill(pid, 0);
|
|
121
|
+
} catch {
|
|
122
|
+
return { ok: true, waitedMs: Date.now() - start };
|
|
123
|
+
}
|
|
124
|
+
const sleepUntil = Date.now() + pollMs;
|
|
125
|
+
while (Date.now() < sleepUntil) { /* spin */ }
|
|
126
|
+
}
|
|
127
|
+
return { ok: false, waitedMs: timeoutMs };
|
|
128
|
+
}
|
|
129
|
+
|
|
78
130
|
// Pre-restart beacons can outlive the browser window that produced them
|
|
79
131
|
// (closed Edge, locked-screen RDP session) and falsely tell restart to skip
|
|
80
132
|
// the auto-open. We wipe the file during restart so the post-restart probe
|
|
@@ -780,6 +832,28 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
780
832
|
killPidOnly(oldEnginePid);
|
|
781
833
|
killByPort(DASH_PORT);
|
|
782
834
|
killMinionsProcesses(['engine.js', 'dashboard.js']);
|
|
835
|
+
// Confirm the OS finished the asynchronous termination before we spawn new
|
|
836
|
+
// processes. Without this, `taskkill /F` returns immediately while the
|
|
837
|
+
// kernel is still releasing the dashboard's port; the new dashboard spawned
|
|
838
|
+
// ~10ms later hits EADDRINUSE and exits, producing the "Restart verification
|
|
839
|
+
// failed, port=7331 listening=no" symptom against an orphan PID that's
|
|
840
|
+
// STILL bound to the port a heartbeat later.
|
|
841
|
+
if (oldEnginePid) {
|
|
842
|
+
const engineDead = waitForPidDeath(oldEnginePid, 5000);
|
|
843
|
+
if (!engineDead.ok) {
|
|
844
|
+
console.error(`\n ERROR: Old engine (PID ${oldEnginePid}) did not exit within 5s after kill.`);
|
|
845
|
+
console.error(` The new engine cannot safely take over control.json. Aborting restart.`);
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const portFree = waitForPortRelease(DASH_PORT, 10000);
|
|
850
|
+
if (!portFree.ok) {
|
|
851
|
+
console.error(`\n ERROR: Port ${DASH_PORT} still in use after 10s — killing failed.`);
|
|
852
|
+
console.error(` Bound by PID(s): ${portFree.stillBound.join(', ')}`);
|
|
853
|
+
console.error(` Manually free the port: taskkill /F /PID ${portFree.stillBound.join(' /PID ')}`);
|
|
854
|
+
console.error(` Then retry: minions restart`);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
783
857
|
// Clear stale beacons AFTER the kill so the old dashboard's last writes
|
|
784
858
|
// can't repopulate the file in the gap between clear and shutdown.
|
|
785
859
|
_clearDashboardBrowserState(MINIONS_HOME);
|
package/dashboard.js
CHANGED
|
@@ -1637,6 +1637,23 @@ function _mtimeTrackedFiles() {
|
|
|
1637
1637
|
}
|
|
1638
1638
|
let _lastMtimes = {}; // { filePath: mtimeMs } — baseline since last build
|
|
1639
1639
|
|
|
1640
|
+
// MAX(events.id) at last rebuild. Phase 0 cache-version source running
|
|
1641
|
+
// alongside the mtime tracker — either signal busts the cache. Once the
|
|
1642
|
+
// engine state files migrate into SQL tables (Phase 1+) the mtime tracker
|
|
1643
|
+
// goes away and this becomes the sole invalidator. Reads are O(index seek).
|
|
1644
|
+
let _lastEventVersion = -1;
|
|
1645
|
+
function _getCurrentEventVersion() {
|
|
1646
|
+
try {
|
|
1647
|
+
const { getDb } = require('./engine/db');
|
|
1648
|
+
const row = getDb().prepare('SELECT COALESCE(MAX(id), 0) AS v FROM events').get();
|
|
1649
|
+
return Number(row.v) || 0;
|
|
1650
|
+
} catch {
|
|
1651
|
+
// DB unavailable (Node < 22.5, missing file, etc.) — return a sentinel that
|
|
1652
|
+
// disables event-based cache busting. The mtime tracker still works.
|
|
1653
|
+
return -1;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1640
1657
|
// Stat a tracked path with transient-error tolerance. ENOENT (file/dir doesn't
|
|
1641
1658
|
// exist) is normal — fresh installs, deleted projects, empty PRD dirs all hit
|
|
1642
1659
|
// this — and maps to 0 so the entry just doesn't bust the cache. EBUSY /
|
|
@@ -1693,6 +1710,11 @@ function invalidateStatusCache(_opts) {
|
|
|
1693
1710
|
_statusCacheJson = null;
|
|
1694
1711
|
_statusCacheGzip = null;
|
|
1695
1712
|
_lastMtimes = {};
|
|
1713
|
+
_lastEventVersion = -1;
|
|
1714
|
+
// Emit a 'cache-invalidate' event row so any peer dashboard process
|
|
1715
|
+
// sharing the SQLite file also sees the bust on its next poll.
|
|
1716
|
+
// Best-effort — db-events swallows all errors.
|
|
1717
|
+
try { require('./engine/db-events').emitStateEvent('cache-invalidate'); } catch { /* optional */ }
|
|
1696
1718
|
// Tell any in-flight refreshStatusAsync() that its result is stale and must
|
|
1697
1719
|
// not be published. Bumping the generation also forces the next ETag to
|
|
1698
1720
|
// differ from anything a client already has cached.
|
|
@@ -2098,12 +2120,23 @@ function _markStatusCacheBuilt() {
|
|
|
2098
2120
|
}
|
|
2099
2121
|
|
|
2100
2122
|
function getStatus() {
|
|
2101
|
-
// Steady-state fast path: cache present +
|
|
2102
|
-
// cached snapshot.
|
|
2103
|
-
//
|
|
2123
|
+
// Steady-state fast path: cache present + neither signal moved → return
|
|
2124
|
+
// cached snapshot. Two cache-version sources running side by side during
|
|
2125
|
+
// Phase 0 migration:
|
|
2126
|
+
// 1. _lastMtimes — legacy file mtime tracker. Single check covers
|
|
2127
|
+
// both former fast + slow registries (see _mtimeTrackedFiles).
|
|
2128
|
+
// 2. _lastEventVersion — MAX(events.id) from engine/state.db.
|
|
2129
|
+
// Bumped by emitStateEvent() in every mutator wrapper
|
|
2130
|
+
// and by invalidateStatusCache() itself.
|
|
2131
|
+
// Either signal advancing busts the cache. Once engine state files
|
|
2132
|
+
// migrate into SQL tables (Phase 1+) the mtime tracker goes away and
|
|
2133
|
+
// event-version becomes the sole invalidator.
|
|
2134
|
+
const preBuildEventVersion = _getCurrentEventVersion();
|
|
2104
2135
|
if (_statusCache) {
|
|
2105
2136
|
const currMtimes = _getMtimes();
|
|
2106
|
-
if (!_mtimesChanged(_lastMtimes, currMtimes))
|
|
2137
|
+
if (preBuildEventVersion === _lastEventVersion && !_mtimesChanged(_lastMtimes, currMtimes)) {
|
|
2138
|
+
return _statusCache;
|
|
2139
|
+
}
|
|
2107
2140
|
}
|
|
2108
2141
|
// Stale or first-call: rebuild everything. Reload config first so newly-
|
|
2109
2142
|
// added projects / agents land before the slice builders read them.
|
|
@@ -2118,6 +2151,7 @@ function getStatus() {
|
|
|
2118
2151
|
const slow = _buildStatusSlowState();
|
|
2119
2152
|
_statusCache = { ...fast, ...slow, timestamp: new Date().toISOString() };
|
|
2120
2153
|
_lastMtimes = preBuildMtimes;
|
|
2154
|
+
_lastEventVersion = preBuildEventVersion;
|
|
2121
2155
|
_markStatusCacheBuilt();
|
|
2122
2156
|
return _statusCache;
|
|
2123
2157
|
}
|
|
@@ -2185,10 +2219,11 @@ function refreshStatusAsync() {
|
|
|
2185
2219
|
try {
|
|
2186
2220
|
const startGeneration = _statusInvalidationGeneration;
|
|
2187
2221
|
|
|
2188
|
-
// Steady-state fast path — same
|
|
2222
|
+
// Steady-state fast path — same dual-signal check as the sync getStatus.
|
|
2223
|
+
const preBuildEventVersion = _getCurrentEventVersion();
|
|
2189
2224
|
if (_statusCache) {
|
|
2190
2225
|
const currMtimes = _getMtimes();
|
|
2191
|
-
if (!_mtimesChanged(_lastMtimes, currMtimes)) {
|
|
2226
|
+
if (preBuildEventVersion === _lastEventVersion && !_mtimesChanged(_lastMtimes, currMtimes)) {
|
|
2192
2227
|
if (profile) {
|
|
2193
2228
|
_emitStatusTiming({
|
|
2194
2229
|
phase: 'cache-hit',
|
|
@@ -2241,6 +2276,7 @@ function refreshStatusAsync() {
|
|
|
2241
2276
|
|
|
2242
2277
|
_statusCache = { ...fast, ...slow, timestamp: new Date().toISOString() };
|
|
2243
2278
|
_lastMtimes = preBuildMtimes;
|
|
2279
|
+
_lastEventVersion = preBuildEventVersion;
|
|
2244
2280
|
_markStatusCacheBuilt();
|
|
2245
2281
|
if (profile) {
|
|
2246
2282
|
_emitStatusTiming({
|
|
@@ -2277,6 +2313,7 @@ function _resetStatusCacheForTesting() {
|
|
|
2277
2313
|
_statusInvalidationGeneration = 0;
|
|
2278
2314
|
_statusRefreshHook = null;
|
|
2279
2315
|
_lastMtimes = {};
|
|
2316
|
+
_lastEventVersion = -1;
|
|
2280
2317
|
}
|
|
2281
2318
|
|
|
2282
2319
|
/** Return cached JSON string of status — single stringify, reused by SSE and /api/status */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// engine/db/index.js — Local SQLite state store, zero deps.
|
|
2
|
+
//
|
|
3
|
+
// Backed by Node's built-in `node:sqlite` module (stable in Node 22.5+,
|
|
4
|
+
// matures further in 24.x). The whole project's "zero deps beyond Node
|
|
5
|
+
// built-ins" contract stays intact.
|
|
6
|
+
//
|
|
7
|
+
// This file is intentionally tiny: open the DB, set sane pragmas, run
|
|
8
|
+
// migrations, hand back a singleton. Everything else lives in callers.
|
|
9
|
+
// Singleton (not per-require) so the WAL writer + dashboard reader share
|
|
10
|
+
// one connection per process — better-sqlite3-style.
|
|
11
|
+
//
|
|
12
|
+
// Failure mode: if SQLite is unavailable (e.g. user on Node < 22.5 despite
|
|
13
|
+
// our engines field bumping the minimum), `getDb()` throws with a clear
|
|
14
|
+
// install-version error. Callers that want best-effort behaviour
|
|
15
|
+
// (emitStateEvent, the dashboard's cache-version bump) should try/catch.
|
|
16
|
+
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
|
|
20
|
+
let _db = null;
|
|
21
|
+
let _dbInitError = null;
|
|
22
|
+
|
|
23
|
+
function _resolveDbPath() {
|
|
24
|
+
// Lazy-require shared/queries so this module can be safely required
|
|
25
|
+
// before MINIONS_DIR is computed (e.g. in tests). Falls back to
|
|
26
|
+
// process.env.MINIONS_HOME when available.
|
|
27
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
28
|
+
let minionsDir = envHome;
|
|
29
|
+
if (!minionsDir) {
|
|
30
|
+
try { minionsDir = require('../shared').MINIONS_DIR; } catch { /* shared not loaded */ }
|
|
31
|
+
}
|
|
32
|
+
if (!minionsDir) throw new Error('engine/db: MINIONS_DIR not resolvable');
|
|
33
|
+
const engineDir = path.join(minionsDir, 'engine');
|
|
34
|
+
try { fs.mkdirSync(engineDir, { recursive: true }); } catch { /* exists */ }
|
|
35
|
+
return path.join(engineDir, 'state.db');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Suppress Node's ExperimentalWarning for `node:sqlite`. It fires once per
|
|
39
|
+
// process the first time the module is required; without filtering it
|
|
40
|
+
// leaks into every engine/dashboard log and CI run. Re-fires only for
|
|
41
|
+
// non-sqlite experimental warnings.
|
|
42
|
+
let _warningFilterInstalled = false;
|
|
43
|
+
function _installExperimentalWarningFilter() {
|
|
44
|
+
if (_warningFilterInstalled) return;
|
|
45
|
+
_warningFilterInstalled = true;
|
|
46
|
+
const origEmit = process.emit;
|
|
47
|
+
process.emit = function (name, data, ...rest) {
|
|
48
|
+
if (name === 'warning' && data && data.name === 'ExperimentalWarning' && /SQLite/.test(String(data.message))) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return origEmit.call(process, name, data, ...rest);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getDb() {
|
|
56
|
+
if (_db) return _db;
|
|
57
|
+
if (_dbInitError) throw _dbInitError;
|
|
58
|
+
try {
|
|
59
|
+
_installExperimentalWarningFilter();
|
|
60
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
61
|
+
const dbPath = _resolveDbPath();
|
|
62
|
+
_db = new DatabaseSync(dbPath);
|
|
63
|
+
// WAL mode lets the engine writer and dashboard reader hit the same
|
|
64
|
+
// file from two processes without lock contention. NORMAL synchronous
|
|
65
|
+
// is the recommended WAL pairing (durable enough; one OS-level fsync
|
|
66
|
+
// per checkpoint instead of per write).
|
|
67
|
+
_db.exec('PRAGMA journal_mode = WAL');
|
|
68
|
+
_db.exec('PRAGMA foreign_keys = ON');
|
|
69
|
+
_db.exec('PRAGMA synchronous = NORMAL');
|
|
70
|
+
_db.exec('PRAGMA busy_timeout = 5000');
|
|
71
|
+
const { runMigrations } = require('./migrate');
|
|
72
|
+
runMigrations(_db);
|
|
73
|
+
return _db;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// Cache the failure so we don't repeatedly retry a broken setup. Most
|
|
76
|
+
// callers wrap getDb() in try/catch and treat the cache-version probe
|
|
77
|
+
// as best-effort, so a missing-Node-module install still boots the
|
|
78
|
+
// dashboard fine — it just won't benefit from cross-process cache
|
|
79
|
+
// invalidation until the install is fixed.
|
|
80
|
+
_dbInitError = new Error(
|
|
81
|
+
`engine/db: failed to open SQLite — ${e.message}. ` +
|
|
82
|
+
`Node 22.5+ required for built-in 'node:sqlite' support.`
|
|
83
|
+
);
|
|
84
|
+
throw _dbInitError;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Force-close — used by tests + graceful shutdown. Safe to call when not open.
|
|
89
|
+
function closeDb() {
|
|
90
|
+
if (_db) { try { _db.close(); } catch { /* already closed */ } _db = null; }
|
|
91
|
+
_dbInitError = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { getDb, closeDb };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// engine/db/migrate.js — minimal versioned migration runner.
|
|
2
|
+
//
|
|
3
|
+
// Convention:
|
|
4
|
+
// migrations/<NNN>-<slug>.js exports { version, description, up(db, ctx) }
|
|
5
|
+
// - version is an integer matching the file's leading NNN
|
|
6
|
+
// - description is shown in the [db-migrate] log line
|
|
7
|
+
// - up(db, ctx) runs any DDL + data backfill; throwing rolls back the
|
|
8
|
+
// enclosing transaction (the rest of `up` never runs, schema_version
|
|
9
|
+
// is NOT bumped, the next startup retries).
|
|
10
|
+
//
|
|
11
|
+
// All migrations are wrapped in a single SQLite transaction. Either every
|
|
12
|
+
// statement in `up` lands AND the schema_version row is written, or
|
|
13
|
+
// nothing does. No partial-migration state can stick on disk.
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
|
|
18
|
+
function _loadMigrations() {
|
|
19
|
+
const dir = path.join(__dirname, 'migrations');
|
|
20
|
+
if (!fs.existsSync(dir)) return [];
|
|
21
|
+
return fs.readdirSync(dir)
|
|
22
|
+
.filter(f => /^\d{3,}-[\w-]+\.js$/.test(f))
|
|
23
|
+
.sort() // lexical sort works because of the fixed-width NNN prefix
|
|
24
|
+
.map(f => require(path.join(dir, f)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function runMigrations(db) {
|
|
28
|
+
// schema_version is itself created via raw exec (chicken-and-egg with
|
|
29
|
+
// the migrations themselves).
|
|
30
|
+
db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
32
|
+
version INTEGER PRIMARY KEY,
|
|
33
|
+
description TEXT,
|
|
34
|
+
applied_at INTEGER NOT NULL
|
|
35
|
+
)
|
|
36
|
+
`);
|
|
37
|
+
|
|
38
|
+
const currentRow = db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version').get();
|
|
39
|
+
const current = Number(currentRow.v) || 0;
|
|
40
|
+
const migrations = _loadMigrations();
|
|
41
|
+
|
|
42
|
+
for (const m of migrations) {
|
|
43
|
+
if (typeof m.version !== 'number' || typeof m.up !== 'function') {
|
|
44
|
+
throw new Error(`engine/db/migrate: migration missing { version, up }: ${JSON.stringify(Object.keys(m))}`);
|
|
45
|
+
}
|
|
46
|
+
if (m.version <= current) continue;
|
|
47
|
+
db.exec('BEGIN');
|
|
48
|
+
try {
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.log(`[db-migrate] Applying v${m.version}: ${m.description || '(no description)'}`);
|
|
51
|
+
m.up(db, { fs, path });
|
|
52
|
+
db.prepare(
|
|
53
|
+
'INSERT INTO schema_version (version, description, applied_at) VALUES (?, ?, ?)'
|
|
54
|
+
).run(m.version, m.description || '', Date.now());
|
|
55
|
+
db.exec('COMMIT');
|
|
56
|
+
} catch (e) {
|
|
57
|
+
db.exec('ROLLBACK');
|
|
58
|
+
// eslint-disable-next-line no-console
|
|
59
|
+
console.error(`[db-migrate] v${m.version} FAILED, rolled back: ${e.message}`);
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { runMigrations };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// engine/db/migrations/001-init.js — first migration.
|
|
2
|
+
//
|
|
3
|
+
// Creates the `events` table: a single append-only event stream that
|
|
4
|
+
// engine writers emit into and the dashboard's status cache reads
|
|
5
|
+
// `MAX(id)` from for cross-process cache invalidation. Replaces the
|
|
6
|
+
// mtime-tracker registry over the next few releases.
|
|
7
|
+
//
|
|
8
|
+
// Topic conventions (free-form text, but document the canonical list
|
|
9
|
+
// here so future contributors don't fragment):
|
|
10
|
+
// - 'cache-invalidate' — explicit dashboard.invalidateStatusCache() call
|
|
11
|
+
// - 'dispatch' — engine/dispatch.json mutation (mutateDispatch)
|
|
12
|
+
// - 'work_items' — engine/work-items.json or per-project file mutation
|
|
13
|
+
// - 'pull_requests' — engine/pull-requests.json or per-project file mutation
|
|
14
|
+
// - 'prd' — prd/*.json mutation
|
|
15
|
+
// - 'watches' — watches.json mutation
|
|
16
|
+
// - 'config' — config.json mutation
|
|
17
|
+
//
|
|
18
|
+
// Payload is optional JSON sidecar — currently unused by the dashboard
|
|
19
|
+
// cache check (single MAX(id) is sufficient) but useful for future
|
|
20
|
+
// debug tooling and topic-scoped subscribers.
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
version: 1,
|
|
24
|
+
description: 'init: events table + topic/ts indices',
|
|
25
|
+
up(db) {
|
|
26
|
+
db.exec(`
|
|
27
|
+
CREATE TABLE events (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
ts INTEGER NOT NULL DEFAULT (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)),
|
|
30
|
+
topic TEXT NOT NULL,
|
|
31
|
+
payload TEXT
|
|
32
|
+
);
|
|
33
|
+
CREATE INDEX idx_events_topic_id ON events(topic, id DESC);
|
|
34
|
+
CREATE INDEX idx_events_ts ON events(ts DESC);
|
|
35
|
+
`);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// engine/db-events.js — `emitStateEvent(topic, payload?)` helper.
|
|
2
|
+
//
|
|
3
|
+
// Append a row to the `events` table so the dashboard's status cache (and
|
|
4
|
+
// any future topic-scoped subscriber) sees the change on its next poll.
|
|
5
|
+
// Designed for the engine-side mutator wrappers (mutateDispatch,
|
|
6
|
+
// mutateWorkItems, mutatePullRequests, …) to call after a successful
|
|
7
|
+
// write — the dashboard's getStatus polls MAX(events.id) as its single
|
|
8
|
+
// cache-version source of truth.
|
|
9
|
+
//
|
|
10
|
+
// Hard contract: this MUST be best-effort. Engine writers throwing from
|
|
11
|
+
// a cache-invalidation side effect would be a serious regression. Any
|
|
12
|
+
// SQLite failure (Node too old, DB locked, disk full) is swallowed.
|
|
13
|
+
// The mtime tracker remains as a fallback while we transition.
|
|
14
|
+
|
|
15
|
+
let _logged = false;
|
|
16
|
+
|
|
17
|
+
function emitStateEvent(topic, payload) {
|
|
18
|
+
try {
|
|
19
|
+
const { getDb } = require('./db');
|
|
20
|
+
const db = getDb();
|
|
21
|
+
if (payload !== undefined && payload !== null) {
|
|
22
|
+
db.prepare('INSERT INTO events (topic, payload) VALUES (?, ?)')
|
|
23
|
+
.run(String(topic), JSON.stringify(payload));
|
|
24
|
+
} else {
|
|
25
|
+
db.prepare('INSERT INTO events (topic) VALUES (?)').run(String(topic));
|
|
26
|
+
}
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// Log once per process so the operator notices a broken install
|
|
29
|
+
// without spamming on every write.
|
|
30
|
+
if (!_logged) {
|
|
31
|
+
_logged = true;
|
|
32
|
+
try {
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.warn(`[db-events] state event emission disabled: ${e.message}`);
|
|
35
|
+
} catch { /* console unavailable in some test seams */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { emitStateEvent };
|
package/engine/dispatch.js
CHANGED
|
@@ -70,7 +70,12 @@ function mutateDispatch(mutator) {
|
|
|
70
70
|
// so a 100-item status-flip doesn't re-byte-count every prompt.
|
|
71
71
|
_sidecarChangedPrompts(next, prevSnap);
|
|
72
72
|
return next;
|
|
73
|
-
}, {
|
|
73
|
+
}, {
|
|
74
|
+
defaultValue: defaultDispatch,
|
|
75
|
+
onWrote: () => {
|
|
76
|
+
try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
|
|
77
|
+
},
|
|
78
|
+
});
|
|
74
79
|
// Invalidate the read cache so next getDispatch() sees fresh data
|
|
75
80
|
try { require('./queries').invalidateDispatchCache(); } catch {}
|
|
76
81
|
return result;
|
package/engine/shared.js
CHANGED
|
@@ -1088,7 +1088,8 @@ function mutateJsonFileLocked(filePath, mutateFn, {
|
|
|
1088
1088
|
defaultValue = {},
|
|
1089
1089
|
lockRetries,
|
|
1090
1090
|
lockRetryBackoffMs,
|
|
1091
|
-
skipWriteIfUnchanged = false
|
|
1091
|
+
skipWriteIfUnchanged = false,
|
|
1092
|
+
onWrote = null,
|
|
1092
1093
|
} = {}) {
|
|
1093
1094
|
const lockPath = `${filePath}.lock`;
|
|
1094
1095
|
const retries = lockRetries ?? ENGINE_DEFAULTS.lockRetries;
|
|
@@ -1110,6 +1111,13 @@ function mutateJsonFileLocked(filePath, mutateFn, {
|
|
|
1110
1111
|
const backupPath = filePath + '.backup';
|
|
1111
1112
|
try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
|
|
1112
1113
|
safeWrite(filePath, finalData);
|
|
1114
|
+
// Side-effect hook fired only when an actual write happened. Callers
|
|
1115
|
+
// use this to emit cache-invalidation signals (events table row) so
|
|
1116
|
+
// skip-write-unchanged paths (dedup, idempotent no-op POSTs) don't
|
|
1117
|
+
// produce spurious cache busts.
|
|
1118
|
+
if (typeof onWrote === 'function') {
|
|
1119
|
+
try { onWrote(finalData); } catch { /* hook errors must not break the write */ }
|
|
1120
|
+
}
|
|
1113
1121
|
}
|
|
1114
1122
|
return finalData;
|
|
1115
1123
|
}, { retries, retryBackoffMs });
|
|
@@ -4567,7 +4575,17 @@ function mutateWorkItems(filePath, mutator) {
|
|
|
4567
4575
|
const result = mutateJsonFileLocked(filePath, (data) => {
|
|
4568
4576
|
if (!Array.isArray(data)) data = [];
|
|
4569
4577
|
return mutator(data) || data;
|
|
4570
|
-
}, {
|
|
4578
|
+
}, {
|
|
4579
|
+
defaultValue: [],
|
|
4580
|
+
skipWriteIfUnchanged: true,
|
|
4581
|
+
// Emit only when an actual write happened. The dedup path through
|
|
4582
|
+
// createWorkItemWithDedup returns the array unchanged → skipWriteIfUnchanged
|
|
4583
|
+
// suppresses the write, and we want to suppress the event too (otherwise
|
|
4584
|
+
// duplicate POSTs bump the cache version for no observable change).
|
|
4585
|
+
onWrote: () => {
|
|
4586
|
+
try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
|
|
4587
|
+
},
|
|
4588
|
+
});
|
|
4571
4589
|
// Invalidate the read cache so the next getWorkItems() sees fresh data
|
|
4572
4590
|
// (W-mpodww9h000b460a). Mirrors dispatch.js's invalidateDispatchCache
|
|
4573
4591
|
// call after mutateDispatch. Lazy-required to avoid the queries→shared
|
|
@@ -4599,7 +4617,12 @@ function mutatePullRequests(filePath, mutator) {
|
|
|
4599
4617
|
return mutateJsonFileLocked(filePath, (data) => {
|
|
4600
4618
|
if (!Array.isArray(data)) data = [];
|
|
4601
4619
|
return mutator(data) || data;
|
|
4602
|
-
}, {
|
|
4620
|
+
}, {
|
|
4621
|
+
defaultValue: [],
|
|
4622
|
+
onWrote: () => {
|
|
4623
|
+
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
4624
|
+
},
|
|
4625
|
+
});
|
|
4603
4626
|
}
|
|
4604
4627
|
|
|
4605
4628
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2066",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"homepage": "https://yemi33.github.io/minions/",
|
|
62
62
|
"engines": {
|
|
63
|
-
"node": ">=
|
|
63
|
+
"node": ">=22.5"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@azure-devops/mcp": "2.7.0"
|