@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
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const apiValidation = require('./api-validation');
|
|
4
|
+
const features = require('./features');
|
|
5
|
+
const modelDiscovery = require('./model-discovery');
|
|
6
|
+
const runtimes = require('./runtimes');
|
|
7
|
+
const shared = require('./shared');
|
|
8
|
+
|
|
9
|
+
const MAX_SETTINGS_ERRORS = 25;
|
|
10
|
+
const SETTINGS_TOP_LEVEL_FIELDS = new Set(['engine', 'claude', 'agents', 'projects', 'features']);
|
|
11
|
+
const CLEAR_SENTINELS = new Set(['', null]);
|
|
12
|
+
const FONT_SIZES = ['small', 'medium', 'large', 'xlarge'];
|
|
13
|
+
const COPILOT_STREAM_MODES = ['on', 'off'];
|
|
14
|
+
const CODEX_SANDBOX_MODES = [...shared.CODEX_SANDBOX_MODES];
|
|
15
|
+
const CC_EFFORT_LEVELS = ['low', 'medium', 'high'];
|
|
16
|
+
const AGENT_FIELDS = new Set([
|
|
17
|
+
'role',
|
|
18
|
+
'expertise',
|
|
19
|
+
'skills',
|
|
20
|
+
'monthlyBudgetUsd',
|
|
21
|
+
'cli',
|
|
22
|
+
'model',
|
|
23
|
+
'maxBudgetUsd',
|
|
24
|
+
'bareMode',
|
|
25
|
+
'workspace_manifest',
|
|
26
|
+
]);
|
|
27
|
+
const WORKSPACE_MANIFEST_FIELDS = new Set([
|
|
28
|
+
'allowed_tools',
|
|
29
|
+
'allowed_repos',
|
|
30
|
+
'allowed_external_urls',
|
|
31
|
+
'memory_scope',
|
|
32
|
+
]);
|
|
33
|
+
const PROJECT_FIELDS = new Set([
|
|
34
|
+
'name',
|
|
35
|
+
'mainBranch',
|
|
36
|
+
'checkoutMode',
|
|
37
|
+
'worktreeMode',
|
|
38
|
+
'liveValidation',
|
|
39
|
+
'liveCheckoutAutoStash',
|
|
40
|
+
'createPrsAsDraft',
|
|
41
|
+
'additionalRepoScopes',
|
|
42
|
+
'workSources',
|
|
43
|
+
]);
|
|
44
|
+
const WORK_SOURCE_NAMES = new Set(['pullRequests', 'workItems']);
|
|
45
|
+
const WORK_SOURCE_FIELDS = new Set(['enabled', 'cooldownMinutes']);
|
|
46
|
+
const MAX_TURNS_TYPES = new Set(Object.values(shared.WORK_TYPE));
|
|
47
|
+
|
|
48
|
+
// Bounds retain the public settings contract while defaults remain authoritative
|
|
49
|
+
// in ENGINE_DEFAULTS. Every entry is checked against that registry at load time.
|
|
50
|
+
const ENGINE_NUMERIC_RANGES = Object.freeze({
|
|
51
|
+
tickInterval: { min: 10000 },
|
|
52
|
+
maxConcurrent: { min: 1, max: 50 },
|
|
53
|
+
inboxConsolidateThreshold: { min: 1 },
|
|
54
|
+
agentTimeout: { min: 60000 },
|
|
55
|
+
maxTurns: { min: 5, max: 500 },
|
|
56
|
+
heartbeatTimeout: { min: 60000 },
|
|
57
|
+
worktreeCreateTimeout: { min: 60000 },
|
|
58
|
+
worktreeCreateRetries: { min: 0, max: 3 },
|
|
59
|
+
worktreeMinFreeSpaceBytes: { min: 0, max: 10 * 1024 * 1024 * 1024 * 1024 },
|
|
60
|
+
assertCleanStatusTimeoutMs: { min: 1000, max: 120000 },
|
|
61
|
+
restartHealthTimeoutMs: { min: 15000, max: 300000 },
|
|
62
|
+
orphanHolderScanTimeoutMs: { min: 1000, max: 30000 },
|
|
63
|
+
statusProbeKillTimeoutMs: { min: 2000, max: 60000 },
|
|
64
|
+
idleAlertMinutes: { min: 1 },
|
|
65
|
+
shutdownTimeout: { min: 30000 },
|
|
66
|
+
restartGracePeriod: { min: 60000 },
|
|
67
|
+
meetingRoundTimeout: { min: 60000 },
|
|
68
|
+
steeringDeferredMaxMs: { min: 60000, max: 14400000 },
|
|
69
|
+
steeringMaxKillRetries: { min: 1, max: 5 },
|
|
70
|
+
spawnPhaseGraceMs: { min: 10000, max: 1800000 },
|
|
71
|
+
spawnPhaseMaxCpuSeconds: { min: 0, max: 60 },
|
|
72
|
+
versionCheckInterval: { min: 60000 },
|
|
73
|
+
prPollStatusEvery: { min: 1 },
|
|
74
|
+
prPollCommentsEvery: { min: 1 },
|
|
75
|
+
agentBusyReassignMs: { min: 0 },
|
|
76
|
+
maxRetriesPerAgent: { min: 1, max: 20 },
|
|
77
|
+
ccTurnTimeoutMs: { min: 10000, max: 3600000 },
|
|
78
|
+
preDispatchEvalConcurrency: { min: 1, max: 20 },
|
|
79
|
+
memoryRetrievalTopK: { min: 1, max: 30 },
|
|
80
|
+
memoryRetrievalMaxBytes: { min: 1024, max: 65536 },
|
|
81
|
+
memoryRetrievalCandidateLimit: { min: 5, max: 200 },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for (const key of Object.keys(ENGINE_NUMERIC_RANGES)) {
|
|
85
|
+
if (typeof shared.ENGINE_DEFAULTS[key] !== 'number') {
|
|
86
|
+
throw new Error(`Settings range "${key}" has no numeric ENGINE_DEFAULTS entry`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ENGINE_BOOLEAN_FIELDS = new Set(
|
|
91
|
+
Object.entries(shared.ENGINE_DEFAULTS)
|
|
92
|
+
.filter(([, value]) => typeof value === 'boolean')
|
|
93
|
+
.map(([key]) => key),
|
|
94
|
+
);
|
|
95
|
+
const ENGINE_SPECIAL_FIELDS = new Set([
|
|
96
|
+
'ccWorkerIdleTimeoutMs',
|
|
97
|
+
'worktreeRoot',
|
|
98
|
+
'operatorLogin',
|
|
99
|
+
'defaultCli',
|
|
100
|
+
'defaultModel',
|
|
101
|
+
'ccCli',
|
|
102
|
+
'ccModel',
|
|
103
|
+
'claudeFallbackModel',
|
|
104
|
+
'copilotFallbackModel',
|
|
105
|
+
'copilotStreamMode',
|
|
106
|
+
'codexCcSandbox',
|
|
107
|
+
'fontSize',
|
|
108
|
+
'maxBudgetUsd',
|
|
109
|
+
'ccEffort',
|
|
110
|
+
'maxTurnsByType',
|
|
111
|
+
'evalMaxCost',
|
|
112
|
+
'ignoredCommentAuthors',
|
|
113
|
+
]);
|
|
114
|
+
const ENGINE_ALLOWED_FIELDS = new Set([
|
|
115
|
+
...Object.keys(ENGINE_NUMERIC_RANGES),
|
|
116
|
+
...ENGINE_BOOLEAN_FIELDS,
|
|
117
|
+
...ENGINE_SPECIAL_FIELDS,
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
class SettingsDiscoveryError extends Error {
|
|
121
|
+
constructor(runtimeName, cause) {
|
|
122
|
+
super(`Model discovery is unavailable for runtime "${runtimeName}". Retry when its catalog is available.`);
|
|
123
|
+
this.name = 'SettingsDiscoveryError';
|
|
124
|
+
this.code = 'model-discovery-unavailable';
|
|
125
|
+
this.statusCode = 503;
|
|
126
|
+
this.runtime = runtimeName;
|
|
127
|
+
this.cause = cause;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
class SettingsErrorCollector {
|
|
132
|
+
constructor() {
|
|
133
|
+
this.details = [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
add(path, code, message, rejectedValue, extra = {}) {
|
|
137
|
+
if (this.details.length >= MAX_SETTINGS_ERRORS) return;
|
|
138
|
+
this.details.push({
|
|
139
|
+
path,
|
|
140
|
+
field: String(path).split('.').pop().replace(/\[\d+\]$/, ''),
|
|
141
|
+
code,
|
|
142
|
+
message,
|
|
143
|
+
expected: extra.expected,
|
|
144
|
+
rejectedValue,
|
|
145
|
+
...(Array.isArray(extra.allowedValues) ? { allowedValues: extra.allowedValues } : {}),
|
|
146
|
+
...(Number.isFinite(extra.min) ? { min: extra.min } : {}),
|
|
147
|
+
...(Number.isFinite(extra.max) ? { max: extra.max } : {}),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
capture(path, rejectedValue, fn) {
|
|
152
|
+
try {
|
|
153
|
+
return fn();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.add(path, 'invalid-setting', error && error.message ? error.message : 'Invalid setting', rejectedValue);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throwIfAny() {
|
|
161
|
+
if (this.details.length === 0) return;
|
|
162
|
+
throw new apiValidation.ApiInputError('Settings validation failed', {
|
|
163
|
+
code: 'invalid-settings',
|
|
164
|
+
statusCode: 422,
|
|
165
|
+
details: this.details,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hasOwn(value, key) {
|
|
171
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isPlainObject(value) {
|
|
175
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
176
|
+
const prototype = Object.getPrototypeOf(value);
|
|
177
|
+
return prototype === Object.prototype || prototype === null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cloneJson(value) {
|
|
181
|
+
return JSON.parse(JSON.stringify(value));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function ensureCurrentConfig(currentConfig) {
|
|
185
|
+
if (!isPlainObject(currentConfig)) {
|
|
186
|
+
throw new Error('Current config.json must contain a JSON object');
|
|
187
|
+
}
|
|
188
|
+
return cloneJson(currentConfig);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function validateObject(value, path, errors) {
|
|
192
|
+
if (isPlainObject(value)) return true;
|
|
193
|
+
errors.add(path, 'invalid-object', `${path} must be a plain object`, value, { expected: 'plain object' });
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function rejectUnknownFields(value, allowed, path, errors) {
|
|
198
|
+
for (const key of Object.keys(value)) {
|
|
199
|
+
if (!allowed.has(key)) {
|
|
200
|
+
errors.add(`${path}.${key}`, 'unknown-field', `${path}.${key} is not a supported setting`, value[key], {
|
|
201
|
+
allowedValues: [...allowed],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseFiniteNumber(value, path, errors, options = {}) {
|
|
208
|
+
let parsed;
|
|
209
|
+
if (typeof value === 'number') {
|
|
210
|
+
parsed = value;
|
|
211
|
+
} else if (typeof value === 'string' && value.trim() !== '') {
|
|
212
|
+
parsed = Number(value);
|
|
213
|
+
} else {
|
|
214
|
+
errors.add(path, 'invalid-number', `${path} must be a finite number`, value, { expected: 'finite number' });
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
if (!Number.isFinite(parsed)) {
|
|
218
|
+
errors.add(path, 'invalid-number', `${path} must be a finite number`, value, { expected: 'finite number' });
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
if (options.integer !== false && !Number.isInteger(parsed)) {
|
|
222
|
+
errors.add(path, 'invalid-integer', `${path} must be an integer`, value, { expected: 'integer' });
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
if ((Number.isFinite(options.min) && parsed < options.min)
|
|
226
|
+
|| (Number.isFinite(options.max) && parsed > options.max)) {
|
|
227
|
+
errors.add(path, 'out-of-range', `${path} is outside the allowed range`, value, {
|
|
228
|
+
expected: 'number in range',
|
|
229
|
+
min: options.min,
|
|
230
|
+
max: options.max,
|
|
231
|
+
});
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
return parsed;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseString(value, path, errors, options = {}) {
|
|
238
|
+
if (typeof value !== 'string') {
|
|
239
|
+
errors.add(path, 'invalid-string', `${path} must be a string`, value, { expected: 'string' });
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
const parsed = options.trim === false ? value : value.trim();
|
|
243
|
+
if (options.allowEmpty === false && parsed.length === 0) {
|
|
244
|
+
errors.add(path, 'invalid-string', `${path} must not be empty`, value, { expected: 'non-empty string' });
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
return parsed;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseBoolean(value, path, errors) {
|
|
251
|
+
if (typeof value !== 'boolean') {
|
|
252
|
+
errors.add(path, 'invalid-boolean', `${path} must be a boolean`, value, { expected: 'boolean' });
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseEnum(value, path, values, errors) {
|
|
259
|
+
if (!values.includes(value)) {
|
|
260
|
+
errors.add(path, 'invalid-enum', `${path} must be one of the allowed values`, value, {
|
|
261
|
+
expected: 'enum',
|
|
262
|
+
allowedValues: values,
|
|
263
|
+
});
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function setOrDelete(target, key, value) {
|
|
270
|
+
if (value === undefined) delete target[key];
|
|
271
|
+
else target[key] = value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseClearableString(value, path, errors) {
|
|
275
|
+
if (CLEAR_SENTINELS.has(value)) return undefined;
|
|
276
|
+
return parseString(value, path, errors, { allowEmpty: false });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseRuntimeName(value, path, errors) {
|
|
280
|
+
const parsed = parseClearableString(value, path, errors);
|
|
281
|
+
if (parsed === undefined) return undefined;
|
|
282
|
+
const names = runtimes.listRuntimes();
|
|
283
|
+
if (!names.includes(parsed)) {
|
|
284
|
+
errors.add(path, 'unknown-runtime', `${path} names an unregistered runtime`, value, {
|
|
285
|
+
expected: 'registered runtime',
|
|
286
|
+
allowedValues: names,
|
|
287
|
+
});
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
return parsed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function applyEngineSettings(candidate, bodyEngine, errors) {
|
|
294
|
+
if (!validateObject(bodyEngine, 'engine', errors)) return;
|
|
295
|
+
rejectUnknownFields(bodyEngine, ENGINE_ALLOWED_FIELDS, 'engine', errors);
|
|
296
|
+
if (!isPlainObject(candidate.engine)) candidate.engine = {};
|
|
297
|
+
const engine = candidate.engine;
|
|
298
|
+
|
|
299
|
+
for (const [key, range] of Object.entries(ENGINE_NUMERIC_RANGES)) {
|
|
300
|
+
if (!hasOwn(bodyEngine, key)) continue;
|
|
301
|
+
const value = parseFiniteNumber(bodyEngine[key], `engine.${key}`, errors, range);
|
|
302
|
+
if (value !== undefined) engine[key] = value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (hasOwn(bodyEngine, 'ccWorkerIdleTimeoutMs')) {
|
|
306
|
+
const raw = bodyEngine.ccWorkerIdleTimeoutMs;
|
|
307
|
+
const value = parseFiniteNumber(raw, 'engine.ccWorkerIdleTimeoutMs', errors, {
|
|
308
|
+
min: raw === 0 || raw === '0' ? 0 : 60000,
|
|
309
|
+
max: raw === 0 || raw === '0' ? 0 : 28800000,
|
|
310
|
+
});
|
|
311
|
+
if (value !== undefined) engine.ccWorkerIdleTimeoutMs = value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const key of ENGINE_BOOLEAN_FIELDS) {
|
|
315
|
+
if (!hasOwn(bodyEngine, key)) continue;
|
|
316
|
+
const value = parseBoolean(bodyEngine[key], `engine.${key}`, errors);
|
|
317
|
+
if (value !== undefined) engine[key] = value;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (hasOwn(bodyEngine, 'worktreeRoot')) {
|
|
321
|
+
const value = parseString(bodyEngine.worktreeRoot, 'engine.worktreeRoot', errors);
|
|
322
|
+
if (value !== undefined) engine.worktreeRoot = value || shared.ENGINE_DEFAULTS.worktreeRoot;
|
|
323
|
+
}
|
|
324
|
+
if (hasOwn(bodyEngine, 'operatorLogin')) {
|
|
325
|
+
const value = parseClearableString(bodyEngine.operatorLogin, 'engine.operatorLogin', errors);
|
|
326
|
+
setOrDelete(engine, 'operatorLogin', value);
|
|
327
|
+
}
|
|
328
|
+
for (const key of ['defaultCli', 'ccCli']) {
|
|
329
|
+
if (!hasOwn(bodyEngine, key)) continue;
|
|
330
|
+
const value = parseRuntimeName(bodyEngine[key], `engine.${key}`, errors);
|
|
331
|
+
if (!CLEAR_SENTINELS.has(bodyEngine[key]) && value === undefined) continue;
|
|
332
|
+
setOrDelete(engine, key, value);
|
|
333
|
+
}
|
|
334
|
+
for (const key of ['defaultModel', 'ccModel', 'claudeFallbackModel', 'copilotFallbackModel']) {
|
|
335
|
+
if (!hasOwn(bodyEngine, key)) continue;
|
|
336
|
+
const value = parseClearableString(bodyEngine[key], `engine.${key}`, errors);
|
|
337
|
+
if (!CLEAR_SENTINELS.has(bodyEngine[key]) && value === undefined) continue;
|
|
338
|
+
setOrDelete(engine, key, value);
|
|
339
|
+
}
|
|
340
|
+
if (hasOwn(bodyEngine, 'copilotStreamMode')) {
|
|
341
|
+
if (CLEAR_SENTINELS.has(bodyEngine.copilotStreamMode)) {
|
|
342
|
+
delete engine.copilotStreamMode;
|
|
343
|
+
} else {
|
|
344
|
+
const value = parseEnum(bodyEngine.copilotStreamMode, 'engine.copilotStreamMode', COPILOT_STREAM_MODES, errors);
|
|
345
|
+
if (value !== undefined) engine.copilotStreamMode = value;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (hasOwn(bodyEngine, 'codexCcSandbox')) {
|
|
349
|
+
if (CLEAR_SENTINELS.has(bodyEngine.codexCcSandbox)) {
|
|
350
|
+
delete engine.codexCcSandbox;
|
|
351
|
+
} else {
|
|
352
|
+
const value = parseEnum(bodyEngine.codexCcSandbox, 'engine.codexCcSandbox', CODEX_SANDBOX_MODES, errors);
|
|
353
|
+
if (value !== undefined) engine.codexCcSandbox = value;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (hasOwn(bodyEngine, 'fontSize')) {
|
|
357
|
+
if (CLEAR_SENTINELS.has(bodyEngine.fontSize)) {
|
|
358
|
+
delete engine.fontSize;
|
|
359
|
+
} else {
|
|
360
|
+
const value = parseEnum(bodyEngine.fontSize, 'engine.fontSize', FONT_SIZES, errors);
|
|
361
|
+
if (value !== undefined) engine.fontSize = value;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (hasOwn(bodyEngine, 'maxBudgetUsd')) {
|
|
365
|
+
if (CLEAR_SENTINELS.has(bodyEngine.maxBudgetUsd)) {
|
|
366
|
+
delete engine.maxBudgetUsd;
|
|
367
|
+
} else {
|
|
368
|
+
const value = parseFiniteNumber(bodyEngine.maxBudgetUsd, 'engine.maxBudgetUsd', errors, {
|
|
369
|
+
min: 0,
|
|
370
|
+
integer: false,
|
|
371
|
+
});
|
|
372
|
+
if (value !== undefined) engine.maxBudgetUsd = value;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (hasOwn(bodyEngine, 'ccEffort')) {
|
|
376
|
+
if (CLEAR_SENTINELS.has(bodyEngine.ccEffort)) {
|
|
377
|
+
engine.ccEffort = null;
|
|
378
|
+
} else {
|
|
379
|
+
const value = parseEnum(bodyEngine.ccEffort, 'engine.ccEffort', CC_EFFORT_LEVELS, errors);
|
|
380
|
+
if (value !== undefined) engine.ccEffort = value;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (hasOwn(bodyEngine, 'evalMaxCost')) {
|
|
384
|
+
if (CLEAR_SENTINELS.has(bodyEngine.evalMaxCost)) {
|
|
385
|
+
engine.evalMaxCost = null;
|
|
386
|
+
} else {
|
|
387
|
+
const value = parseFiniteNumber(bodyEngine.evalMaxCost, 'engine.evalMaxCost', errors, {
|
|
388
|
+
min: 0,
|
|
389
|
+
integer: false,
|
|
390
|
+
});
|
|
391
|
+
if (value !== undefined) engine.evalMaxCost = value;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (hasOwn(bodyEngine, 'maxTurnsByType')) {
|
|
395
|
+
const value = bodyEngine.maxTurnsByType;
|
|
396
|
+
if (validateObject(value, 'engine.maxTurnsByType', errors)) {
|
|
397
|
+
const normalized = {};
|
|
398
|
+
for (const [type, raw] of Object.entries(value)) {
|
|
399
|
+
const path = `engine.maxTurnsByType.${type}`;
|
|
400
|
+
if (!MAX_TURNS_TYPES.has(type)) {
|
|
401
|
+
errors.add(path, 'invalid-enum', `${path} is not a registered work type`, raw, {
|
|
402
|
+
allowedValues: [...MAX_TURNS_TYPES],
|
|
403
|
+
});
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const parsed = parseFiniteNumber(raw, path, errors, { min: 5, max: 500 });
|
|
407
|
+
if (parsed !== undefined) normalized[type] = parsed;
|
|
408
|
+
}
|
|
409
|
+
engine.maxTurnsByType = normalized;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (hasOwn(bodyEngine, 'ignoredCommentAuthors')) {
|
|
413
|
+
const raw = bodyEngine.ignoredCommentAuthors;
|
|
414
|
+
let values;
|
|
415
|
+
if (typeof raw === 'string') values = raw.split(',');
|
|
416
|
+
else if (Array.isArray(raw)) values = raw;
|
|
417
|
+
else {
|
|
418
|
+
errors.add('engine.ignoredCommentAuthors', 'invalid-array',
|
|
419
|
+
'engine.ignoredCommentAuthors must be a comma-separated string or string array', raw, {
|
|
420
|
+
expected: 'string or string array',
|
|
421
|
+
});
|
|
422
|
+
values = null;
|
|
423
|
+
}
|
|
424
|
+
if (values) {
|
|
425
|
+
const normalized = [];
|
|
426
|
+
values.forEach((entry, index) => {
|
|
427
|
+
if (typeof entry !== 'string') {
|
|
428
|
+
errors.add(`engine.ignoredCommentAuthors[${index}]`, 'invalid-string',
|
|
429
|
+
'ignored comment authors must be strings', entry, { expected: 'string' });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const author = entry.trim().toLowerCase();
|
|
433
|
+
if (author) normalized.push(author);
|
|
434
|
+
});
|
|
435
|
+
engine.ignoredCommentAuthors = [...new Set(normalized)];
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function applyClaudeSettings(candidate, bodyClaude, errors) {
|
|
441
|
+
if (!validateObject(bodyClaude, 'claude', errors)) return;
|
|
442
|
+
rejectUnknownFields(bodyClaude, new Set(['allowedTools']), 'claude', errors);
|
|
443
|
+
if (!isPlainObject(candidate.claude)) candidate.claude = {};
|
|
444
|
+
if (hasOwn(bodyClaude, 'allowedTools')) {
|
|
445
|
+
const value = parseString(bodyClaude.allowedTools, 'claude.allowedTools', errors, { trim: false });
|
|
446
|
+
if (value !== undefined) candidate.claude.allowedTools = value;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function normalizeExpertise(value, path, errors) {
|
|
451
|
+
const entries = Array.isArray(value) ? value : (typeof value === 'string' ? value.split(',') : null);
|
|
452
|
+
if (!entries) {
|
|
453
|
+
errors.add(path, 'invalid-array', `${path} must be a comma-separated string or string array`, value, {
|
|
454
|
+
expected: 'string or string array',
|
|
455
|
+
});
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
const normalized = [];
|
|
459
|
+
entries.forEach((entry, index) => {
|
|
460
|
+
if (typeof entry !== 'string') {
|
|
461
|
+
errors.add(`${path}[${index}]`, 'invalid-string', `${path} entries must be strings`, entry, {
|
|
462
|
+
expected: 'string',
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const text = entry.trim();
|
|
467
|
+
if (text) normalized.push(text);
|
|
468
|
+
});
|
|
469
|
+
return normalized;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function normalizeWorkspaceManifest(value, path, errors) {
|
|
473
|
+
if (CLEAR_SENTINELS.has(value)) return undefined;
|
|
474
|
+
if (!validateObject(value, path, errors)) return undefined;
|
|
475
|
+
rejectUnknownFields(value, WORKSPACE_MANIFEST_FIELDS, path, errors);
|
|
476
|
+
const validation = shared.validateWorkspaceManifest(value);
|
|
477
|
+
if (!validation.ok) {
|
|
478
|
+
errors.add(path, 'invalid-workspace-manifest', validation.errors.join('; '), value, {
|
|
479
|
+
expected: 'workspace manifest',
|
|
480
|
+
});
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
return cloneJson(value);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function applyAgentSettings(candidate, bodyAgents, errors) {
|
|
487
|
+
if (!validateObject(bodyAgents, 'agents', errors)) return;
|
|
488
|
+
if (!isPlainObject(candidate.agents)) candidate.agents = {};
|
|
489
|
+
for (const [id, updates] of Object.entries(bodyAgents)) {
|
|
490
|
+
const path = `agents.${id}`;
|
|
491
|
+
if (!hasOwn(candidate.agents, id)) {
|
|
492
|
+
errors.add(path, 'unknown-agent', `${path} is not a configured agent`, updates, {
|
|
493
|
+
allowedValues: Object.keys(candidate.agents),
|
|
494
|
+
});
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (!validateObject(updates, path, errors)) continue;
|
|
498
|
+
rejectUnknownFields(updates, AGENT_FIELDS, path, errors);
|
|
499
|
+
if (!isPlainObject(candidate.agents[id])) candidate.agents[id] = {};
|
|
500
|
+
const agent = candidate.agents[id];
|
|
501
|
+
|
|
502
|
+
if (hasOwn(updates, 'role')) {
|
|
503
|
+
const value = parseString(updates.role, `${path}.role`, errors, { trim: false });
|
|
504
|
+
if (value !== undefined) agent.role = value;
|
|
505
|
+
}
|
|
506
|
+
const expertiseKey = hasOwn(updates, 'expertise') ? 'expertise' : (hasOwn(updates, 'skills') ? 'skills' : null);
|
|
507
|
+
if (expertiseKey) {
|
|
508
|
+
const value = normalizeExpertise(updates[expertiseKey], `${path}.${expertiseKey}`, errors);
|
|
509
|
+
if (value !== undefined) {
|
|
510
|
+
agent.expertise = value;
|
|
511
|
+
delete agent.skills;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
for (const key of ['monthlyBudgetUsd', 'maxBudgetUsd']) {
|
|
515
|
+
if (!hasOwn(updates, key)) continue;
|
|
516
|
+
if (CLEAR_SENTINELS.has(updates[key])) {
|
|
517
|
+
delete agent[key];
|
|
518
|
+
} else {
|
|
519
|
+
const value = parseFiniteNumber(updates[key], `${path}.${key}`, errors, {
|
|
520
|
+
min: 0,
|
|
521
|
+
integer: false,
|
|
522
|
+
});
|
|
523
|
+
if (value !== undefined) agent[key] = value;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (hasOwn(updates, 'cli')) {
|
|
527
|
+
const value = parseRuntimeName(updates.cli, `${path}.cli`, errors);
|
|
528
|
+
if (CLEAR_SENTINELS.has(updates.cli)) delete agent.cli;
|
|
529
|
+
else if (value !== undefined) agent.cli = value;
|
|
530
|
+
}
|
|
531
|
+
if (hasOwn(updates, 'model')) {
|
|
532
|
+
const value = parseClearableString(updates.model, `${path}.model`, errors);
|
|
533
|
+
if (CLEAR_SENTINELS.has(updates.model)) delete agent.model;
|
|
534
|
+
else if (value !== undefined) agent.model = value;
|
|
535
|
+
}
|
|
536
|
+
if (hasOwn(updates, 'bareMode')) {
|
|
537
|
+
if (CLEAR_SENTINELS.has(updates.bareMode)) {
|
|
538
|
+
delete agent.bareMode;
|
|
539
|
+
} else {
|
|
540
|
+
const value = parseBoolean(updates.bareMode, `${path}.bareMode`, errors);
|
|
541
|
+
if (value !== undefined) agent.bareMode = value;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (hasOwn(updates, 'workspace_manifest')) {
|
|
545
|
+
const value = normalizeWorkspaceManifest(updates.workspace_manifest, `${path}.workspace_manifest`, errors);
|
|
546
|
+
if (CLEAR_SENTINELS.has(updates.workspace_manifest)) delete agent.workspace_manifest;
|
|
547
|
+
else if (value !== undefined) agent.workspace_manifest = value;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function applyWorkSourceSettings(project, value, path, errors) {
|
|
553
|
+
if (!validateObject(value, path, errors)) return;
|
|
554
|
+
rejectUnknownFields(value, WORK_SOURCE_NAMES, path, errors);
|
|
555
|
+
if (!isPlainObject(project.workSources)) project.workSources = {};
|
|
556
|
+
for (const sourceName of WORK_SOURCE_NAMES) {
|
|
557
|
+
if (!hasOwn(value, sourceName)) continue;
|
|
558
|
+
const sourcePath = `${path}.${sourceName}`;
|
|
559
|
+
const update = value[sourceName];
|
|
560
|
+
if (!validateObject(update, sourcePath, errors)) continue;
|
|
561
|
+
rejectUnknownFields(update, WORK_SOURCE_FIELDS, sourcePath, errors);
|
|
562
|
+
if (!isPlainObject(project.workSources[sourceName])) {
|
|
563
|
+
project.workSources[sourceName] = {
|
|
564
|
+
enabled: true,
|
|
565
|
+
cooldownMinutes: sourceName === 'pullRequests' ? 30 : 0,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
if (hasOwn(update, 'enabled')) {
|
|
569
|
+
const enabled = parseBoolean(update.enabled, `${sourcePath}.enabled`, errors);
|
|
570
|
+
if (enabled !== undefined) project.workSources[sourceName].enabled = enabled;
|
|
571
|
+
}
|
|
572
|
+
if (hasOwn(update, 'cooldownMinutes')) {
|
|
573
|
+
const cooldown = parseFiniteNumber(update.cooldownMinutes, `${sourcePath}.cooldownMinutes`, errors, {
|
|
574
|
+
min: 0,
|
|
575
|
+
});
|
|
576
|
+
if (cooldown !== undefined) project.workSources[sourceName].cooldownMinutes = cooldown;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function applyProjectSettings(candidate, bodyProjects, errors) {
|
|
582
|
+
if (!Array.isArray(bodyProjects)) {
|
|
583
|
+
errors.add('projects', 'invalid-array', 'projects must be an array', bodyProjects, { expected: 'array' });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (!Array.isArray(candidate.projects)) candidate.projects = [];
|
|
587
|
+
const seen = new Set();
|
|
588
|
+
bodyProjects.forEach((update, index) => {
|
|
589
|
+
const path = `projects[${index}]`;
|
|
590
|
+
if (!validateObject(update, path, errors)) return;
|
|
591
|
+
rejectUnknownFields(update, PROJECT_FIELDS, path, errors);
|
|
592
|
+
const name = parseString(update.name, `${path}.name`, errors, { allowEmpty: false });
|
|
593
|
+
if (name === undefined) return;
|
|
594
|
+
const normalizedName = name.toLowerCase();
|
|
595
|
+
if (seen.has(normalizedName)) {
|
|
596
|
+
errors.add(`${path}.name`, 'duplicate-project', `Project "${name}" appears more than once`, name);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
seen.add(normalizedName);
|
|
600
|
+
const project = shared.findProjectByName(candidate.projects, name);
|
|
601
|
+
if (!project) {
|
|
602
|
+
errors.add(`${path}.name`, 'unknown-project', `Project "${name}" is not configured`, name, {
|
|
603
|
+
allowedValues: candidate.projects.map(entry => entry && entry.name).filter(Boolean),
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (hasOwn(update, 'workSources')) {
|
|
609
|
+
applyWorkSourceSettings(project, update.workSources, `${path}.workSources`, errors);
|
|
610
|
+
}
|
|
611
|
+
if (hasOwn(update, 'mainBranch')) {
|
|
612
|
+
const value = parseClearableString(update.mainBranch, `${path}.mainBranch`, errors);
|
|
613
|
+
if (CLEAR_SENTINELS.has(update.mainBranch)) delete project.mainBranch;
|
|
614
|
+
else if (value !== undefined) project.mainBranch = value;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const hasCheckoutMode = hasOwn(update, 'checkoutMode');
|
|
618
|
+
const hasLegacyMode = hasOwn(update, 'worktreeMode');
|
|
619
|
+
if (hasCheckoutMode && hasLegacyMode) {
|
|
620
|
+
errors.add(`${path}.checkoutMode`, 'conflicting-alias',
|
|
621
|
+
'Specify checkoutMode or the deprecated worktreeMode alias, not both', update.checkoutMode);
|
|
622
|
+
} else if (hasCheckoutMode || hasLegacyMode) {
|
|
623
|
+
const key = hasCheckoutMode ? 'checkoutMode' : 'worktreeMode';
|
|
624
|
+
const raw = update[key];
|
|
625
|
+
const value = errors.capture(`${path}.${key}`, raw, () => shared.validateCheckoutMode(raw));
|
|
626
|
+
if (value === undefined && CLEAR_SENTINELS.has(raw)) delete project.checkoutMode;
|
|
627
|
+
else if (value !== undefined) project.checkoutMode = value;
|
|
628
|
+
delete project.worktreeMode;
|
|
629
|
+
if (project.checkoutMode !== 'live') delete project.liveValidation;
|
|
630
|
+
} else if (hasOwn(project, 'worktreeMode')) {
|
|
631
|
+
project.checkoutMode = shared.resolveCheckoutMode(project);
|
|
632
|
+
delete project.worktreeMode;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (hasOwn(update, 'liveValidation')) {
|
|
636
|
+
const raw = update.liveValidation;
|
|
637
|
+
if (CLEAR_SENTINELS.has(raw)) {
|
|
638
|
+
delete project.liveValidation;
|
|
639
|
+
} else {
|
|
640
|
+
const value = errors.capture(`${path}.liveValidation`, raw, () => (
|
|
641
|
+
shared.validateLiveValidation(raw, { checkoutMode: shared.resolveCheckoutMode(project) })
|
|
642
|
+
));
|
|
643
|
+
if (value !== undefined) project.liveValidation = value;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (hasOwn(update, 'liveCheckoutAutoStash')) {
|
|
647
|
+
const raw = update.liveCheckoutAutoStash;
|
|
648
|
+
if (CLEAR_SENTINELS.has(raw)) {
|
|
649
|
+
delete project.liveCheckoutAutoStash;
|
|
650
|
+
} else {
|
|
651
|
+
const value = parseBoolean(raw, `${path}.liveCheckoutAutoStash`, errors);
|
|
652
|
+
if (value !== undefined) project.liveCheckoutAutoStash = value;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// W-mrxv648b — per-project "Create PRs as draft" override. null / '' /
|
|
656
|
+
// undefined clears the override (engine fleet-wide `engine.createPrsAsDraft`
|
|
657
|
+
// applies); an explicit boolean pins the per-project decision.
|
|
658
|
+
if (hasOwn(update, 'createPrsAsDraft')) {
|
|
659
|
+
const raw = update.createPrsAsDraft;
|
|
660
|
+
if (CLEAR_SENTINELS.has(raw) || raw === undefined) {
|
|
661
|
+
delete project.createPrsAsDraft;
|
|
662
|
+
} else {
|
|
663
|
+
const value = parseBoolean(raw, `${path}.createPrsAsDraft`, errors);
|
|
664
|
+
if (value !== undefined) project.createPrsAsDraft = value;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (hasOwn(update, 'additionalRepoScopes')) {
|
|
668
|
+
const raw = update.additionalRepoScopes;
|
|
669
|
+
if (CLEAR_SENTINELS.has(raw) || (Array.isArray(raw) && raw.length === 0)) {
|
|
670
|
+
delete project.additionalRepoScopes;
|
|
671
|
+
} else {
|
|
672
|
+
const value = errors.capture(`${path}.additionalRepoScopes`, raw, () => (
|
|
673
|
+
shared.validateAdditionalRepoScopes(raw)
|
|
674
|
+
));
|
|
675
|
+
if (value !== undefined) project.additionalRepoScopes = value;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function applyFeatureSettings(candidate, bodyFeatures, errors) {
|
|
682
|
+
if (!validateObject(bodyFeatures, 'features', errors)) return;
|
|
683
|
+
if (!isPlainObject(candidate.features)) candidate.features = {};
|
|
684
|
+
for (const [id, raw] of Object.entries(bodyFeatures)) {
|
|
685
|
+
const path = `features.${id}`;
|
|
686
|
+
if (!features.hasFeature(id)) {
|
|
687
|
+
errors.add(path, 'unknown-feature', `${path} is not registered`, raw, {
|
|
688
|
+
allowedValues: Object.keys(features.FEATURES),
|
|
689
|
+
});
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
const value = parseBoolean(raw, path, errors);
|
|
693
|
+
if (value !== undefined) candidate.features[id] = value;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function resolveModelPair(config, agent) {
|
|
698
|
+
const engine = isPlainObject(config.engine) ? config.engine : {};
|
|
699
|
+
return {
|
|
700
|
+
runtime: shared.resolveAgentCli(agent, engine),
|
|
701
|
+
model: shared.resolveAgentModel(agent, engine),
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function resolveCcModelPair(config) {
|
|
706
|
+
const engine = isPlainObject(config.engine) ? config.engine : {};
|
|
707
|
+
return {
|
|
708
|
+
runtime: shared.resolveCcCli(engine),
|
|
709
|
+
model: shared.resolveCcModel(engine),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function collectChangedModelPairs(base, candidate) {
|
|
714
|
+
const checks = new Map();
|
|
715
|
+
const add = (before, after, path) => {
|
|
716
|
+
if (!after.model || (before.runtime === after.runtime && before.model === after.model)) return;
|
|
717
|
+
const key = `${after.runtime}\u0000${after.model}`;
|
|
718
|
+
if (!checks.has(key)) checks.set(key, { ...after, path });
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
add(resolveModelPair(base, null), resolveModelPair(candidate, null), 'engine.defaultModel');
|
|
722
|
+
add(resolveCcModelPair(base), resolveCcModelPair(candidate), 'engine.ccModel');
|
|
723
|
+
|
|
724
|
+
const baseAgents = isPlainObject(base.agents) ? base.agents : {};
|
|
725
|
+
const candidateAgents = isPlainObject(candidate.agents) ? candidate.agents : {};
|
|
726
|
+
for (const [id, agent] of Object.entries(candidateAgents)) {
|
|
727
|
+
const before = resolveModelPair(base, baseAgents[id]);
|
|
728
|
+
const after = resolveModelPair(candidate, agent);
|
|
729
|
+
const path = agent && agent.model ? `agents.${id}.model` : 'engine.defaultModel';
|
|
730
|
+
add(before, after, path);
|
|
731
|
+
}
|
|
732
|
+
return [...checks.values()];
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function discoverModels(runtimeName, catalogCache, candidate) {
|
|
736
|
+
if (catalogCache.has(runtimeName)) return catalogCache.get(runtimeName);
|
|
737
|
+
const adapter = runtimes.resolveRuntime(runtimeName);
|
|
738
|
+
if (adapter.capabilities && adapter.capabilities.modelDiscovery !== true) {
|
|
739
|
+
catalogCache.set(runtimeName, null);
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
let result;
|
|
743
|
+
try {
|
|
744
|
+
result = await modelDiscovery.getRuntimeModels(runtimeName, {
|
|
745
|
+
config: candidate,
|
|
746
|
+
force: true,
|
|
747
|
+
persist: false,
|
|
748
|
+
throwOnUnavailable: true,
|
|
749
|
+
});
|
|
750
|
+
} catch (error) {
|
|
751
|
+
throw new SettingsDiscoveryError(runtimeName, error);
|
|
752
|
+
}
|
|
753
|
+
const models = result.models;
|
|
754
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
755
|
+
throw new SettingsDiscoveryError(runtimeName, new Error('Runtime returned no model catalog'));
|
|
756
|
+
}
|
|
757
|
+
const ids = new Set();
|
|
758
|
+
for (const model of models) {
|
|
759
|
+
if (model && typeof model.id === 'string' && model.id) ids.add(model.id);
|
|
760
|
+
if (model && typeof model.name === 'string' && model.name) ids.add(model.name);
|
|
761
|
+
}
|
|
762
|
+
if (ids.size === 0) {
|
|
763
|
+
throw new SettingsDiscoveryError(runtimeName, new Error('Runtime returned a malformed model catalog'));
|
|
764
|
+
}
|
|
765
|
+
catalogCache.set(runtimeName, ids);
|
|
766
|
+
return ids;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function validateModelPairs(base, candidate, errors) {
|
|
770
|
+
if (candidate.engine && candidate.engine.disableModelDiscovery === true) return;
|
|
771
|
+
const checks = collectChangedModelPairs(base, candidate);
|
|
772
|
+
if (checks.length === 0) return;
|
|
773
|
+
const catalogCache = new Map();
|
|
774
|
+
const runtimeNames = runtimes.listRuntimes();
|
|
775
|
+
|
|
776
|
+
for (const check of checks) {
|
|
777
|
+
let adapter;
|
|
778
|
+
try {
|
|
779
|
+
adapter = runtimes.resolveRuntime(check.runtime);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
errors.add(check.path, 'unknown-runtime', error.message, check.runtime, {
|
|
782
|
+
allowedValues: runtimeNames,
|
|
783
|
+
});
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
let normalizedModel = check.model;
|
|
787
|
+
if (typeof adapter.resolveModel === 'function') {
|
|
788
|
+
try {
|
|
789
|
+
normalizedModel = adapter.resolveModel(check.model) || check.model;
|
|
790
|
+
} catch (error) {
|
|
791
|
+
errors.add(check.path, 'invalid-runtime-model', error.message, check.model, {
|
|
792
|
+
expected: `model accepted by ${check.runtime}`,
|
|
793
|
+
});
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// A runtime's discovered catalog is authoritative — and therefore worth an
|
|
798
|
+
// enforced membership check — only when the runtime can discover models AND
|
|
799
|
+
// has not explicitly opted out of strict catalog enforcement. Discovery-
|
|
800
|
+
// capable runtimes default to strict (Copilot leaves `strictModelCatalog`
|
|
801
|
+
// unset but its published catalog is authoritative), so an unknown model is
|
|
802
|
+
// rejected. Runtimes that set `strictModelCatalog: false` (e.g. Codex, which
|
|
803
|
+
// must accept staged/custom model IDs not yet in its bundled catalog) or
|
|
804
|
+
// that cannot discover at all accept any model the adapter's own
|
|
805
|
+
// `resolveModel()` already validated. We never infer ownership from another
|
|
806
|
+
// runtime's overlapping catalog: catalogs overlap, so membership elsewhere
|
|
807
|
+
// is not exclusive ownership, and requiring an unrelated runtime's discovery
|
|
808
|
+
// would surface spurious rejections or 503s for a model that is valid here.
|
|
809
|
+
const caps = adapter.capabilities || {};
|
|
810
|
+
const catalogIsAuthoritative = caps.modelDiscovery === true && caps.strictModelCatalog !== false;
|
|
811
|
+
if (!catalogIsAuthoritative) continue;
|
|
812
|
+
|
|
813
|
+
const targetModels = await discoverModels(check.runtime, catalogCache, candidate);
|
|
814
|
+
if (targetModels && (targetModels.has(check.model) || targetModels.has(normalizedModel))) continue;
|
|
815
|
+
errors.add(check.path, 'incompatible-runtime-model',
|
|
816
|
+
`Model "${check.model}" is not available for runtime "${check.runtime}"`, check.model, {
|
|
817
|
+
expected: `model supported by ${check.runtime}`,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function validateCcEffortCapability(candidate, body, errors) {
|
|
823
|
+
if (!body.engine || !isPlainObject(body.engine)) return;
|
|
824
|
+
if (!['ccEffort', 'ccCli', 'defaultCli'].some(key => hasOwn(body.engine, key))) return;
|
|
825
|
+
const effort = candidate.engine && candidate.engine.ccEffort;
|
|
826
|
+
if (!effort) return;
|
|
827
|
+
const runtimeName = shared.resolveCcCli(candidate.engine);
|
|
828
|
+
let adapter;
|
|
829
|
+
try {
|
|
830
|
+
adapter = runtimes.resolveRuntime(runtimeName);
|
|
831
|
+
} catch {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (!adapter.capabilities || adapter.capabilities.effortLevels !== true) {
|
|
835
|
+
errors.add('engine.ccEffort', 'unsupported-runtime-capability',
|
|
836
|
+
`Runtime "${runtimeName}" does not support effort levels`, effort, {
|
|
837
|
+
expected: 'effort-capable runtime',
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function buildSettingsCandidate(currentConfig, body) {
|
|
843
|
+
const errors = new SettingsErrorCollector();
|
|
844
|
+
if (!validateObject(body, 'settings', errors)) errors.throwIfAny();
|
|
845
|
+
rejectUnknownFields(body, SETTINGS_TOP_LEVEL_FIELDS, 'settings', errors);
|
|
846
|
+
const base = ensureCurrentConfig(currentConfig);
|
|
847
|
+
const candidate = cloneJson(base);
|
|
848
|
+
|
|
849
|
+
if (hasOwn(body, 'engine')) applyEngineSettings(candidate, body.engine, errors);
|
|
850
|
+
if (hasOwn(body, 'claude')) applyClaudeSettings(candidate, body.claude, errors);
|
|
851
|
+
if (hasOwn(body, 'agents')) applyAgentSettings(candidate, body.agents, errors);
|
|
852
|
+
if (hasOwn(body, 'projects')) applyProjectSettings(candidate, body.projects, errors);
|
|
853
|
+
if (hasOwn(body, 'features')) applyFeatureSettings(candidate, body.features, errors);
|
|
854
|
+
validateCcEffortCapability(candidate, body, errors);
|
|
855
|
+
|
|
856
|
+
// Do not start catalog I/O when ordinary validation has already rejected the
|
|
857
|
+
// request. This preserves both runtime caches and the all-or-nothing contract.
|
|
858
|
+
errors.throwIfAny();
|
|
859
|
+
await validateModelPairs(base, candidate, errors);
|
|
860
|
+
errors.throwIfAny();
|
|
861
|
+
|
|
862
|
+
shared.pruneDefaultClaudeConfig(candidate, { trackStrip: false });
|
|
863
|
+
return {
|
|
864
|
+
base,
|
|
865
|
+
candidate,
|
|
866
|
+
sideEffects: {
|
|
867
|
+
resetOperatorIdentity: (base.engine && base.engine.operatorLogin)
|
|
868
|
+
!== (candidate.engine && candidate.engine.operatorLogin),
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function buildSettingsResetCandidate(currentConfig) {
|
|
874
|
+
const base = ensureCurrentConfig(currentConfig);
|
|
875
|
+
const candidate = cloneJson(base);
|
|
876
|
+
candidate.engine = cloneJson(shared.ENGINE_DEFAULTS);
|
|
877
|
+
candidate.agents = cloneJson(shared.DEFAULT_AGENTS);
|
|
878
|
+
delete candidate.claude;
|
|
879
|
+
return {
|
|
880
|
+
base,
|
|
881
|
+
candidate,
|
|
882
|
+
sideEffects: {
|
|
883
|
+
resetOperatorIdentity: (base.engine && base.engine.operatorLogin)
|
|
884
|
+
!== (candidate.engine && candidate.engine.operatorLogin),
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function buildRoutingCandidate(body) {
|
|
890
|
+
const errors = new SettingsErrorCollector();
|
|
891
|
+
if (!validateObject(body, 'routing', errors)) errors.throwIfAny();
|
|
892
|
+
rejectUnknownFields(body, new Set(['content']), 'routing', errors);
|
|
893
|
+
if (!hasOwn(body, 'content')) {
|
|
894
|
+
errors.add('content', 'required-field', 'content is required', undefined, { expected: 'non-empty string' });
|
|
895
|
+
} else {
|
|
896
|
+
parseString(body.content, 'content', errors, { allowEmpty: false, trim: false });
|
|
897
|
+
}
|
|
898
|
+
errors.throwIfAny();
|
|
899
|
+
return body.content;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
module.exports = {
|
|
903
|
+
SettingsDiscoveryError,
|
|
904
|
+
buildSettingsCandidate,
|
|
905
|
+
buildSettingsResetCandidate,
|
|
906
|
+
buildRoutingCandidate,
|
|
907
|
+
ENGINE_NUMERIC_RANGES,
|
|
908
|
+
MAX_SETTINGS_ERRORS,
|
|
909
|
+
};
|