@yemi33/minions 0.1.2107 → 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.
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,