@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.
- package/bin/install-internal-minions.js +1209 -0
- package/bin/minions.js +75 -9
- package/dashboard/docs/typography.md +27 -12
- package/dashboard/js/command-center.js +13 -11
- package/dashboard/js/live-stream.js +1 -1
- package/dashboard/js/memory-search.js +388 -43
- package/dashboard/js/modal-qa.js +5 -5
- package/dashboard/js/qa.js +39 -26
- package/dashboard/js/refresh.js +37 -18
- package/dashboard/js/render-agents.js +14 -18
- package/dashboard/js/render-dispatch.js +14 -74
- package/dashboard/js/render-inbox.js +3 -3
- package/dashboard/js/render-meetings.js +8 -8
- package/dashboard/js/render-other.js +45 -20
- package/dashboard/js/render-pipelines.js +6 -6
- package/dashboard/js/render-plans.js +14 -14
- package/dashboard/js/render-prd.js +46 -46
- package/dashboard/js/render-prs.js +9 -65
- package/dashboard/js/render-schedules.js +5 -5
- package/dashboard/js/render-skills.js +6 -6
- package/dashboard/js/render-utils.js +4 -4
- package/dashboard/js/render-watches.js +4 -16
- package/dashboard/js/render-work-items.js +95 -107
- package/dashboard/js/settings.js +175 -74
- package/dashboard/layout.html +3 -2
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard/pages/work.html +1 -1
- package/dashboard/shared/model-display.js +9 -0
- package/dashboard/shared/watches-source.js +43 -0
- package/dashboard/shared/welcome-popup.js +259 -0
- package/dashboard/slim/body.html +6 -6
- package/dashboard/slim/js/chat.js +4 -2
- package/dashboard/slim/js/history.js +10 -3
- package/dashboard/slim/js/modals-tiles.js +85 -16
- package/dashboard/slim/js/status.js +12 -16
- package/dashboard/slim/styles.css +24 -4
- package/dashboard/styles.css +62 -27
- package/dashboard-build.js +8 -2
- package/dashboard.js +3854 -2514
- package/docs/README.md +6 -4
- package/docs/api-errors.md +144 -0
- package/docs/auto-discovery.md +83 -61
- package/docs/capture-demos.js +173 -30
- package/docs/command-center.md +5 -1
- package/docs/completion-reports.md +127 -11
- package/docs/constants.md +15 -3
- package/docs/constellation-style-telemetry.md +2 -2
- package/docs/copilot-cli-schema.md +32 -22
- package/docs/cross-repo-plans.md +30 -30
- package/docs/demo/memory-system.html +1 -1
- package/docs/deprecated.json +132 -18
- package/docs/diagnostics-memory.md +8 -4
- package/docs/human-vs-automated.md +2 -2
- package/docs/internal-install.md +190 -0
- package/docs/kb-pr3223-cascade-archiving.md +16 -0
- package/docs/kb-pr696-merge-conflict-docs.md +23 -0
- package/docs/kb-sweep.md +2 -2
- package/docs/keep-processes.md +8 -1
- package/docs/live-checkout-mode.md +1 -1
- package/docs/managed-spawn.md +21 -3
- package/docs/named-agents.md +3 -2
- package/docs/plan-lifecycle.md +32 -32
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/qa-runbook-lifecycle.md +67 -51
- package/docs/qa-runbooks.md +2 -2
- package/docs/runtime-adapters.md +101 -50
- package/docs/security.md +28 -0
- package/docs/self-improvement.md +47 -13
- package/docs/skills.md +29 -0
- package/docs/slim-ux/concepts.md +2 -2
- package/docs/specs/agent-configurability.md +43 -41
- package/docs/specs/agent-rename.md +13 -13
- package/docs/team-memory.md +283 -14
- package/docs/tutorials/03-plan-driven-feature.md +3 -3
- package/docs/tutorials/04-runtimes-and-harness.md +18 -21
- package/docs/tutorials/08-operations-and-recovery.md +3 -2
- package/docs/tutorials/README.md +4 -3
- package/docs/visual-evidence-ci.md +103 -0
- package/docs/watches.md +35 -30
- package/docs/worktree-lifecycle.md +151 -13
- package/engine/acp-transport.js +1 -0
- package/engine/ado.js +80 -33
- package/engine/agent-api-validation.js +571 -0
- package/engine/agent-worker-pool.js +7 -6
- package/engine/api-contracts/agent-content.js +761 -0
- package/engine/api-contracts/cc-ops.js +219 -0
- package/engine/api-contracts/config-runtime.js +419 -0
- package/engine/api-contracts/core.js +138 -0
- package/engine/api-contracts/index.js +506 -0
- package/engine/api-contracts/orchestration.js +602 -0
- package/engine/api-contracts/pull-requests.js +323 -0
- package/engine/api-contracts/qa-process.js +764 -0
- package/engine/api-contracts/work-plan-prd.js +494 -0
- package/engine/api-validation.js +543 -0
- package/engine/cc-api-validation.js +586 -0
- package/engine/cc-worker-pool.js +9 -1
- package/engine/cleanup.js +393 -271
- package/engine/cli.js +65 -7
- package/engine/comment-classifier.js +70 -5
- package/engine/consolidation.js +123 -5
- package/engine/content-api-validation.js +606 -0
- package/engine/create-pr-worktree.js +22 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
- package/engine/dispatch.js +33 -0
- package/engine/distribution.js +189 -0
- package/engine/execution-model.js +59 -0
- package/engine/features.js +12 -0
- package/engine/github.js +67 -25
- package/engine/harness-context.js +313 -0
- package/engine/inbox-store.js +1 -1
- package/engine/kb-sweep.js +161 -38
- package/engine/keep-process-sweep.js +43 -3
- package/engine/lifecycle.js +1066 -165
- package/engine/llm.js +4 -0
- package/engine/managed-spawn.js +463 -48
- package/engine/meeting.js +197 -34
- package/engine/memory-retrieval.js +280 -0
- package/engine/memory-store.js +821 -38
- package/engine/model-discovery.js +17 -3
- package/engine/pipeline.js +802 -14
- package/engine/plan-prd-validation.js +745 -0
- package/engine/playbook.js +206 -17
- package/engine/pooled-agent-process.js +6 -1
- package/engine/pr-action.js +1 -1
- package/engine/pr-issue-validation.js +653 -0
- package/engine/pr-resolve.js +75 -13
- package/engine/prd-store.js +39 -6
- package/engine/preflight.js +7 -2
- package/engine/process-utils.js +79 -31
- package/engine/projects.js +198 -12
- package/engine/promotion.js +371 -0
- package/engine/qa-from-prd.js +3 -0
- package/engine/qa-process-validation.js +572 -0
- package/engine/qa-runbooks.js +41 -37
- package/engine/qa-runners/maestro.js +11 -7
- package/engine/qa-runners/playwright.js +37 -14
- package/engine/qa-runners.js +9 -10
- package/engine/qa-runs.js +124 -11
- package/engine/qa-sessions.js +139 -40
- package/engine/quarantine-refs.js +81 -11
- package/engine/queries.js +163 -9
- package/engine/review-learning-backfill.js +482 -0
- package/engine/review-learning.js +1236 -0
- package/engine/runtimes/claude.js +60 -2
- package/engine/runtimes/codex.js +57 -16
- package/engine/runtimes/contract.js +21 -0
- package/engine/runtimes/copilot.js +51 -3
- package/engine/runtimes/index.js +1 -0
- package/engine/scheduler.js +201 -32
- package/engine/settings-validation.js +909 -0
- package/engine/shared.js +705 -208
- package/engine/spawn-agent.js +48 -21
- package/engine/steering-constraints.js +31 -0
- package/engine/steering-store.js +10 -2
- package/engine/steering.js +14 -2
- package/engine/timeout.js +404 -111
- package/engine/untrusted-fence.js +24 -10
- package/engine/watch-actions.js +7 -2
- package/engine/watches.js +353 -51
- package/engine/work-item-validation.js +561 -0
- package/engine/work-items-store.js +61 -1
- package/engine/worktree-gc.js +656 -37
- package/engine/worktree-preflight.js +154 -0
- package/engine.js +2068 -342
- package/package.json +1 -1
- package/playbooks/_pr-description-audit.md +88 -41
- package/playbooks/build-fix-complex.md +3 -3
- package/playbooks/docs.md +2 -2
- package/playbooks/fix.md +18 -18
- package/playbooks/implement.md +18 -18
- package/playbooks/qa-session-draft.md +10 -12
- package/playbooks/qa-session-execute.md +16 -12
- package/playbooks/qa-validate.md +8 -7
- package/playbooks/shared-rules.md +45 -0
- package/playbooks/test.md +4 -0
- package/prompts/cc-system.md +1 -1
- 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
|
+
};
|
package/engine/qa-runbooks.js
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
};
|