agentxchain 2.108.0 → 2.109.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 +2 -0
- package/dashboard/app.js +44 -4
- package/dashboard/components/blocked.js +55 -0
- package/dashboard/components/gate.js +49 -0
- package/dashboard/components/timeouts.js +21 -3
- package/package.json +1 -1
- package/src/commands/approve-completion.js +40 -1
- package/src/commands/approve-transition.js +44 -1
- package/src/commands/status.js +50 -4
- package/src/commands/step.js +2 -0
- package/src/lib/dashboard/bridge-server.js +49 -0
- package/src/lib/dashboard/gate-action-reader.js +58 -0
- package/src/lib/dashboard/timeout-status.js +47 -18
- package/src/lib/gate-actions.js +232 -0
- package/src/lib/governed-state.js +157 -3
- package/src/lib/normalized-config.js +6 -1
- package/src/lib/notification-runner.js +162 -6
- package/src/lib/report.js +47 -0
- package/src/lib/run-loop.js +3 -0
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
40
40
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
|
|
41
41
|
import { runHooks } from './hook-runner.js';
|
|
42
|
-
import { emitNotifications } from './notification-runner.js';
|
|
42
|
+
import { emitNotifications, clearSlaReminders } from './notification-runner.js';
|
|
43
43
|
import { emitRunEvent } from './run-events.js';
|
|
44
44
|
import { writeSessionCheckpoint } from './session-checkpoint.js';
|
|
45
45
|
import { recordRunHistory } from './run-history.js';
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
replayVerificationMachineEvidence,
|
|
55
55
|
summarizeVerificationReplay,
|
|
56
56
|
} from './verification-replay.js';
|
|
57
|
+
import { executeGateActions } from './gate-actions.js';
|
|
57
58
|
|
|
58
59
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
59
60
|
|
|
@@ -1273,6 +1274,67 @@ function canApprovePendingGate(state) {
|
|
|
1273
1274
|
return state?.status === 'paused' || state?.status === 'blocked';
|
|
1274
1275
|
}
|
|
1275
1276
|
|
|
1277
|
+
function blockRunForGateActionFailure(root, state, gateFailure, config) {
|
|
1278
|
+
const gateType = gateFailure.gate_type === 'run_completion' ? 'run_completion' : 'phase_transition';
|
|
1279
|
+
const recoveryAction = gateType === 'run_completion'
|
|
1280
|
+
? 'agentxchain approve-completion'
|
|
1281
|
+
: 'agentxchain approve-transition';
|
|
1282
|
+
const actionLabel = gateFailure.action_label || gateFailure.command || gateFailure.gate_id || 'gate action';
|
|
1283
|
+
const blockedAt = gateFailure.timestamp || new Date().toISOString();
|
|
1284
|
+
const blockedState = {
|
|
1285
|
+
...state,
|
|
1286
|
+
status: 'blocked',
|
|
1287
|
+
blocked_on: `gate_action:${gateFailure.gate_id || 'unknown'}`,
|
|
1288
|
+
blocked_reason: {
|
|
1289
|
+
category: 'gate_action_failed',
|
|
1290
|
+
blocked_at: blockedAt,
|
|
1291
|
+
turn_id: gateFailure.requested_by_turn || null,
|
|
1292
|
+
detail: `Gate action failed for "${gateFailure.gate_id || 'unknown'}": ${actionLabel}`,
|
|
1293
|
+
recovery: {
|
|
1294
|
+
typed_reason: 'gate_action_failed',
|
|
1295
|
+
owner: 'human',
|
|
1296
|
+
recovery_action: recoveryAction,
|
|
1297
|
+
turn_retained: false,
|
|
1298
|
+
detail: `${gateFailure.gate_id || 'unknown'} action ${gateFailure.action_index || '?'} (${actionLabel})`,
|
|
1299
|
+
},
|
|
1300
|
+
gate_action: {
|
|
1301
|
+
attempt_id: gateFailure.attempt_id || null,
|
|
1302
|
+
gate_id: gateFailure.gate_id || null,
|
|
1303
|
+
gate_type: gateType,
|
|
1304
|
+
action_index: gateFailure.action_index || null,
|
|
1305
|
+
action_label: gateFailure.action_label || null,
|
|
1306
|
+
command: gateFailure.command || null,
|
|
1307
|
+
exit_code: gateFailure.exit_code ?? null,
|
|
1308
|
+
stderr_tail: gateFailure.stderr_tail || null,
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
writeState(root, blockedState);
|
|
1314
|
+
emitBlockedNotification(root, config, blockedState, {
|
|
1315
|
+
category: 'gate_action_failed',
|
|
1316
|
+
blockedOn: blockedState.blocked_on,
|
|
1317
|
+
recovery: blockedState.blocked_reason.recovery,
|
|
1318
|
+
});
|
|
1319
|
+
emitRunEvent(root, 'run_blocked', {
|
|
1320
|
+
run_id: blockedState.run_id,
|
|
1321
|
+
phase: blockedState.phase,
|
|
1322
|
+
status: blockedState.status,
|
|
1323
|
+
turn: gateFailure.requested_by_turn
|
|
1324
|
+
? { turn_id: gateFailure.requested_by_turn, role_id: null }
|
|
1325
|
+
: undefined,
|
|
1326
|
+
payload: {
|
|
1327
|
+
category: 'gate_action_failed',
|
|
1328
|
+
gate_id: gateFailure.gate_id || null,
|
|
1329
|
+
gate_type: gateType,
|
|
1330
|
+
action_index: gateFailure.action_index || null,
|
|
1331
|
+
exit_code: gateFailure.exit_code ?? null,
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
return blockedState;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1276
1338
|
function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId, turnRetained }) {
|
|
1277
1339
|
const isTamper = errorCode?.includes('_tamper');
|
|
1278
1340
|
const pendingPhaseTransition = state?.pending_phase_transition;
|
|
@@ -3820,9 +3882,10 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3820
3882
|
*
|
|
3821
3883
|
* @param {string} root - project root directory
|
|
3822
3884
|
* @param {object} [config] - normalized config (optional; required for hook support)
|
|
3885
|
+
* @param {object} [opts] - optional execution controls
|
|
3823
3886
|
* @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, transition?: object, hookResults?: object }}
|
|
3824
3887
|
*/
|
|
3825
|
-
export function approvePhaseTransition(root, config) {
|
|
3888
|
+
export function approvePhaseTransition(root, config, opts = {}) {
|
|
3826
3889
|
const state = readState(root);
|
|
3827
3890
|
if (!state) {
|
|
3828
3891
|
return { ok: false, error: 'No governed state.json found' };
|
|
@@ -3836,6 +3899,23 @@ export function approvePhaseTransition(root, config) {
|
|
|
3836
3899
|
|
|
3837
3900
|
const transition = state.pending_phase_transition;
|
|
3838
3901
|
|
|
3902
|
+
if (opts.dryRun) {
|
|
3903
|
+
const gateActions = executeGateActions(root, config, {
|
|
3904
|
+
gateId: transition.gate,
|
|
3905
|
+
gateType: 'phase_transition',
|
|
3906
|
+
phase: state.phase,
|
|
3907
|
+
requestedByTurn: transition.requested_by_turn || null,
|
|
3908
|
+
triggerCommand: 'approve-transition',
|
|
3909
|
+
}, { dryRun: true });
|
|
3910
|
+
return {
|
|
3911
|
+
ok: true,
|
|
3912
|
+
dry_run: true,
|
|
3913
|
+
state: attachLegacyCurrentTurnAlias(state),
|
|
3914
|
+
transition,
|
|
3915
|
+
gate_actions: gateActions.actions,
|
|
3916
|
+
};
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3839
3919
|
// ── before_gate hooks ──────────────────────────────────────────────
|
|
3840
3920
|
const hooksConfig = config?.hooks || {};
|
|
3841
3921
|
if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
|
|
@@ -3877,6 +3957,32 @@ export function approvePhaseTransition(root, config) {
|
|
|
3877
3957
|
}
|
|
3878
3958
|
}
|
|
3879
3959
|
|
|
3960
|
+
const gateActions = executeGateActions(root, config, {
|
|
3961
|
+
gateId: transition.gate,
|
|
3962
|
+
gateType: 'phase_transition',
|
|
3963
|
+
phase: state.phase,
|
|
3964
|
+
requestedByTurn: transition.requested_by_turn || null,
|
|
3965
|
+
triggerCommand: 'approve-transition',
|
|
3966
|
+
});
|
|
3967
|
+
|
|
3968
|
+
if (!gateActions.ok) {
|
|
3969
|
+
for (const entry of gateActions.actions || []) {
|
|
3970
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
3971
|
+
}
|
|
3972
|
+
const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
|
|
3973
|
+
return {
|
|
3974
|
+
ok: false,
|
|
3975
|
+
error: gateActions.error,
|
|
3976
|
+
error_code: 'gate_action_failed',
|
|
3977
|
+
state: blockedState,
|
|
3978
|
+
gateActionRun: gateActions,
|
|
3979
|
+
};
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
for (const entry of gateActions.actions || []) {
|
|
3983
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3880
3986
|
const updatedState = {
|
|
3881
3987
|
...state,
|
|
3882
3988
|
phase: transition.to,
|
|
@@ -3893,6 +3999,7 @@ export function approvePhaseTransition(root, config) {
|
|
|
3893
3999
|
};
|
|
3894
4000
|
|
|
3895
4001
|
writeState(root, updatedState);
|
|
4002
|
+
clearSlaReminders(root, 'pending_phase_transition');
|
|
3896
4003
|
emitRunEvent(root, 'gate_approved', {
|
|
3897
4004
|
run_id: updatedState.run_id,
|
|
3898
4005
|
phase: updatedState.phase,
|
|
@@ -3918,6 +4025,7 @@ export function approvePhaseTransition(root, config) {
|
|
|
3918
4025
|
ok: true,
|
|
3919
4026
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
3920
4027
|
transition,
|
|
4028
|
+
gateActionRun: gateActions,
|
|
3921
4029
|
};
|
|
3922
4030
|
}
|
|
3923
4031
|
|
|
@@ -3933,9 +4041,10 @@ export function approvePhaseTransition(root, config) {
|
|
|
3933
4041
|
*
|
|
3934
4042
|
* @param {string} root - project root directory
|
|
3935
4043
|
* @param {object} [config] - normalized config (optional; required for hook support)
|
|
4044
|
+
* @param {object} [opts] - optional execution controls
|
|
3936
4045
|
* @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, completion?: object, hookResults?: object }}
|
|
3937
4046
|
*/
|
|
3938
|
-
export function approveRunCompletion(root, config) {
|
|
4047
|
+
export function approveRunCompletion(root, config, opts = {}) {
|
|
3939
4048
|
const state = readState(root);
|
|
3940
4049
|
if (!state) {
|
|
3941
4050
|
return { ok: false, error: 'No governed state.json found' };
|
|
@@ -3949,6 +4058,23 @@ export function approveRunCompletion(root, config) {
|
|
|
3949
4058
|
|
|
3950
4059
|
const completion = state.pending_run_completion;
|
|
3951
4060
|
|
|
4061
|
+
if (opts.dryRun) {
|
|
4062
|
+
const gateActions = executeGateActions(root, config, {
|
|
4063
|
+
gateId: completion.gate,
|
|
4064
|
+
gateType: 'run_completion',
|
|
4065
|
+
phase: state.phase,
|
|
4066
|
+
requestedByTurn: completion.requested_by_turn || null,
|
|
4067
|
+
triggerCommand: 'approve-completion',
|
|
4068
|
+
}, { dryRun: true });
|
|
4069
|
+
return {
|
|
4070
|
+
ok: true,
|
|
4071
|
+
dry_run: true,
|
|
4072
|
+
state: attachLegacyCurrentTurnAlias(state),
|
|
4073
|
+
completion,
|
|
4074
|
+
gate_actions: gateActions.actions,
|
|
4075
|
+
};
|
|
4076
|
+
}
|
|
4077
|
+
|
|
3952
4078
|
// ── before_gate hooks ──────────────────────────────────────────────
|
|
3953
4079
|
const hooksConfig = config?.hooks || {};
|
|
3954
4080
|
if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
|
|
@@ -3990,6 +4116,32 @@ export function approveRunCompletion(root, config) {
|
|
|
3990
4116
|
}
|
|
3991
4117
|
}
|
|
3992
4118
|
|
|
4119
|
+
const gateActions = executeGateActions(root, config, {
|
|
4120
|
+
gateId: completion.gate,
|
|
4121
|
+
gateType: 'run_completion',
|
|
4122
|
+
phase: state.phase,
|
|
4123
|
+
requestedByTurn: completion.requested_by_turn || null,
|
|
4124
|
+
triggerCommand: 'approve-completion',
|
|
4125
|
+
});
|
|
4126
|
+
|
|
4127
|
+
if (!gateActions.ok) {
|
|
4128
|
+
for (const entry of gateActions.actions || []) {
|
|
4129
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
4130
|
+
}
|
|
4131
|
+
const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
|
|
4132
|
+
return {
|
|
4133
|
+
ok: false,
|
|
4134
|
+
error: gateActions.error,
|
|
4135
|
+
error_code: 'gate_action_failed',
|
|
4136
|
+
state: blockedState,
|
|
4137
|
+
gateActionRun: gateActions,
|
|
4138
|
+
};
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
for (const entry of gateActions.actions || []) {
|
|
4142
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
4143
|
+
}
|
|
4144
|
+
|
|
3993
4145
|
const updatedState = {
|
|
3994
4146
|
...state,
|
|
3995
4147
|
status: 'completed',
|
|
@@ -4005,6 +4157,7 @@ export function approveRunCompletion(root, config) {
|
|
|
4005
4157
|
};
|
|
4006
4158
|
|
|
4007
4159
|
writeState(root, updatedState);
|
|
4160
|
+
clearSlaReminders(root, 'pending_run_completion');
|
|
4008
4161
|
|
|
4009
4162
|
emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
|
|
4010
4163
|
completed_at: updatedState.completed_at,
|
|
@@ -4035,6 +4188,7 @@ export function approveRunCompletion(root, config) {
|
|
|
4035
4188
|
ok: true,
|
|
4036
4189
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
4037
4190
|
completion,
|
|
4191
|
+
gateActionRun: gateActions,
|
|
4038
4192
|
};
|
|
4039
4193
|
}
|
|
4040
4194
|
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
isWorkflowKitPhaseTemplateId,
|
|
29
29
|
VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS,
|
|
30
30
|
} from './workflow-kit-phase-templates.js';
|
|
31
|
+
import { validateGateActionsConfig } from './gate-actions.js';
|
|
31
32
|
|
|
32
33
|
const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
|
|
33
34
|
const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
|
|
@@ -501,7 +502,8 @@ export function validateV4Config(data, projectRoot) {
|
|
|
501
502
|
|
|
502
503
|
// Gates (optional but validated if present)
|
|
503
504
|
if (data.gates) {
|
|
504
|
-
|
|
505
|
+
validateGateActionsConfig(data.gates, errors);
|
|
506
|
+
if (data.gates && typeof data.gates === 'object' && !Array.isArray(data.gates) && data.routing) {
|
|
505
507
|
for (const [, route] of Object.entries(data.routing)) {
|
|
506
508
|
if (route.exit_gate && !data.gates[route.exit_gate]) {
|
|
507
509
|
errors.push(`Routing references unknown gate: "${route.exit_gate}"`);
|
|
@@ -520,6 +522,9 @@ export function validateV4Config(data, projectRoot) {
|
|
|
520
522
|
if (data.notifications) {
|
|
521
523
|
const notificationValidation = validateNotificationsConfig(data.notifications);
|
|
522
524
|
errors.push(...notificationValidation.errors);
|
|
525
|
+
if (Array.isArray(notificationValidation.warnings)) {
|
|
526
|
+
warnings.push(...notificationValidation.warnings);
|
|
527
|
+
}
|
|
523
528
|
}
|
|
524
529
|
|
|
525
530
|
// Schedules (optional but validated if present)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
@@ -13,6 +13,7 @@ export const VALID_NOTIFICATION_EVENTS = [
|
|
|
13
13
|
'phase_transition_pending',
|
|
14
14
|
'run_completion_pending',
|
|
15
15
|
'run_completed',
|
|
16
|
+
'approval_sla_reminder',
|
|
16
17
|
];
|
|
17
18
|
|
|
18
19
|
const NOTIFICATION_NAME_RE = /^[a-z0-9_-]+$/;
|
|
@@ -166,10 +167,10 @@ export function validateNotificationsConfig(notifications) {
|
|
|
166
167
|
|
|
167
168
|
if (!notifications || typeof notifications !== 'object' || Array.isArray(notifications)) {
|
|
168
169
|
errors.push('notifications must be an object');
|
|
169
|
-
return { ok: false, errors };
|
|
170
|
+
return { ok: false, errors, warnings: [] };
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
const allowedKeys = new Set(['webhooks']);
|
|
173
|
+
const allowedKeys = new Set(['webhooks', 'approval_sla']);
|
|
173
174
|
for (const key of Object.keys(notifications)) {
|
|
174
175
|
if (!allowedKeys.has(key)) {
|
|
175
176
|
errors.push(`notifications contains unknown field "${key}"`);
|
|
@@ -177,12 +178,12 @@ export function validateNotificationsConfig(notifications) {
|
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
if (!('webhooks' in notifications)) {
|
|
180
|
-
return { ok: errors.length === 0, errors };
|
|
181
|
+
return { ok: errors.length === 0, errors, warnings: [] };
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
if (!Array.isArray(notifications.webhooks)) {
|
|
184
185
|
errors.push('notifications.webhooks must be an array');
|
|
185
|
-
return { ok: false, errors };
|
|
186
|
+
return { ok: false, errors, warnings: [] };
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
if (notifications.webhooks.length > MAX_NOTIFICATION_WEBHOOKS) {
|
|
@@ -261,7 +262,51 @@ export function validateNotificationsConfig(notifications) {
|
|
|
261
262
|
}
|
|
262
263
|
});
|
|
263
264
|
|
|
264
|
-
|
|
265
|
+
// Validate approval_sla if present
|
|
266
|
+
const warnings = [];
|
|
267
|
+
if (notifications.approval_sla !== undefined) {
|
|
268
|
+
const sla = notifications.approval_sla;
|
|
269
|
+
if (!sla || typeof sla !== 'object' || Array.isArray(sla)) {
|
|
270
|
+
errors.push('notifications.approval_sla must be an object');
|
|
271
|
+
} else {
|
|
272
|
+
const slaAllowed = new Set(['enabled', 'reminder_after_seconds']);
|
|
273
|
+
for (const key of Object.keys(sla)) {
|
|
274
|
+
if (!slaAllowed.has(key)) {
|
|
275
|
+
errors.push(`notifications.approval_sla contains unknown field "${key}"`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if ('enabled' in sla && typeof sla.enabled !== 'boolean') {
|
|
279
|
+
errors.push('notifications.approval_sla.enabled must be a boolean');
|
|
280
|
+
}
|
|
281
|
+
if (!Array.isArray(sla.reminder_after_seconds) || sla.reminder_after_seconds.length === 0) {
|
|
282
|
+
errors.push('notifications.approval_sla.reminder_after_seconds must be a non-empty array of positive integers');
|
|
283
|
+
} else {
|
|
284
|
+
if (sla.reminder_after_seconds.length > 10) {
|
|
285
|
+
errors.push('notifications.approval_sla.reminder_after_seconds: maximum 10 thresholds');
|
|
286
|
+
}
|
|
287
|
+
let prev = 0;
|
|
288
|
+
for (let i = 0; i < sla.reminder_after_seconds.length; i++) {
|
|
289
|
+
const v = sla.reminder_after_seconds[i];
|
|
290
|
+
if (!Number.isInteger(v) || v <= 0) {
|
|
291
|
+
errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: must be a positive integer`);
|
|
292
|
+
} else if (v < 300) {
|
|
293
|
+
errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: minimum value is 300 (5 minutes)`);
|
|
294
|
+
} else if (v <= prev) {
|
|
295
|
+
errors.push(`notifications.approval_sla.reminder_after_seconds[${i}]: values must be strictly ascending`);
|
|
296
|
+
}
|
|
297
|
+
prev = v;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Warn if no webhook subscribes to approval_sla_reminder
|
|
301
|
+
const hasSubscriber = Array.isArray(notifications.webhooks) &&
|
|
302
|
+
notifications.webhooks.some(w => Array.isArray(w.events) && w.events.includes('approval_sla_reminder'));
|
|
303
|
+
if (!hasSubscriber) {
|
|
304
|
+
warnings.push('notifications.approval_sla is configured but no webhook subscribes to "approval_sla_reminder"');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
265
310
|
}
|
|
266
311
|
|
|
267
312
|
export function emitNotifications(root, config, state, eventType, payload = {}, turn = null) {
|
|
@@ -328,3 +373,114 @@ export function emitNotifications(root, config, state, eventType, payload = {},
|
|
|
328
373
|
|
|
329
374
|
return { ok: true, event_id: eventId, results };
|
|
330
375
|
}
|
|
376
|
+
|
|
377
|
+
const SLA_REMINDERS_PATH = '.agentxchain/sla-reminders.json';
|
|
378
|
+
|
|
379
|
+
function readSlaReminders(root) {
|
|
380
|
+
try {
|
|
381
|
+
const filePath = join(root, SLA_REMINDERS_PATH);
|
|
382
|
+
if (!existsSync(filePath)) return [];
|
|
383
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
384
|
+
return Array.isArray(data) ? data : [];
|
|
385
|
+
} catch { return []; }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function writeSlaReminders(root, sent) {
|
|
389
|
+
const filePath = join(root, SLA_REMINDERS_PATH);
|
|
390
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
391
|
+
writeFileSync(filePath, JSON.stringify(sent, null, 2) + '\n');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Evaluate and emit approval SLA reminders for pending approvals.
|
|
396
|
+
* Uses `.agentxchain/sla-reminders.json` for dedup tracking (not governed state).
|
|
397
|
+
* Returns { reminders_sent: string[], notifications_emitted: number }.
|
|
398
|
+
*/
|
|
399
|
+
export function evaluateApprovalSlaReminders(root, config, state) {
|
|
400
|
+
const result = { reminders_sent: [], notifications_emitted: 0 };
|
|
401
|
+
|
|
402
|
+
const sla = config?.notifications?.approval_sla;
|
|
403
|
+
if (!sla || sla.enabled === false) return result;
|
|
404
|
+
if (!Array.isArray(sla.reminder_after_seconds) || sla.reminder_after_seconds.length === 0) return result;
|
|
405
|
+
|
|
406
|
+
const webhooks = config?.notifications?.webhooks;
|
|
407
|
+
if (!Array.isArray(webhooks) || !webhooks.some(w => Array.isArray(w.events) && w.events.includes('approval_sla_reminder'))) {
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!state) return result;
|
|
412
|
+
|
|
413
|
+
const sent = readSlaReminders(root);
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
const pendingApprovals = [];
|
|
416
|
+
|
|
417
|
+
if (state.pending_phase_transition && state.pending_phase_transition.requested_at) {
|
|
418
|
+
pendingApprovals.push({
|
|
419
|
+
approval_type: 'pending_phase_transition',
|
|
420
|
+
requested_at: state.pending_phase_transition.requested_at,
|
|
421
|
+
from_phase: state.pending_phase_transition.from || null,
|
|
422
|
+
to_phase: state.pending_phase_transition.to || null,
|
|
423
|
+
gate: state.pending_phase_transition.gate || null,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (state.pending_run_completion && state.pending_run_completion.requested_at) {
|
|
428
|
+
pendingApprovals.push({
|
|
429
|
+
approval_type: 'pending_run_completion',
|
|
430
|
+
requested_at: state.pending_run_completion.requested_at,
|
|
431
|
+
from_phase: null,
|
|
432
|
+
to_phase: null,
|
|
433
|
+
gate: state.pending_run_completion.gate || null,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let changed = false;
|
|
438
|
+
for (const approval of pendingApprovals) {
|
|
439
|
+
const requestedMs = new Date(approval.requested_at).getTime();
|
|
440
|
+
if (isNaN(requestedMs)) continue;
|
|
441
|
+
const elapsedSeconds = Math.floor((now - requestedMs) / 1000);
|
|
442
|
+
|
|
443
|
+
for (let i = 0; i < sla.reminder_after_seconds.length; i++) {
|
|
444
|
+
const threshold = sla.reminder_after_seconds[i];
|
|
445
|
+
const reminderKey = `${approval.approval_type}:${threshold}`;
|
|
446
|
+
|
|
447
|
+
if (elapsedSeconds >= threshold && !sent.includes(reminderKey)) {
|
|
448
|
+
const payload = {
|
|
449
|
+
approval_type: approval.approval_type,
|
|
450
|
+
requested_at: approval.requested_at,
|
|
451
|
+
elapsed_seconds: elapsedSeconds,
|
|
452
|
+
threshold_seconds: threshold,
|
|
453
|
+
reminder_index: i + 1,
|
|
454
|
+
total_thresholds: sla.reminder_after_seconds.length,
|
|
455
|
+
};
|
|
456
|
+
if (approval.from_phase) payload.from_phase = approval.from_phase;
|
|
457
|
+
if (approval.to_phase) payload.to_phase = approval.to_phase;
|
|
458
|
+
if (approval.gate) payload.gate = approval.gate;
|
|
459
|
+
|
|
460
|
+
emitNotifications(root, config, state, 'approval_sla_reminder', payload);
|
|
461
|
+
sent.push(reminderKey);
|
|
462
|
+
result.reminders_sent.push(reminderKey);
|
|
463
|
+
result.notifications_emitted++;
|
|
464
|
+
changed = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (changed) {
|
|
470
|
+
writeSlaReminders(root, sent);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return result;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Clear SLA reminder tracking for a resolved approval type.
|
|
478
|
+
* Called from approve-transition / approve-completion.
|
|
479
|
+
*/
|
|
480
|
+
export function clearSlaReminders(root, approvalType) {
|
|
481
|
+
const sent = readSlaReminders(root);
|
|
482
|
+
const filtered = sent.filter(k => !k.startsWith(`${approvalType}:`));
|
|
483
|
+
if (filtered.length !== sent.length) {
|
|
484
|
+
writeSlaReminders(root, filtered);
|
|
485
|
+
}
|
|
486
|
+
}
|
package/src/lib/report.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './coordinator-next-actions.js';
|
|
12
12
|
import { buildCoordinatorRepoStatusEntries } from './coordinator-repo-status-presentation.js';
|
|
13
13
|
import { summarizeCoordinatorEvent } from './coordinator-event-narrative.js';
|
|
14
|
+
import { extractGateActionDigest } from './gate-actions.js';
|
|
14
15
|
|
|
15
16
|
export const GOVERNANCE_REPORT_VERSION = '0.1';
|
|
16
17
|
|
|
@@ -511,6 +512,11 @@ function extractGateFailureDigest(artifact) {
|
|
|
511
512
|
}));
|
|
512
513
|
}
|
|
513
514
|
|
|
515
|
+
function extractGateActionEventDigest(artifact) {
|
|
516
|
+
const data = extractFileData(artifact, '.agentxchain/decision-ledger.jsonl');
|
|
517
|
+
return extractGateActionDigest(data);
|
|
518
|
+
}
|
|
519
|
+
|
|
514
520
|
const GOVERNANCE_EVENT_TYPES = new Set([
|
|
515
521
|
'policy_escalation',
|
|
516
522
|
'conflict_detected',
|
|
@@ -986,6 +992,7 @@ function buildRunSubject(artifact) {
|
|
|
986
992
|
const decisions = extractDecisionDigest(artifact);
|
|
987
993
|
const approvalPolicyEvents = extractApprovalPolicyDigest(artifact);
|
|
988
994
|
const gateFailures = extractGateFailureDigest(artifact);
|
|
995
|
+
const gateActions = extractGateActionEventDigest(artifact);
|
|
989
996
|
const timeoutEvents = extractTimeoutEventDigest(artifact);
|
|
990
997
|
const hookSummary = extractHookSummary(artifact);
|
|
991
998
|
const timing = computeTiming(artifact, turns);
|
|
@@ -1034,6 +1041,7 @@ function buildRunSubject(artifact) {
|
|
|
1034
1041
|
approval_policy_events: approvalPolicyEvents,
|
|
1035
1042
|
governance_events: governanceEvents,
|
|
1036
1043
|
gate_failures: gateFailures,
|
|
1044
|
+
gate_actions: gateActions,
|
|
1037
1045
|
timeout_events: timeoutEvents,
|
|
1038
1046
|
delegation_summary: delegationSummary,
|
|
1039
1047
|
hook_summary: hookSummary,
|
|
@@ -1431,6 +1439,18 @@ export function formatGovernanceReportText(report) {
|
|
|
1431
1439
|
}
|
|
1432
1440
|
}
|
|
1433
1441
|
|
|
1442
|
+
if (run.gate_actions && run.gate_actions.length > 0) {
|
|
1443
|
+
lines.push('', 'Gate Actions:');
|
|
1444
|
+
for (const action of run.gate_actions) {
|
|
1445
|
+
const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
|
|
1446
|
+
const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
|
|
1447
|
+
lines.push(` - ${action.gate_id || 'unknown'} | ${action.gate_type || 'unknown'} | action ${action.action_index || '?'} | ${action.status} | ${label} | exit: ${exit} | at: ${action.timestamp || 'n/a'}`);
|
|
1448
|
+
if (action.stderr_tail) {
|
|
1449
|
+
lines.push(` stderr: ${action.stderr_tail}`);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1434
1454
|
if (run.approval_policy_events && run.approval_policy_events.length > 0) {
|
|
1435
1455
|
lines.push('', 'Approval Policy:');
|
|
1436
1456
|
for (const evt of run.approval_policy_events) {
|
|
@@ -1989,6 +2009,18 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1989
2009
|
}
|
|
1990
2010
|
}
|
|
1991
2011
|
|
|
2012
|
+
if (run.gate_actions && run.gate_actions.length > 0) {
|
|
2013
|
+
lines.push('', '## Gate Actions', '');
|
|
2014
|
+
for (const action of run.gate_actions) {
|
|
2015
|
+
const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
|
|
2016
|
+
const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
|
|
2017
|
+
lines.push(`- \`${action.gate_id || 'unknown'}\` (${action.gate_type || 'unknown'}) action ${action.action_index || '?'} — **${action.status}** at \`${action.timestamp || 'n/a'}\`: ${label} (exit \`${exit}\`)`);
|
|
2018
|
+
if (action.stderr_tail) {
|
|
2019
|
+
lines.push(` - stderr: ${action.stderr_tail}`);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1992
2024
|
if (run.approval_policy_events && run.approval_policy_events.length > 0) {
|
|
1993
2025
|
lines.push('', '## Approval Policy', '');
|
|
1994
2026
|
for (const evt of run.approval_policy_events) {
|
|
@@ -2686,6 +2718,21 @@ function renderRunHtml(report) {
|
|
|
2686
2718
|
sections.push(`<div class="section">${htmlSection('Gate Failures', gfHtml)}</div>`);
|
|
2687
2719
|
}
|
|
2688
2720
|
|
|
2721
|
+
if (run.gate_actions && run.gate_actions.length > 0) {
|
|
2722
|
+
let gaHtml = '<ul>';
|
|
2723
|
+
for (const action of run.gate_actions) {
|
|
2724
|
+
const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
|
|
2725
|
+
const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
|
|
2726
|
+
gaHtml += `<li><code>${esc(action.gate_id || 'unknown')}</code> (${esc(action.gate_type || 'unknown')}) action ${esc(String(action.action_index || '?'))} — <strong>${esc(action.status)}</strong> at <code>${esc(action.timestamp || 'n/a')}</code>: ${esc(label)} (exit <code>${esc(exit)}</code>)`;
|
|
2727
|
+
if (action.stderr_tail) {
|
|
2728
|
+
gaHtml += `<br><code>${esc(action.stderr_tail)}</code>`;
|
|
2729
|
+
}
|
|
2730
|
+
gaHtml += '</li>';
|
|
2731
|
+
}
|
|
2732
|
+
gaHtml += '</ul>';
|
|
2733
|
+
sections.push(`<div class="section">${htmlSection('Gate Actions', gaHtml)}</div>`);
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2689
2736
|
// Approval Policy
|
|
2690
2737
|
if (run.approval_policy_events && run.approval_policy_events.length > 0) {
|
|
2691
2738
|
let apHtml = '<ul>';
|
package/src/lib/run-loop.js
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import { runAdmissionControl } from './admission-control.js';
|
|
36
36
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
37
37
|
import { join, dirname } from 'path';
|
|
38
|
+
import { evaluateApprovalSlaReminders } from './notification-runner.js';
|
|
38
39
|
|
|
39
40
|
const DEFAULT_MAX_TURNS = 50;
|
|
40
41
|
|
|
@@ -495,6 +496,8 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
|
|
|
495
496
|
* Handle a paused state by checking for pending gates and calling approveGate.
|
|
496
497
|
*/
|
|
497
498
|
async function handleGatePause(root, config, state, callbacks, emit) {
|
|
499
|
+
evaluateApprovalSlaReminders(root, config, state);
|
|
500
|
+
|
|
498
501
|
if (state.pending_phase_transition) {
|
|
499
502
|
emit({ type: 'gate_paused', gateType: 'phase_transition', state });
|
|
500
503
|
let approved;
|