@yemi33/minions 0.1.1998 → 0.1.2000

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,514 @@
1
- // dashboard/js/qa.js — QA tab wiring (W-mpd5ewhj000oc5c5).
1
+ // dashboard/js/qa.js — QA tab UI (W-mpeiwz6k0005bf34-d).
2
2
  //
3
- // The QA tab hosts validation runbooks (human-driven and agent-driven) against
4
- // running managed instances. It does NOT mirror the live-process inventory —
5
- // that lives on /engine (Managed Processes + Keep-Processes panels). Runbook
6
- // rows in the next WI will link to their targets by name rather than duplicate
7
- // the inventory tables (W-mpdad3mq000m53bb).
3
+ // Three sections:
4
+ // 1. Targets slim dedup'd list of /api/managed-processes + /api/keep-processes
5
+ // with a health badge and a link back to /engine (full inventory tables
6
+ // live on /engine; see W-mpdad3mq000m53bb do NOT mirror them here).
7
+ // 2. Runbooks list from GET /api/qa/runbooks with an enabled "+ New runbook"
8
+ // button that opens an inline form (name / target dropdown / steps textarea
9
+ // / expected-artifacts repeater) wired to POST /api/qa/runbooks. Each row
10
+ // has a "Run" button that POSTs to /api/qa/runbooks/run.
11
+ // 3. Runs — list from GET /api/qa/runs?limit=50, polled every 5s while the
12
+ // QA page is active. Each row links to the live agent stream and renders
13
+ // inline artifact previews via /api/qa/artifacts/<runId>/<file>
14
+ // (screenshots as <img>, videos as <video>, logs as 40-line text preview
15
+ // with a "View full" link). No direct filesystem paths are exposed —
16
+ // artifact URLs always go through /api/qa/artifacts/.
8
17
  //
9
- // The only wiring this file owns is the switchPage SSE-close hook: if the
10
- // user has the managed-log modal (defined in render-managed.js) open on the
11
- // engine page and navigates away, we close the EventSource so it doesn't
12
- // keep streaming behind the new page. The hook is harmless when the QA page
13
- // never opens the modal itself, and matches the broader page-navigation
14
- // contract for SSE cleanup.
18
+ // The polling interval is cleared on page navigation via the switchPage
19
+ // wrapper below (matches the same pattern keep_processes / managed-spawn
20
+ // uses on /engine). The wrapper ALSO closes any open managed-log SSE so a
21
+ // modal opened from /engine doesn't keep streaming after the user navigates.
15
22
 
16
- (function () {
17
- // Close any open managed-log SSE stream when the user navigates away from a
18
- // page that triggered it — the modal otherwise floats over the new page and
19
- // the EventSource keeps streaming. Hooks into the existing switchPage()
20
- // function from state.js without changing its signature.
21
- if (typeof switchPage === 'function' && !switchPage.__qaWrapped) {
22
- const _origSwitchPage = switchPage;
23
- window.switchPage = function (page, pushState) {
24
- try { if (typeof closeManagedLog === 'function') closeManagedLog(); } catch {}
25
- return _origSwitchPage(page, pushState);
26
- };
27
- window.switchPage.__qaWrapped = true;
23
+ let _qaRunsPollInterval = null;
24
+ let _qaTargetsCache = [];
25
+ let _qaRunbooksCache = [];
26
+
27
+ function _stopQaRunsPoll() {
28
+ if (_qaRunsPollInterval) {
29
+ clearInterval(_qaRunsPollInterval);
30
+ _qaRunsPollInterval = null;
31
+ }
32
+ }
33
+
34
+ function _startQaRunsPoll() {
35
+ _stopQaRunsPoll();
36
+ // Initial fetch + 5s poll (per acceptance criteria). The poll is bounded
37
+ // to the QA page via switchPage cleanup below.
38
+ loadQaRuns();
39
+ _qaRunsPollInterval = setInterval(loadQaRuns, 5000);
40
+ }
41
+
42
+ // ── Section 1: Targets ─────────────────────────────────────────────────────
43
+ async function loadQaTargets() {
44
+ const root = document.getElementById('qa-targets-content');
45
+ if (!root) return;
46
+ let managed = [];
47
+ let keep = [];
48
+ let err = null;
49
+ try {
50
+ const [mRes, kRes] = await Promise.all([
51
+ fetch('/api/managed-processes').then(r => r.ok ? r.json() : { items: [] }).catch(() => ({ items: [] })),
52
+ fetch('/api/keep-processes').then(r => r.ok ? r.json() : { items: [] }).catch(() => ({ items: [] })),
53
+ ]);
54
+ managed = Array.isArray(mRes && mRes.items) ? mRes.items : [];
55
+ keep = Array.isArray(kRes && kRes.items) ? kRes.items : [];
56
+ } catch (e) { err = e; }
57
+
58
+ if (err) {
59
+ const frag = document.createRange().createContextualFragment(
60
+ '<p class="empty" style="color:var(--red)">Failed to load targets: ' + escHtml(err.message || String(err)) + '</p>'
61
+ );
62
+ root.replaceChildren(frag);
63
+ _qaTargetsCache = [];
64
+ _qaPopulateTargetDropdown();
65
+ return;
66
+ }
67
+
68
+ // Dedup by name+project: a managed spec and a keep_processes entry can
69
+ // declare the same name (managed is canonical because the engine owns the
70
+ // lifecycle). Build a Set keyed by `<project>::<name>` and skip keep entries
71
+ // whose name is already represented in managed.
72
+ const seen = new Set();
73
+ const targets = [];
74
+ for (const m of managed) {
75
+ if (!m || !m.name) continue;
76
+ const key = (m.owner_project || '') + '::' + m.name;
77
+ if (seen.has(key)) continue;
78
+ seen.add(key);
79
+ targets.push({
80
+ name: m.name,
81
+ project: m.owner_project || '',
82
+ source: 'managed',
83
+ healthy: !!m.healthy,
84
+ alive: !!m.alive,
85
+ ports: Array.isArray(m.ports) ? m.ports.slice() : [],
86
+ });
87
+ }
88
+ for (const k of keep) {
89
+ if (!k || !k.valid) continue;
90
+ // Keep-processes entries are keyed by agent — use the agent's purpose
91
+ // as a friendly name fallback when no explicit name is present.
92
+ const name = k.name || k.purpose || k.agentId || '';
93
+ if (!name) continue;
94
+ const project = k.project || '';
95
+ const key = project + '::' + name;
96
+ if (seen.has(key)) continue;
97
+ seen.add(key);
98
+ const anyAlive = Array.isArray(k.pids) && k.pids.some(p => p && p.alive);
99
+ targets.push({
100
+ name,
101
+ project,
102
+ source: 'keep',
103
+ healthy: anyAlive,
104
+ alive: anyAlive,
105
+ ports: Array.isArray(k.ports) ? k.ports.slice() : [],
106
+ });
107
+ }
108
+
109
+ _qaTargetsCache = targets;
110
+ _qaPopulateTargetDropdown();
111
+
112
+ if (!targets.length) {
113
+ const frag = document.createRange().createContextualFragment(
114
+ '<p class="empty">No live targets. Spawn a service via <code>meta.managed_spawn</code> or <code>meta.keep_processes</code> to populate this list. Full inventory on <a href="/engine">/engine</a>.</p>'
115
+ );
116
+ root.replaceChildren(frag);
117
+ return;
118
+ }
119
+
120
+ const rows = targets.map(function (t) {
121
+ const badge = t.healthy
122
+ ? '<span class="qa-health-dot qa-health-ok">●</span> healthy'
123
+ : (t.alive ? '<span class="qa-health-dot qa-health-warn">●</span> starting' : '<span class="qa-health-dot qa-health-down">○</span> down');
124
+ const portsTxt = (t.ports && t.ports.length) ? ' ports: ' + t.ports.join(',') : '';
125
+ const projTxt = t.project ? ' <span class="qa-target-project">(' + escHtml(t.project) + ')</span>' : '';
126
+ const srcTxt = '<span class="qa-target-source">' + escHtml(t.source) + '</span>';
127
+ return '<div class="qa-target-row">' +
128
+ '<div class="qa-target-main"><span class="qa-target-name">' + escHtml(t.name) + '</span>' + projTxt + ' ' + srcTxt + '</div>' +
129
+ '<div class="qa-target-meta">' + badge + escHtml(portsTxt) + '</div>' +
130
+ '<a href="/engine" class="qa-target-link">View on /engine</a>' +
131
+ '</div>';
132
+ }).join('');
133
+ // Use createContextualFragment + replaceChildren to keep this file out of
134
+ // the dynamic-innerHTML regression gate (cf. test/unit.test.js
135
+ // DYNAMIC_INNERHTML_BASELINE — all interpolated fields above are wrapped
136
+ // in escHtml()).
137
+ const frag = document.createRange().createContextualFragment(rows);
138
+ root.replaceChildren(frag);
139
+ }
140
+
141
+ function _qaPopulateTargetDropdown() {
142
+ const sel = document.getElementById('qa-runbook-target');
143
+ if (!sel) return;
144
+ const current = sel.value;
145
+ // Build option nodes via DOM API — no innerHTML.
146
+ while (sel.firstChild) sel.removeChild(sel.firstChild);
147
+ const placeholder = document.createElement('option');
148
+ placeholder.value = '';
149
+ placeholder.textContent = '— select a target —';
150
+ sel.appendChild(placeholder);
151
+ for (const t of _qaTargetsCache) {
152
+ const opt = document.createElement('option');
153
+ opt.value = t.name;
154
+ const projLabel = t.project ? ' (' + t.project + ')' : '';
155
+ opt.textContent = t.name + projLabel + ' [' + t.source + ']';
156
+ sel.appendChild(opt);
157
+ }
158
+ if (current) sel.value = current;
159
+ }
160
+
161
+ // ── Section 2: Runbooks ────────────────────────────────────────────────────
162
+ async function loadQaRunbooks() {
163
+ const root = document.getElementById('qa-runbooks-content');
164
+ if (!root) return;
165
+ let items = [];
166
+ let err = null;
167
+ try {
168
+ const res = await fetch('/api/qa/runbooks');
169
+ if (res.ok) {
170
+ const data = await res.json();
171
+ items = Array.isArray(data && data.items) ? data.items : (Array.isArray(data) ? data : []);
172
+ } else if (res.status !== 404) {
173
+ err = new Error('HTTP ' + res.status);
174
+ }
175
+ } catch (e) { err = e; }
176
+ _qaRunbooksCache = items;
177
+
178
+ if (err) {
179
+ const frag = document.createRange().createContextualFragment(
180
+ '<p class="empty" style="color:var(--red)">Failed to load runbooks: ' + escHtml(err.message || String(err)) + '</p>'
181
+ );
182
+ root.replaceChildren(frag);
183
+ return;
184
+ }
185
+ if (!items.length) {
186
+ const frag = document.createRange().createContextualFragment(
187
+ '<p class="empty">No runbooks yet. Click <strong>+ New runbook</strong> to create one.</p>'
188
+ );
189
+ root.replaceChildren(frag);
190
+ return;
191
+ }
192
+ const rows = items.map(function (rb) {
193
+ const id = rb.id || rb.name || '';
194
+ const stepsCount = Array.isArray(rb.steps) ? rb.steps.length : (rb.steps ? String(rb.steps).split(/\r?\n/).filter(Boolean).length : 0);
195
+ const artifactsCount = Array.isArray(rb.expectedArtifacts) ? rb.expectedArtifacts.length : 0;
196
+ return '<div class="qa-runbook-row">' +
197
+ '<div class="qa-runbook-main">' +
198
+ '<div class="qa-runbook-name">' + escHtml(rb.name || id) + '</div>' +
199
+ '<div class="qa-runbook-meta">target: <code>' + escHtml(rb.target || '?') + '</code> · ' +
200
+ stepsCount + ' step' + (stepsCount === 1 ? '' : 's') + ' · ' +
201
+ artifactsCount + ' expected artifact' + (artifactsCount === 1 ? '' : 's') +
202
+ '</div>' +
203
+ '</div>' +
204
+ '<button class="qa-btn-primary qa-runbook-run-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaRunRunbook(this.dataset.runbookId)">Run</button>' +
205
+ '</div>';
206
+ }).join('');
207
+ const frag = document.createRange().createContextualFragment(rows);
208
+ root.replaceChildren(frag);
209
+ }
210
+
211
+ function qaShowNewRunbookForm() {
212
+ const wrap = document.getElementById('qa-runbook-form-wrap');
213
+ if (!wrap) return;
214
+ wrap.style.display = 'block';
215
+ // Make sure target dropdown reflects the current target cache.
216
+ _qaPopulateTargetDropdown();
217
+ const name = document.getElementById('qa-runbook-name');
218
+ if (name) name.focus();
219
+ }
220
+
221
+ function qaHideNewRunbookForm() {
222
+ const wrap = document.getElementById('qa-runbook-form-wrap');
223
+ if (!wrap) return;
224
+ wrap.style.display = 'none';
225
+ const msg = document.getElementById('qa-runbook-form-msg');
226
+ if (msg) msg.textContent = '';
227
+ }
228
+
229
+ function qaAddArtifactRow() {
230
+ const wrap = document.getElementById('qa-runbook-artifacts');
231
+ if (!wrap) return;
232
+ const row = document.createElement('div');
233
+ row.className = 'qa-artifact-row';
234
+ const input = document.createElement('input');
235
+ input.type = 'text';
236
+ input.className = 'qa-form-input qa-artifact-input';
237
+ input.placeholder = 'log:test.log';
238
+ const btn = document.createElement('button');
239
+ btn.type = 'button';
240
+ btn.className = 'qa-btn-ghost';
241
+ btn.textContent = '−';
242
+ btn.onclick = function () { qaRemoveArtifactRow(btn); };
243
+ row.appendChild(input);
244
+ row.appendChild(btn);
245
+ wrap.appendChild(row);
246
+ }
247
+
248
+ function qaRemoveArtifactRow(btn) {
249
+ const row = btn && btn.closest ? btn.closest('.qa-artifact-row') : null;
250
+ if (!row) return;
251
+ const wrap = document.getElementById('qa-runbook-artifacts');
252
+ if (wrap && wrap.children.length <= 1) {
253
+ // Don't delete the last row — just clear it.
254
+ const inp = row.querySelector('input');
255
+ if (inp) inp.value = '';
256
+ return;
28
257
  }
258
+ row.remove();
259
+ }
260
+
261
+ async function qaSaveRunbook() {
262
+ const msg = document.getElementById('qa-runbook-form-msg');
263
+ const name = (document.getElementById('qa-runbook-name') || {}).value || '';
264
+ const target = (document.getElementById('qa-runbook-target') || {}).value || '';
265
+ const stepsRaw = (document.getElementById('qa-runbook-steps') || {}).value || '';
266
+ const artifactInputs = document.querySelectorAll('#qa-runbook-artifacts .qa-artifact-input');
267
+ const expectedArtifacts = Array.from(artifactInputs)
268
+ .map(function (i) { return (i.value || '').trim(); })
269
+ .filter(Boolean);
270
+ if (!name.trim() || !target.trim() || !stepsRaw.trim()) {
271
+ if (msg) { msg.textContent = 'Name, target and steps are required.'; msg.style.color = 'var(--red)'; }
272
+ return;
273
+ }
274
+ if (msg) { msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
275
+ try {
276
+ const res = await fetch('/api/qa/runbooks', {
277
+ method: 'POST',
278
+ headers: { 'Content-Type': 'application/json' },
279
+ body: JSON.stringify({
280
+ name: name.trim(),
281
+ target: target.trim(),
282
+ steps: stepsRaw,
283
+ expectedArtifacts,
284
+ }),
285
+ });
286
+ if (!res.ok) {
287
+ const txt = await res.text().catch(() => '');
288
+ throw new Error('HTTP ' + res.status + (txt ? ': ' + txt.slice(0, 200) : ''));
289
+ }
290
+ if (msg) { msg.textContent = 'Saved.'; msg.style.color = 'var(--green)'; }
291
+ // Reset form + reload list.
292
+ const form = document.getElementById('qa-runbook-form');
293
+ if (form) form.reset();
294
+ qaHideNewRunbookForm();
295
+ loadQaRunbooks();
296
+ } catch (e) {
297
+ if (msg) { msg.textContent = 'Failed to save: ' + (e.message || String(e)); msg.style.color = 'var(--red)'; }
298
+ }
299
+ }
300
+
301
+ async function qaRunRunbook(id) {
302
+ if (!id) return;
303
+ const rb = _qaRunbooksCache.find(function (r) { return (r.id || r.name) === id; });
304
+ const label = rb ? (rb.name || id) : id;
305
+ if (!confirm('Dispatch a validation agent for runbook "' + label + '"?')) return;
306
+ try {
307
+ const res = await fetch('/api/qa/runbooks/run', {
308
+ method: 'POST',
309
+ headers: { 'Content-Type': 'application/json' },
310
+ body: JSON.stringify({ id }),
311
+ });
312
+ if (!res.ok) {
313
+ const txt = await res.text().catch(() => '');
314
+ throw new Error('HTTP ' + res.status + (txt ? ': ' + txt.slice(0, 200) : ''));
315
+ }
316
+ // Reload runs immediately so the new dispatch shows up in section 3.
317
+ loadQaRuns();
318
+ } catch (e) {
319
+ alert('Failed to dispatch runbook "' + label + '": ' + (e.message || String(e)));
320
+ }
321
+ }
322
+
323
+ // ── Section 3: Runs ─────────────────────────────────────────────────────────
324
+ async function loadQaRuns() {
325
+ const root = document.getElementById('qa-runs-content');
326
+ if (!root) return;
327
+ let items = [];
328
+ let err = null;
329
+ try {
330
+ const res = await fetch('/api/qa/runs?limit=50');
331
+ if (res.ok) {
332
+ const data = await res.json();
333
+ items = Array.isArray(data && data.items) ? data.items : (Array.isArray(data) ? data : []);
334
+ } else if (res.status !== 404) {
335
+ err = new Error('HTTP ' + res.status);
336
+ }
337
+ } catch (e) { err = e; }
338
+
339
+ if (err) {
340
+ const frag = document.createRange().createContextualFragment(
341
+ '<p class="empty" style="color:var(--red)">Failed to load runs: ' + escHtml(err.message || String(err)) + '</p>'
342
+ );
343
+ root.replaceChildren(frag);
344
+ return;
345
+ }
346
+ if (!items.length) {
347
+ const frag = document.createRange().createContextualFragment(
348
+ '<p class="empty">No runs yet. Dispatch a runbook above to see results here.</p>'
349
+ );
350
+ root.replaceChildren(frag);
351
+ return;
352
+ }
353
+ const rows = items.map(_qaRenderRunRow).join('');
354
+ const frag = document.createRange().createContextualFragment(rows);
355
+ root.replaceChildren(frag);
356
+ }
357
+
358
+ function _qaRenderRunRow(run) {
359
+ const id = run.id || run.runId || '';
360
+ const status = run.status || 'unknown';
361
+ const statusClass = 'qa-status-' + status.toLowerCase().replace(/[^a-z0-9-]/g, '-');
362
+ const runbook = run.runbookName || run.runbook || '';
363
+ const target = run.target || '';
364
+ const ts = run.startedAt || run.createdAt || '';
365
+ const workItemId = run.workItemId || run.workItem || '';
366
+ const agentId = run.agentId || '';
367
+ // Live-stream link to /agent/<workItemId> per the acceptance contract;
368
+ // routes through openAgentDetail when available so the existing dashboard
369
+ // detail panel handles the live tail. Falls back to a plain anchor when
370
+ // the page is loaded standalone (e.g. snapshot test).
371
+ const linkLabel = workItemId ? 'View live agent →' : (agentId ? 'View live agent →' : '');
372
+ let liveLink = '';
373
+ if (workItemId) {
374
+ liveLink = '<a href="/agent/' + encodeURIComponent(workItemId) + '" class="qa-run-link" ' +
375
+ 'onclick="event.preventDefault();qaOpenRunAgent(this.dataset.wi,this.dataset.agent);" ' +
376
+ 'data-wi="' + escHtml(workItemId) + '" data-agent="' + escHtml(agentId) + '">' + escHtml(linkLabel) + '</a>';
377
+ } else if (agentId) {
378
+ liveLink = '<a href="#" class="qa-run-link" onclick="event.preventDefault();qaOpenRunAgent(\'\',this.dataset.agent);" data-agent="' + escHtml(agentId) + '">' + escHtml(linkLabel) + '</a>';
379
+ }
380
+ const artifactsHtml = _qaRenderArtifactPreviews(id, run.artifacts || []);
381
+ return '<div class="qa-run-row">' +
382
+ '<div class="qa-run-head">' +
383
+ '<span class="qa-run-status ' + escHtml(statusClass) + '">' + escHtml(status) + '</span>' +
384
+ '<span class="qa-run-name">' + escHtml(runbook) + '</span>' +
385
+ (target ? '<span class="qa-run-target">→ <code>' + escHtml(target) + '</code></span>' : '') +
386
+ (ts ? '<span class="qa-run-ts">' + escHtml(String(ts).slice(0, 19).replace('T', ' ')) + '</span>' : '') +
387
+ (liveLink ? '<span class="qa-run-actions">' + liveLink + '</span>' : '') +
388
+ '</div>' +
389
+ (artifactsHtml ? '<div class="qa-run-artifacts">' + artifactsHtml + '</div>' : '') +
390
+ '</div>';
391
+ }
392
+
393
+ function _qaRenderArtifactPreviews(runId, artifacts) {
394
+ if (!runId || !Array.isArray(artifacts) || !artifacts.length) return '';
395
+ const parts = [];
396
+ for (const a of artifacts) {
397
+ // Accept either a string filename or { file, kind } shape. Strip any
398
+ // path separators so a malicious entry can't escape the /api/qa/artifacts/
399
+ // namespace — the server is canonical here, but client-side normalization
400
+ // keeps the URL pattern observable in source-inspection tests.
401
+ const fileRaw = (typeof a === 'string') ? a : (a && (a.file || a.name || a.path) || '');
402
+ if (!fileRaw) continue;
403
+ const file = String(fileRaw).split(/[\\/]/).pop();
404
+ if (!file) continue;
405
+ const kindHint = (typeof a === 'object' && a && a.kind) ? String(a.kind).toLowerCase() : '';
406
+ const ext = (file.match(/\.([a-z0-9]+)$/i) || ['', ''])[1].toLowerCase();
407
+ const url = '/api/qa/artifacts/' + encodeURIComponent(runId) + '/' + encodeURIComponent(file);
408
+ if (kindHint === 'screenshot' || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
409
+ parts.push(
410
+ '<div class="qa-artifact qa-artifact-image">' +
411
+ '<img src="' + escHtml(url) + '" alt="' + escHtml(file) + '" loading="lazy">' +
412
+ '<div class="qa-artifact-caption">' + escHtml(file) + '</div>' +
413
+ '</div>'
414
+ );
415
+ } else if (kindHint === 'video' || ['mp4', 'webm', 'ogg', 'mov'].includes(ext)) {
416
+ parts.push(
417
+ '<div class="qa-artifact qa-artifact-video">' +
418
+ '<video controls src="' + escHtml(url) + '"></video>' +
419
+ '<div class="qa-artifact-caption">' + escHtml(file) + '</div>' +
420
+ '</div>'
421
+ );
422
+ } else {
423
+ // Log / text — render an asynchronously-filled <pre>; first 40 lines
424
+ // fetched lazily so we don't N+1-flood the network on every poll.
425
+ const blockId = 'qa-log-' + runId + '-' + file.replace(/[^a-zA-Z0-9_-]/g, '_');
426
+ parts.push(
427
+ '<div class="qa-artifact qa-artifact-log">' +
428
+ '<div class="qa-artifact-caption">' +
429
+ escHtml(file) + ' ' +
430
+ '<a href="' + escHtml(url) + '" target="_blank" rel="noopener" class="qa-artifact-fulllink">View full</a>' +
431
+ '</div>' +
432
+ '<pre id="' + escHtml(blockId) + '" class="qa-artifact-log-preview" data-qa-log-url="' + escHtml(url) + '">Loading…</pre>' +
433
+ '</div>'
434
+ );
435
+ }
436
+ }
437
+ return parts.join('');
438
+ }
439
+
440
+ // Lazily fetch log previews after the runs DOM is in place. Runs every time
441
+ // loadQaRuns finishes; cheap because the `data-qa-log-url` lookup short-
442
+ // circuits on already-loaded blocks.
443
+ function _qaFillLogPreviews() {
444
+ const blocks = document.querySelectorAll('#qa-runs-content .qa-artifact-log-preview[data-qa-log-url]');
445
+ blocks.forEach(function (el) {
446
+ const url = el.getAttribute('data-qa-log-url');
447
+ if (!url || el.__qaLoaded) return;
448
+ el.__qaLoaded = true;
449
+ fetch(url)
450
+ .then(function (r) { return r.ok ? r.text() : ''; })
451
+ .then(function (txt) {
452
+ const head = (txt || '').split(/\r?\n/).slice(0, 40).join('\n');
453
+ el.textContent = head || '(empty)';
454
+ })
455
+ .catch(function () { el.textContent = '(failed to load)'; });
456
+ });
457
+ }
458
+
459
+ function qaOpenRunAgent(workItemId, agentId) {
460
+ // Prefer the agent live-detail panel if available; fall back to the
461
+ // work-item detail; fall back to a hard navigation as a last resort.
462
+ if (agentId && typeof openAgentDetail === 'function') { try { openAgentDetail(agentId); return; } catch {} }
463
+ if (workItemId && typeof openWorkItemDetail === 'function') { try { openWorkItemDetail(workItemId); return; } catch {} }
464
+ if (workItemId) location.href = '/agent/' + encodeURIComponent(workItemId);
465
+ }
466
+
467
+ // ── Page-navigation hooks ──────────────────────────────────────────────────
468
+ // switchPage wrapper:
469
+ // - clears the runs polling interval (section 3) so the QA page stops
470
+ // polling when the user navigates away
471
+ // - closes any open managed-log SSE stream so a modal opened from /engine
472
+ // doesn't keep streaming behind the new page
473
+ // - lazily kicks off QA page loads + polling when entering /qa
474
+ (function () {
475
+ if (typeof switchPage !== 'function' || switchPage.__qaWrapped) return;
476
+ const _origSwitchPage = switchPage;
477
+ window.switchPage = function (page, pushState) {
478
+ try { if (typeof closeManagedLog === 'function') closeManagedLog(); } catch {}
479
+ try { _stopQaRunsPoll(); } catch {}
480
+ const ret = _origSwitchPage(page, pushState);
481
+ if (page === 'qa') {
482
+ try { loadQaTargets(); } catch {}
483
+ try { loadQaRunbooks(); } catch {}
484
+ try { _startQaRunsPoll(); } catch {}
485
+ }
486
+ return ret;
487
+ };
488
+ window.switchPage.__qaWrapped = true;
489
+ })();
490
+
491
+ // Refresh log previews after every runs render — _qaFillLogPreviews is a
492
+ // no-op for already-loaded blocks, so polling repeatedly is safe.
493
+ (function () {
494
+ const _origLoad = loadQaRuns;
495
+ window.loadQaRuns = async function () {
496
+ const ret = await _origLoad.apply(this, arguments);
497
+ try { _qaFillLogPreviews(); } catch {}
498
+ return ret;
499
+ };
500
+ })();
501
+
502
+ // If the user landed directly on /qa (deep-link), the switchPage wrapper
503
+ // above runs after refresh.js calls switchPage(currentPage) — but only if
504
+ // qa.js was already evaluated. Guard with currentPage so we initialize
505
+ // targets/runbooks/runs immediately on first paint.
506
+ (function () {
507
+ try {
508
+ if (typeof currentPage !== 'undefined' && currentPage === 'qa') {
509
+ loadQaTargets();
510
+ loadQaRunbooks();
511
+ _startQaRunsPoll();
512
+ }
513
+ } catch {}
29
514
  })();
@@ -4,6 +4,24 @@ let allPrs = [];
4
4
  let prPage = 0;
5
5
  const PR_PER_PAGE = 25;
6
6
 
7
+ function _countPrFollowups(pr) {
8
+ // PR follow-up chip (W-mpej3cox00099466) — counts WIs whose
9
+ // meta.pr_followup.parent_pr_url or parent_pr_id matches this PR.
10
+ if (!pr) return 0;
11
+ var wis = (window._lastWorkItems) || [];
12
+ if (!wis.length) return 0;
13
+ var prUrl = pr.url || '';
14
+ var prId = pr.id || '';
15
+ var n = 0;
16
+ for (var i = 0; i < wis.length; i++) {
17
+ var f = wis[i] && wis[i].meta && wis[i].meta.pr_followup;
18
+ if (!f) continue;
19
+ if (prUrl && f.parent_pr_url === prUrl) { n++; continue; }
20
+ if (prId && f.parent_pr_id === prId) { n++; }
21
+ }
22
+ return n;
23
+ }
24
+
7
25
  function prRow(pr) {
8
26
  // Minions review (agent) state — separate from ADO human review
9
27
  const sq = pr.minionsReview || {};
@@ -27,9 +45,13 @@ function prRow(pr) {
27
45
  const pendingReasonHtml = pendingReason
28
46
  ? '<div style="font-size:9px;color:var(--muted);margin-top:2px" title="Pending reason: ' + escapeHtml(pendingReason) + '">' + escapeHtml(pendingReason.replace(/_/g, ' ')) + '</div>'
29
47
  : '';
48
+ var followupCount = _countPrFollowups(pr);
49
+ var followupChip = followupCount > 0
50
+ ? ' <span class="pr-badge draft" style="font-size:8px" title="' + followupCount + ' follow-up work item(s) dispatched from comments on this PR">+' + followupCount + ' follow-up' + (followupCount === 1 ? '' : 's') + '</span>'
51
+ : '';
30
52
  return '<tr>' +
31
53
  '<td><span class="pr-id">' + escapeHtml(String(prId)) + '</span></td>' +
32
- '<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
54
+ '<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + followupChip + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
33
55
  '<td><span class="pr-agent">' + escapeHtml(pr.agent || '—') + '</span></td>' +
34
56
  '<td><span class="' + branchClass + '"' + (branchError ? ' title="' + escapeHtml(branchError) + '"' : '') + '>' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
35
57
  '<td><span class="pr-badge ' + reviewClass + '"' + (reviewTitle ? ' title="' + escapeHtml(reviewTitle) + '"' : '') + '>' + escapeHtml(reviewLabel) + '</span></td>' +
@@ -40,9 +40,19 @@ function wiRow(item) {
40
40
  : (item.branchStrategy === 'shared-branch' && item.status === 'done')
41
41
  ? '<span style="font-size:9px;color:var(--muted)" title="Part of shared branch — aggregate PR created at verify stage">shared branch</span>'
42
42
  : '<span style="color:var(--muted)">—</span>';
43
+ // PR follow-up chip (W-mpej3cox00099466) — surfaced when the WI was spun
44
+ // off from a PR comment via meta.pr_followup. Links to the parent PR.
45
+ var prFollowup = item.meta && item.meta.pr_followup;
46
+ var followupChip = '';
47
+ if (prFollowup && prFollowup.parent_pr_url) {
48
+ var prRef = prFollowup.parent_pr_id || prFollowup.parent_pr_url;
49
+ var prNumMatch = String(prRef).match(/(\d+)(?!.*\d)/);
50
+ var prLabel = prNumMatch ? ('PR #' + prNumMatch[1]) : 'parent PR';
51
+ followupChip = ' <a class="pr-badge draft" style="font-size:8px;text-decoration:none" target="_blank" rel="noopener" href="' + escapeHtml(prFollowup.parent_pr_url) + '" title="Follow-up dispatched from ' + escapeHtml(prRef) + (prFollowup.parent_comment_author ? ' by ' + escapeHtml(prFollowup.parent_comment_author) : '') + '" onclick="event.stopPropagation()">&#8617; from ' + escapeHtml(prLabel) + '</a>';
52
+ }
43
53
  return '<tr data-wi-id="' + escapeHtml(item.id) + '" style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWorkItemDetail(\'' + escapeHtml(item.id) + '\')">' +
44
54
  '<td><span class="pr-id">' + escapeHtml(item.id || '') + '</span></td>' +
45
- '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + '</td>' +
55
+ '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + followupChip + '</td>' +
46
56
  '<td><span style="font-size:10px;color:var(--muted)">' + escapeHtml(item._source || '') + '</span>' +
47
57
  (item.scope === 'fan-out' ? ' <span class="pr-badge ' + (item.status === 'done' || item.status === 'failed' ? 'draft' : 'building') + '" style="font-size:8px">fan-out</span>' : '') + '</td>' +
48
58
  '<td>' + typeBadge(item.type) + '</td>' +