@yemi33/minions 0.1.2065 → 0.1.2067
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/README.md +3 -3
- package/dashboard.js +43 -6
- package/docs/runtime-adapters.md +20 -18
- package/engine/db/index.js +141 -0
- package/engine/db/migrate.js +65 -0
- package/engine/db/migrations/001-init.js +37 -0
- package/engine/db/migrations/002-dispatches.js +113 -0
- package/engine/db/migrations/003-work-items.js +128 -0
- package/engine/db-events.js +40 -0
- package/engine/dispatch-store.js +316 -0
- package/engine/dispatch.js +56 -4
- package/engine/llm.js +4 -3
- package/engine/queries.js +47 -19
- package/engine/restart-health.js +8 -2
- package/engine/runtimes/codex.js +862 -0
- package/engine/runtimes/index.js +1 -0
- package/engine/shared.js +59 -7
- package/engine/work-items-store.js +351 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -11,11 +11,11 @@ Inspired by and initially scaffolded from [Brady Gaster's Squad](https://bradyga
|
|
|
11
11
|
## Prerequisites
|
|
12
12
|
|
|
13
13
|
- **Node.js** 18+ (LTS recommended)
|
|
14
|
-
- **A supported runtime CLI** — Minions defaults to GitHub Copilot CLI (`npm install -g @github/copilot`). Claude Code CLI (`npm install -g @anthropic-ai/claude-code`)
|
|
15
|
-
- **Auth for your runtime** — GitHub Copilot subscription (Copilot CLI handles its own auth) or an Anthropic API key / Claude Max subscription
|
|
14
|
+
- **A supported runtime CLI** — Minions defaults to GitHub Copilot CLI (`npm install -g @github/copilot`). Claude Code CLI (`npm install -g @anthropic-ai/claude-code`) and OpenAI Codex CLI (`npm install -g @openai/codex` or `brew install --cask codex`) are also supported; switch with `minions config set-cli claude`, `minions config set-cli codex`, or per-agent `cli` overrides.
|
|
15
|
+
- **Auth for your runtime** — GitHub Copilot subscription (Copilot CLI handles its own auth), OpenAI Codex login / API key, or an Anthropic API key / Claude Max subscription
|
|
16
16
|
- **Git** — agents create worktrees for all code changes
|
|
17
17
|
|
|
18
|
-
> **Note:** you do **not** need to configure your CLI for "autopilot" / "bypass permissions" / "dangerous mode". Minions passes the right
|
|
18
|
+
> **Note:** you do **not** need to configure your CLI for "autopilot" / "bypass permissions" / "dangerous mode". Minions passes the right runtime-owned flag per spawn (`--dangerously-skip-permissions` for Claude; `--autopilot --allow-all --no-ask-user` for Copilot; `codex exec --sandbox workspace-write --json -` for Codex), independent of your global CLI config. Run `minions doctor` to verify your installed CLI accepts those flags.
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
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 */
|
package/docs/runtime-adapters.md
CHANGED
|
@@ -11,6 +11,7 @@ behavior is hidden behind an adapter object resolved through `resolveRuntime()`.
|
|
|
11
11
|
| `engine/runtimes/index.js` | Adapter registry. `resolveRuntime(name)`, `listRuntimes()`, `registerRuntime()`. Engine code MUST go through these. |
|
|
12
12
|
| `engine/runtimes/claude.js` | Claude Code adapter. Owns binary probe, `--system-prompt-file`, JSONL parser, model shorthands, budget cap, bare mode. |
|
|
13
13
|
| `engine/runtimes/copilot.js` | GitHub Copilot CLI adapter. Owns standalone-vs-`gh-copilot` resolution, stdin-only prompt delivery, `https://api.githubcopilot.com/models` discovery, effort `'max' → 'xhigh'` mapping. |
|
|
14
|
+
| `engine/runtimes/codex.js` | OpenAI Codex CLI adapter. Owns `@openai/codex`/native binary resolution, `codex exec --json -` prompt delivery, `codex debug models --bundled` discovery, `.agents/skills` roots, and Codex JSONL parsing. |
|
|
14
15
|
|
|
15
16
|
`resolveRuntime(name)` throws when `name` is unknown so misconfigurations surface
|
|
16
17
|
at dispatch time instead of producing silent fallbacks deep inside spawn logic.
|
|
@@ -62,24 +63,24 @@ this table is the human-readable mirror.
|
|
|
62
63
|
Engine code branches on flags, never on runtime names. Add a flag whenever a
|
|
63
64
|
real behavioral split appears between adapters.
|
|
64
65
|
|
|
65
|
-
| Flag | Claude | Copilot | Gates |
|
|
66
|
-
|
|
67
|
-
| `streaming` | ✓ | ✓ | JSONL events on stdout. |
|
|
68
|
-
| `sessionResume` | ✓ | ✓ |
|
|
69
|
-
| `midRunSessionId` | ✓ | ✗ | Resumable session ID emitted before the terminal `result` event; when false, steering waits for a checkpoint. |
|
|
70
|
-
| `systemPromptFile` | ✓ | ✗ | sysprompt via `--system-prompt-file` (else inlined into stdin by `buildPrompt`). |
|
|
71
|
-
| `effortLevels` | ✓ | ✓ |
|
|
72
|
-
| `costTracking` | ✓ | ✗ | USD + token counts in the result event. Copilot only emits `premiumRequests
|
|
73
|
-
| `modelShorthands` | ✓ | ✗ | Bare `sonnet`/`opus`/`haiku` accepted
|
|
74
|
-
| `modelDiscovery` | ✗ | ✓ | `listModels()` returns a real catalog (Copilot
|
|
75
|
-
| `promptViaArg` | ✗ | ✗ | If true, prompt goes via `--prompt <text>` instead of stdin. False
|
|
76
|
-
| `budgetCap` | ✓ | ✗ | `--max-budget-usd <n>`. |
|
|
77
|
-
| `bareMode` | ✓ | ✗ | `--bare` (suppresses CLAUDE.md auto-discovery).
|
|
78
|
-
| `fallbackModel` | ✓ | ✗ | `--fallback-model <id>` on rate-limit. |
|
|
79
|
-
| `sessionPersistenceControl` | ✓ | ✗ | Engine writes `session.json
|
|
80
|
-
| `resumePromptCarryover` | ✗ | ✓ | CC resume turns prepend recent visible Q&A in stdin
|
|
81
|
-
| `resumeBookkeepingTurn` | ✓ | — | Claude CLI injects a synthetic "Continue from where you left off." meta turn on `--resume`; CC prompts must tell the model not to treat it as user intent. |
|
|
82
|
-
| `streamConsumer` | ✓ | ✓ | Adapter implements `createStreamConsumer(ctx)` — required by `engine/llm.js` accumulator. |
|
|
66
|
+
| Flag | Claude | Copilot | Codex | Gates |
|
|
67
|
+
|------|--------|---------|-------|-------|
|
|
68
|
+
| `streaming` | ✓ | ✓ | ✓ | JSONL events on stdout. |
|
|
69
|
+
| `sessionResume` | ✓ | ✓ | ✓ | Runtime-specific resume args. |
|
|
70
|
+
| `midRunSessionId` | ✓ | ✗ | ✗ | Resumable session ID emitted before the terminal `result` event; when false, steering waits for a checkpoint. |
|
|
71
|
+
| `systemPromptFile` | ✓ | ✗ | ✗ | sysprompt via `--system-prompt-file` (else inlined into stdin by `buildPrompt`). |
|
|
72
|
+
| `effortLevels` | ✓ | ✓ | ✓ | Runtime reasoning-effort controls. Codex maps through `--config model_reasoning_effort=...`; Copilot maps `'max'` to `'xhigh'`; Claude leaves `'max'` alone. |
|
|
73
|
+
| `costTracking` | ✓ | ✗ | ✗ | USD + token counts in the result event. Copilot only emits `premiumRequests`; Codex accounting is not assumed. |
|
|
74
|
+
| `modelShorthands` | ✓ | ✗ | ✗ | Bare `sonnet`/`opus`/`haiku` accepted by Claude only. |
|
|
75
|
+
| `modelDiscovery` | ✗ | ✓ | ✓ | `listModels()` returns a real catalog when supported (Copilot API, Codex `debug models --bundled`). |
|
|
76
|
+
| `promptViaArg` | ✗ | ✗ | ✗ | If true, prompt goes via `--prompt <text>` instead of stdin. False for all current runtimes. |
|
|
77
|
+
| `budgetCap` | ✓ | ✗ | ✗ | `--max-budget-usd <n>`. |
|
|
78
|
+
| `bareMode` | ✓ | ✗ | ✗ | `--bare` (suppresses CLAUDE.md auto-discovery). Other runtimes use their own config/suppression knobs. |
|
|
79
|
+
| `fallbackModel` | ✓ | ✗ | ✗ | `--fallback-model <id>` on rate-limit. |
|
|
80
|
+
| `sessionPersistenceControl` | ✓ | ✗ | ✗ | Engine writes `session.json`; Copilot and Codex own their runtime stores, while Minions records IDs only for safe resume. |
|
|
81
|
+
| `resumePromptCarryover` | ✗ | ✓ | ✓ | CC resume turns prepend recent visible Q&A in stdin when the runtime session store is opaque to Minions. |
|
|
82
|
+
| `resumeBookkeepingTurn` | ✓ | — | — | Claude CLI injects a synthetic "Continue from where you left off." meta turn on `--resume`; CC prompts must tell the model not to treat it as user intent. |
|
|
83
|
+
| `streamConsumer` | ✓ | ✓ | ✓ | Adapter implements `createStreamConsumer(ctx)` — required by `engine/llm.js` accumulator. |
|
|
83
84
|
|
|
84
85
|
When a behavior is genuinely uniform across all current adapters, it still gets
|
|
85
86
|
a flag if a future runtime might disagree — flags are cheap.
|
|
@@ -165,6 +166,7 @@ contracts.
|
|
|
165
166
|
|---------|---------------------|---------------------|-------------------------------|
|
|
166
167
|
| Claude | `~/.claude/skills` | `<repo>/.claude/skills` | personal: `~/.claude/skills`; project: `<repo>/.claude/skills` |
|
|
167
168
|
| Copilot | `~/.copilot/skills` (+ `~/.agents/skills`) | `<repo>/.github/skills`, `<repo>/.agents/skills` | personal: `~/.copilot/skills`; project: `<repo>/.github/skills` |
|
|
169
|
+
| Codex | `~/.agents/skills` (+ `/etc/codex/skills`) | `<repo>/.agents/skills` | personal: `~/.agents/skills`; project: `<repo>/.agents/skills` |
|
|
168
170
|
|
|
169
171
|
`getUserAssetDirs()` returns the union of runtime-native global roots and is
|
|
170
172
|
passed to spawn as `--add-dir` so spawned agents read the same global assets the
|
|
@@ -0,0 +1,141 @@
|
|
|
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 _dbPath = null;
|
|
22
|
+
let _dbInitError = null;
|
|
23
|
+
|
|
24
|
+
function _resolveDbPath() {
|
|
25
|
+
// Lazy-require shared/queries so this module can be safely required
|
|
26
|
+
// before MINIONS_DIR is computed (e.g. in tests). Falls back to
|
|
27
|
+
// process.env.MINIONS_HOME when available.
|
|
28
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
29
|
+
let minionsDir = envHome;
|
|
30
|
+
if (!minionsDir) {
|
|
31
|
+
try { minionsDir = require('../shared').MINIONS_DIR; } catch { /* shared not loaded */ }
|
|
32
|
+
}
|
|
33
|
+
if (!minionsDir) throw new Error('engine/db: MINIONS_DIR not resolvable');
|
|
34
|
+
const engineDir = path.join(minionsDir, 'engine');
|
|
35
|
+
try { fs.mkdirSync(engineDir, { recursive: true }); } catch { /* exists */ }
|
|
36
|
+
return path.join(engineDir, 'state.db');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Suppress Node's ExperimentalWarning for `node:sqlite`. It fires once per
|
|
40
|
+
// process the first time the module is required; without filtering it
|
|
41
|
+
// leaks into every engine/dashboard log and CI run. Re-fires only for
|
|
42
|
+
// non-sqlite experimental warnings.
|
|
43
|
+
let _warningFilterInstalled = false;
|
|
44
|
+
function _installExperimentalWarningFilter() {
|
|
45
|
+
if (_warningFilterInstalled) return;
|
|
46
|
+
_warningFilterInstalled = true;
|
|
47
|
+
const origEmit = process.emit;
|
|
48
|
+
process.emit = function (name, data, ...rest) {
|
|
49
|
+
if (name === 'warning' && data && data.name === 'ExperimentalWarning' && /SQLite/.test(String(data.message))) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return origEmit.call(process, name, data, ...rest);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getDb() {
|
|
57
|
+
// Re-resolve the DB path on every call so tests that swap MINIONS_TEST_DIR
|
|
58
|
+
// (or production-side configuration that changes MINIONS_HOME between
|
|
59
|
+
// operations) get a fresh connection to the new location instead of
|
|
60
|
+
// a stale handle pointing at a now-deleted tmpdir. The resolve itself
|
|
61
|
+
// is just env-var reads + path.join — sub-microsecond.
|
|
62
|
+
let dbPath;
|
|
63
|
+
try { dbPath = _resolveDbPath(); }
|
|
64
|
+
catch (e) {
|
|
65
|
+
_dbInitError = new Error(`engine/db: cannot resolve DB path — ${e.message}`);
|
|
66
|
+
throw _dbInitError;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (_db) {
|
|
70
|
+
if (_dbPath === dbPath) return _db;
|
|
71
|
+
// Path changed (test isolation swap, or operator reconfigured
|
|
72
|
+
// MINIONS_HOME). Close the old handle and reopen against the new path.
|
|
73
|
+
try { _db.close(); } catch { /* already closed */ }
|
|
74
|
+
_db = null;
|
|
75
|
+
_dbPath = null;
|
|
76
|
+
_dbInitError = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (_dbInitError) throw _dbInitError;
|
|
80
|
+
try {
|
|
81
|
+
_installExperimentalWarningFilter();
|
|
82
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
83
|
+
_db = new DatabaseSync(dbPath);
|
|
84
|
+
_dbPath = dbPath;
|
|
85
|
+
// WAL mode lets the engine writer and dashboard reader hit the same
|
|
86
|
+
// file from two processes without lock contention. NORMAL synchronous
|
|
87
|
+
// is the recommended WAL pairing (durable enough; one OS-level fsync
|
|
88
|
+
// per checkpoint instead of per write).
|
|
89
|
+
_db.exec('PRAGMA journal_mode = WAL');
|
|
90
|
+
_db.exec('PRAGMA foreign_keys = ON');
|
|
91
|
+
_db.exec('PRAGMA synchronous = NORMAL');
|
|
92
|
+
_db.exec('PRAGMA busy_timeout = 5000');
|
|
93
|
+
const { runMigrations } = require('./migrate');
|
|
94
|
+
runMigrations(_db);
|
|
95
|
+
return _db;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
_dbInitError = new Error(
|
|
98
|
+
`engine/db: failed to open SQLite — ${e.message}. ` +
|
|
99
|
+
`Node 22.5+ required for built-in 'node:sqlite' support.`
|
|
100
|
+
);
|
|
101
|
+
throw _dbInitError;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Force-close — used by tests + graceful shutdown. Safe to call when not open.
|
|
106
|
+
function closeDb() {
|
|
107
|
+
if (_db) { try { _db.close(); } catch { /* already closed */ } _db = null; }
|
|
108
|
+
_dbPath = null;
|
|
109
|
+
_dbInitError = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Run `fn(db)` inside a transaction. Nestable: only the OUTERMOST call
|
|
113
|
+
// issues BEGIN/COMMIT; inner calls run inside the enclosing tx so multiple
|
|
114
|
+
// stores (dispatches + work_items + ...) can compose into one atomic
|
|
115
|
+
// mutation. Inner rollback propagates outward, which is the correct
|
|
116
|
+
// semantics for cross-store invariants — if a nested mutation fails, the
|
|
117
|
+
// enclosing one must also fail.
|
|
118
|
+
//
|
|
119
|
+
// We track depth on the db instance itself rather than module-level so
|
|
120
|
+
// reopening the singleton (test isolation, path changes) doesn't leak
|
|
121
|
+
// stale depth into a fresh connection.
|
|
122
|
+
function withTransaction(db, fn) {
|
|
123
|
+
if (!db) throw new Error('engine/db.withTransaction: db is required');
|
|
124
|
+
const isOuter = !db._minionsTxDepth;
|
|
125
|
+
db._minionsTxDepth = (db._minionsTxDepth || 0) + 1;
|
|
126
|
+
if (isOuter) db.exec('BEGIN IMMEDIATE');
|
|
127
|
+
try {
|
|
128
|
+
const result = fn(db);
|
|
129
|
+
db._minionsTxDepth -= 1;
|
|
130
|
+
if (db._minionsTxDepth === 0) db.exec('COMMIT');
|
|
131
|
+
return result;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
db._minionsTxDepth -= 1;
|
|
134
|
+
if (db._minionsTxDepth === 0) {
|
|
135
|
+
try { db.exec('ROLLBACK'); } catch { /* best-effort */ }
|
|
136
|
+
}
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { getDb, closeDb, withTransaction };
|
|
@@ -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,113 @@
|
|
|
1
|
+
// engine/db/migrations/002-dispatches.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 1: move engine/dispatch.json into a `dispatches` table.
|
|
4
|
+
//
|
|
5
|
+
// Schema is intentionally hybrid: a small set of indexed projection columns
|
|
6
|
+
// (status, agent, type, timestamps) for hot filters/sorts, plus a single
|
|
7
|
+
// `data` TEXT column holding the full record JSON. This way the
|
|
8
|
+
// `mutateDispatch(fn)` API contract — "mutator receives the section-shaped
|
|
9
|
+
// object, mutates in place, returns" — round-trips every field cleanly
|
|
10
|
+
// without us needing to enumerate every possible column ahead of time.
|
|
11
|
+
// New fields the engine adds in a future commit land in `data`
|
|
12
|
+
// automatically.
|
|
13
|
+
//
|
|
14
|
+
// Backfill: read engine/dispatch.json's four sections (pending / active /
|
|
15
|
+
// completed / review), INSERT one row per dispatch with `status` derived
|
|
16
|
+
// from the section name. Timestamps are parsed from ISO strings to
|
|
17
|
+
// milliseconds since epoch (matches the rest of the codebase). The JSON
|
|
18
|
+
// file is renamed to engine/dispatch.json.pre-sql-<ts> for rollback —
|
|
19
|
+
// NOT deleted. A separate `minions db rollback-to-json` command (next
|
|
20
|
+
// phase) will read the table and reconstruct the JSON if the user pins
|
|
21
|
+
// to a pre-migration version.
|
|
22
|
+
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
|
|
26
|
+
function _resolveMinionsDir() {
|
|
27
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
28
|
+
if (envHome) return envHome;
|
|
29
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _toMs(v) {
|
|
33
|
+
if (v == null) return null;
|
|
34
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
35
|
+
const parsed = Date.parse(v);
|
|
36
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
version: 2,
|
|
41
|
+
description: 'dispatches: schema + dispatch.json backfill',
|
|
42
|
+
up(db) {
|
|
43
|
+
db.exec(`
|
|
44
|
+
CREATE TABLE dispatches (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
status TEXT NOT NULL,
|
|
47
|
+
agent TEXT,
|
|
48
|
+
type TEXT,
|
|
49
|
+
created_at INTEGER,
|
|
50
|
+
started_at INTEGER,
|
|
51
|
+
completed_at INTEGER,
|
|
52
|
+
data TEXT NOT NULL,
|
|
53
|
+
updated_at INTEGER NOT NULL
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX idx_dispatches_status ON dispatches(status);
|
|
56
|
+
CREATE INDEX idx_dispatches_agent ON dispatches(agent);
|
|
57
|
+
CREATE INDEX idx_dispatches_status_completed ON dispatches(status, completed_at DESC);
|
|
58
|
+
CREATE INDEX idx_dispatches_status_created ON dispatches(status, created_at DESC);
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
const minionsDir = _resolveMinionsDir();
|
|
62
|
+
if (!minionsDir) return;
|
|
63
|
+
const dispatchPath = path.join(minionsDir, 'engine', 'dispatch.json');
|
|
64
|
+
if (!fs.existsSync(dispatchPath)) return;
|
|
65
|
+
|
|
66
|
+
let raw;
|
|
67
|
+
try { raw = JSON.parse(fs.readFileSync(dispatchPath, 'utf8')); }
|
|
68
|
+
catch (e) {
|
|
69
|
+
// Corrupt JSON — abort the migration, surfacing the error. The user
|
|
70
|
+
// can fix the file (or restore from the .backup sidecar safeWrite
|
|
71
|
+
// keeps) and re-run the engine; the migration will retry.
|
|
72
|
+
throw new Error(`engine/db/002-dispatches: cannot parse dispatch.json: ${e.message}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const insert = db.prepare(`
|
|
77
|
+
INSERT INTO dispatches (id, status, agent, type, created_at, started_at, completed_at, data, updated_at)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
79
|
+
`);
|
|
80
|
+
|
|
81
|
+
let inserted = 0;
|
|
82
|
+
for (const status of ['pending', 'active', 'completed', 'review']) {
|
|
83
|
+
const rows = raw[status];
|
|
84
|
+
if (!Array.isArray(rows)) continue;
|
|
85
|
+
for (const d of rows) {
|
|
86
|
+
if (!d || typeof d !== 'object' || !d.id) continue;
|
|
87
|
+
insert.run(
|
|
88
|
+
String(d.id),
|
|
89
|
+
status,
|
|
90
|
+
d.agent || null,
|
|
91
|
+
d.type || null,
|
|
92
|
+
_toMs(d.created_at),
|
|
93
|
+
_toMs(d.started_at),
|
|
94
|
+
_toMs(d.completed_at),
|
|
95
|
+
JSON.stringify(d),
|
|
96
|
+
now,
|
|
97
|
+
);
|
|
98
|
+
inserted += 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// dispatch.json stays on disk as a dual-write mirror — every successful
|
|
103
|
+
// mutateDispatch refreshes it from SQL via dispatch-store._mirrorJsonFromSql.
|
|
104
|
+
// SQL is the source of truth; the file is a read-only derivative kept
|
|
105
|
+
// for backward compatibility with legacy direct-readers (engine/routing.js,
|
|
106
|
+
// engine/queries.js fallback, test infrastructure). Rollback path: if a
|
|
107
|
+
// user pins back to a pre-Phase-1 release, dispatch.json already contains
|
|
108
|
+
// the latest committed state (last mirror write), and the SQL table can
|
|
109
|
+
// be discarded.
|
|
110
|
+
// eslint-disable-next-line no-console
|
|
111
|
+
console.log(`[db-migrate] v2: backfilled ${inserted} dispatches; dispatch.json kept as dual-write mirror`);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// engine/db/migrations/003-work-items.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 2: move work-items.json (central + per-project) into a `work_items`
|
|
4
|
+
// table.
|
|
5
|
+
//
|
|
6
|
+
// Same hybrid schema as `dispatches`: typed projection columns for hot
|
|
7
|
+
// filters (scope/status/agent/parent), plus a `data` TEXT column holding
|
|
8
|
+
// the full record JSON so future fields land transparently.
|
|
9
|
+
//
|
|
10
|
+
// `scope` keys the file the record came from: 'central' for
|
|
11
|
+
// <MINIONS_DIR>/work-items.json, otherwise the project name (matches the
|
|
12
|
+
// directory under projects/<name>/). Per-scope writes diff against the
|
|
13
|
+
// scope's rows so a mutation on one project's file never touches another's.
|
|
14
|
+
//
|
|
15
|
+
// Backfill: walk central + every projects/<name>/work-items.json. The
|
|
16
|
+
// JSON files stay on disk as a dual-write mirror so legacy direct-readers
|
|
17
|
+
// continue to function while we migrate them in Phase 2.5.
|
|
18
|
+
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
|
|
22
|
+
function _resolveMinionsDir() {
|
|
23
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
24
|
+
if (envHome) return envHome;
|
|
25
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function _toMs(v) {
|
|
29
|
+
if (v == null) return null;
|
|
30
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
31
|
+
const parsed = Date.parse(v);
|
|
32
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _readJsonArray(filePath) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _listProjectDirs(minionsDir) {
|
|
46
|
+
const projectsRoot = path.join(minionsDir, 'projects');
|
|
47
|
+
let entries;
|
|
48
|
+
try { entries = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
|
|
49
|
+
catch { return []; }
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (!e.isDirectory()) continue;
|
|
53
|
+
if (e.name === '.archived') continue;
|
|
54
|
+
out.push(e.name);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
version: 3,
|
|
61
|
+
description: 'work_items: schema + work-items.json backfill (central + per-project)',
|
|
62
|
+
up(db) {
|
|
63
|
+
db.exec(`
|
|
64
|
+
CREATE TABLE work_items (
|
|
65
|
+
id TEXT NOT NULL,
|
|
66
|
+
scope TEXT NOT NULL,
|
|
67
|
+
status TEXT NOT NULL,
|
|
68
|
+
type TEXT,
|
|
69
|
+
agent TEXT,
|
|
70
|
+
parent_id TEXT,
|
|
71
|
+
created_at INTEGER,
|
|
72
|
+
completed_at INTEGER,
|
|
73
|
+
data TEXT NOT NULL,
|
|
74
|
+
updated_at INTEGER NOT NULL,
|
|
75
|
+
PRIMARY KEY (scope, id)
|
|
76
|
+
);
|
|
77
|
+
CREATE INDEX idx_wi_status ON work_items(status);
|
|
78
|
+
CREATE INDEX idx_wi_scope_status ON work_items(scope, status);
|
|
79
|
+
CREATE INDEX idx_wi_agent ON work_items(agent);
|
|
80
|
+
CREATE INDEX idx_wi_parent ON work_items(parent_id);
|
|
81
|
+
`);
|
|
82
|
+
|
|
83
|
+
const minionsDir = _resolveMinionsDir();
|
|
84
|
+
if (!minionsDir) return;
|
|
85
|
+
|
|
86
|
+
const insert = db.prepare(`
|
|
87
|
+
INSERT INTO work_items (id, scope, status, type, agent, parent_id, created_at, completed_at, data, updated_at)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
89
|
+
`);
|
|
90
|
+
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
let inserted = 0;
|
|
93
|
+
|
|
94
|
+
const backfillFile = (scope, filePath) => {
|
|
95
|
+
const rows = _readJsonArray(filePath);
|
|
96
|
+
for (const wi of rows) {
|
|
97
|
+
if (!wi || typeof wi !== 'object' || !wi.id) continue;
|
|
98
|
+
try {
|
|
99
|
+
insert.run(
|
|
100
|
+
String(wi.id),
|
|
101
|
+
scope,
|
|
102
|
+
String(wi.status || 'pending'),
|
|
103
|
+
wi.type || null,
|
|
104
|
+
wi.dispatched_to || wi.agent || null,
|
|
105
|
+
wi.parent_id || null,
|
|
106
|
+
_toMs(wi.created_at),
|
|
107
|
+
_toMs(wi.completed_at || wi.completedAt),
|
|
108
|
+
JSON.stringify(wi),
|
|
109
|
+
now,
|
|
110
|
+
);
|
|
111
|
+
inserted += 1;
|
|
112
|
+
} catch {
|
|
113
|
+
// Duplicate (scope, id) within the same file → skip. Real-world
|
|
114
|
+
// work-items.json doesn't carry dupes, but defensive: a corrupted
|
|
115
|
+
// file shouldn't abort the whole migration.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
backfillFile('central', path.join(minionsDir, 'work-items.json'));
|
|
121
|
+
for (const projectName of _listProjectDirs(minionsDir)) {
|
|
122
|
+
backfillFile(projectName, path.join(minionsDir, 'projects', projectName, 'work-items.json'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// eslint-disable-next-line no-console
|
|
126
|
+
console.log(`[db-migrate] v3: backfilled ${inserted} work items; work-items.json files kept as dual-write mirrors`);
|
|
127
|
+
},
|
|
128
|
+
};
|