@yemi33/minions 0.1.2424 → 0.1.2426
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 +7 -5
- 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/index.html +9 -2
- package/docs/internal-install.md +212 -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/onboarding.md +26 -0
- 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/01-install-and-connect.md +23 -0
- 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
package/engine/pipeline.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const crypto = require('crypto');
|
|
9
10
|
const shared = require('./shared');
|
|
10
11
|
const queries = require('./queries');
|
|
11
|
-
const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
|
|
12
|
+
const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutateScheduleRuns, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, WORK_ITEM_PRIORITY, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
|
|
12
13
|
const routing = require('./routing');
|
|
13
14
|
const http = require('http');
|
|
14
15
|
const { shouldRunNow } = require('./scheduler');
|
|
@@ -19,6 +20,8 @@ const {
|
|
|
19
20
|
FENCE_CLOSE,
|
|
20
21
|
} = require('./untrusted-fence');
|
|
21
22
|
const smallStateStore = require('./small-state-store');
|
|
23
|
+
const apiValidation = require('./api-validation');
|
|
24
|
+
const scheduler = require('./scheduler');
|
|
22
25
|
|
|
23
26
|
// All module-relative paths flow through MINIONS_DIR so MINIONS_TEST_DIR
|
|
24
27
|
// (set by test/unit.test.js createTestMinionsDir) consistently redirects
|
|
@@ -30,6 +33,668 @@ const NOTES_INBOX_DIR = path.join(MINIONS_DIR, 'notes', 'inbox');
|
|
|
30
33
|
const NOTES_ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
|
|
31
34
|
const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
|
|
32
35
|
|
|
36
|
+
const PIPELINE_LIMITS = Object.freeze({
|
|
37
|
+
id: 64,
|
|
38
|
+
title: 300,
|
|
39
|
+
description: 20000,
|
|
40
|
+
stages: 100,
|
|
41
|
+
depth: 12,
|
|
42
|
+
nodes: 2000,
|
|
43
|
+
collection: 200,
|
|
44
|
+
resources: 100,
|
|
45
|
+
endpoint: 2048,
|
|
46
|
+
});
|
|
47
|
+
const PIPELINE_ID_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
48
|
+
const PIPELINE_STAGE_TYPES = Object.freeze(Object.values(STAGE_TYPE));
|
|
49
|
+
const PIPELINE_CONDITION_CHECKS = Object.freeze([
|
|
50
|
+
'runSucceeded',
|
|
51
|
+
'noFailedItems',
|
|
52
|
+
'maxRuns',
|
|
53
|
+
'allBuildsGreen',
|
|
54
|
+
]);
|
|
55
|
+
const PIPELINE_CONDITION_ACTIONS = Object.freeze([
|
|
56
|
+
'complete',
|
|
57
|
+
'continue',
|
|
58
|
+
'fail',
|
|
59
|
+
'stop-pipeline',
|
|
60
|
+
]);
|
|
61
|
+
const PIPELINE_API_METHODS = Object.freeze(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
62
|
+
const PIPELINE_WORK_TYPES = Object.freeze(Object.values(WORK_TYPE));
|
|
63
|
+
|
|
64
|
+
function _pipelineInputError(message, options = {}) {
|
|
65
|
+
throw new apiValidation.ApiInputError(message, {
|
|
66
|
+
code: options.code || 'invalid-pipeline',
|
|
67
|
+
statusCode: options.statusCode,
|
|
68
|
+
path: options.path,
|
|
69
|
+
rejectedValue: options.rejectedValue,
|
|
70
|
+
allowedValues: options.allowedValues,
|
|
71
|
+
expected: options.expected,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _childPath(pathValue, child) {
|
|
76
|
+
const base = Array.isArray(pathValue) ? pathValue : [pathValue];
|
|
77
|
+
return [...base.filter(Boolean), child];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _validatePlainTree(value, pathValue = 'pipeline', state = null, depth = 0) {
|
|
81
|
+
const context = state || { nodes: 0, ancestors: new WeakSet() };
|
|
82
|
+
context.nodes += 1;
|
|
83
|
+
if (context.nodes > PIPELINE_LIMITS.nodes || depth > PIPELINE_LIMITS.depth) {
|
|
84
|
+
_pipelineInputError('Pipeline definition is too deeply nested or complex', {
|
|
85
|
+
code: 'pipeline-too-complex',
|
|
86
|
+
path: pathValue,
|
|
87
|
+
rejectedValue: value,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (value === undefined || value === null
|
|
91
|
+
|| typeof value === 'string' || typeof value === 'boolean') return;
|
|
92
|
+
if (typeof value === 'number' && Number.isFinite(value)) return;
|
|
93
|
+
if (typeof value !== 'object') {
|
|
94
|
+
_pipelineInputError('Pipeline values must be JSON-compatible', {
|
|
95
|
+
code: 'invalid-pipeline-value',
|
|
96
|
+
path: pathValue,
|
|
97
|
+
rejectedValue: value,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (context.ancestors.has(value)) {
|
|
101
|
+
_pipelineInputError('Pipeline definition must not contain object cycles', {
|
|
102
|
+
code: 'pipeline-object-cycle',
|
|
103
|
+
path: pathValue,
|
|
104
|
+
rejectedValue: value,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
context.ancestors.add(value);
|
|
108
|
+
try {
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
if (value.length > PIPELINE_LIMITS.collection) {
|
|
111
|
+
_pipelineInputError('Pipeline collection is too large', {
|
|
112
|
+
code: 'pipeline-too-complex',
|
|
113
|
+
path: pathValue,
|
|
114
|
+
rejectedValue: value,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
value.forEach((entry, index) => {
|
|
118
|
+
_validatePlainTree(entry, _childPath(pathValue, index), context, depth + 1);
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
apiValidation.validatePlainObject(value, { path: pathValue });
|
|
123
|
+
const keys = Object.keys(value);
|
|
124
|
+
if (keys.length > PIPELINE_LIMITS.collection) {
|
|
125
|
+
_pipelineInputError('Pipeline object has too many fields', {
|
|
126
|
+
code: 'pipeline-too-complex',
|
|
127
|
+
path: pathValue,
|
|
128
|
+
rejectedValue: value,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
for (const key of keys) {
|
|
132
|
+
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
|
133
|
+
_pipelineInputError('Pipeline definition contains a forbidden object key', {
|
|
134
|
+
code: 'dangerous-key',
|
|
135
|
+
path: _childPath(pathValue, key),
|
|
136
|
+
rejectedValue: key,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
_validatePlainTree(value[key], _childPath(pathValue, key), context, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
context.ancestors.delete(value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validatePipelineIdentifier(id, pathValue = 'id') {
|
|
147
|
+
const validated = apiValidation.validateIdentifier(id, {
|
|
148
|
+
path: pathValue,
|
|
149
|
+
maxLength: PIPELINE_LIMITS.id,
|
|
150
|
+
pattern: PIPELINE_ID_PATTERN,
|
|
151
|
+
});
|
|
152
|
+
if (validated === 'prototype'
|
|
153
|
+
|| Object.prototype.hasOwnProperty.call(Object.prototype, validated)) {
|
|
154
|
+
_pipelineInputError(`${pathValue} must not use a prototype-sensitive name`, {
|
|
155
|
+
code: 'invalid-identifier',
|
|
156
|
+
path: pathValue,
|
|
157
|
+
rejectedValue: id,
|
|
158
|
+
expected: 'safe identifier',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return validated;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _validatePipelineText(value, pathValue, options = {}) {
|
|
165
|
+
return apiValidation.validateString(value, {
|
|
166
|
+
path: pathValue,
|
|
167
|
+
optional: options.optional === true,
|
|
168
|
+
allowEmpty: options.allowEmpty !== false,
|
|
169
|
+
maxLength: options.maxLength || PIPELINE_LIMITS.description,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _validateProjectReference(value, pathValue, config) {
|
|
174
|
+
if (value === undefined || value === null || value === '') return;
|
|
175
|
+
_validatePipelineText(value, pathValue, { allowEmpty: false, maxLength: 128 });
|
|
176
|
+
if (value.trim().toLowerCase() === 'central') return;
|
|
177
|
+
const result = shared.resolveConfiguredProject(value, config || {});
|
|
178
|
+
if (result.error) {
|
|
179
|
+
_pipelineInputError(result.error, {
|
|
180
|
+
code: 'invalid-project',
|
|
181
|
+
path: pathValue,
|
|
182
|
+
rejectedValue: value,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _validateAgentReference(value, pathValue, config, options = {}) {
|
|
188
|
+
if (value === undefined || value === null || value === '') {
|
|
189
|
+
if (options.required === true) {
|
|
190
|
+
_pipelineInputError(`${pathValue.join ? pathValue.join('.') : pathValue} must identify a configured agent`, {
|
|
191
|
+
code: 'invalid-agent',
|
|
192
|
+
path: pathValue,
|
|
193
|
+
rejectedValue: value,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
_validatePipelineText(value, pathValue, { allowEmpty: false, maxLength: 128 });
|
|
199
|
+
if (options.allowAll === true && value === 'all') return;
|
|
200
|
+
const agents = Object.keys(config?.agents || {});
|
|
201
|
+
if (!agents.includes(value)) {
|
|
202
|
+
_pipelineInputError(`Unknown agent "${value}". Known agents: ${agents.join(', ') || '(none)'}`, {
|
|
203
|
+
code: 'invalid-agent',
|
|
204
|
+
path: pathValue,
|
|
205
|
+
rejectedValue: value,
|
|
206
|
+
allowedValues: agents,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function _validateProjectFields(value, pathValue, config) {
|
|
212
|
+
_validateProjectReference(value.project, _childPath(pathValue, 'project'), config);
|
|
213
|
+
if (value.projects !== undefined) {
|
|
214
|
+
apiValidation.validateArray(value.projects, {
|
|
215
|
+
path: _childPath(pathValue, 'projects'),
|
|
216
|
+
maxLength: 50,
|
|
217
|
+
itemValidator(project, context) {
|
|
218
|
+
_validateProjectReference(project, context.path, config);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _validateMonitoredResources(resources, pathValue, config) {
|
|
225
|
+
if (resources === undefined) return;
|
|
226
|
+
apiValidation.validateArray(resources, {
|
|
227
|
+
path: pathValue,
|
|
228
|
+
maxLength: PIPELINE_LIMITS.resources,
|
|
229
|
+
itemValidator(resource, context) {
|
|
230
|
+
if (typeof resource === 'string') {
|
|
231
|
+
_validatePipelineText(resource, context.path, {
|
|
232
|
+
allowEmpty: false,
|
|
233
|
+
maxLength: PIPELINE_LIMITS.endpoint,
|
|
234
|
+
});
|
|
235
|
+
if (shared.getPrNumber({ id: resource, url: resource }) == null) {
|
|
236
|
+
_pipelineInputError('Monitored resource must identify a pull request', {
|
|
237
|
+
code: 'invalid-monitored-resource',
|
|
238
|
+
path: context.path,
|
|
239
|
+
rejectedValue: resource,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
apiValidation.validatePlainObject(resource, { path: context.path });
|
|
245
|
+
if (resource.type !== undefined) {
|
|
246
|
+
apiValidation.validateEnum(resource.type, {
|
|
247
|
+
path: _childPath(context.path, 'type'),
|
|
248
|
+
values: ['pr'],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const ref = resource.id || resource.label || resource.url;
|
|
252
|
+
_validatePipelineText(ref, _childPath(context.path, 'id'), {
|
|
253
|
+
allowEmpty: false,
|
|
254
|
+
maxLength: PIPELINE_LIMITS.endpoint,
|
|
255
|
+
});
|
|
256
|
+
_validateProjectReference(
|
|
257
|
+
resource.project ?? resource._project,
|
|
258
|
+
_childPath(context.path, 'project'),
|
|
259
|
+
config,
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _validateCondition(condition, pathValue) {
|
|
266
|
+
let check = condition;
|
|
267
|
+
let value;
|
|
268
|
+
if (typeof condition === 'object' && condition !== null) {
|
|
269
|
+
apiValidation.validatePlainObject(condition, { path: pathValue });
|
|
270
|
+
check = condition.check;
|
|
271
|
+
value = condition.value;
|
|
272
|
+
}
|
|
273
|
+
apiValidation.validateEnum(check, {
|
|
274
|
+
path: typeof condition === 'object' && condition !== null
|
|
275
|
+
? _childPath(pathValue, 'check')
|
|
276
|
+
: pathValue,
|
|
277
|
+
values: PIPELINE_CONDITION_CHECKS,
|
|
278
|
+
});
|
|
279
|
+
if (check === 'maxRuns' && value !== undefined) {
|
|
280
|
+
apiValidation.validateInteger(value, { path: _childPath(pathValue, 'value') });
|
|
281
|
+
apiValidation.validateRange(value, {
|
|
282
|
+
path: _childPath(pathValue, 'value'),
|
|
283
|
+
min: 1,
|
|
284
|
+
max: 1000000,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function _validateWorkItemPriority(priority, pathValue) {
|
|
290
|
+
const error = shared.validateWorkItemPriority(priority);
|
|
291
|
+
if (!error) return;
|
|
292
|
+
_pipelineInputError(error.error, {
|
|
293
|
+
code: error.code,
|
|
294
|
+
path: pathValue,
|
|
295
|
+
rejectedValue: error.rejectedValue,
|
|
296
|
+
allowedValues: error.allowedValues,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _validateApiCall(call, pathValue) {
|
|
301
|
+
apiValidation.validatePlainObject(call, { path: pathValue });
|
|
302
|
+
const endpoint = _validatePipelineText(call.endpoint, _childPath(pathValue, 'endpoint'), {
|
|
303
|
+
allowEmpty: false,
|
|
304
|
+
maxLength: PIPELINE_LIMITS.endpoint,
|
|
305
|
+
});
|
|
306
|
+
let normalizedPath;
|
|
307
|
+
let decodedPath;
|
|
308
|
+
try {
|
|
309
|
+
normalizedPath = new URL(endpoint, 'http://localhost').pathname;
|
|
310
|
+
decodedPath = decodeURIComponent(normalizedPath);
|
|
311
|
+
} catch {
|
|
312
|
+
normalizedPath = '';
|
|
313
|
+
decodedPath = '';
|
|
314
|
+
}
|
|
315
|
+
const endpointSegments = decodedPath.split('/');
|
|
316
|
+
if (!endpoint.startsWith('/api/') || !normalizedPath.startsWith('/api/')
|
|
317
|
+
|| !decodedPath.startsWith('/api/') || /[?#\\]/.test(decodedPath)
|
|
318
|
+
|| endpointSegments.includes('.') || endpointSegments.includes('..')) {
|
|
319
|
+
_pipelineInputError('Pipeline API endpoint must be a canonical local /api/ path', {
|
|
320
|
+
code: 'invalid-api-endpoint',
|
|
321
|
+
path: _childPath(pathValue, 'endpoint'),
|
|
322
|
+
rejectedValue: endpoint,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
apiValidation.validateEnum(call.method, {
|
|
326
|
+
path: _childPath(pathValue, 'method'),
|
|
327
|
+
optional: true,
|
|
328
|
+
values: PIPELINE_API_METHODS,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function _validateTaskItem(item, pathValue, config) {
|
|
333
|
+
apiValidation.validatePlainObject(item, { path: pathValue });
|
|
334
|
+
_validatePipelineText(item.title, _childPath(pathValue, 'title'), {
|
|
335
|
+
optional: true,
|
|
336
|
+
maxLength: PIPELINE_LIMITS.title,
|
|
337
|
+
});
|
|
338
|
+
_validatePipelineText(item.description, _childPath(pathValue, 'description'), { optional: true });
|
|
339
|
+
apiValidation.validateEnum(item.type, {
|
|
340
|
+
path: _childPath(pathValue, 'type'),
|
|
341
|
+
optional: true,
|
|
342
|
+
values: PIPELINE_WORK_TYPES,
|
|
343
|
+
});
|
|
344
|
+
_validateWorkItemPriority(item.priority, _childPath(pathValue, 'priority'));
|
|
345
|
+
_validateAgentReference(item.agent, _childPath(pathValue, 'agent'), config);
|
|
346
|
+
_validateProjectFields(item, pathValue, config);
|
|
347
|
+
for (const field of ['agentLock', 'hardAgent']) {
|
|
348
|
+
apiValidation.validateBoolean(item[field], {
|
|
349
|
+
path: _childPath(pathValue, field),
|
|
350
|
+
optional: true,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function _rejectStagePayload(stage, fields, pathValue) {
|
|
356
|
+
for (const field of fields) {
|
|
357
|
+
if (stage[field] !== undefined) {
|
|
358
|
+
_pipelineInputError(`Stage type "${stage.type}" does not accept ${field}`, {
|
|
359
|
+
code: 'invalid-stage-payload',
|
|
360
|
+
path: _childPath(pathValue, field),
|
|
361
|
+
rejectedValue: stage[field],
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function _validateStage(stage, pathValue, context) {
|
|
368
|
+
apiValidation.validatePlainObject(stage, { path: pathValue });
|
|
369
|
+
const stageId = validatePipelineIdentifier(stage.id, _childPath(pathValue, 'id'));
|
|
370
|
+
if (context.allStageIds.has(stageId)) {
|
|
371
|
+
_pipelineInputError(`Duplicate pipeline stage id "${stageId}"`, {
|
|
372
|
+
code: 'duplicate-stage-id',
|
|
373
|
+
path: _childPath(pathValue, 'id'),
|
|
374
|
+
rejectedValue: stageId,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
context.allStageIds.add(stageId);
|
|
378
|
+
apiValidation.validateEnum(stage.type, {
|
|
379
|
+
path: _childPath(pathValue, 'type'),
|
|
380
|
+
values: PIPELINE_STAGE_TYPES,
|
|
381
|
+
});
|
|
382
|
+
// `allowLegacyDisplayFields` relaxes the title requirement so historical
|
|
383
|
+
// stored pipeline definitions don't crash discovery. That leniency must NOT
|
|
384
|
+
// extend to side-effecting stages that consume `stage.title` to name a
|
|
385
|
+
// persisted record when they carry no per-record collection: a task stage
|
|
386
|
+
// without `items` and a meeting stage without `meetings` still require a
|
|
387
|
+
// title, otherwise a malformed stored definition like `{id:"task",type:"task"}`
|
|
388
|
+
// reaches executeTaskStage/executeMeetingStage and dispatches an UNTITLED
|
|
389
|
+
// work item / meeting (review PR #966). Schedule stages are already guarded
|
|
390
|
+
// downstream by validateScheduleDefinition (title required there); plan stages
|
|
391
|
+
// carry a safe `slugify(stage.title || 'pipeline-plan')` fallback.
|
|
392
|
+
const titleBacksSideEffect =
|
|
393
|
+
(stage.type === STAGE_TYPE.TASK && stage.items === undefined)
|
|
394
|
+
|| (stage.type === STAGE_TYPE.MEETING && stage.meetings === undefined);
|
|
395
|
+
_validatePipelineText(stage.title, _childPath(pathValue, 'title'), {
|
|
396
|
+
optional: context.allowLegacyDisplayFields === true && !titleBacksSideEffect,
|
|
397
|
+
allowEmpty: false,
|
|
398
|
+
maxLength: PIPELINE_LIMITS.title,
|
|
399
|
+
});
|
|
400
|
+
_validatePipelineText(stage.description, _childPath(pathValue, 'description'), { optional: true });
|
|
401
|
+
_validateWorkItemPriority(stage.priority, _childPath(pathValue, 'priority'));
|
|
402
|
+
_validateAgentReference(stage.agent, _childPath(pathValue, 'agent'), context.config);
|
|
403
|
+
_validateProjectFields(stage, pathValue, context.config);
|
|
404
|
+
_validateMonitoredResources(
|
|
405
|
+
stage.monitoredResources,
|
|
406
|
+
_childPath(pathValue, 'monitoredResources'),
|
|
407
|
+
context.config,
|
|
408
|
+
);
|
|
409
|
+
for (const field of ['agentLock', 'hardAgent', 'autoApprove']) {
|
|
410
|
+
apiValidation.validateBoolean(stage[field], {
|
|
411
|
+
path: _childPath(pathValue, field),
|
|
412
|
+
optional: true,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (stage.dependsOn !== undefined) {
|
|
416
|
+
apiValidation.validateArray(stage.dependsOn, {
|
|
417
|
+
path: _childPath(pathValue, 'dependsOn'),
|
|
418
|
+
maxLength: PIPELINE_LIMITS.stages,
|
|
419
|
+
itemValidator(dependency, dependencyContext) {
|
|
420
|
+
validatePipelineIdentifier(dependency, dependencyContext.path);
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
if (context.insideParallel === true && stage.dependsOn.length > 0) {
|
|
424
|
+
_pipelineInputError('Parallel sub-stages cannot declare dependencies', {
|
|
425
|
+
code: 'unsupported-parallel-dependency',
|
|
426
|
+
path: _childPath(pathValue, 'dependsOn'),
|
|
427
|
+
rejectedValue: stage.dependsOn,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
switch (stage.type) {
|
|
433
|
+
case STAGE_TYPE.TASK:
|
|
434
|
+
apiValidation.validateEnum(stage.taskType, {
|
|
435
|
+
path: _childPath(pathValue, 'taskType'),
|
|
436
|
+
optional: true,
|
|
437
|
+
values: PIPELINE_WORK_TYPES,
|
|
438
|
+
});
|
|
439
|
+
if (stage.count !== undefined) {
|
|
440
|
+
apiValidation.validateInteger(stage.count, { path: _childPath(pathValue, 'count') });
|
|
441
|
+
apiValidation.validateRange(stage.count, {
|
|
442
|
+
path: _childPath(pathValue, 'count'),
|
|
443
|
+
min: 1,
|
|
444
|
+
max: PIPELINE_LIMITS.collection,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (stage.items !== undefined) {
|
|
448
|
+
apiValidation.validateArray(stage.items, {
|
|
449
|
+
path: _childPath(pathValue, 'items'),
|
|
450
|
+
minLength: 1,
|
|
451
|
+
maxLength: PIPELINE_LIMITS.collection,
|
|
452
|
+
itemValidator(item, itemContext) {
|
|
453
|
+
_validateTaskItem(item, itemContext.path, context.config);
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
case STAGE_TYPE.MEETING:
|
|
459
|
+
if (stage.participants !== undefined) {
|
|
460
|
+
apiValidation.validateArray(stage.participants, {
|
|
461
|
+
path: _childPath(pathValue, 'participants'),
|
|
462
|
+
minLength: 1,
|
|
463
|
+
maxLength: PIPELINE_LIMITS.collection,
|
|
464
|
+
itemValidator(participant, participantContext) {
|
|
465
|
+
_validateAgentReference(
|
|
466
|
+
participant,
|
|
467
|
+
participantContext.path,
|
|
468
|
+
context.config,
|
|
469
|
+
{ allowAll: true, required: true },
|
|
470
|
+
);
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
if (stage.participants.includes('all') && stage.participants.length !== 1) {
|
|
474
|
+
_pipelineInputError('Meeting participants "all" must be used alone', {
|
|
475
|
+
code: 'invalid-agent',
|
|
476
|
+
path: _childPath(pathValue, 'participants'),
|
|
477
|
+
rejectedValue: stage.participants,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
} else if (Object.keys(context.config?.agents || {}).length === 0) {
|
|
481
|
+
_pipelineInputError('Meeting stage requires participants or configured agents', {
|
|
482
|
+
code: 'invalid-agent',
|
|
483
|
+
path: _childPath(pathValue, 'participants'),
|
|
484
|
+
rejectedValue: stage.participants,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
if (stage.meetings !== undefined) {
|
|
488
|
+
apiValidation.validateArray(stage.meetings, {
|
|
489
|
+
path: _childPath(pathValue, 'meetings'),
|
|
490
|
+
minLength: 1,
|
|
491
|
+
maxLength: PIPELINE_LIMITS.collection,
|
|
492
|
+
itemValidator(meeting, meetingContext) {
|
|
493
|
+
apiValidation.validatePlainObject(meeting, { path: meetingContext.path });
|
|
494
|
+
_validatePipelineText(meeting.title, _childPath(meetingContext.path, 'title'), {
|
|
495
|
+
allowEmpty: false,
|
|
496
|
+
maxLength: PIPELINE_LIMITS.title,
|
|
497
|
+
});
|
|
498
|
+
_validatePipelineText(meeting.agenda, _childPath(meetingContext.path, 'agenda'), {
|
|
499
|
+
optional: true,
|
|
500
|
+
});
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
case STAGE_TYPE.API: {
|
|
506
|
+
if (stage.calls !== undefined) {
|
|
507
|
+
apiValidation.validateArray(stage.calls, {
|
|
508
|
+
path: _childPath(pathValue, 'calls'),
|
|
509
|
+
minLength: 1,
|
|
510
|
+
maxLength: PIPELINE_LIMITS.collection,
|
|
511
|
+
itemValidator(call, callContext) {
|
|
512
|
+
_validateApiCall(call, callContext.path);
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
} else {
|
|
516
|
+
_validateApiCall({
|
|
517
|
+
endpoint: stage.endpoint,
|
|
518
|
+
method: stage.method,
|
|
519
|
+
body: stage.body,
|
|
520
|
+
}, pathValue);
|
|
521
|
+
}
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case STAGE_TYPE.SCHEDULE:
|
|
525
|
+
apiValidation.validateEnum(stage.taskType, {
|
|
526
|
+
path: _childPath(pathValue, 'taskType'),
|
|
527
|
+
optional: true,
|
|
528
|
+
values: PIPELINE_WORK_TYPES,
|
|
529
|
+
});
|
|
530
|
+
if (stage.schedules !== undefined) {
|
|
531
|
+
apiValidation.validateArray(stage.schedules, {
|
|
532
|
+
path: _childPath(pathValue, 'schedules'),
|
|
533
|
+
minLength: 1,
|
|
534
|
+
maxLength: PIPELINE_LIMITS.collection,
|
|
535
|
+
itemValidator(schedule) {
|
|
536
|
+
scheduler.validateScheduleDefinition(schedule, { config: context.config });
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
} else {
|
|
540
|
+
scheduler.validateScheduleDefinition({
|
|
541
|
+
id: _pipelineScheduleId(stage.id, context.pipelineId),
|
|
542
|
+
cron: stage.cron,
|
|
543
|
+
title: stage.title,
|
|
544
|
+
type: stage.taskType || WORK_TYPE.IMPLEMENT,
|
|
545
|
+
enabled: true,
|
|
546
|
+
}, { config: context.config });
|
|
547
|
+
}
|
|
548
|
+
break;
|
|
549
|
+
case STAGE_TYPE.CONDITION: {
|
|
550
|
+
const condition = stage.check !== undefined ? stage.check : stage.condition;
|
|
551
|
+
_validateCondition(condition, _childPath(pathValue, 'check'));
|
|
552
|
+
for (const field of ['onMet', 'onUnmet']) {
|
|
553
|
+
apiValidation.validateEnum(stage[field], {
|
|
554
|
+
path: _childPath(pathValue, field),
|
|
555
|
+
optional: true,
|
|
556
|
+
values: PIPELINE_CONDITION_ACTIONS,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
case STAGE_TYPE.WAIT:
|
|
562
|
+
_rejectStagePayload(stage, [
|
|
563
|
+
'calls', 'endpoint', 'method', 'body', 'items', 'meetings',
|
|
564
|
+
'stages', 'schedules', 'check', 'condition', 'cron',
|
|
565
|
+
], pathValue);
|
|
566
|
+
break;
|
|
567
|
+
case STAGE_TYPE.PARALLEL:
|
|
568
|
+
_validateStageList(stage.stages, _childPath(pathValue, 'stages'), {
|
|
569
|
+
...context,
|
|
570
|
+
insideParallel: true,
|
|
571
|
+
});
|
|
572
|
+
break;
|
|
573
|
+
case STAGE_TYPE.PLAN:
|
|
574
|
+
case STAGE_TYPE.MERGE_PRS:
|
|
575
|
+
break;
|
|
576
|
+
default:
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function _validateStageList(stages, pathValue, context) {
|
|
582
|
+
apiValidation.validateArray(stages, {
|
|
583
|
+
path: pathValue,
|
|
584
|
+
minLength: 1,
|
|
585
|
+
maxLength: PIPELINE_LIMITS.stages,
|
|
586
|
+
});
|
|
587
|
+
const localStages = new Map();
|
|
588
|
+
stages.forEach((stage, index) => {
|
|
589
|
+
_validateStage(stage, _childPath(pathValue, index), context);
|
|
590
|
+
localStages.set(stage.id, stage);
|
|
591
|
+
});
|
|
592
|
+
for (const [stageId, stage] of localStages) {
|
|
593
|
+
for (const dependency of stage.dependsOn || []) {
|
|
594
|
+
if (!localStages.has(dependency)) {
|
|
595
|
+
_pipelineInputError(`Stage "${stageId}" depends on unknown stage "${dependency}"`, {
|
|
596
|
+
code: 'unknown-stage-dependency',
|
|
597
|
+
path: _childPath(pathValue, stageId),
|
|
598
|
+
rejectedValue: dependency,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const visiting = new Set();
|
|
604
|
+
const visited = new Set();
|
|
605
|
+
function visit(stageId) {
|
|
606
|
+
if (visiting.has(stageId)) {
|
|
607
|
+
_pipelineInputError(`Pipeline stage dependency cycle includes "${stageId}"`, {
|
|
608
|
+
code: 'pipeline-cycle',
|
|
609
|
+
path: pathValue,
|
|
610
|
+
rejectedValue: stageId,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
if (visited.has(stageId)) return;
|
|
614
|
+
visiting.add(stageId);
|
|
615
|
+
for (const dependency of localStages.get(stageId).dependsOn || []) visit(dependency);
|
|
616
|
+
visiting.delete(stageId);
|
|
617
|
+
visited.add(stageId);
|
|
618
|
+
}
|
|
619
|
+
for (const stageId of localStages.keys()) visit(stageId);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function validatePipelineDefinition(definition, options = {}) {
|
|
623
|
+
_validatePlainTree(definition);
|
|
624
|
+
apiValidation.validatePlainObject(definition, { path: 'pipeline' });
|
|
625
|
+
validatePipelineIdentifier(definition.id);
|
|
626
|
+
_validatePipelineText(definition.title, 'title', {
|
|
627
|
+
optional: options.allowLegacyDisplayFields === true,
|
|
628
|
+
allowEmpty: false,
|
|
629
|
+
maxLength: PIPELINE_LIMITS.title,
|
|
630
|
+
});
|
|
631
|
+
_validatePipelineText(definition.description, 'description', { optional: true });
|
|
632
|
+
apiValidation.validateBoolean(definition.enabled, { path: 'enabled', optional: true });
|
|
633
|
+
_validateProjectFields(definition, 'pipeline', options.config);
|
|
634
|
+
_validateMonitoredResources(
|
|
635
|
+
definition.monitoredResources,
|
|
636
|
+
'monitoredResources',
|
|
637
|
+
options.config,
|
|
638
|
+
);
|
|
639
|
+
if (definition.trigger !== undefined && definition.trigger !== null) {
|
|
640
|
+
apiValidation.validatePlainObject(definition.trigger, { path: 'trigger' });
|
|
641
|
+
const triggerKeys = Object.keys(definition.trigger);
|
|
642
|
+
if (triggerKeys.some(key => key !== 'cron')) {
|
|
643
|
+
_pipelineInputError('Pipeline trigger accepts only a cron field', {
|
|
644
|
+
code: 'invalid-pipeline-trigger',
|
|
645
|
+
path: 'trigger',
|
|
646
|
+
rejectedValue: definition.trigger,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (triggerKeys.length > 0) {
|
|
650
|
+
scheduler.validateCronExpression(definition.trigger.cron, { path: 'trigger.cron' });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (definition.stopWhen !== undefined && definition.stopWhen !== null) {
|
|
654
|
+
_validateCondition(definition.stopWhen, 'stopWhen');
|
|
655
|
+
}
|
|
656
|
+
_validateStageList(definition.stages, 'stages', {
|
|
657
|
+
config: options.config || {},
|
|
658
|
+
pipelineId: definition.id,
|
|
659
|
+
allowLegacyDisplayFields: options.allowLegacyDisplayFields === true,
|
|
660
|
+
allStageIds: new Set(),
|
|
661
|
+
});
|
|
662
|
+
return definition;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function findPipelineStage(definition, stageId) {
|
|
666
|
+
for (const stage of definition?.stages || []) {
|
|
667
|
+
if (stage?.id === stageId) return stage;
|
|
668
|
+
const nested = findPipelineStage(stage, stageId);
|
|
669
|
+
if (nested) return nested;
|
|
670
|
+
}
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function _pipelineScheduleId(stageId, pipelineId) {
|
|
675
|
+
const identity = pipelineId ? `${pipelineId}-${stageId}` : stageId;
|
|
676
|
+
const hash = crypto.createHash('sha256')
|
|
677
|
+
.update(`${pipelineId || 'standalone'}\0${stageId}`)
|
|
678
|
+
.digest('hex')
|
|
679
|
+
.slice(0, 32);
|
|
680
|
+
const suffix = `-${hash}-sched`;
|
|
681
|
+
const base = slugify(identity, scheduler.SCHEDULE_LIMITS.id - suffix.length) || 'stage';
|
|
682
|
+
return `${base}${suffix}`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function validatePipelineStoredIdentity(pipelineId, pipeline) {
|
|
686
|
+
validatePipelineIdentifier(pipelineId, 'pipelineId');
|
|
687
|
+
const storedId = validatePipelineIdentifier(pipeline?.id);
|
|
688
|
+
if (storedId !== pipelineId) {
|
|
689
|
+
_pipelineInputError('Stored pipeline id does not match the requested id', {
|
|
690
|
+
code: 'pipeline-id-mismatch',
|
|
691
|
+
path: 'id',
|
|
692
|
+
rejectedValue: storedId,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return pipeline;
|
|
696
|
+
}
|
|
697
|
+
|
|
33
698
|
// ── Pipeline CRUD ────────────────────────────────────────────────────────────
|
|
34
699
|
|
|
35
700
|
function getPipelines() {
|
|
@@ -51,16 +716,20 @@ function getPipelines() {
|
|
|
51
716
|
}
|
|
52
717
|
|
|
53
718
|
function getPipeline(id) {
|
|
719
|
+
validatePipelineIdentifier(id);
|
|
54
720
|
const filePath = path.join(PIPELINES_DIR, id + '.json');
|
|
55
721
|
return safeJson(filePath);
|
|
56
722
|
}
|
|
57
723
|
|
|
58
|
-
function savePipeline(pipeline) {
|
|
724
|
+
function savePipeline(pipeline, options = {}) {
|
|
725
|
+
if (options.validate === true) validatePipelineDefinition(pipeline, options);
|
|
726
|
+
else validatePipelineIdentifier(pipeline?.id);
|
|
59
727
|
if (!fs.existsSync(PIPELINES_DIR)) fs.mkdirSync(PIPELINES_DIR, { recursive: true });
|
|
60
728
|
mutateJsonFileLocked(path.join(PIPELINES_DIR, pipeline.id + '.json'), () => pipeline, { defaultValue: {} });
|
|
61
729
|
}
|
|
62
730
|
|
|
63
731
|
function deletePipeline(id) {
|
|
732
|
+
validatePipelineIdentifier(id);
|
|
64
733
|
const filePath = path.join(PIPELINES_DIR, id + '.json');
|
|
65
734
|
if (!fs.existsSync(filePath)) return false;
|
|
66
735
|
try {
|
|
@@ -96,6 +765,7 @@ function getActiveRun(pipelineId) {
|
|
|
96
765
|
}
|
|
97
766
|
|
|
98
767
|
function startRun(pipelineId, pipeline) {
|
|
768
|
+
validatePipelineIdentifier(pipelineId, 'pipelineId');
|
|
99
769
|
const runId = `run-${uid()}`;
|
|
100
770
|
const stages = {};
|
|
101
771
|
for (const stage of (pipeline.stages || [])) {
|
|
@@ -106,7 +776,9 @@ function startRun(pipelineId, pipeline) {
|
|
|
106
776
|
let alreadyActive = false;
|
|
107
777
|
mutatePipelineRuns((data) => {
|
|
108
778
|
if (!data[pipelineId]) data[pipelineId] = [];
|
|
109
|
-
if (data[pipelineId].some(r => r.status === PIPELINE_STATUS.RUNNING
|
|
779
|
+
if (data[pipelineId].some(r => r.status === PIPELINE_STATUS.RUNNING
|
|
780
|
+
|| r.status === PIPELINE_STATUS.PAUSED
|
|
781
|
+
|| r.status === PIPELINE_STATUS.WAITING_HUMAN)) {
|
|
110
782
|
alreadyActive = true;
|
|
111
783
|
return data;
|
|
112
784
|
}
|
|
@@ -123,6 +795,25 @@ function startRun(pipelineId, pipeline) {
|
|
|
123
795
|
return run;
|
|
124
796
|
}
|
|
125
797
|
|
|
798
|
+
function validatePipelineStart(pipelineId, pipeline, config) {
|
|
799
|
+
validatePipelineStoredIdentity(pipelineId, pipeline);
|
|
800
|
+
validatePipelineDefinition(pipeline, { config });
|
|
801
|
+
if (pipeline.enabled === false) {
|
|
802
|
+
_pipelineInputError(`Pipeline "${pipelineId}" is disabled`, {
|
|
803
|
+
code: 'pipeline-disabled',
|
|
804
|
+
statusCode: 409,
|
|
805
|
+
path: 'enabled',
|
|
806
|
+
rejectedValue: pipeline.enabled,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return pipeline;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function startValidatedRun(pipelineId, pipeline, config) {
|
|
813
|
+
validatePipelineStart(pipelineId, pipeline, config);
|
|
814
|
+
return startRun(pipelineId, pipeline);
|
|
815
|
+
}
|
|
816
|
+
|
|
126
817
|
function updateRunStage(pipelineId, runId, stageId, updates) {
|
|
127
818
|
if (updates && Object.prototype.hasOwnProperty.call(updates, 'status')) {
|
|
128
819
|
const validStatuses = Object.values(PIPELINE_STATUS);
|
|
@@ -465,7 +1156,7 @@ async function executeStage(stage, run, pipeline, config) {
|
|
|
465
1156
|
case STAGE_TYPE.MERGE_PRS:
|
|
466
1157
|
return executeMergePrsStage(resolved, stageState, run, config);
|
|
467
1158
|
case STAGE_TYPE.SCHEDULE:
|
|
468
|
-
return executeScheduleStage(resolved, stageState, config);
|
|
1159
|
+
return executeScheduleStage(resolved, stageState, config, pipeline);
|
|
469
1160
|
case STAGE_TYPE.CONDITION:
|
|
470
1161
|
return executeConditionStage(resolved, stageState, run, pipeline, config);
|
|
471
1162
|
case STAGE_TYPE.WAIT:
|
|
@@ -512,7 +1203,7 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
512
1203
|
title: item.title || stage.title,
|
|
513
1204
|
description: item.description || stage.description || '',
|
|
514
1205
|
type: wiType,
|
|
515
|
-
priority: item.priority || stage.priority ||
|
|
1206
|
+
priority: item.priority || stage.priority || WORK_ITEM_PRIORITY.MEDIUM,
|
|
516
1207
|
// Agent is a soft routing hint unless agentLock/hardAgent is set.
|
|
517
1208
|
...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
|
|
518
1209
|
...(item.agentLock === true || stage.agentLock === true || item.hardAgent === true || stage.hardAgent === true ? { agentLock: true } : {}),
|
|
@@ -712,7 +1403,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
712
1403
|
} else if (!workItems.some(w => w.id === wiId)) {
|
|
713
1404
|
workItems.push({
|
|
714
1405
|
id: wiId, title: `Convert plan to PRD: ${existingPlanFile}`,
|
|
715
|
-
type: WORK_TYPE.PLAN_TO_PRD, priority:
|
|
1406
|
+
type: WORK_TYPE.PLAN_TO_PRD, priority: WORK_ITEM_PRIORITY.HIGH, status: WI_STATUS.PENDING,
|
|
716
1407
|
planFile: existingPlanFile, created: ts(), createdBy: 'pipeline:' + run.pipelineId,
|
|
717
1408
|
// W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
|
|
718
1409
|
_pipelineRun: run.runId, _pipelineStage: stage.id,
|
|
@@ -816,7 +1507,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
816
1507
|
id: wiId,
|
|
817
1508
|
title: `Convert plan to PRD: ${path.basename(filePath)}`,
|
|
818
1509
|
type: WORK_TYPE.PLAN_TO_PRD,
|
|
819
|
-
priority:
|
|
1510
|
+
priority: WORK_ITEM_PRIORITY.HIGH,
|
|
820
1511
|
status: WI_STATUS.PENDING,
|
|
821
1512
|
planFile: path.basename(filePath),
|
|
822
1513
|
created: ts(),
|
|
@@ -935,9 +1626,17 @@ function executeMergePrsStage(stage, stageState, run, config) {
|
|
|
935
1626
|
return { status: PIPELINE_STATUS.RUNNING, artifacts: { prs: prIds } };
|
|
936
1627
|
}
|
|
937
1628
|
|
|
938
|
-
function executeScheduleStage(stage, stageState, config) {
|
|
1629
|
+
function executeScheduleStage(stage, stageState, config, pipeline) {
|
|
939
1630
|
// Create/update schedules in config
|
|
940
|
-
const
|
|
1631
|
+
const implicit = !stage.schedules;
|
|
1632
|
+
const schedules = stage.schedules || [{
|
|
1633
|
+
id: _pipelineScheduleId(stage.id, pipeline?.id),
|
|
1634
|
+
cron: stage.cron,
|
|
1635
|
+
title: stage.title,
|
|
1636
|
+
type: routing.normalizeWorkType(stage.taskType, WORK_TYPE.IMPLEMENT),
|
|
1637
|
+
...(pipeline?.id ? { _pipelineId: pipeline.id } : {}),
|
|
1638
|
+
_pipelineStage: stage.id,
|
|
1639
|
+
}];
|
|
941
1640
|
// P-bf05-config-write-race — atomic locked write so concurrent
|
|
942
1641
|
// addProject/removeProject/dashboard-settings writers can't clobber the
|
|
943
1642
|
// schedules we just appended. The prior `safeWrite(CONFIG_PATH, config)`
|
|
@@ -946,10 +1645,60 @@ function executeScheduleStage(stage, stageState, config) {
|
|
|
946
1645
|
// don't drop concurrent edits.
|
|
947
1646
|
mutateJsonFileLocked(CONFIG_PATH, (cfg) => {
|
|
948
1647
|
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return cfg;
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1648
|
+
cfg.schedules = cfg.schedules || [];
|
|
1649
|
+
for (const definition of schedules) {
|
|
1650
|
+
const sched = { ...definition };
|
|
1651
|
+
let existingIndex = cfg.schedules.findIndex(candidate => candidate.id === sched.id);
|
|
1652
|
+
|
|
1653
|
+
if (implicit) {
|
|
1654
|
+
const sameDefinition = candidate => candidate
|
|
1655
|
+
&& candidate.cron === sched.cron
|
|
1656
|
+
&& candidate.title === sched.title
|
|
1657
|
+
&& routing.normalizeWorkType(candidate.type, WORK_TYPE.IMPLEMENT) === sched.type;
|
|
1658
|
+
const sameOwner = candidate => sameDefinition(candidate)
|
|
1659
|
+
&& candidate._pipelineStage === stage.id
|
|
1660
|
+
&& (pipeline?.id ? candidate._pipelineId === pipeline.id : !candidate._pipelineId);
|
|
1661
|
+
const legacyId = `${stage.id}-sched`;
|
|
1662
|
+
const legacyIndex = cfg.schedules.findIndex(candidate => (
|
|
1663
|
+
candidate.id === legacyId && sameDefinition(candidate)
|
|
1664
|
+
));
|
|
1665
|
+
|
|
1666
|
+
if (legacyIndex >= 0) {
|
|
1667
|
+
const legacy = cfg.schedules[legacyIndex];
|
|
1668
|
+
if (existingIndex >= 0 && sameOwner(cfg.schedules[existingIndex])) {
|
|
1669
|
+
const existing = cfg.schedules[existingIndex];
|
|
1670
|
+
if (legacy.enabled !== undefined) existing.enabled = legacy.enabled;
|
|
1671
|
+
Object.assign(existing, sched);
|
|
1672
|
+
if (legacy.enabled !== undefined) existing.enabled = legacy.enabled;
|
|
1673
|
+
_copyScheduleRunHistory(legacyId, existing.id);
|
|
1674
|
+
cfg.schedules.splice(legacyIndex, 1);
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
if (existingIndex >= 0) {
|
|
1678
|
+
sched.id = _uniqueScheduleId(cfg.schedules, sched.id);
|
|
1679
|
+
existingIndex = -1;
|
|
1680
|
+
}
|
|
1681
|
+
_copyScheduleRunHistory(legacyId, sched.id);
|
|
1682
|
+
cfg.schedules[legacyIndex] = {
|
|
1683
|
+
...legacy,
|
|
1684
|
+
...sched,
|
|
1685
|
+
enabled: legacy.enabled === undefined ? true : legacy.enabled,
|
|
1686
|
+
};
|
|
1687
|
+
continue;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
if (existingIndex >= 0) {
|
|
1691
|
+
const existing = cfg.schedules[existingIndex];
|
|
1692
|
+
if (sameOwner(existing) || (sameDefinition(existing) && !existing._pipelineId)) {
|
|
1693
|
+
Object.assign(existing, sched);
|
|
1694
|
+
continue;
|
|
1695
|
+
}
|
|
1696
|
+
sched.id = _uniqueScheduleId(cfg.schedules, sched.id);
|
|
1697
|
+
existingIndex = -1;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
if (existingIndex < 0) {
|
|
953
1702
|
cfg.schedules.push({ ...sched, enabled: true });
|
|
954
1703
|
}
|
|
955
1704
|
}
|
|
@@ -958,6 +1707,25 @@ function executeScheduleStage(stage, stageState, config) {
|
|
|
958
1707
|
return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts() };
|
|
959
1708
|
}
|
|
960
1709
|
|
|
1710
|
+
function _uniqueScheduleId(schedules, desiredId) {
|
|
1711
|
+
let candidate = desiredId;
|
|
1712
|
+
for (let suffix = 2; schedules.some(schedule => schedule.id === candidate); suffix += 1) {
|
|
1713
|
+
const suffixText = `-${suffix}`;
|
|
1714
|
+
candidate = `${desiredId.slice(0, scheduler.SCHEDULE_LIMITS.id - suffixText.length)}${suffixText}`;
|
|
1715
|
+
}
|
|
1716
|
+
return candidate;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
function _copyScheduleRunHistory(fromId, toId) {
|
|
1720
|
+
mutateScheduleRuns((runs) => {
|
|
1721
|
+
if (Object.prototype.hasOwnProperty.call(runs, fromId)
|
|
1722
|
+
&& !Object.prototype.hasOwnProperty.call(runs, toId)) {
|
|
1723
|
+
runs[toId] = runs[fromId];
|
|
1724
|
+
}
|
|
1725
|
+
return runs;
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
|
|
961
1729
|
function executeConditionStage(stage, stageState, run, pipeline, config) {
|
|
962
1730
|
const check = stage.check || stage.condition;
|
|
963
1731
|
if (!check) {
|
|
@@ -1179,6 +1947,15 @@ async function discoverPipelineWork(config) {
|
|
|
1179
1947
|
const now = new Date();
|
|
1180
1948
|
|
|
1181
1949
|
for (const pipeline of pipelines) {
|
|
1950
|
+
try {
|
|
1951
|
+
validatePipelineDefinition(pipeline, {
|
|
1952
|
+
config,
|
|
1953
|
+
allowLegacyDisplayFields: true,
|
|
1954
|
+
});
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
log('warn', `Pipeline ${pipeline?.id || '(missing id)'}: invalid definition — skipping (${error.message})`);
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1182
1959
|
if (pipeline.enabled === false) continue;
|
|
1183
1960
|
|
|
1184
1961
|
// Check for active run
|
|
@@ -1466,11 +2243,22 @@ async function discoverPipelineWork(config) {
|
|
|
1466
2243
|
|
|
1467
2244
|
module.exports = {
|
|
1468
2245
|
PIPELINES_DIR,
|
|
2246
|
+
PIPELINE_LIMITS,
|
|
2247
|
+
PIPELINE_STAGE_TYPES,
|
|
2248
|
+
PIPELINE_CONDITION_CHECKS,
|
|
2249
|
+
PIPELINE_CONDITION_ACTIONS,
|
|
2250
|
+
PIPELINE_API_METHODS,
|
|
2251
|
+
validatePipelineIdentifier,
|
|
2252
|
+
validatePipelineDefinition,
|
|
2253
|
+
validatePipelineStoredIdentity,
|
|
2254
|
+
validatePipelineStart,
|
|
2255
|
+
findPipelineStage,
|
|
1469
2256
|
getPipelines, getPipeline, savePipeline, deletePipeline,
|
|
1470
|
-
getPipelineRuns, getActiveRun, startRun, updateRunStage, upsertRunStage, completeRun,
|
|
2257
|
+
getPipelineRuns, getActiveRun, startRun, startValidatedRun, updateRunStage, upsertRunStage, completeRun,
|
|
1471
2258
|
discoverPipelineWork,
|
|
1472
2259
|
evaluateCondition, // exported for testing
|
|
1473
2260
|
executeTaskStage, executePlanStage, executeScheduleStage, executeApiStage, executeMeetingStage, executeMergePrsStage, isStageComplete, resolveTemplate, // exported for testing
|
|
2261
|
+
_pipelineScheduleId, // exported for testing
|
|
1474
2262
|
_resolvePipelineProjects, // exported for testing
|
|
1475
2263
|
_findMeetingsInRun, _findExistingPlanForMeeting, _findExistingPrdForPlan, _canonicalPlanName, // exported for testing
|
|
1476
2264
|
};
|