agentxchain 2.100.0 → 2.102.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/bin/agentxchain.js +28 -1
- package/package.json +1 -1
- package/src/commands/benchmark-workloads.js +206 -0
- package/src/commands/benchmark.js +775 -0
- package/src/commands/doctor.js +17 -0
- package/src/commands/verify.js +136 -0
- package/src/lib/admission-control.js +247 -0
- package/src/lib/export-diff.js +96 -0
- package/src/lib/export-verifier.js +49 -0
- package/src/lib/export.js +7 -0
- package/src/lib/normalized-config.js +3 -72
- package/src/lib/run-loop.js +8 -0
- package/src/lib/validation.js +5 -3
package/src/commands/doctor.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { loadConfig, loadLock, findProjectRoot } from '../lib/config.js';
|
|
6
6
|
import { validateProject } from '../lib/validation.js';
|
|
7
|
+
import { runAdmissionControl } from '../lib/admission-control.js';
|
|
7
8
|
import { getWatchPid } from './watch.js';
|
|
8
9
|
import { getDashboardPid, getDashboardSession } from './dashboard.js';
|
|
9
10
|
import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-config.js';
|
|
@@ -235,6 +236,22 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
// 10. Admission control — static dead-end detection
|
|
240
|
+
if (normalized) {
|
|
241
|
+
const admission = runAdmissionControl(normalized, rawConfig);
|
|
242
|
+
if (!admission.ok) {
|
|
243
|
+
const errSummary = admission.errors.slice(0, 2).map(e => e.replace(/^ADM-\d+: /, '')).join('; ');
|
|
244
|
+
checks.push({ id: 'admission_control', name: 'Admission control', level: 'fail', detail: errSummary });
|
|
245
|
+
} else if (admission.warnings.length > 0) {
|
|
246
|
+
// ADM-003 warnings are advisory (external approval is a legitimate pattern),
|
|
247
|
+
// so report as info rather than warn to avoid noisy defaults.
|
|
248
|
+
const infoSummary = admission.warnings.slice(0, 2).map(w => w.replace(/^ADM-\d+: /, '')).join('; ');
|
|
249
|
+
checks.push({ id: 'admission_control', name: 'Admission control', level: 'info', detail: infoSummary });
|
|
250
|
+
} else {
|
|
251
|
+
checks.push({ id: 'admission_control', name: 'Admission control', level: 'pass', detail: 'No dead-end configs detected' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
238
255
|
// Compute summary
|
|
239
256
|
const failCount = checks.filter(c => c.level === 'fail').length;
|
|
240
257
|
const warnCount = checks.filter(c => c.level === 'warn').length;
|
package/src/commands/verify.js
CHANGED
|
@@ -6,6 +6,7 @@ import { validateStagedTurnResult } from '../lib/turn-result-validator.js';
|
|
|
6
6
|
import { getTurnStagingResultPath } from '../lib/turn-paths.js';
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
8
|
import { loadExportArtifact, verifyExportArtifact } from '../lib/export-verifier.js';
|
|
9
|
+
import { buildExportDiff, resolveExportArtifact } from '../lib/export-diff.js';
|
|
9
10
|
import { verifyProtocolConformance } from '../lib/protocol-conformance.js';
|
|
10
11
|
import {
|
|
11
12
|
DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS,
|
|
@@ -72,6 +73,18 @@ function emitProtocolVerifyError(format, message) {
|
|
|
72
73
|
console.log(chalk.red(`Protocol verification failed: ${message}`));
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
function emitVerifyDiffError(format, message) {
|
|
77
|
+
if (format === 'json') {
|
|
78
|
+
console.log(JSON.stringify({
|
|
79
|
+
overall: 'error',
|
|
80
|
+
message,
|
|
81
|
+
}, null, 2));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(chalk.red(`Diff verification failed: ${message}`));
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
export async function verifyExportCommand(opts) {
|
|
76
89
|
const format = opts.format || 'text';
|
|
77
90
|
const loaded = loadExportArtifact(opts.input || '-', process.cwd());
|
|
@@ -104,6 +117,75 @@ export async function verifyExportCommand(opts) {
|
|
|
104
117
|
process.exit(result.ok ? 0 : 1);
|
|
105
118
|
}
|
|
106
119
|
|
|
120
|
+
export async function verifyDiffCommand(leftRef, rightRef, opts = {}) {
|
|
121
|
+
const format = opts.format || 'text';
|
|
122
|
+
const leftLoaded = resolveExportArtifact(leftRef);
|
|
123
|
+
if (!leftLoaded.ok) {
|
|
124
|
+
emitVerifyDiffError(format, leftLoaded.error);
|
|
125
|
+
process.exit(2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const rightLoaded = resolveExportArtifact(rightRef);
|
|
129
|
+
if (!rightLoaded.ok) {
|
|
130
|
+
emitVerifyDiffError(format, rightLoaded.error);
|
|
131
|
+
process.exit(2);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (leftLoaded.artifact.export_kind !== rightLoaded.artifact.export_kind) {
|
|
135
|
+
emitVerifyDiffError(
|
|
136
|
+
format,
|
|
137
|
+
`Export kinds do not match: ${leftLoaded.artifact.export_kind} vs ${rightLoaded.artifact.export_kind}`,
|
|
138
|
+
);
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const leftVerification = verifyExportArtifact(leftLoaded.artifact);
|
|
143
|
+
const rightVerification = verifyExportArtifact(rightLoaded.artifact);
|
|
144
|
+
const leftReport = { ...leftVerification.report, input: leftLoaded.resolved_ref };
|
|
145
|
+
const rightReport = { ...rightVerification.report, input: rightLoaded.resolved_ref };
|
|
146
|
+
|
|
147
|
+
let diff = null;
|
|
148
|
+
let overall = 'pass';
|
|
149
|
+
let exitCode = 0;
|
|
150
|
+
let message = null;
|
|
151
|
+
|
|
152
|
+
if (!leftVerification.ok || !rightVerification.ok) {
|
|
153
|
+
overall = 'fail';
|
|
154
|
+
exitCode = 1;
|
|
155
|
+
message = 'Diff skipped because one or both exports failed verification.';
|
|
156
|
+
} else {
|
|
157
|
+
const diffResult = buildExportDiff(leftLoaded.artifact, rightLoaded.artifact, {
|
|
158
|
+
left_ref: leftLoaded.resolved_ref,
|
|
159
|
+
right_ref: rightLoaded.resolved_ref,
|
|
160
|
+
});
|
|
161
|
+
if (!diffResult.ok) {
|
|
162
|
+
emitVerifyDiffError(format, diffResult.error);
|
|
163
|
+
process.exit(2);
|
|
164
|
+
}
|
|
165
|
+
diff = diffResult.diff;
|
|
166
|
+
if (diff.has_regressions) {
|
|
167
|
+
overall = 'fail';
|
|
168
|
+
exitCode = 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const report = {
|
|
173
|
+
overall,
|
|
174
|
+
left: leftReport,
|
|
175
|
+
right: rightReport,
|
|
176
|
+
diff,
|
|
177
|
+
message,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (format === 'json') {
|
|
181
|
+
console.log(JSON.stringify(report, null, 2));
|
|
182
|
+
} else {
|
|
183
|
+
printVerifyDiffReport(report);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
process.exit(exitCode);
|
|
187
|
+
}
|
|
188
|
+
|
|
107
189
|
export async function verifyTurnCommand(turnId, opts = {}) {
|
|
108
190
|
const context = loadProjectContext();
|
|
109
191
|
if (!context) {
|
|
@@ -237,6 +319,54 @@ function printExportReport(report) {
|
|
|
237
319
|
console.log('');
|
|
238
320
|
}
|
|
239
321
|
|
|
322
|
+
function printVerifyDiffReport(report) {
|
|
323
|
+
console.log('');
|
|
324
|
+
console.log(chalk.bold(' AgentXchain Diff Verification'));
|
|
325
|
+
console.log(chalk.dim(' ' + '─'.repeat(41)));
|
|
326
|
+
console.log(chalk.dim(` Left: ${report.left.input}`));
|
|
327
|
+
console.log(chalk.dim(` Right: ${report.right.input}`));
|
|
328
|
+
console.log('');
|
|
329
|
+
|
|
330
|
+
const overallLabel = report.overall === 'pass'
|
|
331
|
+
? chalk.green('PASS')
|
|
332
|
+
: report.overall === 'fail'
|
|
333
|
+
? chalk.red('FAIL')
|
|
334
|
+
: chalk.red('ERROR');
|
|
335
|
+
console.log(` Overall: ${overallLabel}`);
|
|
336
|
+
console.log(` Left export: ${formatVerifyStatus(report.left.overall)}`);
|
|
337
|
+
console.log(` Right export: ${formatVerifyStatus(report.right.overall)}`);
|
|
338
|
+
|
|
339
|
+
if (report.diff) {
|
|
340
|
+
console.log(chalk.dim(` Diff subject: ${report.diff.subject_kind}`));
|
|
341
|
+
console.log(chalk.dim(` Changed: ${report.diff.changed ? 'yes' : 'no'}`));
|
|
342
|
+
console.log(chalk.dim(` Governance regressions: ${report.diff.regression_count}`));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (report.message) {
|
|
346
|
+
console.log(chalk.yellow(` ${report.message}`));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const error of report.left.errors || []) {
|
|
350
|
+
console.log(chalk.red(` left ✗ ${error}`));
|
|
351
|
+
}
|
|
352
|
+
for (const error of report.right.errors || []) {
|
|
353
|
+
console.log(chalk.red(` right ✗ ${error}`));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (report.diff?.regressions?.length > 0) {
|
|
357
|
+
console.log('');
|
|
358
|
+
console.log(chalk.bold.red(' Governance Regressions'));
|
|
359
|
+
for (const regression of report.diff.regressions) {
|
|
360
|
+
const severity = regression.severity === 'error'
|
|
361
|
+
? chalk.red(`[${regression.severity}]`)
|
|
362
|
+
: chalk.yellow(`[${regression.severity}]`);
|
|
363
|
+
console.log(` ${severity} ${regression.id}: ${regression.message}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
console.log('');
|
|
368
|
+
}
|
|
369
|
+
|
|
240
370
|
function resolveTargetTurnId(requestedTurnId, activeTurns) {
|
|
241
371
|
const turnIds = Object.keys(activeTurns);
|
|
242
372
|
|
|
@@ -351,3 +481,9 @@ function formatOutcome(outcome) {
|
|
|
351
481
|
if (outcome === 'mismatch') return chalk.red('mismatch');
|
|
352
482
|
return chalk.yellow('not_reproducible');
|
|
353
483
|
}
|
|
484
|
+
|
|
485
|
+
function formatVerifyStatus(status) {
|
|
486
|
+
if (status === 'pass') return chalk.green('PASS');
|
|
487
|
+
if (status === 'fail') return chalk.red('FAIL');
|
|
488
|
+
return chalk.red(String(status || 'error').toUpperCase());
|
|
489
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admission Control — pre-run static analysis that rejects governed configs
|
|
3
|
+
* which cannot possibly reach completion.
|
|
4
|
+
*
|
|
5
|
+
* Pure function: no filesystem access, no state reads.
|
|
6
|
+
*
|
|
7
|
+
* Checks:
|
|
8
|
+
* ADM-001 No file producer for gated phase
|
|
9
|
+
* ADM-002 Authoritative writer unreachable for owned artifacts
|
|
10
|
+
* ADM-003 Impossible human approval topology (warning only)
|
|
11
|
+
* ADM-004 Owned artifact owner cannot write
|
|
12
|
+
*
|
|
13
|
+
* See .planning/ADMISSION_CONTROL_SPEC.md for full spec.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getEffectiveGateArtifacts } from './gate-evaluator.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run all admission control checks against a governed config.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} config - normalized governed config
|
|
22
|
+
* @param {object} rawConfig - raw agentxchain.json (for workflow_kit, gates, approval_policy)
|
|
23
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[] }}
|
|
24
|
+
*/
|
|
25
|
+
export function runAdmissionControl(config, rawConfig) {
|
|
26
|
+
const errors = [];
|
|
27
|
+
const warnings = [];
|
|
28
|
+
|
|
29
|
+
const routing = config?.routing;
|
|
30
|
+
const gates = config?.gates || rawConfig?.gates;
|
|
31
|
+
const roles = config?.roles;
|
|
32
|
+
const runtimes = config?.runtimes || rawConfig?.runtimes;
|
|
33
|
+
|
|
34
|
+
if (!routing) {
|
|
35
|
+
return { ok: true, errors, warnings };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ADM-001 + ADM-002 + ADM-004: per-phase gate analysis
|
|
39
|
+
if (gates) {
|
|
40
|
+
for (const [phase, route] of Object.entries(routing)) {
|
|
41
|
+
const exitGateId = route?.exit_gate;
|
|
42
|
+
if (!exitGateId || !gates[exitGateId]) continue;
|
|
43
|
+
|
|
44
|
+
const gateDef = gates[exitGateId];
|
|
45
|
+
const effectiveArtifacts = getEffectiveGateArtifacts(config, gateDef, phase);
|
|
46
|
+
const requiredArtifacts = effectiveArtifacts.filter(a => a.required);
|
|
47
|
+
|
|
48
|
+
if (requiredArtifacts.length === 0) continue;
|
|
49
|
+
|
|
50
|
+
// Collect all roles routed to this phase
|
|
51
|
+
const candidateRoleIds = [
|
|
52
|
+
route?.entry_role,
|
|
53
|
+
...(Array.isArray(route?.allowed_next_roles) ? route.allowed_next_roles : []),
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
|
|
56
|
+
const uniqueRoleIds = [...new Set(candidateRoleIds)];
|
|
57
|
+
|
|
58
|
+
// ADM-001: check if any role can produce files
|
|
59
|
+
// Manual runtime roles are excluded — human operators can produce files
|
|
60
|
+
// outside the governed turn mechanism regardless of write_authority.
|
|
61
|
+
// Note: normalized config uses runtime_id, raw config uses runtime.
|
|
62
|
+
const rolesWithAuthority = uniqueRoleIds
|
|
63
|
+
.map(id => {
|
|
64
|
+
const role = roles?.[id];
|
|
65
|
+
const rtKey = role?.runtime_id || role?.runtime;
|
|
66
|
+
return { id, role, runtime: runtimes?.[rtKey] };
|
|
67
|
+
})
|
|
68
|
+
.filter(({ role }) => role);
|
|
69
|
+
|
|
70
|
+
const hasFileProducer = rolesWithAuthority.some(({ role, runtime }) =>
|
|
71
|
+
canRoleProduceFiles(role, runtime));
|
|
72
|
+
|
|
73
|
+
// Only flag non-manual roles as review_only dead-ends
|
|
74
|
+
const nonManualRoles = rolesWithAuthority.filter(({ runtime }) => runtime?.type !== 'manual');
|
|
75
|
+
if (!hasFileProducer && nonManualRoles.length > 0) {
|
|
76
|
+
const roleSummary = nonManualRoles
|
|
77
|
+
.map(({ id, role }) => `${id}:${role.write_authority}`)
|
|
78
|
+
.join(', ');
|
|
79
|
+
const fileSummary = requiredArtifacts.map(a => a.path).join(', ');
|
|
80
|
+
errors.push(
|
|
81
|
+
`ADM-001: Phase "${phase}" gate "${exitGateId}" requires files (${fileSummary}) but all routed roles are review_only (${roleSummary}). No agent can produce the required artifacts.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ADM-002: check owned_by roles are reachable in this phase
|
|
86
|
+
for (const artifact of requiredArtifacts) {
|
|
87
|
+
if (!artifact.owned_by) continue;
|
|
88
|
+
if (!uniqueRoleIds.includes(artifact.owned_by)) {
|
|
89
|
+
errors.push(
|
|
90
|
+
`ADM-002: Phase "${phase}" artifact "${artifact.path}" is owned_by "${artifact.owned_by}" but that role is not in the phase routing (entry_role or allowed_next_roles). The ownership predicate can never be satisfied.`
|
|
91
|
+
);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ownerRole = roles?.[artifact.owned_by];
|
|
96
|
+
if (!ownerRole) continue;
|
|
97
|
+
const ownerRuntimeKey = ownerRole.runtime_id || ownerRole.runtime;
|
|
98
|
+
const ownerRuntime = runtimes?.[ownerRuntimeKey];
|
|
99
|
+
|
|
100
|
+
if (!canRoleProduceFiles(ownerRole, ownerRuntime)) {
|
|
101
|
+
errors.push(
|
|
102
|
+
`ADM-004: Phase "${phase}" artifact "${artifact.path}" is owned_by "${artifact.owned_by}" but that role is ${ownerRole.write_authority} on runtime type "${ownerRuntime?.type || 'unknown'}". The owner can participate in the phase but cannot produce the required artifact.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ADM-003: impossible human approval topology
|
|
110
|
+
checkHumanApprovalTopology(config, rawConfig, routing, gates, roles, runtimes, warnings);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
ok: errors.length === 0,
|
|
114
|
+
errors,
|
|
115
|
+
warnings,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ADM-003: Check whether human approval requirements are reachable.
|
|
121
|
+
* Emits warnings (not errors) because external approval paths are legitimate.
|
|
122
|
+
*/
|
|
123
|
+
function checkHumanApprovalTopology(config, rawConfig, routing, gates, roles, runtimes, warnings) {
|
|
124
|
+
// Determine if any manual runtime exists
|
|
125
|
+
const hasManualRuntime = runtimes
|
|
126
|
+
? Object.values(runtimes).some(rt => rt?.type === 'manual')
|
|
127
|
+
: false;
|
|
128
|
+
|
|
129
|
+
// If there's a manual runtime, human approval is always reachable
|
|
130
|
+
if (hasManualRuntime) return;
|
|
131
|
+
|
|
132
|
+
const approvalPolicy = config?.approval_policy || rawConfig?.approval_policy;
|
|
133
|
+
|
|
134
|
+
// Collect phases/gates that require human approval
|
|
135
|
+
const humanApprovalPoints = [];
|
|
136
|
+
|
|
137
|
+
for (const [phase, route] of Object.entries(routing)) {
|
|
138
|
+
const exitGateId = route?.exit_gate;
|
|
139
|
+
if (!exitGateId || !gates?.[exitGateId]) continue;
|
|
140
|
+
|
|
141
|
+
const gateDef = gates[exitGateId];
|
|
142
|
+
if (gateDef.requires_human_approval) {
|
|
143
|
+
// Check if approval_policy overrides to auto_approve for this transition
|
|
144
|
+
if (!isAutoApprovedByPolicy(approvalPolicy, 'phase_transitions', phase)) {
|
|
145
|
+
humanApprovalPoints.push({ type: 'phase_transition', phase, gate: exitGateId });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check run_completion gates
|
|
151
|
+
const completionGateId = config?.completion_gate || rawConfig?.completion_gate;
|
|
152
|
+
if (completionGateId && gates?.[completionGateId]) {
|
|
153
|
+
const gateDef = gates[completionGateId];
|
|
154
|
+
if (gateDef.requires_human_approval) {
|
|
155
|
+
if (!isAutoApprovedByPolicy(approvalPolicy, 'run_completion', null)) {
|
|
156
|
+
humanApprovalPoints.push({ type: 'run_completion', gate: completionGateId });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Also check approval_policy for explicit require_human actions
|
|
162
|
+
if (approvalPolicy?.phase_transitions) {
|
|
163
|
+
const pt = approvalPolicy.phase_transitions;
|
|
164
|
+
if (pt.default === 'require_human') {
|
|
165
|
+
// Every phase transition defaults to human approval
|
|
166
|
+
for (const phase of Object.keys(routing)) {
|
|
167
|
+
const hasAutoOverride = (pt.rules || []).some(rule =>
|
|
168
|
+
rule.action === 'auto_approve' && matchesPhaseRule(rule, phase));
|
|
169
|
+
if (!hasAutoOverride) {
|
|
170
|
+
const already = humanApprovalPoints.some(p => p.type === 'phase_transition' && p.phase === phase);
|
|
171
|
+
if (!already) {
|
|
172
|
+
humanApprovalPoints.push({ type: 'phase_transition', phase, gate: routing[phase]?.exit_gate || '(policy)' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(pt.rules)) {
|
|
178
|
+
for (const rule of pt.rules) {
|
|
179
|
+
if (rule.action === 'require_human' && rule.from_phase) {
|
|
180
|
+
const already = humanApprovalPoints.some(p => p.type === 'phase_transition' && p.phase === rule.from_phase);
|
|
181
|
+
if (!already) {
|
|
182
|
+
humanApprovalPoints.push({ type: 'phase_transition', phase: rule.from_phase, gate: '(policy)' });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (approvalPolicy?.run_completion?.action === 'require_human') {
|
|
190
|
+
const already = humanApprovalPoints.some(p => p.type === 'run_completion');
|
|
191
|
+
if (!already) {
|
|
192
|
+
humanApprovalPoints.push({ type: 'run_completion', gate: '(policy)' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const point of humanApprovalPoints) {
|
|
197
|
+
if (point.type === 'phase_transition') {
|
|
198
|
+
warnings.push(
|
|
199
|
+
`ADM-003: Phase "${point.phase}" requires human approval (gate "${point.gate}") but no role uses runtime type "manual". The run will pause at pending_phase_transition and require external approval (CLI, dashboard, or webhook).`
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
warnings.push(
|
|
203
|
+
`ADM-003: Run completion requires human approval (gate "${point.gate}") but no role uses runtime type "manual". The run will pause at pending_run_completion and require external approval.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isAutoApprovedByPolicy(approvalPolicy, section, phase) {
|
|
210
|
+
if (!approvalPolicy) return false;
|
|
211
|
+
|
|
212
|
+
if (section === 'phase_transitions') {
|
|
213
|
+
const pt = approvalPolicy.phase_transitions;
|
|
214
|
+
if (!pt) return false;
|
|
215
|
+
|
|
216
|
+
// Check specific rules first
|
|
217
|
+
if (Array.isArray(pt.rules)) {
|
|
218
|
+
for (const rule of pt.rules) {
|
|
219
|
+
if (rule.action === 'auto_approve' && matchesPhaseRule(rule, phase)) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fall back to default
|
|
226
|
+
return pt.default === 'auto_approve';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (section === 'run_completion') {
|
|
230
|
+
return approvalPolicy.run_completion?.action === 'auto_approve';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function matchesPhaseRule(rule, phase) {
|
|
237
|
+
// A rule matches if from_phase is unset or matches the phase
|
|
238
|
+
if (rule.from_phase && rule.from_phase !== phase) return false;
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function canRoleProduceFiles(role, runtime) {
|
|
243
|
+
if (!role) return false;
|
|
244
|
+
return runtime?.type === 'manual'
|
|
245
|
+
|| role.write_authority === 'authoritative'
|
|
246
|
+
|| role.write_authority === 'proposed';
|
|
247
|
+
}
|
package/src/lib/export-diff.js
CHANGED
|
@@ -193,6 +193,7 @@ function normalizeRunExport(artifact) {
|
|
|
193
193
|
run_id: summary.run_id || null,
|
|
194
194
|
status: summary.status || null,
|
|
195
195
|
phase: summary.phase || null,
|
|
196
|
+
workflow_phase_order: Array.isArray(summary.workflow_phase_order) ? summary.workflow_phase_order : null,
|
|
196
197
|
project_name: artifact.project?.name || null,
|
|
197
198
|
project_goal: summary.project_goal || artifact.project?.goal || null,
|
|
198
199
|
provenance_trigger: summary.provenance?.trigger || null,
|
|
@@ -213,6 +214,7 @@ function normalizeRunExport(artifact) {
|
|
|
213
214
|
budget_warn_mode: budgetStatus.warn_mode === true,
|
|
214
215
|
budget_exhausted: budgetStatus.exhausted === true,
|
|
215
216
|
phase_gate_status: normalizeGateStatusMap(phaseGateStatus),
|
|
217
|
+
delegation_missing_decisions: normalizeDelegationMissingMap(summary.delegation_summary),
|
|
216
218
|
};
|
|
217
219
|
}
|
|
218
220
|
|
|
@@ -227,6 +229,7 @@ function normalizeCoordinatorExport(artifact) {
|
|
|
227
229
|
super_run_id: summary.super_run_id || null,
|
|
228
230
|
status: summary.status || null,
|
|
229
231
|
phase: summary.phase || null,
|
|
232
|
+
workflow_phase_order: Array.isArray(summary.workflow_phase_order) ? summary.workflow_phase_order : null,
|
|
230
233
|
project_name: artifact.coordinator?.project_name || null,
|
|
231
234
|
barrier_count: toNumber(summary.barrier_count),
|
|
232
235
|
history_entries: toNumber(summary.history_entries),
|
|
@@ -354,6 +357,27 @@ function normalizeNumericMap(value) {
|
|
|
354
357
|
);
|
|
355
358
|
}
|
|
356
359
|
|
|
360
|
+
function normalizeDelegationMissingMap(summary) {
|
|
361
|
+
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return {};
|
|
362
|
+
const chains = Array.isArray(summary.delegation_chains) ? summary.delegation_chains : [];
|
|
363
|
+
const entries = [];
|
|
364
|
+
for (const chain of chains) {
|
|
365
|
+
const delegations = Array.isArray(chain?.delegations) ? chain.delegations : [];
|
|
366
|
+
for (const delegation of delegations) {
|
|
367
|
+
if (!delegation || typeof delegation !== 'object' || Array.isArray(delegation)) continue;
|
|
368
|
+
const delegationId = typeof delegation.delegation_id === 'string' && delegation.delegation_id.trim()
|
|
369
|
+
? delegation.delegation_id.trim()
|
|
370
|
+
: null;
|
|
371
|
+
if (!delegationId) continue;
|
|
372
|
+
entries.push([
|
|
373
|
+
delegationId,
|
|
374
|
+
normalizeStringArray(delegation.missing_decision_ids),
|
|
375
|
+
]);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right, 'en')));
|
|
379
|
+
}
|
|
380
|
+
|
|
357
381
|
function toNumber(value) {
|
|
358
382
|
return typeof value === 'number' ? value : null;
|
|
359
383
|
}
|
|
@@ -391,6 +415,55 @@ function detectRunRegressions(left, right) {
|
|
|
391
415
|
});
|
|
392
416
|
}
|
|
393
417
|
|
|
418
|
+
const leftHasPhaseOrder = Array.isArray(left.workflow_phase_order) && left.workflow_phase_order.length > 0;
|
|
419
|
+
const rightHasPhaseOrder = Array.isArray(right.workflow_phase_order) && right.workflow_phase_order.length > 0;
|
|
420
|
+
const phaseOrderDrift = leftHasPhaseOrder && rightHasPhaseOrder && !isEqual(left.workflow_phase_order, right.workflow_phase_order);
|
|
421
|
+
|
|
422
|
+
if (phaseOrderDrift) {
|
|
423
|
+
regressions.push({
|
|
424
|
+
id: `REG-PHASE-ORDER-${String(++counter).padStart(3, '0')}`,
|
|
425
|
+
category: 'phase',
|
|
426
|
+
severity: 'warning',
|
|
427
|
+
message: 'Workflow phase order changed between exports; directional phase comparison skipped',
|
|
428
|
+
field: 'workflow_phase_order',
|
|
429
|
+
left: left.workflow_phase_order,
|
|
430
|
+
right: right.workflow_phase_order,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Phase regression: backward movement in workflow phase order
|
|
435
|
+
if (left.phase && right.phase === null) {
|
|
436
|
+
// Phase disappeared — information loss
|
|
437
|
+
regressions.push({
|
|
438
|
+
id: `REG-PHASE-${String(++counter).padStart(3, '0')}`,
|
|
439
|
+
category: 'phase',
|
|
440
|
+
severity: 'warning',
|
|
441
|
+
message: `Phase regressed from "${left.phase}" to null (phase information lost)`,
|
|
442
|
+
field: 'phase',
|
|
443
|
+
left: left.phase,
|
|
444
|
+
right: null,
|
|
445
|
+
});
|
|
446
|
+
} else if (left.phase && right.phase && left.phase !== right.phase) {
|
|
447
|
+
const canCompareDirection = leftHasPhaseOrder && rightHasPhaseOrder && !phaseOrderDrift;
|
|
448
|
+
if (canCompareDirection) {
|
|
449
|
+
const phaseOrder = right.workflow_phase_order;
|
|
450
|
+
const leftIndex = phaseOrder.indexOf(left.phase);
|
|
451
|
+
const rightIndex = phaseOrder.indexOf(right.phase);
|
|
452
|
+
// Only flag when both phases are known and right is earlier than left
|
|
453
|
+
if (leftIndex !== -1 && rightIndex !== -1 && rightIndex < leftIndex) {
|
|
454
|
+
regressions.push({
|
|
455
|
+
id: `REG-PHASE-${String(++counter).padStart(3, '0')}`,
|
|
456
|
+
category: 'phase',
|
|
457
|
+
severity: 'warning',
|
|
458
|
+
message: `Phase moved backward from "${left.phase}" (position ${leftIndex}) to "${right.phase}" (position ${rightIndex})`,
|
|
459
|
+
field: 'phase',
|
|
460
|
+
left: left.phase,
|
|
461
|
+
right: right.phase,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
394
467
|
// Budget warn_mode regression
|
|
395
468
|
if (left.budget_warn_mode === false && right.budget_warn_mode === true) {
|
|
396
469
|
regressions.push({
|
|
@@ -432,6 +505,29 @@ function detectRunRegressions(left, right) {
|
|
|
432
505
|
});
|
|
433
506
|
}
|
|
434
507
|
|
|
508
|
+
// Delegation contract regressions: newly missing required decisions.
|
|
509
|
+
const allDelegationIds = new Set([
|
|
510
|
+
...Object.keys(left.delegation_missing_decisions || {}),
|
|
511
|
+
...Object.keys(right.delegation_missing_decisions || {}),
|
|
512
|
+
]);
|
|
513
|
+
for (const delegationId of allDelegationIds) {
|
|
514
|
+
const leftMissing = normalizeStringArray((left.delegation_missing_decisions || {})[delegationId]);
|
|
515
|
+
const rightMissing = normalizeStringArray((right.delegation_missing_decisions || {})[delegationId]);
|
|
516
|
+
const leftSet = new Set(leftMissing);
|
|
517
|
+
const newlyMissing = rightMissing.filter((decisionId) => !leftSet.has(decisionId));
|
|
518
|
+
if (newlyMissing.length > 0) {
|
|
519
|
+
regressions.push({
|
|
520
|
+
id: `REG-DELEGATION-MISSING-${String(++counter).padStart(3, '0')}`,
|
|
521
|
+
category: 'delegation',
|
|
522
|
+
severity: 'error',
|
|
523
|
+
message: `Delegation "${delegationId}" is now missing required decisions: ${newlyMissing.join(', ')}`,
|
|
524
|
+
field: `delegation_summary.${delegationId}.missing_decision_ids`,
|
|
525
|
+
left: leftMissing,
|
|
526
|
+
right: rightMissing,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
435
531
|
// Gate regressions: passed/approved -> failed/blocked
|
|
436
532
|
const allGateIds = new Set([...Object.keys(left.phase_gate_status || {}), ...Object.keys(right.phase_gate_status || {})]);
|
|
437
533
|
for (const gateId of allGateIds) {
|
|
@@ -31,6 +31,52 @@ function addError(errors, path, message) {
|
|
|
31
31
|
errors.push(`${path}: ${message}`);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function verifyWorkflowPhaseOrder(summary, errors, summaryPath = 'summary') {
|
|
35
|
+
const phaseOrder = summary?.workflow_phase_order;
|
|
36
|
+
if (phaseOrder === undefined || phaseOrder === null) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const path = `${summaryPath}.workflow_phase_order`;
|
|
41
|
+
if (!Array.isArray(phaseOrder)) {
|
|
42
|
+
addError(errors, path, 'must be an array or null');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (phaseOrder.length === 0) {
|
|
47
|
+
addError(errors, path, 'must not be empty when present');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (let index = 0; index < phaseOrder.length; index += 1) {
|
|
53
|
+
const entry = phaseOrder[index];
|
|
54
|
+
const entryPath = `${path}[${index}]`;
|
|
55
|
+
if (typeof entry !== 'string') {
|
|
56
|
+
addError(errors, entryPath, 'must be a string');
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const trimmed = entry.trim();
|
|
60
|
+
if (!trimmed) {
|
|
61
|
+
addError(errors, entryPath, 'must not be blank');
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (trimmed !== entry) {
|
|
65
|
+
addError(errors, entryPath, 'must be trimmed');
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (seen.has(entry)) {
|
|
69
|
+
addError(errors, path, `must not contain duplicate phase "${entry}"`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
seen.add(entry);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (summary.phase !== null && summary.phase !== undefined && !seen.has(summary.phase)) {
|
|
76
|
+
addError(errors, `${summaryPath}.phase`, 'must appear in summary.workflow_phase_order when workflow_phase_order is present');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
34
80
|
function verifyFileEntry(relPath, entry, errors) {
|
|
35
81
|
const path = `files.${relPath}`;
|
|
36
82
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
@@ -539,6 +585,8 @@ function verifyRunExport(artifact, errors) {
|
|
|
539
585
|
addError(errors, 'summary.phase', 'must match state.phase');
|
|
540
586
|
}
|
|
541
587
|
|
|
588
|
+
verifyWorkflowPhaseOrder(artifact.summary, errors);
|
|
589
|
+
|
|
542
590
|
const expectedHistoryEntries = countJsonl(artifact.files, '.agentxchain/history.jsonl');
|
|
543
591
|
const expectedDecisionEntries = countJsonl(artifact.files, '.agentxchain/decision-ledger.jsonl');
|
|
544
592
|
const expectedHookAuditEntries = countJsonl(artifact.files, '.agentxchain/hook-audit.jsonl');
|
|
@@ -628,6 +676,7 @@ function verifyCoordinatorExport(artifact, errors) {
|
|
|
628
676
|
if (artifact.summary.phase !== (coordinatorState?.phase || null)) {
|
|
629
677
|
addError(errors, 'summary.phase', 'must match coordinator state phase');
|
|
630
678
|
}
|
|
679
|
+
verifyWorkflowPhaseOrder(artifact.summary, errors);
|
|
631
680
|
if (!isDeepStrictEqual(artifact.summary.repo_run_statuses, expectedStatuses)) {
|
|
632
681
|
addError(errors, 'summary.repo_run_statuses', 'must match coordinator state repo run statuses');
|
|
633
682
|
}
|
package/src/lib/export.js
CHANGED
|
@@ -454,6 +454,9 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
454
454
|
run_id: state?.run_id || null,
|
|
455
455
|
status: state?.status || null,
|
|
456
456
|
phase: state?.phase || null,
|
|
457
|
+
workflow_phase_order: config.routing && Object.keys(config.routing).length > 0
|
|
458
|
+
? Object.keys(config.routing)
|
|
459
|
+
: null,
|
|
457
460
|
provenance: normalizeRunProvenance(state?.provenance),
|
|
458
461
|
inherited_context: state?.inherited_context || null,
|
|
459
462
|
active_turn_ids: activeTurns,
|
|
@@ -640,6 +643,10 @@ export function buildCoordinatorExport(startDir = process.cwd()) {
|
|
|
640
643
|
super_run_id: coordState?.super_run_id || null,
|
|
641
644
|
status: coordState?.status || null,
|
|
642
645
|
phase: coordState?.phase || null,
|
|
646
|
+
workflow_phase_order: rawConfig.routing && typeof rawConfig.routing === 'object'
|
|
647
|
+
&& Object.keys(rawConfig.routing).length > 0
|
|
648
|
+
? Object.keys(rawConfig.routing)
|
|
649
|
+
: null,
|
|
643
650
|
repo_run_statuses: repoRunStatuses,
|
|
644
651
|
barrier_count: barrierCount,
|
|
645
652
|
history_entries: countJsonl(files, '.agentxchain/multirepo/history.jsonl'),
|