@yemi33/minions 0.1.2082 → 0.1.2083
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 +41 -2
- package/dashboard/js/settings.js +56 -56
- package/dashboard/js/state.js +1 -1
- package/dashboard/pages/qa.html +3 -2
- package/dashboard.js +27 -6
- package/docs/qa-runbook-lifecycle.md +21 -1
- package/engine/features.js +7 -2
- package/engine/playbook.js +8 -0
- package/engine/qa-sessions.js +316 -42
- package/engine.js +17 -0
- package/package.json +1 -1
- package/playbooks/qa-session-setup.md +32 -0
- package/prompts/cc-system.md +14 -6
package/dashboard/js/qa.js
CHANGED
|
@@ -504,6 +504,7 @@ function qaOpenRunAgent(workItemId, agentId) {
|
|
|
504
504
|
loadQaTargets();
|
|
505
505
|
loadQaRunbooks();
|
|
506
506
|
loadQaRunners();
|
|
507
|
+
loadQaProjectsSelect();
|
|
507
508
|
loadQaSessions();
|
|
508
509
|
_startQaSessionsPoll();
|
|
509
510
|
_startQaRunsPoll();
|
|
@@ -594,6 +595,38 @@ async function loadQaRunners() {
|
|
|
594
595
|
}
|
|
595
596
|
}
|
|
596
597
|
|
|
598
|
+
// W-mpq6xqzj000606d0 — Populate the multi-select projects dropdown on the
|
|
599
|
+
// QA Session form. Prefers the in-memory `cmdProjects` cache (populated by
|
|
600
|
+
// /api/status renders), falling back to a direct /api/status fetch when the
|
|
601
|
+
// QA page is opened in isolation before any other page has loaded.
|
|
602
|
+
async function loadQaProjectsSelect() {
|
|
603
|
+
const sel = document.getElementById('qa-session-projects');
|
|
604
|
+
if (!sel) return;
|
|
605
|
+
let projects = [];
|
|
606
|
+
try {
|
|
607
|
+
if (typeof cmdProjects !== 'undefined' && Array.isArray(cmdProjects) && cmdProjects.length > 0) {
|
|
608
|
+
projects = cmdProjects.slice();
|
|
609
|
+
} else {
|
|
610
|
+
const res = await fetch('/api/status');
|
|
611
|
+
const json = res.ok ? await res.json() : {};
|
|
612
|
+
if (Array.isArray(json && json.projects)) {
|
|
613
|
+
projects = json.projects.map(p => ({ name: p.name, description: p.description || '' }));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
} catch { projects = []; }
|
|
617
|
+
// Preserve current selection across re-renders.
|
|
618
|
+
const previouslySelected = new Set(Array.from(sel.selectedOptions || []).map(o => o.value));
|
|
619
|
+
while (sel.firstChild) sel.removeChild(sel.firstChild);
|
|
620
|
+
for (const p of projects) {
|
|
621
|
+
if (!p || !p.name) continue;
|
|
622
|
+
const opt = document.createElement('option');
|
|
623
|
+
opt.value = p.name;
|
|
624
|
+
opt.textContent = p.name;
|
|
625
|
+
if (previouslySelected.has(p.name)) opt.selected = true;
|
|
626
|
+
sel.appendChild(opt);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
597
630
|
async function loadQaSessions() {
|
|
598
631
|
const root = document.getElementById('qa-sessions-content');
|
|
599
632
|
if (!root) return;
|
|
@@ -773,7 +806,13 @@ async function qaSubmitSessionForm() {
|
|
|
773
806
|
const flowsRaw = (document.getElementById('qa-session-flows') || {}).value || '';
|
|
774
807
|
const mode = (document.getElementById('qa-session-mode') || {}).value || 'confirm';
|
|
775
808
|
const runner = (document.getElementById('qa-session-runner') || {}).value || '';
|
|
776
|
-
|
|
809
|
+
// W-mpq6xqzj000606d0 — Multi-select projects dropdown. First selected =
|
|
810
|
+
// primary (drives DRAFT/EXECUTE); rest = co-services (dev-up only).
|
|
811
|
+
// Empty selection = central (no project).
|
|
812
|
+
const projectsSel = document.getElementById('qa-session-projects');
|
|
813
|
+
const projects = projectsSel
|
|
814
|
+
? Array.from(projectsSel.selectedOptions || []).map(o => o.value).filter(Boolean)
|
|
815
|
+
: [];
|
|
777
816
|
const capture = {
|
|
778
817
|
video: !!(document.getElementById('qa-session-capture-video') || {}).checked,
|
|
779
818
|
screenshots: !!(document.getElementById('qa-session-capture-screenshots') || {}).checked,
|
|
@@ -781,7 +820,7 @@ async function qaSubmitSessionForm() {
|
|
|
781
820
|
};
|
|
782
821
|
const body = { target, flowsRaw, mode, capture };
|
|
783
822
|
if (runner) body.runner = runner;
|
|
784
|
-
if (
|
|
823
|
+
if (projects.length > 0) body.projects = projects;
|
|
785
824
|
|
|
786
825
|
try {
|
|
787
826
|
const res = await fetch('/api/qa/session', {
|
package/dashboard/js/settings.js
CHANGED
|
@@ -69,19 +69,19 @@ async function openSettings() {
|
|
|
69
69
|
const agentRows = Object.entries(agents).map(function([id, a]) {
|
|
70
70
|
return '<tr>' +
|
|
71
71
|
'<td style="font-weight:600">' + escHtml(a.emoji || '') + ' ' + escHtml(a.name || id) + '</td>' +
|
|
72
|
-
'<td><input data-agent="' + escHtml(id) + '" data-field="role" value="' + escHtml(a.role || '') + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
73
|
-
'<td><input data-agent="' + escHtml(id) + '" data-field="skills" value="' + escHtml((a.skills || []).join(', ')) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
72
|
+
'<td><input data-agent="' + escHtml(id) + '" data-field="role" value="' + escHtml(a.role || '') + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base)"></td>' +
|
|
73
|
+
'<td><input data-agent="' + escHtml(id) + '" data-field="skills" value="' + escHtml((a.skills || []).join(', ')) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base)"></td>' +
|
|
74
74
|
'<td data-runtime-cli="' + escHtml(id) + '" style="min-width:110px">' +
|
|
75
75
|
// Initial loading placeholder — initRuntimeFleetUI() replaces this with a
|
|
76
76
|
// <select> populated from /api/runtimes once the registry resolves.
|
|
77
|
-
'<input value="' + escHtml(a.cli || '') + '" placeholder="' + escHtml(fleetCliLabel) + ' (fleet)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:
|
|
77
|
+
'<input value="' + escHtml(a.cli || '') + '" placeholder="' + escHtml(fleetCliLabel) + ' (fleet)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-base)">' +
|
|
78
78
|
'</td>' +
|
|
79
79
|
'<td data-runtime-model="' + escHtml(id) + '" style="min-width:140px">' +
|
|
80
80
|
// Loading placeholder — initRuntimeFleetUI() replaces this with a
|
|
81
81
|
// <select> populated from /api/runtimes/<resolved-cli>/models.
|
|
82
|
-
'<input value="' + escHtml(a.model || '') + '" placeholder="' + escHtml(fleetModelLabel) + ' (fleet)" disabled style="width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:
|
|
82
|
+
'<input value="' + escHtml(a.model || '') + '" placeholder="' + escHtml(fleetModelLabel) + ' (fleet)" disabled style="width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-base)">' +
|
|
83
83
|
'</td>' +
|
|
84
|
-
'<td><input data-agent="' + escHtml(id) + '" data-field="monthlyBudgetUsd" value="' + escHtml(a.monthlyBudgetUsd != null ? String(a.monthlyBudgetUsd) : '') + '" placeholder="unlimited" style="width:70px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
84
|
+
'<td><input data-agent="' + escHtml(id) + '" data-field="monthlyBudgetUsd" value="' + escHtml(a.monthlyBudgetUsd != null ? String(a.monthlyBudgetUsd) : '') + '" placeholder="unlimited" style="width:70px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base);text-align:right"></td>' +
|
|
85
85
|
'</tr>';
|
|
86
86
|
}).join('');
|
|
87
87
|
|
|
@@ -96,50 +96,50 @@ async function openSettings() {
|
|
|
96
96
|
'<div id="set-runtime-section" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:16px">' +
|
|
97
97
|
'<div style="display:grid;grid-template-columns:1fr 2fr;gap:8px;margin-bottom:8px">' +
|
|
98
98
|
'<div data-search="default cli fleet runtime">' +
|
|
99
|
-
'<label style="font-size:
|
|
100
|
-
'<select id="set-defaultCli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
99
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Default CLI</label>' +
|
|
100
|
+
'<select id="set-defaultCli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
|
|
101
101
|
'<option value="">Loading…</option>' +
|
|
102
102
|
'</select>' +
|
|
103
|
-
'<div style="font-size:
|
|
103
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Fleet-wide runtime — registered adapters from <code>/api/runtimes</code></div>' +
|
|
104
104
|
'</div>' +
|
|
105
105
|
'<div data-search="default model fleet">' +
|
|
106
|
-
'<label style="font-size:
|
|
107
|
-
'<div id="set-defaultModel-wrap"><input id="set-defaultModel" value="' + escHtml(e.defaultModel || '') + '" placeholder="Default (CLI chooses)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
108
|
-
'<div style="font-size:
|
|
106
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Default Model</label>' +
|
|
107
|
+
'<div id="set-defaultModel-wrap"><input id="set-defaultModel" value="' + escHtml(e.defaultModel || '') + '" placeholder="Default (CLI chooses)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)"></div>' +
|
|
108
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Empty = let the runtime pick its own default</div>' +
|
|
109
109
|
'</div>' +
|
|
110
110
|
'</div>' +
|
|
111
111
|
// CC overrides — collapsed by default
|
|
112
112
|
'<details id="set-cc-overrides-details"' + ((e.ccCli || e.ccModel) ? ' open' : '') + ' style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px">' +
|
|
113
|
-
'<summary style="cursor:pointer;font-size:
|
|
114
|
-
'<span style="font-size:
|
|
113
|
+
'<summary style="cursor:pointer;font-size:var(--text-base);color:var(--text);user-select:none">Customize CC separately ' +
|
|
114
|
+
'<span style="font-size:var(--text-xs);color:var(--muted)">(Command Center + doc-chat use the fleet defaults unless overridden)</span>' +
|
|
115
115
|
'</summary>' +
|
|
116
116
|
'<div style="display:grid;grid-template-columns:1fr 2fr 1fr;gap:8px;margin-top:8px">' +
|
|
117
117
|
'<div data-search="cc cli command center runtime">' +
|
|
118
|
-
'<label style="font-size:
|
|
119
|
-
'<select id="set-ccCli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
118
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">CC CLI</label>' +
|
|
119
|
+
'<select id="set-ccCli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
|
|
120
120
|
'<option value="">Loading…</option>' +
|
|
121
121
|
'</select>' +
|
|
122
|
-
'<div style="font-size:
|
|
122
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Empty = inherit Default CLI</div>' +
|
|
123
123
|
'</div>' +
|
|
124
124
|
'<div data-search="cc model command center">' +
|
|
125
|
-
'<label style="font-size:
|
|
125
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">CC Model</label>' +
|
|
126
126
|
// Wrap the input in a dedicated container so loadModelsForRuntime
|
|
127
127
|
// (which does `wrap.innerHTML = …` on the input's parent) only
|
|
128
128
|
// swaps THIS element. Without the wrap it would wipe the label
|
|
129
129
|
// and the hint below, breaking vertical alignment with the
|
|
130
130
|
// sibling CC CLI / Effort columns.
|
|
131
|
-
'<div id="set-ccModel-wrap"><input id="set-ccModel" value="' + escHtml(e.ccModel || '') + '" placeholder="(inherits Default Model)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
132
|
-
'<div style="font-size:
|
|
131
|
+
'<div id="set-ccModel-wrap"><input id="set-ccModel" value="' + escHtml(e.ccModel || '') + '" placeholder="(inherits Default Model)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)"></div>' +
|
|
132
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Empty = inherit Default Model</div>' +
|
|
133
133
|
'</div>' +
|
|
134
134
|
'<div data-search="cc effort reasoning">' +
|
|
135
|
-
'<label style="font-size:
|
|
136
|
-
'<select id="set-ccEffort" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
135
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Effort</label>' +
|
|
136
|
+
'<select id="set-ccEffort" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
|
|
137
137
|
'<option value=""' + (!e.ccEffort ? ' selected' : '') + '>Default</option>' +
|
|
138
138
|
'<option value="low"' + (e.ccEffort === 'low' ? ' selected' : '') + '>Low</option>' +
|
|
139
139
|
'<option value="medium"' + (e.ccEffort === 'medium' ? ' selected' : '') + '>Medium</option>' +
|
|
140
140
|
'<option value="high"' + (e.ccEffort === 'high' ? ' selected' : '') + '>High</option>' +
|
|
141
141
|
'</select>' +
|
|
142
|
-
'<div style="font-size:
|
|
142
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">CC reasoning depth</div>' +
|
|
143
143
|
'</div>' +
|
|
144
144
|
'</div>' +
|
|
145
145
|
// W-mpmwxni2000c25c7-d — per-turn watchdog. Surfaced under CC overrides
|
|
@@ -150,8 +150,8 @@ async function openSettings() {
|
|
|
150
150
|
'</details>' +
|
|
151
151
|
'</div>' +
|
|
152
152
|
'<h4>Agents</h4>' +
|
|
153
|
-
'<div style="font-size:
|
|
154
|
-
'<table style="width:100%;border-collapse:collapse;margin-bottom:16px;font-size:
|
|
153
|
+
'<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:6px">CLI / Model placeholders show the fleet default each agent will inherit. Pick a value to pin per-agent; clear to re-inherit. Per-agent monthly budget overrides the fleet ceiling.</div>' +
|
|
154
|
+
'<table style="width:100%;border-collapse:collapse;margin-bottom:16px;font-size:var(--text-base)">' +
|
|
155
155
|
'<tr style="text-align:left;color:var(--muted)"><th style="padding:4px">Agent</th><th style="padding:4px">Role</th><th style="padding:4px">Skills</th><th style="padding:4px">CLI</th><th style="padding:4px">Model</th><th style="padding:4px">Budget $/mo</th></tr>' +
|
|
156
156
|
agentRows +
|
|
157
157
|
'</table>';
|
|
@@ -187,8 +187,8 @@ async function openSettings() {
|
|
|
187
187
|
'<div class="settings-pane-sub">Dashboard-wide visual preferences. Persisted in browser localStorage + server-side settings so they survive a reload from a cold cache.</div>' +
|
|
188
188
|
'<div class="settings-grid-2">' +
|
|
189
189
|
'<div data-search="font size scale appearance dashboard">' +
|
|
190
|
-
'<label style="font-size:
|
|
191
|
-
'<select id="set-fontSize" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
190
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Font Size</label>' +
|
|
191
|
+
'<select id="set-fontSize" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
|
|
192
192
|
(function() {
|
|
193
193
|
var current = (e.fontSize || 'small');
|
|
194
194
|
var opts = [['small', 'Small (current default)'], ['medium', 'Medium'], ['large', 'Large'], ['xlarge', 'Extra Large']];
|
|
@@ -197,7 +197,7 @@ async function openSettings() {
|
|
|
197
197
|
}).join('');
|
|
198
198
|
})() +
|
|
199
199
|
'</select>' +
|
|
200
|
-
'<div style="font-size:
|
|
200
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Scales the whole dashboard. Persisted in browser + server.</div>' +
|
|
201
201
|
'</div>' +
|
|
202
202
|
'</div>';
|
|
203
203
|
|
|
@@ -216,23 +216,23 @@ async function openSettings() {
|
|
|
216
216
|
var mismatch = !!(liveProj && liveProj.branchMismatch);
|
|
217
217
|
var localBranch = (liveProj && liveProj.gitBranch) || '';
|
|
218
218
|
var driftNote = mismatch
|
|
219
|
-
? '<div style="font-size:
|
|
219
|
+
? '<div style="font-size:var(--text-sm);color:var(--yellow);margin-top:4px">⚠ Configured main (<code>' + escHtml(p.mainBranch || '') + '</code>) differs from origin/HEAD (<code>' + escHtml(remoteDefault) + '</code>) — config is likely stale.</div>'
|
|
220
220
|
: '';
|
|
221
221
|
var pathRow = p.localPath
|
|
222
|
-
? '<div style="font-size:
|
|
222
|
+
? '<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">' + escHtml(p.localPath) + '</div>'
|
|
223
223
|
: '';
|
|
224
224
|
var branchGrid = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">' +
|
|
225
225
|
settingsField('Configured main branch', 'set-mainBranch-' + p.name, p.mainBranch || '', '', 'Used by branch-naming + dependency-merge to identify mainline. Empty = auto-detect from origin/HEAD.') +
|
|
226
226
|
'<div>' +
|
|
227
|
-
'<label style="font-size:
|
|
228
|
-
'<input id="set-remoteDefaultBranch-' + p.name + '" value="' + escHtml(remoteDefault || '(unset)') + '" readonly style="width:100%;padding:4px 6px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:
|
|
229
|
-
'<div style="font-size:
|
|
227
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Remote default <span style="opacity:0.6">(read-only)</span></label>' +
|
|
228
|
+
'<input id="set-remoteDefaultBranch-' + p.name + '" value="' + escHtml(remoteDefault || '(unset)') + '" readonly style="width:100%;padding:4px 6px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-md);font-family:ui-monospace,SFMono-Regular,Menlo,monospace">' +
|
|
229
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Parsed from <code>git symbolic-ref refs/remotes/origin/HEAD</code>' + (localBranch ? '. Local HEAD: <code>' + escHtml(localBranch) + '</code>' : '') + '.</div>' +
|
|
230
230
|
'</div>' +
|
|
231
231
|
'</div>';
|
|
232
232
|
return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
|
|
233
233
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
|
|
234
|
-
'<div style="font-size:
|
|
235
|
-
'<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:
|
|
234
|
+
'<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
|
|
235
|
+
'<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:var(--text-xs);padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
|
|
236
236
|
'</div>' +
|
|
237
237
|
pathRow +
|
|
238
238
|
branchGrid +
|
|
@@ -300,8 +300,8 @@ async function openSettings() {
|
|
|
300
300
|
'</div>' +
|
|
301
301
|
'<div class="settings-grid-2">' +
|
|
302
302
|
'<div data-search="copilot stream mode incremental batched">' +
|
|
303
|
-
'<label style="font-size:
|
|
304
|
-
'<select id="set-copilotStreamMode" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
303
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Copilot stream</label>' +
|
|
304
|
+
'<select id="set-copilotStreamMode" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
|
|
305
305
|
'<option value="on"' + ((e.copilotStreamMode || 'on') === 'on' ? ' selected' : '') + '>on (incremental)</option>' +
|
|
306
306
|
'<option value="off"' + (e.copilotStreamMode === 'off' ? ' selected' : '') + '>off (batched)</option>' +
|
|
307
307
|
'</select>' +
|
|
@@ -321,7 +321,7 @@ async function openSettings() {
|
|
|
321
321
|
'<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:12px">' +
|
|
322
322
|
settingsField('Allowed Tools', 'set-allowedTools', c.allowedTools || '', '', 'Claude allow-list passed through for compatibility; runtime bypass flags are adapter-owned.') +
|
|
323
323
|
'</div>' +
|
|
324
|
-
'<div data-search="permission bypass dangerously-skip-permissions autopilot allow-all" style="font-size:
|
|
324
|
+
'<div data-search="permission bypass dangerously-skip-permissions autopilot allow-all" style="font-size:var(--text-sm);color:var(--muted);margin-top:12px;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface-subtle, rgba(130,160,210,0.08))">' +
|
|
325
325
|
'Permission bypass is runtime-owned: Claude agents use <code>--dangerously-skip-permissions</code>; Copilot agents use <code>--autopilot --allow-all --no-ask-user</code>. There is no dashboard permission-mode setting.' +
|
|
326
326
|
'</div>';
|
|
327
327
|
|
|
@@ -355,16 +355,16 @@ async function openSettings() {
|
|
|
355
355
|
'<h3>Feature Flags</h3>' +
|
|
356
356
|
'<div class="settings-pane-sub">In-progress UX or behavior gates. Toggles persist immediately. Registry: <code>engine/features.js</code>. Env override: <code>MINIONS_FEATURE_<NAME>=1</code>.</div>' +
|
|
357
357
|
'<details id="settings-features-details" open style="border:1px solid var(--border);border-radius:6px;padding:12px">' +
|
|
358
|
-
'<summary style="cursor:pointer;font-size:
|
|
359
|
-
'<span style="font-size:
|
|
358
|
+
'<summary style="cursor:pointer;font-size:var(--text-md);color:var(--text);user-select:none;margin-bottom:8px">Show experimental flags ' +
|
|
359
|
+
'<span style="font-size:var(--text-sm);color:var(--muted)">(' + featuresList.length + ' registered)</span>' +
|
|
360
360
|
'</summary>' +
|
|
361
361
|
(featuresList.length === 0
|
|
362
|
-
? '<div style="font-size:
|
|
362
|
+
? '<div style="font-size:var(--text-base);color:var(--muted);padding:12px;border:1px dashed var(--border);border-radius:4px;text-align:center">No experimental features registered. Add entries to <code>engine/features.js</code> to gate new work.</div>'
|
|
363
363
|
: '<div style="display:flex;flex-direction:column;gap:8px">' +
|
|
364
364
|
featuresList.map(function(f) {
|
|
365
365
|
const checked = f.enabled ? ' checked' : '';
|
|
366
366
|
const expiredBadge = f.expired
|
|
367
|
-
? ' <span style="font-size:
|
|
367
|
+
? ' <span style="font-size:var(--text-xs);padding:1px 5px;background:rgba(220,80,80,0.15);color:var(--red);border-radius:3px;margin-left:4px">EXPIRED</span>'
|
|
368
368
|
: '';
|
|
369
369
|
const meta = [];
|
|
370
370
|
if (f.addedIn) meta.push('added in ' + escHtml(f.addedIn));
|
|
@@ -374,9 +374,9 @@ async function openSettings() {
|
|
|
374
374
|
return '<label data-feature-id="' + escHtml(f.id) + '" data-search="' + escHtml(searchText) + '" style="display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid var(--border);border-radius:4px;cursor:pointer">' +
|
|
375
375
|
'<input type="checkbox" data-feature-toggle="' + escHtml(f.id) + '"' + checked + ' style="margin-top:3px;cursor:pointer">' +
|
|
376
376
|
'<div style="flex:1">' +
|
|
377
|
-
'<div style="font-size:
|
|
378
|
-
(f.description ? '<div style="font-size:
|
|
379
|
-
'<div style="font-size:
|
|
377
|
+
'<div style="font-size:var(--text-base);font-weight:600;color:var(--text)">' + escHtml(f.id) + expiredBadge + '</div>' +
|
|
378
|
+
(f.description ? '<div style="font-size:var(--text-sm);color:var(--muted);margin-top:2px">' + escHtml(f.description) + '</div>' : '') +
|
|
379
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:3px">' + meta.join(' · ') + '</div>' +
|
|
380
380
|
'</div>' +
|
|
381
381
|
'</label>';
|
|
382
382
|
}).join('') +
|
|
@@ -388,7 +388,7 @@ async function openSettings() {
|
|
|
388
388
|
'<div class="settings-pane-sub">Routing table + low-level engine knobs that most operators never touch. Change with care.</div>' +
|
|
389
389
|
'<h4>Routing Table</h4>' +
|
|
390
390
|
'<div data-search="routing table playbook agent assignment" style="margin-bottom:16px">' +
|
|
391
|
-
'<textarea id="set-routing" rows="10" style="width:100%;padding:8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:monospace;font-size:
|
|
391
|
+
'<textarea id="set-routing" rows="10" style="width:100%;padding:8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:monospace;font-size:var(--text-base);resize:vertical">' + escHtml(data.routing || '') + '</textarea>' +
|
|
392
392
|
'</div>' +
|
|
393
393
|
'<h4>Inbox & Status Retention</h4>' +
|
|
394
394
|
'<div class="settings-grid-2">' +
|
|
@@ -457,7 +457,7 @@ async function openSettings() {
|
|
|
457
457
|
'<nav class="settings-rail-nav" id="settings-rail-nav">' + railHtml + '</nav>' +
|
|
458
458
|
'</aside>' +
|
|
459
459
|
'<div class="settings-content" id="settings-content">' +
|
|
460
|
-
'<div id="settings-status" style="font-size:
|
|
460
|
+
'<div id="settings-status" style="font-size:var(--text-base);min-height:16px;margin-bottom:8px"></div>' +
|
|
461
461
|
panesHtml +
|
|
462
462
|
'</div>' +
|
|
463
463
|
'</div>';
|
|
@@ -588,7 +588,7 @@ async function initRuntimeFleetUI(engineCfg, agentsCfg) {
|
|
|
588
588
|
const current = agent.cli || '';
|
|
589
589
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agentId, runtime names)
|
|
590
590
|
cell.innerHTML =
|
|
591
|
-
'<select data-agent="' + escHtml(agentId) + '" data-field="cli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
591
|
+
'<select data-agent="' + escHtml(agentId) + '" data-field="cli" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base)">' +
|
|
592
592
|
'<option value=""' + (!current ? ' selected' : '') + '>(fleet default)</option>' +
|
|
593
593
|
names.map(n =>
|
|
594
594
|
'<option value="' + escHtml(n) + '"' + (n === current ? ' selected' : '') + '>' + escHtml(n) + '</option>'
|
|
@@ -647,7 +647,7 @@ async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
|
|
|
647
647
|
const token = _nextModelLoadToken('runtime', inputId);
|
|
648
648
|
if (!runtimeName) {
|
|
649
649
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: currentValue; inputId is an internal fixed DOM id)
|
|
650
|
-
wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="(no runtime selected)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:
|
|
650
|
+
wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="(no runtime selected)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-md)">';
|
|
651
651
|
return;
|
|
652
652
|
}
|
|
653
653
|
let payload = { models: null };
|
|
@@ -662,7 +662,7 @@ async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
|
|
|
662
662
|
// Free-text fallback — let the user type anything (custom Anthropic /
|
|
663
663
|
// OpenAI model IDs, future models, etc.).
|
|
664
664
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: currentValue; inputId is an internal fixed DOM id)
|
|
665
|
-
wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="Default (CLI chooses)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
665
|
+
wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="Default (CLI chooses)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">';
|
|
666
666
|
return;
|
|
667
667
|
}
|
|
668
668
|
// Dropdown. The first option submits empty string → "Default (CLI chooses)".
|
|
@@ -679,7 +679,7 @@ async function loadModelsForRuntime(runtimeName, inputId, currentValue) {
|
|
|
679
679
|
opts += '<option value="' + escHtml(currentValue) + '" selected>' + escHtml(currentValue) + ' (custom)</option>';
|
|
680
680
|
}
|
|
681
681
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: model id, model label, currentValue; inputId is an internal fixed DOM id)
|
|
682
|
-
wrap.innerHTML = '<select id="' + inputId + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
682
|
+
wrap.innerHTML = '<select id="' + inputId + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' + opts + '</select>';
|
|
683
683
|
}
|
|
684
684
|
|
|
685
685
|
/**
|
|
@@ -693,7 +693,7 @@ async function loadModelsForAgent(agentId, runtimeName, currentValue) {
|
|
|
693
693
|
const cell = document.querySelector('[data-runtime-model="' + agentId + '"]');
|
|
694
694
|
if (!cell) return;
|
|
695
695
|
const baseAttrs = 'data-agent="' + escHtml(agentId) + '" data-field="model"';
|
|
696
|
-
const baseStyle = 'width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
696
|
+
const baseStyle = 'width:120px;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-base)';
|
|
697
697
|
const token = _nextModelLoadToken('agent', agentId);
|
|
698
698
|
if (!runtimeName) {
|
|
699
699
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: agentId, currentValue)
|
|
@@ -735,17 +735,17 @@ function settingsToggle(label, id, checked, hint) {
|
|
|
735
735
|
const searchKey = (String(label || '') + ' ' + String(hint || '')).toLowerCase().replace(/"/g, '');
|
|
736
736
|
return '<div class="settings-row" data-search="' + escHtml(searchKey) + '" style="display:flex;align-items:center;gap:8px;padding:4px 0">' +
|
|
737
737
|
'<input type="checkbox" id="' + id + '"' + (checked ? ' checked' : '') + ' style="accent-color:var(--blue);width:16px;height:16px;cursor:pointer">' +
|
|
738
|
-
'<label for="' + id + '" style="font-size:
|
|
739
|
-
(hint ? '<span style="font-size:
|
|
738
|
+
'<label for="' + id + '" style="font-size:var(--text-md);color:var(--text);cursor:pointer">' + escHtml(label) + '</label>' +
|
|
739
|
+
(hint ? '<span style="font-size:var(--text-xs);color:var(--muted)">' + escHtml(hint) + '</span>' : '') +
|
|
740
740
|
'</div>';
|
|
741
741
|
}
|
|
742
742
|
|
|
743
743
|
function settingsField(label, id, value, unit, hint) {
|
|
744
744
|
const searchKey = (String(label || '') + ' ' + String(hint || '') + ' ' + String(unit || '')).toLowerCase().replace(/"/g, '');
|
|
745
745
|
return '<div class="settings-row" data-search="' + escHtml(searchKey) + '">' +
|
|
746
|
-
'<label style="font-size:
|
|
747
|
-
'<input id="' + id + '" value="' + escHtml(String(value)) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:
|
|
748
|
-
(hint ? '<div style="font-size:
|
|
746
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">' + escHtml(label) + (unit ? ' <span style="opacity:0.6">(' + escHtml(unit) + ')</span>' : '') + '</label>' +
|
|
747
|
+
'<input id="' + id + '" value="' + escHtml(String(value)) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
|
|
748
|
+
(hint ? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">' + escHtml(hint) + '</div>' : '') +
|
|
749
749
|
'</div>';
|
|
750
750
|
}
|
|
751
751
|
|
package/dashboard/js/state.js
CHANGED
|
@@ -43,7 +43,7 @@ let currentPage = getPageFromUrl();
|
|
|
43
43
|
// _stopMeetingPoll, _stopQaRunsPoll all clearInterval on a nullable handle;
|
|
44
44
|
// closeDetail / closeManagedLog short-circuit when no panel/stream is open.
|
|
45
45
|
const PAGE_LAZY_LOADERS = {
|
|
46
|
-
qa: ['loadQaTargets', 'loadQaRunbooks', 'loadQaRunners', 'loadQaSessions', '_startQaSessionsPoll', '_startQaRunsPoll'],
|
|
46
|
+
qa: ['loadQaTargets', 'loadQaRunbooks', 'loadQaRunners', 'loadQaProjectsSelect', 'loadQaSessions', '_startQaSessionsPoll', '_startQaRunsPoll'],
|
|
47
47
|
plans: ['refreshPlans'],
|
|
48
48
|
inbox: ['refreshKnowledgeBase'],
|
|
49
49
|
};
|
package/dashboard/pages/qa.html
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
</select>
|
|
18
18
|
</div>
|
|
19
19
|
<div>
|
|
20
|
-
<label class="qa-form-label" for="qa-session-
|
|
21
|
-
<
|
|
20
|
+
<label class="qa-form-label" for="qa-session-projects">Projects</label>
|
|
21
|
+
<select id="qa-session-projects" class="qa-form-input" multiple size="3"></select>
|
|
22
|
+
<p class="empty" style="margin-top:4px;font-size:0.85em">Hold Ctrl/Cmd to select multiple. First selected = primary (drives DRAFT/EXECUTE). Others = co-services (dev-up only). Leave empty = central.</p>
|
|
22
23
|
</div>
|
|
23
24
|
</div>
|
|
24
25
|
<div class="qa-form-row qa-session-target-fields">
|
package/dashboard.js
CHANGED
|
@@ -10273,13 +10273,31 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10273
10273
|
return { wiPath: target.wiPath, project: target.project ? target.project.name : null };
|
|
10274
10274
|
}
|
|
10275
10275
|
|
|
10276
|
+
// W-mpq6xqzj000606d0 — Multi-project resolver. Walks each requested project
|
|
10277
|
+
// through resolveWorkItemsCreateTarget, returning either the full list of
|
|
10278
|
+
// {wiPath, project} entries (with the first treated as primary) or
|
|
10279
|
+
// { error } on the first failure. Accepts the canonical `projects: string[]`
|
|
10280
|
+
// and falls back to the legacy single `project` string.
|
|
10281
|
+
function _qaSessionsResolveTargets(body) {
|
|
10282
|
+
const list = Array.isArray(body && body.projects) && body.projects.length > 0
|
|
10283
|
+
? body.projects.slice()
|
|
10284
|
+
: [body && body.project ? body.project : ''];
|
|
10285
|
+
const resolved = [];
|
|
10286
|
+
for (const name of list) {
|
|
10287
|
+
const t = _qaSessionsResolveTarget(name);
|
|
10288
|
+
if (t.error) return { error: `project "${name || '(central)'}": ${t.error}` };
|
|
10289
|
+
resolved.push(t);
|
|
10290
|
+
}
|
|
10291
|
+
return { targets: resolved };
|
|
10292
|
+
}
|
|
10293
|
+
|
|
10276
10294
|
async function handleQaSessionCreate(req, res) {
|
|
10277
10295
|
try {
|
|
10278
10296
|
const body = await readBody(req);
|
|
10279
10297
|
// Validate first so a bad spec returns 400 BEFORE we touch state. The
|
|
10280
10298
|
// body shape is identical to qaSessions.validateSpec — target, flowsRaw,
|
|
10281
|
-
// mode, capture, runner, project, createdBy — so we forward
|
|
10282
|
-
// verbatim.
|
|
10299
|
+
// mode, capture, runner, project | projects, createdBy — so we forward
|
|
10300
|
+
// it verbatim.
|
|
10283
10301
|
const qaSessions = require('./engine/qa-sessions');
|
|
10284
10302
|
const v = qaSessions.validateSpec(body);
|
|
10285
10303
|
if (!v.ok) return jsonReply(res, 400, { error: 'invalid spec', details: v.errors }, req);
|
|
@@ -10294,8 +10312,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10294
10312
|
createSpec.createdBy = originAgent;
|
|
10295
10313
|
}
|
|
10296
10314
|
|
|
10297
|
-
|
|
10298
|
-
|
|
10315
|
+
// W-mpq6xqzj000606d0 — Resolve EACH project (multi-project fan-out)
|
|
10316
|
+
// before persisting the session, so a single bad project rejects the
|
|
10317
|
+
// whole submit instead of leaving an orphan session in PENDING.
|
|
10318
|
+
const resolution = _qaSessionsResolveTargets(createSpec);
|
|
10319
|
+
if (resolution.error) return jsonReply(res, 400, { error: resolution.error }, req);
|
|
10299
10320
|
|
|
10300
10321
|
let session;
|
|
10301
10322
|
try { session = qaSessions.createSession(createSpec); }
|
|
@@ -10304,8 +10325,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10304
10325
|
let setupWiId = null;
|
|
10305
10326
|
try {
|
|
10306
10327
|
setupWiId = qaSessions.queueSetup(session.id, {
|
|
10307
|
-
wiPath:
|
|
10308
|
-
project: resolved.project,
|
|
10328
|
+
resolvedTargets: resolution.targets.map(t => ({ project: t.project, wiPath: t.wiPath })),
|
|
10309
10329
|
});
|
|
10310
10330
|
} catch (e) {
|
|
10311
10331
|
// queueSetup throws when pending→spawning is rejected (createSession
|
|
@@ -10324,6 +10344,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10324
10344
|
state: 'spawning',
|
|
10325
10345
|
setupWorkItemId: setupWiId,
|
|
10326
10346
|
managedSpawnName: session.managedSpawnName,
|
|
10347
|
+
projects: resolution.targets.map(t => t.project),
|
|
10327
10348
|
}, req);
|
|
10328
10349
|
} catch (e) {
|
|
10329
10350
|
return jsonReply(res, 500, { error: e.message }, req);
|
|
@@ -138,6 +138,26 @@ routing default):
|
|
|
138
138
|
the session `spawning → drafting`. First-health failure → `failed` with
|
|
139
139
|
`failure_class: qa-session-setup-failed`.
|
|
140
140
|
|
|
141
|
+
**Multi-project fan-out (W-mpq6xqzj000606d0):** when `spec.projects`
|
|
142
|
+
contains more than one project name, SETUP is fanned out — one work item
|
|
143
|
+
per project, queued in parallel into each project's own
|
|
144
|
+
`work-items.json`. The first project (`spec.projects[0]`) is the
|
|
145
|
+
**primary** (`meta.qaSession.primary === true`); the rest are
|
|
146
|
+
**co-services** (`primary === false`). Each WI's
|
|
147
|
+
`meta.qaSession.coServices` lists the co-service project names (only the
|
|
148
|
+
primary carries the full list; co-services see `[]`) and
|
|
149
|
+
`meta.qaSession.primaryProject` carries the canonical primary name.
|
|
150
|
+
The primary keeps the unsuffixed managed-spawn name; each co-service
|
|
151
|
+
gets `qa-session-<sessionId>-<project>`. The session tracks per-project
|
|
152
|
+
completion in `session.setupStatus[<project>]: { state, wiId, error,
|
|
153
|
+
completedAt }`, where `state ∈ {pending, success, failed}`. **All**
|
|
154
|
+
projects must succeed before `spawning → drafting`; any failure flips
|
|
155
|
+
the session to `failed` with `failure_class: qa-session-setup-failed`
|
|
156
|
+
and a JSON-encoded per-project error array in `session.error`. DRAFT
|
|
157
|
+
queues on `session.primaryWiPath` (stamped at queueSetup time).
|
|
158
|
+
Single-project sessions skip `setupStatus` entirely (fast path uses
|
|
159
|
+
`session.state` directly).
|
|
160
|
+
|
|
141
161
|
2. **DRAFT** (`playbooks/qa-session-draft.md`) reads the live spawn
|
|
142
162
|
metadata via `/api/managed-processes/by-name/qa-session-<id>`, calls the
|
|
143
163
|
resolved runner's `generateBrief({target, flowsRaw, capture})` hook, and
|
|
@@ -168,7 +188,7 @@ Documented in `dashboard.js`; routes are visible at `GET /api/routes`.
|
|
|
168
188
|
|
|
169
189
|
| Method | Path | Behavior |
|
|
170
190
|
|--------|--------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
|
|
171
|
-
| POST | `/api/qa/session` | Create session; validates spec, calls `createSession` + `queueSetup` (`pending → spawning`). Returns `sessionId`, `setupWorkItemId`, `
|
|
191
|
+
| POST | `/api/qa/session` | Create session; validates spec, calls `createSession` + `queueSetup` (`pending → spawning`). Body accepts `project: string` (single) OR `projects: string[]` (multi, ≤5; first is primary, rest are co-services). Returns `sessionId`, `setupWorkItemId` (primary's WI), `managedSpawnName`, and (multi only) `projects: string[]`. |
|
|
172
192
|
| GET | `/api/qa/sessions` | List sessions newest-first. Optional `?limit=N` and `?state=pending\|spawning\|drafting\|awaiting-approval\|executing\|done\|failed\|killed`. |
|
|
173
193
|
| GET | `/api/qa/sessions/<id>` | Fetch a single session record by id. |
|
|
174
194
|
| POST | `/api/qa/sessions/<id>/approve` | `awaiting-approval → executing`. Server-side creates the linked `qa-runs` record (synthetic `runbookId='qa-session-<id>'`), queues EXECUTE WI, stamps `qaRunId` on the session. |
|
package/engine/features.js
CHANGED
|
@@ -51,14 +51,19 @@ const FEATURES = {
|
|
|
51
51
|
// is the canonical predicate every dashboard.js call site uses. Explicit
|
|
52
52
|
// `engine.ccUseWorkerPool` true/false in config wins ONLY when CC runtime
|
|
53
53
|
// is copilot; otherwise the helper forces `false`. PR #2492 flipped the
|
|
54
|
-
// copilot default ON;
|
|
54
|
+
// copilot resolver default ON; W-mpqf3ybq000455f2 flipped this registry
|
|
55
|
+
// default ON to match the resolved behavior (so the Settings/`/api/features`
|
|
56
|
+
// surface reports the same default the runtime actually uses). The pool
|
|
57
|
+
// has been stable since PR #2492's cold-spawn measurements; opt-out via
|
|
58
|
+
// `config.features.ccUseWorkerPool: false` or `engine.ccUseWorkerPool: false`
|
|
59
|
+
// still works.
|
|
55
60
|
//
|
|
56
61
|
// `requiredCcRuntime: 'copilot'` here is a UX hint — the Settings panel
|
|
57
62
|
// greys out the toggle when the resolved CC runtime mismatches so users
|
|
58
63
|
// see the constraint instead of toggling a no-op.
|
|
59
64
|
'ccUseWorkerPool': {
|
|
60
65
|
description: 'Route Command Center / doc-chat through a persistent `copilot --acp` worker per tab instead of spawning a fresh CLI per turn (~14s cold-start savings). Copilot-only — has no effect when the CC runtime is Claude.',
|
|
61
|
-
default:
|
|
66
|
+
default: true,
|
|
62
67
|
addedIn: '0.1.1916',
|
|
63
68
|
requiredCcRuntime: 'copilot',
|
|
64
69
|
},
|
package/engine/playbook.js
CHANGED
|
@@ -325,6 +325,14 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
|
|
|
325
325
|
'runner_brief', // generateBrief() output — used by qa-session-draft
|
|
326
326
|
'runner_execute_brief', // executeBrief() output — used by qa-session-execute
|
|
327
327
|
'test_file', // session.testFile — set after DRAFT, used by EXECUTE
|
|
328
|
+
// W-mpq6xqzj000606d0 — Multi-project QA Session fan-out. SETUP playbook
|
|
329
|
+
// gets one work item per project; these vars distinguish the "primary"
|
|
330
|
+
// (which owns DRAFT/EXECUTE handoff and the canonical managed-spawn name)
|
|
331
|
+
// from N "co-service" WIs (which just declare their own managed-spawn and
|
|
332
|
+
// exit). Optional because single-project sessions never set them.
|
|
333
|
+
'role', // 'primary' | 'co-service'
|
|
334
|
+
'primary_project', // canonical primary project name; empty on single-project sessions
|
|
335
|
+
'co_services_json', // JSON-encoded co-service project names (e.g. ["api","worker"]); empty when none
|
|
328
336
|
]);
|
|
329
337
|
|
|
330
338
|
const PLAYBOOK_REQUIRED_VARS = {
|
package/engine/qa-sessions.js
CHANGED
|
@@ -138,6 +138,11 @@ const LIMITS = {
|
|
|
138
138
|
targetFieldMax: 500,
|
|
139
139
|
projectMax: 64,
|
|
140
140
|
summaryMax: 2000,
|
|
141
|
+
// W-mpq6xqzj000606d0 — multi-project QA Session: cap on co-services per
|
|
142
|
+
// session. Each entry is a separate SETUP WI fan-out, so the cap doubles as
|
|
143
|
+
// a guardrail against an operator queueing dozens of parallel dev-up
|
|
144
|
+
// services from a single form submit.
|
|
145
|
+
projectsMax: 5,
|
|
141
146
|
};
|
|
142
147
|
|
|
143
148
|
// Mirrors engine/qa-runbooks.js _isSafeId — kebab-case ≤64 chars, no leading/
|
|
@@ -286,6 +291,37 @@ function validateSpec(spec) {
|
|
|
286
291
|
}
|
|
287
292
|
}
|
|
288
293
|
|
|
294
|
+
// W-mpq6xqzj000606d0 — multi-project mode. `spec.projects` is the canonical
|
|
295
|
+
// field; `spec.project` (legacy single-string) is still accepted above and
|
|
296
|
+
// normalized into `projects` by createSession. Both may legitimately be
|
|
297
|
+
// empty/null = "central work-items target", but if BOTH are supplied they
|
|
298
|
+
// must agree (the dashboard back-compat wrapper sends just one). We don't
|
|
299
|
+
// hard-fail on duplication here — createSession picks projects[] first.
|
|
300
|
+
if (spec.projects !== undefined && spec.projects !== null) {
|
|
301
|
+
if (!Array.isArray(spec.projects)) {
|
|
302
|
+
errors.push('projects must be an array of strings when present');
|
|
303
|
+
} else if (spec.projects.length > LIMITS.projectsMax) {
|
|
304
|
+
errors.push(`projects exceeds ${LIMITS.projectsMax} entries`);
|
|
305
|
+
} else {
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
for (const p of spec.projects) {
|
|
308
|
+
if (typeof p !== 'string' || p.length === 0) {
|
|
309
|
+
errors.push('projects entries must be non-empty strings');
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
if (p.length > LIMITS.projectMax) {
|
|
313
|
+
errors.push(`projects entry exceeds ${LIMITS.projectMax} chars`);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
if (seen.has(p)) {
|
|
317
|
+
errors.push(`projects contains duplicate entry: ${p}`);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
seen.add(p);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
289
325
|
return { ok: errors.length === 0, errors };
|
|
290
326
|
}
|
|
291
327
|
|
|
@@ -318,6 +354,19 @@ function createSession(spec) {
|
|
|
318
354
|
throw err;
|
|
319
355
|
}
|
|
320
356
|
|
|
357
|
+
// W-mpq6xqzj000606d0 — normalize legacy `project` and new `projects` into a
|
|
358
|
+
// single canonical `projects: string[] | null` (null = central). When both
|
|
359
|
+
// are present, `projects` wins. Empty string in legacy `project` is treated
|
|
360
|
+
// as central (matches the dashboard input convention).
|
|
361
|
+
let projects = null;
|
|
362
|
+
if (Array.isArray(spec.projects) && spec.projects.length > 0) {
|
|
363
|
+
projects = spec.projects.slice();
|
|
364
|
+
} else if (typeof spec.project === 'string' && spec.project.length > 0) {
|
|
365
|
+
projects = [spec.project];
|
|
366
|
+
}
|
|
367
|
+
const primaryProject = projects && projects.length > 0 ? projects[0] : null;
|
|
368
|
+
const coServices = projects && projects.length > 1 ? projects.slice(1) : [];
|
|
369
|
+
|
|
321
370
|
const id = 'qas-' + uid();
|
|
322
371
|
const now = ts();
|
|
323
372
|
const session = {
|
|
@@ -333,8 +382,25 @@ function createSession(spec) {
|
|
|
333
382
|
logs: !!(spec.capture && spec.capture.logs),
|
|
334
383
|
},
|
|
335
384
|
runner: spec.runner || null,
|
|
336
|
-
project
|
|
385
|
+
// Canonical multi-project field. `project` (legacy single-string) is
|
|
386
|
+
// mirrored for back-compat readers (qa.js card render, /api/status,
|
|
387
|
+
// older lifecycle hooks); new code keys off `projects`.
|
|
388
|
+
projects,
|
|
389
|
+
project: primaryProject,
|
|
337
390
|
},
|
|
391
|
+
// W-mpq6xqzj000606d0 — denormalized fast-path fields used by the dispatch
|
|
392
|
+
// chain (queueSetup / handleSetupComplete) without re-deriving from
|
|
393
|
+
// spec.projects on every call.
|
|
394
|
+
primaryProject,
|
|
395
|
+
coServices,
|
|
396
|
+
// Per-project SETUP completion map. Only populated when projects.length >
|
|
397
|
+
// 1 (multi-project fan-out). Single-project sessions skip this map and
|
|
398
|
+
// use session.state directly.
|
|
399
|
+
setupStatus: null,
|
|
400
|
+
// primaryWiPath is stamped at queueSetup time so handleSetupComplete can
|
|
401
|
+
// queue DRAFT on the right work-items file without re-resolving the
|
|
402
|
+
// project through dashboard config.
|
|
403
|
+
primaryWiPath: null,
|
|
338
404
|
// Per-phase WI links — back-filled by setSessionWorkItem when the
|
|
339
405
|
// dashboard endpoint or lifecycle hook queues the next phase.
|
|
340
406
|
workItems: { setup: null, draft: null, execute: null },
|
|
@@ -492,7 +558,10 @@ function transitionSession(id, toState, patch = {}) {
|
|
|
492
558
|
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
|
|
493
559
|
// Whitelist mutable fields to keep transitionSession from rewriting
|
|
494
560
|
// immutable spec/createdAt fields by mistake.
|
|
495
|
-
|
|
561
|
+
// W-mpq6xqzj000606d0 — added `setupStatus` and `primaryWiPath` so the
|
|
562
|
+
// multi-project fan-in handler can stamp per-project completion state
|
|
563
|
+
// through the same locked path used by every other mutation.
|
|
564
|
+
for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', 'primaryWiPath']) {
|
|
496
565
|
if (Object.prototype.hasOwnProperty.call(patch, field)) {
|
|
497
566
|
session[field] = patch[field];
|
|
498
567
|
}
|
|
@@ -524,8 +593,24 @@ function markKilled(id, patch) { return transitionSession(id, QA_SESSION_STATE.K
|
|
|
524
593
|
// pulling dispatch into the unit test path. They're also called by the
|
|
525
594
|
// lifecycle chain helpers below to queue the next phase.
|
|
526
595
|
|
|
527
|
-
function _baseWorkItem(session, phase, { title, description, project }) {
|
|
596
|
+
function _baseWorkItem(session, phase, { title, description, project, primary, coServices, primaryProject }) {
|
|
528
597
|
const wiId = 'W-' + uid();
|
|
598
|
+
// W-mpq6xqzj000606d0 — multi-project fan-out: SETUP work items carry
|
|
599
|
+
// `primary: boolean` + `coServices: string[]` + `primaryProject: string` on
|
|
600
|
+
// meta.qaSession. Single-project sessions default to primary=true /
|
|
601
|
+
// coServices=[] so existing single-target prompts render unchanged.
|
|
602
|
+
// DRAFT/EXECUTE always omit these (primary-only) — pass `primary:
|
|
603
|
+
// undefined` to keep meta clean.
|
|
604
|
+
const qaMeta = {
|
|
605
|
+
target: session.spec.target,
|
|
606
|
+
flowsRaw: session.spec.flowsRaw,
|
|
607
|
+
mode: session.spec.mode,
|
|
608
|
+
capture: session.spec.capture,
|
|
609
|
+
runner: session.spec.runner,
|
|
610
|
+
};
|
|
611
|
+
if (primary !== undefined) qaMeta.primary = !!primary;
|
|
612
|
+
if (Array.isArray(coServices)) qaMeta.coServices = coServices.slice();
|
|
613
|
+
if (primaryProject) qaMeta.primaryProject = String(primaryProject);
|
|
529
614
|
const wi = {
|
|
530
615
|
id: wiId,
|
|
531
616
|
title,
|
|
@@ -543,13 +628,7 @@ function _baseWorkItem(session, phase, { title, description, project }) {
|
|
|
543
628
|
meta: {
|
|
544
629
|
sessionId: session.id,
|
|
545
630
|
sessionPhase: phase,
|
|
546
|
-
qaSession:
|
|
547
|
-
target: session.spec.target,
|
|
548
|
-
flowsRaw: session.spec.flowsRaw,
|
|
549
|
-
mode: session.spec.mode,
|
|
550
|
-
capture: session.spec.capture,
|
|
551
|
-
runner: session.spec.runner,
|
|
552
|
-
},
|
|
631
|
+
qaSession: qaMeta,
|
|
553
632
|
playbook: 'qa-session-' + phase,
|
|
554
633
|
},
|
|
555
634
|
};
|
|
@@ -562,21 +641,34 @@ function _baseWorkItem(session, phase, { title, description, project }) {
|
|
|
562
641
|
* Build the SETUP work item. The agent resolves the target, sets up a
|
|
563
642
|
* worktree, and writes a managed-spawn.json sidecar. Engine then spawns the
|
|
564
643
|
* service and the healthcheck gate drives the next transition.
|
|
644
|
+
*
|
|
645
|
+
* W-mpq6xqzj000606d0 — When the session has multiple projects (fan-out),
|
|
646
|
+
* `primary` distinguishes the orchestrator WI (true; carries the full
|
|
647
|
+
* coServices list) from each co-service WI (false; coServices === []). The
|
|
648
|
+
* session's DRAFT phase keys off the primary's wiPath.
|
|
565
649
|
*/
|
|
566
|
-
function buildSetupWorkItem(session, { project } = {}) {
|
|
650
|
+
function buildSetupWorkItem(session, { project, primary, coServices, primaryProject } = {}) {
|
|
651
|
+
const isPrimary = primary === undefined ? true : !!primary;
|
|
652
|
+
const co = Array.isArray(coServices) ? coServices.slice() : [];
|
|
567
653
|
return _baseWorkItem(session, SESSION_PHASE.SETUP, {
|
|
568
|
-
title: `QA Session SETUP: ${_summarizeTarget(session.spec.target)}`,
|
|
654
|
+
title: `QA Session SETUP: ${_summarizeTarget(session.spec.target)}${isPrimary && co.length ? ` (primary +${co.length} co-services)` : (!isPrimary ? ' (co-service)' : '')}`,
|
|
569
655
|
description: [
|
|
570
656
|
`QA Session ${session.id} — SETUP phase.`,
|
|
571
657
|
'',
|
|
572
658
|
`Target: ${JSON.stringify(session.spec.target)}`,
|
|
573
659
|
`Flows: ${session.spec.flowsRaw}`,
|
|
660
|
+
isPrimary
|
|
661
|
+
? (co.length ? `Role: PRIMARY (co-services: ${co.join(', ')})` : 'Role: PRIMARY (single-project session)')
|
|
662
|
+
: 'Role: CO-SERVICE (dev-up only; primary owns the test orchestration)',
|
|
574
663
|
'',
|
|
575
664
|
'Resolve the target to a worktree, inspect the codebase for the dev-up command,',
|
|
576
|
-
`and write \`agents/<your-id>/managed-spawn.json\` with name=\`${session.managedSpawnName}\`.`,
|
|
665
|
+
`and write \`agents/<your-id>/managed-spawn.json\` with name=\`${session.managedSpawnName}${isPrimary ? '' : '-' + project}\`.`,
|
|
577
666
|
'See `playbooks/qa-session-setup.md` for the full contract.',
|
|
578
667
|
].join('\n'),
|
|
579
668
|
project,
|
|
669
|
+
primary: isPrimary,
|
|
670
|
+
coServices: co,
|
|
671
|
+
primaryProject: primaryProject || (isPrimary ? project : undefined),
|
|
580
672
|
});
|
|
581
673
|
}
|
|
582
674
|
|
|
@@ -661,6 +753,23 @@ function _summarizeTarget(target) {
|
|
|
661
753
|
}
|
|
662
754
|
}
|
|
663
755
|
|
|
756
|
+
/**
|
|
757
|
+
* W-mpq6xqzj000606d0 — Back-compat shim: return the canonical projects[]
|
|
758
|
+
* array for a session, regardless of whether the record was written under
|
|
759
|
+
* the legacy single-`project` schema or the new `projects[]` schema. Returns
|
|
760
|
+
* `[]` for "central" sessions (no project) so callers can simply check
|
|
761
|
+
* `length > 1` for multi-project mode.
|
|
762
|
+
*/
|
|
763
|
+
function _resolveSessionProjects(session) {
|
|
764
|
+
if (!session || typeof session !== 'object') return [];
|
|
765
|
+
const fromArr = session.spec && Array.isArray(session.spec.projects) ? session.spec.projects : null;
|
|
766
|
+
if (fromArr && fromArr.length > 0) return fromArr.slice();
|
|
767
|
+
const fromStr = session.spec && typeof session.spec.project === 'string' && session.spec.project.length > 0
|
|
768
|
+
? session.spec.project
|
|
769
|
+
: null;
|
|
770
|
+
return fromStr ? [fromStr] : [];
|
|
771
|
+
}
|
|
772
|
+
|
|
664
773
|
// ── Cross-WI dispatch chain helpers ─────────────────────────────────────────
|
|
665
774
|
//
|
|
666
775
|
// These are the integration entry points the lifecycle hook + dashboard
|
|
@@ -693,26 +802,113 @@ function _queueWorkItem(wi, wiPath) {
|
|
|
693
802
|
|
|
694
803
|
/**
|
|
695
804
|
* Called by the POST /api/qa/session handler immediately after createSession.
|
|
696
|
-
* Validates pending → spawning, queues the SETUP WI, returns the queued
|
|
805
|
+
* Validates pending → spawning, queues the SETUP WI(s), returns the queued
|
|
806
|
+
* primary WI id (back-compat).
|
|
807
|
+
*
|
|
808
|
+
* W-mpq6xqzj000606d0 — multi-project fan-out. Two calling shapes are
|
|
809
|
+
* accepted:
|
|
810
|
+
* 1. Legacy single-target: `{ wiPath, project }` (project may be null for
|
|
811
|
+
* central). Builds and queues one SETUP WI exactly like before.
|
|
812
|
+
* 2. Multi-target: `{ resolvedTargets: [{ project, wiPath }, ...] }`. The
|
|
813
|
+
* FIRST entry is treated as the primary (drives DRAFT/EXECUTE later);
|
|
814
|
+
* the rest are co-services. One SETUP WI is queued per entry, and
|
|
815
|
+
* `session.setupStatus` is initialized with a `pending` entry per
|
|
816
|
+
* project so handleSetupComplete can fan-in.
|
|
697
817
|
*
|
|
698
818
|
* @param {string} sessionId
|
|
699
819
|
* @param {object} opts
|
|
700
|
-
* @param {string} opts.wiPath
|
|
701
|
-
* @param {string} [opts.project] - project name
|
|
820
|
+
* @param {string} [opts.wiPath] - legacy single-target wiPath
|
|
821
|
+
* @param {string} [opts.project] - legacy single-target project name
|
|
822
|
+
* @param {Array<{project:?string, wiPath:string}>} [opts.resolvedTargets]
|
|
823
|
+
* @returns {string} the queued PRIMARY SETUP WI id
|
|
702
824
|
*/
|
|
703
|
-
function queueSetup(sessionId,
|
|
704
|
-
|
|
825
|
+
function queueSetup(sessionId, opts = {}) {
|
|
826
|
+
let targets = Array.isArray(opts.resolvedTargets) ? opts.resolvedTargets.slice() : null;
|
|
827
|
+
if (!targets || targets.length === 0) {
|
|
828
|
+
if (!_isNonEmptyString(opts.wiPath)) {
|
|
829
|
+
throw new Error('qa-sessions: queueSetup requires wiPath or resolvedTargets');
|
|
830
|
+
}
|
|
831
|
+
targets = [{ project: opts.project || null, wiPath: opts.wiPath }];
|
|
832
|
+
}
|
|
833
|
+
// Defensive: each target needs a wiPath. project may be null for central.
|
|
834
|
+
for (const t of targets) {
|
|
835
|
+
if (!t || !_isNonEmptyString(t.wiPath)) {
|
|
836
|
+
throw new Error('qa-sessions: queueSetup target missing wiPath');
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (targets.length > LIMITS.projectsMax) {
|
|
840
|
+
throw new Error(`qa-sessions: queueSetup target count exceeds ${LIMITS.projectsMax}`);
|
|
841
|
+
}
|
|
842
|
+
|
|
705
843
|
const session = getSession(sessionId);
|
|
706
844
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
845
|
+
|
|
846
|
+
const primaryTarget = targets[0];
|
|
847
|
+
const coServiceProjects = targets.slice(1).map(t => t.project || null).filter(Boolean);
|
|
848
|
+
const isMulti = targets.length > 1;
|
|
849
|
+
|
|
850
|
+
// Initialize setupStatus + primaryWiPath BEFORE transitioning so the
|
|
851
|
+
// fan-in handler sees the full project list even on a racing completion.
|
|
852
|
+
// Single-project sessions skip the map to keep blast radius minimal.
|
|
853
|
+
const setupStatusPatch = isMulti
|
|
854
|
+
? targets.reduce((acc, t) => {
|
|
855
|
+
const key = t.project || '__central__';
|
|
856
|
+
acc[key] = { state: 'pending', wiId: null, error: null, completedAt: null };
|
|
857
|
+
return acc;
|
|
858
|
+
}, {})
|
|
859
|
+
: null;
|
|
860
|
+
|
|
707
861
|
// transitionSession enforces pending → spawning. If the session is already
|
|
708
862
|
// past pending (createSession + queueSetup called twice), the throw bubbles
|
|
709
863
|
// up to the dashboard handler and surfaces as a 409 — better than silently
|
|
710
864
|
// double-queueing.
|
|
711
|
-
markSpawning(sessionId
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
865
|
+
markSpawning(sessionId, {
|
|
866
|
+
setupStatus: setupStatusPatch,
|
|
867
|
+
primaryWiPath: primaryTarget.wiPath,
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
// Build + queue one WI per target. Primary is index 0; coServices is the
|
|
871
|
+
// full list of OTHER project names. Each SETUP WI gets its own meta so
|
|
872
|
+
// the agent prompt clearly states its role.
|
|
873
|
+
let primaryWiId = null;
|
|
874
|
+
const builtWiIds = {};
|
|
875
|
+
for (let i = 0; i < targets.length; i++) {
|
|
876
|
+
const t = targets[i];
|
|
877
|
+
const isPrimary = i === 0;
|
|
878
|
+
const wi = buildSetupWorkItem(session, {
|
|
879
|
+
project: t.project || null,
|
|
880
|
+
primary: isPrimary,
|
|
881
|
+
coServices: isPrimary ? coServiceProjects : [],
|
|
882
|
+
primaryProject: primaryTarget.project || null,
|
|
883
|
+
});
|
|
884
|
+
_queueWorkItem(wi, t.wiPath);
|
|
885
|
+
builtWiIds[t.project || '__central__'] = wi.id;
|
|
886
|
+
if (isPrimary) primaryWiId = wi.id;
|
|
887
|
+
}
|
|
888
|
+
// Stamp the primary WI id into workItems.setup (back-compat: dashboard
|
|
889
|
+
// cards index off this single field).
|
|
890
|
+
setSessionWorkItem(sessionId, SESSION_PHASE.SETUP, primaryWiId);
|
|
891
|
+
|
|
892
|
+
// Back-fill each per-project setupStatus entry with its wiId for multi-
|
|
893
|
+
// project sessions.
|
|
894
|
+
if (isMulti) {
|
|
895
|
+
const updated = getSession(sessionId);
|
|
896
|
+
const nextStatus = { ...(updated.setupStatus || setupStatusPatch) };
|
|
897
|
+
for (const [key, wiId] of Object.entries(builtWiIds)) {
|
|
898
|
+
if (nextStatus[key]) nextStatus[key] = { ...nextStatus[key], wiId };
|
|
899
|
+
}
|
|
900
|
+
mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
|
|
901
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
902
|
+
const s = sessions.find(x => x && x.id === sessionId);
|
|
903
|
+
if (s) {
|
|
904
|
+
s.setupStatus = nextStatus;
|
|
905
|
+
s.updatedAt = ts();
|
|
906
|
+
}
|
|
907
|
+
return sessions;
|
|
908
|
+
}, { defaultValue: [] });
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return primaryWiId;
|
|
716
912
|
}
|
|
717
913
|
|
|
718
914
|
/**
|
|
@@ -721,36 +917,113 @@ function queueSetup(sessionId, { wiPath, project } = {}) {
|
|
|
721
917
|
* marking the dispatch successful), so we advance to drafting and queue the
|
|
722
918
|
* DRAFT WI. On failure we record the failureClass and mark the session failed.
|
|
723
919
|
*
|
|
920
|
+
* W-mpq6xqzj000606d0 — fan-in for multi-project sessions. When
|
|
921
|
+
* `session.setupStatus` is populated (length > 1 targets queued), each
|
|
922
|
+
* completion updates one entry. The session only advances to drafting once
|
|
923
|
+
* ALL entries are `success`. Any failure marks the entire session failed
|
|
924
|
+
* with a per-project error map encoded into session.error.
|
|
925
|
+
*
|
|
724
926
|
* @param {string} sessionId
|
|
725
927
|
* @param {object} opts
|
|
726
928
|
* @param {boolean} opts.success
|
|
727
|
-
* @param {string} [opts.wiPath]
|
|
728
|
-
* @param {string} [opts.project]
|
|
929
|
+
* @param {string} [opts.wiPath] - required when success=true (single-project) OR for the PRIMARY target on multi-project DRAFT queueing
|
|
930
|
+
* @param {string} [opts.project] - which project the completing WI was for (multi-project routing key)
|
|
729
931
|
* @param {string} [opts.failureClass]
|
|
730
932
|
* @param {string} [opts.reason]
|
|
731
|
-
* @returns {string|null} the queued DRAFT WI id on success, null
|
|
933
|
+
* @returns {string|null} the queued DRAFT WI id on success (single OR last-to-complete in multi), null otherwise
|
|
732
934
|
*/
|
|
733
935
|
function handleSetupComplete(sessionId, opts = {}) {
|
|
734
936
|
const session = getSession(sessionId);
|
|
735
937
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
938
|
+
|
|
939
|
+
const isMulti = session.setupStatus && typeof session.setupStatus === 'object'
|
|
940
|
+
&& Object.keys(session.setupStatus).length > 1;
|
|
941
|
+
|
|
942
|
+
// ── Single-project fast path: original behavior, untouched semantics ──
|
|
943
|
+
if (!isMulti) {
|
|
944
|
+
if (opts.success) {
|
|
945
|
+
if (!_isNonEmptyString(opts.wiPath)) {
|
|
946
|
+
throw new Error('qa-sessions: handleSetupComplete success requires wiPath');
|
|
947
|
+
}
|
|
948
|
+
markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
|
|
949
|
+
const updated = getSession(sessionId);
|
|
950
|
+
const wi = buildDraftWorkItem(updated, { project: opts.project || updated.spec.project || null });
|
|
951
|
+
_queueWorkItem(wi, opts.wiPath);
|
|
952
|
+
setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
|
|
953
|
+
return wi.id;
|
|
739
954
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
return wi.id;
|
|
955
|
+
markFailed(sessionId, {
|
|
956
|
+
failureClass: opts.failureClass || 'qa-session-setup-failed',
|
|
957
|
+
error: opts.reason || null,
|
|
958
|
+
summary: opts.reason || 'SETUP phase failed',
|
|
959
|
+
});
|
|
960
|
+
return null;
|
|
747
961
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
962
|
+
|
|
963
|
+
// ── Multi-project fan-in path ─────────────────────────────────────────
|
|
964
|
+
const project = opts.project || null;
|
|
965
|
+
const key = project || '__central__';
|
|
966
|
+
|
|
967
|
+
// Update this project's entry under lock. Capture the merged map so we
|
|
968
|
+
// can decide on a transition without re-reading.
|
|
969
|
+
let mergedStatus = null;
|
|
970
|
+
mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
|
|
971
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
972
|
+
const s = sessions.find(x => x && x.id === sessionId);
|
|
973
|
+
if (!s || !s.setupStatus) return sessions;
|
|
974
|
+
if (!s.setupStatus[key]) {
|
|
975
|
+
// Unknown project — log via error rather than crashing the lifecycle
|
|
976
|
+
// hook. Treat as if the WI for that project failed so the session
|
|
977
|
+
// doesn't stall waiting for an entry that will never arrive.
|
|
978
|
+
s.setupStatus[key] = { state: 'pending', wiId: null, error: null, completedAt: null };
|
|
979
|
+
}
|
|
980
|
+
s.setupStatus[key] = {
|
|
981
|
+
...s.setupStatus[key],
|
|
982
|
+
state: opts.success ? 'success' : 'failed',
|
|
983
|
+
error: opts.success ? null : (opts.reason || opts.failureClass || 'unknown'),
|
|
984
|
+
completedAt: ts(),
|
|
985
|
+
};
|
|
986
|
+
s.updatedAt = ts();
|
|
987
|
+
mergedStatus = JSON.parse(JSON.stringify(s.setupStatus));
|
|
988
|
+
return sessions;
|
|
989
|
+
}, { defaultValue: [] });
|
|
990
|
+
|
|
991
|
+
if (!mergedStatus) return null;
|
|
992
|
+
|
|
993
|
+
const entries = Object.entries(mergedStatus);
|
|
994
|
+
const stillPending = entries.some(([, v]) => v.state === 'pending');
|
|
995
|
+
const anyFailed = entries.some(([, v]) => v.state === 'failed');
|
|
996
|
+
|
|
997
|
+
if (stillPending) {
|
|
998
|
+
// Wait for sibling WIs to report in. No state transition yet.
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (anyFailed) {
|
|
1003
|
+
const errors = entries
|
|
1004
|
+
.filter(([, v]) => v.state === 'failed')
|
|
1005
|
+
.map(([k, v]) => ({ project: k === '__central__' ? null : k, reason: v.error }));
|
|
1006
|
+
markFailed(sessionId, {
|
|
1007
|
+
failureClass: 'qa-session-setup-failed',
|
|
1008
|
+
error: JSON.stringify(errors),
|
|
1009
|
+
summary: `SETUP phase failed for ${errors.length}/${entries.length} project(s)`,
|
|
1010
|
+
});
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// All success → advance to drafting and queue DRAFT on the PRIMARY's
|
|
1015
|
+
// wiPath (captured at queueSetup time).
|
|
1016
|
+
const fresh = getSession(sessionId);
|
|
1017
|
+
const draftWiPath = fresh && fresh.primaryWiPath;
|
|
1018
|
+
if (!_isNonEmptyString(draftWiPath)) {
|
|
1019
|
+
throw new Error('qa-sessions: handleSetupComplete multi-project missing session.primaryWiPath');
|
|
1020
|
+
}
|
|
1021
|
+
markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
|
|
1022
|
+
const updated = getSession(sessionId);
|
|
1023
|
+
const wi = buildDraftWorkItem(updated, { project: updated.primaryProject || updated.spec.project || null });
|
|
1024
|
+
_queueWorkItem(wi, draftWiPath);
|
|
1025
|
+
setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
|
|
1026
|
+
return wi.id;
|
|
754
1027
|
}
|
|
755
1028
|
|
|
756
1029
|
/**
|
|
@@ -1005,4 +1278,5 @@ module.exports = {
|
|
|
1005
1278
|
summarizeSessionsForStatus,
|
|
1006
1279
|
// Internals (exposed for tests)
|
|
1007
1280
|
_isSafeSessionId,
|
|
1281
|
+
_resolveSessionProjects,
|
|
1008
1282
|
};
|
package/engine.js
CHANGED
|
@@ -5200,6 +5200,23 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
|
|
|
5200
5200
|
.join(',')
|
|
5201
5201
|
: '',
|
|
5202
5202
|
session_mode: (item.meta && item.meta.qaSession && item.meta.qaSession.mode) || '',
|
|
5203
|
+
// W-mpq6xqzj000606d0 — Multi-project QA Session fan-out vars. SETUP fan-out
|
|
5204
|
+
// queues one WI per project with meta.qaSession.primary boolean +
|
|
5205
|
+
// coServices array (set by qa-sessions._baseWorkItem). Surface them here as
|
|
5206
|
+
// template vars so the SETUP playbook can branch on {{role}} and so the
|
|
5207
|
+
// primary's prompt sees the canonical primary_project + co_services_json
|
|
5208
|
+
// it needs to poll each co-service's managed-spawn health before
|
|
5209
|
+
// finalizing. Single-project sessions never set qaSession.primary, so all
|
|
5210
|
+
// three vars resolve to empty strings (filtered via PLAYBOOK_OPTIONAL_VARS).
|
|
5211
|
+
role: (item.meta && item.meta.qaSession && typeof item.meta.qaSession.primary === 'boolean'
|
|
5212
|
+
? (item.meta.qaSession.primary ? 'primary' : 'co-service')
|
|
5213
|
+
: ''),
|
|
5214
|
+
primary_project: (item.meta && item.meta.qaSession && item.meta.qaSession.primaryProject)
|
|
5215
|
+
? String(item.meta.qaSession.primaryProject)
|
|
5216
|
+
: '',
|
|
5217
|
+
co_services_json: (item.meta && item.meta.qaSession && Array.isArray(item.meta.qaSession.coServices)
|
|
5218
|
+
? JSON.stringify(item.meta.qaSession.coServices)
|
|
5219
|
+
: ''),
|
|
5203
5220
|
// P-f9a2e1b4 — Runner adapter briefs. The DRAFT playbook consumes
|
|
5204
5221
|
// {{runner_brief}} (runner.generateBrief() output); EXECUTE consumes
|
|
5205
5222
|
// {{runner_execute_brief}} (runner.executeBrief() output) plus
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2083",
|
|
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"
|
|
@@ -24,9 +24,41 @@ A user asked Minions to QA the following target and flows:
|
|
|
24
24
|
- **Runner hint (optional explicit runner):** `{{runner_hint}}`
|
|
25
25
|
- **Capture:** `{{capture}}`
|
|
26
26
|
- **Mode:** `{{session_mode}}`
|
|
27
|
+
- **Role (multi-project fan-out):** `{{role}}` *(empty = single-project session, behave as if `primary`)*
|
|
28
|
+
- **Primary project (multi-project fan-out):** `{{primary_project}}`
|
|
29
|
+
- **Co-services JSON (multi-project fan-out):** `{{co_services_json}}`
|
|
27
30
|
|
|
28
31
|
{{additional_context}}
|
|
29
32
|
|
|
33
|
+
## Multi-project fan-out (W-mpq6xqzj000606d0)
|
|
34
|
+
|
|
35
|
+
When the user picks **multiple projects** from the QA Session form, the engine
|
|
36
|
+
queues one SETUP work item **per project** in parallel. Each WI sees the same
|
|
37
|
+
target/flows/managed_spawn_name, but with a different `{{role}}` value:
|
|
38
|
+
|
|
39
|
+
- **`{{role}} == "primary"`** *(or empty for single-project sessions)*: you are
|
|
40
|
+
the orchestrator. You own the canonical `{{managed_spawn_name}}` (no suffix)
|
|
41
|
+
and your work item's wiPath is the one that gets the DRAFT phase queued
|
|
42
|
+
on it once **all** SETUP WIs succeed. If `{{co_services_json}}` is non-empty
|
|
43
|
+
(e.g. `["api","worker"]`), the DRAFT and EXECUTE agents will poll each of
|
|
44
|
+
those co-services' managed-spawn health (name = `{{managed_spawn_name}}-<project>`)
|
|
45
|
+
via `/api/managed-processes/by-name` before treating the system as ready.
|
|
46
|
+
- **`{{role}} == "co-service"`**: you are a supporting service (DB, API,
|
|
47
|
+
worker, etc.). Your managed-spawn `name` MUST be `{{managed_spawn_name}}-<your-project>`
|
|
48
|
+
(the engine appends `-<project>` automatically when computing the spec name).
|
|
49
|
+
You do **not** write tests, do not queue DRAFT — just stand up your service,
|
|
50
|
+
declare its healthcheck, and exit clean. The primary's DRAFT/EXECUTE will
|
|
51
|
+
reach you over the network (use a deterministic port; document it in
|
|
52
|
+
`managed-spawn.json` `ports[]`).
|
|
53
|
+
|
|
54
|
+
Any co-service SETUP that fails (sidecar invalid, healthcheck miss, build
|
|
55
|
+
error) **fails the entire session** with `failure_class: 'qa-session-setup-failed'`
|
|
56
|
+
and a per-project error JSON in `session.error`. The primary's WI does NOT
|
|
57
|
+
get the DRAFT phase queued in that case.
|
|
58
|
+
|
|
59
|
+
For single-project sessions (`{{co_services_json}}` empty or `[]`), ignore
|
|
60
|
+
this section — the original single-WI flow applies unchanged.
|
|
61
|
+
|
|
30
62
|
## What "qa-session-setup" means
|
|
31
63
|
|
|
32
64
|
A `qa-session-setup` task is the **first** of three chained work items the
|
package/prompts/cc-system.md
CHANGED
|
@@ -186,25 +186,33 @@ When the user describes a UI/E2E flow they want validated against a *live, runni
|
|
|
186
186
|
- `mode` — `"confirm"` (default — pauses at `awaiting-approval` so the user can review the drafted test before EXECUTE fires) or `"auto"` (chains straight from DRAFT to EXECUTE). Pick `"confirm"` unless the user said "just run it" / "no review needed" / "auto".
|
|
187
187
|
- `capture` — optional `{ video?: bool, screenshots?: bool, logs?: bool }`. Default is everything false. Set what the user asked for.
|
|
188
188
|
- `runner` — optional kebab-case name to force a specific runner (`"playwright"`, `"maestro"`, or a plugin). Omit to let the engine auto-detect (Maestro wins when the project has `.maestro/`; Playwright is the safe default).
|
|
189
|
-
- `project` — REQUIRED when multiple projects are configured (mirrors `/api/work-items`). Omit for the central path.
|
|
189
|
+
- `project` — REQUIRED (legacy single-project form) when multiple projects are configured (mirrors `/api/work-items`). Omit for the central path. **Multi-project form (W-mpq6xqzj000606d0):** pass `projects: string[]` (≤5) instead of `project` to QA across multiple services in parallel. The first entry is the **primary** (owns DRAFT/EXECUTE and the canonical managed-spawn name); the rest are **co-services** (each gets a SETUP work item that stands up its dev-up command, named `qa-session-<id>-<project>`). All co-services must pass first-healthcheck before DRAFT fires; any failure fails the whole session. Use this when the user says "QA the api+worker against PR #X" or "smoke test the full stack on `develop`".
|
|
190
190
|
|
|
191
|
-
**Worked example — PR target, confirm mode (default):**
|
|
191
|
+
**Worked example — PR target, confirm mode (default), single project:**
|
|
192
192
|
```
|
|
193
193
|
curl -s -X POST http://localhost:{{dashboard_port}}/api/qa/session \
|
|
194
194
|
-H 'Content-Type: application/json' \
|
|
195
195
|
-H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
196
|
-
-d '{"target":{"kind":"pr","prId":"github:yemi33/MyApp#1234"},"flowsRaw":"Open the homepage, click Login, enter test@example.com / hunter2, and verify the dashboard renders with the user'\''s name in the header.","mode":"confirm","capture":{"screenshots":true,"logs":true},"
|
|
196
|
+
-d '{"target":{"kind":"pr","prId":"github:yemi33/MyApp#1234"},"flowsRaw":"Open the homepage, click Login, enter test@example.com / hunter2, and verify the dashboard renders with the user'\''s name in the header.","mode":"confirm","capture":{"screenshots":true,"logs":true},"projects":["MyApp"]}'
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
**Worked example — current worktree, auto mode, video capture:**
|
|
199
|
+
**Worked example — current worktree, auto mode, video capture, single project:**
|
|
200
200
|
```
|
|
201
201
|
curl -s -X POST http://localhost:{{dashboard_port}}/api/qa/session \
|
|
202
202
|
-H 'Content-Type: application/json' \
|
|
203
203
|
-H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
204
|
-
-d '{"target":{"kind":"current"},"flowsRaw":"Add three items to the cart, go to checkout, complete the payment form with the Stripe test card, and verify the success page.","mode":"auto","capture":{"video":true,"screenshots":true},"
|
|
204
|
+
-d '{"target":{"kind":"current"},"flowsRaw":"Add three items to the cart, go to checkout, complete the payment form with the Stripe test card, and verify the success page.","mode":"auto","capture":{"video":true,"screenshots":true},"projects":["MyApp"]}'
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
-
**
|
|
207
|
+
**Worked example — multi-project fan-out (primary + 2 co-services):**
|
|
208
|
+
```
|
|
209
|
+
curl -s -X POST http://localhost:{{dashboard_port}}/api/qa/session \
|
|
210
|
+
-H 'Content-Type: application/json' \
|
|
211
|
+
-H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
212
|
+
-d '{"target":{"kind":"branch","branch":"develop"},"flowsRaw":"Place an order on the storefront and verify it shows up in the admin panel within 5 seconds.","mode":"confirm","capture":{"video":true,"logs":true},"projects":["storefront","api","worker"]}'
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Response:** `{ sessionId, state: "spawning", setupWorkItemId, managedSpawnName, projects? }`. `setupWorkItemId` is the **primary's** WI id; `projects` is the canonical array (single-project sessions omit it). Tell the user the session id so they can watch it at `/qa` and steer it via the `/approve` (run the drafted test), `/edit` (re-draft with feedback), `/dismiss` (accept the draft without running), `/cancel` (give up), or `/kill` (cancel + tear down the managed-spawn) endpoints listed in `GET /api/routes`.
|
|
208
216
|
|
|
209
217
|
**Do not also dispatch a `/api/work-items` `implement` or `test` for the same QA request.** The QA Session pipeline owns its own SETUP → DRAFT → EXECUTE work items end-to-end; firing a parallel work-item is the same double-dispatch class that the "Never both" rule above forbids. If the user asks for both a QA pass AND a code change, do them as two separate, sequential calls — QA Session for the behavioural check, work-item for the fix.
|
|
210
218
|
|