@yemi33/minions 0.1.1998 → 0.1.1999

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
  })();
@@ -1,11 +1,58 @@
1
1
  <section>
2
2
  <h2>QA</h2>
3
- <p class="empty" style="margin:4px 0 12px 0">Canonical home for human-driven and agent-driven validation against running managed instances. Runbook dispatch lands in a follow-up WI.</p>
3
+ <p class="empty" style="margin:4px 0 12px 0">Validation runbooks dispatched against live managed instances. Targets, runbooks, and run history with artifact previews.</p>
4
4
  </section>
5
- <section id="qa-runbooks-section">
6
- <h2>Validation Runbooks <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">human or agent-driven smoke / E2E flows against the live instances above</span></h2>
7
- <div style="border:1px dashed var(--border);border-radius:6px;padding:16px;background:var(--surface2);text-align:center">
8
- <p class="empty" style="margin:0 0 12px 0">Validation runbooks will live here. Coming soon: dispatch a human or agent to validate a running instance.</p>
9
- <button id="qa-new-runbook-btn" disabled title="Coming soon — runbook schema lands in the next WI" style="padding:6px 14px;background:var(--surface);color:var(--muted);border:1px solid var(--border);border-radius:4px;cursor:not-allowed;font-size:12px">+ New runbook</button>
5
+ <section id="qa-targets-section" class="qa-section">
6
+ <h2>Targets <span class="qa-section-subtitle">live managed/keep processes available as runbook targets full inventory lives on <a href="/engine" class="qa-engine-link">/engine</a></span></h2>
7
+ <div id="qa-targets-content" class="qa-targets-list">
8
+ <p class="empty">Loading targets…</p>
9
+ </div>
10
+ </section>
11
+ <section id="qa-runbooks-section" class="qa-section">
12
+ <h2>Validation Runbooks <span class="qa-section-subtitle">human or agent-driven smoke / E2E flows against the targets above</span></h2>
13
+ <div class="qa-runbooks-actions">
14
+ <button id="qa-new-runbook-btn" class="qa-btn-primary" onclick="qaShowNewRunbookForm()">+ New runbook</button>
15
+ </div>
16
+ <div id="qa-runbook-form-wrap" class="qa-runbook-form" style="display:none">
17
+ <form id="qa-runbook-form" onsubmit="event.preventDefault();qaSaveRunbook();">
18
+ <div class="qa-form-row">
19
+ <label class="qa-form-label" for="qa-runbook-name">Name</label>
20
+ <input id="qa-runbook-name" type="text" class="qa-form-input" placeholder="login-smoke" required>
21
+ </div>
22
+ <div class="qa-form-row">
23
+ <label class="qa-form-label" for="qa-runbook-target">Target</label>
24
+ <select id="qa-runbook-target" class="qa-form-input" required>
25
+ <option value="">— select a target —</option>
26
+ </select>
27
+ </div>
28
+ <div class="qa-form-row">
29
+ <label class="qa-form-label" for="qa-runbook-steps">Steps</label>
30
+ <textarea id="qa-runbook-steps" class="qa-form-input qa-form-textarea" placeholder="1. Open the app&#10;2. Click login&#10;3. Verify dashboard loads" required></textarea>
31
+ </div>
32
+ <div class="qa-form-row">
33
+ <label class="qa-form-label">Expected artifacts</label>
34
+ <div id="qa-runbook-artifacts" class="qa-artifacts-repeater">
35
+ <div class="qa-artifact-row">
36
+ <input type="text" class="qa-form-input qa-artifact-input" placeholder="screenshot:dashboard.png">
37
+ <button type="button" class="qa-btn-ghost" onclick="qaRemoveArtifactRow(this)">−</button>
38
+ </div>
39
+ </div>
40
+ <button type="button" class="qa-btn-ghost qa-add-artifact-btn" onclick="qaAddArtifactRow()">+ Add artifact</button>
41
+ </div>
42
+ <div class="qa-form-row qa-form-actions">
43
+ <button type="submit" class="qa-btn-primary">Save</button>
44
+ <button type="button" class="qa-btn-ghost" onclick="qaHideNewRunbookForm()">Cancel</button>
45
+ <span id="qa-runbook-form-msg" class="qa-form-msg"></span>
46
+ </div>
47
+ </form>
48
+ </div>
49
+ <div id="qa-runbooks-content" class="qa-runbooks-list">
50
+ <p class="empty">Loading runbooks…</p>
51
+ </div>
52
+ </section>
53
+ <section id="qa-runs-section" class="qa-section">
54
+ <h2>Recent Runs <span class="qa-section-subtitle">latest 50 dispatched validation runs — polled every 5s while this page is active</span></h2>
55
+ <div id="qa-runs-content" class="qa-runs-list">
56
+ <p class="empty">Loading runs…</p>
10
57
  </div>
11
58
  </section>
@@ -736,3 +736,152 @@
736
736
  display: inline-block; max-width: 240px; overflow: hidden;
737
737
  text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
738
738
  }
739
+
740
+ /* QA tab (W-mpeiwz6k0005bf34-d) — targets / runbooks / runs sections.
741
+ * Reuses surface/border/text tokens defined in :root so the QA page
742
+ * blends with the rest of the dashboard. */
743
+ .qa-section { padding: var(--space-7) var(--space-9); }
744
+ .qa-section h2 { display: flex; align-items: baseline; gap: var(--space-4); }
745
+ .qa-section-subtitle {
746
+ font-size: var(--text-sm); color: var(--muted);
747
+ font-weight: 400; text-transform: none; letter-spacing: 0;
748
+ }
749
+ .qa-engine-link { color: var(--blue); text-decoration: none; }
750
+ .qa-engine-link:hover { text-decoration: underline; }
751
+
752
+ /* Targets list — slim row per dedup'd target. */
753
+ .qa-targets-list {
754
+ display: flex; flex-direction: column; gap: var(--space-2);
755
+ border: 1px solid var(--border); border-radius: var(--radius-md);
756
+ background: var(--surface2); padding: var(--space-4);
757
+ }
758
+ .qa-target-row {
759
+ display: flex; align-items: center; gap: var(--space-5);
760
+ padding: var(--space-3) var(--space-4);
761
+ background: var(--surface); border: 1px solid var(--border);
762
+ border-radius: var(--radius-sm); font-size: var(--text-md);
763
+ }
764
+ .qa-target-main { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
765
+ .qa-target-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; color: var(--text); }
766
+ .qa-target-project { color: var(--muted); font-size: var(--text-base); }
767
+ .qa-target-source {
768
+ display: inline-block; margin-left: var(--space-3);
769
+ font-size: var(--text-xs); color: var(--muted);
770
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
771
+ padding: 1px var(--space-3); text-transform: uppercase;
772
+ }
773
+ .qa-target-meta { font-size: var(--text-base); color: var(--muted); white-space: nowrap; }
774
+ .qa-target-link {
775
+ font-size: var(--text-sm); color: var(--blue); text-decoration: none;
776
+ padding: var(--space-1) var(--space-4); border: 1px solid var(--border);
777
+ border-radius: var(--radius-sm); background: var(--surface2);
778
+ }
779
+ .qa-target-link:hover { background: var(--bg); }
780
+ .qa-health-dot { font-size: var(--text-md); }
781
+ .qa-health-ok { color: var(--green); }
782
+ .qa-health-warn { color: var(--yellow); }
783
+ .qa-health-down { color: var(--muted); }
784
+
785
+ /* Runbooks list + inline new-runbook form. */
786
+ .qa-runbooks-actions { margin-bottom: var(--space-5); }
787
+ .qa-runbook-form {
788
+ border: 1px solid var(--border); border-radius: var(--radius-md);
789
+ background: var(--surface2); padding: var(--space-5);
790
+ margin-bottom: var(--space-5);
791
+ }
792
+ .qa-form-row { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-5); }
793
+ .qa-form-label { font-size: var(--text-base); color: var(--muted); font-weight: 600; }
794
+ .qa-form-input {
795
+ background: var(--bg); color: var(--text);
796
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
797
+ padding: var(--space-3) var(--space-4); font-size: var(--text-md);
798
+ font-family: inherit;
799
+ }
800
+ .qa-form-input:focus { border-color: var(--blue); outline: none; }
801
+ .qa-form-textarea { min-height: 88px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; }
802
+ .qa-artifacts-repeater { display: flex; flex-direction: column; gap: var(--space-2); }
803
+ .qa-artifact-row { display: flex; gap: var(--space-3); align-items: center; }
804
+ .qa-artifact-input { flex: 1; }
805
+ .qa-add-artifact-btn { align-self: flex-start; margin-top: var(--space-2); }
806
+ .qa-form-actions { flex-direction: row; align-items: center; gap: var(--space-4); }
807
+ .qa-form-msg { font-size: var(--text-base); margin-left: var(--space-3); }
808
+ .qa-btn-primary {
809
+ background: var(--blue); color: #fff; border: none;
810
+ border-radius: var(--radius-sm); padding: var(--space-3) var(--space-7);
811
+ font-size: var(--text-md); font-weight: 600; cursor: pointer;
812
+ }
813
+ .qa-btn-primary:hover { opacity: 0.9; }
814
+ .qa-btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
815
+ .qa-btn-ghost {
816
+ background: transparent; color: var(--muted);
817
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
818
+ padding: var(--space-2) var(--space-5); font-size: var(--text-base);
819
+ cursor: pointer;
820
+ }
821
+ .qa-btn-ghost:hover { color: var(--text); background: var(--surface); }
822
+
823
+ .qa-runbooks-list { display: flex; flex-direction: column; gap: var(--space-3); }
824
+ .qa-runbook-row {
825
+ display: flex; align-items: center; gap: var(--space-5);
826
+ padding: var(--space-4) var(--space-5);
827
+ background: var(--surface2); border: 1px solid var(--border);
828
+ border-radius: var(--radius-sm);
829
+ }
830
+ .qa-runbook-main { flex: 1; min-width: 0; }
831
+ .qa-runbook-name { font-weight: 600; color: var(--text); font-size: var(--text-md); }
832
+ .qa-runbook-meta { font-size: var(--text-base); color: var(--muted); margin-top: var(--space-1); }
833
+
834
+ /* Runs list — status badge + inline artifact previews. */
835
+ .qa-runs-list { display: flex; flex-direction: column; gap: var(--space-4); }
836
+ .qa-run-row {
837
+ border: 1px solid var(--border); border-radius: var(--radius-md);
838
+ background: var(--surface2); padding: var(--space-4) var(--space-5);
839
+ }
840
+ .qa-run-head { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; font-size: var(--text-md); }
841
+ .qa-run-status {
842
+ display: inline-block; font-size: var(--text-xs); font-weight: 700;
843
+ padding: 2px var(--space-3); border-radius: var(--radius-sm);
844
+ text-transform: uppercase; letter-spacing: 0.5px;
845
+ background: var(--surface); color: var(--muted); border: 1px solid var(--border);
846
+ }
847
+ .qa-status-passed, .qa-status-success { color: var(--green); border-color: var(--green); }
848
+ .qa-status-failed, .qa-status-error { color: var(--red); border-color: var(--red); }
849
+ .qa-status-running, .qa-status-dispatched { color: var(--blue); border-color: var(--blue); }
850
+ .qa-status-pending { color: var(--yellow); border-color: var(--yellow); }
851
+ .qa-run-name { font-weight: 600; color: var(--text); }
852
+ .qa-run-target { color: var(--muted); font-size: var(--text-base); }
853
+ .qa-run-ts { color: var(--muted); font-size: var(--text-base); margin-left: auto; }
854
+ .qa-run-actions { margin-left: var(--space-5); }
855
+ .qa-run-link { color: var(--blue); text-decoration: none; font-size: var(--text-base); }
856
+ .qa-run-link:hover { text-decoration: underline; }
857
+
858
+ .qa-run-artifacts {
859
+ display: flex; flex-wrap: wrap; gap: var(--space-4);
860
+ margin-top: var(--space-4); padding-top: var(--space-4);
861
+ border-top: 1px solid var(--border);
862
+ }
863
+ .qa-artifact {
864
+ display: flex; flex-direction: column; gap: var(--space-2);
865
+ background: var(--surface); border: 1px solid var(--border);
866
+ border-radius: var(--radius-sm); padding: var(--space-3);
867
+ max-width: 320px;
868
+ }
869
+ .qa-artifact img, .qa-artifact video {
870
+ max-width: 100%; max-height: 200px; border-radius: var(--radius-sm);
871
+ background: var(--bg);
872
+ }
873
+ .qa-artifact-caption {
874
+ font-size: var(--text-xs); color: var(--muted);
875
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
876
+ word-break: break-all;
877
+ }
878
+ .qa-artifact-fulllink { color: var(--blue); text-decoration: none; margin-left: var(--space-3); }
879
+ .qa-artifact-fulllink:hover { text-decoration: underline; }
880
+ .qa-artifact-log-preview {
881
+ background: var(--bg); color: var(--text);
882
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
883
+ font-size: var(--text-xs); padding: var(--space-3);
884
+ border-radius: var(--radius-sm); margin: 0;
885
+ max-height: 160px; overflow: auto; white-space: pre;
886
+ }
887
+ .qa-artifact-log { max-width: 480px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1998",
3
+ "version": "0.1.1999",
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"