@yemi33/minions 0.1.2108 → 0.1.2109

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.
@@ -344,19 +344,81 @@ function _qaRenderRunRow(run) {
344
344
  } else if (agentId) {
345
345
  liveLink = '<a href="#" class="qa-run-link" onclick="event.preventDefault();qaOpenRunAgent(\'\',this.dataset.agent);" data-agent="' + escHtml(agentId) + '">' + escHtml(linkLabel) + '</a>';
346
346
  }
347
+ // W-mpxp90xs000i5732 — per-row Delete button, terminal-only. The engine
348
+ // also enforces terminal-only with a 409 (defense in depth); rendering
349
+ // the button conditionally just keeps the UI honest about what's safe
350
+ // to click.
351
+ let deleteBtn = '';
352
+ if (_qaIsTerminalStatus(status)) {
353
+ deleteBtn = '<button class="qa-btn-ghost qa-run-delete-btn" ' +
354
+ 'data-run-id="' + escHtml(id) + '" ' +
355
+ 'onclick="qaDeleteRun(this.dataset.runId)">Delete</button>';
356
+ }
357
+ const actionsHtml = (liveLink || deleteBtn)
358
+ ? '<span class="qa-run-actions">' + liveLink + deleteBtn + '</span>'
359
+ : '';
347
360
  const artifactsHtml = _qaRenderArtifactPreviews(id, run.artifacts || []);
348
- return '<div class="qa-run-row">' +
361
+ return '<div class="qa-run-row" data-run-id="' + escHtml(id) + '">' +
349
362
  '<div class="qa-run-head">' +
350
363
  '<span class="qa-run-status ' + escHtml(statusClass) + '">' + escHtml(status) + '</span>' +
351
364
  '<span class="qa-run-name">' + escHtml(runbook) + '</span>' +
352
365
  (target ? '<span class="qa-run-target">→ <code>' + escHtml(target) + '</code></span>' : '') +
353
366
  (ts ? '<span class="qa-run-ts">' + escHtml(String(ts).slice(0, 19).replace('T', ' ')) + '</span>' : '') +
354
- (liveLink ? '<span class="qa-run-actions">' + liveLink + '</span>' : '') +
367
+ actionsHtml +
355
368
  '</div>' +
356
369
  (artifactsHtml ? '<div class="qa-run-artifacts">' + artifactsHtml + '</div>' : '') +
357
370
  '</div>';
358
371
  }
359
372
 
373
+ // W-mpxp90xs000i5732 — terminal status set kept in sync with
374
+ // engine/qa-runs.js#TERMINAL_STATUSES. The Delete button only renders for
375
+ // runs in one of these states; the engine's deleteQaRun returns 409 for
376
+ // anything else (defense in depth).
377
+ function _qaIsTerminalStatus(status) {
378
+ if (!status || typeof status !== 'string') return false;
379
+ const s = status.toLowerCase();
380
+ return s === 'passed' || s === 'failed' || s === 'errored';
381
+ }
382
+
383
+ // W-mpxp90xs000i5732 — per-row Delete handler. Confirms with the user,
384
+ // fires DELETE /api/qa/runs/<id>, optimistic-removes the row on 200, and
385
+ // surfaces errors via showToast(). Does NOT use alert() post-API per the
386
+ // WI's UX contract.
387
+ async function qaDeleteRun(id) {
388
+ if (!id) return;
389
+ if (!confirm('Delete QA run ' + id + ' and its artifacts? This cannot be undone.')) return;
390
+ let res, json;
391
+ try {
392
+ res = await fetch('/api/qa/runs/' + encodeURIComponent(id), {
393
+ method: 'DELETE',
394
+ });
395
+ } catch (e) {
396
+ if (typeof showToast === 'function') {
397
+ showToast('cmd-toast', 'Delete QA run failed: ' + (e && e.message || String(e)), false);
398
+ }
399
+ return;
400
+ }
401
+ try { json = await res.json(); } catch { json = {}; }
402
+ if (res.ok && json && json.ok) {
403
+ const sel = (window.CSS && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'));
404
+ const row = document.querySelector('.qa-run-row[data-run-id="' + sel + '"]');
405
+ if (row && row.parentNode) row.parentNode.removeChild(row);
406
+ if (typeof loadQaRuns === 'function') loadQaRuns();
407
+ if (typeof showToast === 'function') {
408
+ const msg = json.artifactsRemoved
409
+ ? 'QA run deleted (artifacts removed).'
410
+ : 'QA run deleted.';
411
+ showToast('cmd-toast', msg, true);
412
+ }
413
+ return;
414
+ }
415
+ const errMsg = (json && (json.error + (json.currentStatus ? ' (status=' + json.currentStatus + ')' : '')))
416
+ || ('HTTP ' + (res ? res.status : '?'));
417
+ if (typeof showToast === 'function') {
418
+ showToast('cmd-toast', 'Delete QA run failed: ' + errMsg, false);
419
+ }
420
+ }
421
+
360
422
  function _qaRenderArtifactPreviews(runId, artifacts) {
361
423
  if (!runId || !Array.isArray(artifacts) || !artifacts.length) return '';
362
424
  const parts = [];
@@ -404,6 +404,7 @@ async function openSettings() {
404
404
  '<div class="settings-stack">' +
405
405
  settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
406
406
  settingsToggle('Disable model discovery', 'set-disableModelDiscovery', !!e.disableModelDiscovery, 'Skip /api/runtimes/<name>/models REST calls fleet-wide. Settings UI falls back to free-text.') +
407
+ settingsToggle('QA: dual-write JSON sidecars', 'set-qaDualWriteJson', e.qaDualWriteJson !== false, 'Keep mirroring qa-runs.json and qa-sessions.json on every mutation alongside the SQLite source of truth. Default ON. Disable only once external tooling has been migrated off the JSON files — disabling drops the rollback path back to the JSON store.') +
407
408
  '</div>';
408
409
 
409
410
  // Section registry — order is intentional (Runtime + Auto-fix surface first
@@ -880,6 +881,7 @@ async function saveSettings() {
880
881
  copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
881
882
  maxBudgetUsd: (document.getElementById('set-maxBudgetUsd')?.value ?? '').trim(),
882
883
  disableModelDiscovery: !!document.getElementById('set-disableModelDiscovery')?.checked,
884
+ qaDualWriteJson: !!document.getElementById('set-qaDualWriteJson')?.checked,
883
885
  // W-mpmwxkrw000872ec — global font-size scale. Allowlist validation
884
886
  // (and clamp messaging) lives server-side in handleSettingsUpdate.
885
887
  fontSize: document.getElementById('set-fontSize')?.value || 'small',
package/dashboard.js CHANGED
@@ -10345,6 +10345,46 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10345
10345
  }
10346
10346
  }
10347
10347
 
10348
+ // W-mpxp90xs000i5732 — DELETE /api/qa/runs/<id>. Hard-delete: removes the
10349
+ // run from engine/qa-runs.json AND best-effort wipes the on-disk artifact
10350
+ // directory under engine/qa-artifacts/<runId>/. Terminal-only — refuses
10351
+ // pending/running runs with 409. Caller must cancel/complete the run via
10352
+ // the lifecycle helpers before deletion. CSRF/Origin gate is applied by
10353
+ // the server dispatcher for all mutating methods (see MUTATING_METHODS).
10354
+ function handleQaRunsDelete(req, res, match) {
10355
+ try {
10356
+ const qa = require('./engine/qa-runs');
10357
+ const id = decodeURIComponent(match[1] || '');
10358
+ if (!_qaIsSafeSegment(id)) {
10359
+ return jsonReply(res, 400, { error: 'invalid run id' }, req);
10360
+ }
10361
+ const result = qa.deleteQaRun(id);
10362
+ if (result && result.ok) {
10363
+ return jsonReply(res, 200, {
10364
+ ok: true,
10365
+ id,
10366
+ artifactsRemoved: !!result.artifactsRemoved,
10367
+ }, req);
10368
+ }
10369
+ const err = result && result.error;
10370
+ if (err === 'not_found') {
10371
+ return jsonReply(res, 404, { error: 'not_found' }, req);
10372
+ }
10373
+ if (err === 'not_terminal') {
10374
+ return jsonReply(res, 409, {
10375
+ error: 'not_terminal',
10376
+ currentStatus: result.currentStatus,
10377
+ }, req);
10378
+ }
10379
+ if (err === 'invalid_id') {
10380
+ return jsonReply(res, 400, { error: 'invalid run id' }, req);
10381
+ }
10382
+ return jsonReply(res, 500, { error: 'qa-runs delete returned unknown result' }, req);
10383
+ } catch (e) {
10384
+ return jsonReply(res, 500, { error: e.message }, req);
10385
+ }
10386
+ }
10387
+
10348
10388
  function handleQaArtifact(req, res, match) {
10349
10389
  try {
10350
10390
  const qa = require('./engine/qa-runs');
@@ -11052,6 +11092,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11052
11092
  // traversal attempts return 403, missing files return 404.
11053
11093
  { method: 'GET', path: '/api/qa/runs', desc: 'List QA validation runs (newest first). Optional ?limit=N and ?status=pending|running|passed|failed|errored filters.', handler: handleQaRunsList },
11054
11094
  { method: 'GET', path: /^\/api\/qa\/runs\/([^/?]+)$/, template: '/api/qa/runs/<id>', desc: 'Fetch a single QA run record by id.', handler: handleQaRunsById },
11095
+ { method: 'DELETE', path: /^\/api\/qa\/runs\/([^/?]+)$/, template: '/api/qa/runs/<id>', desc: 'Delete a QA run by id (terminal runs only). Best-effort removes artifacts under engine/qa-artifacts/<runId>/.', destructive: true, handler: handleQaRunsDelete },
11055
11096
  { method: 'GET', path: /^\/api\/qa\/artifacts\/([^/?]+)\/([^?]+)$/, template: '/api/qa/artifacts/<runId>/<file>', desc: 'Serve a QA artifact file (image/video/log). Sandboxed to engine/qa-artifacts/; rejects path traversal with 403.', handler: handleQaArtifact },
11056
11097
  // QA Runbooks (W-mpeiwz6k0005bf34-a) — per-project test plans stored at
11057
11098
  // <MINIONS_DIR>/projects/<name>/runbooks/<id>.json. Pure persistence —
@@ -1,4 +1,12 @@
1
1
  [
2
+ {
3
+ "id": "qa-json-sidecars",
4
+ "location": "engine/small-state-store.js _mirrorQaRunsJson + _mirrorQaSessionsJson; engine/shared.js _qaMutator (mirror call); dashboard/js/settings.js set-qaDualWriteJson toggle",
5
+ "reason": "Phase 8 migration (migrate-qa-state-to-sqlite, shipped 2026-06) moved qa_runs + qa_sessions into SQLite (engine/state.db, tables qa_runs + qa_sessions) as the source of truth. The legacy JSON sidecars engine/qa-runs.json and engine/qa-sessions.json are dual-written on every mutation while operators migrate external tooling and dashboards off the JSON files. Gated by engine.qaDualWriteJson (default true).",
6
+ "deprecated": "2026-06-03",
7
+ "targetRemovalDate": "2026-09-03",
8
+ "notes": "Safe to remove on or after 2026-09-03 (90 days post-ship, ~3 release windows) once: (1) operator telemetry / dashboard Settings shows engine.qaDualWriteJson flipped false on the main fleet; (2) no external readers (CI scripts, dashboards, ops runbooks) reference the JSON sidecars directly. Removal scope: delete _mirrorQaRunsJson + _mirrorQaSessionsJson + their exports in engine/small-state-store.js; drop the _qaDualWriteEnabled() + mirror call in engine/shared.js#_qaMutator; remove qaDualWriteJson from ENGINE_DEFAULTS and from the Settings toggle in dashboard/js/settings.js; remove the divergence-rehydrate branch in _resyncQaRunsIfDiverged / _resyncQaSessionsIfDiverged (the JSON files no longer exist to diverge against)."
9
+ },
2
10
  {
3
11
  "id": "config-poll-key-migration",
4
12
  "location": "engine/queries.js:126-163",
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Author: Rebecca (Architect) | Date: 2026-04-07 | Status: **Accepted — implementation in progress**
4
4
 
5
- > **Implementation status (as of 2026-05):** The `node:sqlite` recommendation in §3 has been adopted ahead of schedule. Phases 0–7 have shipped (events, dispatches, work_items, pull_requests, logs, metrics, watches, schedule_runs + pipeline_runs + managed_processes + worktree_pool — see `CHANGELOG.md`). The SQLite schema lives under `engine/db/migrations/` and the singleton opens `engine/state.db` in WAL mode. The "Phase 2: estimated Node 26 LTS" timeline in §3 is now historical context; treat sections 1–3 as design rationale rather than a forward plan.
5
+ > **Implementation status (as of 2026-06):** The `node:sqlite` recommendation in §3 has been adopted ahead of schedule. Phases 0–8 have shipped (events, dispatches, work_items, pull_requests, logs, metrics, watches, schedule_runs + pipeline_runs + managed_processes + worktree_pool, and qa_runs + qa_sessions — see `CHANGELOG.md`). The SQLite schema lives under `engine/db/migrations/` and the singleton opens `engine/state.db` in WAL mode. Phase 8 added the first opt-out toggle for the JSON sidecars (`engine.qaDualWriteJson`, default true) — when ops trust SQL as the source of truth for QA state, flip it false to halve write I/O on hot loops. The "Phase 2: estimated Node 26 LTS" timeline in §3 is now historical context; treat sections 1–3 as design rationale rather than a forward plan.
6
6
 
7
7
  ## Executive Summary
8
8
 
@@ -27,6 +27,41 @@ and clears the interval on page navigation via the `switchPage` wrapper in
27
27
  `dashboard/js/qa.js` (matches `_stopPlanPoll`/`_stopMeetingPoll` pattern in
28
28
  `dashboard/js/state.js`).
29
29
 
30
+ ### Persistence (Phase 8: SQLite source of truth)
31
+
32
+ Since the Phase 8 migration (`migrate-qa-state-to-sqlite`), both
33
+ `qa-runs.json` and `qa-sessions.json` are dual-written sidecars and SQLite
34
+ (`engine/state.db`, tables `qa_runs` + `qa_sessions`) is the source of
35
+ truth. Every mutation goes through `shared.mutateQaRuns` /
36
+ `shared.mutateQaSessions`, which apply inside a SQLite transaction
37
+ (`BEGIN IMMEDIATE`) then best-effort mirror to JSON under a `withFileLock`
38
+ on the sidecar path so cross-process writers serialize. Reads go through
39
+ `small-state-store.readQaRuns` / `readQaSessions`, which compare the SQL
40
+ content-hash to the JSON sidecar and rehydrate SQL from JSON when an
41
+ external editor (operator hand-edit, manual rollback) has diverged.
42
+
43
+ The JSON mirror is gated by `engine.qaDualWriteJson` (default true). Once
44
+ operators trust SQL as the source of truth and have migrated external
45
+ tooling off the JSON files, flip it false via Dashboard → Settings →
46
+ Advanced → "QA: dual-write JSON sidecars" to halve write I/O. With the
47
+ toggle off, `qa-runs.json` / `qa-sessions.json` stop receiving updates
48
+ (stale snapshot at the moment of the flip), so don't disable until any
49
+ external readers have moved to the SQL tables or the dashboard `/api/qa/*`
50
+ endpoints. The qa-runs cap (`QA_RUNS_MAX_RECORDS=2000`) and qa-sessions
51
+ cap (`QA_SESSIONS_MAX_RECORDS=500`) still apply, enforced in-memory at
52
+ createRun/createSession time.
53
+
54
+ ### Deleting runs
55
+
56
+ `DELETE /api/qa/runs/<id>` hard-deletes a single terminal-status run
57
+ (`passed`/`failed`/`errored`) and best-effort removes its artifact
58
+ directory under `engine/qa-artifacts/<runId>/`. Non-terminal runs return
59
+ `409 not_terminal` — operators must cancel/complete the run via the
60
+ lifecycle helpers first. Invalid ids return `400 invalid_id`. The /qa
61
+ dashboard page renders a per-row Delete button conditionally (terminal
62
+ only), confirms with the user, and surfaces success/error via
63
+ `showToast()` rather than `alert()`.
64
+
30
65
  ## Artifact contract
31
66
 
32
67
  `engine/qa-artifacts/<runId>/<file>`, served via
@@ -198,6 +233,7 @@ Documented in `dashboard.js`; routes are visible at `GET /api/routes`.
198
233
  | POST | `/api/qa/sessions/<id>/dismiss` | Non-terminal → `done`. Accept the draft as final; leaves spawn alive. Optional `{ summary }`. |
199
234
  | GET | `/api/qa/runners` | List registered runner adapters (built-ins + `qa-runners.d/` plugins). Metadata only — hooks (functions) are stripped. |
200
235
  | POST | `/api/qa/runners/reload` | Clear in-process registry, re-register built-ins, re-scan `qa-runners.d/` for plugin edits. Returns the fresh runner list. |
236
+ | DELETE | `/api/qa/runs/<id>` | Hard-delete a single QA run record (terminal-status runs only) and best-effort wipe its artifact directory under `engine/qa-artifacts/<runId>/`. Returns `{ ok: true, id, artifactsRemoved }` on success, `409 not_terminal` for in-flight runs, `404 not_found` for unknown ids. |
201
237
 
202
238
  The single-session POSTs share `_qaSessionAction` in `dashboard.js`; module
203
239
  errors are mapped to HTTP via `_qaSessionsErrorToStatus`:
@@ -0,0 +1,140 @@
1
+ // engine/db/migrations/009-qa.js
2
+ //
3
+ // Phase 8: move the two QA state files into SQL.
4
+ //
5
+ // engine/qa-runs.json -> qa_runs (top-level array → row per id)
6
+ // engine/qa-sessions.json -> qa_sessions (top-level array → row per id)
7
+ //
8
+ // Mirrors the dual-write / content-hash divergence pattern established by
9
+ // the watches and Phase 7 small-state stores. JSON sidecars stay in place as
10
+ // dual-write mirrors for one release cycle (gated by `engine.qaDualWriteJson`).
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ function _resolveMinionsDir() {
16
+ const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
17
+ if (envHome) return envHome;
18
+ try { return require('../../shared').MINIONS_DIR; } catch { return null; }
19
+ }
20
+
21
+ function _toMs(v) {
22
+ if (v == null) return null;
23
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
24
+ const parsed = Date.parse(v);
25
+ return Number.isFinite(parsed) ? parsed : null;
26
+ }
27
+
28
+ function _readJsonOr(filePath, fallback) {
29
+ try {
30
+ const raw = fs.readFileSync(filePath, 'utf8');
31
+ return JSON.parse(raw);
32
+ } catch { return fallback; }
33
+ }
34
+
35
+ module.exports = {
36
+ version: 9,
37
+ description: 'qa_runs + qa_sessions',
38
+ up(db) {
39
+ db.exec(`
40
+ CREATE TABLE qa_runs (
41
+ id TEXT PRIMARY KEY,
42
+ runbook_id TEXT NOT NULL,
43
+ target_name TEXT NOT NULL,
44
+ project TEXT,
45
+ work_item_id TEXT,
46
+ status TEXT NOT NULL,
47
+ started_at INTEGER,
48
+ completed_at INTEGER,
49
+ created_at INTEGER NOT NULL,
50
+ data TEXT NOT NULL
51
+ );
52
+ CREATE INDEX idx_qa_runs_status ON qa_runs(status);
53
+ CREATE INDEX idx_qa_runs_created_at ON qa_runs(created_at DESC);
54
+ CREATE INDEX idx_qa_runs_runbook ON qa_runs(runbook_id);
55
+ CREATE INDEX idx_qa_runs_target ON qa_runs(target_name);
56
+ CREATE INDEX idx_qa_runs_work_item ON qa_runs(work_item_id) WHERE work_item_id IS NOT NULL;
57
+
58
+ CREATE TABLE qa_sessions (
59
+ id TEXT PRIMARY KEY,
60
+ state TEXT NOT NULL,
61
+ primary_project TEXT,
62
+ qa_run_id TEXT,
63
+ created_at INTEGER NOT NULL,
64
+ updated_at INTEGER NOT NULL,
65
+ completed_at INTEGER,
66
+ data TEXT NOT NULL
67
+ );
68
+ CREATE INDEX idx_qa_sessions_state ON qa_sessions(state);
69
+ CREATE INDEX idx_qa_sessions_created_at ON qa_sessions(created_at DESC);
70
+ CREATE INDEX idx_qa_sessions_project ON qa_sessions(primary_project);
71
+ CREATE INDEX idx_qa_sessions_qa_run ON qa_sessions(qa_run_id) WHERE qa_run_id IS NOT NULL;
72
+ `);
73
+
74
+ const minionsDir = _resolveMinionsDir();
75
+ if (!minionsDir) return;
76
+ const now = Date.now();
77
+ let inserted = 0;
78
+
79
+ // ── qa_runs ────────────────────────────────────────────────────────────
80
+ {
81
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'qa-runs.json'), null);
82
+ if (Array.isArray(raw)) {
83
+ const ins = db.prepare(`
84
+ INSERT INTO qa_runs (id, runbook_id, target_name, project, work_item_id, status, started_at, completed_at, created_at, data)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
86
+ ON CONFLICT(id) DO NOTHING
87
+ `);
88
+ for (const run of raw) {
89
+ if (!run || !run.id) continue;
90
+ try {
91
+ ins.run(
92
+ String(run.id),
93
+ String(run.runbookId || ''),
94
+ String(run.targetName || ''),
95
+ run.project || null,
96
+ run.workItemId || null,
97
+ String(run.status || 'pending'),
98
+ _toMs(run.startedAt),
99
+ _toMs(run.completedAt),
100
+ _toMs(run.createdAt) || now,
101
+ JSON.stringify(run),
102
+ );
103
+ inserted += 1;
104
+ } catch { /* duplicate / corrupt — skip */ }
105
+ }
106
+ }
107
+ }
108
+
109
+ // ── qa_sessions ────────────────────────────────────────────────────────
110
+ {
111
+ const raw = _readJsonOr(path.join(minionsDir, 'engine', 'qa-sessions.json'), null);
112
+ if (Array.isArray(raw)) {
113
+ const ins = db.prepare(`
114
+ INSERT INTO qa_sessions (id, state, primary_project, qa_run_id, created_at, updated_at, completed_at, data)
115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
116
+ ON CONFLICT(id) DO NOTHING
117
+ `);
118
+ for (const session of raw) {
119
+ if (!session || !session.id) continue;
120
+ try {
121
+ ins.run(
122
+ String(session.id),
123
+ String(session.state || 'pending'),
124
+ session.primaryProject || (session.spec && session.spec.project) || null,
125
+ session.qaRunId || null,
126
+ _toMs(session.createdAt) || now,
127
+ _toMs(session.updatedAt) || _toMs(session.createdAt) || now,
128
+ _toMs(session.completedAt),
129
+ JSON.stringify(session),
130
+ );
131
+ inserted += 1;
132
+ } catch { /* duplicate / corrupt — skip */ }
133
+ }
134
+ }
135
+ }
136
+
137
+ // eslint-disable-next-line no-console
138
+ console.log(`[db-migrate] v9: backfilled ${inserted} QA rows; JSON files kept as dual-write mirrors`);
139
+ },
140
+ };
package/engine/qa-runs.js CHANGED
@@ -5,13 +5,19 @@
5
5
  * a managed target. Records carry status, timing, work-item linkage, and an
6
6
  * artifacts array (screenshots, logs, videos captured during the run).
7
7
  *
8
- * State file: engine/qa-runs.json (single file, all runs across all projects).
9
- * Artifacts live on disk under engine/qa-artifacts/<runId>/ the run record
10
- * stores relative paths (`type`, `path`, `label`, `capturedAt`), and the
11
- * dashboard exposes them through GET /api/qa/artifacts/<runId>/<file>.
8
+ * Persistence: SQLite (engine/state.db, `qa_runs` table) as source of truth
9
+ * since Phase 8 (PRD: migrate-qa-state-to-sqlite). The legacy JSON sidecar
10
+ * at engine/qa-runs.json is dual-written on every mutation when
11
+ * `engine.qaDualWriteJson` is true (default) so external tooling and the
12
+ * rollback path keep working. Artifacts live on disk under
13
+ * engine/qa-artifacts/<runId>/ — the run record stores relative paths
14
+ * (`type`, `path`, `label`, `capturedAt`), and the dashboard exposes them
15
+ * through GET /api/qa/artifacts/<runId>/<file>.
12
16
  *
13
- * Concurrency: every mutation goes through mutateJsonFileLocked per
14
- * CLAUDE.md best-practice; callbacks are synchronous and never await.
17
+ * Concurrency: every mutation goes through shared.mutateQaRuns, which routes
18
+ * to small-state-store.applyQaRunsMutation inside a SQLite transaction
19
+ * (BEGIN IMMEDIATE) plus a content-hash divergence rehydrate on read.
20
+ * Callbacks are synchronous and never await.
15
21
  *
16
22
  * Status state machine:
17
23
  *
@@ -20,22 +26,20 @@
20
26
  * ╲──▶ errored
21
27
  *
22
28
  * Illegal transitions throw with a descriptive error. Terminal statuses
23
- * (passed / failed / errored) cannot transition further.
29
+ * (passed / failed / errored) cannot transition further. `deleteRun` is
30
+ * only allowed once a run is terminal.
24
31
  */
25
32
 
26
33
  const fs = require('fs');
27
34
  const path = require('path');
28
35
  const shared = require('./shared');
29
- const { mutateJsonFileLocked, uid, ts, log } = shared;
36
+ const { mutateQaRuns, uid, ts, log } = shared;
30
37
 
31
- // Cap qa-runs.json so the file doesn't grow unboundedly over months of nightly
32
- // QA dispatch. Without a cap, listRuns + summarizeRunsForStatus pay O(N) on
33
- // every read, and /api/status's fast-state slice runs the summary on every
34
- // rebuild at 10k+ historical runs the JSON parse alone starts eating into
35
- // the W-mpehsyhv event-loop budget that CC SSE isolation depends on. Mirrors
36
- // the 2500-entry cap on engine/log.json. createRun trims oldest-by-createdAt
37
- // when crossing the threshold; terminal-status runs that have already shipped
38
- // completion notifications are safe to drop.
38
+ // Cap qa-runs.json so the file (and qa_runs table) doesn't grow unboundedly
39
+ // over months of nightly QA dispatch. Mirrors the 2500-entry cap on
40
+ // engine/log.json. createRun trims oldest-by-createdAt when crossing the
41
+ // threshold; terminal-status runs that have already shipped completion
42
+ // notifications are safe to drop.
39
43
  const QA_RUNS_MAX_RECORDS = 2000;
40
44
 
41
45
  const QA_RUN_STATUS = Object.freeze({
@@ -80,7 +84,8 @@ const ALLOWED_TRANSITIONS = {
80
84
  [QA_RUN_STATUS.ERRORED]: new Set(),
81
85
  };
82
86
 
83
- // Dynamic paths — respect MINIONS_TEST_DIR for test isolation.
87
+ // Dynamic paths — respect MINIONS_TEST_DIR for test isolation. Kept exported
88
+ // for back-compat with tests that probe the JSON sidecar on disk.
84
89
  function qaRunsPath() {
85
90
  return path.join(shared.MINIONS_DIR, 'engine', 'qa-runs.json');
86
91
  }
@@ -148,7 +153,7 @@ function createRun({ runbookId, targetName, project, workItemId } = {}) {
148
153
  createdAt: ts(),
149
154
  };
150
155
 
151
- mutateJsonFileLocked(qaRunsPath(), (runs) => {
156
+ mutateQaRuns((runs) => {
152
157
  if (!Array.isArray(runs)) runs = [];
153
158
  runs.push(run);
154
159
  // Rotation: drop oldest-by-createdAt when over the cap. Cheap because
@@ -158,7 +163,7 @@ function createRun({ runbookId, targetName, project, workItemId } = {}) {
158
163
  runs = runs.slice(runs.length - QA_RUNS_MAX_RECORDS);
159
164
  }
160
165
  return runs;
161
- }, { defaultValue: [] });
166
+ });
162
167
 
163
168
  // Pre-create the artifact directory outside the lock — directory creation
164
169
  // is idempotent and slow file I/O must never run while holding the lock.
@@ -178,7 +183,7 @@ function markRunning(id) {
178
183
  if (!id) throw new Error('qa-runs: id is required');
179
184
  let captured = null;
180
185
  let transitionError = null;
181
- mutateJsonFileLocked(qaRunsPath(), (runs) => {
186
+ mutateQaRuns((runs) => {
182
187
  if (!Array.isArray(runs)) runs = [];
183
188
  const run = runs.find(r => r && r.id === id);
184
189
  if (!run) { transitionError = new Error(`qa-runs: run not found: ${id}`); return runs; }
@@ -188,7 +193,7 @@ function markRunning(id) {
188
193
  run.startedAt = ts();
189
194
  captured = run;
190
195
  return runs;
191
- }, { defaultValue: [] });
196
+ });
192
197
  if (transitionError) throw transitionError;
193
198
  return captured;
194
199
  }
@@ -215,7 +220,7 @@ function completeRun(id, { status, summary, artifacts } = {}) {
215
220
 
216
221
  let captured = null;
217
222
  let transitionError = null;
218
- mutateJsonFileLocked(qaRunsPath(), (runs) => {
223
+ mutateQaRuns((runs) => {
219
224
  if (!Array.isArray(runs)) runs = [];
220
225
  const run = runs.find(r => r && r.id === id);
221
226
  if (!run) { transitionError = new Error(`qa-runs: run not found: ${id}`); return runs; }
@@ -230,7 +235,7 @@ function completeRun(id, { status, summary, artifacts } = {}) {
230
235
  }
231
236
  captured = run;
232
237
  return runs;
233
- }, { defaultValue: [] });
238
+ });
234
239
  if (transitionError) throw transitionError;
235
240
  return captured;
236
241
  }
@@ -242,11 +247,22 @@ function completeRun(id, { status, summary, artifacts } = {}) {
242
247
  */
243
248
  function getRun(id) {
244
249
  if (!id) return null;
245
- const runs = shared.safeJsonArr(qaRunsPath());
250
+ const runs = _readRuns();
246
251
  const run = runs.find(r => r && r.id === id);
247
252
  return run || null;
248
253
  }
249
254
 
255
+ // Internal helper — reads via the small-state store (SQL → JSON fallback)
256
+ // when available, falling back to direct JSON read if SQLite is unavailable.
257
+ function _readRuns() {
258
+ try {
259
+ const store = require('./small-state-store');
260
+ const arr = store.readQaRuns();
261
+ if (Array.isArray(arr)) return arr;
262
+ } catch { /* fall back */ }
263
+ return shared.safeJsonArr(qaRunsPath());
264
+ }
265
+
250
266
  /**
251
267
  * List runs, newest first, optionally filtered by status, capped by limit.
252
268
  * @param {object} [opts]
@@ -255,7 +271,7 @@ function getRun(id) {
255
271
  * @returns {Array<object>}
256
272
  */
257
273
  function listRuns({ limit, status } = {}) {
258
- let runs = shared.safeJsonArr(qaRunsPath());
274
+ let runs = _readRuns();
259
275
  if (!Array.isArray(runs)) return [];
260
276
  if (status) {
261
277
  if (!isValidStatus(status)) return [];
@@ -280,7 +296,7 @@ function listRuns({ limit, status } = {}) {
280
296
  */
281
297
  function getRunsForWorkItem(wi) {
282
298
  if (!wi) return [];
283
- const runs = shared.safeJsonArr(qaRunsPath());
299
+ const runs = _readRuns();
284
300
  return runs
285
301
  .filter(r => r && r.workItemId === wi)
286
302
  .sort((a, b) => {
@@ -303,7 +319,7 @@ function getRunsForWorkItem(wi) {
303
319
  function setRunWorkItemId(id, workItemId) {
304
320
  if (!id) return null;
305
321
  let captured = null;
306
- mutateJsonFileLocked(qaRunsPath(), (runs) => {
322
+ mutateQaRuns((runs) => {
307
323
  if (!Array.isArray(runs)) runs = [];
308
324
  const run = runs.find(r => r && r.id === id);
309
325
  if (run) {
@@ -311,10 +327,80 @@ function setRunWorkItemId(id, workItemId) {
311
327
  captured = run;
312
328
  }
313
329
  return runs;
314
- }, { defaultValue: [] });
330
+ });
315
331
  return captured;
316
332
  }
317
333
 
334
+ // W-mpxp90xs000i5732 — sandbox check for run ids reaching deleteQaRun /
335
+ // artifact path operations. Mirrors dashboard.js#_qaIsSafeSegment: rejects
336
+ // empty / overlong / nulls / separators / .. / drive letters. Defense in
337
+ // depth — createRun's uid-suffix is already safe, but operator-driven
338
+ // DELETE endpoints take ids from URL paths, so every callsite re-validates.
339
+ function _isSafeRunId(id) {
340
+ if (!id || typeof id !== 'string') return false;
341
+ if (id.length > 128) return false;
342
+ if (id.indexOf('\0') >= 0) return false;
343
+ if (id.indexOf('/') >= 0 || id.indexOf('\\') >= 0) return false;
344
+ if (id.indexOf('..') >= 0) return false;
345
+ if (/^[a-zA-Z]:/.test(id)) return false;
346
+ return true;
347
+ }
348
+
349
+ /**
350
+ * Hard-delete a terminal-status run record and best-effort remove its
351
+ * artifact directory. Result envelope (matches PR #3008 shape so the
352
+ * dashboard DELETE /api/qa/runs/<id> handler stays generic):
353
+ *
354
+ * { ok: true, id, artifactsRemoved: bool }
355
+ * { ok: false, error: 'invalid_id' | 'not_found' | 'not_terminal',
356
+ * currentStatus?: string }
357
+ *
358
+ * Caller must cancel/complete the run via the lifecycle helpers before
359
+ * deletion — running/pending runs are refused so an operator can't vacuum a
360
+ * live QA agent out from under itself. The artifact rmSync runs OUTSIDE
361
+ * the lock, with a path.resolve + startsWith() belt-and-braces sandbox.
362
+ *
363
+ * @param {string} id - run id (must pass _isSafeRunId)
364
+ * @returns {object} envelope (see above)
365
+ */
366
+ function deleteQaRun(id) {
367
+ if (!_isSafeRunId(id)) return { ok: false, error: 'invalid_id' };
368
+ let captured = null;
369
+ let notFound = false;
370
+ let notTerminal = null;
371
+ mutateQaRuns((runs) => {
372
+ if (!Array.isArray(runs)) return runs;
373
+ const idx = runs.findIndex(r => r && r.id === id);
374
+ if (idx < 0) { notFound = true; return runs; }
375
+ const run = runs[idx];
376
+ if (!TERMINAL_STATUSES.has(run.status)) {
377
+ notTerminal = run.status;
378
+ return runs;
379
+ }
380
+ captured = run;
381
+ runs.splice(idx, 1);
382
+ return runs;
383
+ });
384
+ if (notFound) return { ok: false, error: 'not_found' };
385
+ if (notTerminal !== null) return { ok: false, error: 'not_terminal', currentStatus: notTerminal };
386
+ // Sandbox check + best-effort rm outside the lock.
387
+ let artifactsRemoved = false;
388
+ try {
389
+ const dir = qaArtifactsDirForRun(id);
390
+ const base = path.resolve(qaArtifactsDir());
391
+ const resolved = path.resolve(dir);
392
+ if (resolved.startsWith(base + path.sep) || resolved === base) {
393
+ if (fs.existsSync(resolved)) {
394
+ fs.rmSync(resolved, { recursive: true, force: true });
395
+ artifactsRemoved = true;
396
+ }
397
+ }
398
+ } catch (e) {
399
+ log('warn', `qa-runs: rm artifacts dir failed for ${id}: ${e.message}`);
400
+ }
401
+ return { ok: true, id, artifactsRemoved, run: captured };
402
+ }
403
+
318
404
  /**
319
405
  * Cheap summary helper for the dashboard /api/status fast-state slice. Returns
320
406
  * `{ total, sig }` without sorting the run list — the sidebar activity-dot
@@ -322,14 +408,12 @@ function setRunWorkItemId(id, workItemId) {
322
408
  * (b) when any status flips. `sig` joins id:status across all current runs;
323
409
  * any change to either advances the string, which is enough signal for the
324
410
  * counter. We deliberately skip the sort that listRuns() does because this
325
- * runs on every fast-state rebuild (~every 10 s + every mtime-tracked write),
326
- * and an O(N log N) sort on a 2 k-entry file would eat into the event-loop
327
- * budget that CC SSE isolation (W-mpehsyhv) depends on.
411
+ * runs on every fast-state rebuild (~every 10 s + every mtime-tracked write).
328
412
  *
329
413
  * @returns {{ total: number, sig: string }}
330
414
  */
331
415
  function summarizeRunsForStatus() {
332
- const runs = shared.safeJsonArr(qaRunsPath());
416
+ const runs = _readRuns();
333
417
  if (!Array.isArray(runs) || runs.length === 0) return { total: 0, sig: '' };
334
418
  let sig = '';
335
419
  for (const r of runs) {
@@ -350,6 +434,8 @@ module.exports = {
350
434
  markRunning,
351
435
  completeRun,
352
436
  setRunWorkItemId,
437
+ deleteQaRun,
438
+ _isSafeRunId,
353
439
  getRun,
354
440
  listRuns,
355
441
  getRunsForWorkItem,
@@ -28,8 +28,10 @@
28
28
  *
29
29
  * (awaiting-approval ──▶ drafting on /edit; drafting ──▶ executing on auto mode.)
30
30
  *
31
- * Concurrency: every mutation goes through mutateJsonFileLocked per the repo
32
- * convention. Callbacks are synchronous and never await. Slow filesystem work
31
+ * Concurrency: every mutation goes through shared.mutateQaSessions, which
32
+ * routes to small-state-store.applyQaSessionsMutation inside a SQLite
33
+ * transaction (BEGIN IMMEDIATE) plus a content-hash divergence rehydrate on
34
+ * read. Callbacks are synchronous and never await. Slow filesystem work
33
35
  * (qa-tests/<id>/ scaffolding, dispatch enqueueing) runs OUTSIDE the lock.
34
36
  *
35
37
  * Path-traversal hardening: sessionId is generated by createSession() with a
@@ -38,14 +40,28 @@
38
40
  * a filesystem path or a session lookup. Mirrors engine/qa-runbooks.js
39
41
  * _isSafeId (PR #2694 review feedback).
40
42
  *
41
- * State file: engine/qa-sessions.json (single file, all sessions across all
42
- * projects), capped at QA_SESSIONS_MAX_RECORDS via createSession-time rotation.
43
+ * Persistence: SQLite (engine/state.db, `qa_sessions` table) as source of
44
+ * truth since Phase 8 (PRD: migrate-qa-state-to-sqlite). The legacy JSON
45
+ * sidecar at engine/qa-sessions.json is dual-written on every mutation when
46
+ * `engine.qaDualWriteJson` is true (default). Capped at
47
+ * QA_SESSIONS_MAX_RECORDS via createSession-time rotation.
43
48
  */
44
49
 
45
50
  const fs = require('fs');
46
51
  const path = require('path');
47
52
  const shared = require('./shared');
48
- const { mutateJsonFileLocked, uid, ts, log } = shared;
53
+ const { mutateQaSessions, uid, ts, log } = shared;
54
+
55
+ // Internal helper — read sessions via the small-state store when available
56
+ // (SQL → JSON fallback inside the store), else direct JSON read.
57
+ function _readSessions() {
58
+ try {
59
+ const store = require('./small-state-store');
60
+ const arr = store.readQaSessions();
61
+ if (Array.isArray(arr)) return arr;
62
+ } catch { /* fall back */ }
63
+ return _readSessions();
64
+ }
49
65
 
50
66
  // Cap engine/qa-sessions.json. Sessions cost more than runs (3 WIs, a
51
67
  // managed-spawn, artifacts) so the operational steady state is meaningfully
@@ -422,7 +438,7 @@ function createSession(spec) {
422
438
  completedAt: null,
423
439
  };
424
440
 
425
- mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
441
+ mutateQaSessions( (sessions) => {
426
442
  if (!Array.isArray(sessions)) sessions = [];
427
443
  sessions.push(session);
428
444
  // Rotation: drop oldest-by-createdAt when over cap. Cheap because it runs
@@ -448,7 +464,7 @@ function createSession(spec) {
448
464
  */
449
465
  function getSession(id) {
450
466
  if (!_isSafeSessionId(id)) return null;
451
- const sessions = shared.safeJsonArr(qaSessionsPath());
467
+ const sessions = _readSessions();
452
468
  return sessions.find(s => s && s.id === id) || null;
453
469
  }
454
470
 
@@ -520,7 +536,7 @@ function getSessionTestFile(sessionId) {
520
536
  * List sessions, newest first, optionally filtered by state, capped by limit.
521
537
  */
522
538
  function listSessions({ limit, state } = {}) {
523
- let sessions = shared.safeJsonArr(qaSessionsPath());
539
+ let sessions = _readSessions();
524
540
  if (!Array.isArray(sessions)) return [];
525
541
  if (state) {
526
542
  if (!isValidState(state)) return [];
@@ -547,7 +563,7 @@ function setSessionWorkItem(id, phase, workItemId) {
547
563
  if (!_isSafeSessionId(id)) return null;
548
564
  if (!Object.values(SESSION_PHASE).includes(phase)) return null;
549
565
  let captured = null;
550
- mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
566
+ mutateQaSessions( (sessions) => {
551
567
  if (!Array.isArray(sessions)) sessions = [];
552
568
  const session = sessions.find(s => s && s.id === id);
553
569
  if (session) {
@@ -571,7 +587,7 @@ function setSessionWorkItem(id, phase, workItemId) {
571
587
  function setSessionQaRunId(id, qaRunId) {
572
588
  if (!_isSafeSessionId(id)) return null;
573
589
  let captured = null;
574
- mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
590
+ mutateQaSessions( (sessions) => {
575
591
  if (!Array.isArray(sessions)) sessions = [];
576
592
  const session = sessions.find(s => s && s.id === id);
577
593
  if (session) {
@@ -607,7 +623,7 @@ function transitionSession(id, toState, patch = {}) {
607
623
 
608
624
  let captured = null;
609
625
  let transitionError = null;
610
- mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
626
+ mutateQaSessions( (sessions) => {
611
627
  if (!Array.isArray(sessions)) sessions = [];
612
628
  const session = sessions.find(s => s && s.id === id);
613
629
  if (!session) { transitionError = new Error(`qa-sessions: session not found: ${id}`); return sessions; }
@@ -961,7 +977,7 @@ function queueSetup(sessionId, opts = {}) {
961
977
  for (const [key, wiId] of Object.entries(builtWiIds)) {
962
978
  if (nextStatus[key]) nextStatus[key] = { ...nextStatus[key], wiId };
963
979
  }
964
- mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
980
+ mutateQaSessions( (sessions) => {
965
981
  if (!Array.isArray(sessions)) sessions = [];
966
982
  const s = sessions.find(x => x && x.id === sessionId);
967
983
  if (s) {
@@ -1031,7 +1047,7 @@ function handleSetupComplete(sessionId, opts = {}) {
1031
1047
  // Update this project's entry under lock. Capture the merged map so we
1032
1048
  // can decide on a transition without re-reading.
1033
1049
  let mergedStatus = null;
1034
- mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
1050
+ mutateQaSessions( (sessions) => {
1035
1051
  if (!Array.isArray(sessions)) sessions = [];
1036
1052
  const s = sessions.find(x => x && x.id === sessionId);
1037
1053
  if (!s || !s.setupStatus) return sessions;
@@ -1280,7 +1296,7 @@ function dismissSession(sessionId, { summary } = {}) {
1280
1296
  * @returns {{ total: number, sig: string }}
1281
1297
  */
1282
1298
  function summarizeSessionsForStatus() {
1283
- const sessions = shared.safeJsonArr(qaSessionsPath());
1299
+ const sessions = _readSessions();
1284
1300
  if (!Array.isArray(sessions) || sessions.length === 0) return { total: 0, sig: '' };
1285
1301
  let sig = '';
1286
1302
  for (const s of sessions) {
package/engine/shared.js CHANGED
@@ -2072,6 +2072,13 @@ const ENGINE_DEFAULTS = {
2072
2072
  ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
2073
2073
  maxBudgetUsd: undefined, // fleet USD ceiling for --max-budget-usd (per-agent override: agents.<id>.maxBudgetUsd). Honors 0 via ?? so a literal cap of $0 works
2074
2074
  disableModelDiscovery: false, // skip runtime.listModels() REST calls fleet-wide (settings UI falls back to free-text)
2075
+ // Phase 8 (qa-runs.json + qa-sessions.json → SQL). When true, every QA
2076
+ // mutation also writes the JSON sidecar at engine/qa-runs.json and
2077
+ // engine/qa-sessions.json for back-compat with external tooling. SQL is
2078
+ // always the source of truth — flip this OFF once operators trust the
2079
+ // SQL store and want to drop the dual-write cost. Sunset tracked in
2080
+ // docs/deprecated.json (qa-json-sidecars).
2081
+ qaDualWriteJson: true,
2075
2082
  // W-mpmwxkrw000872ec — dashboard global font-size scale. Drives the
2076
2083
  // [data-font-size] attribute on <html> via the inline bootstrap script in
2077
2084
  // layout.html (localStorage fast path) and is reconciled from the server
@@ -2979,6 +2986,77 @@ const mutateWorktreePool = _smallStateMutator({
2979
2986
  defaultValue: () => ({ entries: [] }),
2980
2987
  });
2981
2988
 
2989
+ /**
2990
+ * Phase 8 — QA state mutators. Both qa-runs and qa-sessions are top-level
2991
+ * JSON arrays (mutator receives the array; mutates in place or returns a
2992
+ * replacement). The store diffs by `id`. Falls back to mutateJsonFileLocked
2993
+ * on SQLite failure so a node:sqlite-broken install keeps recording QA
2994
+ * state. The JSON mirror is gated by `engine.qaDualWriteJson` (default true)
2995
+ * — turn off once operators trust SQL as the source of truth.
2996
+ */
2997
+ function _qaDualWriteEnabled() {
2998
+ try {
2999
+ const cfg = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
3000
+ const flag = cfg && cfg.engine && cfg.engine.qaDualWriteJson;
3001
+ if (flag === false) return false;
3002
+ return true;
3003
+ } catch { return true; }
3004
+ }
3005
+
3006
+ function _qaMutator({ filePath, applyMutation, mirror, topic }) {
3007
+ return (mutator) => {
3008
+ // Cross-process serialization: SQLite's BEGIN IMMEDIATE already
3009
+ // serializes the table write across processes, but the JSON sidecar
3010
+ // mirror is best-effort and can race — two concurrent processes can
3011
+ // each snapshot SQL and write the JSON, and an older snapshot can
3012
+ // overwrite a newer one. Wrap SQL apply + mirror in a single file
3013
+ // lock on the JSON path's .lock file so multi-process writers
3014
+ // serialize through the mirror, matching the legacy fully-locked
3015
+ // semantics that test/unit/qa-runs.test.js asserts.
3016
+ return withFileLock(filePath + '.lock', () => {
3017
+ try {
3018
+ const store = require('./small-state-store');
3019
+ const { wrote, result } = store[applyMutation]((arr) => {
3020
+ if (!Array.isArray(arr)) arr = [];
3021
+ return mutator(arr) || arr;
3022
+ });
3023
+ if (wrote) {
3024
+ if (_qaDualWriteEnabled()) {
3025
+ try { store[mirror](filePath); } catch { /* mirror best-effort */ }
3026
+ }
3027
+ try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3028
+ }
3029
+ return result;
3030
+ } catch (e) {
3031
+ if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) throw e;
3032
+ return mutateJsonFileLocked(filePath, (data) => {
3033
+ if (!Array.isArray(data)) data = [];
3034
+ return mutator(data) || data;
3035
+ }, {
3036
+ defaultValue: [],
3037
+ onWrote: () => {
3038
+ try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
3039
+ },
3040
+ });
3041
+ }
3042
+ }, { timeoutMs: 5000, retries: 3 });
3043
+ };
3044
+ }
3045
+
3046
+ const mutateQaRuns = _qaMutator({
3047
+ filePath: path.join(MINIONS_DIR, 'engine', 'qa-runs.json'),
3048
+ applyMutation: 'applyQaRunsMutation',
3049
+ mirror: '_mirrorQaRunsJson',
3050
+ topic: 'qa_runs',
3051
+ });
3052
+
3053
+ const mutateQaSessions = _qaMutator({
3054
+ filePath: path.join(MINIONS_DIR, 'engine', 'qa-sessions.json'),
3055
+ applyMutation: 'applyQaSessionsMutation',
3056
+ mirror: '_mirrorQaSessionsJson',
3057
+ topic: 'qa_sessions',
3058
+ });
3059
+
2982
3060
  /**
2983
3061
  * Route a watches mutation through the SQL store. Same shape as
2984
3062
  * mutateWorkItems / mutatePullRequests: mutator receives the watches
@@ -5769,7 +5847,7 @@ module.exports = {
5769
5847
  runtimeConfigWarnings,
5770
5848
  projectWorkSourceWarnings,
5771
5849
  backfillProjectWorkSourceDefaults,
5772
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5850
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
5773
5851
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
5774
5852
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
5775
5853
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
@@ -521,6 +521,314 @@ function _mirrorWorktreePoolJson(filePath) {
521
521
  } catch { /* mirror best-effort */ }
522
522
  }
523
523
 
524
+ // ─── qa_runs ───────────────────────────────────────────────────────────────
525
+ // Shape: [ {id, runbookId, targetName, project, workItemId, status, startedAt,
526
+ // completedAt, createdAt, artifacts, summary, ...}, ... ]
527
+ // SQL: row per id, extracted query columns + JSON blob `data`.
528
+ // Pattern: mirrors watches-store (top-level array, id-keyed diff).
529
+
530
+ let _qaRunsHash = null;
531
+
532
+ function _hydrateQaRuns(db) {
533
+ const fp = _resolveFilePath('qa-runs.json');
534
+ const raw = _readJson(fp) || [];
535
+ if (!Array.isArray(raw)) return;
536
+ db.prepare('DELETE FROM qa_runs').run();
537
+ const now = Date.now();
538
+ const ins = db.prepare(`
539
+ INSERT INTO qa_runs (id, runbook_id, target_name, project, work_item_id, status, started_at, completed_at, created_at, data)
540
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
541
+ ON CONFLICT(id) DO NOTHING
542
+ `);
543
+ for (const run of raw) {
544
+ if (!run || !run.id) continue;
545
+ ins.run(
546
+ String(run.id),
547
+ String(run.runbookId || ''),
548
+ String(run.targetName || ''),
549
+ run.project || null,
550
+ run.workItemId || null,
551
+ String(run.status || 'pending'),
552
+ _toMs(run.startedAt),
553
+ _toMs(run.completedAt),
554
+ _toMs(run.createdAt) || now,
555
+ JSON.stringify(run),
556
+ );
557
+ }
558
+ }
559
+
560
+ function _resyncQaRunsIfDiverged(db) {
561
+ const fp = _resolveFilePath('qa-runs.json');
562
+ const currentHash = _fileContentHash(fp);
563
+ if (currentHash == null) return;
564
+ if (_qaRunsHash != null && currentHash === _qaRunsHash) return;
565
+ if (_qaRunsHash == null) {
566
+ const sqlHas = db.prepare('SELECT 1 FROM qa_runs LIMIT 1').get();
567
+ if (sqlHas) { _qaRunsHash = currentHash; return; }
568
+ }
569
+ _hydrateQaRuns(db);
570
+ _qaRunsHash = currentHash;
571
+ }
572
+
573
+ function _readQaRunsFromSqlOnly(db) {
574
+ const rows = db.prepare('SELECT data FROM qa_runs ORDER BY created_at, rowid').all();
575
+ const out = [];
576
+ for (const row of rows) {
577
+ try { out.push(JSON.parse(row.data)); } catch { /* skip malformed */ }
578
+ }
579
+ return out;
580
+ }
581
+
582
+ function readQaRuns() {
583
+ const { getDb } = require('./db');
584
+ let db;
585
+ try { db = getDb(); }
586
+ catch { return _readJson(_resolveFilePath('qa-runs.json')) || []; }
587
+ _resyncQaRunsIfDiverged(db);
588
+ const out = _readQaRunsFromSqlOnly(db);
589
+ if (out.length === 0) {
590
+ const fallback = _readJson(_resolveFilePath('qa-runs.json'));
591
+ if (Array.isArray(fallback) && fallback.length > 0) return fallback;
592
+ return [];
593
+ }
594
+ return out;
595
+ }
596
+
597
+ function applyQaRunsMutation(mutator) {
598
+ const { getDb, withTransaction } = require('./db');
599
+ let db;
600
+ try { db = getDb(); }
601
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
602
+
603
+ return withTransaction(db, () => {
604
+ _resyncQaRunsIfDiverged(db);
605
+ const before = _readQaRunsFromSqlOnly(db);
606
+ const beforeSnap = JSON.parse(JSON.stringify(before));
607
+ const next = mutator(before);
608
+ const after = (next === undefined || next === null)
609
+ ? before
610
+ : (Array.isArray(next) ? next : before);
611
+
612
+ const indexById = (arr) => {
613
+ const out = new Map();
614
+ for (const r of arr) {
615
+ if (r && r.id) out.set(String(r.id), r);
616
+ }
617
+ return out;
618
+ };
619
+ const beforeMap = indexById(beforeSnap);
620
+ const afterMap = indexById(after);
621
+
622
+ const now = Date.now();
623
+ const upsert = db.prepare(`
624
+ INSERT INTO qa_runs (id, runbook_id, target_name, project, work_item_id, status, started_at, completed_at, created_at, data)
625
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
626
+ ON CONFLICT(id) DO UPDATE SET
627
+ runbook_id = excluded.runbook_id,
628
+ target_name = excluded.target_name,
629
+ project = excluded.project,
630
+ work_item_id = excluded.work_item_id,
631
+ status = excluded.status,
632
+ started_at = excluded.started_at,
633
+ completed_at = excluded.completed_at,
634
+ created_at = excluded.created_at,
635
+ data = excluded.data
636
+ `);
637
+ const del = db.prepare('DELETE FROM qa_runs WHERE id = ?');
638
+ let wrote = false;
639
+ for (const [id, run] of afterMap) {
640
+ const prev = beforeMap.get(id);
641
+ if (!prev || JSON.stringify(prev) !== JSON.stringify(run)) {
642
+ upsert.run(
643
+ id,
644
+ String(run.runbookId || ''),
645
+ String(run.targetName || ''),
646
+ run.project || null,
647
+ run.workItemId || null,
648
+ String(run.status || 'pending'),
649
+ _toMs(run.startedAt),
650
+ _toMs(run.completedAt),
651
+ _toMs(run.createdAt) || now,
652
+ JSON.stringify(run),
653
+ );
654
+ wrote = true;
655
+ }
656
+ }
657
+ for (const [id] of beforeMap) {
658
+ if (!afterMap.has(id)) { del.run(id); wrote = true; }
659
+ }
660
+ return { wrote, result: after };
661
+ });
662
+ }
663
+
664
+ function _mirrorQaRunsJson(filePath) {
665
+ try {
666
+ const shared = require('./shared');
667
+ const { getDb } = require('./db');
668
+ const arr = _readQaRunsFromSqlOnly(getDb());
669
+ const target = filePath || _resolveFilePath('qa-runs.json');
670
+ shared.safeWrite(target, arr);
671
+ const h = _fileContentHash(target);
672
+ if (h != null) _qaRunsHash = h;
673
+ } catch { /* mirror best-effort */ }
674
+ }
675
+
676
+ // ─── qa_sessions ───────────────────────────────────────────────────────────
677
+ // Shape: [ {id, state, spec:{...}, primaryProject, coServices, setupStatus,
678
+ // workItems, managedSpawnName, qaRunId, testFile, summary,
679
+ // failureClass, error, createdAt, updatedAt, completedAt, ...}, ... ]
680
+ // SQL: row per id, extracted query columns + JSON blob.
681
+
682
+ let _qaSessionsHash = null;
683
+
684
+ function _qaSessionPrimaryProject(session) {
685
+ if (!session || typeof session !== 'object') return null;
686
+ if (session.primaryProject) return String(session.primaryProject);
687
+ if (session.spec && session.spec.project) return String(session.spec.project);
688
+ if (session.spec && Array.isArray(session.spec.projects) && session.spec.projects[0]) {
689
+ return String(session.spec.projects[0]);
690
+ }
691
+ return null;
692
+ }
693
+
694
+ function _hydrateQaSessions(db) {
695
+ const fp = _resolveFilePath('qa-sessions.json');
696
+ const raw = _readJson(fp) || [];
697
+ if (!Array.isArray(raw)) return;
698
+ db.prepare('DELETE FROM qa_sessions').run();
699
+ const now = Date.now();
700
+ const ins = db.prepare(`
701
+ INSERT INTO qa_sessions (id, state, primary_project, qa_run_id, created_at, updated_at, completed_at, data)
702
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
703
+ ON CONFLICT(id) DO NOTHING
704
+ `);
705
+ for (const session of raw) {
706
+ if (!session || !session.id) continue;
707
+ ins.run(
708
+ String(session.id),
709
+ String(session.state || 'pending'),
710
+ _qaSessionPrimaryProject(session),
711
+ session.qaRunId || null,
712
+ _toMs(session.createdAt) || now,
713
+ _toMs(session.updatedAt) || _toMs(session.createdAt) || now,
714
+ _toMs(session.completedAt),
715
+ JSON.stringify(session),
716
+ );
717
+ }
718
+ }
719
+
720
+ function _resyncQaSessionsIfDiverged(db) {
721
+ const fp = _resolveFilePath('qa-sessions.json');
722
+ const currentHash = _fileContentHash(fp);
723
+ if (currentHash == null) return;
724
+ if (_qaSessionsHash != null && currentHash === _qaSessionsHash) return;
725
+ if (_qaSessionsHash == null) {
726
+ const sqlHas = db.prepare('SELECT 1 FROM qa_sessions LIMIT 1').get();
727
+ if (sqlHas) { _qaSessionsHash = currentHash; return; }
728
+ }
729
+ _hydrateQaSessions(db);
730
+ _qaSessionsHash = currentHash;
731
+ }
732
+
733
+ function _readQaSessionsFromSqlOnly(db) {
734
+ const rows = db.prepare('SELECT data FROM qa_sessions ORDER BY created_at, rowid').all();
735
+ const out = [];
736
+ for (const row of rows) {
737
+ try { out.push(JSON.parse(row.data)); } catch { /* skip */ }
738
+ }
739
+ return out;
740
+ }
741
+
742
+ function readQaSessions() {
743
+ const { getDb } = require('./db');
744
+ let db;
745
+ try { db = getDb(); }
746
+ catch { return _readJson(_resolveFilePath('qa-sessions.json')) || []; }
747
+ _resyncQaSessionsIfDiverged(db);
748
+ const out = _readQaSessionsFromSqlOnly(db);
749
+ if (out.length === 0) {
750
+ const fallback = _readJson(_resolveFilePath('qa-sessions.json'));
751
+ if (Array.isArray(fallback) && fallback.length > 0) return fallback;
752
+ return [];
753
+ }
754
+ return out;
755
+ }
756
+
757
+ function applyQaSessionsMutation(mutator) {
758
+ const { getDb, withTransaction } = require('./db');
759
+ let db;
760
+ try { db = getDb(); }
761
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
762
+
763
+ return withTransaction(db, () => {
764
+ _resyncQaSessionsIfDiverged(db);
765
+ const before = _readQaSessionsFromSqlOnly(db);
766
+ const beforeSnap = JSON.parse(JSON.stringify(before));
767
+ const next = mutator(before);
768
+ const after = (next === undefined || next === null)
769
+ ? before
770
+ : (Array.isArray(next) ? next : before);
771
+
772
+ const indexById = (arr) => {
773
+ const out = new Map();
774
+ for (const s of arr) {
775
+ if (s && s.id) out.set(String(s.id), s);
776
+ }
777
+ return out;
778
+ };
779
+ const beforeMap = indexById(beforeSnap);
780
+ const afterMap = indexById(after);
781
+
782
+ const now = Date.now();
783
+ const upsert = db.prepare(`
784
+ INSERT INTO qa_sessions (id, state, primary_project, qa_run_id, created_at, updated_at, completed_at, data)
785
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
786
+ ON CONFLICT(id) DO UPDATE SET
787
+ state = excluded.state,
788
+ primary_project = excluded.primary_project,
789
+ qa_run_id = excluded.qa_run_id,
790
+ created_at = excluded.created_at,
791
+ updated_at = excluded.updated_at,
792
+ completed_at = excluded.completed_at,
793
+ data = excluded.data
794
+ `);
795
+ const del = db.prepare('DELETE FROM qa_sessions WHERE id = ?');
796
+ let wrote = false;
797
+ for (const [id, session] of afterMap) {
798
+ const prev = beforeMap.get(id);
799
+ if (!prev || JSON.stringify(prev) !== JSON.stringify(session)) {
800
+ upsert.run(
801
+ id,
802
+ String(session.state || 'pending'),
803
+ _qaSessionPrimaryProject(session),
804
+ session.qaRunId || null,
805
+ _toMs(session.createdAt) || now,
806
+ _toMs(session.updatedAt) || _toMs(session.createdAt) || now,
807
+ _toMs(session.completedAt),
808
+ JSON.stringify(session),
809
+ );
810
+ wrote = true;
811
+ }
812
+ }
813
+ for (const [id] of beforeMap) {
814
+ if (!afterMap.has(id)) { del.run(id); wrote = true; }
815
+ }
816
+ return { wrote, result: after };
817
+ });
818
+ }
819
+
820
+ function _mirrorQaSessionsJson(filePath) {
821
+ try {
822
+ const shared = require('./shared');
823
+ const { getDb } = require('./db');
824
+ const arr = _readQaSessionsFromSqlOnly(getDb());
825
+ const target = filePath || _resolveFilePath('qa-sessions.json');
826
+ shared.safeWrite(target, arr);
827
+ const h = _fileContentHash(target);
828
+ if (h != null) _qaSessionsHash = h;
829
+ } catch { /* mirror best-effort */ }
830
+ }
831
+
524
832
  // ─── Test seam ─────────────────────────────────────────────────────────────
525
833
 
526
834
  function _resetAllForTest() {
@@ -531,11 +839,15 @@ function _resetAllForTest() {
531
839
  db.exec('DELETE FROM pipeline_runs');
532
840
  db.exec('DELETE FROM managed_processes');
533
841
  db.exec('DELETE FROM worktree_pool');
842
+ try { db.exec('DELETE FROM qa_runs'); } catch { /* migration not applied */ }
843
+ try { db.exec('DELETE FROM qa_sessions'); } catch { /* migration not applied */ }
534
844
  } catch { /* not initialized */ }
535
845
  _scheduleRunsHash = null;
536
846
  _pipelineRunsHash = null;
537
847
  _managedProcessesHash = null;
538
848
  _worktreePoolHash = null;
849
+ _qaRunsHash = null;
850
+ _qaSessionsHash = null;
539
851
  }
540
852
 
541
853
  module.exports = {
@@ -555,6 +867,14 @@ module.exports = {
555
867
  readWorktreePool,
556
868
  applyWorktreePoolMutation,
557
869
  _mirrorWorktreePoolJson,
870
+ // qa_runs
871
+ readQaRuns,
872
+ applyQaRunsMutation,
873
+ _mirrorQaRunsJson,
874
+ // qa_sessions
875
+ readQaSessions,
876
+ applyQaSessionsMutation,
877
+ _mirrorQaSessionsJson,
558
878
  // test seam
559
879
  _resetAllForTest,
560
880
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2108",
3
+ "version": "0.1.2109",
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"