@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/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 = [];
|
|
@@ -766,6 +766,20 @@ async function prdItemEdit(source, itemId) {
|
|
|
766
766
|
d.meta?.item?.id === itemId && d.meta?.item?.sourcePlan === source
|
|
767
767
|
);
|
|
768
768
|
|
|
769
|
+
// W-mpxridf8001852f1 — resolve work-item type for the detail-modal pill via
|
|
770
|
+
// documented fallback chain: structured PRD field, materialized WI, dispatch
|
|
771
|
+
// sidecar, then prose `Type: <label>` prefix in the description (covers
|
|
772
|
+
// legacy unmaterialized items like prd/minions-2026-06-03.json).
|
|
773
|
+
let typeValue = item.type || wi?.type || completedEntry?.meta?.item?.type || '';
|
|
774
|
+
if (!typeValue && item.description) {
|
|
775
|
+
const m = /^Type:\s*([a-z-]+)\b/i.exec(item.description.trimStart());
|
|
776
|
+
if (m) typeValue = m[1];
|
|
777
|
+
}
|
|
778
|
+
typeValue = (typeValue || '').toString().toLowerCase();
|
|
779
|
+
const typePillHtml = typeValue
|
|
780
|
+
? '<span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:3px;background:var(--surface-alt,var(--surface));border:1px solid var(--border);color:var(--muted);text-transform:lowercase">' + escHtml(typeValue) + '</span>'
|
|
781
|
+
: '';
|
|
782
|
+
|
|
769
783
|
// Build completion summary section
|
|
770
784
|
let completionHtml = '';
|
|
771
785
|
const isDone = item.status === 'done';
|
|
@@ -786,6 +800,7 @@ async function prdItemEdit(source, itemId) {
|
|
|
786
800
|
completionHtml = '<div style="background:var(--surface);border:1px solid var(--border);border-left:3px solid ' + statusColor + ';border-radius:4px;padding:10px 12px;margin-bottom:12px">' +
|
|
787
801
|
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">' +
|
|
788
802
|
(wi ? '<a href="#" onclick="event.preventDefault();event.stopPropagation();openWorkItemDetail(\'' + escHtml(wi.id) + '\')" title="Open ' + escHtml(wi.id) + ' detail" style="font-size:11px;font-weight:700;color:' + statusColor + ';text-decoration:none;cursor:pointer" onmouseover="this.style.textDecoration=\'underline\'" onmouseout="this.style.textDecoration=\'none\'">' + statusLabel + ' →</a>' : '<span style="font-size:11px;font-weight:700;color:' + statusColor + '">' + statusLabel + '</span>') +
|
|
803
|
+
typePillHtml +
|
|
789
804
|
(agent ? '<span style="font-size:11px;color:var(--muted)">by ' + escHtml(agent) + '</span>' : '') +
|
|
790
805
|
(completedAt ? '<span style="font-size:10px;color:var(--muted)">' + escHtml(completedAt.slice(0, 16).replace('T', ' ')) + '</span>' : '') +
|
|
791
806
|
'</div>' +
|
|
@@ -795,8 +810,13 @@ async function prdItemEdit(source, itemId) {
|
|
|
795
810
|
'</div>';
|
|
796
811
|
}
|
|
797
812
|
|
|
813
|
+
const standaloneTypePillHtml = (!completionHtml && typePillHtml)
|
|
814
|
+
? '<div style="margin-bottom:10px">' + typePillHtml + '</div>'
|
|
815
|
+
: '';
|
|
816
|
+
|
|
798
817
|
const html = '<div style="padding:8px 0">' +
|
|
799
818
|
completionHtml +
|
|
819
|
+
standaloneTypePillHtml +
|
|
800
820
|
'<label style="font-size:11px;color:var(--muted);display:block;margin-bottom:4px">Name</label>' +
|
|
801
821
|
'<input id="prd-edit-name" value="' + escHtml(item.name || '') + '" style="width:100%;padding:6px 10px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;margin-bottom:10px">' +
|
|
802
822
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
|
|
@@ -831,7 +851,7 @@ async function prdItemEdit(source, itemId) {
|
|
|
831
851
|
'</div>';
|
|
832
852
|
|
|
833
853
|
document.getElementById('modal-title').textContent = item.id + ' — ' + (item.name || '').slice(0, 60);
|
|
834
|
-
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() or renderMd() (fields: PRD item name/description, source, item id, agent, completion summary, PR links)
|
|
854
|
+
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() or renderMd() (fields: PRD item name/description, source, item id, agent, completion summary, PR links, work-item type pill)
|
|
835
855
|
document.getElementById('modal-body').innerHTML = html;
|
|
836
856
|
document.getElementById('modal-body').style.fontFamily = '';
|
|
837
857
|
document.getElementById('modal-body').style.whiteSpace = '';
|
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 —
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Dead-code audit retractions
|
|
2
|
+
|
|
3
|
+
**Future dead-code audit dispatches MUST read this file before re-asserting any of the retracted findings below.**
|
|
4
|
+
|
|
5
|
+
This file records false-positive findings from past dead-code audits so subsequent weekly audits do not re-cite them. Each entry names the claim verbatim, the correction, and the source lines that disprove it.
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"id": "reviewFeedbackSourceMatches-not-dead-exported",
|
|
10
|
+
"retractedOn": "2026-06-03",
|
|
11
|
+
"retractedBy": "Dead Code Review meeting (Lambert, Dallas, Ripley unanimous)",
|
|
12
|
+
"claim": "engine/lifecycle.js reviewFeedbackSourceMatches is still dead-exported",
|
|
13
|
+
"correction": "The function is NOT exported (verify: grep `reviewFeedbackSourceMatches` in engine/lifecycle.js module.exports block at :5161-5220 returns zero hits) and IS live-called intra-file at engine/lifecycle.js:2939 inside createReviewFeedbackForAuthor.",
|
|
14
|
+
"sources": [
|
|
15
|
+
"engine/lifecycle.js:2883 (definition)",
|
|
16
|
+
"engine/lifecycle.js:2939 (live caller inside createReviewFeedbackForAuthor)",
|
|
17
|
+
"engine/lifecycle.js:5161-5220 (module.exports block — function absent)",
|
|
18
|
+
"knowledge/project-notes/2026-06-03-ripley-meeting-conclusion-dead-code-review-2026-06-03.md",
|
|
19
|
+
"notes/inbox/ripley-2026-06-03-1012.md (R2 retraction)"
|
|
20
|
+
],
|
|
21
|
+
"futureGuidance": "Do NOT remove. Do NOT 'un-export' — there is no export. The function is a 33-line scope-filter helper that is intentionally callable separately for testability/naming; inlining it into its sole caller worsens readability of the inbox-scan loop and is not a dead-code action."
|
|
22
|
+
}
|
|
23
|
+
```
|
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",
|
|
@@ -9,34 +17,34 @@
|
|
|
9
17
|
},
|
|
10
18
|
{
|
|
11
19
|
"id": "legacy-done-aliases",
|
|
12
|
-
"location": "engine/cleanup.js:
|
|
20
|
+
"location": "engine/cleanup.js:1165-1166",
|
|
13
21
|
"constants": ["LEGACY_DONE_ALIASES", "LEGACY_NEEDS_REVIEW_STATUS"],
|
|
14
22
|
"reason": "Read-side tolerance: cleanup sweep auto-migrates four obsolete work-item / PRD status strings ('in-pr', 'implemented', 'complete', 'needs-human-review') to the canonical 'done' / 'failed' values. The aliases are no longer written anywhere in the engine; the constants exist only to repair stale on-disk values from old engine versions.",
|
|
15
23
|
"targetRemovalDate": null,
|
|
16
|
-
"notes": "Keep indefinitely until telemetry / a sweep log shows zero migrations performed for 30 consecutive days across all known projects (work-items.json + prd/*.json). At that point the constants and both _migrateLegacyItem branches in engine/cleanup.js (definitions at :
|
|
24
|
+
"notes": "Keep indefinitely until telemetry / a sweep log shows zero migrations performed for 30 consecutive days across all known projects (work-items.json + prd/*.json). At that point the constants and both _migrateLegacyItem branches in engine/cleanup.js (definitions at :1165-1166; usage at :1168-1183 for work items and :1269-1272 for PRD missing_features) can be deleted. Total cost on disk today: 4 strings."
|
|
17
25
|
},
|
|
18
26
|
{
|
|
19
27
|
"id": "completion-fallback-parsers",
|
|
20
28
|
"description": "parseStructuredCompletion and parseCompletionFieldSummary in engine/lifecycle.js",
|
|
21
29
|
"file": "engine/lifecycle.js",
|
|
22
|
-
"lines": "
|
|
30
|
+
"lines": "3075, 3273",
|
|
23
31
|
"telemetryGate": "_engine.completionFallbacks must read 0 (both fenced and summary counters) across sweepWindowDays starting from sweepStartDate",
|
|
24
32
|
"sweepWindowDays": 14,
|
|
25
33
|
"sweepStartDate": "2026-05-27",
|
|
26
34
|
"sweepRationale": "14 days matches the dead-code audit cadence (one full weekly audit cycle plus a buffer week). Policy decision pending confirmation in open-question #1 of the 2026-05-27 Bug Audit Review meeting conclusion — if the human teammate prefers a different cadence, repin this field and the matching enforcingSweepWindowTest fixture.",
|
|
27
35
|
"enforcingTest": "test/unit/completion-fallback-telemetry.test.js:217-234",
|
|
28
36
|
"enforcingSweepWindowTest": "test/unit/completion-fallback-sweep-window.test.js",
|
|
29
|
-
"notes": "Do NOT set removedAt until telemetry confirms zero usage across the sweepWindowDays from sweepStartDate. The follow-up code-removal PR (dropping parseStructuredCompletion at engine/lifecycle.js:
|
|
37
|
+
"notes": "Do NOT set removedAt until telemetry confirms zero usage across the sweepWindowDays from sweepStartDate. The follow-up code-removal PR (dropping parseStructuredCompletion at engine/lifecycle.js:3075, parseCompletionFieldSummary at :3273, and the gated fallback at :4240-4266) is dispatched separately once the window is observed clean."
|
|
30
38
|
},
|
|
31
39
|
{
|
|
32
40
|
"id": "config-claude-binary-override",
|
|
33
41
|
"description": "Legacy `config.claude.binary` runtime-resolution override. Older `minions init` versions persisted a `config.claude.binary` field that pointed the Claude runtime at a specific binary path. The runtime adapter still honors this override on every Claude spawn; the engine emits a `deprecated-config-claude` warning at config-load time but does NOT delete the override, so the override branch in claude.js is load-bearing for any install that still carries a non-default value.",
|
|
34
42
|
"code": [
|
|
35
|
-
{ "file": "engine/runtimes/claude.js", "lines": "82-
|
|
36
|
-
{ "file": "engine/shared.js", "lines": "
|
|
37
|
-
{ "file": "engine/shared.js", "lines": "
|
|
43
|
+
{ "file": "engine/runtimes/claude.js", "lines": "82-86", "note": "resolveBinary() respects config.claude.binary on every Claude spawn (probes npm package dir or direct binary path)" },
|
|
44
|
+
{ "file": "engine/shared.js", "lines": "2482-2496", "note": "warnings.push({ id: 'deprecated-config-claude' }) — surface-only; never deletes the override" },
|
|
45
|
+
{ "file": "engine/shared.js", "lines": "3120-3124", "note": "DEFAULT_CLAUDE.binary baseline that the warning + prune logic compares against" }
|
|
38
46
|
],
|
|
39
|
-
"removalGate": "Telemetry: the `deprecated-config-claude` warning emitted at engine/shared.js:
|
|
47
|
+
"removalGate": "Telemetry: the `deprecated-config-claude` warning emitted at engine/shared.js:2492-2495 must report zero hits across all known engines for >=30 consecutive days, AND a sweep of every persisted config.json must show no `config.claude.binary` value that diverges from DEFAULT_CLAUDE.binary. Only then is the override branch in resolveBinary() (engine/runtimes/claude.js:82-86) removable, along with the `_deprecatedConfigClaudeFields` membership for `binary` and the warning emitter at engine/shared.js:2482-2496.",
|
|
40
48
|
"targetRemovalDate": null,
|
|
41
49
|
"notes": "Do NOT set targetRemovalDate — removal must be signal-gated, not calendar-gated. This entry is paired with `prune-default-claude-config`: the prune strips DEFAULT-matching values but intentionally preserves user overrides, which is precisely why the override branch in claude.js stays reachable. Removing the override before the prune entry's gate clears would silently break installs that still rely on a custom binary path."
|
|
42
50
|
},
|
|
@@ -44,15 +52,15 @@
|
|
|
44
52
|
"id": "legacy-cc-model-migration",
|
|
45
53
|
"description": "applyLegacyCcModelMigration: in-memory shim that promotes legacy `engine.ccModel` to `engine.defaultModel` when defaultModel is unset, so single-model installs keep working after the runtime fleet refactor (P-3b8e5f1d). No on-disk rewrite — the persisted config.json continues to carry the legacy `ccModel` field. Called unconditionally on every engine boot from cli.start().",
|
|
46
54
|
"code": [
|
|
47
|
-
{ "file": "engine/shared.js", "lines": "
|
|
48
|
-
{ "file": "engine/cli.js", "lines": "
|
|
55
|
+
{ "file": "engine/shared.js", "lines": "2407", "note": "applyLegacyCcModelMigration definition (function signature + once-per-process flag via _resetLegacyCcModelMigrationFlag)" },
|
|
56
|
+
{ "file": "engine/cli.js", "lines": "477", "note": "Boot call site inside start(); wrapped in try/catch so a migration failure cannot block startup" },
|
|
49
57
|
{ "file": "CLAUDE.md", "lines": "316", "note": "Architectural documentation calling out the in-memory promotion contract" },
|
|
50
58
|
{ "file": "docs/slim-ux/concepts.md", "lines": "671", "note": "Surface-level concepts doc cross-reference" },
|
|
51
|
-
{ "file": "test/unit.test.js", "lines": "
|
|
59
|
+
{ "file": "test/unit.test.js", "lines": "19801", "note": "Source-inspection test pinning the CLAUDE.md description against the function name" },
|
|
52
60
|
{ "file": "test/unit/runtime-fleet-helpers.test.js", "lines": "209-254", "note": "Behavioural unit tests (promotion, no-op when defaultModel set, no-op when ccModel unset, empty-string handling, once-only logging, null-safety)" },
|
|
53
61
|
{ "file": "test/unit/runtime-fleet-helpers.test.js", "lines": "500-505", "note": "Source-inspection test pinning the cli.js boot call site" }
|
|
54
62
|
],
|
|
55
|
-
"removalGate": "Telemetry: the once-per-boot deprecation log line emitted by applyLegacyCcModelMigration (via the injected logger at engine/shared.js:
|
|
63
|
+
"removalGate": "Telemetry: the once-per-boot deprecation log line emitted by applyLegacyCcModelMigration (via the injected logger at engine/shared.js:2407) must show zero promotion events across all known engines for >=30 consecutive days, AND a sweep of every persisted config.json must confirm no `engine.ccModel` field remains. Once both conditions hold, removal deletes the function + _resetLegacyCcModelMigrationFlag export at engine/shared.js:4977, the boot call at engine/cli.js:477, the CLAUDE.md:316 paragraph and docs/slim-ux/concepts.md:671 reference, and the tests at runtime-fleet-helpers.test.js:209-254 + :500-505 + unit.test.js:19801.",
|
|
56
64
|
"targetRemovalDate": null,
|
|
57
65
|
"notes": "Do NOT set targetRemovalDate — gating is signal-based. The function is silent on no-op (returns false without logging), so the meaningful telemetry signal is the absence of the promotion log line over the sweep window, NOT the absence of function invocations (cli.js calls it every boot regardless)."
|
|
58
66
|
},
|
|
@@ -77,18 +85,18 @@
|
|
|
77
85
|
"id": "prune-default-claude-config",
|
|
78
86
|
"description": "pruneDefaultClaudeConfig: active sanitizer that strips generated `config.claude.{binary,outputFormat,allowedTools,permissionMode}` defaults from persisted config.json so the `deprecated-config-claude` warning stops tripping on stale defaults left by older `minions init` versions. Sub-cluster of `config-claude-binary-override` — the prune deliberately preserves non-default user overrides (binary/allowedTools), which is what keeps the override branch in engine/runtimes/claude.js load-bearing.",
|
|
79
87
|
"code": [
|
|
80
|
-
{ "file": "engine/shared.js", "lines": "
|
|
81
|
-
{ "file": "engine/shared.js", "lines": "
|
|
82
|
-
{ "file": "dashboard.js", "lines": "
|
|
83
|
-
{ "file": "dashboard.js", "lines": "
|
|
84
|
-
{ "file": "dashboard.js", "lines": "
|
|
85
|
-
{ "file": "dashboard.js", "lines": "
|
|
88
|
+
{ "file": "engine/shared.js", "lines": "3126", "note": "pruneDefaultClaudeConfig definition: preserves non-default binary/allowedTools, always strips permissionMode + outputFormat" },
|
|
89
|
+
{ "file": "engine/shared.js", "lines": "5673", "note": "Module export entry" },
|
|
90
|
+
{ "file": "dashboard.js", "lines": "202", "note": "Called when loading config for the dashboard UI" },
|
|
91
|
+
{ "file": "dashboard.js", "lines": "9116", "note": "Called during first config save handler" },
|
|
92
|
+
{ "file": "dashboard.js", "lines": "9331", "note": "Called during second config save path" },
|
|
93
|
+
{ "file": "dashboard.js", "lines": "9450", "note": "Called during third config save path" },
|
|
86
94
|
{ "file": "minions.js", "lines": "385", "note": "Called during CLI init/update flow" },
|
|
87
|
-
{ "file": "test/unit.test.js", "lines": "
|
|
95
|
+
{ "file": "test/unit.test.js", "lines": "2260-2303", "note": "Behavioural unit tests (default strip, override preservation, outputFormat unconditional strip) + dashboard call-site source pin" },
|
|
88
96
|
{ "file": "test/unit/runtime-fleet-helpers.test.js", "lines": "546", "note": "Source-inspection test pinning the dashboard handler call site" }
|
|
89
97
|
],
|
|
90
|
-
"removalGate": "Telemetry: pruneDefaultClaudeConfig must return false (no mutation) for every call across all known engines for >=30 consecutive days (add an `_engine.pruneDefaultClaudeConfigStrips` counter if needed to observe this), AND the parent `config-claude-binary-override` entry must have already cleared its own gate. The dependency is strict: removing the prune while users still rely on the override branch would surface the `deprecated-config-claude` warning on every stale generated default. Once both conditions hold, removal is the function definition (engine/shared.js:
|
|
98
|
+
"removalGate": "Telemetry: pruneDefaultClaudeConfig must return false (no mutation) for every call across all known engines for >=30 consecutive days (add an `_engine.pruneDefaultClaudeConfigStrips` counter if needed to observe this), AND the parent `config-claude-binary-override` entry must have already cleared its own gate. The dependency is strict: removing the prune while users still rely on the override branch would surface the `deprecated-config-claude` warning on every stale generated default. Once both conditions hold, removal is the function definition (engine/shared.js:3126), the export at :5673, all 5 call sites (dashboard.js:202, :9116, :9331, :9450; minions.js:385), and the tests at unit.test.js:2260-2303 + runtime-fleet-helpers.test.js:546.",
|
|
91
99
|
"targetRemovalDate": null,
|
|
92
|
-
"notes": "Do NOT set targetRemovalDate — gating is signal-based AND ordered. This entry MUST NOT be removed before `config-claude-binary-override` clears its gate, otherwise installs with stale defaults will flood the deprecation channel until their next config save. The 5 call sites form a complete coverage net: load (dashboard.js:
|
|
100
|
+
"notes": "Do NOT set targetRemovalDate — gating is signal-based AND ordered. This entry MUST NOT be removed before `config-claude-binary-override` clears its gate, otherwise installs with stale defaults will flood the deprecation channel until their next config save. The 5 call sites form a complete coverage net: load (dashboard.js:202 + minions.js:385) + save (dashboard.js:9116/9331/9450), so any code path that touches config.json runs the sanitizer."
|
|
93
101
|
}
|
|
94
102
|
]
|
|
@@ -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
|
+
};
|