agentxchain 2.45.0 → 2.46.2
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/dashboard/app.js +6 -0
- package/dashboard/components/coordinator-timeouts.js +220 -0
- package/dashboard/components/timeouts.js +201 -0
- package/dashboard/index.html +2 -0
- package/package.json +1 -1
- package/scripts/publish-from-tag.sh +33 -4
- package/src/commands/accept-turn.js +30 -0
- package/src/commands/init.js +8 -1
- package/src/commands/migrate.js +1 -0
- package/src/commands/status.js +49 -0
- package/src/lib/approval-policy.js +139 -0
- package/src/lib/blocked-state.js +35 -0
- package/src/lib/dashboard/bridge-server.js +14 -0
- package/src/lib/dashboard/coordinator-timeout-status.js +139 -0
- package/src/lib/dashboard/timeout-status.js +201 -0
- package/src/lib/governed-state.js +530 -25
- package/src/lib/governed-templates.js +2 -0
- package/src/lib/normalized-config.js +132 -0
- package/src/lib/policy-evaluator.js +330 -0
- package/src/lib/reference-conformance-adapter.js +1 -0
- package/src/lib/repo-observer.js +132 -1
- package/src/lib/report.js +323 -6
- package/src/lib/schema.js +47 -0
- package/src/lib/timeout-evaluator.js +234 -0
- package/src/templates/governed/enterprise-app.json +20 -0
|
@@ -80,6 +80,7 @@ const VALID_SCAFFOLD_BLUEPRINT_KEYS = new Set([
|
|
|
80
80
|
'runtimes',
|
|
81
81
|
'routing',
|
|
82
82
|
'gates',
|
|
83
|
+
'policies',
|
|
83
84
|
'workflow_kit',
|
|
84
85
|
]);
|
|
85
86
|
|
|
@@ -106,6 +107,7 @@ function validateScaffoldBlueprint(scaffoldBlueprint, errors) {
|
|
|
106
107
|
runtimes: scaffoldBlueprint.runtimes,
|
|
107
108
|
routing: scaffoldBlueprint.routing,
|
|
108
109
|
gates: scaffoldBlueprint.gates,
|
|
110
|
+
policies: scaffoldBlueprint.policies,
|
|
109
111
|
workflow_kit: scaffoldBlueprint.workflow_kit,
|
|
110
112
|
});
|
|
111
113
|
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { validateHooksConfig } from './hook-runner.js';
|
|
16
16
|
import { validateNotificationsConfig } from './notification-runner.js';
|
|
17
|
+
import { validatePolicies, normalizePolicies } from './policy-evaluator.js';
|
|
18
|
+
import { validateTimeoutsConfig } from './timeout-evaluator.js';
|
|
17
19
|
import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
|
|
18
20
|
import {
|
|
19
21
|
buildDefaultWorkflowKitArtifactsForPhase,
|
|
@@ -512,6 +514,23 @@ export function validateV4Config(data, projectRoot) {
|
|
|
512
514
|
errors.push(...wkValidation.errors);
|
|
513
515
|
}
|
|
514
516
|
|
|
517
|
+
// Policies (optional but validated if present)
|
|
518
|
+
if (data.policies !== undefined) {
|
|
519
|
+
const policyValidation = validatePolicies(data.policies);
|
|
520
|
+
errors.push(...policyValidation.errors);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Approval Policy (optional but validated if present)
|
|
524
|
+
if (data.approval_policy !== undefined) {
|
|
525
|
+
errors.push(...validateApprovalPolicy(data.approval_policy, data.routing));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Timeouts (optional but validated if present)
|
|
529
|
+
if (data.timeouts !== undefined) {
|
|
530
|
+
const timeoutValidation = validateTimeoutsConfig(data.timeouts, data.routing);
|
|
531
|
+
errors.push(...timeoutValidation.errors);
|
|
532
|
+
}
|
|
533
|
+
|
|
515
534
|
return { ok: errors.length === 0, errors };
|
|
516
535
|
}
|
|
517
536
|
|
|
@@ -684,6 +703,113 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
|
|
|
684
703
|
return { ok: errors.length === 0, errors, warnings };
|
|
685
704
|
}
|
|
686
705
|
|
|
706
|
+
const VALID_APPROVAL_ACTIONS = ['auto_approve', 'require_human'];
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Validate the approval_policy config section.
|
|
710
|
+
* Returns an array of error strings.
|
|
711
|
+
*/
|
|
712
|
+
export function validateApprovalPolicy(ap, routing) {
|
|
713
|
+
const errors = [];
|
|
714
|
+
if (ap === null || ap === undefined) return errors;
|
|
715
|
+
if (typeof ap !== 'object' || Array.isArray(ap)) {
|
|
716
|
+
errors.push('approval_policy must be an object');
|
|
717
|
+
return errors;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const routingPhases = routing ? Object.keys(routing) : [];
|
|
721
|
+
|
|
722
|
+
// phase_transitions
|
|
723
|
+
if (ap.phase_transitions !== undefined) {
|
|
724
|
+
const pt = ap.phase_transitions;
|
|
725
|
+
if (typeof pt !== 'object' || Array.isArray(pt)) {
|
|
726
|
+
errors.push('approval_policy.phase_transitions must be an object');
|
|
727
|
+
} else {
|
|
728
|
+
if (pt.default !== undefined && !VALID_APPROVAL_ACTIONS.includes(pt.default)) {
|
|
729
|
+
errors.push(`approval_policy.phase_transitions.default must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
|
|
730
|
+
}
|
|
731
|
+
if (pt.rules !== undefined) {
|
|
732
|
+
if (!Array.isArray(pt.rules)) {
|
|
733
|
+
errors.push('approval_policy.phase_transitions.rules must be an array');
|
|
734
|
+
} else {
|
|
735
|
+
for (let i = 0; i < pt.rules.length; i++) {
|
|
736
|
+
const rule = pt.rules[i];
|
|
737
|
+
const prefix = `approval_policy.phase_transitions.rules[${i}]`;
|
|
738
|
+
if (!rule || typeof rule !== 'object') {
|
|
739
|
+
errors.push(`${prefix} must be an object`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (!VALID_APPROVAL_ACTIONS.includes(rule.action)) {
|
|
743
|
+
errors.push(`${prefix}.action must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
|
|
744
|
+
}
|
|
745
|
+
if (rule.from_phase !== undefined) {
|
|
746
|
+
if (typeof rule.from_phase !== 'string') {
|
|
747
|
+
errors.push(`${prefix}.from_phase must be a string`);
|
|
748
|
+
} else if (routingPhases.length > 0 && !routingPhases.includes(rule.from_phase)) {
|
|
749
|
+
errors.push(`${prefix}.from_phase "${rule.from_phase}" does not exist in routing`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (rule.to_phase !== undefined) {
|
|
753
|
+
if (typeof rule.to_phase !== 'string') {
|
|
754
|
+
errors.push(`${prefix}.to_phase must be a string`);
|
|
755
|
+
} else if (routingPhases.length > 0 && !routingPhases.includes(rule.to_phase)) {
|
|
756
|
+
errors.push(`${prefix}.to_phase "${rule.to_phase}" does not exist in routing`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (rule.when !== undefined) {
|
|
760
|
+
errors.push(...validateApprovalWhen(rule.when, prefix));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// run_completion
|
|
769
|
+
if (ap.run_completion !== undefined) {
|
|
770
|
+
const rc = ap.run_completion;
|
|
771
|
+
if (typeof rc !== 'object' || Array.isArray(rc)) {
|
|
772
|
+
errors.push('approval_policy.run_completion must be an object');
|
|
773
|
+
} else {
|
|
774
|
+
if (rc.action !== undefined && !VALID_APPROVAL_ACTIONS.includes(rc.action)) {
|
|
775
|
+
errors.push(`approval_policy.run_completion.action must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
|
|
776
|
+
}
|
|
777
|
+
if (rc.when !== undefined) {
|
|
778
|
+
errors.push(...validateApprovalWhen(rc.when, 'approval_policy.run_completion'));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return errors;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function validateApprovalWhen(when, prefix) {
|
|
787
|
+
const errors = [];
|
|
788
|
+
if (typeof when !== 'object' || Array.isArray(when) || when === null) {
|
|
789
|
+
errors.push(`${prefix}.when must be an object`);
|
|
790
|
+
return errors;
|
|
791
|
+
}
|
|
792
|
+
if (when.gate_passed !== undefined && typeof when.gate_passed !== 'boolean') {
|
|
793
|
+
errors.push(`${prefix}.when.gate_passed must be a boolean`);
|
|
794
|
+
}
|
|
795
|
+
if (when.roles_participated !== undefined) {
|
|
796
|
+
if (!Array.isArray(when.roles_participated)) {
|
|
797
|
+
errors.push(`${prefix}.when.roles_participated must be an array of role IDs`);
|
|
798
|
+
} else {
|
|
799
|
+
for (const r of when.roles_participated) {
|
|
800
|
+
if (typeof r !== 'string' || !r.trim()) {
|
|
801
|
+
errors.push(`${prefix}.when.roles_participated entries must be non-empty strings`);
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (when.all_phases_visited !== undefined && typeof when.all_phases_visited !== 'boolean') {
|
|
808
|
+
errors.push(`${prefix}.when.all_phases_visited must be a boolean`);
|
|
809
|
+
}
|
|
810
|
+
return errors;
|
|
811
|
+
}
|
|
812
|
+
|
|
687
813
|
/**
|
|
688
814
|
* Normalize a legacy v3 config into the internal shape.
|
|
689
815
|
* Does NOT modify the original file — this is a read-time transformation.
|
|
@@ -725,6 +851,9 @@ export function normalizeV3(raw) {
|
|
|
725
851
|
hooks: {},
|
|
726
852
|
notifications: {},
|
|
727
853
|
budget: null,
|
|
854
|
+
policies: [],
|
|
855
|
+
approval_policy: null,
|
|
856
|
+
timeouts: null,
|
|
728
857
|
workflow_kit: normalizeWorkflowKit(undefined, DEFAULT_PHASES),
|
|
729
858
|
retention: {
|
|
730
859
|
talk_strategy: 'append_only',
|
|
@@ -789,6 +918,9 @@ export function normalizeV4(raw) {
|
|
|
789
918
|
hooks: raw.hooks || {},
|
|
790
919
|
notifications: raw.notifications || {},
|
|
791
920
|
budget: raw.budget || null,
|
|
921
|
+
policies: normalizePolicies(raw.policies),
|
|
922
|
+
approval_policy: raw.approval_policy || null,
|
|
923
|
+
timeouts: raw.timeouts || null,
|
|
792
924
|
workflow_kit: normalizeWorkflowKit(raw.workflow_kit, routingPhases),
|
|
793
925
|
retention: raw.retention || {
|
|
794
926
|
talk_strategy: 'append_only',
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy evaluator — declarative governance rules for turn acceptance.
|
|
3
|
+
*
|
|
4
|
+
* Policies are config-driven rules that evaluate on every turn acceptance.
|
|
5
|
+
* Gates evaluate at phase boundaries. Hooks run external commands.
|
|
6
|
+
* Policies evaluate built-in governance rules on every accepted turn.
|
|
7
|
+
*
|
|
8
|
+
* Pure functions only — no I/O, no side effects.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Registry of built-in rule evaluators.
|
|
13
|
+
* Each evaluator: (params, context) → { triggered: boolean, message: string }
|
|
14
|
+
*/
|
|
15
|
+
const RULE_EVALUATORS = {
|
|
16
|
+
max_turns_per_phase: (params, ctx) => {
|
|
17
|
+
const count = ctx.history.filter(
|
|
18
|
+
(entry) => entry.phase === ctx.currentPhase,
|
|
19
|
+
).length;
|
|
20
|
+
if (count >= params.limit) {
|
|
21
|
+
return {
|
|
22
|
+
triggered: true,
|
|
23
|
+
message: `phase "${ctx.currentPhase}" has reached ${count}/${params.limit} accepted turns`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { triggered: false, message: '' };
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
max_total_turns: (params, ctx) => {
|
|
30
|
+
const count = ctx.history.length;
|
|
31
|
+
if (count >= params.limit) {
|
|
32
|
+
return {
|
|
33
|
+
triggered: true,
|
|
34
|
+
message: `run has reached ${count}/${params.limit} total accepted turns`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return { triggered: false, message: '' };
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
max_consecutive_same_role: (params, ctx) => {
|
|
41
|
+
const role = ctx.turnRole;
|
|
42
|
+
let consecutive = 0;
|
|
43
|
+
for (let i = ctx.history.length - 1; i >= 0; i--) {
|
|
44
|
+
if (ctx.history[i].role === role) {
|
|
45
|
+
consecutive++;
|
|
46
|
+
} else {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// The current turn (not yet in history) adds one more
|
|
51
|
+
consecutive += 1;
|
|
52
|
+
if (consecutive > params.limit) {
|
|
53
|
+
return {
|
|
54
|
+
triggered: true,
|
|
55
|
+
message: `role "${role}" has ${consecutive} consecutive turns (limit: ${params.limit})`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { triggered: false, message: '' };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
max_cost_per_turn: (params, ctx) => {
|
|
62
|
+
const cost = ctx.turnCostUsd;
|
|
63
|
+
if (cost != null && cost > params.limit_usd) {
|
|
64
|
+
return {
|
|
65
|
+
triggered: true,
|
|
66
|
+
message: `turn cost $${cost.toFixed(2)} exceeds limit $${params.limit_usd.toFixed(2)}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { triggered: false, message: '' };
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
require_status: (params, ctx) => {
|
|
73
|
+
if (!params.allowed.includes(ctx.turnStatus)) {
|
|
74
|
+
return {
|
|
75
|
+
triggered: true,
|
|
76
|
+
message: `status "${ctx.turnStatus}" is not in allowed set [${params.allowed.join(', ')}]`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { triggered: false, message: '' };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const VALID_POLICY_RULES = Object.keys(RULE_EVALUATORS);
|
|
84
|
+
export const VALID_POLICY_ACTIONS = ['block', 'warn', 'escalate'];
|
|
85
|
+
export const VALID_POLICY_TURN_STATUSES = [
|
|
86
|
+
'completed',
|
|
87
|
+
'blocked',
|
|
88
|
+
'needs_human',
|
|
89
|
+
'failed',
|
|
90
|
+
];
|
|
91
|
+
const VALID_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate a single policy definition at config load time.
|
|
95
|
+
* Returns an array of error strings (empty if valid).
|
|
96
|
+
*/
|
|
97
|
+
export function validatePolicy(policy, index) {
|
|
98
|
+
const errors = [];
|
|
99
|
+
const prefix = `policies[${index}]`;
|
|
100
|
+
|
|
101
|
+
if (!policy || typeof policy !== 'object') {
|
|
102
|
+
return [`${prefix}: must be an object`];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof policy.id !== 'string' || !VALID_ID_PATTERN.test(policy.id)) {
|
|
106
|
+
errors.push(`${prefix}: id must be a lowercase kebab-case string`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!VALID_POLICY_RULES.includes(policy.rule)) {
|
|
110
|
+
errors.push(
|
|
111
|
+
`${prefix}: unknown rule "${policy.rule}"; valid rules: ${VALID_POLICY_RULES.join(', ')}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!VALID_POLICY_ACTIONS.includes(policy.action)) {
|
|
116
|
+
errors.push(
|
|
117
|
+
`${prefix}: action must be one of ${VALID_POLICY_ACTIONS.join(', ')}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Rule-specific param validation
|
|
122
|
+
if (VALID_POLICY_RULES.includes(policy.rule)) {
|
|
123
|
+
const paramErrors = validatePolicyParams(policy.rule, policy.params, prefix);
|
|
124
|
+
errors.push(...paramErrors);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Scope validation (optional)
|
|
128
|
+
if (policy.scope != null) {
|
|
129
|
+
if (typeof policy.scope !== 'object') {
|
|
130
|
+
errors.push(`${prefix}: scope must be an object`);
|
|
131
|
+
} else {
|
|
132
|
+
if (policy.scope.phases != null && !Array.isArray(policy.scope.phases)) {
|
|
133
|
+
errors.push(`${prefix}: scope.phases must be an array`);
|
|
134
|
+
}
|
|
135
|
+
if (policy.scope.roles != null && !Array.isArray(policy.scope.roles)) {
|
|
136
|
+
errors.push(`${prefix}: scope.roles must be an array`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return errors;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validatePolicyParams(rule, params, prefix) {
|
|
145
|
+
const errors = [];
|
|
146
|
+
|
|
147
|
+
switch (rule) {
|
|
148
|
+
case 'max_turns_per_phase':
|
|
149
|
+
case 'max_total_turns':
|
|
150
|
+
if (!params || typeof params.limit !== 'number' || params.limit < 1) {
|
|
151
|
+
errors.push(`${prefix}: params.limit must be a number >= 1`);
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case 'max_consecutive_same_role':
|
|
156
|
+
if (!params || typeof params.limit !== 'number' || params.limit < 1) {
|
|
157
|
+
errors.push(`${prefix}: params.limit must be a number >= 1`);
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
|
|
161
|
+
case 'max_cost_per_turn':
|
|
162
|
+
if (
|
|
163
|
+
!params ||
|
|
164
|
+
typeof params.limit_usd !== 'number' ||
|
|
165
|
+
params.limit_usd <= 0
|
|
166
|
+
) {
|
|
167
|
+
errors.push(`${prefix}: params.limit_usd must be a number > 0`);
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case 'require_status':
|
|
172
|
+
if (
|
|
173
|
+
!params ||
|
|
174
|
+
!Array.isArray(params.allowed) ||
|
|
175
|
+
params.allowed.length === 0
|
|
176
|
+
) {
|
|
177
|
+
errors.push(`${prefix}: params.allowed must be a non-empty array`);
|
|
178
|
+
} else {
|
|
179
|
+
for (const status of params.allowed) {
|
|
180
|
+
if (!VALID_POLICY_TURN_STATUSES.includes(status)) {
|
|
181
|
+
errors.push(
|
|
182
|
+
`${prefix}: params.allowed contains invalid status "${status}"; valid statuses: ${VALID_POLICY_TURN_STATUSES.join(', ')}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return errors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate the full policies array at config load time.
|
|
195
|
+
* Returns { ok: boolean, errors: string[] }.
|
|
196
|
+
*/
|
|
197
|
+
export function validatePolicies(policies) {
|
|
198
|
+
if (policies == null) {
|
|
199
|
+
return { ok: true, errors: [] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!Array.isArray(policies)) {
|
|
203
|
+
return { ok: false, errors: ['policies must be an array'] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const errors = [];
|
|
207
|
+
const ids = new Set();
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < policies.length; i++) {
|
|
210
|
+
const policyErrors = validatePolicy(policies[i], i);
|
|
211
|
+
errors.push(...policyErrors);
|
|
212
|
+
|
|
213
|
+
if (policies[i]?.id) {
|
|
214
|
+
if (ids.has(policies[i].id)) {
|
|
215
|
+
errors.push(`policies[${i}]: duplicate id "${policies[i].id}"`);
|
|
216
|
+
}
|
|
217
|
+
ids.add(policies[i].id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { ok: errors.length === 0, errors };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Evaluate all policies against the current turn context.
|
|
226
|
+
*
|
|
227
|
+
* @param {Array} policies - normalized policies from config
|
|
228
|
+
* @param {object} context
|
|
229
|
+
* @param {string} context.currentPhase
|
|
230
|
+
* @param {string} context.turnRole - role of the turn being accepted
|
|
231
|
+
* @param {string} context.turnStatus - status from turn result
|
|
232
|
+
* @param {number|null} context.turnCostUsd - cost from turn result
|
|
233
|
+
* @param {Array} context.history - accepted history entries
|
|
234
|
+
* @returns {PolicyEvaluationResult}
|
|
235
|
+
*
|
|
236
|
+
* @typedef {object} PolicyEvaluationResult
|
|
237
|
+
* @property {boolean} ok - true if no block/escalate violations
|
|
238
|
+
* @property {PolicyViolation[]} violations - all triggered policies
|
|
239
|
+
* @property {PolicyViolation[]} blocks - violations with action "block"
|
|
240
|
+
* @property {PolicyViolation[]} escalations - violations with action "escalate"
|
|
241
|
+
* @property {PolicyViolation[]} warnings - violations with action "warn"
|
|
242
|
+
*
|
|
243
|
+
* @typedef {object} PolicyViolation
|
|
244
|
+
* @property {string} policy_id
|
|
245
|
+
* @property {string} rule
|
|
246
|
+
* @property {string} action
|
|
247
|
+
* @property {string} message
|
|
248
|
+
*/
|
|
249
|
+
export function evaluatePolicies(policies, context) {
|
|
250
|
+
const result = {
|
|
251
|
+
ok: true,
|
|
252
|
+
violations: [],
|
|
253
|
+
blocks: [],
|
|
254
|
+
escalations: [],
|
|
255
|
+
warnings: [],
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (!Array.isArray(policies) || policies.length === 0) {
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const policy of policies) {
|
|
263
|
+
// Scope check: skip if out of scope
|
|
264
|
+
if (policy.scope) {
|
|
265
|
+
if (
|
|
266
|
+
Array.isArray(policy.scope.phases) &&
|
|
267
|
+
policy.scope.phases.length > 0 &&
|
|
268
|
+
!policy.scope.phases.includes(context.currentPhase)
|
|
269
|
+
) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
Array.isArray(policy.scope.roles) &&
|
|
274
|
+
policy.scope.roles.length > 0 &&
|
|
275
|
+
!policy.scope.roles.includes(context.turnRole)
|
|
276
|
+
) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const evaluator = RULE_EVALUATORS[policy.rule];
|
|
282
|
+
if (!evaluator) {
|
|
283
|
+
continue; // Unknown rules caught at config validation
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const evaluation = evaluator(policy.params || {}, context);
|
|
287
|
+
if (!evaluation.triggered) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const violation = {
|
|
292
|
+
policy_id: policy.id,
|
|
293
|
+
rule: policy.rule,
|
|
294
|
+
action: policy.action,
|
|
295
|
+
message:
|
|
296
|
+
policy.message ||
|
|
297
|
+
`Policy "${policy.id}": ${evaluation.message}`,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
result.violations.push(violation);
|
|
301
|
+
|
|
302
|
+
switch (policy.action) {
|
|
303
|
+
case 'block':
|
|
304
|
+
result.blocks.push(violation);
|
|
305
|
+
break;
|
|
306
|
+
case 'escalate':
|
|
307
|
+
result.escalations.push(violation);
|
|
308
|
+
break;
|
|
309
|
+
case 'warn':
|
|
310
|
+
result.warnings.push(violation);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
result.ok = result.blocks.length === 0 && result.escalations.length === 0;
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Normalize policies config: null/undefined → [], validate, return.
|
|
321
|
+
*/
|
|
322
|
+
export function normalizePolicies(raw) {
|
|
323
|
+
if (raw == null) {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
if (!Array.isArray(raw)) {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
return raw;
|
|
330
|
+
}
|
|
@@ -139,6 +139,7 @@ function inflateState(rawState = {}, config) {
|
|
|
139
139
|
blocked_on: rawState.blocked_on ?? null,
|
|
140
140
|
blocked_reason: rawState.blocked_reason ?? null,
|
|
141
141
|
escalation: rawState.escalation ?? null,
|
|
142
|
+
last_gate_failure: rawState.last_gate_failure ?? null,
|
|
142
143
|
accepted_sequence: rawState.accepted_sequence ?? 0,
|
|
143
144
|
turn_sequence: rawState.turn_sequence ?? 0,
|
|
144
145
|
budget_reservations: rawState.budget_reservations ?? {},
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -121,6 +121,7 @@ export function observeChanges(root, baseline) {
|
|
|
121
121
|
// Non-git project — no observation possible
|
|
122
122
|
return {
|
|
123
123
|
files_changed: [],
|
|
124
|
+
file_markers: {},
|
|
124
125
|
head_ref: null,
|
|
125
126
|
diff_summary: null,
|
|
126
127
|
observation_available: false,
|
|
@@ -162,6 +163,7 @@ export function observeChanges(root, baseline) {
|
|
|
162
163
|
|
|
163
164
|
return {
|
|
164
165
|
files_changed: actorFiles.sort(),
|
|
166
|
+
file_markers: buildFileMarkers(root, actorFiles),
|
|
165
167
|
head_ref: currentHead,
|
|
166
168
|
diff_summary: diffSummary,
|
|
167
169
|
observation_available: true,
|
|
@@ -169,6 +171,119 @@ export function observeChanges(root, baseline) {
|
|
|
169
171
|
};
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = []) {
|
|
175
|
+
const observedFiles = Array.isArray(observation?.files_changed) ? observation.files_changed : [];
|
|
176
|
+
if (observedFiles.length === 0) {
|
|
177
|
+
return observation;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const concurrentIds = new Set(
|
|
181
|
+
Array.isArray(currentTurn?.concurrent_with) ? currentTurn.concurrent_with : [],
|
|
182
|
+
);
|
|
183
|
+
if (concurrentIds.size === 0) {
|
|
184
|
+
return observation;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const assignedSequence = Number.isInteger(currentTurn?.assigned_sequence)
|
|
188
|
+
? currentTurn.assigned_sequence
|
|
189
|
+
: 0;
|
|
190
|
+
const acceptedConcurrentSiblings = (Array.isArray(historyEntries) ? historyEntries : [])
|
|
191
|
+
.filter((entry) => (
|
|
192
|
+
Number.isInteger(entry?.accepted_sequence)
|
|
193
|
+
&& entry.accepted_sequence > assignedSequence
|
|
194
|
+
&& concurrentIds.has(entry.turn_id)
|
|
195
|
+
))
|
|
196
|
+
.sort((left, right) => left.accepted_sequence - right.accepted_sequence);
|
|
197
|
+
|
|
198
|
+
if (acceptedConcurrentSiblings.length === 0) {
|
|
199
|
+
return observation;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const siblingMarkersByFile = new Map();
|
|
203
|
+
for (const entry of acceptedConcurrentSiblings) {
|
|
204
|
+
const siblingFiles = Array.isArray(entry?.observed_artifact?.files_changed)
|
|
205
|
+
? entry.observed_artifact.files_changed
|
|
206
|
+
: Array.isArray(entry?.files_changed)
|
|
207
|
+
? entry.files_changed
|
|
208
|
+
: [];
|
|
209
|
+
const siblingMarkers = entry?.observed_artifact?.file_markers;
|
|
210
|
+
if (!siblingMarkers || typeof siblingMarkers !== 'object' || Array.isArray(siblingMarkers)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
for (const filePath of siblingFiles) {
|
|
214
|
+
if (typeof siblingMarkers[filePath] === 'string' && siblingMarkers[filePath].length > 0) {
|
|
215
|
+
siblingMarkersByFile.set(filePath, siblingMarkers[filePath]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (siblingMarkersByFile.size === 0) {
|
|
221
|
+
return observation;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const nextFiles = [];
|
|
225
|
+
const nextMarkers = {};
|
|
226
|
+
const attributedToConcurrentSiblings = [];
|
|
227
|
+
const observationMarkers = observation?.file_markers && typeof observation.file_markers === 'object'
|
|
228
|
+
? observation.file_markers
|
|
229
|
+
: {};
|
|
230
|
+
|
|
231
|
+
for (const filePath of observedFiles) {
|
|
232
|
+
const currentMarker = observationMarkers[filePath];
|
|
233
|
+
const siblingMarker = siblingMarkersByFile.get(filePath);
|
|
234
|
+
if (typeof siblingMarker === 'string' && siblingMarker === currentMarker) {
|
|
235
|
+
attributedToConcurrentSiblings.push(filePath);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
nextFiles.push(filePath);
|
|
239
|
+
if (typeof currentMarker === 'string') {
|
|
240
|
+
nextMarkers[filePath] = currentMarker;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (attributedToConcurrentSiblings.length === 0) {
|
|
245
|
+
return observation;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
...observation,
|
|
250
|
+
files_changed: nextFiles,
|
|
251
|
+
file_markers: nextMarkers,
|
|
252
|
+
attributed_to_concurrent_siblings: attributedToConcurrentSiblings,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build the file set used for acceptance-time conflict detection.
|
|
258
|
+
*
|
|
259
|
+
* Parallel attribution removes unchanged sibling files from the current turn's
|
|
260
|
+
* observed set so declared-vs-observed checks stay truthful. Conflict detection
|
|
261
|
+
* is stricter: if the agent declared a file that still appears in the raw
|
|
262
|
+
* baseline-to-now workspace union, that overlap must remain conflict-eligible
|
|
263
|
+
* even if the current file contents match a sibling's accepted marker.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} rawObservation — unfiltered observeChanges() result
|
|
266
|
+
* @param {object} attributedObservation — result after attributeObservedChangesToTurn()
|
|
267
|
+
* @param {string[]} declaredFiles — turnResult.files_changed
|
|
268
|
+
* @returns {string[]}
|
|
269
|
+
*/
|
|
270
|
+
export function buildConflictCandidateFiles(rawObservation, attributedObservation, declaredFiles = []) {
|
|
271
|
+
const conflictFiles = new Set(
|
|
272
|
+
Array.isArray(attributedObservation?.files_changed) ? attributedObservation.files_changed : [],
|
|
273
|
+
);
|
|
274
|
+
const rawObserved = new Set(
|
|
275
|
+
Array.isArray(rawObservation?.files_changed) ? rawObservation.files_changed : [],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
for (const filePath of declaredFiles || []) {
|
|
279
|
+
if (rawObserved.has(filePath)) {
|
|
280
|
+
conflictFiles.add(filePath);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return [...conflictFiles].sort();
|
|
285
|
+
}
|
|
286
|
+
|
|
172
287
|
/**
|
|
173
288
|
* Classify observed file changes into added, modified, and deleted.
|
|
174
289
|
*
|
|
@@ -284,13 +399,21 @@ function getFilteredChanges(root, baseRef, filter) {
|
|
|
284
399
|
* @returns {object}
|
|
285
400
|
*/
|
|
286
401
|
export function buildObservedArtifact(observation, baseline) {
|
|
287
|
-
|
|
402
|
+
const artifact = {
|
|
288
403
|
derived_by: 'orchestrator',
|
|
289
404
|
baseline_ref: baseline?.head_ref ? `git:${baseline.head_ref}` : null,
|
|
290
405
|
accepted_ref: observation.head_ref ? `git:${observation.head_ref}` : 'workspace:dirty',
|
|
291
406
|
files_changed: observation.files_changed,
|
|
407
|
+
file_markers: observation.file_markers || {},
|
|
292
408
|
diff_summary: observation.diff_summary,
|
|
293
409
|
};
|
|
410
|
+
// Preserve parallel attribution so operators can see which files were
|
|
411
|
+
// attributed to concurrent siblings and excluded from this turn.
|
|
412
|
+
const attributed = observation.attributed_to_concurrent_siblings;
|
|
413
|
+
if (Array.isArray(attributed) && attributed.length > 0) {
|
|
414
|
+
artifact.attributed_to_concurrent_siblings = attributed;
|
|
415
|
+
}
|
|
416
|
+
return artifact;
|
|
294
417
|
}
|
|
295
418
|
|
|
296
419
|
// ── Verification Normalization ──────────────────────────────────────────────
|
|
@@ -572,6 +695,14 @@ function getWorkspaceFileMarker(root, filePath) {
|
|
|
572
695
|
}
|
|
573
696
|
}
|
|
574
697
|
|
|
698
|
+
function buildFileMarkers(root, filePaths) {
|
|
699
|
+
const markers = {};
|
|
700
|
+
for (const filePath of filePaths || []) {
|
|
701
|
+
markers[filePath] = getWorkspaceFileMarker(root, filePath);
|
|
702
|
+
}
|
|
703
|
+
return markers;
|
|
704
|
+
}
|
|
705
|
+
|
|
575
706
|
function getUntrackedFiles(root) {
|
|
576
707
|
try {
|
|
577
708
|
const result = execSync('git ls-files --others --exclude-standard', {
|