leerness 1.36.183 → 1.36.185
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/CHANGELOG.md +8 -0
- package/LICENSE +21 -21
- package/README.ko.md +15 -8
- package/README.md +25 -7
- package/SECURITY.md +66 -66
- package/bin/leerness.js +1184 -206
- package/docs/role-agent-routing-v2.md +411 -0
- package/lib/agents.js +1236 -100
- package/lib/diagnostics.js +184 -184
- package/lib/file-leases.js +845 -0
- package/lib/health.js +350 -350
- package/lib/io.js +152 -1
- package/lib/mcp-tools.js +121 -104
- package/lib/pure-utils.js +52 -4
- package/lib/referee.js +270 -270
- package/lib/review-request.js +339 -339
- package/lib/role-agent-schema.js +1179 -0
- package/lib/role-catalog.js +31 -1
- package/lib/role-fallback.js +1611 -0
- package/lib/role-store.js +730 -0
- package/lib/routing.js +109 -21
- package/lib/session-close.js +49 -1
- package/package.json +10 -5
- package/scripts/command-flags-probe.js +40 -3
- package/scripts/e2e.js +121 -44
- package/scripts/file-lease-probe.js +1139 -0
- package/scripts/i18n-next-cluster-probe.js +436 -5
- package/scripts/installed-cleanroom-probe.js +39 -12
- package/scripts/lint.js +73 -73
- package/scripts/provider-capacity-probe.js +425 -0
- package/scripts/release-runtime-probe.js +16 -1
- package/scripts/role-agent-schema-probe.js +834 -0
- package/scripts/role-fallback-probe.js +2010 -0
- package/scripts/role-store-loader-probe.js +882 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
// lib/role-agent-schema.js — pure Role / Agent / Routing v2 contracts.
|
|
2
|
+
//
|
|
3
|
+
// This module is intentionally side-effect-free:
|
|
4
|
+
// - no filesystem or environment access
|
|
5
|
+
// - no process spawning
|
|
6
|
+
// - no clock access
|
|
7
|
+
// - no implicit migration or dispatch
|
|
8
|
+
//
|
|
9
|
+
// It validates strict v2 documents and provides a deterministic, reversible
|
|
10
|
+
// projection for the legacy .leerness/agent-roles.json assignment store.
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { stripDefaultIgnorables, isValidModelIdentifier } = require('./pure-utils');
|
|
14
|
+
|
|
15
|
+
const SCHEMA_VERSION = 2;
|
|
16
|
+
const LEGACY_SCHEMA_VERSION = 1;
|
|
17
|
+
const LEGACY_READ_SCHEMA_VERSIONS = Object.freeze([1, 2]);
|
|
18
|
+
|
|
19
|
+
const STORE_FILES = Object.freeze({
|
|
20
|
+
roleDefinitions: 'role-definitions.json',
|
|
21
|
+
agentInstances: 'agent-instances.json',
|
|
22
|
+
routingPolicy: 'routing-policy.json',
|
|
23
|
+
legacyAssignments: 'agent-roles.json',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const PERMISSION_TIERS = Object.freeze([
|
|
27
|
+
'read-only',
|
|
28
|
+
'safe-write',
|
|
29
|
+
'project-write',
|
|
30
|
+
'shell-read',
|
|
31
|
+
'shell-write',
|
|
32
|
+
'git-write',
|
|
33
|
+
'network',
|
|
34
|
+
'publish',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Persisted compatibility IDs remain canonical in v2. New roles are additive.
|
|
38
|
+
const BUILTIN_ROLE_IDS = Object.freeze([
|
|
39
|
+
'commander',
|
|
40
|
+
'coder',
|
|
41
|
+
'dispatcher',
|
|
42
|
+
'reviewer',
|
|
43
|
+
'architect',
|
|
44
|
+
'designer',
|
|
45
|
+
'debugger',
|
|
46
|
+
'router',
|
|
47
|
+
'tester',
|
|
48
|
+
'security',
|
|
49
|
+
'release',
|
|
50
|
+
'observer',
|
|
51
|
+
'director',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// Project definitions may specialize built-ins, but cannot grant them more
|
|
55
|
+
// authority than the product role contract. Missing fields inherit these
|
|
56
|
+
// ceilings; explicit fields may only narrow them.
|
|
57
|
+
const BUILTIN_ROLE_PERMISSION_CEILINGS = Object.freeze({
|
|
58
|
+
commander: Object.freeze({ requiredTier: 'project-write', codeWrite: false, approve: true, release: false }),
|
|
59
|
+
coder: Object.freeze({ requiredTier: 'project-write', codeWrite: true, approve: false, release: false }),
|
|
60
|
+
dispatcher: Object.freeze({ requiredTier: 'safe-write', codeWrite: false, approve: false, release: false }),
|
|
61
|
+
reviewer: Object.freeze({ requiredTier: 'shell-read', codeWrite: false, approve: true, release: false }),
|
|
62
|
+
architect: Object.freeze({ requiredTier: 'read-only', codeWrite: false, approve: true, release: false }),
|
|
63
|
+
designer: Object.freeze({ requiredTier: 'project-write', codeWrite: true, approve: false, release: false }),
|
|
64
|
+
debugger: Object.freeze({ requiredTier: 'project-write', codeWrite: true, approve: false, release: false }),
|
|
65
|
+
router: Object.freeze({ requiredTier: 'read-only', codeWrite: false, approve: false, release: false }),
|
|
66
|
+
tester: Object.freeze({ requiredTier: 'shell-read', codeWrite: false, approve: false, release: false }),
|
|
67
|
+
security: Object.freeze({ requiredTier: 'shell-read', codeWrite: false, approve: true, release: false }),
|
|
68
|
+
release: Object.freeze({ requiredTier: 'publish', codeWrite: false, approve: false, release: true }),
|
|
69
|
+
observer: Object.freeze({ requiredTier: 'read-only', codeWrite: false, approve: false, release: false }),
|
|
70
|
+
director: Object.freeze({ requiredTier: 'read-only', codeWrite: false, approve: true, release: false }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Only these seven identifiers were canonical persisted roles before v2.
|
|
74
|
+
// New input aliases and additive v2 built-ins must not reinterpret an old
|
|
75
|
+
// user-forced custom key merely because its spelling now has a new meaning.
|
|
76
|
+
const LEGACY_PERSISTED_ROLE_IDS = Object.freeze([
|
|
77
|
+
'commander',
|
|
78
|
+
'coder',
|
|
79
|
+
'dispatcher',
|
|
80
|
+
'reviewer',
|
|
81
|
+
'architect',
|
|
82
|
+
'designer',
|
|
83
|
+
'debugger',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const ROLE_INPUT_ALIASES = Object.freeze({
|
|
87
|
+
orchestrator: 'commander',
|
|
88
|
+
commander: 'commander',
|
|
89
|
+
'지휘': 'commander',
|
|
90
|
+
'지휘관': 'commander',
|
|
91
|
+
'사령관': 'commander',
|
|
92
|
+
implementer: 'coder',
|
|
93
|
+
worker: 'coder',
|
|
94
|
+
coder: 'coder',
|
|
95
|
+
'코딩': 'coder',
|
|
96
|
+
'코더': 'coder',
|
|
97
|
+
'코딩담당': 'coder',
|
|
98
|
+
reviewer: 'reviewer',
|
|
99
|
+
'검수': 'reviewer',
|
|
100
|
+
'검수자': 'reviewer',
|
|
101
|
+
'리뷰': 'reviewer',
|
|
102
|
+
'리뷰어': 'reviewer',
|
|
103
|
+
architect: 'architect',
|
|
104
|
+
'설계': 'architect',
|
|
105
|
+
'설계담당': 'architect',
|
|
106
|
+
'아키텍트': 'architect',
|
|
107
|
+
designer: 'designer',
|
|
108
|
+
'디자인': 'designer',
|
|
109
|
+
'디자인담당': 'designer',
|
|
110
|
+
debugger: 'debugger',
|
|
111
|
+
'디버그': 'debugger',
|
|
112
|
+
'디버거': 'debugger',
|
|
113
|
+
'디버깅': 'debugger',
|
|
114
|
+
dispatcher: 'dispatcher',
|
|
115
|
+
'assignment-dispatcher': 'dispatcher',
|
|
116
|
+
'분배': 'dispatcher',
|
|
117
|
+
'분배담당': 'dispatcher',
|
|
118
|
+
'오케스트레이터': 'dispatcher',
|
|
119
|
+
router: 'router',
|
|
120
|
+
tester: 'tester',
|
|
121
|
+
qa: 'tester',
|
|
122
|
+
security: 'security',
|
|
123
|
+
release: 'release',
|
|
124
|
+
observer: 'observer',
|
|
125
|
+
director: 'director',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const HIGH_RISK_REQUIRED_ROLES = Object.freeze([
|
|
129
|
+
'architect',
|
|
130
|
+
'commander',
|
|
131
|
+
'coder',
|
|
132
|
+
'tester',
|
|
133
|
+
'reviewer',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const NAMED_PIPELINE_ROLE_ORDER = Object.freeze({
|
|
137
|
+
tiny: Object.freeze(['coder', 'tester']),
|
|
138
|
+
normal: Object.freeze(['commander', 'coder', 'tester', 'reviewer']),
|
|
139
|
+
'high-risk': HIGH_RISK_REQUIRED_ROLES,
|
|
140
|
+
'review-only': Object.freeze(['reviewer']),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const NAMED_PIPELINE_MINIMUM_REQUIREMENTS = Object.freeze({
|
|
144
|
+
tiny: Object.freeze(['verifyClaim', 'leaseForWrites']),
|
|
145
|
+
normal: Object.freeze(['independentReviewer', 'verifyClaim', 'gate', 'leaseForWrites']),
|
|
146
|
+
'high-risk': Object.freeze(['humanApproval', 'independentReviewer', 'verifyClaim', 'gate', 'leaseForWrites']),
|
|
147
|
+
'review-only': Object.freeze([]),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const ROLE_ID_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
151
|
+
const AGENT_ID_RE = /^[a-z][a-z0-9._-]{0,79}$/;
|
|
152
|
+
const PROVIDER_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
153
|
+
// The pre-v2 provider registry accepts case-preserving user IDs. Legacy input
|
|
154
|
+
// must continue to read those IDs; the stricter lowercase predicate applies only
|
|
155
|
+
// to native v2 Agent documents.
|
|
156
|
+
const LEGACY_PROVIDER_ID_RE = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
|
157
|
+
const PIPELINE_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
158
|
+
const TAG_RE = /^[a-z0-9][a-z0-9._:-]{0,39}$/;
|
|
159
|
+
|
|
160
|
+
function _isObject(value) {
|
|
161
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _normalizeToken(value) {
|
|
165
|
+
let text = stripDefaultIgnorables(value, { nfkc: true, visibleIdentity: true }).trim();
|
|
166
|
+
return text.toLowerCase();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function canonicalRoleId(value) {
|
|
170
|
+
const token = _normalizeToken(value);
|
|
171
|
+
return Object.prototype.hasOwnProperty.call(ROLE_INPUT_ALIASES, token)
|
|
172
|
+
? ROLE_INPUT_ALIASES[token]
|
|
173
|
+
: token;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isCanonicalRoleId(value) {
|
|
177
|
+
if (typeof value !== 'string') return false;
|
|
178
|
+
const token = _normalizeToken(value);
|
|
179
|
+
// Persisted v2 IDs are already-normalized data. Accepting whitespace, case,
|
|
180
|
+
// or full-width variants here would validate one spelling while retaining a
|
|
181
|
+
// different object key, creating cross-document ambiguity.
|
|
182
|
+
return value === token && ROLE_ID_RE.test(token) && canonicalRoleId(token) === token;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _hasForbiddenControls(value, allowMultiline) {
|
|
186
|
+
const text = String(value == null ? '' : value);
|
|
187
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/.test(text)) return true;
|
|
188
|
+
if (!allowMultiline && /[\r\n]/.test(text)) return true;
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _validText(value, options = {}) {
|
|
193
|
+
if (typeof value !== 'string') return false;
|
|
194
|
+
const min = options.min == null ? 0 : options.min;
|
|
195
|
+
const max = options.max == null ? 4096 : options.max;
|
|
196
|
+
if (value.length < min || value.length > max) return false;
|
|
197
|
+
return !_hasForbiddenControls(value, options.multiline === true);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _validNullableText(value, options = {}) {
|
|
201
|
+
return value === null || _validText(value, options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _validNonNegativeIntegerOrNull(value) {
|
|
205
|
+
return value === null || (Number.isInteger(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _cloneJson(value) {
|
|
209
|
+
return JSON.parse(JSON.stringify(value));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Bracket assignment to `__proto__` invokes Object.prototype's legacy setter
|
|
213
|
+
// on ordinary objects. Compatibility projection must preserve arbitrary legacy
|
|
214
|
+
// role keys as own JSON data without changing an object's prototype.
|
|
215
|
+
function _setOwnDataProperty(target, key, value) {
|
|
216
|
+
Object.defineProperty(target, key, {
|
|
217
|
+
value,
|
|
218
|
+
enumerable: true,
|
|
219
|
+
configurable: true,
|
|
220
|
+
writable: true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _canonical(value) {
|
|
225
|
+
if (Array.isArray(value)) return '[' + value.map(_canonical).join(',') + ']';
|
|
226
|
+
if (_isObject(value)) {
|
|
227
|
+
return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + _canonical(value[key])).join(',') + '}';
|
|
228
|
+
}
|
|
229
|
+
return JSON.stringify(value);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function stableStringify(value) {
|
|
233
|
+
return _canonical(value);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _cmpText(a, b) {
|
|
237
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function _problem(code, path, detail) {
|
|
241
|
+
const row = { code, path };
|
|
242
|
+
if (detail !== undefined && detail !== '') row.detail = String(detail);
|
|
243
|
+
return row;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function _sortedProblems(problems) {
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
return problems
|
|
249
|
+
.filter(problem => {
|
|
250
|
+
const key = `${problem.path}\u0000${problem.code}\u0000${problem.detail || ''}`;
|
|
251
|
+
if (seen.has(key)) return false;
|
|
252
|
+
seen.add(key);
|
|
253
|
+
return true;
|
|
254
|
+
})
|
|
255
|
+
.sort((a, b) => _cmpText(a.path, b.path) || _cmpText(a.code, b.code) || _cmpText(a.detail || '', b.detail || ''));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function _finish(problems, extra = {}) {
|
|
259
|
+
const rows = _sortedProblems(problems);
|
|
260
|
+
return {
|
|
261
|
+
ok: rows.length === 0,
|
|
262
|
+
code: rows.length ? rows[0].code : 'ok',
|
|
263
|
+
problems: rows,
|
|
264
|
+
...extra,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function _unknownFields(value, allowed, path, problems) {
|
|
269
|
+
if (!_isObject(value)) return;
|
|
270
|
+
const allow = new Set(allowed);
|
|
271
|
+
for (const key of Object.keys(value).sort()) {
|
|
272
|
+
if (!allow.has(key)) problems.push(_problem('unknown-field', `${path}.${key}`));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function _validateSchemaHeader(doc, kind, path, allowedTop, problems) {
|
|
277
|
+
if (!_isObject(doc)) {
|
|
278
|
+
problems.push(_problem('root-not-object', path));
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
_unknownFields(doc, allowedTop, path, problems);
|
|
282
|
+
if (doc.schemaVersion !== SCHEMA_VERSION) {
|
|
283
|
+
problems.push(_problem(
|
|
284
|
+
Number.isInteger(doc.schemaVersion) ? 'unsupported-schema-version' : 'invalid-schema-version',
|
|
285
|
+
`${path}.schemaVersion`,
|
|
286
|
+
doc.schemaVersion,
|
|
287
|
+
));
|
|
288
|
+
}
|
|
289
|
+
if (doc.kind !== kind) problems.push(_problem('wrong-kind', `${path}.kind`, doc.kind));
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function _validateSource(source, path, problems) {
|
|
294
|
+
if (!_isObject(source)) {
|
|
295
|
+
problems.push(_problem('invalid-source', path));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
_unknownFields(source, ['kind', 'file'], path, problems);
|
|
299
|
+
if (!_validText(source.kind, { min: 1, max: 80 })) problems.push(_problem('invalid-source-kind', `${path}.kind`));
|
|
300
|
+
if (!_validText(source.file, { min: 1, max: 160 })) problems.push(_problem('invalid-source-file', `${path}.file`));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function _validateBudget(budget, path, problems) {
|
|
304
|
+
if (!_isObject(budget)) {
|
|
305
|
+
problems.push(_problem('invalid-budget', path));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
_unknownFields(budget, ['inputTokens', 'outputTokens', 'retries'], path, problems);
|
|
309
|
+
if (!_validNonNegativeIntegerOrNull(budget.inputTokens)) problems.push(_problem('invalid-input-token-budget', `${path}.inputTokens`));
|
|
310
|
+
if (!_validNonNegativeIntegerOrNull(budget.outputTokens)) problems.push(_problem('invalid-output-token-budget', `${path}.outputTokens`));
|
|
311
|
+
if (!Number.isInteger(budget.retries) || budget.retries < 0 || budget.retries > 10) problems.push(_problem('invalid-retry-budget', `${path}.retries`));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function _validateStringArray(value, path, problems, options = {}) {
|
|
315
|
+
if (!Array.isArray(value)) {
|
|
316
|
+
problems.push(_problem(options.code || 'not-array', path));
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const out = [];
|
|
320
|
+
const seen = new Set();
|
|
321
|
+
value.forEach((item, index) => {
|
|
322
|
+
const itemPath = `${path}[${index}]`;
|
|
323
|
+
const valid = typeof options.validator === 'function'
|
|
324
|
+
? options.validator(item)
|
|
325
|
+
: _validText(item, { min: 1, max: options.max || 160 });
|
|
326
|
+
if (!valid) {
|
|
327
|
+
problems.push(_problem(options.itemCode || 'invalid-array-item', itemPath));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const normalized = options.normalize ? options.normalize(item) : item;
|
|
331
|
+
if (seen.has(normalized)) problems.push(_problem(options.duplicateCode || 'duplicate-array-item', itemPath, normalized));
|
|
332
|
+
else {
|
|
333
|
+
seen.add(normalized);
|
|
334
|
+
out.push(normalized);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function validateRoleDefinitions(doc) {
|
|
341
|
+
const problems = [];
|
|
342
|
+
const path = '$.roleDefinitions';
|
|
343
|
+
if (!_validateSchemaHeader(doc, 'role-definitions', path, ['schemaVersion', 'kind', 'roles', 'source'], problems)) return _finish(problems);
|
|
344
|
+
if (doc.source !== undefined) _validateSource(doc.source, `${path}.source`, problems);
|
|
345
|
+
if (!_isObject(doc.roles)) {
|
|
346
|
+
problems.push(_problem('roles-not-object', `${path}.roles`));
|
|
347
|
+
return _finish(problems);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const roleId of Object.keys(doc.roles).sort()) {
|
|
351
|
+
const rolePath = `${path}.roles.${roleId}`;
|
|
352
|
+
if (!isCanonicalRoleId(roleId)) problems.push(_problem('non-canonical-role-id', rolePath, canonicalRoleId(roleId)));
|
|
353
|
+
const def = doc.roles[roleId];
|
|
354
|
+
if (!_isObject(def)) {
|
|
355
|
+
problems.push(_problem('role-definition-not-object', rolePath));
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
_unknownFields(def, [
|
|
359
|
+
'label', 'extends', 'responsibilities', 'requiredTier', 'codeWrite', 'approve', 'release',
|
|
360
|
+
'forbidden', 'requiredInputs', 'requiredOutputs', 'contextPolicy', 'defaultBudget',
|
|
361
|
+
], rolePath, problems);
|
|
362
|
+
|
|
363
|
+
if (def.label !== undefined) {
|
|
364
|
+
if (!_isObject(def.label)) problems.push(_problem('invalid-role-label', `${rolePath}.label`));
|
|
365
|
+
else {
|
|
366
|
+
_unknownFields(def.label, ['ko', 'en'], `${rolePath}.label`, problems);
|
|
367
|
+
if (!_validText(def.label.ko, { min: 1, max: 80 })) problems.push(_problem('invalid-role-label-ko', `${rolePath}.label.ko`));
|
|
368
|
+
if (!_validText(def.label.en, { min: 1, max: 80 })) problems.push(_problem('invalid-role-label-en', `${rolePath}.label.en`));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (def.extends !== undefined && def.extends !== null && !isCanonicalRoleId(def.extends)) problems.push(_problem('invalid-role-extends', `${rolePath}.extends`, canonicalRoleId(def.extends)));
|
|
372
|
+
if (def.requiredTier !== undefined && !PERMISSION_TIERS.includes(def.requiredTier)) problems.push(_problem('invalid-required-tier', `${rolePath}.requiredTier`, def.requiredTier));
|
|
373
|
+
for (const key of ['codeWrite', 'approve', 'release']) {
|
|
374
|
+
if (def[key] !== undefined && typeof def[key] !== 'boolean') problems.push(_problem('invalid-role-boolean', `${rolePath}.${key}`));
|
|
375
|
+
}
|
|
376
|
+
if (def.responsibilities !== undefined) _validateStringArray(def.responsibilities, `${rolePath}.responsibilities`, problems, { max: 240, itemCode: 'invalid-role-responsibility', duplicateCode: 'duplicate-role-responsibility' });
|
|
377
|
+
if (def.forbidden !== undefined) _validateStringArray(def.forbidden, `${rolePath}.forbidden`, problems, { max: 120, itemCode: 'invalid-forbidden-action', duplicateCode: 'duplicate-forbidden-action' });
|
|
378
|
+
if (def.requiredInputs !== undefined) _validateStringArray(def.requiredInputs, `${rolePath}.requiredInputs`, problems, { itemCode: 'invalid-required-input', duplicateCode: 'duplicate-required-input' });
|
|
379
|
+
if (def.requiredOutputs !== undefined) _validateStringArray(def.requiredOutputs, `${rolePath}.requiredOutputs`, problems, { itemCode: 'invalid-required-output', duplicateCode: 'duplicate-required-output' });
|
|
380
|
+
if (def.contextPolicy !== undefined && !_validText(def.contextPolicy, { min: 1, max: 160 })) problems.push(_problem('invalid-context-policy', `${rolePath}.contextPolicy`));
|
|
381
|
+
if (def.defaultBudget !== undefined) _validateBudget(def.defaultBudget, `${rolePath}.defaultBudget`, problems);
|
|
382
|
+
|
|
383
|
+
const builtinCeiling = Object.prototype.hasOwnProperty.call(BUILTIN_ROLE_PERMISSION_CEILINGS, roleId)
|
|
384
|
+
? BUILTIN_ROLE_PERMISSION_CEILINGS[roleId] : null;
|
|
385
|
+
if (builtinCeiling) {
|
|
386
|
+
if (def.requiredTier !== undefined
|
|
387
|
+
&& PERMISSION_TIERS.includes(def.requiredTier)
|
|
388
|
+
&& PERMISSION_TIERS.indexOf(def.requiredTier) < PERMISSION_TIERS.indexOf(builtinCeiling.requiredTier)) {
|
|
389
|
+
problems.push(_problem('builtin-role-tier-widening', `${rolePath}.requiredTier`, `${def.requiredTier} < ${builtinCeiling.requiredTier}`));
|
|
390
|
+
}
|
|
391
|
+
for (const key of ['codeWrite', 'approve', 'release']) {
|
|
392
|
+
if (def[key] === true && builtinCeiling[key] !== true) {
|
|
393
|
+
problems.push(_problem('builtin-role-authority-widening', `${rolePath}.${key}`, roleId));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (def.extends) {
|
|
397
|
+
const extendedCeiling = Object.prototype.hasOwnProperty.call(BUILTIN_ROLE_PERMISSION_CEILINGS, def.extends)
|
|
398
|
+
? BUILTIN_ROLE_PERMISSION_CEILINGS[def.extends] : null;
|
|
399
|
+
const extendsWider = !extendedCeiling
|
|
400
|
+
|| PERMISSION_TIERS.indexOf(extendedCeiling.requiredTier) < PERMISSION_TIERS.indexOf(builtinCeiling.requiredTier)
|
|
401
|
+
|| ['codeWrite', 'approve', 'release'].some(key => extendedCeiling[key] === true && builtinCeiling[key] !== true);
|
|
402
|
+
if (extendsWider) problems.push(_problem('builtin-role-extends-widening', `${rolePath}.extends`, def.extends));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Custom project roles need an explicit label and permission ceiling. Built-in
|
|
407
|
+
// entries are narrow overrides, so every field may remain optional.
|
|
408
|
+
if (!BUILTIN_ROLE_IDS.includes(roleId)) {
|
|
409
|
+
if (!_isObject(def.label)) problems.push(_problem('custom-role-label-required', `${rolePath}.label`));
|
|
410
|
+
if (!Array.isArray(def.responsibilities)) problems.push(_problem('custom-role-responsibilities-required', `${rolePath}.responsibilities`));
|
|
411
|
+
if (!PERMISSION_TIERS.includes(def.requiredTier)) problems.push(_problem('custom-role-tier-required', `${rolePath}.requiredTier`));
|
|
412
|
+
for (const key of ['codeWrite', 'approve', 'release']) {
|
|
413
|
+
if (typeof def[key] !== 'boolean') problems.push(_problem('custom-role-boolean-required', `${rolePath}.${key}`));
|
|
414
|
+
}
|
|
415
|
+
if (!Array.isArray(def.forbidden)) problems.push(_problem('custom-role-forbidden-required', `${rolePath}.forbidden`));
|
|
416
|
+
if (!Array.isArray(def.requiredInputs)) problems.push(_problem('custom-role-inputs-required', `${rolePath}.requiredInputs`));
|
|
417
|
+
if (!Array.isArray(def.requiredOutputs)) problems.push(_problem('custom-role-outputs-required', `${rolePath}.requiredOutputs`));
|
|
418
|
+
if (!_validText(def.contextPolicy, { min: 1, max: 160 })) problems.push(_problem('custom-role-context-required', `${rolePath}.contextPolicy`));
|
|
419
|
+
if (!_isObject(def.defaultBudget)) problems.push(_problem('custom-role-budget-required', `${rolePath}.defaultBudget`));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const effective = builtinCeiling
|
|
423
|
+
? {
|
|
424
|
+
requiredTier: def.requiredTier === undefined ? builtinCeiling.requiredTier : def.requiredTier,
|
|
425
|
+
codeWrite: def.codeWrite === undefined ? builtinCeiling.codeWrite : def.codeWrite,
|
|
426
|
+
release: def.release === undefined ? builtinCeiling.release : def.release,
|
|
427
|
+
}
|
|
428
|
+
: def;
|
|
429
|
+
const tierRank = PERMISSION_TIERS.indexOf(effective.requiredTier);
|
|
430
|
+
if (effective.codeWrite === true && tierRank >= 0 && tierRank < PERMISSION_TIERS.indexOf('project-write')) {
|
|
431
|
+
problems.push(_problem('code-write-tier-too-low', `${rolePath}.requiredTier`, effective.requiredTier));
|
|
432
|
+
}
|
|
433
|
+
if (effective.release === true && tierRank >= 0 && tierRank < PERMISSION_TIERS.indexOf('publish')) {
|
|
434
|
+
problems.push(_problem('release-tier-too-low', `${rolePath}.requiredTier`, effective.requiredTier));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return _finish(problems, { roleIds: Object.keys(doc.roles).sort() });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function _validateLegacyProjection(value, path, problems) {
|
|
441
|
+
if (!_isObject(value)) {
|
|
442
|
+
problems.push(_problem('invalid-legacy-projection', path));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
_unknownFields(value, ['file', 'roleKey', 'provider', 'primary'], path, problems);
|
|
446
|
+
if (value.file !== STORE_FILES.legacyAssignments) problems.push(_problem('invalid-legacy-file', `${path}.file`, value.file));
|
|
447
|
+
if (!_validText(value.roleKey, { min: 1, max: 128 })) problems.push(_problem('invalid-legacy-role-key', `${path}.roleKey`));
|
|
448
|
+
if (value.provider !== undefined
|
|
449
|
+
&& (typeof value.provider !== 'string' || !LEGACY_PROVIDER_ID_RE.test(value.provider))) {
|
|
450
|
+
problems.push(_problem('invalid-legacy-provider', `${path}.provider`, value.provider));
|
|
451
|
+
}
|
|
452
|
+
if (typeof value.primary !== 'boolean') problems.push(_problem('invalid-legacy-primary', `${path}.primary`));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function validateAgentInstances(doc) {
|
|
456
|
+
const problems = [];
|
|
457
|
+
const path = '$.agentInstances';
|
|
458
|
+
if (!_validateSchemaHeader(doc, 'agent-instances', path, ['schemaVersion', 'kind', 'agents', 'source'], problems)) return _finish(problems);
|
|
459
|
+
if (doc.source !== undefined) _validateSource(doc.source, `${path}.source`, problems);
|
|
460
|
+
if (!Array.isArray(doc.agents)) {
|
|
461
|
+
problems.push(_problem('agents-not-array', `${path}.agents`));
|
|
462
|
+
return _finish(problems);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const ids = new Set();
|
|
466
|
+
doc.agents.forEach((agent, index) => {
|
|
467
|
+
const agentPath = `${path}.agents[${index}]`;
|
|
468
|
+
if (!_isObject(agent)) {
|
|
469
|
+
problems.push(_problem('agent-not-object', agentPath));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
_unknownFields(agent, [
|
|
473
|
+
'id', 'role', 'provider', 'model', 'persona', 'enabled', 'maxConcurrency',
|
|
474
|
+
'sessionKeyPolicy', 'budget', 'fallback', 'tags', 'legacyProjection',
|
|
475
|
+
], agentPath, problems);
|
|
476
|
+
|
|
477
|
+
if (typeof agent.id !== 'string' || !AGENT_ID_RE.test(agent.id)) problems.push(_problem('invalid-agent-id', `${agentPath}.id`, agent.id));
|
|
478
|
+
else if (ids.has(agent.id)) problems.push(_problem('duplicate-agent-id', `${agentPath}.id`, agent.id));
|
|
479
|
+
else ids.add(agent.id);
|
|
480
|
+
|
|
481
|
+
if (!isCanonicalRoleId(agent.role)) problems.push(_problem('non-canonical-agent-role', `${agentPath}.role`, canonicalRoleId(agent.role)));
|
|
482
|
+
if (typeof agent.provider !== 'string' || !PROVIDER_ID_RE.test(agent.provider)) problems.push(_problem('invalid-agent-provider', `${agentPath}.provider`, agent.provider));
|
|
483
|
+
if (!_validNullableText(agent.model, { min: 1, max: 200 })
|
|
484
|
+
|| (typeof agent.model === 'string' && !isValidModelIdentifier(agent.model))) {
|
|
485
|
+
problems.push(_problem('invalid-agent-model', `${agentPath}.model`));
|
|
486
|
+
}
|
|
487
|
+
if (agent.persona !== undefined && !_validText(agent.persona, { max: 4000, multiline: true })) problems.push(_problem('invalid-agent-persona', `${agentPath}.persona`));
|
|
488
|
+
if (typeof agent.enabled !== 'boolean') problems.push(_problem('invalid-agent-enabled', `${agentPath}.enabled`));
|
|
489
|
+
if (!Number.isInteger(agent.maxConcurrency) || agent.maxConcurrency < 1 || agent.maxConcurrency > 64) problems.push(_problem('invalid-agent-concurrency', `${agentPath}.maxConcurrency`));
|
|
490
|
+
if (!['required-for-write', 'optional', 'forbidden'].includes(agent.sessionKeyPolicy)) problems.push(_problem('invalid-session-key-policy', `${agentPath}.sessionKeyPolicy`, agent.sessionKeyPolicy));
|
|
491
|
+
_validateBudget(agent.budget, `${agentPath}.budget`, problems);
|
|
492
|
+
|
|
493
|
+
const fallback = _validateStringArray(agent.fallback, `${agentPath}.fallback`, problems, {
|
|
494
|
+
validator: value => typeof value === 'string' && AGENT_ID_RE.test(value),
|
|
495
|
+
itemCode: 'invalid-fallback-agent-id',
|
|
496
|
+
duplicateCode: 'duplicate-fallback-agent-id',
|
|
497
|
+
});
|
|
498
|
+
if (typeof agent.id === 'string' && fallback.includes(agent.id)) problems.push(_problem('self-fallback', `${agentPath}.fallback`, agent.id));
|
|
499
|
+
|
|
500
|
+
_validateStringArray(agent.tags, `${agentPath}.tags`, problems, {
|
|
501
|
+
validator: value => typeof value === 'string' && TAG_RE.test(value),
|
|
502
|
+
itemCode: 'invalid-agent-tag',
|
|
503
|
+
duplicateCode: 'duplicate-agent-tag',
|
|
504
|
+
});
|
|
505
|
+
if (agent.legacyProjection !== undefined) _validateLegacyProjection(agent.legacyProjection, `${agentPath}.legacyProjection`, problems);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
return _finish(problems, { agentIds: Array.from(ids).sort() });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function _validateRequirement(value, path, problems) {
|
|
512
|
+
if (!_isObject(value)) {
|
|
513
|
+
problems.push(_problem('invalid-pipeline-requirement', path));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const keys = ['humanApproval', 'independentReviewer', 'verifyClaim', 'gate', 'leaseForWrites'];
|
|
517
|
+
_unknownFields(value, keys, path, problems);
|
|
518
|
+
for (const key of keys) {
|
|
519
|
+
if (typeof value[key] !== 'boolean') problems.push(_problem('invalid-pipeline-requirement-boolean', `${path}.${key}`));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function validateRoutingPolicy(doc) {
|
|
524
|
+
const problems = [];
|
|
525
|
+
const path = '$.routingPolicy';
|
|
526
|
+
if (!_validateSchemaHeader(doc, 'routing-policy', path, ['schemaVersion', 'kind', 'defaultMode', 'pipelines', 'requirements', 'source'], problems)) return _finish(problems);
|
|
527
|
+
if (doc.source !== undefined) _validateSource(doc.source, `${path}.source`, problems);
|
|
528
|
+
if (!['suggest', 'confirm'].includes(doc.defaultMode)) problems.push(_problem('invalid-default-mode', `${path}.defaultMode`, doc.defaultMode));
|
|
529
|
+
if (!_isObject(doc.pipelines)) problems.push(_problem('pipelines-not-object', `${path}.pipelines`));
|
|
530
|
+
if (!_isObject(doc.requirements)) problems.push(_problem('requirements-not-object', `${path}.requirements`));
|
|
531
|
+
if (!_isObject(doc.pipelines) || !_isObject(doc.requirements)) return _finish(problems);
|
|
532
|
+
|
|
533
|
+
const pipelineIds = Object.keys(doc.pipelines).sort();
|
|
534
|
+
for (const pipelineId of pipelineIds) {
|
|
535
|
+
const pipelinePath = `${path}.pipelines.${pipelineId}`;
|
|
536
|
+
if (!PIPELINE_ID_RE.test(pipelineId)) problems.push(_problem('invalid-pipeline-id', pipelinePath));
|
|
537
|
+
const normalizedRoles = _validateStringArray(doc.pipelines[pipelineId], pipelinePath, problems, {
|
|
538
|
+
validator: value => isCanonicalRoleId(value),
|
|
539
|
+
normalize: value => _normalizeToken(value),
|
|
540
|
+
itemCode: 'non-canonical-pipeline-role',
|
|
541
|
+
duplicateCode: 'duplicate-pipeline-role',
|
|
542
|
+
});
|
|
543
|
+
if (Array.isArray(doc.pipelines[pipelineId]) && normalizedRoles.length === 0) {
|
|
544
|
+
problems.push(_problem('empty-pipeline', pipelinePath));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
for (const pipelineId of pipelineIds) {
|
|
548
|
+
if (!Object.prototype.hasOwnProperty.call(doc.requirements, pipelineId)) {
|
|
549
|
+
problems.push(_problem('pipeline-requirement-missing', `${path}.requirements.${pipelineId}`));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
for (const pipelineId of Object.keys(doc.requirements).sort()) {
|
|
553
|
+
if (!Object.prototype.hasOwnProperty.call(doc.pipelines, pipelineId)) problems.push(_problem('requirement-without-pipeline', `${path}.requirements.${pipelineId}`));
|
|
554
|
+
_validateRequirement(doc.requirements[pipelineId], `${path}.requirements.${pipelineId}`, problems);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
for (const pipelineId of pipelineIds) {
|
|
558
|
+
const requirement = doc.requirements[pipelineId];
|
|
559
|
+
const roles = Array.isArray(doc.pipelines[pipelineId])
|
|
560
|
+
? doc.pipelines[pipelineId].map(canonicalRoleId)
|
|
561
|
+
: [];
|
|
562
|
+
if (_isObject(requirement) && requirement.independentReviewer === true && !roles.includes('reviewer')) {
|
|
563
|
+
problems.push(_problem('independent-reviewer-role-missing', `${path}.pipelines.${pipelineId}`, 'reviewer'));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
for (const pipelineId of Object.keys(NAMED_PIPELINE_ROLE_ORDER).sort()) {
|
|
568
|
+
if (!Object.prototype.hasOwnProperty.call(doc.pipelines, pipelineId) || !Array.isArray(doc.pipelines[pipelineId])) continue;
|
|
569
|
+
const roles = doc.pipelines[pipelineId].map(canonicalRoleId);
|
|
570
|
+
const required = NAMED_PIPELINE_ROLE_ORDER[pipelineId];
|
|
571
|
+
let previous = -1;
|
|
572
|
+
let orderInvalid = false;
|
|
573
|
+
for (const role of required) {
|
|
574
|
+
const index = roles.indexOf(role);
|
|
575
|
+
if (index < 0) {
|
|
576
|
+
if (pipelineId !== 'high-risk') problems.push(_problem('named-pipeline-role-missing', `${path}.pipelines.${pipelineId}`, role));
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (index < previous) orderInvalid = true;
|
|
580
|
+
previous = Math.max(previous, index);
|
|
581
|
+
}
|
|
582
|
+
if (orderInvalid) {
|
|
583
|
+
problems.push(_problem(
|
|
584
|
+
pipelineId === 'high-risk' ? 'high-risk-role-order-invalid' : 'named-pipeline-role-order-invalid',
|
|
585
|
+
`${path}.pipelines.${pipelineId}`,
|
|
586
|
+
required.join(' -> '),
|
|
587
|
+
));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (!Object.prototype.hasOwnProperty.call(doc.pipelines, 'high-risk')) {
|
|
592
|
+
problems.push(_problem('high-risk-pipeline-required', `${path}.pipelines.high-risk`));
|
|
593
|
+
} else if (Array.isArray(doc.pipelines['high-risk'])) {
|
|
594
|
+
const roles = new Set(doc.pipelines['high-risk'].map(canonicalRoleId));
|
|
595
|
+
for (const role of HIGH_RISK_REQUIRED_ROLES) {
|
|
596
|
+
if (!roles.has(role)) problems.push(_problem('high-risk-role-missing', `${path}.pipelines.high-risk`, role));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
for (const pipelineId of Object.keys(NAMED_PIPELINE_MINIMUM_REQUIREMENTS).sort()) {
|
|
600
|
+
const requirement = doc.requirements[pipelineId];
|
|
601
|
+
if (!_isObject(requirement)) {
|
|
602
|
+
if (pipelineId === 'high-risk') problems.push(_problem('high-risk-requirements-required', `${path}.requirements.high-risk`));
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
for (const key of NAMED_PIPELINE_MINIMUM_REQUIREMENTS[pipelineId]) {
|
|
606
|
+
if (requirement[key] !== true) {
|
|
607
|
+
problems.push(_problem(
|
|
608
|
+
pipelineId === 'high-risk' ? 'high-risk-requirement-disabled' : 'named-pipeline-requirement-disabled',
|
|
609
|
+
`${path}.requirements.${pipelineId}.${key}`,
|
|
610
|
+
key,
|
|
611
|
+
));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return _finish(problems, { pipelineIds });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function validateLegacyRoleStore(doc) {
|
|
620
|
+
const problems = [];
|
|
621
|
+
const warnings = [];
|
|
622
|
+
const unknownFields = [];
|
|
623
|
+
const path = '$.legacyAssignments';
|
|
624
|
+
if (!_isObject(doc)) return _finish([_problem('root-not-object', path)], { warnings, unknownFields });
|
|
625
|
+
|
|
626
|
+
const versionPresent = Object.prototype.hasOwnProperty.call(doc, 'schemaVersion');
|
|
627
|
+
if (versionPresent && !LEGACY_READ_SCHEMA_VERSIONS.includes(doc.schemaVersion)) {
|
|
628
|
+
problems.push(_problem(
|
|
629
|
+
Number.isInteger(doc.schemaVersion) ? 'unsupported-schema-version' : 'invalid-schema-version',
|
|
630
|
+
`${path}.schemaVersion`,
|
|
631
|
+
doc.schemaVersion,
|
|
632
|
+
));
|
|
633
|
+
}
|
|
634
|
+
for (const key of Object.keys(doc).sort()) {
|
|
635
|
+
if (!['schemaVersion', 'updatedAt', 'roles'].includes(key)) unknownFields.push(`${path}.${key}`);
|
|
636
|
+
}
|
|
637
|
+
if (doc.updatedAt !== undefined && !_validText(doc.updatedAt, { min: 1, max: 80 })) problems.push(_problem('invalid-updated-at', `${path}.updatedAt`));
|
|
638
|
+
|
|
639
|
+
let roles = doc.roles;
|
|
640
|
+
if (!Object.prototype.hasOwnProperty.call(doc, 'roles')) {
|
|
641
|
+
problems.push(_problem('roles-missing', `${path}.roles`));
|
|
642
|
+
roles = {};
|
|
643
|
+
} else if (!_isObject(roles)) {
|
|
644
|
+
problems.push(_problem('roles-not-object', `${path}.roles`));
|
|
645
|
+
roles = {};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const canonicalOwners = new Map();
|
|
649
|
+
for (const roleKey of Object.keys(roles).sort()) {
|
|
650
|
+
const rolePath = `${path}.roles.${roleKey}`;
|
|
651
|
+
if (!_validText(roleKey, { min: 1, max: 128 })) {
|
|
652
|
+
problems.push(_problem('invalid-legacy-role-key', rolePath));
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const canonical = legacyRoleId(roleKey);
|
|
656
|
+
if (canonicalOwners.has(canonical) && canonicalOwners.get(canonical) !== roleKey) {
|
|
657
|
+
problems.push(_problem('legacy-role-alias-collision', rolePath, `${canonicalOwners.get(canonical)} -> ${canonical}`));
|
|
658
|
+
} else canonicalOwners.set(canonical, roleKey);
|
|
659
|
+
|
|
660
|
+
const entry = roles[roleKey];
|
|
661
|
+
if (!_isObject(entry)) {
|
|
662
|
+
problems.push(_problem('legacy-role-not-object', rolePath));
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
for (const key of Object.keys(entry).sort()) {
|
|
666
|
+
if (!['provider', 'model', 'persona'].includes(key)) unknownFields.push(`${rolePath}.${key}`);
|
|
667
|
+
}
|
|
668
|
+
if (typeof entry.provider !== 'string' || !LEGACY_PROVIDER_ID_RE.test(entry.provider)) problems.push(_problem('invalid-legacy-provider', `${rolePath}.provider`, entry.provider));
|
|
669
|
+
if (entry.model !== undefined && !_validNullableText(entry.model, { min: 1, max: 200 })) problems.push(_problem('invalid-legacy-model', `${rolePath}.model`));
|
|
670
|
+
if (entry.persona !== undefined && !_validText(entry.persona, { max: 4000, multiline: true })) problems.push(_problem('invalid-legacy-persona', `${rolePath}.persona`));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return _finish(problems, {
|
|
674
|
+
warnings: warnings.sort(),
|
|
675
|
+
unknownFields: unknownFields.sort(),
|
|
676
|
+
version: versionPresent ? doc.schemaVersion : null,
|
|
677
|
+
unversioned: !versionPresent,
|
|
678
|
+
roleKeys: Object.keys(roles).sort(),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function parseLegacyRoleStoreText(text) {
|
|
683
|
+
if (typeof text !== 'string') return _finish([_problem('invalid-input-text', '$.legacyAssignments')], { state: 'invalid-input', doc: null });
|
|
684
|
+
let source = text;
|
|
685
|
+
if (source.charCodeAt(0) === 0xFEFF) source = source.slice(1);
|
|
686
|
+
if (!source.trim()) {
|
|
687
|
+
// Missing files are handled by the filesystem caller. An existing zero-byte
|
|
688
|
+
// or BOM-only store is incomplete data and must not become an empty config.
|
|
689
|
+
return _finish([_problem('empty-document', '$.legacyAssignments')], { state: 'empty-document', doc: null });
|
|
690
|
+
}
|
|
691
|
+
let doc;
|
|
692
|
+
try { doc = JSON.parse(source); }
|
|
693
|
+
catch {
|
|
694
|
+
// JSON parser messages can echo malformed source text. Keep diagnostics
|
|
695
|
+
// stable and credential-safe by returning only the canonical error code.
|
|
696
|
+
return _finish([_problem('invalid-json', '$.legacyAssignments')], { state: 'invalid-json', doc: null });
|
|
697
|
+
}
|
|
698
|
+
const validation = validateLegacyRoleStore(doc);
|
|
699
|
+
return {
|
|
700
|
+
...validation,
|
|
701
|
+
state: validation.ok ? 'valid' : 'invalid',
|
|
702
|
+
doc,
|
|
703
|
+
validation,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function legacySemanticAssignments(doc) {
|
|
708
|
+
const roles = _isObject(doc) && _isObject(doc.roles) ? doc.roles : {};
|
|
709
|
+
const out = {};
|
|
710
|
+
for (const roleKey of Object.keys(roles).sort()) {
|
|
711
|
+
const entry = _isObject(roles[roleKey]) ? roles[roleKey] : {};
|
|
712
|
+
_setOwnDataProperty(out, roleKey, {
|
|
713
|
+
provider: typeof entry.provider === 'string' ? entry.provider : null,
|
|
714
|
+
model: typeof entry.model === 'string' ? entry.model : null,
|
|
715
|
+
persona: typeof entry.persona === 'string' ? entry.persona : '',
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function _fnv1a64(text) {
|
|
722
|
+
let hash = 0xcbf29ce484222325n;
|
|
723
|
+
const prime = 0x100000001b3n;
|
|
724
|
+
const mask = 0xffffffffffffffffn;
|
|
725
|
+
const value = String(text);
|
|
726
|
+
for (let i = 0; i < value.length; i++) {
|
|
727
|
+
hash ^= BigInt(value.charCodeAt(i));
|
|
728
|
+
hash = (hash * prime) & mask;
|
|
729
|
+
}
|
|
730
|
+
return hash.toString(16).padStart(16, '0');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function _slug(value, max = 32) {
|
|
734
|
+
const normalized = _normalizeToken(value);
|
|
735
|
+
const slug = normalized.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, max);
|
|
736
|
+
return slug || 'role';
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function legacyRoleId(roleKey) {
|
|
740
|
+
// Persist only the original v1 canonical set as built-ins. For example, a
|
|
741
|
+
// user could previously create `implementer` or `tester` with --force; v2
|
|
742
|
+
// must preserve that custom identity instead of silently granting a newly
|
|
743
|
+
// introduced built-in meaning.
|
|
744
|
+
if (typeof roleKey === 'string' && LEGACY_PERSISTED_ROLE_IDS.includes(roleKey)) return roleKey;
|
|
745
|
+
return `legacy-role-${_slug(roleKey, 28)}-${_fnv1a64(_normalizeToken(roleKey))}`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function stableLegacyAgentId(roleKey) {
|
|
749
|
+
return `legacy-${_slug(roleKey, 32)}-${_fnv1a64(roleKey)}`;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function _legacyProviderProjectionId(provider) {
|
|
753
|
+
const lower = String(provider).toLowerCase();
|
|
754
|
+
// Preserve a lowercase native ID directly. Case-preserving legacy IDs need a
|
|
755
|
+
// distinct marker; otherwise changing `MyProvider` to the real `myprovider`
|
|
756
|
+
// is indistinguishable from leaving the projection untouched.
|
|
757
|
+
return provider === lower && PROVIDER_ID_RE.test(lower)
|
|
758
|
+
? lower : `legacy-provider-${_fnv1a64(provider)}`;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function _legacyProjectionSource() {
|
|
762
|
+
return { kind: 'legacy-agent-roles-v1', file: STORE_FILES.legacyAssignments };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function _builtInRoutingSource() {
|
|
766
|
+
return { kind: 'built-in-default', file: 'lib/role-agent-schema.js' };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function defaultRoutingPolicyDocument(source) {
|
|
770
|
+
const provenance = source === undefined ? _builtInRoutingSource() : source;
|
|
771
|
+
return {
|
|
772
|
+
schemaVersion: SCHEMA_VERSION,
|
|
773
|
+
kind: 'routing-policy',
|
|
774
|
+
defaultMode: 'suggest',
|
|
775
|
+
pipelines: {
|
|
776
|
+
tiny: ['coder', 'tester'],
|
|
777
|
+
normal: ['commander', 'coder', 'tester', 'reviewer'],
|
|
778
|
+
'high-risk': ['architect', 'commander', 'coder', 'tester', 'reviewer'],
|
|
779
|
+
'review-only': ['reviewer'],
|
|
780
|
+
},
|
|
781
|
+
requirements: {
|
|
782
|
+
tiny: {
|
|
783
|
+
humanApproval: false,
|
|
784
|
+
independentReviewer: false,
|
|
785
|
+
verifyClaim: true,
|
|
786
|
+
gate: false,
|
|
787
|
+
leaseForWrites: true,
|
|
788
|
+
},
|
|
789
|
+
normal: {
|
|
790
|
+
humanApproval: false,
|
|
791
|
+
independentReviewer: true,
|
|
792
|
+
verifyClaim: true,
|
|
793
|
+
gate: true,
|
|
794
|
+
leaseForWrites: true,
|
|
795
|
+
},
|
|
796
|
+
'high-risk': {
|
|
797
|
+
humanApproval: true,
|
|
798
|
+
independentReviewer: true,
|
|
799
|
+
verifyClaim: true,
|
|
800
|
+
gate: true,
|
|
801
|
+
leaseForWrites: true,
|
|
802
|
+
},
|
|
803
|
+
'review-only': {
|
|
804
|
+
humanApproval: false,
|
|
805
|
+
independentReviewer: false,
|
|
806
|
+
verifyClaim: false,
|
|
807
|
+
gate: false,
|
|
808
|
+
leaseForWrites: false,
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
source: _cloneJson(provenance),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function projectLegacyRoleStore(doc) {
|
|
816
|
+
const checked = validateLegacyRoleStore(doc);
|
|
817
|
+
if (!checked.ok) return { ok: false, code: checked.code, problems: checked.problems, validation: checked };
|
|
818
|
+
const roles = _isObject(doc.roles) ? doc.roles : {};
|
|
819
|
+
const projectedRoleDefinitions = {};
|
|
820
|
+
const agents = Object.keys(roles).sort().map(roleKey => {
|
|
821
|
+
const entry = roles[roleKey];
|
|
822
|
+
const role = legacyRoleId(roleKey);
|
|
823
|
+
if (!BUILTIN_ROLE_IDS.includes(role) && !Object.prototype.hasOwnProperty.call(projectedRoleDefinitions, role)) {
|
|
824
|
+
projectedRoleDefinitions[role] = {
|
|
825
|
+
label: { ko: roleKey.slice(0, 80), en: roleKey.slice(0, 80) },
|
|
826
|
+
responsibilities: [],
|
|
827
|
+
requiredTier: 'read-only',
|
|
828
|
+
codeWrite: false,
|
|
829
|
+
approve: false,
|
|
830
|
+
release: false,
|
|
831
|
+
forbidden: ['permission-widening-without-explicit-review'],
|
|
832
|
+
requiredInputs: [],
|
|
833
|
+
requiredOutputs: [],
|
|
834
|
+
contextPolicy: 'legacy-unclassified',
|
|
835
|
+
defaultBudget: { inputTokens: null, outputTokens: null, retries: 1 },
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
id: stableLegacyAgentId(roleKey),
|
|
840
|
+
role,
|
|
841
|
+
// Legacy stores accepted case-preserving and unbounded registered IDs.
|
|
842
|
+
// Keep the exact spelling in projection provenance while the native v2
|
|
843
|
+
// field receives a deterministic bounded canonical ID.
|
|
844
|
+
provider: _legacyProviderProjectionId(entry.provider),
|
|
845
|
+
model: Object.prototype.hasOwnProperty.call(entry, 'model') ? entry.model : null,
|
|
846
|
+
persona: typeof entry.persona === 'string' ? entry.persona : '',
|
|
847
|
+
enabled: true,
|
|
848
|
+
maxConcurrency: 1,
|
|
849
|
+
sessionKeyPolicy: 'required-for-write',
|
|
850
|
+
budget: { inputTokens: null, outputTokens: null, retries: 1 },
|
|
851
|
+
fallback: [],
|
|
852
|
+
tags: [],
|
|
853
|
+
legacyProjection: {
|
|
854
|
+
file: STORE_FILES.legacyAssignments,
|
|
855
|
+
roleKey,
|
|
856
|
+
provider: entry.provider,
|
|
857
|
+
primary: true,
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
});
|
|
861
|
+
const source = _legacyProjectionSource();
|
|
862
|
+
const roleDefinitions = {
|
|
863
|
+
schemaVersion: SCHEMA_VERSION,
|
|
864
|
+
kind: 'role-definitions',
|
|
865
|
+
roles: projectedRoleDefinitions,
|
|
866
|
+
source: _cloneJson(source),
|
|
867
|
+
};
|
|
868
|
+
const agentInstances = {
|
|
869
|
+
schemaVersion: SCHEMA_VERSION,
|
|
870
|
+
kind: 'agent-instances',
|
|
871
|
+
agents,
|
|
872
|
+
source: _cloneJson(source),
|
|
873
|
+
};
|
|
874
|
+
const routingPolicy = defaultRoutingPolicyDocument(source);
|
|
875
|
+
const bundleValidation = validateConfigBundle({ roleDefinitions, agentInstances, routingPolicy });
|
|
876
|
+
if (!bundleValidation.ok) return { ok: false, code: 'projection-invalid', problems: bundleValidation.problems, validation: bundleValidation };
|
|
877
|
+
return {
|
|
878
|
+
ok: true,
|
|
879
|
+
code: 'ok',
|
|
880
|
+
roleDefinitions,
|
|
881
|
+
agentInstances,
|
|
882
|
+
routingPolicy,
|
|
883
|
+
legacySemantic: legacySemanticAssignments(doc),
|
|
884
|
+
assignmentReady: bundleValidation.assignmentReady,
|
|
885
|
+
assignmentGaps: bundleValidation.assignmentGaps,
|
|
886
|
+
readinessScope: bundleValidation.readinessScope,
|
|
887
|
+
providerReadinessChecked: bundleValidation.providerReadinessChecked,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function projectAgentInstancesToLegacy(agentInstances, baseLegacyDocument) {
|
|
892
|
+
const agentValidation = validateAgentInstances(agentInstances);
|
|
893
|
+
if (!agentValidation.ok) return { ok: false, code: agentValidation.code, problems: agentValidation.problems };
|
|
894
|
+
const base = baseLegacyDocument === undefined ? { schemaVersion: LEGACY_SCHEMA_VERSION, roles: {} } : baseLegacyDocument;
|
|
895
|
+
const legacyValidation = validateLegacyRoleStore(base);
|
|
896
|
+
if (!legacyValidation.ok) return { ok: false, code: legacyValidation.code, problems: legacyValidation.problems };
|
|
897
|
+
|
|
898
|
+
const disabledPrimary = agentInstances.agents
|
|
899
|
+
.filter(agent => agent.legacyProjection && agent.legacyProjection.primary === true && agent.enabled !== true)
|
|
900
|
+
.map(agent => _problem('disabled-primary-legacy-projection', `$.agentInstances.agents.${agent.id}.legacyProjection`, agent.id));
|
|
901
|
+
if (disabledPrimary.length) return _finish(disabledPrimary);
|
|
902
|
+
|
|
903
|
+
const projected = agentInstances.agents
|
|
904
|
+
.filter(agent => agent.legacyProjection && agent.legacyProjection.primary === true)
|
|
905
|
+
.slice()
|
|
906
|
+
.sort((a, b) => _cmpText(a.legacyProjection.roleKey, b.legacyProjection.roleKey));
|
|
907
|
+
const seen = new Set();
|
|
908
|
+
const problems = [];
|
|
909
|
+
for (const agent of projected) {
|
|
910
|
+
const roleKey = agent.legacyProjection.roleKey;
|
|
911
|
+
if (seen.has(roleKey)) problems.push(_problem('duplicate-legacy-role-key', '$.agentInstances.agents', roleKey));
|
|
912
|
+
else seen.add(roleKey);
|
|
913
|
+
if (legacyRoleId(roleKey) !== agent.role) problems.push(_problem('legacy-role-mismatch', `$.agentInstances.agents.${agent.id}.legacyProjection.roleKey`, `${roleKey} -> ${agent.role}`));
|
|
914
|
+
}
|
|
915
|
+
if (problems.length) return _finish(problems);
|
|
916
|
+
|
|
917
|
+
const out = _cloneJson(base);
|
|
918
|
+
if (!_isObject(out.roles)) out.roles = {};
|
|
919
|
+
for (const agent of projected) {
|
|
920
|
+
const roleKey = agent.legacyProjection.roleKey;
|
|
921
|
+
const current = _isObject(out.roles[roleKey]) ? out.roles[roleKey] : {};
|
|
922
|
+
const previousProvider = typeof current.provider === 'string' ? current.provider : null;
|
|
923
|
+
const previousModel = typeof current.model === 'string' ? current.model : null;
|
|
924
|
+
const projectedLegacyProvider = typeof agent.legacyProjection.provider === 'string'
|
|
925
|
+
? agent.legacyProjection.provider : null;
|
|
926
|
+
const provider = projectedLegacyProvider
|
|
927
|
+
&& agent.provider === _legacyProviderProjectionId(projectedLegacyProvider)
|
|
928
|
+
? projectedLegacyProvider
|
|
929
|
+
: agent.provider;
|
|
930
|
+
const assignmentChanged = previousProvider !== provider || previousModel !== agent.model;
|
|
931
|
+
const next = { ...current, provider };
|
|
932
|
+
if (agent.model !== null || Object.prototype.hasOwnProperty.call(current, 'model')) next.model = agent.model;
|
|
933
|
+
if ((typeof agent.persona === 'string' && agent.persona !== '') || Object.prototype.hasOwnProperty.call(current, 'persona')) next.persona = agent.persona || '';
|
|
934
|
+
// `primary`/`modelFamily` are known legacy runtime extensions, not opaque
|
|
935
|
+
// metadata. Keeping their old assignment while replacing the flat fields
|
|
936
|
+
// creates a document that projection reports as valid but runtime rejects.
|
|
937
|
+
if (assignmentChanged && Object.prototype.hasOwnProperty.call(next, 'modelFamily')) delete next.modelFamily;
|
|
938
|
+
if (_isObject(current.primary)) {
|
|
939
|
+
const primary = { ...current.primary, provider };
|
|
940
|
+
if (agent.model !== null || Object.prototype.hasOwnProperty.call(current.primary, 'model')) primary.model = agent.model;
|
|
941
|
+
if (assignmentChanged && Object.prototype.hasOwnProperty.call(primary, 'modelFamily')) delete primary.modelFamily;
|
|
942
|
+
next.primary = primary;
|
|
943
|
+
}
|
|
944
|
+
_setOwnDataProperty(out.roles, roleKey, next);
|
|
945
|
+
}
|
|
946
|
+
const finalValidation = validateLegacyRoleStore(out);
|
|
947
|
+
if (!finalValidation.ok) return { ok: false, code: finalValidation.code, problems: finalValidation.problems };
|
|
948
|
+
// The v2 schema deliberately preserves legacy extension fields. Validate the
|
|
949
|
+
// final projection with the runtime consumer as well so stale primary/model
|
|
950
|
+
// metadata or malformed fallback extensions cannot pass the schema layer and
|
|
951
|
+
// then be rejected only when routing begins.
|
|
952
|
+
const { validateRoleDefinitionShape } = require('./role-fallback');
|
|
953
|
+
const runtimeProblems = [];
|
|
954
|
+
for (const roleKey of Object.keys(out.roles).sort()) {
|
|
955
|
+
// Reverse projection writes a legacy document. Existing registry IDs may
|
|
956
|
+
// exceed native v2's 64-character bound and are carried losslessly through
|
|
957
|
+
// legacyProjection; validate with the legacy read contract here.
|
|
958
|
+
const runtimeValidation = validateRoleDefinitionShape(roleKey, out.roles[roleKey], { allowLegacyProviderIds: true });
|
|
959
|
+
if (!runtimeValidation.ok) {
|
|
960
|
+
runtimeProblems.push(_problem(
|
|
961
|
+
'legacy-runtime-invalid',
|
|
962
|
+
`$.roles.${roleKey}${runtimeValidation.field ? '.' + runtimeValidation.field : ''}`,
|
|
963
|
+
runtimeValidation.error,
|
|
964
|
+
));
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (runtimeProblems.length) return { ok: false, code: 'projection-runtime-invalid', problems: runtimeProblems };
|
|
968
|
+
return {
|
|
969
|
+
ok: true,
|
|
970
|
+
code: 'ok',
|
|
971
|
+
document: out,
|
|
972
|
+
semantic: legacySemanticAssignments(out),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function _prefixProblems(problems, prefix) {
|
|
977
|
+
return (problems || []).map(problem => ({
|
|
978
|
+
...problem,
|
|
979
|
+
path: `${prefix}${problem.path.startsWith('$') ? problem.path.slice(1) : '.' + problem.path}`,
|
|
980
|
+
}));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function validateConfigBundle(bundle) {
|
|
984
|
+
const problems = [];
|
|
985
|
+
const path = '$.bundle';
|
|
986
|
+
if (!_isObject(bundle)) return _finish([_problem('root-not-object', path)]);
|
|
987
|
+
_unknownFields(bundle, ['roleDefinitions', 'agentInstances', 'routingPolicy'], path, problems);
|
|
988
|
+
|
|
989
|
+
const roleValidation = validateRoleDefinitions(bundle.roleDefinitions);
|
|
990
|
+
const agentValidation = validateAgentInstances(bundle.agentInstances);
|
|
991
|
+
const routingValidation = validateRoutingPolicy(bundle.routingPolicy);
|
|
992
|
+
problems.push(..._prefixProblems(roleValidation.problems, '$.bundle'));
|
|
993
|
+
problems.push(..._prefixProblems(agentValidation.problems, '$.bundle'));
|
|
994
|
+
problems.push(..._prefixProblems(routingValidation.problems, '$.bundle'));
|
|
995
|
+
|
|
996
|
+
if (!roleValidation.ok || !agentValidation.ok || !routingValidation.ok) return _finish(problems);
|
|
997
|
+
|
|
998
|
+
const roleIds = new Set(BUILTIN_ROLE_IDS);
|
|
999
|
+
for (const roleId of Object.keys(bundle.roleDefinitions.roles)) roleIds.add(roleId);
|
|
1000
|
+
|
|
1001
|
+
const roleDefs = bundle.roleDefinitions.roles;
|
|
1002
|
+
for (const roleId of Object.keys(roleDefs).sort()) {
|
|
1003
|
+
const parent = roleDefs[roleId].extends;
|
|
1004
|
+
if (parent !== undefined && parent !== null && !roleIds.has(parent)) {
|
|
1005
|
+
problems.push(_problem('role-extends-target-missing', `$.bundle.roleDefinitions.roles.${roleId}.extends`, parent));
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const roleVisited = new Set();
|
|
1009
|
+
const roleVisiting = new Set();
|
|
1010
|
+
const roleStack = [];
|
|
1011
|
+
const roleCycles = new Set();
|
|
1012
|
+
function visitRole(roleId) {
|
|
1013
|
+
if (roleVisiting.has(roleId)) {
|
|
1014
|
+
const start = roleStack.indexOf(roleId);
|
|
1015
|
+
roleCycles.add(roleStack.slice(start).concat(roleId).join(' -> '));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (roleVisited.has(roleId)) return;
|
|
1019
|
+
roleVisiting.add(roleId);
|
|
1020
|
+
roleStack.push(roleId);
|
|
1021
|
+
const def = roleDefs[roleId];
|
|
1022
|
+
const parent = def && def.extends;
|
|
1023
|
+
if (parent && Object.prototype.hasOwnProperty.call(roleDefs, parent)) visitRole(parent);
|
|
1024
|
+
roleStack.pop();
|
|
1025
|
+
roleVisiting.delete(roleId);
|
|
1026
|
+
roleVisited.add(roleId);
|
|
1027
|
+
}
|
|
1028
|
+
for (const roleId of Object.keys(roleDefs).sort()) visitRole(roleId);
|
|
1029
|
+
for (const cycle of Array.from(roleCycles).sort()) problems.push(_problem('role-extends-cycle', '$.bundle.roleDefinitions.roles', cycle));
|
|
1030
|
+
|
|
1031
|
+
const agents = bundle.agentInstances.agents;
|
|
1032
|
+
const byId = new Map(agents.map(agent => [agent.id, agent]));
|
|
1033
|
+
const effectiveRolePermissions = roleId => {
|
|
1034
|
+
const def = Object.prototype.hasOwnProperty.call(roleDefs, roleId) ? roleDefs[roleId] : {};
|
|
1035
|
+
const builtin = Object.prototype.hasOwnProperty.call(BUILTIN_ROLE_PERMISSION_CEILINGS, roleId)
|
|
1036
|
+
? BUILTIN_ROLE_PERMISSION_CEILINGS[roleId] : null;
|
|
1037
|
+
return {
|
|
1038
|
+
requiredTier: def.requiredTier === undefined ? (builtin && builtin.requiredTier) : def.requiredTier,
|
|
1039
|
+
codeWrite: def.codeWrite === undefined ? !!(builtin && builtin.codeWrite) : def.codeWrite === true,
|
|
1040
|
+
release: def.release === undefined ? !!(builtin && builtin.release) : def.release === true,
|
|
1041
|
+
};
|
|
1042
|
+
};
|
|
1043
|
+
const sessionPolicyRank = Object.freeze({ forbidden: 0, optional: 1, 'required-for-write': 2 });
|
|
1044
|
+
|
|
1045
|
+
const primaryLegacyByKey = new Map();
|
|
1046
|
+
const primaryLegacyByRole = new Map();
|
|
1047
|
+
for (const agent of agents.slice().sort((a, b) => _cmpText(a.id, b.id))) {
|
|
1048
|
+
const agentPath = `$.bundle.agentInstances.agents.${agent.id}`;
|
|
1049
|
+
if (!roleIds.has(agent.role)) problems.push(_problem('unknown-agent-role', `${agentPath}.role`, agent.role));
|
|
1050
|
+
const effectivePermissions = effectiveRolePermissions(agent.role);
|
|
1051
|
+
if ((effectivePermissions.codeWrite === true || effectivePermissions.release === true)
|
|
1052
|
+
&& agent.sessionKeyPolicy !== 'required-for-write') {
|
|
1053
|
+
problems.push(_problem('write-agent-session-key-required', `${agentPath}.sessionKeyPolicy`, agent.sessionKeyPolicy));
|
|
1054
|
+
}
|
|
1055
|
+
for (const fallbackId of agent.fallback.slice().sort()) {
|
|
1056
|
+
const target = byId.get(fallbackId);
|
|
1057
|
+
if (!target) problems.push(_problem('fallback-target-missing', `${agentPath}.fallback`, fallbackId));
|
|
1058
|
+
else if (target.role !== agent.role) problems.push(_problem('fallback-role-mismatch', `${agentPath}.fallback`, `${fallbackId}:${target.role}`));
|
|
1059
|
+
else if (sessionPolicyRank[target.sessionKeyPolicy] < sessionPolicyRank[agent.sessionKeyPolicy]) {
|
|
1060
|
+
problems.push(_problem('fallback-session-policy-weakening', `${agentPath}.fallback`, `${fallbackId}:${target.sessionKeyPolicy}`));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const projection = agent.legacyProjection;
|
|
1065
|
+
if (projection && projection.primary === true) {
|
|
1066
|
+
const roleKey = projection.roleKey;
|
|
1067
|
+
const projectionPath = `${agentPath}.legacyProjection.roleKey`;
|
|
1068
|
+
const projectedRole = legacyRoleId(roleKey);
|
|
1069
|
+
if (agent.enabled !== true) {
|
|
1070
|
+
problems.push(_problem('disabled-primary-legacy-projection', projectionPath, agent.id));
|
|
1071
|
+
}
|
|
1072
|
+
if (projectedRole !== agent.role) {
|
|
1073
|
+
problems.push(_problem('legacy-role-mismatch', projectionPath, `${roleKey} -> ${agent.role}`));
|
|
1074
|
+
}
|
|
1075
|
+
if (primaryLegacyByKey.has(roleKey)) {
|
|
1076
|
+
problems.push(_problem('duplicate-legacy-role-key', projectionPath, roleKey));
|
|
1077
|
+
} else {
|
|
1078
|
+
primaryLegacyByKey.set(roleKey, agent.id);
|
|
1079
|
+
}
|
|
1080
|
+
const semanticOwner = primaryLegacyByRole.get(projectedRole);
|
|
1081
|
+
if (semanticOwner && semanticOwner.roleKey !== roleKey) {
|
|
1082
|
+
problems.push(_problem('legacy-role-alias-collision', projectionPath, `${semanticOwner.roleKey} -> ${projectedRole}`));
|
|
1083
|
+
} else if (!semanticOwner) {
|
|
1084
|
+
primaryLegacyByRole.set(projectedRole, { roleKey, agentId: agent.id });
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const visited = new Set();
|
|
1090
|
+
const visiting = new Set();
|
|
1091
|
+
const stack = [];
|
|
1092
|
+
const cycles = new Set();
|
|
1093
|
+
function visit(agentId) {
|
|
1094
|
+
if (visiting.has(agentId)) {
|
|
1095
|
+
const start = stack.indexOf(agentId);
|
|
1096
|
+
const cycle = stack.slice(start).concat(agentId).join(' -> ');
|
|
1097
|
+
cycles.add(cycle);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (visited.has(agentId)) return;
|
|
1101
|
+
visiting.add(agentId);
|
|
1102
|
+
stack.push(agentId);
|
|
1103
|
+
const agent = byId.get(agentId);
|
|
1104
|
+
if (agent) {
|
|
1105
|
+
for (const next of agent.fallback.slice().sort()) {
|
|
1106
|
+
if (next !== agentId && byId.has(next)) visit(next);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
stack.pop();
|
|
1110
|
+
visiting.delete(agentId);
|
|
1111
|
+
visited.add(agentId);
|
|
1112
|
+
}
|
|
1113
|
+
for (const agentId of Array.from(byId.keys()).sort()) visit(agentId);
|
|
1114
|
+
for (const cycle of Array.from(cycles).sort()) problems.push(_problem('fallback-cycle', '$.bundle.agentInstances.agents', cycle));
|
|
1115
|
+
|
|
1116
|
+
const enabledRoles = new Set(agents.filter(agent => agent.enabled === true).map(agent => agent.role));
|
|
1117
|
+
const assignmentGaps = [];
|
|
1118
|
+
for (const pipelineId of Object.keys(bundle.routingPolicy.pipelines).sort()) {
|
|
1119
|
+
const roles = bundle.routingPolicy.pipelines[pipelineId];
|
|
1120
|
+
for (const roleId of roles) {
|
|
1121
|
+
if (!roleIds.has(roleId)) problems.push(_problem('unknown-pipeline-role', `$.bundle.routingPolicy.pipelines.${pipelineId}`, roleId));
|
|
1122
|
+
if (!enabledRoles.has(roleId)) {
|
|
1123
|
+
assignmentGaps.push({
|
|
1124
|
+
code: 'pipeline-role-unassigned',
|
|
1125
|
+
pipeline: pipelineId,
|
|
1126
|
+
role: roleId,
|
|
1127
|
+
reason: 'no-enabled-agent',
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
return _finish(problems, {
|
|
1134
|
+
roleIds: Array.from(roleIds).sort(),
|
|
1135
|
+
agentIds: Array.from(byId.keys()).sort(),
|
|
1136
|
+
pipelineIds: Object.keys(bundle.routingPolicy.pipelines).sort(),
|
|
1137
|
+
assignmentReady: problems.length === 0 && assignmentGaps.length === 0,
|
|
1138
|
+
assignmentGaps,
|
|
1139
|
+
readinessScope: 'configured-enabled-agent-assignments-only',
|
|
1140
|
+
providerReadinessChecked: false,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function validateStoreDocument(kind, doc) {
|
|
1145
|
+
if (kind === 'role-definitions') return validateRoleDefinitions(doc);
|
|
1146
|
+
if (kind === 'agent-instances') return validateAgentInstances(doc);
|
|
1147
|
+
if (kind === 'routing-policy') return validateRoutingPolicy(doc);
|
|
1148
|
+
if (kind === 'legacy-agent-roles') return validateLegacyRoleStore(doc);
|
|
1149
|
+
return _finish([_problem('unknown-store-kind', '$.kind', kind)]);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
module.exports = {
|
|
1153
|
+
SCHEMA_VERSION,
|
|
1154
|
+
LEGACY_SCHEMA_VERSION,
|
|
1155
|
+
STORE_FILES,
|
|
1156
|
+
PERMISSION_TIERS,
|
|
1157
|
+
BUILTIN_ROLE_IDS,
|
|
1158
|
+
BUILTIN_ROLE_PERMISSION_CEILINGS,
|
|
1159
|
+
LEGACY_PERSISTED_ROLE_IDS,
|
|
1160
|
+
ROLE_INPUT_ALIASES,
|
|
1161
|
+
HIGH_RISK_REQUIRED_ROLES,
|
|
1162
|
+
NAMED_PIPELINE_MINIMUM_REQUIREMENTS,
|
|
1163
|
+
canonicalRoleId,
|
|
1164
|
+
isCanonicalRoleId,
|
|
1165
|
+
stableStringify,
|
|
1166
|
+
legacyRoleId,
|
|
1167
|
+
stableLegacyAgentId,
|
|
1168
|
+
validateRoleDefinitions,
|
|
1169
|
+
validateAgentInstances,
|
|
1170
|
+
validateRoutingPolicy,
|
|
1171
|
+
validateLegacyRoleStore,
|
|
1172
|
+
parseLegacyRoleStoreText,
|
|
1173
|
+
legacySemanticAssignments,
|
|
1174
|
+
defaultRoutingPolicyDocument,
|
|
1175
|
+
projectLegacyRoleStore,
|
|
1176
|
+
projectAgentInstancesToLegacy,
|
|
1177
|
+
validateConfigBundle,
|
|
1178
|
+
validateStoreDocument,
|
|
1179
|
+
};
|