@yemi33/minions 0.1.2104 → 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.
- package/dashboard/js/qa.js +392 -198
- package/dashboard/pages/qa.html +37 -112
- package/dashboard.js +42 -12
- package/engine/qa-sessions.js +65 -0
- package/engine/shared.js +66 -0
- package/engine.js +7 -2
- package/package.json +1 -1
- package/playbooks/fix.md +4 -0
- package/playbooks/shared-rules.md +11 -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
|
+
}
|
package/dashboard/pages/qa.html
CHANGED
|
@@ -1,131 +1,56 @@
|
|
|
1
1
|
<section>
|
|
2
2
|
<h2>QA</h2>
|
|
3
|
-
<p class="empty" style="margin:4px 0 12px 0">
|
|
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">
|
|
7
|
-
<div class="qa-
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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">
|
|
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 2. Click login 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
|
|
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
|
@@ -3329,8 +3329,12 @@ function normalizeMeetingParticipants(participants) {
|
|
|
3329
3329
|
|
|
3330
3330
|
|
|
3331
3331
|
function getWorkItemPrRef(input) {
|
|
3332
|
-
|
|
3333
|
-
|
|
3332
|
+
// Delegates to shared.extractWorkItemPrRef so the dashboard's WI-create
|
|
3333
|
+
// path detects PR pointers in references[*].url / meta.pr_followup /
|
|
3334
|
+
// description text — not just the canonical structured fields — and routes
|
|
3335
|
+
// the dispatch to the existing PR branch instead of a fresh `work/<wi-id>`
|
|
3336
|
+
// parallel branch (issue #2999 / W-mpx6i5kh000ac040).
|
|
3337
|
+
return shared.extractWorkItemPrRef(input);
|
|
3334
3338
|
}
|
|
3335
3339
|
|
|
3336
3340
|
function isPrTargetedWorkType(workType) {
|
|
@@ -3341,17 +3345,11 @@ function trimTrailingPrRefPunctuation(value) {
|
|
|
3341
3345
|
return String(value || '').replace(/[),.;:]+$/g, '');
|
|
3342
3346
|
}
|
|
3343
3347
|
|
|
3348
|
+
// Thin wrapper over shared.extractPrRefFromText so callers in dashboard.js
|
|
3349
|
+
// keep their existing import shape while the canonical regex + extraction
|
|
3350
|
+
// logic lives in engine/shared.js (issue #2999 / W-mpx6i5kh000ac040).
|
|
3344
3351
|
function extractPrRefFromText(value) {
|
|
3345
|
-
|
|
3346
|
-
if (!text.trim()) return null;
|
|
3347
|
-
const urlMatch = text.match(/https?:\/\/[^\s<>()]+(?:\/pull\/\d+|\/pullrequest\/\d+)[^\s<>()]*/i);
|
|
3348
|
-
if (urlMatch) return trimTrailingPrRefPunctuation(urlMatch[0]);
|
|
3349
|
-
const canonicalMatch = text.match(/\b(?:github|ado):[^\s#]+#\d+\b/i);
|
|
3350
|
-
if (canonicalMatch) return canonicalMatch[0];
|
|
3351
|
-
const legacyMatch = text.match(/\bPR-(\d+)\b/i);
|
|
3352
|
-
if (legacyMatch) return legacyMatch[1];
|
|
3353
|
-
const numberMatch = text.match(/\b(?:pr|pull\s+request|pullrequest)\s*#?\s*(\d+)\b/i);
|
|
3354
|
-
return numberMatch ? numberMatch[1] : null;
|
|
3352
|
+
return shared.extractPrRefFromText(value);
|
|
3355
3353
|
}
|
|
3356
3354
|
|
|
3357
3355
|
|
|
@@ -10486,6 +10484,37 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10486
10484
|
}
|
|
10487
10485
|
}
|
|
10488
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
|
+
|
|
10489
10518
|
// Shared helper for approve + edit + cancel + kill + dismiss — each takes
|
|
10490
10519
|
// a sessionId from the URL and a body, delegates to qaSessions.* and maps
|
|
10491
10520
|
// module errors to HTTP statuses.
|
|
@@ -10966,6 +10995,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10966
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 },
|
|
10967
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 },
|
|
10968
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 },
|
|
10969
10999
|
{ method: 'GET', path: /^\/api\/qa\/sessions\/([^/?]+)$/, template: '/api/qa/sessions/<id>', desc: 'Fetch a single QA session record by id.', handler: handleQaSessionsById },
|
|
10970
11000
|
// QA Runners endpoints (P-d2f5a8c9). Pluggable runner-adapter registry.
|
|
10971
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 },
|
package/engine/qa-sessions.js
CHANGED
|
@@ -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/engine/shared.js
CHANGED
|
@@ -4300,6 +4300,70 @@ function parsePrUrl(url) {
|
|
|
4300
4300
|
return parseGitHubPrUrl(url) || parseAdoPrUrl(url);
|
|
4301
4301
|
}
|
|
4302
4302
|
|
|
4303
|
+
// ─── PR reference extraction from work-item bodies (W-mpx6i5kh000ac040) ─────
|
|
4304
|
+
//
|
|
4305
|
+
// `extractPrRefFromText` scans free-form text (description, comment bodies,
|
|
4306
|
+
// release notes) for the first PR reference it can recognise: a GitHub or ADO
|
|
4307
|
+
// PR URL, a canonical `github:owner/repo#N` / `ado:org/proj/repo#N` id, or a
|
|
4308
|
+
// legacy `PR-<n>` token. Returns null when nothing parseable is found.
|
|
4309
|
+
//
|
|
4310
|
+
// `extractWorkItemPrRef` is the engine + dashboard's single source of truth
|
|
4311
|
+
// for "does this work-item target an existing PR?". It checks the canonical
|
|
4312
|
+
// structured fields first (current behaviour preserved), then falls back to
|
|
4313
|
+
// `references[*].url`, `meta.pr_followup.parent_pr_url`, and finally a text
|
|
4314
|
+
// scan of the title/description. Returning a truthy ref keeps `linkedPr` /
|
|
4315
|
+
// `isPrTargeted` paths intact and prevents PR-feedback fix WIs from being
|
|
4316
|
+
// dispatched on a fresh `work/<wi-id>` branch when only the description /
|
|
4317
|
+
// references[] carried the PR pointer (issue #2999).
|
|
4318
|
+
function _trimTrailingPrRefPunctuation(value) {
|
|
4319
|
+
return String(value || '').replace(/[),.;:]+$/g, '');
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
function extractPrRefFromText(value) {
|
|
4323
|
+
const text = String(value || '');
|
|
4324
|
+
if (!text.trim()) return null;
|
|
4325
|
+
const urlMatch = text.match(/https?:\/\/[^\s<>()]+(?:\/pull\/\d+|\/pullrequest\/\d+)[^\s<>()]*/i);
|
|
4326
|
+
if (urlMatch) return _trimTrailingPrRefPunctuation(urlMatch[0]);
|
|
4327
|
+
const canonicalMatch = text.match(/\b(?:github|ado):[^\s#]+#\d+\b/i);
|
|
4328
|
+
if (canonicalMatch) return canonicalMatch[0];
|
|
4329
|
+
const legacyMatch = text.match(/\bPR-(\d+)\b/i);
|
|
4330
|
+
if (legacyMatch) return legacyMatch[1];
|
|
4331
|
+
const numberMatch = text.match(/\b(?:pr|pull\s+request|pullrequest)\s*#?\s*(\d+)\b/i);
|
|
4332
|
+
return numberMatch ? numberMatch[1] : null;
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
function extractWorkItemPrRef(item) {
|
|
4336
|
+
if (!item || typeof item !== 'object') return null;
|
|
4337
|
+
const structured = item.targetPr
|
|
4338
|
+
|| item.pr
|
|
4339
|
+
|| item.pr_id
|
|
4340
|
+
|| item.prId
|
|
4341
|
+
|| item.sourcePr
|
|
4342
|
+
|| item.pullRequest
|
|
4343
|
+
|| item.prUrl
|
|
4344
|
+
|| item.prNumber;
|
|
4345
|
+
if (structured) return structured;
|
|
4346
|
+
// references[*].url — manual /api/work-items callers often supply the PR
|
|
4347
|
+
// pointer here when no structured prId/targetPr is known up front.
|
|
4348
|
+
if (Array.isArray(item.references)) {
|
|
4349
|
+
for (const ref of item.references) {
|
|
4350
|
+
const url = ref && typeof ref === 'object' ? ref.url : null;
|
|
4351
|
+
const fromUrl = extractPrRefFromText(url);
|
|
4352
|
+
if (fromUrl) return fromUrl;
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
// meta.pr_followup.parent_pr_url — set by playbooks/templates/followup-dispatch.md.
|
|
4356
|
+
const followupUrl = item?.meta?.pr_followup?.parent_pr_url;
|
|
4357
|
+
if (followupUrl) {
|
|
4358
|
+
const fromFollowup = extractPrRefFromText(followupUrl);
|
|
4359
|
+
if (fromFollowup) return fromFollowup;
|
|
4360
|
+
}
|
|
4361
|
+
// Last resort: scan description + title for a PR URL / canonical id.
|
|
4362
|
+
const fromDescription = extractPrRefFromText(item.description);
|
|
4363
|
+
if (fromDescription) return fromDescription;
|
|
4364
|
+
return extractPrRefFromText(item.title) || null;
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4303
4367
|
function getProjectPrScope(project) {
|
|
4304
4368
|
if (!project) return '';
|
|
4305
4369
|
const host = String(project.repoHost || '').toLowerCase();
|
|
@@ -5632,6 +5696,8 @@ module.exports = {
|
|
|
5632
5696
|
parseGitHubPrUrl, // exported for testing
|
|
5633
5697
|
parseAdoPrUrl, // exported for testing
|
|
5634
5698
|
parsePrUrl, // exported for testing
|
|
5699
|
+
extractPrRefFromText,
|
|
5700
|
+
extractWorkItemPrRef,
|
|
5635
5701
|
getProjectPrScope,
|
|
5636
5702
|
getPrNumber,
|
|
5637
5703
|
getPrDisplayId,
|
package/engine.js
CHANGED
|
@@ -5583,8 +5583,13 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
|
|
|
5583
5583
|
}
|
|
5584
5584
|
|
|
5585
5585
|
function getWorkItemPrRef(item) {
|
|
5586
|
-
|
|
5587
|
-
|
|
5586
|
+
// Delegates to shared.extractWorkItemPrRef so engine + dashboard agree on
|
|
5587
|
+
// which work-item fields count as PR pointers — covers structured fields,
|
|
5588
|
+
// references[*].url, meta.pr_followup.parent_pr_url, and description text
|
|
5589
|
+
// (issue #2999 / W-mpx6i5kh000ac040). Defence in depth: even if a manual
|
|
5590
|
+
// /api/work-items create slipped through without structured PR fields, the
|
|
5591
|
+
// engine still detects the PR pointer here at dispatch time.
|
|
5592
|
+
return shared.extractWorkItemPrRef(item);
|
|
5588
5593
|
}
|
|
5589
5594
|
|
|
5590
5595
|
function resolveWorkItemPrRecord(item, project) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
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"
|
package/playbooks/fix.md
CHANGED
|
@@ -46,6 +46,10 @@ Before editing, split the feedback into:
|
|
|
46
46
|
|
|
47
47
|
Before starting work, run `git status` and verify the worktree is clean and on the expected branch (`{{pr_branch}}`). If the worktree is dirty or on the wrong branch, report the issue and stop.
|
|
48
48
|
|
|
49
|
+
### Branch-mismatch guard (issue #2999)
|
|
50
|
+
|
|
51
|
+
If your task description, `references[]`, or the PR thread clearly identifies an existing PR but your checkout branch does **not** match the PR's source branch — for example you are on `work/W-…` while the task references PR `!5284819` (source branch `yemishin/task-hub-symphony-gate`) — **stop immediately**. Do not branch off master, do not replay the PR's commits onto a fresh branch, and do not open a duplicate PR. Emit a non-retryable completion (`failure_class: "branch-mismatch"`, `retryable: false`) with the referenced PR id and the actual branch you found, so the engine and the operator can re-dispatch on the correct branch. The canonical bad incident is in issue #2999 — manual fix WI `W-mpwxgd4v00078c06` opened duplicate ADO PR `!5288540` instead of patching `!5284819`.
|
|
52
|
+
|
|
49
53
|
## Working Style
|
|
50
54
|
|
|
51
55
|
Use subagents only for genuinely parallel, independent tasks. For sequential work, single-file edits, searches, and file reads, work directly — do not spawn subagents.
|
|
@@ -64,6 +64,17 @@ Application:
|
|
|
64
64
|
- When you create a work item programmatically (API, plan-to-prd, scripts), set the WI's `branch` (or PRD `feature_branch`) to the conventional name so the engine creates the worktree on the right branch from the start. `dashboard.js` derives this automatically when callers omit `branch`.
|
|
65
65
|
- The legacy `feat/<id>-<slug>` and bare `work/<id>` formats are deprecated; the engine no longer falls back to them.
|
|
66
66
|
|
|
67
|
+
### PR-fix exception (DO NOT branch off master)
|
|
68
|
+
|
|
69
|
+
For a `type: "fix"` work item that targets an existing PR, the engine reuses the PR's **source branch** (e.g. `yemishin/task-hub-symphony-gate`) instead of derivingthe `user/<loginname>/<wi-id>-<slug>` form. PR pointers are detected from any of:
|
|
70
|
+
|
|
71
|
+
- Structured fields: `targetPr`, `pr_id`, `prUrl`, `prNumber`, `pullRequest`, `sourcePr`
|
|
72
|
+
- `references[*].url` (manual `/api/work-items` callers usually pass the PR pointer here)
|
|
73
|
+
- `meta.pr_followup.parent_pr_url` (follow-up dispatch template)
|
|
74
|
+
- A PR URL or canonical `github:owner/repo#N` / `ado:org/proj/repo#N` id in the `description` text
|
|
75
|
+
|
|
76
|
+
If you are running a fix task and `{{pr_branch}}` is populated, your worktree is already on that branch — push the fix commit there and do **not** create a new PR. If `{{pr_branch}}` is empty but the task description / references clearly point at an existing PR, **stop** and emit a non-retryable completion (`failure_class: 'branch-mismatch'`) instead of branching off master and opening a duplicate PR. See issue #2999 for the canonical bad incident (ADO PR `!5284819` → duplicate `!5288540`).
|
|
77
|
+
|
|
67
78
|
## Engine Rules (apply to all tasks)
|
|
68
79
|
|
|
69
80
|
**Context compaction:** Your context window may be compacted mid-task by Claude's infrastructure. If you notice your earlier conversation history appears truncated or summarized, this is normal and expected. Do not interpret compaction as a signal to stop early or wrap up. Continue working toward your task objective — all relevant instructions and state remain available.
|