@yemi33/minions 0.1.2108 → 0.1.2110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -344,19 +344,81 @@ function _qaRenderRunRow(run) {
344
344
  } else if (agentId) {
345
345
  liveLink = '<a href="#" class="qa-run-link" onclick="event.preventDefault();qaOpenRunAgent(\'\',this.dataset.agent);" data-agent="' + escHtml(agentId) + '">' + escHtml(linkLabel) + '</a>';
346
346
  }
347
+ // W-mpxp90xs000i5732 — per-row Delete button, terminal-only. The engine
348
+ // also enforces terminal-only with a 409 (defense in depth); rendering
349
+ // the button conditionally just keeps the UI honest about what's safe
350
+ // to click.
351
+ let deleteBtn = '';
352
+ if (_qaIsTerminalStatus(status)) {
353
+ deleteBtn = '<button class="qa-btn-ghost qa-run-delete-btn" ' +
354
+ 'data-run-id="' + escHtml(id) + '" ' +
355
+ 'onclick="qaDeleteRun(this.dataset.runId)">Delete</button>';
356
+ }
357
+ const actionsHtml = (liveLink || deleteBtn)
358
+ ? '<span class="qa-run-actions">' + liveLink + deleteBtn + '</span>'
359
+ : '';
347
360
  const artifactsHtml = _qaRenderArtifactPreviews(id, run.artifacts || []);
348
- return '<div class="qa-run-row">' +
361
+ return '<div class="qa-run-row" data-run-id="' + escHtml(id) + '">' +
349
362
  '<div class="qa-run-head">' +
350
363
  '<span class="qa-run-status ' + escHtml(statusClass) + '">' + escHtml(status) + '</span>' +
351
364
  '<span class="qa-run-name">' + escHtml(runbook) + '</span>' +
352
365
  (target ? '<span class="qa-run-target">→ <code>' + escHtml(target) + '</code></span>' : '') +
353
366
  (ts ? '<span class="qa-run-ts">' + escHtml(String(ts).slice(0, 19).replace('T', ' ')) + '</span>' : '') +
354
- (liveLink ? '<span class="qa-run-actions">' + liveLink + '</span>' : '') +
367
+ actionsHtml +
355
368
  '</div>' +
356
369
  (artifactsHtml ? '<div class="qa-run-artifacts">' + artifactsHtml + '</div>' : '') +
357
370
  '</div>';
358
371
  }
359
372
 
373
+ // W-mpxp90xs000i5732 — terminal status set kept in sync with
374
+ // engine/qa-runs.js#TERMINAL_STATUSES. The Delete button only renders for
375
+ // runs in one of these states; the engine's deleteQaRun returns 409 for
376
+ // anything else (defense in depth).
377
+ function _qaIsTerminalStatus(status) {
378
+ if (!status || typeof status !== 'string') return false;
379
+ const s = status.toLowerCase();
380
+ return s === 'passed' || s === 'failed' || s === 'errored';
381
+ }
382
+
383
+ // W-mpxp90xs000i5732 — per-row Delete handler. Confirms with the user,
384
+ // fires DELETE /api/qa/runs/<id>, optimistic-removes the row on 200, and
385
+ // surfaces errors via showToast(). Does NOT use alert() post-API per the
386
+ // WI's UX contract.
387
+ async function qaDeleteRun(id) {
388
+ if (!id) return;
389
+ if (!confirm('Delete QA run ' + id + ' and its artifacts? This cannot be undone.')) return;
390
+ let res, json;
391
+ try {
392
+ res = await fetch('/api/qa/runs/' + encodeURIComponent(id), {
393
+ method: 'DELETE',
394
+ });
395
+ } catch (e) {
396
+ if (typeof showToast === 'function') {
397
+ showToast('cmd-toast', 'Delete QA run failed: ' + (e && e.message || String(e)), false);
398
+ }
399
+ return;
400
+ }
401
+ try { json = await res.json(); } catch { json = {}; }
402
+ if (res.ok && json && json.ok) {
403
+ const sel = (window.CSS && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'));
404
+ const row = document.querySelector('.qa-run-row[data-run-id="' + sel + '"]');
405
+ if (row && row.parentNode) row.parentNode.removeChild(row);
406
+ if (typeof loadQaRuns === 'function') loadQaRuns();
407
+ if (typeof showToast === 'function') {
408
+ const msg = json.artifactsRemoved
409
+ ? 'QA run deleted (artifacts removed).'
410
+ : 'QA run deleted.';
411
+ showToast('cmd-toast', msg, true);
412
+ }
413
+ return;
414
+ }
415
+ const errMsg = (json && (json.error + (json.currentStatus ? ' (status=' + json.currentStatus + ')' : '')))
416
+ || ('HTTP ' + (res ? res.status : '?'));
417
+ if (typeof showToast === 'function') {
418
+ showToast('cmd-toast', 'Delete QA run failed: ' + errMsg, false);
419
+ }
420
+ }
421
+
360
422
  function _qaRenderArtifactPreviews(runId, artifacts) {
361
423
  if (!runId || !Array.isArray(artifacts) || !artifacts.length) return '';
362
424
  const parts = [];
@@ -404,6 +404,7 @@ async function openSettings() {
404
404
  '<div class="settings-stack">' +
405
405
  settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
406
406
  settingsToggle('Disable model discovery', 'set-disableModelDiscovery', !!e.disableModelDiscovery, 'Skip /api/runtimes/<name>/models REST calls fleet-wide. Settings UI falls back to free-text.') +
407
+ settingsToggle('QA: dual-write JSON sidecars', 'set-qaDualWriteJson', e.qaDualWriteJson !== false, 'Keep mirroring qa-runs.json and qa-sessions.json on every mutation alongside the SQLite source of truth. Default ON. Disable only once external tooling has been migrated off the JSON files — disabling drops the rollback path back to the JSON store.') +
407
408
  '</div>';
408
409
 
409
410
  // Section registry — order is intentional (Runtime + Auto-fix surface first
@@ -880,6 +881,7 @@ async function saveSettings() {
880
881
  copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
881
882
  maxBudgetUsd: (document.getElementById('set-maxBudgetUsd')?.value ?? '').trim(),
882
883
  disableModelDiscovery: !!document.getElementById('set-disableModelDiscovery')?.checked,
884
+ qaDualWriteJson: !!document.getElementById('set-qaDualWriteJson')?.checked,
883
885
  // W-mpmwxkrw000872ec — global font-size scale. Allowlist validation
884
886
  // (and clamp messaging) lives server-side in handleSettingsUpdate.
885
887
  fontSize: document.getElementById('set-fontSize')?.value || 'small',
package/dashboard.js CHANGED
@@ -10345,6 +10345,46 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10345
10345
  }
10346
10346
  }
10347
10347
 
10348
+ // W-mpxp90xs000i5732 — DELETE /api/qa/runs/<id>. Hard-delete: removes the
10349
+ // run from engine/qa-runs.json AND best-effort wipes the on-disk artifact
10350
+ // directory under engine/qa-artifacts/<runId>/. Terminal-only — refuses
10351
+ // pending/running runs with 409. Caller must cancel/complete the run via
10352
+ // the lifecycle helpers before deletion. CSRF/Origin gate is applied by
10353
+ // the server dispatcher for all mutating methods (see MUTATING_METHODS).
10354
+ function handleQaRunsDelete(req, res, match) {
10355
+ try {
10356
+ const qa = require('./engine/qa-runs');
10357
+ const id = decodeURIComponent(match[1] || '');
10358
+ if (!_qaIsSafeSegment(id)) {
10359
+ return jsonReply(res, 400, { error: 'invalid run id' }, req);
10360
+ }
10361
+ const result = qa.deleteQaRun(id);
10362
+ if (result && result.ok) {
10363
+ return jsonReply(res, 200, {
10364
+ ok: true,
10365
+ id,
10366
+ artifactsRemoved: !!result.artifactsRemoved,
10367
+ }, req);
10368
+ }
10369
+ const err = result && result.error;
10370
+ if (err === 'not_found') {
10371
+ return jsonReply(res, 404, { error: 'not_found' }, req);
10372
+ }
10373
+ if (err === 'not_terminal') {
10374
+ return jsonReply(res, 409, {
10375
+ error: 'not_terminal',
10376
+ currentStatus: result.currentStatus,
10377
+ }, req);
10378
+ }
10379
+ if (err === 'invalid_id') {
10380
+ return jsonReply(res, 400, { error: 'invalid run id' }, req);
10381
+ }
10382
+ return jsonReply(res, 500, { error: 'qa-runs delete returned unknown result' }, req);
10383
+ } catch (e) {
10384
+ return jsonReply(res, 500, { error: e.message }, req);
10385
+ }
10386
+ }
10387
+
10348
10388
  function handleQaArtifact(req, res, match) {
10349
10389
  try {
10350
10390
  const qa = require('./engine/qa-runs');
@@ -11052,6 +11092,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11052
11092
  // traversal attempts return 403, missing files return 404.
11053
11093
  { method: 'GET', path: '/api/qa/runs', desc: 'List QA validation runs (newest first). Optional ?limit=N and ?status=pending|running|passed|failed|errored filters.', handler: handleQaRunsList },
11054
11094
  { method: 'GET', path: /^\/api\/qa\/runs\/([^/?]+)$/, template: '/api/qa/runs/<id>', desc: 'Fetch a single QA run record by id.', handler: handleQaRunsById },
11095
+ { method: 'DELETE', path: /^\/api\/qa\/runs\/([^/?]+)$/, template: '/api/qa/runs/<id>', desc: 'Delete a QA run by id (terminal runs only). Best-effort removes artifacts under engine/qa-artifacts/<runId>/.', destructive: true, handler: handleQaRunsDelete },
11055
11096
  { method: 'GET', path: /^\/api\/qa\/artifacts\/([^/?]+)\/([^?]+)$/, template: '/api/qa/artifacts/<runId>/<file>', desc: 'Serve a QA artifact file (image/video/log). Sandboxed to engine/qa-artifacts/; rejects path traversal with 403.', handler: handleQaArtifact },
11056
11097
  // QA Runbooks (W-mpeiwz6k0005bf34-a) — per-project test plans stored at
11057
11098
  // <MINIONS_DIR>/projects/<name>/runbooks/<id>.json. Pure persistence —
@@ -1,4 +1,12 @@
1
1
  [
2
+ {
3
+ "id": "qa-json-sidecars",
4
+ "location": "engine/small-state-store.js _mirrorQaRunsJson + _mirrorQaSessionsJson; engine/shared.js _qaMutator (mirror call); dashboard/js/settings.js set-qaDualWriteJson toggle",
5
+ "reason": "Phase 8 migration (migrate-qa-state-to-sqlite, shipped 2026-06) moved qa_runs + qa_sessions into SQLite (engine/state.db, tables qa_runs + qa_sessions) as the source of truth. The legacy JSON sidecars engine/qa-runs.json and engine/qa-sessions.json are dual-written on every mutation while operators migrate external tooling and dashboards off the JSON files. Gated by engine.qaDualWriteJson (default true).",
6
+ "deprecated": "2026-06-03",
7
+ "targetRemovalDate": "2026-09-03",
8
+ "notes": "Safe to remove on or after 2026-09-03 (90 days post-ship, ~3 release windows) once: (1) operator telemetry / dashboard Settings shows engine.qaDualWriteJson flipped false on the main fleet; (2) no external readers (CI scripts, dashboards, ops runbooks) reference the JSON sidecars directly. Removal scope: delete _mirrorQaRunsJson + _mirrorQaSessionsJson + their exports in engine/small-state-store.js; drop the _qaDualWriteEnabled() + mirror call in engine/shared.js#_qaMutator; remove qaDualWriteJson from ENGINE_DEFAULTS and from the Settings toggle in dashboard/js/settings.js; remove the divergence-rehydrate branch in _resyncQaRunsIfDiverged / _resyncQaSessionsIfDiverged (the JSON files no longer exist to diverge against)."
9
+ },
2
10
  {
3
11
  "id": "config-poll-key-migration",
4
12
  "location": "engine/queries.js:126-163",
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Author: Rebecca (Architect) | Date: 2026-04-07 | Status: **Accepted — implementation in progress**
4
4
 
5
- > **Implementation status (as of 2026-05):** The `node:sqlite` recommendation in §3 has been adopted ahead of schedule. Phases 0–7 have shipped (events, dispatches, work_items, pull_requests, logs, metrics, watches, schedule_runs + pipeline_runs + managed_processes + worktree_pool — see `CHANGELOG.md`). The SQLite schema lives under `engine/db/migrations/` and the singleton opens `engine/state.db` in WAL mode. The "Phase 2: estimated Node 26 LTS" timeline in §3 is now historical context; treat sections 1–3 as design rationale rather than a forward plan.
5
+ > **Implementation status (as of 2026-06):** The `node:sqlite` recommendation in §3 has been adopted ahead of schedule. Phases 0–8 have shipped (events, dispatches, work_items, pull_requests, logs, metrics, watches, schedule_runs + pipeline_runs + managed_processes + worktree_pool, and qa_runs + qa_sessions — see `CHANGELOG.md`). The SQLite schema lives under `engine/db/migrations/` and the singleton opens `engine/state.db` in WAL mode. Phase 8 added the first opt-out toggle for the JSON sidecars (`engine.qaDualWriteJson`, default true) — when ops trust SQL as the source of truth for QA state, flip it false to halve write I/O on hot loops. The "Phase 2: estimated Node 26 LTS" timeline in §3 is now historical context; treat sections 1–3 as design rationale rather than a forward plan.
6
6
 
7
7
  ## Executive Summary
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  A fast, hands-on walkthrough for new contributors. Follow the eight steps below in order. Each step is independent enough that you can stop, take a break, and resume without losing state.
4
4
 
5
- > **Prerequisites:** Node.js 18+, Git, and a working Claude Code CLI (`npm install -g @anthropic-ai/claude-code`) authenticated against your Anthropic API key or Claude Max subscription. See the top-level [README.md](../README.md#prerequisites) for the full list.
5
+ > **Prerequisites:** Node.js 22.5+ (required by `package.json#engines` for the `node:sqlite` runtime backing the state DB), Git, and a working Claude Code CLI (`npm install -g @anthropic-ai/claude-code`) authenticated against your Anthropic API key or Claude Max subscription. See the top-level [README.md](../README.md#prerequisites) for the full list.
6
6
 
7
7
  > **Screenshots:** This walkthrough is intentionally text-only on the first pass. If you want annotated dashboard screenshots, run the [`capture-demos`](../README.md#dashboard) skill after Step 5; it drives Playwright over the running dashboard and writes images you can drop alongside each section.
8
8
 
@@ -15,7 +15,7 @@ You only need to clone if you intend to modify Minions itself. End users normall
15
15
  ```bash
16
16
  git clone https://github.com/yemi33/minions.git ~/minions-dev
17
17
  cd ~/minions-dev
18
- npm install # installs dev tooling (Playwright); engine itself has zero deps
18
+ npm install # installs dev tooling (Playwright) + the lone runtime dep (@azure-devops/mcp); engine is otherwise built on Node built-ins
19
19
  ```
20
20
 
21
21
  You should now have:
@@ -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
+ };