@yemi33/minions 0.1.2424 → 0.1.2425

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.
Files changed (182) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +6 -4
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +1 -1
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  64. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  65. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  69. package/docs/qa-runbook-lifecycle.md +67 -51
  70. package/docs/qa-runbooks.md +2 -2
  71. package/docs/runtime-adapters.md +101 -50
  72. package/docs/security.md +28 -0
  73. package/docs/self-improvement.md +47 -13
  74. package/docs/skills.md +29 -0
  75. package/docs/slim-ux/concepts.md +2 -2
  76. package/docs/specs/agent-configurability.md +43 -41
  77. package/docs/specs/agent-rename.md +13 -13
  78. package/docs/team-memory.md +283 -14
  79. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  80. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  81. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  82. package/docs/tutorials/README.md +4 -3
  83. package/docs/visual-evidence-ci.md +103 -0
  84. package/docs/watches.md +35 -30
  85. package/docs/worktree-lifecycle.md +151 -13
  86. package/engine/acp-transport.js +1 -0
  87. package/engine/ado.js +80 -33
  88. package/engine/agent-api-validation.js +571 -0
  89. package/engine/agent-worker-pool.js +7 -6
  90. package/engine/api-contracts/agent-content.js +761 -0
  91. package/engine/api-contracts/cc-ops.js +219 -0
  92. package/engine/api-contracts/config-runtime.js +419 -0
  93. package/engine/api-contracts/core.js +138 -0
  94. package/engine/api-contracts/index.js +506 -0
  95. package/engine/api-contracts/orchestration.js +602 -0
  96. package/engine/api-contracts/pull-requests.js +323 -0
  97. package/engine/api-contracts/qa-process.js +764 -0
  98. package/engine/api-contracts/work-plan-prd.js +494 -0
  99. package/engine/api-validation.js +543 -0
  100. package/engine/cc-api-validation.js +586 -0
  101. package/engine/cc-worker-pool.js +9 -1
  102. package/engine/cleanup.js +393 -271
  103. package/engine/cli.js +65 -7
  104. package/engine/comment-classifier.js +70 -5
  105. package/engine/consolidation.js +123 -5
  106. package/engine/content-api-validation.js +606 -0
  107. package/engine/create-pr-worktree.js +22 -1
  108. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  109. package/engine/dispatch.js +33 -0
  110. package/engine/distribution.js +189 -0
  111. package/engine/execution-model.js +59 -0
  112. package/engine/features.js +12 -0
  113. package/engine/github.js +67 -25
  114. package/engine/harness-context.js +313 -0
  115. package/engine/inbox-store.js +1 -1
  116. package/engine/kb-sweep.js +161 -38
  117. package/engine/keep-process-sweep.js +43 -3
  118. package/engine/lifecycle.js +1066 -165
  119. package/engine/llm.js +4 -0
  120. package/engine/managed-spawn.js +463 -48
  121. package/engine/meeting.js +197 -34
  122. package/engine/memory-retrieval.js +280 -0
  123. package/engine/memory-store.js +821 -38
  124. package/engine/model-discovery.js +17 -3
  125. package/engine/pipeline.js +802 -14
  126. package/engine/plan-prd-validation.js +745 -0
  127. package/engine/playbook.js +206 -17
  128. package/engine/pooled-agent-process.js +6 -1
  129. package/engine/pr-action.js +1 -1
  130. package/engine/pr-issue-validation.js +653 -0
  131. package/engine/pr-resolve.js +75 -13
  132. package/engine/prd-store.js +39 -6
  133. package/engine/preflight.js +7 -2
  134. package/engine/process-utils.js +79 -31
  135. package/engine/projects.js +198 -12
  136. package/engine/promotion.js +371 -0
  137. package/engine/qa-from-prd.js +3 -0
  138. package/engine/qa-process-validation.js +572 -0
  139. package/engine/qa-runbooks.js +41 -37
  140. package/engine/qa-runners/maestro.js +11 -7
  141. package/engine/qa-runners/playwright.js +37 -14
  142. package/engine/qa-runners.js +9 -10
  143. package/engine/qa-runs.js +124 -11
  144. package/engine/qa-sessions.js +139 -40
  145. package/engine/quarantine-refs.js +81 -11
  146. package/engine/queries.js +163 -9
  147. package/engine/review-learning-backfill.js +482 -0
  148. package/engine/review-learning.js +1236 -0
  149. package/engine/runtimes/claude.js +60 -2
  150. package/engine/runtimes/codex.js +57 -16
  151. package/engine/runtimes/contract.js +21 -0
  152. package/engine/runtimes/copilot.js +51 -3
  153. package/engine/runtimes/index.js +1 -0
  154. package/engine/scheduler.js +201 -32
  155. package/engine/settings-validation.js +909 -0
  156. package/engine/shared.js +705 -208
  157. package/engine/spawn-agent.js +48 -21
  158. package/engine/steering-constraints.js +31 -0
  159. package/engine/steering-store.js +10 -2
  160. package/engine/steering.js +14 -2
  161. package/engine/timeout.js +404 -111
  162. package/engine/untrusted-fence.js +24 -10
  163. package/engine/watch-actions.js +7 -2
  164. package/engine/watches.js +353 -51
  165. package/engine/work-item-validation.js +561 -0
  166. package/engine/work-items-store.js +61 -1
  167. package/engine/worktree-gc.js +656 -37
  168. package/engine/worktree-preflight.js +154 -0
  169. package/engine.js +2068 -342
  170. package/package.json +1 -1
  171. package/playbooks/_pr-description-audit.md +88 -41
  172. package/playbooks/build-fix-complex.md +3 -3
  173. package/playbooks/docs.md +2 -2
  174. package/playbooks/fix.md +18 -18
  175. package/playbooks/implement.md +18 -18
  176. package/playbooks/qa-session-draft.md +10 -12
  177. package/playbooks/qa-session-execute.md +16 -12
  178. package/playbooks/qa-validate.md +8 -7
  179. package/playbooks/shared-rules.md +45 -0
  180. package/playbooks/test.md +4 -0
  181. package/prompts/cc-system.md +1 -1
  182. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -0,0 +1,572 @@
1
+ 'use strict';
2
+
3
+ const apiValidation = require('./api-validation');
4
+ const keepProcesses = require('./keep-process-sweep');
5
+ const qaFromPrd = require('./qa-from-prd');
6
+ const qaRunbooks = require('./qa-runbooks');
7
+ const qaRuns = require('./qa-runs');
8
+ const qaSessions = require('./qa-sessions');
9
+ const shared = require('./shared');
10
+
11
+ const {
12
+ ApiInputError,
13
+ validateEnum,
14
+ validateInteger,
15
+ validateIdentifier,
16
+ validatePlainObject,
17
+ validateRange,
18
+ validateString,
19
+ } = apiValidation;
20
+
21
+ function hasOwn(value, key) {
22
+ return Object.prototype.hasOwnProperty.call(value, key);
23
+ }
24
+
25
+ function detailPathForQaError(message, basePath = 'body') {
26
+ const match = String(message || '').match(
27
+ /^([A-Za-z_][A-Za-z0-9_]*(?:\[\d+\])?(?:\.[A-Za-z_][A-Za-z0-9_]*)?)/,
28
+ );
29
+ const fieldPath = match && match[1] !== 'spec' ? match[1] : '';
30
+ return fieldPath ? `${basePath}.${fieldPath}` : basePath;
31
+ }
32
+
33
+ function detailMetadata(pathName) {
34
+ const pathKey = String(pathName || '')
35
+ .replace(/^(?:body|path|query)\./, '')
36
+ .replace(/\[\d+\]/g, '[]');
37
+ const sessionLimits = qaSessions.LIMITS;
38
+ const runbookLimits = qaRunbooks.LIMITS;
39
+ const metadata = {
40
+ 'target.kind': { allowedValues: [...qaSessions.VALID_TARGET_KINDS] },
41
+ 'target.prId': { maxLength: sessionLimits.targetFieldMax },
42
+ 'target.branch': { maxLength: sessionLimits.targetFieldMax },
43
+ 'target.sha': { maxLength: sessionLimits.targetFieldMax },
44
+ 'target.worktree': { maxLength: sessionLimits.targetFieldMax },
45
+ flowsRaw: { maxLength: sessionLimits.flowsMax },
46
+ mode: { allowedValues: [...qaSessions.VALID_MODES] },
47
+ runner: { maxLength: sessionLimits.runnerNameMax },
48
+ project: { maxLength: sessionLimits.projectMax },
49
+ projects: { maxLength: sessionLimits.projectsMax },
50
+ 'projects[]': { maxLength: sessionLimits.projectMax },
51
+ 'capture.video': { expected: 'boolean' },
52
+ 'capture.screenshots': { expected: 'boolean' },
53
+ 'capture.logs': { expected: 'boolean' },
54
+ id: { maxLength: runbookLimits.idMax },
55
+ name: { maxLength: runbookLimits.nameMax },
56
+ targetName: { maxLength: runbookLimits.targetNameMax },
57
+ steps: { maxLength: runbookLimits.stepsMax },
58
+ 'steps[].description': { maxLength: runbookLimits.stepDescriptionMax },
59
+ 'steps[].command': { maxLength: runbookLimits.stepCommandMax },
60
+ expectedArtifacts: { maxLength: runbookLimits.artifactsMax },
61
+ 'expectedArtifacts[].type': { allowedValues: qaRunbooks.ARTIFACT_TYPES },
62
+ 'expectedArtifacts[].label': { maxLength: runbookLimits.artifactLabelMax },
63
+ 'expectedArtifacts[].path': { maxLength: runbookLimits.artifactPathMax },
64
+ };
65
+ return metadata[pathKey] || {};
66
+ }
67
+
68
+ function projectQaValidationErrors(errors, options = {}) {
69
+ const messages = Array.isArray(errors) && errors.length
70
+ ? errors
71
+ : ['Invalid QA request'];
72
+ const details = messages
73
+ .slice(0, apiValidation.API_INPUT_ERROR_DETAILS_MAX)
74
+ .map((message) => {
75
+ const detailPath = detailPathForQaError(message, options.basePath || 'body');
76
+ return {
77
+ code: options.detailCode || 'invalid-qa-field',
78
+ message,
79
+ path: detailPath,
80
+ ...detailMetadata(detailPath),
81
+ };
82
+ });
83
+ return new ApiInputError(options.message || 'Invalid QA request', {
84
+ code: options.code || 'invalid-qa-input',
85
+ statusCode: options.statusCode || 400,
86
+ details,
87
+ });
88
+ }
89
+
90
+ function unexpectedFields(value, allowedFields, pathName) {
91
+ const allowed = new Set(allowedFields);
92
+ return Object.keys(value)
93
+ .filter(field => !allowed.has(field))
94
+ .map(field => ({
95
+ code: 'unexpected-field',
96
+ message: `Unexpected field: ${field}`,
97
+ path: `${pathName}.${field}`,
98
+ }));
99
+ }
100
+
101
+ function validateObjectFields(value, allowedFields, pathName = 'body') {
102
+ validatePlainObject(value, { path: pathName });
103
+ const details = unexpectedFields(value, allowedFields, pathName);
104
+ if (details.length) {
105
+ throw new ApiInputError('Request contains unexpected fields', {
106
+ code: 'unexpected-field',
107
+ details,
108
+ });
109
+ }
110
+ return value;
111
+ }
112
+
113
+ function validateKnownRunner(runner, pathName) {
114
+ if (runner === undefined || runner === null || runner === '') return runner;
115
+ const qaRunners = require('./qa-runners');
116
+ if (!qaRunners.getRunner(runner)) {
117
+ throw new ApiInputError(`Unknown QA runner: ${runner}`, {
118
+ code: 'unknown-qa-runner',
119
+ path: pathName,
120
+ rejectedValue: runner,
121
+ allowedValues: qaRunners.listRunners().map(item => item.name),
122
+ });
123
+ }
124
+ return runner;
125
+ }
126
+
127
+ function validateQaIdentityHeaders(headers, options = {}) {
128
+ const source = headers || {};
129
+ let originAgent;
130
+ let ccTurnId;
131
+ if (hasOwn(source, 'x-minions-agent')) {
132
+ originAgent = validateString(source['x-minions-agent'], {
133
+ path: 'headers.X-Minions-Agent',
134
+ trim: true,
135
+ allowEmpty: false,
136
+ maxLength: keepProcesses.KEEP_PROCESS_AGENT_ID_MAX,
137
+ });
138
+ if (!keepProcesses.isSafeKeepProcessAgentId(originAgent)) {
139
+ throw new ApiInputError('Invalid X-Minions-Agent header', {
140
+ code: 'invalid-agent-id',
141
+ path: 'headers.X-Minions-Agent',
142
+ rejectedValue: originAgent,
143
+ maxLength: keepProcesses.KEEP_PROCESS_AGENT_ID_MAX,
144
+ });
145
+ }
146
+ }
147
+ if (options.allowCc === true && hasOwn(source, 'x-cc-turn-id')) {
148
+ ccTurnId = validateIdentifier(source['x-cc-turn-id'], {
149
+ path: 'headers.X-CC-Turn-Id',
150
+ trim: true,
151
+ maxLength: shared.CC_TURN_ID_MAX,
152
+ });
153
+ }
154
+ return { originAgent, ccTurnId };
155
+ }
156
+
157
+ function validateQaSessionRequest(body, options = {}) {
158
+ validateObjectFields(body, [
159
+ 'target',
160
+ 'flowsRaw',
161
+ 'mode',
162
+ 'capture',
163
+ 'runner',
164
+ 'project',
165
+ 'projects',
166
+ 'createdBy',
167
+ ]);
168
+ const result = qaSessions.validateSpec(body);
169
+ if (!result.ok) {
170
+ throw projectQaValidationErrors(result.errors, {
171
+ message: 'Invalid QA session specification',
172
+ code: 'invalid-qa-session',
173
+ });
174
+ }
175
+
176
+ const targetFields = {
177
+ pr: ['kind', 'prId'],
178
+ branch: ['kind', 'branch'],
179
+ current: ['kind', 'worktree'],
180
+ commit: ['kind', 'sha'],
181
+ };
182
+ validateObjectFields(body.target, targetFields[body.target.kind], 'body.target');
183
+ if (body.capture !== undefined && body.capture !== null) {
184
+ validateObjectFields(body.capture, ['video', 'screenshots', 'logs'], 'body.capture');
185
+ }
186
+ if (hasOwn(body, 'project') && body.project
187
+ && Array.isArray(body.projects) && body.projects.length) {
188
+ throw new ApiInputError('Use either project or projects, not both', {
189
+ code: 'conflicting-fields',
190
+ path: 'body.projects',
191
+ field: 'projects',
192
+ });
193
+ }
194
+ if (hasOwn(body, 'createdBy')) {
195
+ validateString(body.createdBy, {
196
+ path: 'body.createdBy',
197
+ maxLength: keepProcesses.KEEP_PROCESS_AGENT_ID_MAX,
198
+ });
199
+ }
200
+ if (options.validateRunner !== false) validateKnownRunner(body.runner, 'body.runner');
201
+ return body;
202
+ }
203
+
204
+ function validateQaRunbookRequest(body) {
205
+ validateObjectFields(body, [
206
+ 'id',
207
+ 'name',
208
+ 'project',
209
+ 'targetName',
210
+ 'steps',
211
+ 'expectedArtifacts',
212
+ ]);
213
+ const result = qaRunbooks.validateRunbook(body);
214
+ if (!result.ok) {
215
+ throw projectQaValidationErrors(result.errors, {
216
+ message: 'Invalid QA runbook',
217
+ code: 'invalid-qa-runbook',
218
+ });
219
+ }
220
+ body.steps.forEach((step, index) => {
221
+ validateObjectFields(step, ['description', 'command'], `body.steps[${index}]`);
222
+ });
223
+ body.expectedArtifacts.forEach((artifact, index) => {
224
+ validateObjectFields(artifact, ['type', 'label', 'path'], `body.expectedArtifacts[${index}]`);
225
+ });
226
+ return body;
227
+ }
228
+
229
+ function validateQaFromPrdRequest(body) {
230
+ validateObjectFields(body, ['prd', 'mode', 'runner']);
231
+ const prd = validateString(body.prd, {
232
+ path: 'body.prd',
233
+ trim: true,
234
+ allowEmpty: false,
235
+ maxLength: qaFromPrd.QA_PRD_FILENAME_MAX,
236
+ });
237
+ if (!qaFromPrd._isSafePrdFileName(prd)) {
238
+ throw new ApiInputError('prd must be a safe PRD filename', {
239
+ code: 'invalid-prd-filename',
240
+ path: 'body.prd',
241
+ rejectedValue: prd,
242
+ maxLength: qaFromPrd.QA_PRD_FILENAME_MAX,
243
+ });
244
+ }
245
+ if (hasOwn(body, 'mode')) {
246
+ validateEnum(body.mode, {
247
+ path: 'body.mode',
248
+ values: [...qaSessions.VALID_MODES],
249
+ });
250
+ }
251
+ if (hasOwn(body, 'runner') && body.runner !== null && body.runner !== '') {
252
+ validateString(body.runner, {
253
+ path: 'body.runner',
254
+ allowEmpty: false,
255
+ maxLength: qaSessions.LIMITS.runnerNameMax,
256
+ });
257
+ validateKnownRunner(body.runner, 'body.runner');
258
+ }
259
+ return { ...body, prd };
260
+ }
261
+
262
+ function validateQaRunbookRunRequest(body, options = {}) {
263
+ validateObjectFields(body, ['runbookId', 'targetName', 'agent']);
264
+ const runbookId = validateString(body.runbookId, {
265
+ path: 'body.runbookId',
266
+ trim: true,
267
+ allowEmpty: false,
268
+ maxLength: qaRunbooks.LIMITS.idMax,
269
+ });
270
+ if (!qaRunbooks._isSafeId(runbookId)) {
271
+ throw new ApiInputError('runbookId must be a safe kebab-case identifier', {
272
+ code: 'invalid-runbook-id',
273
+ path: 'body.runbookId',
274
+ rejectedValue: runbookId,
275
+ maxLength: qaRunbooks.LIMITS.idMax,
276
+ });
277
+ }
278
+ const targetName = validateString(body.targetName, {
279
+ path: 'body.targetName',
280
+ trim: true,
281
+ allowEmpty: false,
282
+ maxLength: qaRunbooks.LIMITS.targetNameMax,
283
+ });
284
+ let agent;
285
+ if (hasOwn(body, 'agent')) {
286
+ agent = validateString(body.agent, {
287
+ path: 'body.agent',
288
+ trim: true,
289
+ allowEmpty: false,
290
+ maxLength: keepProcesses.KEEP_PROCESS_AGENT_ID_MAX,
291
+ });
292
+ if (options.agents && !hasOwn(options.agents, agent)) {
293
+ throw new ApiInputError(`Unknown agent: ${agent}`, {
294
+ code: 'unknown-agent',
295
+ path: 'body.agent',
296
+ rejectedValue: agent,
297
+ allowedValues: Object.keys(options.agents),
298
+ });
299
+ }
300
+ }
301
+ return { runbookId, targetName, ...(agent ? { agent } : {}) };
302
+ }
303
+
304
+ function validateQaSessionActionRequest(body, action) {
305
+ const fieldsByAction = {
306
+ approve: [],
307
+ edit: ['feedback'],
308
+ cancel: ['reason'],
309
+ kill: ['reason'],
310
+ dismiss: ['summary'],
311
+ };
312
+ const allowedFields = fieldsByAction[action];
313
+ if (!allowedFields) throw new TypeError(`Unknown QA session action: ${action}`);
314
+ validateObjectFields(body, allowedFields);
315
+ if (action === 'edit') {
316
+ validateString(body.feedback, {
317
+ path: 'body.feedback',
318
+ trim: true,
319
+ allowEmpty: false,
320
+ maxLength: qaSessions.LIMITS.feedbackMax,
321
+ });
322
+ }
323
+ const optionalField = action === 'dismiss' ? 'summary' : 'reason';
324
+ if (allowedFields.includes(optionalField) && hasOwn(body, optionalField)) {
325
+ validateString(body[optionalField], {
326
+ path: `body.${optionalField}`,
327
+ maxLength: qaSessions.LIMITS.summaryMax,
328
+ });
329
+ }
330
+ return body;
331
+ }
332
+
333
+ function validateQaSessionActionState(session, action) {
334
+ const state = session && session.state;
335
+ if (action === 'approve' || action === 'edit') {
336
+ if (state !== qaSessions.QA_SESSION_STATE.AWAITING_APPROVAL) {
337
+ throw new ApiInputError(
338
+ `${action} requires state ${qaSessions.QA_SESSION_STATE.AWAITING_APPROVAL}, got ${state}`,
339
+ {
340
+ code: 'qa-session-state-conflict',
341
+ statusCode: 409,
342
+ path: 'state',
343
+ rejectedValue: state,
344
+ allowedValues: [qaSessions.QA_SESSION_STATE.AWAITING_APPROVAL],
345
+ },
346
+ );
347
+ }
348
+ return session;
349
+ }
350
+ const targetState = action === 'dismiss'
351
+ ? qaSessions.QA_SESSION_STATE.DONE
352
+ : qaSessions.QA_SESSION_STATE.KILLED;
353
+ try {
354
+ qaSessions.validateTransition(state, targetState);
355
+ } catch (cause) {
356
+ throw new ApiInputError(cause.message, {
357
+ code: 'qa-session-state-conflict',
358
+ statusCode: 409,
359
+ path: 'state',
360
+ rejectedValue: state,
361
+ cause,
362
+ });
363
+ }
364
+ return session;
365
+ }
366
+
367
+ function decodePathComponent(raw, options = {}) {
368
+ try {
369
+ return decodeURIComponent(String(raw || ''));
370
+ } catch (cause) {
371
+ throw new ApiInputError(`Invalid encoded ${options.label || 'path component'}`, {
372
+ code: options.code || 'invalid-path-encoding',
373
+ statusCode: options.statusCode || 400,
374
+ path: options.path || 'path',
375
+ cause,
376
+ });
377
+ }
378
+ }
379
+
380
+ function validateRunId(value, pathName = 'path.id') {
381
+ if (!qaRuns._isSafeRunId(value)) {
382
+ throw new ApiInputError('Invalid QA run id', {
383
+ code: 'invalid-qa-run-id',
384
+ path: pathName,
385
+ rejectedValue: value,
386
+ maxLength: qaRuns.QA_RUN_ID_MAX,
387
+ });
388
+ }
389
+ return value;
390
+ }
391
+
392
+ function validateSessionId(value, pathName = 'path.id') {
393
+ if (!qaSessions._isSafeSessionId(value)) {
394
+ throw new ApiInputError('Invalid QA session id', {
395
+ code: 'invalid-qa-session-id',
396
+ path: pathName,
397
+ rejectedValue: value,
398
+ maxLength: qaSessions.LIMITS.idMax,
399
+ });
400
+ }
401
+ return value;
402
+ }
403
+
404
+ function validateRunbookId(value, pathName = 'path.id') {
405
+ if (!qaRunbooks._isSafeId(value)) {
406
+ throw new ApiInputError('Invalid QA runbook id', {
407
+ code: 'invalid-qa-runbook-id',
408
+ path: pathName,
409
+ rejectedValue: value,
410
+ maxLength: qaRunbooks.LIMITS.idMax,
411
+ });
412
+ }
413
+ return value;
414
+ }
415
+
416
+ function validateManagedSpecName(value, pathName) {
417
+ const result = require('./managed-spawn').validateManagedSpecName(value);
418
+ if (!result.ok) {
419
+ throw new ApiInputError('Invalid managed process name', {
420
+ code: 'invalid-managed-process-name',
421
+ path: pathName,
422
+ rejectedValue: value,
423
+ maxLength: result.maxLength,
424
+ });
425
+ }
426
+ return result.value;
427
+ }
428
+
429
+ function validateManagedProcessActionRequest(body) {
430
+ validateObjectFields(body, ['name']);
431
+ return {
432
+ name: validateManagedSpecName(body.name, 'body.name'),
433
+ };
434
+ }
435
+
436
+ function validateKeepProcessKillRequest(body) {
437
+ validateObjectFields(body, ['agentId', 'pid']);
438
+ if (!keepProcesses.isSafeKeepProcessAgentId(body.agentId)) {
439
+ throw new ApiInputError('Invalid keep-process agent id', {
440
+ code: 'invalid-agent-id',
441
+ path: 'body.agentId',
442
+ rejectedValue: body.agentId,
443
+ maxLength: keepProcesses.KEEP_PROCESS_AGENT_ID_MAX,
444
+ });
445
+ }
446
+ const pidResult = keepProcesses.validateKeepProcessPid(body.pid);
447
+ if (!pidResult.ok) {
448
+ throw new ApiInputError('Invalid kept process PID', {
449
+ code: 'invalid-pid',
450
+ path: 'body.pid',
451
+ rejectedValue: body.pid,
452
+ min: 1,
453
+ max: keepProcesses.KEEP_PROCESS_PID_MAX,
454
+ });
455
+ }
456
+ return { agentId: body.agentId, pid: pidResult.value };
457
+ }
458
+
459
+ function validateQuery(urlValue, schemas = {}) {
460
+ const params = new URL(urlValue, 'http://localhost').searchParams;
461
+ for (const key of new Set(params.keys())) {
462
+ if (!hasOwn(schemas, key)) {
463
+ throw new ApiInputError(`Unexpected query parameter: ${key}`, {
464
+ code: 'unexpected-query-parameter',
465
+ path: `query.${key}`,
466
+ });
467
+ }
468
+ if (params.getAll(key).length !== 1) {
469
+ throw new ApiInputError(`Query parameter ${key} must be supplied once`, {
470
+ code: 'duplicate-query-parameter',
471
+ path: `query.${key}`,
472
+ });
473
+ }
474
+ }
475
+
476
+ const result = {};
477
+ for (const [key, schema] of Object.entries(schemas)) {
478
+ if (!params.has(key)) continue;
479
+ const raw = params.get(key);
480
+ if (schema.type === 'positive-integer') {
481
+ if (!/^[1-9]\d*$/.test(raw)) {
482
+ throw new ApiInputError(`${key} must be a positive integer`, {
483
+ code: 'invalid-integer',
484
+ path: `query.${key}`,
485
+ rejectedValue: raw,
486
+ min: 1,
487
+ ...(Number.isFinite(schema.max) ? { max: schema.max } : {}),
488
+ });
489
+ }
490
+ const value = Number(raw);
491
+ validateInteger(value, { path: `query.${key}` });
492
+ validateRange(value, { path: `query.${key}`, min: 1, max: schema.max });
493
+ result[key] = value;
494
+ } else if (schema.type === 'enum') {
495
+ result[key] = validateEnum(raw, {
496
+ path: `query.${key}`,
497
+ values: schema.values,
498
+ });
499
+ } else if (schema.type === 'string') {
500
+ result[key] = validateString(raw, {
501
+ path: `query.${key}`,
502
+ trim: schema.trim !== false,
503
+ allowEmpty: false,
504
+ maxLength: schema.maxLength,
505
+ });
506
+ } else {
507
+ throw new TypeError(`Unsupported query schema for ${key}`);
508
+ }
509
+ }
510
+ return result;
511
+ }
512
+
513
+ function validateConfiguredProject(value, projects, pathName = 'query.project') {
514
+ try {
515
+ shared.validateProjectName(value);
516
+ } catch (cause) {
517
+ throw new ApiInputError(cause.message, {
518
+ code: 'invalid-project',
519
+ path: pathName,
520
+ rejectedValue: value,
521
+ cause,
522
+ });
523
+ }
524
+ const project = shared.findProjectByName(projects, value);
525
+ if (!project) {
526
+ throw new ApiInputError(`Project not found: ${value}`, {
527
+ code: 'project-not-found',
528
+ statusCode: 404,
529
+ path: pathName,
530
+ rejectedValue: value,
531
+ allowedValues: (projects || []).map(item => item && item.name).filter(Boolean),
532
+ });
533
+ }
534
+ return project.name;
535
+ }
536
+
537
+ function requestHasBody(req) {
538
+ const contentLength = Number.parseInt(req && req.headers && req.headers['content-length'] || '0', 10);
539
+ return contentLength > 0 || !!(req && req.headers && req.headers['transfer-encoding']);
540
+ }
541
+
542
+ function rejectRequestBody(req) {
543
+ if (requestHasBody(req)) {
544
+ throw new ApiInputError('This route does not accept a request body', {
545
+ code: 'request-body-not-allowed',
546
+ path: 'body',
547
+ });
548
+ }
549
+ }
550
+
551
+ module.exports = {
552
+ projectQaValidationErrors,
553
+ validateObjectFields,
554
+ validateQaSessionRequest,
555
+ validateQaIdentityHeaders,
556
+ validateQaRunbookRequest,
557
+ validateQaFromPrdRequest,
558
+ validateQaRunbookRunRequest,
559
+ validateQaSessionActionRequest,
560
+ validateQaSessionActionState,
561
+ decodePathComponent,
562
+ validateRunId,
563
+ validateSessionId,
564
+ validateRunbookId,
565
+ validateManagedSpecName,
566
+ validateManagedProcessActionRequest,
567
+ validateKeepProcessKillRequest,
568
+ validateQuery,
569
+ validateConfiguredProject,
570
+ requestHasBody,
571
+ rejectRequestBody,
572
+ };
@@ -118,23 +118,24 @@ function validateRunbook(spec) {
118
118
  } else {
119
119
  if (spec.steps.length > LIMITS.stepsMax) {
120
120
  errors.push('steps exceeds max of ' + LIMITS.stepsMax);
121
- }
122
- for (let i = 0; i < spec.steps.length; i++) {
123
- const s = spec.steps[i];
124
- if (!s || typeof s !== 'object' || Array.isArray(s)) {
125
- errors.push('steps[' + i + '] must be an object');
126
- continue;
127
- }
128
- if (!_isNonEmptyString(s.description)) {
129
- errors.push('steps[' + i + '].description is required (non-empty string)');
130
- } else if (s.description.length > LIMITS.stepDescriptionMax) {
131
- errors.push('steps[' + i + '].description exceeds ' + LIMITS.stepDescriptionMax + ' chars');
132
- }
133
- if (s.command !== undefined && s.command !== null) {
134
- if (typeof s.command !== 'string') {
135
- errors.push('steps[' + i + '].command must be a string when present');
136
- } else if (s.command.length > LIMITS.stepCommandMax) {
137
- errors.push('steps[' + i + '].command exceeds ' + LIMITS.stepCommandMax + ' chars');
121
+ } else {
122
+ for (let i = 0; i < spec.steps.length; i++) {
123
+ const s = spec.steps[i];
124
+ if (!s || typeof s !== 'object' || Array.isArray(s)) {
125
+ errors.push('steps[' + i + '] must be an object');
126
+ continue;
127
+ }
128
+ if (!_isNonEmptyString(s.description)) {
129
+ errors.push('steps[' + i + '].description is required (non-empty string)');
130
+ } else if (s.description.length > LIMITS.stepDescriptionMax) {
131
+ errors.push('steps[' + i + '].description exceeds ' + LIMITS.stepDescriptionMax + ' chars');
132
+ }
133
+ if (s.command !== undefined && s.command !== null) {
134
+ if (typeof s.command !== 'string') {
135
+ errors.push('steps[' + i + '].command must be a string when present');
136
+ } else if (s.command.length > LIMITS.stepCommandMax) {
137
+ errors.push('steps[' + i + '].command exceeds ' + LIMITS.stepCommandMax + ' chars');
138
+ }
138
139
  }
139
140
  }
140
141
  }
@@ -145,26 +146,27 @@ function validateRunbook(spec) {
145
146
  } else {
146
147
  if (spec.expectedArtifacts.length > LIMITS.artifactsMax) {
147
148
  errors.push('expectedArtifacts exceeds max of ' + LIMITS.artifactsMax);
148
- }
149
- for (let i = 0; i < spec.expectedArtifacts.length; i++) {
150
- const a = spec.expectedArtifacts[i];
151
- if (!a || typeof a !== 'object' || Array.isArray(a)) {
152
- errors.push('expectedArtifacts[' + i + '] must be an object');
153
- continue;
154
- }
155
- if (!_isNonEmptyString(a.type) || !ARTIFACT_TYPES.includes(a.type)) {
156
- errors.push('expectedArtifacts[' + i + '].type must be one of: ' + ARTIFACT_TYPES.join(', '));
157
- }
158
- if (!_isNonEmptyString(a.label)) {
159
- errors.push('expectedArtifacts[' + i + '].label is required (non-empty string)');
160
- } else if (a.label.length > LIMITS.artifactLabelMax) {
161
- errors.push('expectedArtifacts[' + i + '].label exceeds ' + LIMITS.artifactLabelMax + ' chars');
162
- }
163
- if (a.path !== undefined && a.path !== null) {
164
- if (typeof a.path !== 'string') {
165
- errors.push('expectedArtifacts[' + i + '].path must be a string when present');
166
- } else if (a.path.length > LIMITS.artifactPathMax) {
167
- errors.push('expectedArtifacts[' + i + '].path exceeds ' + LIMITS.artifactPathMax + ' chars');
149
+ } else {
150
+ for (let i = 0; i < spec.expectedArtifacts.length; i++) {
151
+ const a = spec.expectedArtifacts[i];
152
+ if (!a || typeof a !== 'object' || Array.isArray(a)) {
153
+ errors.push('expectedArtifacts[' + i + '] must be an object');
154
+ continue;
155
+ }
156
+ if (!_isNonEmptyString(a.type) || !ARTIFACT_TYPES.includes(a.type)) {
157
+ errors.push('expectedArtifacts[' + i + '].type must be one of: ' + ARTIFACT_TYPES.join(', '));
158
+ }
159
+ if (!_isNonEmptyString(a.label)) {
160
+ errors.push('expectedArtifacts[' + i + '].label is required (non-empty string)');
161
+ } else if (a.label.length > LIMITS.artifactLabelMax) {
162
+ errors.push('expectedArtifacts[' + i + '].label exceeds ' + LIMITS.artifactLabelMax + ' chars');
163
+ }
164
+ if (a.path !== undefined && a.path !== null) {
165
+ if (typeof a.path !== 'string') {
166
+ errors.push('expectedArtifacts[' + i + '].path must be a string when present');
167
+ } else if (a.path.length > LIMITS.artifactPathMax) {
168
+ errors.push('expectedArtifacts[' + i + '].path exceeds ' + LIMITS.artifactPathMax + ' chars');
169
+ }
168
170
  }
169
171
  }
170
172
  }
@@ -323,6 +325,8 @@ module.exports = {
323
325
  saveRunbook,
324
326
  deleteRunbook,
325
327
  // internals exposed for testing
328
+ _isSafeId,
329
+ _isSafeProjectName,
326
330
  _runbookPath,
327
331
  _runbooksDir,
328
332
  };