@yemi33/minions 0.1.2105 → 0.1.2107
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/README.md +6 -6
- package/dashboard/js/qa.js +392 -198
- package/dashboard/js/render-dispatch.js +3 -2
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard/pages/qa.html +37 -112
- package/dashboard.js +110 -1
- package/docs/auto-discovery.md +7 -7
- package/docs/human-vs-automated.md +2 -2
- package/docs/index.html +1 -1
- package/docs/managed-spawn.md +1 -1
- package/docs/onboarding.md +2 -2
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/self-improvement.md +1 -1
- package/docs/slim-ux/concepts.md +2 -2
- package/docs/watches.md +1 -1
- package/engine/cli.js +1 -1
- package/engine/qa-sessions.js +65 -0
- package/engine/shared.js +113 -6
- package/engine/watch-actions.js +301 -0
- package/engine/watches.js +3 -2
- package/engine.js +18 -13
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +25 -0
- package/prompts/cc-system.md +9 -0
package/dashboard/js/qa.js
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
|
-
// dashboard/js/qa.js — QA tab UI
|
|
1
|
+
// dashboard/js/qa.js — QA tab UI.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
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
|
-
'<
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
632
|
-
|
|
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
|
-
|
|
595
|
+
activeRoot.replaceChildren(frag);
|
|
647
596
|
return;
|
|
648
597
|
}
|
|
649
598
|
|
|
650
599
|
_qaSessionsCache = sessions;
|
|
651
|
-
|
|
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
|
|
609
|
+
'<p class="empty">No active QA sessions.</p>'
|
|
654
610
|
);
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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 & 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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
+
// (<, >, ', &) 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
|
+
}
|