@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/dashboard/js/qa.js +64 -2
- package/dashboard/js/render-prd.js +21 -1
- package/dashboard/js/settings.js +2 -0
- package/dashboard.js +41 -0
- package/docs/dead-code-audit-retractions.md +23 -0
- package/docs/deprecated.json +29 -21
- package/docs/design-state-storage.md +1 -1
- package/docs/qa-runbook-lifecycle.md +36 -0
- package/engine/db/migrations/009-qa.js +140 -0
- package/engine/qa-runs.js +118 -32
- package/engine/qa-sessions.js +30 -14
- package/engine/shared.js +79 -1
- package/engine/small-state-store.js +320 -0
- package/package.json +1 -1
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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 {
|
|
36
|
+
const { mutateQaRuns, uid, ts, log } = shared;
|
|
30
37
|
|
|
31
|
-
// Cap qa-runs.json so the file doesn't grow unboundedly
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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,
|
package/engine/qa-sessions.js
CHANGED
|
@@ -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
|
|
32
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|