@yemi33/minions 0.1.2105 → 0.1.2106

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.
@@ -1,24 +1,31 @@
1
- // dashboard/js/qa.js — QA tab UI (W-mpeiwz6k0005bf34-d).
1
+ // dashboard/js/qa.js — QA tab UI.
2
2
  //
3
- // Three sections:
4
- // 1. Targets slim dedup'd list of /api/managed-processes + /api/keep-processes
5
- // with a health badge and a link back to /engine (full inventory tables
6
- // live on /engine; see W-mpdad3mq000m53bb do NOT mirror them here).
7
- // 2. Runbooks list from GET /api/qa/runbooks with an enabled "+ New runbook"
8
- // button that opens an inline form (name / target dropdown / steps textarea
9
- // / expected-artifacts repeater) wired to POST /api/qa/runbooks. Each row
10
- // has a "Run" button that POSTs to /api/qa/runbooks/run.
11
- // 3. Runs list from GET /api/qa/runs?limit=50, polled every 5s while the
12
- // QA page is active. Each row links to the live agent stream and renders
13
- // inline artifact previews via /api/qa/artifacts/<runId>/<file>
3
+ // Originally W-mpeiwz6k0005bf34-d shipped this as a three-section tab with
4
+ // inline forms for "Start QA Session" and "+ New runbook". W-mpxp7a3e000g0a49
5
+ // (this rewrite) demoted the page to passive monitoring: session and runbook
6
+ // CREATION now flow through Command Center; this page only renders what is
7
+ // happening and exposes per-row operational controls.
8
+ //
9
+ // Sections (top bottom):
10
+ // 1. Active QA Sessions non-terminal sessions only, with a CC-pitch card
11
+ // at the top and a collapsed disclosure for terminal sessions (last 10).
12
+ // Per-card controls: approve / edit / cancel / dismiss / kill.
13
+ // 2. Live Targets slim dedup'd list of /api/managed-processes +
14
+ // /api/keep-processes with a health badge and a link to /engine
15
+ // (W-mpdad3mq000m53bb — do NOT mirror the full inventory here).
16
+ // 3. Recent Runs — list from GET /api/qa/runs?limit=20, polled every 5s
17
+ // while the QA page is active. Each row links to the live agent stream
18
+ // and renders inline artifact previews via /api/qa/artifacts/<runId>/<file>
14
19
  // (screenshots as <img>, videos as <video>, logs as 40-line text preview
15
20
  // with a "View full" link). No direct filesystem paths are exposed —
16
21
  // artifact URLs always go through /api/qa/artifacts/.
22
+ // 4. Validation Runbooks — collapsed disclosure with a small CC-pitch and
23
+ // the runbook list. Each row has a Run + Delete button.
17
24
  //
18
- // The polling interval is cleared on page navigation via the switchPage
19
- // wrapper below (matches the same pattern keep_processes / managed-spawn
20
- // uses on /engine). The wrapper ALSO closes any open managed-log SSE so a
21
- // modal opened from /engine doesn't keep streaming after the user navigates.
25
+ // The polling interval is cleared on page navigation via the PAGE_LEAVE_HOOKS
26
+ // in state.js (W-mpgb0xa7000d90d4). closeManagedLog is invoked from the same
27
+ // hook so a modal opened from /engine doesn't keep streaming after the user
28
+ // navigates.
22
29
 
23
30
  let _qaRunsPollInterval = null;
24
31
  let _qaTargetsCache = [];
@@ -39,6 +46,22 @@ function _startQaRunsPoll() {
39
46
  _qaRunsPollInterval = setInterval(loadQaRuns, 5000);
40
47
  }
41
48
 
49
+ // W-mpxp7a3e000g0a49 — open Command Center drawer + focus the composer.
50
+ // toggleCommandCenter() flips _ccOpen, so re-invoking when already open
51
+ // would close it; guard against that and just focus the input instead.
52
+ function qaFocusCommandCenter() {
53
+ try {
54
+ const alreadyOpen = (typeof _ccOpen !== 'undefined') ? !!_ccOpen
55
+ : ((typeof window !== 'undefined') && !!window._ccOpen);
56
+ if (!alreadyOpen && typeof toggleCommandCenter === 'function') {
57
+ toggleCommandCenter();
58
+ return; // toggleCommandCenter focuses #cc-input on open
59
+ }
60
+ const input = document.getElementById('cc-input');
61
+ if (input) input.focus();
62
+ } catch (_e) { /* never let a focus helper crash the page */ }
63
+ }
64
+
42
65
  // ── Section 1: Targets ─────────────────────────────────────────────────────
43
66
  async function loadQaTargets() {
44
67
  const root = document.getElementById('qa-targets-content');
@@ -176,6 +199,7 @@ async function loadQaRunbooks() {
176
199
  }
177
200
  } catch (e) { err = e; }
178
201
  _qaRunbooksCache = items;
202
+ _qaUpdateRunbooksDisclosureCount(items.length);
179
203
 
180
204
  if (err) {
181
205
  // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: err.message)
@@ -187,7 +211,7 @@ async function loadQaRunbooks() {
187
211
  }
188
212
  if (!items.length) {
189
213
  const frag = document.createRange().createContextualFragment(
190
- '<p class="empty">No runbooks yet. Click <strong>+ New runbook</strong> to create one.</p>'
214
+ '<p class="empty">No runbooks saved yet.</p>'
191
215
  );
192
216
  root.replaceChildren(frag);
193
217
  return;
@@ -204,7 +228,10 @@ async function loadQaRunbooks() {
204
228
  artifactsCount + ' expected artifact' + (artifactsCount === 1 ? '' : 's') +
205
229
  '</div>' +
206
230
  '</div>' +
207
- '<button class="qa-btn-primary qa-runbook-run-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaRunRunbook(this.dataset.runbookId)">Run</button>' +
231
+ '<div class="qa-runbook-actions" style="display:flex;gap:var(--space-2)">' +
232
+ '<button class="qa-btn-primary qa-runbook-run-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaRunRunbook(this.dataset.runbookId)">Run</button>' +
233
+ '<button class="qa-btn-ghost qa-btn-danger qa-runbook-delete-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaDeleteRunbook(this.dataset.runbookId)">Delete</button>' +
234
+ '</div>' +
208
235
  '</div>';
209
236
  }).join('');
210
237
  // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: runbook id, name, target)
@@ -212,93 +239,27 @@ async function loadQaRunbooks() {
212
239
  root.replaceChildren(frag);
213
240
  }
214
241
 
215
- function qaShowNewRunbookForm() {
216
- const wrap = document.getElementById('qa-runbook-form-wrap');
217
- if (!wrap) return;
218
- wrap.style.display = 'block';
219
- // Make sure target dropdown reflects the current target cache.
220
- _qaPopulateTargetDropdown();
221
- const name = document.getElementById('qa-runbook-name');
222
- if (name) name.focus();
223
- }
224
-
225
- function qaHideNewRunbookForm() {
226
- const wrap = document.getElementById('qa-runbook-form-wrap');
227
- if (!wrap) return;
228
- wrap.style.display = 'none';
229
- const msg = document.getElementById('qa-runbook-form-msg');
230
- if (msg) msg.textContent = '';
231
- }
232
-
233
- function qaAddArtifactRow() {
234
- const wrap = document.getElementById('qa-runbook-artifacts');
235
- if (!wrap) return;
236
- const row = document.createElement('div');
237
- row.className = 'qa-artifact-row';
238
- const input = document.createElement('input');
239
- input.type = 'text';
240
- input.className = 'qa-form-input qa-artifact-input';
241
- input.placeholder = 'log:test.log';
242
- const btn = document.createElement('button');
243
- btn.type = 'button';
244
- btn.className = 'qa-btn-ghost';
245
- btn.textContent = '−';
246
- btn.onclick = function () { qaRemoveArtifactRow(btn); };
247
- row.appendChild(input);
248
- row.appendChild(btn);
249
- wrap.appendChild(row);
250
- }
251
-
252
- function qaRemoveArtifactRow(btn) {
253
- const row = btn && btn.closest ? btn.closest('.qa-artifact-row') : null;
254
- if (!row) return;
255
- const wrap = document.getElementById('qa-runbook-artifacts');
256
- if (wrap && wrap.children.length <= 1) {
257
- // Don't delete the last row — just clear it.
258
- const inp = row.querySelector('input');
259
- if (inp) inp.value = '';
260
- return;
261
- }
262
- row.remove();
242
+ // W-mpxp7a3e000g0a49 — keep the "Show saved runbooks (N)" disclosure summary
243
+ // in sync with the runbook count after every load.
244
+ function _qaUpdateRunbooksDisclosureCount(n) {
245
+ const sum = document.getElementById('qa-runbooks-disclosure-summary');
246
+ if (sum) sum.textContent = 'Show saved runbooks (' + n + ')';
263
247
  }
264
248
 
265
- async function qaSaveRunbook() {
266
- const msg = document.getElementById('qa-runbook-form-msg');
267
- const name = (document.getElementById('qa-runbook-name') || {}).value || '';
268
- const target = (document.getElementById('qa-runbook-target') || {}).value || '';
269
- const stepsRaw = (document.getElementById('qa-runbook-steps') || {}).value || '';
270
- const artifactInputs = document.querySelectorAll('#qa-runbook-artifacts .qa-artifact-input');
271
- const expectedArtifacts = Array.from(artifactInputs)
272
- .map(function (i) { return (i.value || '').trim(); })
273
- .filter(Boolean);
274
- if (!name.trim() || !target.trim() || !stepsRaw.trim()) {
275
- if (msg) { msg.textContent = 'Name, target and steps are required.'; msg.style.color = 'var(--red)'; }
276
- return;
277
- }
278
- if (msg) { msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
249
+ async function qaDeleteRunbook(id) {
250
+ if (!id) return;
251
+ const rb = _qaRunbooksCache.find(function (r) { return (r.id || r.name) === id; });
252
+ const label = rb ? (rb.name || id) : id;
253
+ if (!confirm('Delete runbook "' + label + '"? This cannot be undone.')) return;
279
254
  try {
280
- const res = await fetch('/api/qa/runbooks', {
281
- method: 'POST',
282
- headers: { 'Content-Type': 'application/json' },
283
- body: JSON.stringify({
284
- name: name.trim(),
285
- target: target.trim(),
286
- steps: stepsRaw,
287
- expectedArtifacts,
288
- }),
289
- });
255
+ const res = await fetch('/api/qa/runbooks/' + encodeURIComponent(id), { method: 'DELETE' });
290
256
  if (!res.ok) {
291
257
  const txt = await res.text().catch(() => '');
292
258
  throw new Error('HTTP ' + res.status + (txt ? ': ' + txt.slice(0, 200) : ''));
293
259
  }
294
- if (msg) { msg.textContent = 'Saved.'; msg.style.color = 'var(--green)'; }
295
- // Reset form + reload list.
296
- const form = document.getElementById('qa-runbook-form');
297
- if (form) form.reset();
298
- qaHideNewRunbookForm();
299
260
  loadQaRunbooks();
300
261
  } catch (e) {
301
- if (msg) { msg.textContent = 'Failed to save: ' + (e.message || String(e)); msg.style.color = 'var(--red)'; }
262
+ alert('Failed to delete runbook "' + label + '": ' + (e.message || String(e)));
302
263
  }
303
264
  }
304
265
 
@@ -331,7 +292,7 @@ async function loadQaRuns() {
331
292
  let items = [];
332
293
  let err = null;
333
294
  try {
334
- const res = await fetch('/api/qa/runs?limit=50');
295
+ const res = await fetch('/api/qa/runs?limit=20');
335
296
  if (res.ok) {
336
297
  const data = await res.json();
337
298
  items = Array.isArray(data && data.items) ? data.items : (Array.isArray(data) ? data : []);
@@ -550,29 +511,16 @@ function _startQaSessionsPoll() {
550
511
  _qaSessionsPollInterval = setInterval(loadQaSessions, 3000);
551
512
  }
552
513
 
553
- // Show / hide the per-target-kind sub-input. Called from the kind dropdown
554
- // onchange so users only see the field that maps to their selected kind.
555
- function qaSessionTargetKindChanged() {
556
- const kind = document.getElementById('qa-session-target-kind');
557
- if (!kind) return;
558
- const wraps = {
559
- pr: 'qa-session-target-pr-wrap',
560
- branch: 'qa-session-target-branch-wrap',
561
- commit: 'qa-session-target-sha-wrap',
562
- current: 'qa-session-target-worktree-wrap',
563
- };
564
- for (const id of Object.values(wraps)) {
565
- const el = document.getElementById(id);
566
- if (el) el.style.display = 'none';
567
- }
568
- const showId = wraps[kind.value];
569
- if (showId) {
570
- const el = document.getElementById(showId);
571
- if (el) el.style.display = '';
572
- }
573
- }
514
+ // W-mpxp7a3e000g0a49 qaSessionTargetKindChanged removed; the inline
515
+ // "Start QA Session" form has moved to Command Center. loadQaRunners and
516
+ // loadQaProjectsSelect below are no-ops on this page (their target DOM
517
+ // elements were removed with the form) but remain exported so the
518
+ // PAGE_LAZY_LOADERS registry in state.js can still reference them by
519
+ // name without throwing.
574
520
 
575
521
  async function loadQaRunners() {
522
+ // Form removed (W-mpxp7a3e000g0a49) — dropdown no longer exists, so this
523
+ // early-returns. Kept exported because PAGE_LAZY_LOADERS.qa references it.
576
524
  const sel = document.getElementById('qa-session-runner');
577
525
  if (!sel) return;
578
526
  try {
@@ -628,8 +576,9 @@ async function loadQaProjectsSelect() {
628
576
  }
629
577
 
630
578
  async function loadQaSessions() {
631
- const root = document.getElementById('qa-sessions-content');
632
- if (!root) return;
579
+ const activeRoot = document.getElementById('qa-sessions-content');
580
+ const recentRoot = document.getElementById('qa-sessions-recent-content');
581
+ if (!activeRoot) return;
633
582
  let sessions = [];
634
583
  let err = null;
635
584
  try {
@@ -643,27 +592,46 @@ async function loadQaSessions() {
643
592
  const frag = document.createRange().createContextualFragment(
644
593
  '<p class="empty" style="color:var(--red)">Failed to load sessions: ' + escHtml(err.message || String(err)) + '</p>'
645
594
  );
646
- root.replaceChildren(frag);
595
+ activeRoot.replaceChildren(frag);
647
596
  return;
648
597
  }
649
598
 
650
599
  _qaSessionsCache = sessions;
651
- if (!sessions.length) {
600
+
601
+ // W-mpxp7a3e000g0a49 — split into active (non-terminal) + recent (terminal,
602
+ // last 10). Active list is the primary surface; terminal sessions live
603
+ // behind a collapsed disclosure to keep the page scannable.
604
+ const active = sessions.filter(s => s && !QA_SESSION_TERMINAL_STATES.has(s.state));
605
+ const terminal = sessions.filter(s => s && QA_SESSION_TERMINAL_STATES.has(s.state)).slice(0, 10);
606
+
607
+ if (!active.length) {
652
608
  const frag = document.createRange().createContextualFragment(
653
- '<p class="empty">No QA sessions yet. Use the form above to start one.</p>'
609
+ '<p class="empty">No active QA sessions.</p>'
654
610
  );
655
- root.replaceChildren(frag);
656
- _qaAfterSessionsRender();
657
- return;
611
+ activeRoot.replaceChildren(frag);
612
+ } else {
613
+ let html = '';
614
+ for (const s of active) html += _qaRenderSessionCard(s);
615
+ // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: session id, state, target sub-fields, mode, runner, error/summary/failureClass)
616
+ const frag = document.createRange().createContextualFragment(html);
617
+ activeRoot.replaceChildren(frag);
658
618
  }
659
619
 
660
- let html = '';
661
- for (const s of sessions) {
662
- html += _qaRenderSessionCard(s);
620
+ if (recentRoot) {
621
+ if (!terminal.length) {
622
+ const frag = document.createRange().createContextualFragment(
623
+ '<p class="empty">No recent sessions.</p>'
624
+ );
625
+ recentRoot.replaceChildren(frag);
626
+ } else {
627
+ let html = '';
628
+ for (const s of terminal) html += _qaRenderSessionCard(s);
629
+ // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: session id, state, target sub-fields, mode, runner, error/summary/failureClass)
630
+ const frag = document.createRange().createContextualFragment(html);
631
+ recentRoot.replaceChildren(frag);
632
+ }
663
633
  }
664
- // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: session id, state, target sub-fields, mode, runner, error/summary/failureClass)
665
- const frag = document.createRange().createContextualFragment(html);
666
- root.replaceChildren(frag);
634
+
667
635
  _qaAfterSessionsRender();
668
636
  }
669
637
 
@@ -695,11 +663,13 @@ function _qaRenderSessionCard(s) {
695
663
  const phases = _qaRenderPhaseChips(state);
696
664
 
697
665
  // Action buttons depend on session state:
698
- // - awaiting-approval → [APPROVE & RUN] [EDIT] [CANCEL]
666
+ // - awaiting-approval → [REVIEW DRAFT] [APPROVE & RUN] [EDIT] [CANCEL]
667
+ // The Review draft button comes first so users read before approving.
699
668
  // - non-terminal → footer always shows [DISMISS] [KILL SPAWN]
700
669
  // - terminal → no actions
701
670
  let actions = '';
702
671
  if (state === 'awaiting-approval') {
672
+ actions += '<button class="qa-btn-ghost" onclick="qaSessionReviewDraft(\'' + escHtml(id) + '\')">Review draft</button>';
703
673
  actions += '<button class="qa-btn-primary" onclick="qaSessionApprove(\'' + escHtml(id) + '\')">Approve &amp; run</button>';
704
674
  actions += '<button class="qa-btn-ghost" onclick="qaSessionEditPrompt(\'' + escHtml(id) + '\')">Edit</button>';
705
675
  actions += '<button class="qa-btn-ghost" onclick="qaSessionCancel(\'' + escHtml(id) + '\')">Cancel</button>';
@@ -788,71 +758,9 @@ function _qaSummarizeTarget(target) {
788
758
  }
789
759
  }
790
760
 
791
- async function qaSubmitSessionForm() {
792
- const msg = document.getElementById('qa-session-form-msg');
793
- const submit = document.getElementById('qa-session-submit');
794
- if (msg) msg.textContent = '';
795
- if (submit) submit.disabled = true;
796
-
797
- const kind = (document.getElementById('qa-session-target-kind') || {}).value;
798
- const target = { kind };
799
- if (kind === 'pr') target.prId = (document.getElementById('qa-session-target-pr-id') || {}).value || '';
800
- if (kind === 'branch') target.branch = (document.getElementById('qa-session-target-branch') || {}).value || '';
801
- if (kind === 'commit') target.sha = (document.getElementById('qa-session-target-sha') || {}).value || '';
802
- if (kind === 'current') {
803
- const wt = (document.getElementById('qa-session-target-worktree') || {}).value || '';
804
- if (wt) target.worktree = wt;
805
- }
806
- const flowsRaw = (document.getElementById('qa-session-flows') || {}).value || '';
807
- const mode = (document.getElementById('qa-session-mode') || {}).value || 'confirm';
808
- const runner = (document.getElementById('qa-session-runner') || {}).value || '';
809
- // W-mpq6xqzj000606d0 — Multi-select projects dropdown. First selected =
810
- // primary (drives DRAFT/EXECUTE); rest = co-services (dev-up only).
811
- // Empty selection = central (no project).
812
- const projectsSel = document.getElementById('qa-session-projects');
813
- const projects = projectsSel
814
- ? Array.from(projectsSel.selectedOptions || []).map(o => o.value).filter(Boolean)
815
- : [];
816
- const capture = {
817
- video: !!(document.getElementById('qa-session-capture-video') || {}).checked,
818
- screenshots: !!(document.getElementById('qa-session-capture-screenshots') || {}).checked,
819
- logs: !!(document.getElementById('qa-session-capture-logs') || {}).checked,
820
- };
821
- const body = { target, flowsRaw, mode, capture };
822
- if (runner) body.runner = runner;
823
- if (projects.length > 0) body.projects = projects;
824
-
825
- try {
826
- const res = await fetch('/api/qa/session', {
827
- method: 'POST',
828
- headers: { 'Content-Type': 'application/json' },
829
- body: JSON.stringify(body),
830
- });
831
- const json = await res.json().catch(() => ({}));
832
- if (!res.ok) {
833
- if (msg) msg.textContent = 'Error ' + res.status + ': ' + (json.error || 'unknown');
834
- if (msg) msg.style.color = 'var(--red)';
835
- return;
836
- }
837
- if (msg) {
838
- msg.textContent = 'Session ' + (json.sessionId || '') + ' queued.';
839
- msg.style.color = 'var(--green)';
840
- }
841
- // Reset only the flows (the rest is sticky for fast iteration).
842
- const flowsEl = document.getElementById('qa-session-flows');
843
- if (flowsEl) flowsEl.value = '';
844
- // Optimistic refresh + restart polling — a new non-terminal session
845
- // means the auto-stop heuristic should reactivate.
846
- _startQaSessionsPoll();
847
- } catch (e) {
848
- if (msg) {
849
- msg.textContent = 'Network error: ' + (e && e.message || String(e));
850
- msg.style.color = 'var(--red)';
851
- }
852
- } finally {
853
- if (submit) submit.disabled = false;
854
- }
855
- }
761
+ // W-mpxp7a3e000g0a49 qaSubmitSessionForm removed; creating QA sessions
762
+ // now flows through Command Center (POST /api/qa/session is still wired
763
+ // for CC and external callers, but the inline form on this page is gone).
856
764
 
857
765
  async function _qaSessionPost(url, label, body) {
858
766
  try {
@@ -904,3 +812,289 @@ function qaSessionEditPrompt(id) {
904
812
  if (!fb || !fb.trim()) return null;
905
813
  return qaSessionEdit(id, fb);
906
814
  }
815
+
816
+ // ── Review draft modal (W-mpxpvpwn000ra419) ──────────────────────────────
817
+ //
818
+ // Greenfield modal infrastructure. There is no reusable generic modal in
819
+ // dashboard/js/* today (modal-qa.js is doc-chat, not a modal shell), so the
820
+ // minimal one below is inlined here. Read-only by design — editing a draft
821
+ // continues to flow through the existing prompt()-based qaSessionEditPrompt
822
+ // path. The "Request edit" button in the modal footer just calls it.
823
+ //
824
+ // All DOM construction uses document.createElement + appendChild (and
825
+ // textContent for any caller-supplied content) so eslint-plugin-no-unsanitized
826
+ // stays happy. The only HTML-string interpolation is for static structural
827
+ // markup (icons + class names), which the lint rule explicitly allows.
828
+ //
829
+ // State invariants:
830
+ // - Idempotent open: a re-open while a modal is already mounted no-ops if
831
+ // the sessionId matches, or closes-then-reopens for a different session.
832
+ // - Close on backdrop click + Esc; both paths route through _qaModalClose
833
+ // so the keydown listener and focus restore happen exactly once.
834
+ // - Focus restores to the originating button on close (a11y).
835
+
836
+ let _qaModalState = null; // { sessionId, root, originButton, keydownHandler }
837
+
838
+ function qaSessionReviewDraft(id) {
839
+ if (!id) return null;
840
+ // Idempotent: same id → no-op; different id → close + reopen.
841
+ if (_qaModalState) {
842
+ if (_qaModalState.sessionId === id) return null;
843
+ _qaModalClose();
844
+ }
845
+ _qaInstallModalStyle();
846
+ // Capture the focused button so we can restore focus on close (a11y).
847
+ const originButton = (document.activeElement && document.activeElement.tagName === 'BUTTON')
848
+ ? document.activeElement
849
+ : null;
850
+ // Mount the shell synchronously with a "Loading…" body so the modal feels
851
+ // responsive even on slow fetches. We replace the body once the test file
852
+ // comes back from /api/qa/sessions/<id>/test.
853
+ const root = _qaBuildModalShell(id);
854
+ document.body.appendChild(root);
855
+ const keydownHandler = (e) => {
856
+ if (e.key === 'Escape') {
857
+ e.preventDefault();
858
+ _qaModalClose();
859
+ }
860
+ };
861
+ document.addEventListener('keydown', keydownHandler);
862
+ _qaModalState = { sessionId: id, root, originButton, keydownHandler };
863
+ // Fetch + render body. We don't block opening on the fetch — the spinner
864
+ // is shown until the response lands.
865
+ fetch('/api/qa/sessions/' + encodeURIComponent(id) + '/test')
866
+ .then(res => res.json().catch(() => ({})).then(json => ({ res, json })))
867
+ .then(({ res, json }) => {
868
+ if (!_qaModalState || _qaModalState.sessionId !== id) return; // closed mid-flight
869
+ if (!res.ok || !json || !json.ok) {
870
+ _qaModalRenderError(json && json.error ? String(json.error) : 'Failed to load draft (' + res.status + ')');
871
+ return;
872
+ }
873
+ _qaModalRenderBody(json);
874
+ })
875
+ .catch((e) => {
876
+ if (!_qaModalState || _qaModalState.sessionId !== id) return;
877
+ _qaModalRenderError(e && e.message || String(e));
878
+ });
879
+ return null;
880
+ }
881
+
882
+ function _qaModalClose() {
883
+ if (!_qaModalState) return;
884
+ const { root, originButton, keydownHandler } = _qaModalState;
885
+ _qaModalState = null;
886
+ if (keydownHandler) document.removeEventListener('keydown', keydownHandler);
887
+ if (root && root.parentNode) root.parentNode.removeChild(root);
888
+ // Restore focus to the originating button for keyboard navigation.
889
+ if (originButton && typeof originButton.focus === 'function') {
890
+ try { originButton.focus(); } catch (_) { /* noop */ }
891
+ }
892
+ }
893
+
894
+ function _qaBuildModalShell(sessionId) {
895
+ const backdrop = document.createElement('div');
896
+ backdrop.className = 'qa-modal-backdrop';
897
+ backdrop.setAttribute('role', 'dialog');
898
+ backdrop.setAttribute('aria-modal', 'true');
899
+ backdrop.setAttribute('aria-label', 'Review drafted QA test');
900
+ backdrop.addEventListener('click', (e) => {
901
+ if (e.target === backdrop) _qaModalClose();
902
+ });
903
+
904
+ const shell = document.createElement('div');
905
+ shell.className = 'qa-modal-shell';
906
+ backdrop.appendChild(shell);
907
+
908
+ // Header
909
+ const header = document.createElement('div');
910
+ header.className = 'qa-modal-header';
911
+ const title = document.createElement('div');
912
+ title.className = 'qa-modal-title';
913
+ title.textContent = 'Drafted test — (loading…)';
914
+ header.appendChild(title);
915
+ const closeBtn = document.createElement('button');
916
+ closeBtn.type = 'button';
917
+ closeBtn.className = 'qa-btn-ghost qa-modal-close';
918
+ closeBtn.setAttribute('aria-label', 'Close');
919
+ closeBtn.textContent = '×';
920
+ closeBtn.addEventListener('click', _qaModalClose);
921
+ header.appendChild(closeBtn);
922
+ shell.appendChild(header);
923
+
924
+ // Meta strip
925
+ const meta = document.createElement('div');
926
+ meta.className = 'qa-modal-meta';
927
+ meta.setAttribute('data-qa-modal-region', 'meta');
928
+ const metaSession = document.createElement('span');
929
+ metaSession.className = 'qa-modal-meta-item';
930
+ metaSession.textContent = 'session: ' + sessionId;
931
+ meta.appendChild(metaSession);
932
+ shell.appendChild(meta);
933
+
934
+ // Body
935
+ const body = document.createElement('div');
936
+ body.className = 'qa-modal-body';
937
+ body.setAttribute('data-qa-modal-region', 'body');
938
+ const loading = document.createElement('p');
939
+ loading.className = 'qa-modal-loading';
940
+ loading.textContent = 'Loading draft…';
941
+ body.appendChild(loading);
942
+ shell.appendChild(body);
943
+
944
+ // Footer
945
+ const footer = document.createElement('div');
946
+ footer.className = 'qa-modal-footer';
947
+ footer.setAttribute('data-qa-modal-region', 'footer');
948
+ shell.appendChild(footer);
949
+
950
+ return backdrop;
951
+ }
952
+
953
+ function _qaModalRenderError(message) {
954
+ if (!_qaModalState) return;
955
+ const body = _qaModalState.root.querySelector('[data-qa-modal-region="body"]');
956
+ if (!body) return;
957
+ body.textContent = '';
958
+ const err = document.createElement('p');
959
+ err.className = 'qa-modal-error';
960
+ err.textContent = message;
961
+ body.appendChild(err);
962
+ }
963
+
964
+ function _qaModalRenderBody(payload) {
965
+ if (!_qaModalState) return;
966
+ const id = _qaModalState.sessionId;
967
+ const root = _qaModalState.root;
968
+ // Update header title with the test file path.
969
+ const title = root.querySelector('.qa-modal-title');
970
+ if (title) title.textContent = 'Drafted test — ' + (payload.path || '(unknown file)');
971
+
972
+ // Augment meta strip with language + size.
973
+ const meta = root.querySelector('[data-qa-modal-region="meta"]');
974
+ if (meta) {
975
+ const lang = document.createElement('span');
976
+ lang.className = 'qa-modal-meta-item';
977
+ lang.textContent = 'language: ' + (payload.language || 'plaintext');
978
+ meta.appendChild(lang);
979
+ const size = document.createElement('span');
980
+ size.className = 'qa-modal-meta-item';
981
+ size.textContent = 'size: ' + (typeof payload.size === 'number' ? payload.size + ' bytes' : 'unknown');
982
+ meta.appendChild(size);
983
+ }
984
+
985
+ // Body: either code block or "too large" placeholder.
986
+ const body = root.querySelector('[data-qa-modal-region="body"]');
987
+ if (body) {
988
+ body.textContent = '';
989
+ if (payload.truncated) {
990
+ const tooBig = document.createElement('p');
991
+ tooBig.className = 'qa-modal-too-big';
992
+ // payload.path is the relative session.testFile from the API; render the
993
+ // hint with textContent so escHtml-style XSS is impossible by construction.
994
+ tooBig.textContent = 'File is ' + (payload.size || 0) +
995
+ ' bytes — open engine/qa-tests/' + id + '/' + (payload.path || '') +
996
+ ' to review.';
997
+ body.appendChild(tooBig);
998
+ } else {
999
+ const pre = document.createElement('pre');
1000
+ pre.className = 'qa-modal-code';
1001
+ const code = document.createElement('code');
1002
+ // Assign the raw file content via textContent — the DOM treats the value
1003
+ // as literal text and neutralizes any HTML/script for free. Do NOT wrap
1004
+ // with escHtml(): textContent would then render the escape entities
1005
+ // (&lt;, &gt;, &#39;, &amp;) verbatim, defeating the source preview for
1006
+ // any TS/JS/Py file with <, >, &, or ' characters (W-mpxpvpwn000ra419).
1007
+ code.textContent = String(payload.content || '');
1008
+ pre.appendChild(code);
1009
+ body.appendChild(pre);
1010
+ }
1011
+ }
1012
+
1013
+ // Footer: Approve & run + Request edit + Close. Approve is suppressed when
1014
+ // the file is truncated (>256KB) so users can't blind-approve a megabyte.
1015
+ const footer = root.querySelector('[data-qa-modal-region="footer"]');
1016
+ if (footer) {
1017
+ footer.textContent = '';
1018
+ if (!payload.truncated) {
1019
+ const approve = document.createElement('button');
1020
+ approve.type = 'button';
1021
+ approve.className = 'qa-btn-primary';
1022
+ approve.textContent = 'Approve & run';
1023
+ approve.addEventListener('click', () => {
1024
+ _qaModalClose();
1025
+ qaSessionApprove(id);
1026
+ });
1027
+ footer.appendChild(approve);
1028
+ }
1029
+ const editBtn = document.createElement('button');
1030
+ editBtn.type = 'button';
1031
+ editBtn.className = 'qa-btn-ghost';
1032
+ editBtn.textContent = 'Request edit';
1033
+ editBtn.addEventListener('click', () => {
1034
+ _qaModalClose();
1035
+ qaSessionEditPrompt(id);
1036
+ });
1037
+ footer.appendChild(editBtn);
1038
+ const closeBtn = document.createElement('button');
1039
+ closeBtn.type = 'button';
1040
+ closeBtn.className = 'qa-btn-ghost';
1041
+ closeBtn.textContent = 'Close';
1042
+ closeBtn.addEventListener('click', _qaModalClose);
1043
+ footer.appendChild(closeBtn);
1044
+ }
1045
+ }
1046
+
1047
+ // Inject modal CSS once. Keyed off a data attribute on the <style> tag so
1048
+ // repeated calls (e.g. from a re-rendered card) don't duplicate the block.
1049
+ function _qaInstallModalStyle() {
1050
+ if (document.querySelector('style[data-qa-modal-style]')) return;
1051
+ const style = document.createElement('style');
1052
+ style.setAttribute('data-qa-modal-style', '1');
1053
+ style.textContent = [
1054
+ '.qa-modal-backdrop {',
1055
+ ' position: fixed; inset: 0; z-index: 9999;',
1056
+ ' background: rgba(0,0,0,0.5);',
1057
+ ' display: flex; align-items: center; justify-content: center;',
1058
+ '}',
1059
+ '.qa-modal-shell {',
1060
+ ' background: var(--surface, #fff);',
1061
+ ' color: var(--text, #111);',
1062
+ ' border-radius: 8px;',
1063
+ ' max-width: 80vw; max-height: 80vh;',
1064
+ ' width: 800px;',
1065
+ ' display: flex; flex-direction: column;',
1066
+ ' box-shadow: 0 8px 32px rgba(0,0,0,0.3);',
1067
+ ' border: 1px solid var(--border, #ddd);',
1068
+ '}',
1069
+ '.qa-modal-header {',
1070
+ ' display: flex; align-items: center; justify-content: space-between;',
1071
+ ' padding: 12px 16px; border-bottom: 1px solid var(--border, #ddd);',
1072
+ '}',
1073
+ '.qa-modal-title { font-weight: 600; }',
1074
+ '.qa-modal-close { font-size: 20px; padding: 0 8px; }',
1075
+ '.qa-modal-meta {',
1076
+ ' display: flex; gap: 12px; flex-wrap: wrap;',
1077
+ ' padding: 8px 16px; border-bottom: 1px solid var(--border, #ddd);',
1078
+ ' font-size: 12px; color: var(--muted, #666);',
1079
+ '}',
1080
+ '.qa-modal-meta-item { white-space: nowrap; }',
1081
+ '.qa-modal-body { flex: 1; overflow: auto; padding: 12px 16px; }',
1082
+ '.qa-modal-code {',
1083
+ ' font-family: ui-monospace, Menlo, Consolas, monospace;',
1084
+ ' font-size: 12px; line-height: 1.5;',
1085
+ ' background: var(--surface2, #f6f6f6);',
1086
+ ' border: 1px solid var(--border, #ddd);',
1087
+ ' border-radius: 4px; padding: 12px;',
1088
+ ' white-space: pre; overflow: auto;',
1089
+ ' margin: 0;',
1090
+ '}',
1091
+ '.qa-modal-too-big { color: var(--muted, #666); font-style: italic; }',
1092
+ '.qa-modal-error { color: var(--red, #c00); }',
1093
+ '.qa-modal-loading { color: var(--muted, #666); }',
1094
+ '.qa-modal-footer {',
1095
+ ' display: flex; gap: 8px; justify-content: flex-end;',
1096
+ ' padding: 12px 16px; border-top: 1px solid var(--border, #ddd);',
1097
+ '}',
1098
+ ].join('\n');
1099
+ document.head.appendChild(style);
1100
+ }
@@ -1,131 +1,56 @@
1
1
  <section>
2
2
  <h2>QA</h2>
3
- <p class="empty" style="margin:4px 0 12px 0">Validation runbooks dispatched against live managed instances. Targets, runbooks, and run history with artifact previews.</p>
3
+ <p class="empty" style="margin:4px 0 12px 0">Passive monitor for QA sessions, live targets, runs, and saved runbooks. Create new sessions or runbooks from <button type="button" class="qa-inline-link" onclick="qaFocusCommandCenter()">Command Center</button>.</p>
4
4
  </section>
5
5
  <section id="qa-sessions-section" class="qa-section">
6
- <h2>QA Sessions <span class="qa-section-subtitle">natural-language smoke tests — engine sets up a live target, drafts a runner-native test, and (with your approval) executes it</span></h2>
7
- <div class="qa-session-form-wrap">
8
- <form id="qa-session-form" onsubmit="event.preventDefault();qaSubmitSessionForm();">
9
- <div class="qa-form-row qa-form-row--grid">
10
- <div>
11
- <label class="qa-form-label" for="qa-session-target-kind">Target</label>
12
- <select id="qa-session-target-kind" class="qa-form-input" onchange="qaSessionTargetKindChanged()">
13
- <option value="current">Current worktree</option>
14
- <option value="pr">Pull request</option>
15
- <option value="branch">Branch</option>
16
- <option value="commit">Commit SHA</option>
17
- </select>
18
- </div>
19
- <div>
20
- <label class="qa-form-label" for="qa-session-projects">Projects</label>
21
- <select id="qa-session-projects" class="qa-form-input" multiple size="3"></select>
22
- <p class="empty" style="margin-top:4px;font-size:0.85em">Hold Ctrl/Cmd to select multiple. First selected = primary (drives DRAFT/EXECUTE). Others = co-services (dev-up only). Leave empty = central.</p>
23
- </div>
24
- </div>
25
- <div class="qa-form-row qa-session-target-fields">
26
- <div id="qa-session-target-pr-wrap" class="qa-session-target-sub" style="display:none">
27
- <label class="qa-form-label" for="qa-session-target-pr-id">PR id</label>
28
- <input id="qa-session-target-pr-id" type="text" class="qa-form-input" placeholder="2887">
29
- </div>
30
- <div id="qa-session-target-branch-wrap" class="qa-session-target-sub" style="display:none">
31
- <label class="qa-form-label" for="qa-session-target-branch">Branch</label>
32
- <input id="qa-session-target-branch" type="text" class="qa-form-input" placeholder="work/my-feature">
33
- </div>
34
- <div id="qa-session-target-sha-wrap" class="qa-session-target-sub" style="display:none">
35
- <label class="qa-form-label" for="qa-session-target-sha">Commit SHA</label>
36
- <input id="qa-session-target-sha" type="text" class="qa-form-input" placeholder="abc1234">
37
- </div>
38
- <div id="qa-session-target-worktree-wrap" class="qa-session-target-sub">
39
- <label class="qa-form-label" for="qa-session-target-worktree">Worktree path</label>
40
- <input id="qa-session-target-worktree" type="text" class="qa-form-input" placeholder="(blank = $MINIONS_AGENT_CWD)">
41
- </div>
42
- </div>
43
- <div class="qa-form-row">
44
- <label class="qa-form-label" for="qa-session-flows">Flows</label>
45
- <textarea id="qa-session-flows" class="qa-form-input qa-form-textarea" placeholder="open the homepage, click login, verify the dashboard renders" required></textarea>
46
- </div>
47
- <div class="qa-form-row qa-form-row--inline">
48
- <div>
49
- <label class="qa-form-label" for="qa-session-mode">Mode</label>
50
- <select id="qa-session-mode" class="qa-form-input">
51
- <option value="confirm">Confirm (review draft first)</option>
52
- <option value="auto">Auto (skip approval)</option>
53
- </select>
54
- </div>
55
- <div>
56
- <label class="qa-form-label" for="qa-session-runner">Runner</label>
57
- <select id="qa-session-runner" class="qa-form-input">
58
- <option value="">Auto-detect</option>
59
- </select>
60
- </div>
61
- <div class="qa-session-capture-group">
62
- <label class="qa-form-label">Capture</label>
63
- <label class="qa-session-capture-item"><input id="qa-session-capture-screenshots" type="checkbox" checked> screenshots</label>
64
- <label class="qa-session-capture-item"><input id="qa-session-capture-video" type="checkbox"> video</label>
65
- <label class="qa-session-capture-item"><input id="qa-session-capture-logs" type="checkbox" checked> logs</label>
66
- </div>
67
- </div>
68
- <div class="qa-form-row qa-form-actions">
69
- <button type="submit" id="qa-session-submit" class="qa-btn-primary">Start QA Session</button>
70
- <span id="qa-session-form-msg" class="qa-form-msg"></span>
71
- </div>
72
- </form>
6
+ <h2>Active QA Sessions <span class="qa-section-subtitle">live, non-terminal runs — engine sets up a target, drafts a runner-native test, and (with your approval) executes it</span></h2>
7
+ <div id="qa-cc-pitch" class="qa-cc-pitch" style="border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface2);padding:var(--space-5);margin-bottom:var(--space-5);display:flex;flex-direction:column;gap:var(--space-3)">
8
+ <div style="font-weight:600;color:var(--text);display:flex;align-items:center;gap:var(--space-2)">💬 Ask Command Center to start a QA session</div>
9
+ <div style="color:var(--muted);font-size:var(--text-base);line-height:1.5">
10
+ Examples:
11
+ <ul style="margin:var(--space-2) 0 0 var(--space-5);padding:0;color:var(--muted)">
12
+ <li><code>QA the login flow on PR #1234</code></li>
13
+ <li><code>Smoke test the checkout flow on develop</code></li>
14
+ <li><code>Validate the signup journey on my current worktree</code></li>
15
+ </ul>
16
+ </div>
17
+ <div style="display:flex;gap:var(--space-3);align-items:center;margin-top:var(--space-2)">
18
+ <button type="button" id="qa-cc-pitch-open" class="qa-btn-primary" onclick="qaFocusCommandCenter()">Open Command Center →</button>
19
+ <a href="/state/docs/qa-runbook-lifecycle.md" target="_blank" rel="noopener" class="qa-cc-pitch-docs" style="color:var(--blue);font-size:var(--text-base)">Docs →</a>
20
+ </div>
73
21
  </div>
74
22
  <div id="qa-sessions-content" class="qa-sessions-list">
75
23
  <p class="empty">Loading sessions…</p>
76
24
  </div>
25
+ <details id="qa-sessions-recent" class="qa-disclosure" style="margin-top:var(--space-5);border-top:1px solid var(--border);padding-top:var(--space-4)">
26
+ <summary id="qa-sessions-recent-summary" style="cursor:pointer;font-size:var(--text-base);color:var(--muted)">Recent sessions (last 10)</summary>
27
+ <div id="qa-sessions-recent-content" class="qa-sessions-list" style="margin-top:var(--space-3)">
28
+ <p class="empty">No recent sessions.</p>
29
+ </div>
30
+ </details>
77
31
  </section>
78
32
  <section id="qa-targets-section" class="qa-section">
79
- <h2>Targets <span class="qa-section-subtitle">live managed/keep processes available as runbook targets — full inventory lives on <a href="/engine" class="qa-engine-link">/engine</a></span></h2>
33
+ <h2>Live Targets <span class="qa-section-subtitle">managed/keep processes available as runbook targets — full inventory on <a href="/engine" class="qa-engine-link">/engine</a></span></h2>
80
34
  <div id="qa-targets-content" class="qa-targets-list">
81
35
  <p class="empty">Loading targets…</p>
82
36
  </div>
83
37
  </section>
84
- <section id="qa-runbooks-section" class="qa-section">
85
- <h2>Validation Runbooks <span class="qa-section-subtitle">human or agent-driven smoke / E2E flows against the targets above</span></h2>
86
- <div class="qa-runbooks-actions">
87
- <button id="qa-new-runbook-btn" class="qa-btn-primary" onclick="qaShowNewRunbookForm()">+ New runbook</button>
88
- </div>
89
- <div id="qa-runbook-form-wrap" class="qa-runbook-form" style="display:none">
90
- <form id="qa-runbook-form" onsubmit="event.preventDefault();qaSaveRunbook();">
91
- <div class="qa-form-row">
92
- <label class="qa-form-label" for="qa-runbook-name">Name</label>
93
- <input id="qa-runbook-name" type="text" class="qa-form-input" placeholder="login-smoke" required>
94
- </div>
95
- <div class="qa-form-row">
96
- <label class="qa-form-label" for="qa-runbook-target">Target</label>
97
- <select id="qa-runbook-target" class="qa-form-input" required>
98
- <option value="">— select a target —</option>
99
- </select>
100
- </div>
101
- <div class="qa-form-row">
102
- <label class="qa-form-label" for="qa-runbook-steps">Steps</label>
103
- <textarea id="qa-runbook-steps" class="qa-form-input qa-form-textarea" placeholder="1. Open the app&#10;2. Click login&#10;3. Verify dashboard loads" required></textarea>
104
- </div>
105
- <div class="qa-form-row">
106
- <label class="qa-form-label">Expected artifacts</label>
107
- <div id="qa-runbook-artifacts" class="qa-artifacts-repeater">
108
- <div class="qa-artifact-row">
109
- <input type="text" class="qa-form-input qa-artifact-input" placeholder="screenshot:dashboard.png">
110
- <button type="button" class="qa-btn-ghost" onclick="qaRemoveArtifactRow(this)">−</button>
111
- </div>
112
- </div>
113
- <button type="button" class="qa-btn-ghost qa-add-artifact-btn" onclick="qaAddArtifactRow()">+ Add artifact</button>
114
- </div>
115
- <div class="qa-form-row qa-form-actions">
116
- <button type="submit" class="qa-btn-primary">Save</button>
117
- <button type="button" class="qa-btn-ghost" onclick="qaHideNewRunbookForm()">Cancel</button>
118
- <span id="qa-runbook-form-msg" class="qa-form-msg"></span>
119
- </div>
120
- </form>
121
- </div>
122
- <div id="qa-runbooks-content" class="qa-runbooks-list">
123
- <p class="empty">Loading runbooks…</p>
124
- </div>
125
- </section>
126
38
  <section id="qa-runs-section" class="qa-section">
127
- <h2>Recent Runs <span class="qa-section-subtitle">latest 50 dispatched validation runs — polled every 5s while this page is active</span></h2>
39
+ <h2>Recent Runs <span class="qa-section-subtitle">latest 20 validation runs — polled every 5s while this page is active</span></h2>
128
40
  <div id="qa-runs-content" class="qa-runs-list">
129
41
  <p class="empty">Loading runs…</p>
130
42
  </div>
131
43
  </section>
44
+ <section id="qa-runbooks-section" class="qa-section">
45
+ <details id="qa-runbooks-disclosure" class="qa-disclosure">
46
+ <summary id="qa-runbooks-disclosure-summary" style="cursor:pointer;font-size:var(--text-md);font-weight:600;color:var(--text)">Show saved runbooks (0)</summary>
47
+ <div style="margin-top:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">
48
+ <div class="qa-cc-pitch qa-cc-pitch--small" style="border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface2);padding:var(--space-4);color:var(--muted);font-size:var(--text-base)">
49
+ 💬 Ask Command Center to create a new runbook — e.g. <code>save a runbook that opens the cart and clicks checkout</code>. <button type="button" class="qa-inline-link" onclick="qaFocusCommandCenter()">Open Command Center →</button>
50
+ </div>
51
+ <div id="qa-runbooks-content" class="qa-runbooks-list">
52
+ <p class="empty">Loading runbooks…</p>
53
+ </div>
54
+ </div>
55
+ </details>
56
+ </section>
package/dashboard.js CHANGED
@@ -10484,6 +10484,37 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10484
10484
  }
10485
10485
  }
10486
10486
 
10487
+ // GET /api/qa/sessions/<id>/test — read-only fetch of the drafted test file
10488
+ // for the Review draft modal (W-mpxpvpwn000ra419). Sandboxed at two layers:
10489
+ // the per-segment _qaIsSafeSegment guard rejects hostile ids before they
10490
+ // reach qaSessions, and qaSessions.getSessionTestFile() repeats the path
10491
+ // resolution + prefix check internally so anything that slips through
10492
+ // (e.g. a session record whose testFile field already escapes the sandbox)
10493
+ // still returns null instead of leaking bytes. Read-only, so no CSRF gate.
10494
+ function handleQaSessionTestFile(req, res, match) {
10495
+ try {
10496
+ const qaSessions = require('./engine/qa-sessions');
10497
+ const id = decodeURIComponent(match[1] || '');
10498
+ if (!_qaIsSafeSegment(id)) return jsonReply(res, 400, { error: 'invalid session id' }, req);
10499
+ const session = qaSessions.getSession(id);
10500
+ if (!session) return jsonReply(res, 404, { error: 'qa session not found' }, req);
10501
+ const result = qaSessions.getSessionTestFile(id);
10502
+ if (!result) {
10503
+ return jsonReply(res, 404, { error: 'qa session test file not available' }, req);
10504
+ }
10505
+ return jsonReply(res, 200, {
10506
+ ok: true,
10507
+ path: session.testFile,
10508
+ content: result.content,
10509
+ language: result.language,
10510
+ truncated: !!result.truncated,
10511
+ size: result.size,
10512
+ }, req);
10513
+ } catch (e) {
10514
+ return jsonReply(res, 500, { error: e.message }, req);
10515
+ }
10516
+ }
10517
+
10487
10518
  // Shared helper for approve + edit + cancel + kill + dismiss — each takes
10488
10519
  // a sessionId from the URL and a body, delegates to qaSessions.* and maps
10489
10520
  // module errors to HTTP statuses.
@@ -10964,6 +10995,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10964
10995
  { method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/cancel$/, template: '/api/qa/sessions/<id>/cancel', desc: 'Cancel a session (non-terminal → killed). Does NOT touch the managed-spawn — use /kill for that.', params: 'reason?', handler: handleQaSessionCancel },
10965
10996
  { method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/kill$/, template: '/api/qa/sessions/<id>/kill', desc: 'Kill a session and its managed-spawn (non-terminal → killed). Best-effort on the spawn kill.', params: 'reason?', handler: handleQaSessionKill },
10966
10997
  { method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/dismiss$/, template: '/api/qa/sessions/<id>/dismiss', desc: 'Mark a session done without running EXECUTE (non-terminal → done).', params: 'summary?', handler: handleQaSessionDismiss },
10998
+ { method: 'GET', path: /^\/api\/qa\/sessions\/([^/?]+)\/test$/, template: '/api/qa/sessions/<id>/test', desc: 'Fetch the drafted test file for the Review draft modal. Read-only; returns { ok, path, content, language, truncated, size }. Files > 256KB return truncated:true with content:null.', handler: handleQaSessionTestFile },
10967
10999
  { method: 'GET', path: /^\/api\/qa\/sessions\/([^/?]+)$/, template: '/api/qa/sessions/<id>', desc: 'Fetch a single QA session record by id.', handler: handleQaSessionsById },
10968
11000
  // QA Runners endpoints (P-d2f5a8c9). Pluggable runner-adapter registry.
10969
11001
  { method: 'GET', path: '/api/qa/runners', desc: 'List registered QA runner adapters (built-ins + qa-runners.d/ plugins). Returns metadata only (no hooks).', handler: handleQaRunnersList },
@@ -452,6 +452,70 @@ function getSession(id) {
452
452
  return sessions.find(s => s && s.id === id) || null;
453
453
  }
454
454
 
455
+ // W-mpxpvpwn000ra419 — Review draft modal: pure read helper that resolves
456
+ // session.testFile against qa-tests/<id>/ with the same sandbox guards as
457
+ // dashboard.js#handleQaArtifact (the canonical traversal-defense pattern).
458
+ // Used by GET /api/qa/sessions/<id>/test to feed the read-only Review draft
459
+ // modal on the /qa page.
460
+ //
461
+ // Contract (matches the work-item description, kept tight on purpose):
462
+ // - Returns null when session missing, session.testFile is null/empty, the
463
+ // resolved path escapes qa-tests/<id>/, or the file doesn't exist on disk.
464
+ // Never throws on hostile input — _isSafeSessionId + path.resolve + the
465
+ // baseWithSep prefix check together absorb every traversal shape.
466
+ // - Returns { path, content, language, truncated:false, size } on a normal
467
+ // file. `path` is the resolved absolute path (so callers can log it); the
468
+ // dashboard handler echoes the relative session.testFile back to clients.
469
+ // - Returns { path, content:null, language, truncated:true, size } when the
470
+ // file exceeds GET_SESSION_TEST_FILE_MAX_BYTES (256KB). The modal shows a
471
+ // "file too large" placeholder and suppresses in-modal Approve so users
472
+ // can't blind-approve a megabyte test.
473
+ // - Language inferred from extension: .spec.ts/.test.ts → typescript,
474
+ // .spec.js/.test.js/.js → javascript, .ts → typescript, .py → python,
475
+ // anything else → plaintext. Used by the dashboard to pick a syntax theme.
476
+ const GET_SESSION_TEST_FILE_MAX_BYTES = 256 * 1024;
477
+
478
+ function _inferTestLanguage(relPath) {
479
+ const lower = String(relPath || '').toLowerCase();
480
+ if (lower.endsWith('.spec.ts') || lower.endsWith('.test.ts') || lower.endsWith('.ts')) return 'typescript';
481
+ if (lower.endsWith('.spec.js') || lower.endsWith('.test.js') || lower.endsWith('.js')) return 'javascript';
482
+ if (lower.endsWith('.py')) return 'python';
483
+ return 'plaintext';
484
+ }
485
+
486
+ function getSessionTestFile(sessionId) {
487
+ if (!_isSafeSessionId(sessionId)) return null;
488
+ const session = getSession(sessionId);
489
+ if (!session) return null;
490
+ const rel = session.testFile;
491
+ if (!_isNonEmptyString(rel)) return null;
492
+ // Reject early on null bytes — path.resolve does not strip them and the fs
493
+ // call would either throw or be silently truncated on some platforms.
494
+ if (rel.indexOf('\0') !== -1) return null;
495
+ // Reject absolute paths and Windows drive-letter paths before resolution.
496
+ // path.resolve(base, '/etc/passwd') returns '/etc/passwd' which would
497
+ // bypass the suffix check below if base happened to share a leading
498
+ // prefix; reject pre-resolution to make the intent obvious.
499
+ if (path.isAbsolute(rel)) return null;
500
+ if (/^[a-zA-Z]:/.test(rel)) return null;
501
+ const base = path.resolve(qaTestsDirForSession(sessionId));
502
+ const target = path.resolve(base, rel);
503
+ const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
504
+ if (target !== base && !target.startsWith(baseWithSep)) return null;
505
+ let stat;
506
+ try { stat = fs.statSync(target); }
507
+ catch (e) { return null; }
508
+ if (!stat.isFile()) return null;
509
+ const language = _inferTestLanguage(rel);
510
+ if (stat.size > GET_SESSION_TEST_FILE_MAX_BYTES) {
511
+ return { path: target, content: null, language, truncated: true, size: stat.size };
512
+ }
513
+ let content;
514
+ try { content = fs.readFileSync(target, 'utf8'); }
515
+ catch (e) { return null; }
516
+ return { path: target, content, language, truncated: false, size: stat.size };
517
+ }
518
+
455
519
  /**
456
520
  * List sessions, newest first, optionally filtered by state, capped by limit.
457
521
  */
@@ -1247,6 +1311,7 @@ module.exports = {
1247
1311
  // CRUD
1248
1312
  createSession,
1249
1313
  getSession,
1314
+ getSessionTestFile,
1250
1315
  listSessions,
1251
1316
  setSessionWorkItem,
1252
1317
  setSessionQaRunId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2105",
3
+ "version": "0.1.2106",
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"