@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.
- package/dashboard/js/qa.js +509 -24
- package/dashboard/js/render-prs.js +23 -1
- package/dashboard/js/render-work-items.js +11 -1
- package/dashboard/pages/qa.html +53 -6
- package/dashboard/styles.css +149 -0
- package/dashboard.js +99 -1
- package/docs/completion-reports.md +33 -0
- package/docs/pr-comment-followup.md +206 -0
- package/engine/lifecycle.js +80 -0
- package/package.json +1 -1
- package/playbooks/fix.md +16 -1
- package/playbooks/review.md +14 -0
- package/playbooks/shared-rules.md +21 -0
- package/playbooks/templates/followup-dispatch.md +157 -0
package/dashboard/js/qa.js
CHANGED
|
@@ -1,29 +1,514 @@
|
|
|
1
|
-
// dashboard/js/qa.js — QA tab
|
|
1
|
+
// dashboard/js/qa.js — QA tab UI (W-mpeiwz6k0005bf34-d).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
10
|
-
//
|
|
11
|
-
// engine
|
|
12
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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()">↩ 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>' +
|