@yemi33/minions 0.1.2070 → 0.1.2072
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 +358 -0
- package/dashboard/js/state.js +2 -1
- package/dashboard/pages/qa.html +72 -0
- package/dashboard/styles.css +102 -0
- package/dashboard.js +410 -6
- package/docs/qa-runbook-lifecycle.md +232 -0
- package/engine/cleanup.js +4 -1
- package/engine/comment-classifier.js +8 -1
- package/engine/cooldown.js +6 -2
- package/engine/gh-comment.js +74 -3
- package/engine/gh-token.js +7 -9
- package/engine/lifecycle.js +100 -0
- package/engine/pipeline.js +9 -1
- package/engine/playbook.js +39 -0
- package/engine/qa-runners/maestro.js +152 -0
- package/engine/qa-runners/playwright.js +149 -0
- package/engine/qa-runners.js +323 -0
- package/engine/qa-sessions.js +1008 -0
- package/engine/shared.js +71 -12
- package/engine.js +140 -0
- package/package.json +1 -1
- package/playbooks/qa-session-draft.md +158 -0
- package/playbooks/qa-session-execute.md +165 -0
- package/playbooks/qa-session-setup.md +154 -0
- package/prompts/cc-system.md +43 -0
- package/routing.md +3 -0
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/qa-sessions.js — Lifecycle + persistence for QA Sessions.
|
|
3
|
+
*
|
|
4
|
+
* QA Session = a single end-to-end natural-language QA flow that the engine
|
|
5
|
+
* orchestrates across THREE chained work items:
|
|
6
|
+
*
|
|
7
|
+
* SETUP → resolves target (PR / branch / current / commit), checks out a
|
|
8
|
+
* worktree, decides the dev-up command, writes a
|
|
9
|
+
* managed-spawn.json sidecar. Engine spawns the spec; healthcheck
|
|
10
|
+
* gates the transition to DRAFT.
|
|
11
|
+
* DRAFT → uses the active runner adapter to translate the user's
|
|
12
|
+
* natural-language flows into a runner-native test file at
|
|
13
|
+
* engine/qa-tests/<sessionId>/test.<ext>. In `confirm` mode the
|
|
14
|
+
* session parks at `awaiting-approval` for human review; in
|
|
15
|
+
* `auto` mode it auto-chains EXECUTE.
|
|
16
|
+
* EXECUTE → runs the drafted test against the managed-spawn target, writes
|
|
17
|
+
* agents/<id>/qa-run-result.json. The existing qa-runs lifecycle
|
|
18
|
+
* hook (engine/lifecycle.js:4340) ingests the sidecar; this
|
|
19
|
+
* module then transitions the session done/failed based on the
|
|
20
|
+
* resulting qa-run terminal status.
|
|
21
|
+
*
|
|
22
|
+
* State machine (8 values):
|
|
23
|
+
*
|
|
24
|
+
* pending ──▶ spawning ──▶ drafting ──▶ awaiting-approval ──▶ executing ──▶ done
|
|
25
|
+
* │ │ │ │ │ ╲
|
|
26
|
+
* ╰────────────┴─────────────┴────────────────┴────────────────┴───────────▶ failed
|
|
27
|
+
* ╰────────────┴─────────────┴────────────────┴────────────────┴───────────▶ killed
|
|
28
|
+
*
|
|
29
|
+
* (awaiting-approval ──▶ drafting on /edit; drafting ──▶ executing on auto mode.)
|
|
30
|
+
*
|
|
31
|
+
* Concurrency: every mutation goes through mutateJsonFileLocked per the repo
|
|
32
|
+
* convention. Callbacks are synchronous and never await. Slow filesystem work
|
|
33
|
+
* (qa-tests/<id>/ scaffolding, dispatch enqueueing) runs OUTSIDE the lock.
|
|
34
|
+
*
|
|
35
|
+
* Path-traversal hardening: sessionId is generated by createSession() with a
|
|
36
|
+
* uid suffix, but the module still treats every callsite as untrusted —
|
|
37
|
+
* _isSafeSessionId() is invoked on every public read/write that maps an id to
|
|
38
|
+
* a filesystem path or a session lookup. Mirrors engine/qa-runbooks.js
|
|
39
|
+
* _isSafeId (PR #2694 review feedback).
|
|
40
|
+
*
|
|
41
|
+
* State file: engine/qa-sessions.json (single file, all sessions across all
|
|
42
|
+
* projects), capped at QA_SESSIONS_MAX_RECORDS via createSession-time rotation.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const fs = require('fs');
|
|
46
|
+
const path = require('path');
|
|
47
|
+
const shared = require('./shared');
|
|
48
|
+
const { mutateJsonFileLocked, uid, ts, log } = shared;
|
|
49
|
+
|
|
50
|
+
// Cap engine/qa-sessions.json. Sessions cost more than runs (3 WIs, a
|
|
51
|
+
// managed-spawn, artifacts) so the operational steady state is meaningfully
|
|
52
|
+
// smaller than QA_RUNS_MAX_RECORDS (2000). 500 covers ~2 months of nightly +
|
|
53
|
+
// ad-hoc sessions without ballooning the JSON parse cost on /api/status's
|
|
54
|
+
// fast-state slice (W-mpehsyhv event-loop budget — see qa-runs.js cap notes).
|
|
55
|
+
const QA_SESSIONS_MAX_RECORDS = 500;
|
|
56
|
+
|
|
57
|
+
const QA_SESSION_STATE = Object.freeze({
|
|
58
|
+
PENDING: 'pending',
|
|
59
|
+
SPAWNING: 'spawning',
|
|
60
|
+
DRAFTING: 'drafting',
|
|
61
|
+
AWAITING_APPROVAL: 'awaiting-approval',
|
|
62
|
+
EXECUTING: 'executing',
|
|
63
|
+
DONE: 'done',
|
|
64
|
+
FAILED: 'failed',
|
|
65
|
+
KILLED: 'killed',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const TERMINAL_STATES = Object.freeze(new Set([
|
|
69
|
+
QA_SESSION_STATE.DONE,
|
|
70
|
+
QA_SESSION_STATE.FAILED,
|
|
71
|
+
QA_SESSION_STATE.KILLED,
|
|
72
|
+
]));
|
|
73
|
+
|
|
74
|
+
const SESSION_PHASE = Object.freeze({
|
|
75
|
+
SETUP: 'setup',
|
|
76
|
+
DRAFT: 'draft',
|
|
77
|
+
EXECUTE: 'execute',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Allowed forward transitions. Anything not enumerated here is rejected.
|
|
81
|
+
//
|
|
82
|
+
// Notes:
|
|
83
|
+
// - pending → killed/failed: a session can be cancelled or fail before the
|
|
84
|
+
// SETUP WI even starts (e.g., POST /sessions/<id>/cancel right after
|
|
85
|
+
// POST /api/qa/session, or createSession failed to queue SETUP).
|
|
86
|
+
// - drafting → executing: auto-mode skips awaiting-approval and goes
|
|
87
|
+
// straight from DRAFT-done to EXECUTE.
|
|
88
|
+
// - awaiting-approval → drafting: POST /api/qa/sessions/<id>/edit re-fires
|
|
89
|
+
// DRAFT with the user's natural-language feedback as steering.
|
|
90
|
+
// - awaiting-approval → done: POST /api/qa/sessions/<id>/dismiss accepts
|
|
91
|
+
// the draft as final but doesn't run it (user decided to ship the test
|
|
92
|
+
// file as-is and stop the session).
|
|
93
|
+
// - executing → killed: POST /api/qa/sessions/<id>/kill while the EXECUTE
|
|
94
|
+
// WI is mid-flight terminates the spawn and short-circuits the session.
|
|
95
|
+
// - Terminal states (done/failed/killed) have NO outgoing transitions.
|
|
96
|
+
const ALLOWED_TRANSITIONS = {
|
|
97
|
+
[QA_SESSION_STATE.PENDING]: new Set([
|
|
98
|
+
QA_SESSION_STATE.SPAWNING,
|
|
99
|
+
QA_SESSION_STATE.FAILED,
|
|
100
|
+
QA_SESSION_STATE.KILLED,
|
|
101
|
+
]),
|
|
102
|
+
[QA_SESSION_STATE.SPAWNING]: new Set([
|
|
103
|
+
QA_SESSION_STATE.DRAFTING,
|
|
104
|
+
QA_SESSION_STATE.FAILED,
|
|
105
|
+
QA_SESSION_STATE.KILLED,
|
|
106
|
+
]),
|
|
107
|
+
[QA_SESSION_STATE.DRAFTING]: new Set([
|
|
108
|
+
QA_SESSION_STATE.AWAITING_APPROVAL,
|
|
109
|
+
QA_SESSION_STATE.EXECUTING,
|
|
110
|
+
QA_SESSION_STATE.FAILED,
|
|
111
|
+
QA_SESSION_STATE.KILLED,
|
|
112
|
+
]),
|
|
113
|
+
[QA_SESSION_STATE.AWAITING_APPROVAL]: new Set([
|
|
114
|
+
QA_SESSION_STATE.DRAFTING,
|
|
115
|
+
QA_SESSION_STATE.EXECUTING,
|
|
116
|
+
QA_SESSION_STATE.DONE,
|
|
117
|
+
QA_SESSION_STATE.FAILED,
|
|
118
|
+
QA_SESSION_STATE.KILLED,
|
|
119
|
+
]),
|
|
120
|
+
[QA_SESSION_STATE.EXECUTING]: new Set([
|
|
121
|
+
QA_SESSION_STATE.DONE,
|
|
122
|
+
QA_SESSION_STATE.FAILED,
|
|
123
|
+
QA_SESSION_STATE.KILLED,
|
|
124
|
+
]),
|
|
125
|
+
[QA_SESSION_STATE.DONE]: new Set(),
|
|
126
|
+
[QA_SESSION_STATE.FAILED]: new Set(),
|
|
127
|
+
[QA_SESSION_STATE.KILLED]: new Set(),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const VALID_TARGET_KINDS = new Set(['pr', 'branch', 'current', 'commit']);
|
|
131
|
+
const VALID_MODES = new Set(['confirm', 'auto']);
|
|
132
|
+
|
|
133
|
+
const LIMITS = {
|
|
134
|
+
idMax: 64,
|
|
135
|
+
flowsMax: 4000,
|
|
136
|
+
feedbackMax: 4000,
|
|
137
|
+
runnerNameMax: 64,
|
|
138
|
+
targetFieldMax: 500,
|
|
139
|
+
projectMax: 64,
|
|
140
|
+
summaryMax: 2000,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Mirrors engine/qa-runbooks.js _isSafeId — kebab-case ≤64 chars, no leading/
|
|
144
|
+
// trailing hyphen, no double hyphen, no path separators / null bytes / `..`.
|
|
145
|
+
// Reject anything that isn't safe BEFORE it can reach a path.join or a session
|
|
146
|
+
// lookup so a hostile sessionId from the dashboard can't read or overwrite an
|
|
147
|
+
// arbitrary file under MINIONS_DIR/engine.
|
|
148
|
+
const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
149
|
+
|
|
150
|
+
function _isNonEmptyString(v) {
|
|
151
|
+
return typeof v === 'string' && v.length > 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _isSafeSessionId(id) {
|
|
155
|
+
return _isNonEmptyString(id) && id.length <= LIMITS.idMax && _KEBAB_RE.test(id);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Dynamic paths — respect MINIONS_TEST_DIR for test isolation. shared.MINIONS_DIR
|
|
159
|
+
// resolves at every call so MINIONS_TEST_DIR=foo flips the resolution
|
|
160
|
+
// without re-requiring this module (mirrors qa-runs.js pattern).
|
|
161
|
+
function qaSessionsPath() {
|
|
162
|
+
return path.join(shared.MINIONS_DIR, 'engine', 'qa-sessions.json');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function qaTestsDir() {
|
|
166
|
+
return path.join(shared.MINIONS_DIR, 'engine', 'qa-tests');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function qaTestsDirForSession(sessionId) {
|
|
170
|
+
if (!_isSafeSessionId(sessionId)) {
|
|
171
|
+
throw new Error('qa-sessions: unsafe sessionId for path: ' + sessionId);
|
|
172
|
+
}
|
|
173
|
+
return path.join(qaTestsDir(), sessionId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isValidState(s) {
|
|
177
|
+
return Object.values(QA_SESSION_STATE).includes(s);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function validateTransition(from, to) {
|
|
181
|
+
if (!isValidState(from)) throw new Error(`qa-sessions: invalid source state "${from}"`);
|
|
182
|
+
if (!isValidState(to)) throw new Error(`qa-sessions: invalid target state "${to}"`);
|
|
183
|
+
const allowed = ALLOWED_TRANSITIONS[from];
|
|
184
|
+
if (!allowed.has(to)) {
|
|
185
|
+
throw new Error(`qa-sessions: illegal state transition ${from} -> ${to}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Validation helpers ──────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function _validateTarget(target) {
|
|
192
|
+
const errors = [];
|
|
193
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
|
194
|
+
return ['target must be a plain object'];
|
|
195
|
+
}
|
|
196
|
+
if (!_isNonEmptyString(target.kind) || !VALID_TARGET_KINDS.has(target.kind)) {
|
|
197
|
+
errors.push('target.kind must be one of: ' + [...VALID_TARGET_KINDS].join(', '));
|
|
198
|
+
return errors;
|
|
199
|
+
}
|
|
200
|
+
// Per-kind sub-field requirements. Validate each as a length-capped string;
|
|
201
|
+
// the SETUP playbook is responsible for the semantic checks (PR exists,
|
|
202
|
+
// branch fetches, etc.) — this layer just guards path/length safety.
|
|
203
|
+
const requireField = (field) => {
|
|
204
|
+
const v = target[field];
|
|
205
|
+
if (!_isNonEmptyString(v)) errors.push(`target.${field} is required when kind=${target.kind}`);
|
|
206
|
+
else if (v.length > LIMITS.targetFieldMax) {
|
|
207
|
+
errors.push(`target.${field} exceeds ${LIMITS.targetFieldMax} chars`);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
switch (target.kind) {
|
|
211
|
+
case 'pr':
|
|
212
|
+
requireField('prId');
|
|
213
|
+
break;
|
|
214
|
+
case 'branch':
|
|
215
|
+
requireField('branch');
|
|
216
|
+
break;
|
|
217
|
+
case 'commit':
|
|
218
|
+
requireField('sha');
|
|
219
|
+
break;
|
|
220
|
+
case 'current':
|
|
221
|
+
// No required sub-field. `worktree` is optional and validated as string.
|
|
222
|
+
if (target.worktree !== undefined && target.worktree !== null) {
|
|
223
|
+
if (typeof target.worktree !== 'string') {
|
|
224
|
+
errors.push('target.worktree must be a string when present');
|
|
225
|
+
} else if (target.worktree.length > LIMITS.targetFieldMax) {
|
|
226
|
+
errors.push(`target.worktree exceeds ${LIMITS.targetFieldMax} chars`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
return errors;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _validateCapture(capture) {
|
|
235
|
+
if (capture === undefined || capture === null) return [];
|
|
236
|
+
if (typeof capture !== 'object' || Array.isArray(capture)) {
|
|
237
|
+
return ['capture must be a plain object'];
|
|
238
|
+
}
|
|
239
|
+
const errors = [];
|
|
240
|
+
for (const field of ['video', 'screenshots', 'logs']) {
|
|
241
|
+
if (capture[field] !== undefined && typeof capture[field] !== 'boolean') {
|
|
242
|
+
errors.push(`capture.${field} must be boolean when present`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return errors;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validate a createSession spec. Returns { ok, errors }. Never throws.
|
|
250
|
+
*/
|
|
251
|
+
function validateSpec(spec) {
|
|
252
|
+
const errors = [];
|
|
253
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
254
|
+
return { ok: false, errors: ['spec must be a plain object'] };
|
|
255
|
+
}
|
|
256
|
+
errors.push(..._validateTarget(spec.target));
|
|
257
|
+
|
|
258
|
+
if (!_isNonEmptyString(spec.flowsRaw)) {
|
|
259
|
+
errors.push('flowsRaw is required (non-empty string)');
|
|
260
|
+
} else if (spec.flowsRaw.length > LIMITS.flowsMax) {
|
|
261
|
+
errors.push(`flowsRaw exceeds ${LIMITS.flowsMax} chars`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const mode = spec.mode || 'confirm';
|
|
265
|
+
if (!VALID_MODES.has(mode)) {
|
|
266
|
+
errors.push('mode must be one of: ' + [...VALID_MODES].join(', '));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
errors.push(..._validateCapture(spec.capture));
|
|
270
|
+
|
|
271
|
+
if (spec.runner !== undefined && spec.runner !== null) {
|
|
272
|
+
if (typeof spec.runner !== 'string') {
|
|
273
|
+
errors.push('runner must be a string or null when present');
|
|
274
|
+
} else if (spec.runner.length > LIMITS.runnerNameMax) {
|
|
275
|
+
errors.push(`runner exceeds ${LIMITS.runnerNameMax} chars`);
|
|
276
|
+
} else if (spec.runner && !_KEBAB_RE.test(spec.runner)) {
|
|
277
|
+
errors.push('runner must be kebab-case (a-z, 0-9, hyphens)');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (spec.project !== undefined && spec.project !== null) {
|
|
282
|
+
if (typeof spec.project !== 'string') {
|
|
283
|
+
errors.push('project must be a string when present');
|
|
284
|
+
} else if (spec.project.length > LIMITS.projectMax) {
|
|
285
|
+
errors.push(`project exceeds ${LIMITS.projectMax} chars`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { ok: errors.length === 0, errors };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Create a session in `pending` state and persist it to qa-sessions.json.
|
|
296
|
+
*
|
|
297
|
+
* The caller (POST /api/qa/session handler) is responsible for queuing the
|
|
298
|
+
* SETUP work item via buildSetupWorkItem() + the standard work-items/dispatch
|
|
299
|
+
* flow. createSession() intentionally does NOT touch dispatch.json so the
|
|
300
|
+
* pure persistence layer stays unit-testable without standing up the whole
|
|
301
|
+
* engine.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} spec
|
|
304
|
+
* @param {object} spec.target - { kind: 'pr'|'branch'|'current'|'commit', ...sub-fields }
|
|
305
|
+
* @param {string} spec.flowsRaw - natural-language description of what to test
|
|
306
|
+
* @param {string} [spec.mode] - 'confirm' (default) | 'auto'
|
|
307
|
+
* @param {object} [spec.capture] - { video?, screenshots?, logs? }
|
|
308
|
+
* @param {string} [spec.runner] - explicit runner name, or null to auto-detect
|
|
309
|
+
* @param {string} [spec.project] - project name (used to scope artifacts)
|
|
310
|
+
* @param {string} [spec.createdBy] - operator identity for audit
|
|
311
|
+
* @returns {object} the created session record
|
|
312
|
+
*/
|
|
313
|
+
function createSession(spec) {
|
|
314
|
+
const v = validateSpec(spec);
|
|
315
|
+
if (!v.ok) {
|
|
316
|
+
const err = new Error('qa-sessions: invalid spec: ' + v.errors.join('; '));
|
|
317
|
+
err.validationErrors = v.errors;
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const id = 'qas-' + uid();
|
|
322
|
+
const now = ts();
|
|
323
|
+
const session = {
|
|
324
|
+
id,
|
|
325
|
+
state: QA_SESSION_STATE.PENDING,
|
|
326
|
+
spec: {
|
|
327
|
+
target: { ...spec.target },
|
|
328
|
+
flowsRaw: spec.flowsRaw,
|
|
329
|
+
mode: spec.mode || 'confirm',
|
|
330
|
+
capture: {
|
|
331
|
+
video: !!(spec.capture && spec.capture.video),
|
|
332
|
+
screenshots: !!(spec.capture && spec.capture.screenshots),
|
|
333
|
+
logs: !!(spec.capture && spec.capture.logs),
|
|
334
|
+
},
|
|
335
|
+
runner: spec.runner || null,
|
|
336
|
+
project: spec.project || null,
|
|
337
|
+
},
|
|
338
|
+
// Per-phase WI links — back-filled by setSessionWorkItem when the
|
|
339
|
+
// dashboard endpoint or lifecycle hook queues the next phase.
|
|
340
|
+
workItems: { setup: null, draft: null, execute: null },
|
|
341
|
+
// The managed-spawn name follows a deterministic convention
|
|
342
|
+
// (`qa-session-<id>`) so /engine and listManagedSpecs() can join the
|
|
343
|
+
// spawn back to its owning session.
|
|
344
|
+
managedSpawnName: 'qa-session-' + id,
|
|
345
|
+
// qaRunId is the linked qa-runs record id — populated when EXECUTE
|
|
346
|
+
// queues its WI (the dashboard endpoint creates the qa-runs record and
|
|
347
|
+
// stamps it onto session.qaRunId). Default null until then.
|
|
348
|
+
qaRunId: null,
|
|
349
|
+
testFile: null, // relative path under engine/qa-tests/<id>/ filled in by DRAFT
|
|
350
|
+
summary: null,
|
|
351
|
+
failureClass: null,
|
|
352
|
+
error: null,
|
|
353
|
+
createdAt: now,
|
|
354
|
+
createdBy: typeof spec.createdBy === 'string' ? spec.createdBy : null,
|
|
355
|
+
updatedAt: now,
|
|
356
|
+
completedAt: null,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
|
|
360
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
361
|
+
sessions.push(session);
|
|
362
|
+
// Rotation: drop oldest-by-createdAt when over cap. Cheap because it runs
|
|
363
|
+
// only at createSession — the steady-state read paths skip the sort.
|
|
364
|
+
if (sessions.length > QA_SESSIONS_MAX_RECORDS) {
|
|
365
|
+
sessions.sort((a, b) => ((a && a.createdAt) || '').localeCompare((b && b.createdAt) || ''));
|
|
366
|
+
sessions = sessions.slice(sessions.length - QA_SESSIONS_MAX_RECORDS);
|
|
367
|
+
}
|
|
368
|
+
return sessions;
|
|
369
|
+
}, { defaultValue: [] });
|
|
370
|
+
|
|
371
|
+
// Pre-create the per-session test directory OUTSIDE the lock — directory
|
|
372
|
+
// creation is idempotent and the slow fs call must not run while holding
|
|
373
|
+
// the JSON lock (CLAUDE.md convention). DRAFT writes test.<ext> into here.
|
|
374
|
+
try { fs.mkdirSync(qaTestsDirForSession(id), { recursive: true }); }
|
|
375
|
+
catch (e) { log('warn', `qa-sessions: mkdir tests dir failed for ${id}: ${e.message}`); }
|
|
376
|
+
|
|
377
|
+
return session;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Lookup a single session by id, or null if missing / id unsafe.
|
|
382
|
+
*/
|
|
383
|
+
function getSession(id) {
|
|
384
|
+
if (!_isSafeSessionId(id)) return null;
|
|
385
|
+
const sessions = shared.safeJsonArr(qaSessionsPath());
|
|
386
|
+
return sessions.find(s => s && s.id === id) || null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* List sessions, newest first, optionally filtered by state, capped by limit.
|
|
391
|
+
*/
|
|
392
|
+
function listSessions({ limit, state } = {}) {
|
|
393
|
+
let sessions = shared.safeJsonArr(qaSessionsPath());
|
|
394
|
+
if (!Array.isArray(sessions)) return [];
|
|
395
|
+
if (state) {
|
|
396
|
+
if (!isValidState(state)) return [];
|
|
397
|
+
sessions = sessions.filter(s => s && s.state === state);
|
|
398
|
+
}
|
|
399
|
+
sessions = sessions.slice().sort((a, b) => {
|
|
400
|
+
const ac = (a && a.createdAt) || '';
|
|
401
|
+
const bc = (b && b.createdAt) || '';
|
|
402
|
+
if (ac === bc) return ((b && b.id) || '').localeCompare((a && a.id) || '');
|
|
403
|
+
return ac < bc ? 1 : -1;
|
|
404
|
+
});
|
|
405
|
+
const n = Number(limit);
|
|
406
|
+
if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, Math.floor(n));
|
|
407
|
+
return sessions;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Back-fill session.workItems[phase] with the queued WI id. Idempotent —
|
|
412
|
+
* overwrites any prior value (a re-queued DRAFT after /edit replaces the
|
|
413
|
+
* stale id). Returns the updated session or null on unknown id / unsafe id /
|
|
414
|
+
* invalid phase.
|
|
415
|
+
*/
|
|
416
|
+
function setSessionWorkItem(id, phase, workItemId) {
|
|
417
|
+
if (!_isSafeSessionId(id)) return null;
|
|
418
|
+
if (!Object.values(SESSION_PHASE).includes(phase)) return null;
|
|
419
|
+
let captured = null;
|
|
420
|
+
mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
|
|
421
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
422
|
+
const session = sessions.find(s => s && s.id === id);
|
|
423
|
+
if (session) {
|
|
424
|
+
if (!session.workItems || typeof session.workItems !== 'object') {
|
|
425
|
+
session.workItems = { setup: null, draft: null, execute: null };
|
|
426
|
+
}
|
|
427
|
+
session.workItems[phase] = workItemId || null;
|
|
428
|
+
session.updatedAt = ts();
|
|
429
|
+
captured = session;
|
|
430
|
+
}
|
|
431
|
+
return sessions;
|
|
432
|
+
}, { defaultValue: [] });
|
|
433
|
+
return captured;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Back-fill session.qaRunId with the linked qa-runs record id. Called by the
|
|
438
|
+
* EXECUTE dispatch endpoint (PR4) after qaRuns.createRun(). Returns the
|
|
439
|
+
* updated session or null on unknown / unsafe id.
|
|
440
|
+
*/
|
|
441
|
+
function setSessionQaRunId(id, qaRunId) {
|
|
442
|
+
if (!_isSafeSessionId(id)) return null;
|
|
443
|
+
let captured = null;
|
|
444
|
+
mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
|
|
445
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
446
|
+
const session = sessions.find(s => s && s.id === id);
|
|
447
|
+
if (session) {
|
|
448
|
+
session.qaRunId = qaRunId || null;
|
|
449
|
+
session.updatedAt = ts();
|
|
450
|
+
captured = session;
|
|
451
|
+
}
|
|
452
|
+
return sessions;
|
|
453
|
+
}, { defaultValue: [] });
|
|
454
|
+
return captured;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── State transitions ──────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Generic state transition with optional patch. Validates the transition
|
|
461
|
+
* (throws on illegal), applies the patch, stamps updatedAt + completedAt (on
|
|
462
|
+
* terminal). Returns the updated session.
|
|
463
|
+
*
|
|
464
|
+
* Patch fields are applied directly to the session record — callers pass
|
|
465
|
+
* { summary, error, failureClass, testFile, qaRunId, ... } as needed. The
|
|
466
|
+
* `state` field on the patch is IGNORED; use the toState parameter.
|
|
467
|
+
*
|
|
468
|
+
* @param {string} id
|
|
469
|
+
* @param {string} toState
|
|
470
|
+
* @param {object} [patch]
|
|
471
|
+
* @returns {object} updated session
|
|
472
|
+
* @throws Error on unknown id, unsafe id, or illegal transition
|
|
473
|
+
*/
|
|
474
|
+
function transitionSession(id, toState, patch = {}) {
|
|
475
|
+
if (!_isSafeSessionId(id)) throw new Error('qa-sessions: unsafe sessionId: ' + id);
|
|
476
|
+
if (!isValidState(toState)) throw new Error('qa-sessions: invalid target state: ' + toState);
|
|
477
|
+
|
|
478
|
+
let captured = null;
|
|
479
|
+
let transitionError = null;
|
|
480
|
+
mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
|
|
481
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
482
|
+
const session = sessions.find(s => s && s.id === id);
|
|
483
|
+
if (!session) { transitionError = new Error(`qa-sessions: session not found: ${id}`); return sessions; }
|
|
484
|
+
try { validateTransition(session.state, toState); }
|
|
485
|
+
catch (e) { transitionError = e; return sessions; }
|
|
486
|
+
|
|
487
|
+
session.state = toState;
|
|
488
|
+
session.updatedAt = ts();
|
|
489
|
+
if (TERMINAL_STATES.has(toState)) {
|
|
490
|
+
session.completedAt = ts();
|
|
491
|
+
}
|
|
492
|
+
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
|
|
493
|
+
// Whitelist mutable fields to keep transitionSession from rewriting
|
|
494
|
+
// immutable spec/createdAt fields by mistake.
|
|
495
|
+
for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth']) {
|
|
496
|
+
if (Object.prototype.hasOwnProperty.call(patch, field)) {
|
|
497
|
+
session[field] = patch[field];
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
captured = session;
|
|
502
|
+
return sessions;
|
|
503
|
+
}, { defaultValue: [] });
|
|
504
|
+
|
|
505
|
+
if (transitionError) throw transitionError;
|
|
506
|
+
return captured;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Named convenience transitions — thin wrappers over transitionSession for
|
|
510
|
+
// readability at the call sites in lifecycle.js + dashboard.js. Each preserves
|
|
511
|
+
// the throw-on-illegal contract.
|
|
512
|
+
function markSpawning(id, patch) { return transitionSession(id, QA_SESSION_STATE.SPAWNING, patch); }
|
|
513
|
+
function markDrafting(id, patch) { return transitionSession(id, QA_SESSION_STATE.DRAFTING, patch); }
|
|
514
|
+
function markAwaitingApproval(id, patch) { return transitionSession(id, QA_SESSION_STATE.AWAITING_APPROVAL, patch); }
|
|
515
|
+
function markExecuting(id, patch) { return transitionSession(id, QA_SESSION_STATE.EXECUTING, patch); }
|
|
516
|
+
function markDone(id, patch) { return transitionSession(id, QA_SESSION_STATE.DONE, patch); }
|
|
517
|
+
function markFailed(id, patch) { return transitionSession(id, QA_SESSION_STATE.FAILED, patch); }
|
|
518
|
+
function markKilled(id, patch) { return transitionSession(id, QA_SESSION_STATE.KILLED, patch); }
|
|
519
|
+
|
|
520
|
+
// ── Work-item builders (pure) ──────────────────────────────────────────────
|
|
521
|
+
//
|
|
522
|
+
// Each builder returns a WI spec ready for mutateWorkItems().push + addToDispatch.
|
|
523
|
+
// Keeping these pure lets the dashboard endpoints (PR4) reuse them without
|
|
524
|
+
// pulling dispatch into the unit test path. They're also called by the
|
|
525
|
+
// lifecycle chain helpers below to queue the next phase.
|
|
526
|
+
|
|
527
|
+
function _baseWorkItem(session, phase, { title, description, project }) {
|
|
528
|
+
const wiId = 'W-' + uid();
|
|
529
|
+
const wi = {
|
|
530
|
+
id: wiId,
|
|
531
|
+
title,
|
|
532
|
+
// Use TEST as the underlying type — qa-validate's existing dispatch uses
|
|
533
|
+
// the same pattern (dashboard.js:9962). meta.playbook overrides routing
|
|
534
|
+
// so the engine renders the qa-session-* playbook bodies (PR5+6).
|
|
535
|
+
type: shared.WORK_TYPE.TEST,
|
|
536
|
+
priority: 'medium',
|
|
537
|
+
description,
|
|
538
|
+
status: shared.WI_STATUS.PENDING,
|
|
539
|
+
created: new Date().toISOString(),
|
|
540
|
+
createdBy: 'qa-session-' + phase,
|
|
541
|
+
oneShot: true,
|
|
542
|
+
skipPr: true,
|
|
543
|
+
meta: {
|
|
544
|
+
sessionId: session.id,
|
|
545
|
+
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
|
+
},
|
|
553
|
+
playbook: 'qa-session-' + phase,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
if (project) wi.project = project;
|
|
557
|
+
if (phase === 'setup') wi.meta.managed_spawn = true;
|
|
558
|
+
return wi;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Build the SETUP work item. The agent resolves the target, sets up a
|
|
563
|
+
* worktree, and writes a managed-spawn.json sidecar. Engine then spawns the
|
|
564
|
+
* service and the healthcheck gate drives the next transition.
|
|
565
|
+
*/
|
|
566
|
+
function buildSetupWorkItem(session, { project } = {}) {
|
|
567
|
+
return _baseWorkItem(session, SESSION_PHASE.SETUP, {
|
|
568
|
+
title: `QA Session SETUP: ${_summarizeTarget(session.spec.target)}`,
|
|
569
|
+
description: [
|
|
570
|
+
`QA Session ${session.id} — SETUP phase.`,
|
|
571
|
+
'',
|
|
572
|
+
`Target: ${JSON.stringify(session.spec.target)}`,
|
|
573
|
+
`Flows: ${session.spec.flowsRaw}`,
|
|
574
|
+
'',
|
|
575
|
+
'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}\`.`,
|
|
577
|
+
'See `playbooks/qa-session-setup.md` for the full contract.',
|
|
578
|
+
].join('\n'),
|
|
579
|
+
project,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Build the DRAFT work item. The agent reads the live spawn metadata, calls
|
|
585
|
+
* runner.generateBrief(), and writes the runner-native test file under
|
|
586
|
+
* engine/qa-tests/<sessionId>/.
|
|
587
|
+
*
|
|
588
|
+
* @param {object} session
|
|
589
|
+
* @param {object} [opts]
|
|
590
|
+
* @param {string} [opts.project]
|
|
591
|
+
* @param {string} [opts.feedback] - natural-language feedback from /edit, threaded into the prompt as steering
|
|
592
|
+
*/
|
|
593
|
+
function buildDraftWorkItem(session, { project, feedback } = {}) {
|
|
594
|
+
const lines = [
|
|
595
|
+
`QA Session ${session.id} — DRAFT phase.`,
|
|
596
|
+
'',
|
|
597
|
+
`Flows: ${session.spec.flowsRaw}`,
|
|
598
|
+
`Runner: ${session.spec.runner || '(auto-detected)'}`,
|
|
599
|
+
`Mode: ${session.spec.mode}`,
|
|
600
|
+
'',
|
|
601
|
+
`Managed-spawn target: \`${session.managedSpawnName}\` (live; query /api/managed-processes/by-name).`,
|
|
602
|
+
`Write the test file to \`engine/qa-tests/${session.id}/test.<ext>\` using the runner's native format.`,
|
|
603
|
+
'See `playbooks/qa-session-draft.md` for the full contract.',
|
|
604
|
+
];
|
|
605
|
+
if (feedback) {
|
|
606
|
+
lines.push('', '## Reviewer feedback on previous draft', '', String(feedback));
|
|
607
|
+
}
|
|
608
|
+
return _baseWorkItem(session, SESSION_PHASE.DRAFT, {
|
|
609
|
+
title: `QA Session DRAFT: ${_summarizeTarget(session.spec.target)}`,
|
|
610
|
+
description: lines.join('\n'),
|
|
611
|
+
project,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Build the EXECUTE work item. The agent invokes the runner against the live
|
|
617
|
+
* spawn, captures artifacts per `capture`, and writes
|
|
618
|
+
* agents/<id>/qa-run-result.json. The existing qaRunId hook at
|
|
619
|
+
* engine/lifecycle.js:4340 ingests the sidecar; our own session hook below
|
|
620
|
+
* transitions done/failed based on the resulting qa-run terminal status.
|
|
621
|
+
*
|
|
622
|
+
* @param {object} session
|
|
623
|
+
* @param {object} opts
|
|
624
|
+
* @param {string} opts.qaRunId - id from qaRuns.createRun()
|
|
625
|
+
* @param {string} [opts.project]
|
|
626
|
+
*/
|
|
627
|
+
function buildExecuteWorkItem(session, { qaRunId, project } = {}) {
|
|
628
|
+
if (!_isNonEmptyString(qaRunId)) {
|
|
629
|
+
throw new Error('qa-sessions: buildExecuteWorkItem requires qaRunId');
|
|
630
|
+
}
|
|
631
|
+
const wi = _baseWorkItem(session, SESSION_PHASE.EXECUTE, {
|
|
632
|
+
title: `QA Session EXECUTE: ${_summarizeTarget(session.spec.target)}`,
|
|
633
|
+
description: [
|
|
634
|
+
`QA Session ${session.id} — EXECUTE phase.`,
|
|
635
|
+
'',
|
|
636
|
+
`Run \`engine/qa-tests/${session.id}/${session.testFile || 'test.<ext>'}\` against \`${session.managedSpawnName}\`.`,
|
|
637
|
+
`qaRunId: ${qaRunId}`,
|
|
638
|
+
'',
|
|
639
|
+
'Capture artifacts per session.spec.capture. Write a qa-run-result.json',
|
|
640
|
+
'sidecar to your agent dir (the engine ingests it and marks the linked',
|
|
641
|
+
'qa-runs record terminal). See `playbooks/qa-session-execute.md`.',
|
|
642
|
+
].join('\n'),
|
|
643
|
+
project,
|
|
644
|
+
});
|
|
645
|
+
// qaRunId on the WI meta routes the existing lifecycle hook (line 4340) so
|
|
646
|
+
// the qa-runs record gets the same completion semantics as the standalone
|
|
647
|
+
// qa-validate dispatch path. Keeping it at top level (not nested under
|
|
648
|
+
// qaSession) matches the dispatchItem.meta.qaRunId convention.
|
|
649
|
+
wi.meta.qaRunId = qaRunId;
|
|
650
|
+
return wi;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function _summarizeTarget(target) {
|
|
654
|
+
if (!target || typeof target !== 'object') return '(unknown)';
|
|
655
|
+
switch (target.kind) {
|
|
656
|
+
case 'pr': return `PR#${target.prId}`;
|
|
657
|
+
case 'branch': return `branch:${target.branch}`;
|
|
658
|
+
case 'commit': return `commit:${String(target.sha || '').slice(0, 8)}`;
|
|
659
|
+
case 'current': return `current:${target.worktree || 'cwd'}`;
|
|
660
|
+
default: return target.kind || '(unknown)';
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Cross-WI dispatch chain helpers ─────────────────────────────────────────
|
|
665
|
+
//
|
|
666
|
+
// These are the integration entry points the lifecycle hook + dashboard
|
|
667
|
+
// endpoints call when an agent finishes or a user takes an action. Each one
|
|
668
|
+
// validates the transition first (so an illegal call throws BEFORE side
|
|
669
|
+
// effects like queueing the next WI), then applies the state change, then
|
|
670
|
+
// queues the next phase via _queueWorkItem when appropriate.
|
|
671
|
+
//
|
|
672
|
+
// dispatch + work-items are lazy-required inside _queueWorkItem to keep
|
|
673
|
+
// `require('./qa-sessions')` cycle-safe at the top of lifecycle.js.
|
|
674
|
+
|
|
675
|
+
function _queueWorkItem(wi, wiPath) {
|
|
676
|
+
// Append the WI to the project (or central) work-items file, then queue a
|
|
677
|
+
// dispatch entry that wraps it. Mirrors the qa-validate flow at
|
|
678
|
+
// dashboard.js handleQaRunbookRun (line 9985+). Both writes go through their
|
|
679
|
+
// module-internal locks so concurrent dashboard calls don't lose entries.
|
|
680
|
+
shared.mutateWorkItems(wiPath, (items) => {
|
|
681
|
+
if (!Array.isArray(items)) items = [];
|
|
682
|
+
if (!items.some(i => i && i.id === wi.id)) items.push(wi);
|
|
683
|
+
return items;
|
|
684
|
+
});
|
|
685
|
+
const dispatch = require('./dispatch');
|
|
686
|
+
dispatch.addToDispatch({
|
|
687
|
+
type: wi.type,
|
|
688
|
+
agent: wi.agent || null,
|
|
689
|
+
meta: { item: wi, playbook: wi.meta.playbook },
|
|
690
|
+
});
|
|
691
|
+
return wi.id;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Called by the POST /api/qa/session handler immediately after createSession.
|
|
696
|
+
* Validates pending → spawning, queues the SETUP WI, returns the queued WI id.
|
|
697
|
+
*
|
|
698
|
+
* @param {string} sessionId
|
|
699
|
+
* @param {object} opts
|
|
700
|
+
* @param {string} opts.wiPath - resolved work-items path (central or per-project)
|
|
701
|
+
* @param {string} [opts.project] - project name (set on the WI)
|
|
702
|
+
*/
|
|
703
|
+
function queueSetup(sessionId, { wiPath, project } = {}) {
|
|
704
|
+
if (!_isNonEmptyString(wiPath)) throw new Error('qa-sessions: queueSetup requires wiPath');
|
|
705
|
+
const session = getSession(sessionId);
|
|
706
|
+
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
707
|
+
// transitionSession enforces pending → spawning. If the session is already
|
|
708
|
+
// past pending (createSession + queueSetup called twice), the throw bubbles
|
|
709
|
+
// up to the dashboard handler and surfaces as a 409 — better than silently
|
|
710
|
+
// double-queueing.
|
|
711
|
+
markSpawning(sessionId);
|
|
712
|
+
const wi = buildSetupWorkItem(session, { project: project || session.spec.project || null });
|
|
713
|
+
_queueWorkItem(wi, wiPath);
|
|
714
|
+
setSessionWorkItem(sessionId, SESSION_PHASE.SETUP, wi.id);
|
|
715
|
+
return wi.id;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Lifecycle hook: SETUP WI completed. On success the managed-spawn was
|
|
720
|
+
* accepted AND its healthcheck passed (engine.js drives this gating before
|
|
721
|
+
* marking the dispatch successful), so we advance to drafting and queue the
|
|
722
|
+
* DRAFT WI. On failure we record the failureClass and mark the session failed.
|
|
723
|
+
*
|
|
724
|
+
* @param {string} sessionId
|
|
725
|
+
* @param {object} opts
|
|
726
|
+
* @param {boolean} opts.success
|
|
727
|
+
* @param {string} [opts.wiPath] - required when success=true
|
|
728
|
+
* @param {string} [opts.project]
|
|
729
|
+
* @param {string} [opts.failureClass]
|
|
730
|
+
* @param {string} [opts.reason]
|
|
731
|
+
* @returns {string|null} the queued DRAFT WI id on success, null on failure
|
|
732
|
+
*/
|
|
733
|
+
function handleSetupComplete(sessionId, opts = {}) {
|
|
734
|
+
const session = getSession(sessionId);
|
|
735
|
+
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
736
|
+
if (opts.success) {
|
|
737
|
+
if (!_isNonEmptyString(opts.wiPath)) {
|
|
738
|
+
throw new Error('qa-sessions: handleSetupComplete success requires wiPath');
|
|
739
|
+
}
|
|
740
|
+
markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
|
|
741
|
+
// Re-read to pick up the state change for the DRAFT WI builder.
|
|
742
|
+
const updated = getSession(sessionId);
|
|
743
|
+
const wi = buildDraftWorkItem(updated, { project: opts.project || updated.spec.project || null });
|
|
744
|
+
_queueWorkItem(wi, opts.wiPath);
|
|
745
|
+
setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
|
|
746
|
+
return wi.id;
|
|
747
|
+
}
|
|
748
|
+
markFailed(sessionId, {
|
|
749
|
+
failureClass: opts.failureClass || 'qa-session-setup-failed',
|
|
750
|
+
error: opts.reason || null,
|
|
751
|
+
summary: opts.reason || 'SETUP phase failed',
|
|
752
|
+
});
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Lifecycle hook: DRAFT WI completed. On success we either park at
|
|
758
|
+
* awaiting-approval (confirm mode — user must call /approve) or auto-chain
|
|
759
|
+
* EXECUTE (auto mode). The testFile path is captured for the EXECUTE prompt.
|
|
760
|
+
*
|
|
761
|
+
* @param {string} sessionId
|
|
762
|
+
* @param {object} opts
|
|
763
|
+
* @param {boolean} opts.success
|
|
764
|
+
* @param {string} [opts.testFile] - relative path under qa-tests/<id>/, captured for EXECUTE
|
|
765
|
+
* @param {string} [opts.wiPath] - required when success=true and mode=auto
|
|
766
|
+
* @param {string} [opts.project]
|
|
767
|
+
* @param {string} [opts.qaRunId] - required when success=true and mode=auto (caller creates the qa-runs record)
|
|
768
|
+
* @param {string} [opts.reason]
|
|
769
|
+
* @returns {object} { nextState, queuedExecuteWi: string|null }
|
|
770
|
+
*/
|
|
771
|
+
function handleDraftComplete(sessionId, opts = {}) {
|
|
772
|
+
const session = getSession(sessionId);
|
|
773
|
+
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
774
|
+
if (!opts.success) {
|
|
775
|
+
markFailed(sessionId, {
|
|
776
|
+
failureClass: 'qa-session-draft-failed',
|
|
777
|
+
error: opts.reason || null,
|
|
778
|
+
summary: opts.reason || 'DRAFT phase failed',
|
|
779
|
+
});
|
|
780
|
+
return { nextState: QA_SESSION_STATE.FAILED, queuedExecuteWi: null };
|
|
781
|
+
}
|
|
782
|
+
const testFilePatch = opts.testFile ? { testFile: opts.testFile } : {};
|
|
783
|
+
if (session.spec.mode === 'auto') {
|
|
784
|
+
if (!_isNonEmptyString(opts.wiPath)) {
|
|
785
|
+
throw new Error('qa-sessions: handleDraftComplete (auto) requires wiPath');
|
|
786
|
+
}
|
|
787
|
+
if (!_isNonEmptyString(opts.qaRunId)) {
|
|
788
|
+
throw new Error('qa-sessions: handleDraftComplete (auto) requires qaRunId');
|
|
789
|
+
}
|
|
790
|
+
markExecuting(sessionId, { ...testFilePatch, qaRunId: opts.qaRunId });
|
|
791
|
+
const updated = getSession(sessionId);
|
|
792
|
+
const wi = buildExecuteWorkItem(updated, {
|
|
793
|
+
qaRunId: opts.qaRunId,
|
|
794
|
+
project: opts.project || updated.spec.project || null,
|
|
795
|
+
});
|
|
796
|
+
_queueWorkItem(wi, opts.wiPath);
|
|
797
|
+
setSessionWorkItem(sessionId, SESSION_PHASE.EXECUTE, wi.id);
|
|
798
|
+
return { nextState: QA_SESSION_STATE.EXECUTING, queuedExecuteWi: wi.id };
|
|
799
|
+
}
|
|
800
|
+
// confirm mode (default)
|
|
801
|
+
markAwaitingApproval(sessionId, testFilePatch);
|
|
802
|
+
return { nextState: QA_SESSION_STATE.AWAITING_APPROVAL, queuedExecuteWi: null };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Lifecycle hook: EXECUTE WI completed. The qa-runs record's terminal status
|
|
807
|
+
* is the source of truth for done vs failed — the qaRunId hook at
|
|
808
|
+
* engine/lifecycle.js:4340 has already written it. We just read that record
|
|
809
|
+
* (when provided) and transition the session accordingly.
|
|
810
|
+
*
|
|
811
|
+
* @param {string} sessionId
|
|
812
|
+
* @param {object} opts
|
|
813
|
+
* @param {boolean} opts.success - dispatch-level success (whether the agent exited 0)
|
|
814
|
+
* @param {string} [opts.qaRunStatus] - 'passed' | 'failed' | 'errored' (from qa-runs record)
|
|
815
|
+
* @param {string} [opts.summary]
|
|
816
|
+
* @param {string} [opts.reason]
|
|
817
|
+
*/
|
|
818
|
+
function handleExecuteComplete(sessionId, opts = {}) {
|
|
819
|
+
const session = getSession(sessionId);
|
|
820
|
+
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
821
|
+
// qa-run terminal status (when known) trumps dispatch-level success — a
|
|
822
|
+
// passing assertion run with an exit-1 wrapper still reports a passed
|
|
823
|
+
// qa-run; we mark the session done. Conversely, a failed/errored qa-run
|
|
824
|
+
// overrides a green dispatch.
|
|
825
|
+
const qaStatus = opts.qaRunStatus;
|
|
826
|
+
let toState;
|
|
827
|
+
let patch = { summary: opts.summary || null };
|
|
828
|
+
if (qaStatus === 'passed') {
|
|
829
|
+
toState = QA_SESSION_STATE.DONE;
|
|
830
|
+
} else if (qaStatus === 'failed' || qaStatus === 'errored') {
|
|
831
|
+
toState = QA_SESSION_STATE.FAILED;
|
|
832
|
+
patch.failureClass = qaStatus === 'errored' ? 'qa-session-execute-errored' : 'qa-session-execute-failed';
|
|
833
|
+
patch.error = opts.reason || `qa-run terminal status: ${qaStatus}`;
|
|
834
|
+
} else if (opts.success) {
|
|
835
|
+
// No qa-run status reported but the dispatch was successful — assume done.
|
|
836
|
+
toState = QA_SESSION_STATE.DONE;
|
|
837
|
+
} else {
|
|
838
|
+
toState = QA_SESSION_STATE.FAILED;
|
|
839
|
+
patch.failureClass = 'qa-session-execute-failed';
|
|
840
|
+
patch.error = opts.reason || 'EXECUTE phase failed';
|
|
841
|
+
}
|
|
842
|
+
transitionSession(sessionId, toState, patch);
|
|
843
|
+
return toState;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── User-initiated actions (called by dashboard endpoints) ─────────────────
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* POST /api/qa/sessions/<id>/approve — awaiting-approval → executing, queues
|
|
850
|
+
* the EXECUTE WI. Caller creates the qa-runs record and passes its id.
|
|
851
|
+
*/
|
|
852
|
+
function approveDraft(sessionId, { wiPath, qaRunId, project } = {}) {
|
|
853
|
+
if (!_isNonEmptyString(wiPath)) throw new Error('qa-sessions: approveDraft requires wiPath');
|
|
854
|
+
if (!_isNonEmptyString(qaRunId)) throw new Error('qa-sessions: approveDraft requires qaRunId');
|
|
855
|
+
const session = getSession(sessionId);
|
|
856
|
+
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
857
|
+
if (session.state !== QA_SESSION_STATE.AWAITING_APPROVAL) {
|
|
858
|
+
throw new Error(`qa-sessions: approveDraft requires state awaiting-approval, got ${session.state}`);
|
|
859
|
+
}
|
|
860
|
+
markExecuting(sessionId, { qaRunId });
|
|
861
|
+
const updated = getSession(sessionId);
|
|
862
|
+
const wi = buildExecuteWorkItem(updated, {
|
|
863
|
+
qaRunId,
|
|
864
|
+
project: project || updated.spec.project || null,
|
|
865
|
+
});
|
|
866
|
+
_queueWorkItem(wi, wiPath);
|
|
867
|
+
setSessionWorkItem(sessionId, SESSION_PHASE.EXECUTE, wi.id);
|
|
868
|
+
return wi.id;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* POST /api/qa/sessions/<id>/edit — awaiting-approval → drafting, re-queue the
|
|
873
|
+
* DRAFT WI with the user's natural-language feedback threaded into the prompt.
|
|
874
|
+
*/
|
|
875
|
+
function editDraft(sessionId, { wiPath, feedback, project } = {}) {
|
|
876
|
+
if (!_isNonEmptyString(wiPath)) throw new Error('qa-sessions: editDraft requires wiPath');
|
|
877
|
+
if (!_isNonEmptyString(feedback)) throw new Error('qa-sessions: editDraft requires feedback');
|
|
878
|
+
if (feedback.length > LIMITS.feedbackMax) {
|
|
879
|
+
throw new Error(`qa-sessions: editDraft feedback exceeds ${LIMITS.feedbackMax} chars`);
|
|
880
|
+
}
|
|
881
|
+
const session = getSession(sessionId);
|
|
882
|
+
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
883
|
+
if (session.state !== QA_SESSION_STATE.AWAITING_APPROVAL) {
|
|
884
|
+
throw new Error(`qa-sessions: editDraft requires state awaiting-approval, got ${session.state}`);
|
|
885
|
+
}
|
|
886
|
+
markDrafting(sessionId);
|
|
887
|
+
const updated = getSession(sessionId);
|
|
888
|
+
const wi = buildDraftWorkItem(updated, {
|
|
889
|
+
project: project || updated.spec.project || null,
|
|
890
|
+
feedback,
|
|
891
|
+
});
|
|
892
|
+
_queueWorkItem(wi, wiPath);
|
|
893
|
+
setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
|
|
894
|
+
return wi.id;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* POST /api/qa/sessions/<id>/cancel — any non-terminal state → killed. Caller
|
|
899
|
+
* is responsible for killing the managed-spawn (the dashboard endpoint does
|
|
900
|
+
* this via managed-spawn.killSpec before calling cancelSession).
|
|
901
|
+
*/
|
|
902
|
+
function cancelSession(sessionId, { reason } = {}) {
|
|
903
|
+
return markKilled(sessionId, {
|
|
904
|
+
summary: 'Session cancelled by user',
|
|
905
|
+
error: reason || null,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* POST /api/qa/sessions/<id>/kill — same as cancel but explicitly indicates
|
|
911
|
+
* the spawn should be killed too. Caller does the kill outside this module.
|
|
912
|
+
*/
|
|
913
|
+
function killSession(sessionId, { reason } = {}) {
|
|
914
|
+
return markKilled(sessionId, {
|
|
915
|
+
summary: 'Session killed by user',
|
|
916
|
+
error: reason || null,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* POST /api/qa/sessions/<id>/dismiss — mark done without running. Valid from
|
|
922
|
+
* any non-terminal pre-execute state. Caller leaves spawn alive.
|
|
923
|
+
*/
|
|
924
|
+
function dismissSession(sessionId, { summary } = {}) {
|
|
925
|
+
const session = getSession(sessionId);
|
|
926
|
+
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
927
|
+
if (TERMINAL_STATES.has(session.state)) {
|
|
928
|
+
throw new Error(`qa-sessions: dismissSession requires non-terminal state, got ${session.state}`);
|
|
929
|
+
}
|
|
930
|
+
return transitionSession(sessionId, QA_SESSION_STATE.DONE, {
|
|
931
|
+
summary: summary || 'Session dismissed by user',
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ── Status summary (cheap, for /api/status fast-state slice) ───────────────
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Cheap summary for the dashboard /api/status fast-state slice. Mirrors
|
|
939
|
+
* qa-runs.js summarizeRunsForStatus — no sorting, no extra parses. Used by
|
|
940
|
+
* the sidebar activity-dot to detect when a session is created or transitions
|
|
941
|
+
* state without paying for a full read of the session list.
|
|
942
|
+
*
|
|
943
|
+
* @returns {{ total: number, sig: string }}
|
|
944
|
+
*/
|
|
945
|
+
function summarizeSessionsForStatus() {
|
|
946
|
+
const sessions = shared.safeJsonArr(qaSessionsPath());
|
|
947
|
+
if (!Array.isArray(sessions) || sessions.length === 0) return { total: 0, sig: '' };
|
|
948
|
+
let sig = '';
|
|
949
|
+
for (const s of sessions) {
|
|
950
|
+
if (!s) continue;
|
|
951
|
+
sig += (s.id || '') + ':' + (s.state || '') + ',';
|
|
952
|
+
}
|
|
953
|
+
return { total: sessions.length, sig };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
module.exports = {
|
|
957
|
+
// Constants
|
|
958
|
+
QA_SESSION_STATE,
|
|
959
|
+
TERMINAL_STATES,
|
|
960
|
+
SESSION_PHASE,
|
|
961
|
+
ALLOWED_TRANSITIONS,
|
|
962
|
+
VALID_TARGET_KINDS,
|
|
963
|
+
VALID_MODES,
|
|
964
|
+
LIMITS,
|
|
965
|
+
QA_SESSIONS_MAX_RECORDS,
|
|
966
|
+
// Paths
|
|
967
|
+
qaSessionsPath,
|
|
968
|
+
qaTestsDir,
|
|
969
|
+
qaTestsDirForSession,
|
|
970
|
+
// Validation
|
|
971
|
+
validateSpec,
|
|
972
|
+
validateTransition,
|
|
973
|
+
isValidState,
|
|
974
|
+
// CRUD
|
|
975
|
+
createSession,
|
|
976
|
+
getSession,
|
|
977
|
+
listSessions,
|
|
978
|
+
setSessionWorkItem,
|
|
979
|
+
setSessionQaRunId,
|
|
980
|
+
// Transitions
|
|
981
|
+
transitionSession,
|
|
982
|
+
markSpawning,
|
|
983
|
+
markDrafting,
|
|
984
|
+
markAwaitingApproval,
|
|
985
|
+
markExecuting,
|
|
986
|
+
markDone,
|
|
987
|
+
markFailed,
|
|
988
|
+
markKilled,
|
|
989
|
+
// Work-item builders (pure)
|
|
990
|
+
buildSetupWorkItem,
|
|
991
|
+
buildDraftWorkItem,
|
|
992
|
+
buildExecuteWorkItem,
|
|
993
|
+
// Chain helpers (impure — call dispatch + work-items)
|
|
994
|
+
queueSetup,
|
|
995
|
+
handleSetupComplete,
|
|
996
|
+
handleDraftComplete,
|
|
997
|
+
handleExecuteComplete,
|
|
998
|
+
// User actions
|
|
999
|
+
approveDraft,
|
|
1000
|
+
editDraft,
|
|
1001
|
+
cancelSession,
|
|
1002
|
+
killSession,
|
|
1003
|
+
dismissSession,
|
|
1004
|
+
// Status
|
|
1005
|
+
summarizeSessionsForStatus,
|
|
1006
|
+
// Internals (exposed for tests)
|
|
1007
|
+
_isSafeSessionId,
|
|
1008
|
+
};
|