agentxchain 2.9.0 → 2.11.0
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/README.md +1 -0
- package/bin/agentxchain.js +14 -0
- package/package.json +2 -1
- package/src/commands/run.js +365 -0
- package/src/commands/step.js +12 -32
- package/src/commands/template-validate.js +42 -0
- package/src/commands/verify.js +41 -13
- package/src/lib/adapters/api-proxy-adapter.js +1 -1
- package/src/lib/governed-templates.js +94 -0
- package/src/lib/normalized-config.js +14 -0
- package/src/lib/protocol-conformance.js +230 -15
- package/src/lib/reference-conformance-adapter.js +47 -0
- package/src/lib/role-resolution.js +103 -0
- package/src/lib/run-loop.js +269 -0
- package/src/lib/validation.js +5 -8
|
@@ -762,7 +762,7 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
|
|
|
762
762
|
return errorReturn(root, turn.turn_id, classified);
|
|
763
763
|
}
|
|
764
764
|
|
|
765
|
-
const endpoint = PROVIDER_ENDPOINTS[provider];
|
|
765
|
+
const endpoint = runtime.base_url || PROVIDER_ENDPOINTS[provider];
|
|
766
766
|
if (!endpoint) {
|
|
767
767
|
const classified = classifyError(
|
|
768
768
|
'unsupported_provider',
|
|
@@ -248,6 +248,42 @@ export function validateProjectPlanningArtifacts(root, templateId) {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
const TEMPLATE_GUIDANCE_HEADER = '## Template Guidance';
|
|
251
|
+
const GOVERNED_WORKFLOW_KIT_BASE_FILES = Object.freeze([
|
|
252
|
+
'.planning/PM_SIGNOFF.md',
|
|
253
|
+
'.planning/ROADMAP.md',
|
|
254
|
+
'.planning/acceptance-matrix.md',
|
|
255
|
+
'.planning/ship-verdict.md',
|
|
256
|
+
]);
|
|
257
|
+
const GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS = Object.freeze([
|
|
258
|
+
{
|
|
259
|
+
id: 'pm_signoff_approved_field',
|
|
260
|
+
file: '.planning/PM_SIGNOFF.md',
|
|
261
|
+
pattern: /^Approved\s*:/im,
|
|
262
|
+
description: 'PM signoff declares an Approved field',
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: 'roadmap_phases_section',
|
|
266
|
+
file: '.planning/ROADMAP.md',
|
|
267
|
+
pattern: /^##\s+Phases\b/im,
|
|
268
|
+
description: 'Roadmap defines a ## Phases section',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 'acceptance_matrix_table_header',
|
|
272
|
+
file: '.planning/acceptance-matrix.md',
|
|
273
|
+
pattern: /^\|\s*Req\s*#\s*\|/im,
|
|
274
|
+
description: 'Acceptance matrix includes the requirement table header',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: 'ship_verdict_heading',
|
|
278
|
+
file: '.planning/ship-verdict.md',
|
|
279
|
+
pattern: /^##\s+Verdict\s*:/im,
|
|
280
|
+
description: 'Ship verdict declares a verdict heading',
|
|
281
|
+
},
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
function uniqueStrings(values) {
|
|
285
|
+
return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
|
|
286
|
+
}
|
|
251
287
|
|
|
252
288
|
export function validateAcceptanceHintCompletion(root, templateId) {
|
|
253
289
|
const effectiveTemplateId = templateId || 'generic';
|
|
@@ -358,6 +394,64 @@ export function validateAcceptanceHintCompletion(root, templateId) {
|
|
|
358
394
|
};
|
|
359
395
|
}
|
|
360
396
|
|
|
397
|
+
export function validateGovernedWorkflowKit(root, config = {}) {
|
|
398
|
+
const errors = [];
|
|
399
|
+
const warnings = [];
|
|
400
|
+
const gateRequiredFiles = uniqueStrings(
|
|
401
|
+
Object.values(config?.gates || {}).flatMap((gate) => Array.isArray(gate?.requires_files) ? gate.requires_files : [])
|
|
402
|
+
);
|
|
403
|
+
const requiredFiles = uniqueStrings([...GOVERNED_WORKFLOW_KIT_BASE_FILES, ...gateRequiredFiles]);
|
|
404
|
+
const present = [];
|
|
405
|
+
const missing = [];
|
|
406
|
+
|
|
407
|
+
for (const relPath of requiredFiles) {
|
|
408
|
+
if (existsSync(join(root, relPath))) {
|
|
409
|
+
present.push(relPath);
|
|
410
|
+
} else {
|
|
411
|
+
missing.push(relPath);
|
|
412
|
+
errors.push(`Workflow kit requires "${relPath}" but it is missing.`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const structuralChecks = GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS.map((check) => {
|
|
417
|
+
const absPath = join(root, check.file);
|
|
418
|
+
if (!existsSync(absPath)) {
|
|
419
|
+
return {
|
|
420
|
+
id: check.id,
|
|
421
|
+
file: check.file,
|
|
422
|
+
ok: false,
|
|
423
|
+
skipped: true,
|
|
424
|
+
description: check.description,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const content = readFileSync(absPath, 'utf8');
|
|
429
|
+
const ok = check.pattern.test(content);
|
|
430
|
+
if (!ok) {
|
|
431
|
+
errors.push(`Workflow kit file "${check.file}" must preserve its structural marker: ${check.description}.`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
id: check.id,
|
|
436
|
+
file: check.file,
|
|
437
|
+
ok,
|
|
438
|
+
skipped: false,
|
|
439
|
+
description: check.description,
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
ok: errors.length === 0,
|
|
445
|
+
required_files: requiredFiles,
|
|
446
|
+
gate_required_files: gateRequiredFiles,
|
|
447
|
+
present,
|
|
448
|
+
missing,
|
|
449
|
+
structural_checks: structuralChecks,
|
|
450
|
+
errors,
|
|
451
|
+
warnings,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
361
455
|
export function validateGovernedProjectTemplate(templateId, source = 'agentxchain.json') {
|
|
362
456
|
const effectiveTemplateId = templateId || 'generic';
|
|
363
457
|
const effectiveSource = templateId ? source : 'implicit_default';
|
|
@@ -344,6 +344,20 @@ export function validateV4Config(data, projectRoot) {
|
|
|
344
344
|
if (typeof rt.auth_env !== 'string' || !rt.auth_env.trim()) {
|
|
345
345
|
errors.push(`Runtime "${id}": api_proxy requires "auth_env" (environment variable name for API key)`);
|
|
346
346
|
}
|
|
347
|
+
if ('base_url' in rt) {
|
|
348
|
+
if (typeof rt.base_url !== 'string' || !rt.base_url.trim()) {
|
|
349
|
+
errors.push(`Runtime "${id}": api_proxy base_url must be a non-empty string when provided`);
|
|
350
|
+
} else {
|
|
351
|
+
try {
|
|
352
|
+
const parsed = new URL(rt.base_url);
|
|
353
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
354
|
+
errors.push(`Runtime "${id}": api_proxy base_url must use http or https`);
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
errors.push(`Runtime "${id}": api_proxy base_url must be a valid absolute URL`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
347
361
|
if ('retry_policy' in rt) {
|
|
348
362
|
validateApiProxyRetryPolicy(id, rt.retry_policy, errors);
|
|
349
363
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { request as httpRequest } from 'node:http';
|
|
4
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
5
|
import { dirname, join, resolve } from 'node:path';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
|
|
6
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
9
|
const DEFAULT_FIXTURE_ROOT = resolve(__dirname, '..', '..', '..', '.agentxchain-conformance', 'fixtures');
|
|
10
|
+
const DEFAULT_REMOTE_TIMEOUT_MS = 30_000;
|
|
8
11
|
const VALID_RESPONSE_STATUSES = new Set(['pass', 'fail', 'error', 'not_implemented']);
|
|
9
12
|
const VALID_TIERS = new Set([1, 2, 3]);
|
|
10
13
|
|
|
@@ -56,13 +59,7 @@ function validateFixtureShape(fixture, filePath) {
|
|
|
56
59
|
return errors;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
function
|
|
60
|
-
const capabilitiesPath = join(targetRoot, '.agentxchain-conformance', 'capabilities.json');
|
|
61
|
-
if (!existsSync(capabilitiesPath)) {
|
|
62
|
-
throw new Error(`Missing capabilities file at ${capabilitiesPath}`);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const capabilities = readJsonFile(capabilitiesPath);
|
|
62
|
+
function validateCapabilities(capabilities, { remote = false } = {}) {
|
|
66
63
|
const errors = [];
|
|
67
64
|
|
|
68
65
|
if (typeof capabilities.implementation !== 'string' || !capabilities.implementation.trim()) {
|
|
@@ -80,14 +77,26 @@ function loadCapabilities(targetRoot) {
|
|
|
80
77
|
if (!capabilities.adapter || typeof capabilities.adapter !== 'object' || Array.isArray(capabilities.adapter)) {
|
|
81
78
|
errors.push('capabilities.adapter must be an object');
|
|
82
79
|
} else {
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
const expectedProtocol = remote ? 'http-fixture-v1' : 'stdio-fixture-v1';
|
|
81
|
+
if (capabilities.adapter.protocol !== expectedProtocol) {
|
|
82
|
+
errors.push(`capabilities.adapter.protocol must be "${expectedProtocol}"`);
|
|
85
83
|
}
|
|
86
|
-
if (!Array.isArray(capabilities.adapter.command) || capabilities.adapter.command.length === 0) {
|
|
84
|
+
if (!remote && (!Array.isArray(capabilities.adapter.command) || capabilities.adapter.command.length === 0)) {
|
|
87
85
|
errors.push('capabilities.adapter.command must be a non-empty array');
|
|
88
86
|
}
|
|
89
87
|
}
|
|
90
88
|
|
|
89
|
+
return errors;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function loadLocalCapabilities(targetRoot) {
|
|
93
|
+
const capabilitiesPath = join(targetRoot, '.agentxchain-conformance', 'capabilities.json');
|
|
94
|
+
if (!existsSync(capabilitiesPath)) {
|
|
95
|
+
throw new Error(`Missing capabilities file at ${capabilitiesPath}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const capabilities = readJsonFile(capabilitiesPath);
|
|
99
|
+
const errors = validateCapabilities(capabilities);
|
|
91
100
|
if (errors.length > 0) {
|
|
92
101
|
throw new Error(`Invalid capabilities.json: ${errors.join('; ')}`);
|
|
93
102
|
}
|
|
@@ -95,6 +104,84 @@ function loadCapabilities(targetRoot) {
|
|
|
95
104
|
return capabilities;
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
function normalizeRemoteBase(remote) {
|
|
108
|
+
let url;
|
|
109
|
+
try {
|
|
110
|
+
url = new URL(remote);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error(`Invalid remote URL "${remote}": ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const normalized = url.toString();
|
|
116
|
+
return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildRemoteUrl(remoteBase, path) {
|
|
120
|
+
return new URL(path.replace(/^\//, ''), `${remoteBase}/`).toString();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildRemoteHeaders(token, extraHeaders = {}) {
|
|
124
|
+
return {
|
|
125
|
+
...extraHeaders,
|
|
126
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isTimeoutError(error) {
|
|
131
|
+
return error?.name === 'TimeoutError'
|
|
132
|
+
|| error?.name === 'AbortError'
|
|
133
|
+
|| error?.cause?.name === 'TimeoutError'
|
|
134
|
+
|| error?.cause?.name === 'AbortError';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatRemoteFetchError(error, timeout, prefix) {
|
|
138
|
+
if (isTimeoutError(error)) {
|
|
139
|
+
return `${prefix} timeout after ${timeout}ms`;
|
|
140
|
+
}
|
|
141
|
+
return `${prefix} network error: ${error.message}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function loadRemoteCapabilities(remote, token, timeout) {
|
|
145
|
+
const remoteBase = normalizeRemoteBase(remote);
|
|
146
|
+
const capabilitiesUrl = buildRemoteUrl(remoteBase, '/conform/capabilities');
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const response = await requestRemote(capabilitiesUrl, {
|
|
150
|
+
headers: buildRemoteHeaders(token),
|
|
151
|
+
timeout,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (response.statusCode !== 200) {
|
|
155
|
+
throw new Error(`Failed to fetch remote capabilities: HTTP ${response.statusCode}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let capabilities;
|
|
159
|
+
try {
|
|
160
|
+
capabilities = JSON.parse(response.body);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new Error(`Invalid capabilities response: ${error.message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const errors = validateCapabilities(capabilities, { remote: true });
|
|
166
|
+
if (errors.length > 0) {
|
|
167
|
+
throw new Error(`Invalid capabilities.json: ${errors.join('; ')}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
capabilities,
|
|
172
|
+
remoteBase,
|
|
173
|
+
executeUrl: buildRemoteUrl(remoteBase, '/conform/execute'),
|
|
174
|
+
};
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error.message.startsWith('Failed to fetch remote capabilities:')
|
|
177
|
+
|| error.message.startsWith('Invalid capabilities response:')
|
|
178
|
+
|| error.message.startsWith('Invalid capabilities.json:')) {
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
throw new Error(formatRemoteFetchError(error, timeout, 'Failed to fetch remote capabilities'));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
98
185
|
function selectFixtureFiles(fixtureRoot, requestedTier, surface) {
|
|
99
186
|
if (!existsSync(fixtureRoot)) {
|
|
100
187
|
throw new Error(`Fixture root not found at ${fixtureRoot}`);
|
|
@@ -135,7 +222,7 @@ function ensureSurfaceSummary(tierSummary, surface) {
|
|
|
135
222
|
return tierSummary.surfaces[surface];
|
|
136
223
|
}
|
|
137
224
|
|
|
138
|
-
function
|
|
225
|
+
function executeLocalFixture(targetRoot, adapterCommand, fixture) {
|
|
139
226
|
const [executable, ...args] = adapterCommand;
|
|
140
227
|
const result = spawnSync(executable, args, {
|
|
141
228
|
cwd: targetRoot,
|
|
@@ -196,12 +283,128 @@ function executeFixture(targetRoot, adapterCommand, fixture) {
|
|
|
196
283
|
return parsed;
|
|
197
284
|
}
|
|
198
285
|
|
|
286
|
+
async function executeRemoteFixture(executeUrl, token, timeout, fixture) {
|
|
287
|
+
let response;
|
|
288
|
+
try {
|
|
289
|
+
response = await requestRemote(executeUrl, {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: buildRemoteHeaders(token, {
|
|
292
|
+
'content-type': 'application/json',
|
|
293
|
+
}),
|
|
294
|
+
body: JSON.stringify(fixture),
|
|
295
|
+
timeout,
|
|
296
|
+
});
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return {
|
|
299
|
+
status: 'error',
|
|
300
|
+
message: formatRemoteFetchError(error, timeout, 'HTTP fixture execution'),
|
|
301
|
+
actual: null,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const rawBody = response.body;
|
|
306
|
+
|
|
307
|
+
if (response.statusCode !== 200) {
|
|
308
|
+
let actual = null;
|
|
309
|
+
let message = rawBody.trim() || `HTTP ${response.statusCode}`;
|
|
310
|
+
|
|
311
|
+
if (rawBody.trim()) {
|
|
312
|
+
try {
|
|
313
|
+
const parsed = JSON.parse(rawBody);
|
|
314
|
+
actual = parsed;
|
|
315
|
+
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
|
316
|
+
message = parsed.message.trim();
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
actual = { body: rawBody };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
status: 'error',
|
|
325
|
+
message: `HTTP ${response.statusCode}: ${message}`,
|
|
326
|
+
actual,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let parsed;
|
|
331
|
+
try {
|
|
332
|
+
parsed = JSON.parse(rawBody.trim() || '{}');
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return {
|
|
335
|
+
status: 'error',
|
|
336
|
+
message: `Malformed response: ${error.message}`,
|
|
337
|
+
actual: {
|
|
338
|
+
body: rawBody,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!VALID_RESPONSE_STATUSES.has(parsed.status)) {
|
|
344
|
+
return {
|
|
345
|
+
status: 'error',
|
|
346
|
+
message: 'Adapter response missing valid "status"',
|
|
347
|
+
actual: parsed,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return parsed;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function requestRemote(urlString, { method = 'GET', headers = {}, body = null, timeout }) {
|
|
355
|
+
const url = new URL(urlString);
|
|
356
|
+
const requestImpl = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
357
|
+
const requestHeaders = {
|
|
358
|
+
connection: 'close',
|
|
359
|
+
...headers,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
if (body != null && requestHeaders['content-length'] == null && requestHeaders['Content-Length'] == null) {
|
|
363
|
+
requestHeaders['content-length'] = Buffer.byteLength(body);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
367
|
+
const req = requestImpl(url, {
|
|
368
|
+
method,
|
|
369
|
+
headers: requestHeaders,
|
|
370
|
+
}, (res) => {
|
|
371
|
+
let responseBody = '';
|
|
372
|
+
res.setEncoding('utf8');
|
|
373
|
+
res.on('data', (chunk) => {
|
|
374
|
+
responseBody += chunk;
|
|
375
|
+
});
|
|
376
|
+
res.on('end', () => {
|
|
377
|
+
resolveRequest({
|
|
378
|
+
statusCode: res.statusCode ?? 0,
|
|
379
|
+
body: responseBody,
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
req.on('error', rejectRequest);
|
|
385
|
+
req.setTimeout(timeout, () => {
|
|
386
|
+
const error = new Error(`timeout after ${timeout}ms`);
|
|
387
|
+
error.name = 'TimeoutError';
|
|
388
|
+
req.destroy(error);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
if (body) {
|
|
392
|
+
req.write(body);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
req.end();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
199
399
|
export function getDefaultFixtureRoot() {
|
|
200
400
|
return DEFAULT_FIXTURE_ROOT;
|
|
201
401
|
}
|
|
202
402
|
|
|
203
|
-
export function verifyProtocolConformance({
|
|
403
|
+
export async function verifyProtocolConformance({
|
|
204
404
|
targetRoot,
|
|
405
|
+
remote = null,
|
|
406
|
+
token = null,
|
|
407
|
+
timeout = DEFAULT_REMOTE_TIMEOUT_MS,
|
|
205
408
|
requestedTier = 1,
|
|
206
409
|
surface = null,
|
|
207
410
|
fixtureRoot = DEFAULT_FIXTURE_ROOT,
|
|
@@ -209,9 +412,18 @@ export function verifyProtocolConformance({
|
|
|
209
412
|
if (!Number.isInteger(requestedTier) || !VALID_TIERS.has(requestedTier)) {
|
|
210
413
|
throw new Error(`Tier must be 1, 2, or 3. Received "${requestedTier}"`);
|
|
211
414
|
}
|
|
415
|
+
if (!Number.isInteger(timeout) || timeout <= 0) {
|
|
416
|
+
throw new Error(`Timeout must be a positive integer number of milliseconds. Received "${timeout}"`);
|
|
417
|
+
}
|
|
418
|
+
if (!!targetRoot === !!remote) {
|
|
419
|
+
throw new Error('Specify exactly one of targetRoot or remote');
|
|
420
|
+
}
|
|
212
421
|
|
|
213
|
-
const resolvedTargetRoot = resolve(targetRoot);
|
|
214
|
-
const
|
|
422
|
+
const resolvedTargetRoot = targetRoot ? resolve(targetRoot) : null;
|
|
423
|
+
const remoteTarget = remote ? normalizeRemoteBase(remote) : null;
|
|
424
|
+
const localCapabilities = resolvedTargetRoot ? loadLocalCapabilities(resolvedTargetRoot) : null;
|
|
425
|
+
const remoteCapabilities = remoteTarget ? await loadRemoteCapabilities(remoteTarget, token, timeout) : null;
|
|
426
|
+
const capabilities = localCapabilities || remoteCapabilities.capabilities;
|
|
215
427
|
|
|
216
428
|
// Enforce surface claims when capabilities.surfaces exists and --surface is requested
|
|
217
429
|
if (surface && capabilities.surfaces && typeof capabilities.surfaces === 'object') {
|
|
@@ -231,6 +443,7 @@ export function verifyProtocolConformance({
|
|
|
231
443
|
tier_requested: requestedTier,
|
|
232
444
|
timestamp: new Date().toISOString(),
|
|
233
445
|
target_root: resolvedTargetRoot,
|
|
446
|
+
remote: remoteTarget,
|
|
234
447
|
fixture_root: fixtureRoot,
|
|
235
448
|
results: {},
|
|
236
449
|
overall: 'pass',
|
|
@@ -253,7 +466,9 @@ export function verifyProtocolConformance({
|
|
|
253
466
|
}
|
|
254
467
|
|
|
255
468
|
const surfaceSummary = ensureSurfaceSummary(tierSummary, fixture.surface);
|
|
256
|
-
const adapterResult =
|
|
469
|
+
const adapterResult = resolvedTargetRoot
|
|
470
|
+
? executeLocalFixture(resolvedTargetRoot, capabilities.adapter.command, fixture)
|
|
471
|
+
: await executeRemoteFixture(remoteCapabilities.executeUrl, token, timeout, fixture);
|
|
257
472
|
|
|
258
473
|
tierSummary.fixtures_run += 1;
|
|
259
474
|
|
|
@@ -283,6 +283,33 @@ function isAssertionObject(value) {
|
|
|
283
283
|
return value && typeof value === 'object' && !Array.isArray(value) && typeof value.assert === 'string';
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
function matchUnorderedArray(expectedItems, actual) {
|
|
287
|
+
if (!Array.isArray(expectedItems) || !Array.isArray(actual) || expectedItems.length !== actual.length) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const used = new Set();
|
|
292
|
+
|
|
293
|
+
for (const expectedItem of expectedItems) {
|
|
294
|
+
let matchedIndex = -1;
|
|
295
|
+
for (let index = 0; index < actual.length; index += 1) {
|
|
296
|
+
if (used.has(index)) continue;
|
|
297
|
+
if (matchExpected(expectedItem, actual[index])) {
|
|
298
|
+
matchedIndex = index;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (matchedIndex === -1) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
used.add(matchedIndex);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
286
313
|
function matchExpected(expected, actual) {
|
|
287
314
|
if (isAssertionObject(expected)) {
|
|
288
315
|
if (expected.assert === 'nonempty_string') {
|
|
@@ -294,6 +321,9 @@ function matchExpected(expected, actual) {
|
|
|
294
321
|
if (expected.assert === 'present') {
|
|
295
322
|
return actual !== undefined;
|
|
296
323
|
}
|
|
324
|
+
if (expected.assert === 'unordered_array') {
|
|
325
|
+
return matchUnorderedArray(expected.items, actual);
|
|
326
|
+
}
|
|
297
327
|
return false;
|
|
298
328
|
}
|
|
299
329
|
|
|
@@ -520,6 +550,22 @@ function applyManifestFixtureMutations(root, fixture, turnId) {
|
|
|
520
550
|
// Missing files are surfaced by manifest verification, not fixture setup.
|
|
521
551
|
}
|
|
522
552
|
}
|
|
553
|
+
|
|
554
|
+
if (fixture.setup.post_finalize_delete_manifest) {
|
|
555
|
+
try {
|
|
556
|
+
unlinkSync(join(bundleDir, 'MANIFEST.json'));
|
|
557
|
+
} catch {
|
|
558
|
+
// Missing manifest is surfaced by manifest verification, not fixture setup.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const corruptedManifest = fixture.setup.post_finalize_corrupt_manifest?.[turnId];
|
|
563
|
+
if (corruptedManifest !== undefined) {
|
|
564
|
+
const nextManifestContent = typeof corruptedManifest === 'string'
|
|
565
|
+
? corruptedManifest
|
|
566
|
+
: JSON.stringify(corruptedManifest, null, 2);
|
|
567
|
+
writeFileSync(join(bundleDir, 'MANIFEST.json'), nextManifestContent);
|
|
568
|
+
}
|
|
523
569
|
}
|
|
524
570
|
|
|
525
571
|
function executeFixtureOperation(workspace, fixture) {
|
|
@@ -757,6 +803,7 @@ function executeFixtureOperation(workspace, fixture) {
|
|
|
757
803
|
hook_ok: hookResult.ok,
|
|
758
804
|
blocked: hookResult.blocked || false,
|
|
759
805
|
audit_entry: auditEntry,
|
|
806
|
+
audit_entries: hookResult.results || [],
|
|
760
807
|
};
|
|
761
808
|
}
|
|
762
809
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared governed role-resolution contract for operator commands.
|
|
3
|
+
*
|
|
4
|
+
* Keeps `step` and `run` aligned on:
|
|
5
|
+
* - explicit role override validation
|
|
6
|
+
* - routing-legal next-role recommendations
|
|
7
|
+
* - phase entry-role fallback
|
|
8
|
+
* - first-role final fallback
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function getPhaseId(state, config) {
|
|
12
|
+
if (state?.phase) {
|
|
13
|
+
return state.phase;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const firstPhase = config?.phases?.[0];
|
|
17
|
+
if (typeof firstPhase === 'string') {
|
|
18
|
+
return firstPhase;
|
|
19
|
+
}
|
|
20
|
+
if (firstPhase?.id) {
|
|
21
|
+
return firstPhase.id;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveGovernedRole({ override = null, state = null, config }) {
|
|
28
|
+
const roles = Object.keys(config?.roles || {});
|
|
29
|
+
const phase = getPhaseId(state, config);
|
|
30
|
+
const routing = phase ? config?.routing?.[phase] : null;
|
|
31
|
+
const warnings = [];
|
|
32
|
+
|
|
33
|
+
if (override) {
|
|
34
|
+
if (!config?.roles?.[override]) {
|
|
35
|
+
return {
|
|
36
|
+
roleId: null,
|
|
37
|
+
warnings,
|
|
38
|
+
error: `Unknown role: "${override}"`,
|
|
39
|
+
availableRoles: roles,
|
|
40
|
+
phase,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (routing?.allowed_next_roles && !routing.allowed_next_roles.includes(override) && override !== 'human') {
|
|
45
|
+
warnings.push(`role "${override}" is not in allowed_next_roles for phase "${phase}"`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
roleId: override,
|
|
50
|
+
warnings,
|
|
51
|
+
error: null,
|
|
52
|
+
availableRoles: roles,
|
|
53
|
+
phase,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (state?.next_recommended_role && config?.roles?.[state.next_recommended_role]) {
|
|
58
|
+
const recommended = state.next_recommended_role;
|
|
59
|
+
const isLegal = !routing?.allowed_next_roles || routing.allowed_next_roles.includes(recommended);
|
|
60
|
+
if (isLegal) {
|
|
61
|
+
return {
|
|
62
|
+
roleId: recommended,
|
|
63
|
+
warnings,
|
|
64
|
+
error: null,
|
|
65
|
+
availableRoles: roles,
|
|
66
|
+
phase,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (routing?.entry_role && config?.roles?.[routing.entry_role]) {
|
|
72
|
+
return {
|
|
73
|
+
roleId: routing.entry_role,
|
|
74
|
+
warnings,
|
|
75
|
+
error: null,
|
|
76
|
+
availableRoles: roles,
|
|
77
|
+
phase,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (roles.length > 0) {
|
|
82
|
+
warnings.push(
|
|
83
|
+
phase
|
|
84
|
+
? `No entry_role for phase "${phase}". Defaulting to "${roles[0]}".`
|
|
85
|
+
: `No routing phase resolved. Defaulting to "${roles[0]}".`,
|
|
86
|
+
);
|
|
87
|
+
return {
|
|
88
|
+
roleId: roles[0],
|
|
89
|
+
warnings,
|
|
90
|
+
error: null,
|
|
91
|
+
availableRoles: roles,
|
|
92
|
+
phase,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
roleId: null,
|
|
98
|
+
warnings,
|
|
99
|
+
error: 'No roles defined in config.',
|
|
100
|
+
availableRoles: roles,
|
|
101
|
+
phase,
|
|
102
|
+
};
|
|
103
|
+
}
|