@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.
- package/dashboard/js/qa.js +64 -2
- package/dashboard/js/settings.js +2 -0
- package/dashboard.js +41 -0
- package/docs/deprecated.json +8 -0
- 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/dashboard/js/qa.js
CHANGED
|
@@ -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
|
-
|
|
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 = [];
|
package/dashboard/js/settings.js
CHANGED
|
@@ -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 —
|
package/docs/deprecated.json
CHANGED
|
@@ -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-
|
|
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
|
-
*
|
|
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,
|
|
@@ -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.
|
|
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"
|