agentxchain 2.108.0 → 2.110.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 +57 -0
- package/dashboard/components/gate.js +52 -0
- package/dashboard/components/timeouts.js +21 -3
- package/package.json +1 -1
- package/src/commands/approve-completion.js +45 -1
- package/src/commands/approve-transition.js +49 -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 +263 -0
- package/src/lib/governed-state.js +161 -3
- package/src/lib/normalized-config.js +6 -1
- package/src/lib/notification-runner.js +162 -6
- package/src/lib/report.js +50 -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,71 @@ 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 failureVerb = gateFailure.timed_out ? 'timed out' : 'failed';
|
|
1285
|
+
const failureDetail = `Gate action ${failureVerb} for "${gateFailure.gate_id || 'unknown'}": ${actionLabel}`;
|
|
1286
|
+
const blockedState = {
|
|
1287
|
+
...state,
|
|
1288
|
+
status: 'blocked',
|
|
1289
|
+
blocked_on: `gate_action:${gateFailure.gate_id || 'unknown'}`,
|
|
1290
|
+
blocked_reason: {
|
|
1291
|
+
category: 'gate_action_failed',
|
|
1292
|
+
blocked_at: blockedAt,
|
|
1293
|
+
turn_id: gateFailure.requested_by_turn || null,
|
|
1294
|
+
detail: failureDetail,
|
|
1295
|
+
recovery: {
|
|
1296
|
+
typed_reason: 'gate_action_failed',
|
|
1297
|
+
owner: 'human',
|
|
1298
|
+
recovery_action: recoveryAction,
|
|
1299
|
+
turn_retained: false,
|
|
1300
|
+
detail: `${gateFailure.gate_id || 'unknown'} action ${gateFailure.action_index || '?'} (${actionLabel})${gateFailure.timed_out ? ` timed out after ${gateFailure.timeout_ms}ms` : ''}`,
|
|
1301
|
+
},
|
|
1302
|
+
gate_action: {
|
|
1303
|
+
attempt_id: gateFailure.attempt_id || null,
|
|
1304
|
+
gate_id: gateFailure.gate_id || null,
|
|
1305
|
+
gate_type: gateType,
|
|
1306
|
+
action_index: gateFailure.action_index || null,
|
|
1307
|
+
action_label: gateFailure.action_label || null,
|
|
1308
|
+
command: gateFailure.command || null,
|
|
1309
|
+
exit_code: gateFailure.exit_code ?? null,
|
|
1310
|
+
stderr_tail: gateFailure.stderr_tail || null,
|
|
1311
|
+
timeout_ms: gateFailure.timeout_ms ?? null,
|
|
1312
|
+
timed_out: gateFailure.timed_out === true,
|
|
1313
|
+
},
|
|
1314
|
+
},
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
writeState(root, blockedState);
|
|
1318
|
+
emitBlockedNotification(root, config, blockedState, {
|
|
1319
|
+
category: 'gate_action_failed',
|
|
1320
|
+
blockedOn: blockedState.blocked_on,
|
|
1321
|
+
recovery: blockedState.blocked_reason.recovery,
|
|
1322
|
+
});
|
|
1323
|
+
emitRunEvent(root, 'run_blocked', {
|
|
1324
|
+
run_id: blockedState.run_id,
|
|
1325
|
+
phase: blockedState.phase,
|
|
1326
|
+
status: blockedState.status,
|
|
1327
|
+
turn: gateFailure.requested_by_turn
|
|
1328
|
+
? { turn_id: gateFailure.requested_by_turn, role_id: null }
|
|
1329
|
+
: undefined,
|
|
1330
|
+
payload: {
|
|
1331
|
+
category: 'gate_action_failed',
|
|
1332
|
+
gate_id: gateFailure.gate_id || null,
|
|
1333
|
+
gate_type: gateType,
|
|
1334
|
+
action_index: gateFailure.action_index || null,
|
|
1335
|
+
exit_code: gateFailure.exit_code ?? null,
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
return blockedState;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1276
1342
|
function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId, turnRetained }) {
|
|
1277
1343
|
const isTamper = errorCode?.includes('_tamper');
|
|
1278
1344
|
const pendingPhaseTransition = state?.pending_phase_transition;
|
|
@@ -3820,9 +3886,10 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3820
3886
|
*
|
|
3821
3887
|
* @param {string} root - project root directory
|
|
3822
3888
|
* @param {object} [config] - normalized config (optional; required for hook support)
|
|
3889
|
+
* @param {object} [opts] - optional execution controls
|
|
3823
3890
|
* @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, transition?: object, hookResults?: object }}
|
|
3824
3891
|
*/
|
|
3825
|
-
export function approvePhaseTransition(root, config) {
|
|
3892
|
+
export function approvePhaseTransition(root, config, opts = {}) {
|
|
3826
3893
|
const state = readState(root);
|
|
3827
3894
|
if (!state) {
|
|
3828
3895
|
return { ok: false, error: 'No governed state.json found' };
|
|
@@ -3836,6 +3903,23 @@ export function approvePhaseTransition(root, config) {
|
|
|
3836
3903
|
|
|
3837
3904
|
const transition = state.pending_phase_transition;
|
|
3838
3905
|
|
|
3906
|
+
if (opts.dryRun) {
|
|
3907
|
+
const gateActions = executeGateActions(root, config, {
|
|
3908
|
+
gateId: transition.gate,
|
|
3909
|
+
gateType: 'phase_transition',
|
|
3910
|
+
phase: state.phase,
|
|
3911
|
+
requestedByTurn: transition.requested_by_turn || null,
|
|
3912
|
+
triggerCommand: 'approve-transition',
|
|
3913
|
+
}, { dryRun: true });
|
|
3914
|
+
return {
|
|
3915
|
+
ok: true,
|
|
3916
|
+
dry_run: true,
|
|
3917
|
+
state: attachLegacyCurrentTurnAlias(state),
|
|
3918
|
+
transition,
|
|
3919
|
+
gate_actions: gateActions.actions,
|
|
3920
|
+
};
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3839
3923
|
// ── before_gate hooks ──────────────────────────────────────────────
|
|
3840
3924
|
const hooksConfig = config?.hooks || {};
|
|
3841
3925
|
if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
|
|
@@ -3877,6 +3961,32 @@ export function approvePhaseTransition(root, config) {
|
|
|
3877
3961
|
}
|
|
3878
3962
|
}
|
|
3879
3963
|
|
|
3964
|
+
const gateActions = executeGateActions(root, config, {
|
|
3965
|
+
gateId: transition.gate,
|
|
3966
|
+
gateType: 'phase_transition',
|
|
3967
|
+
phase: state.phase,
|
|
3968
|
+
requestedByTurn: transition.requested_by_turn || null,
|
|
3969
|
+
triggerCommand: 'approve-transition',
|
|
3970
|
+
});
|
|
3971
|
+
|
|
3972
|
+
if (!gateActions.ok) {
|
|
3973
|
+
for (const entry of gateActions.actions || []) {
|
|
3974
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
3975
|
+
}
|
|
3976
|
+
const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
|
|
3977
|
+
return {
|
|
3978
|
+
ok: false,
|
|
3979
|
+
error: gateActions.error,
|
|
3980
|
+
error_code: 'gate_action_failed',
|
|
3981
|
+
state: blockedState,
|
|
3982
|
+
gateActionRun: gateActions,
|
|
3983
|
+
};
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
for (const entry of gateActions.actions || []) {
|
|
3987
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3880
3990
|
const updatedState = {
|
|
3881
3991
|
...state,
|
|
3882
3992
|
phase: transition.to,
|
|
@@ -3893,6 +4003,7 @@ export function approvePhaseTransition(root, config) {
|
|
|
3893
4003
|
};
|
|
3894
4004
|
|
|
3895
4005
|
writeState(root, updatedState);
|
|
4006
|
+
clearSlaReminders(root, 'pending_phase_transition');
|
|
3896
4007
|
emitRunEvent(root, 'gate_approved', {
|
|
3897
4008
|
run_id: updatedState.run_id,
|
|
3898
4009
|
phase: updatedState.phase,
|
|
@@ -3918,6 +4029,7 @@ export function approvePhaseTransition(root, config) {
|
|
|
3918
4029
|
ok: true,
|
|
3919
4030
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
3920
4031
|
transition,
|
|
4032
|
+
gateActionRun: gateActions,
|
|
3921
4033
|
};
|
|
3922
4034
|
}
|
|
3923
4035
|
|
|
@@ -3933,9 +4045,10 @@ export function approvePhaseTransition(root, config) {
|
|
|
3933
4045
|
*
|
|
3934
4046
|
* @param {string} root - project root directory
|
|
3935
4047
|
* @param {object} [config] - normalized config (optional; required for hook support)
|
|
4048
|
+
* @param {object} [opts] - optional execution controls
|
|
3936
4049
|
* @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, completion?: object, hookResults?: object }}
|
|
3937
4050
|
*/
|
|
3938
|
-
export function approveRunCompletion(root, config) {
|
|
4051
|
+
export function approveRunCompletion(root, config, opts = {}) {
|
|
3939
4052
|
const state = readState(root);
|
|
3940
4053
|
if (!state) {
|
|
3941
4054
|
return { ok: false, error: 'No governed state.json found' };
|
|
@@ -3949,6 +4062,23 @@ export function approveRunCompletion(root, config) {
|
|
|
3949
4062
|
|
|
3950
4063
|
const completion = state.pending_run_completion;
|
|
3951
4064
|
|
|
4065
|
+
if (opts.dryRun) {
|
|
4066
|
+
const gateActions = executeGateActions(root, config, {
|
|
4067
|
+
gateId: completion.gate,
|
|
4068
|
+
gateType: 'run_completion',
|
|
4069
|
+
phase: state.phase,
|
|
4070
|
+
requestedByTurn: completion.requested_by_turn || null,
|
|
4071
|
+
triggerCommand: 'approve-completion',
|
|
4072
|
+
}, { dryRun: true });
|
|
4073
|
+
return {
|
|
4074
|
+
ok: true,
|
|
4075
|
+
dry_run: true,
|
|
4076
|
+
state: attachLegacyCurrentTurnAlias(state),
|
|
4077
|
+
completion,
|
|
4078
|
+
gate_actions: gateActions.actions,
|
|
4079
|
+
};
|
|
4080
|
+
}
|
|
4081
|
+
|
|
3952
4082
|
// ── before_gate hooks ──────────────────────────────────────────────
|
|
3953
4083
|
const hooksConfig = config?.hooks || {};
|
|
3954
4084
|
if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
|
|
@@ -3990,6 +4120,32 @@ export function approveRunCompletion(root, config) {
|
|
|
3990
4120
|
}
|
|
3991
4121
|
}
|
|
3992
4122
|
|
|
4123
|
+
const gateActions = executeGateActions(root, config, {
|
|
4124
|
+
gateId: completion.gate,
|
|
4125
|
+
gateType: 'run_completion',
|
|
4126
|
+
phase: state.phase,
|
|
4127
|
+
requestedByTurn: completion.requested_by_turn || null,
|
|
4128
|
+
triggerCommand: 'approve-completion',
|
|
4129
|
+
});
|
|
4130
|
+
|
|
4131
|
+
if (!gateActions.ok) {
|
|
4132
|
+
for (const entry of gateActions.actions || []) {
|
|
4133
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
4134
|
+
}
|
|
4135
|
+
const blockedState = blockRunForGateActionFailure(root, state, gateActions.failed_action, config);
|
|
4136
|
+
return {
|
|
4137
|
+
ok: false,
|
|
4138
|
+
error: gateActions.error,
|
|
4139
|
+
error_code: 'gate_action_failed',
|
|
4140
|
+
state: blockedState,
|
|
4141
|
+
gateActionRun: gateActions,
|
|
4142
|
+
};
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
for (const entry of gateActions.actions || []) {
|
|
4146
|
+
appendJsonl(root, LEDGER_PATH, entry);
|
|
4147
|
+
}
|
|
4148
|
+
|
|
3993
4149
|
const updatedState = {
|
|
3994
4150
|
...state,
|
|
3995
4151
|
status: 'completed',
|
|
@@ -4005,6 +4161,7 @@ export function approveRunCompletion(root, config) {
|
|
|
4005
4161
|
};
|
|
4006
4162
|
|
|
4007
4163
|
writeState(root, updatedState);
|
|
4164
|
+
clearSlaReminders(root, 'pending_run_completion');
|
|
4008
4165
|
|
|
4009
4166
|
emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
|
|
4010
4167
|
completed_at: updatedState.completed_at,
|
|
@@ -4035,6 +4192,7 @@ export function approveRunCompletion(root, config) {
|
|
|
4035
4192
|
ok: true,
|
|
4036
4193
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
4037
4194
|
completion,
|
|
4195
|
+
gateActionRun: gateActions,
|
|
4038
4196
|
};
|
|
4039
4197
|
}
|
|
4040
4198
|
|
|
@@ -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,19 @@ 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
|
+
const timeoutTag = action.timed_out ? ` | timed_out after ${action.timeout_ms}ms` : '';
|
|
1448
|
+
lines.push(` - ${action.gate_id || 'unknown'} | ${action.gate_type || 'unknown'} | action ${action.action_index || '?'} | ${action.status} | ${label} | exit: ${exit}${timeoutTag} | at: ${action.timestamp || 'n/a'}`);
|
|
1449
|
+
if (action.stderr_tail) {
|
|
1450
|
+
lines.push(` stderr: ${action.stderr_tail}`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1434
1455
|
if (run.approval_policy_events && run.approval_policy_events.length > 0) {
|
|
1435
1456
|
lines.push('', 'Approval Policy:');
|
|
1436
1457
|
for (const evt of run.approval_policy_events) {
|
|
@@ -1989,6 +2010,19 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1989
2010
|
}
|
|
1990
2011
|
}
|
|
1991
2012
|
|
|
2013
|
+
if (run.gate_actions && run.gate_actions.length > 0) {
|
|
2014
|
+
lines.push('', '## Gate Actions', '');
|
|
2015
|
+
for (const action of run.gate_actions) {
|
|
2016
|
+
const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
|
|
2017
|
+
const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
|
|
2018
|
+
const mdTimeout = action.timed_out ? ` ⏱ timed out after ${action.timeout_ms}ms` : '';
|
|
2019
|
+
lines.push(`- \`${action.gate_id || 'unknown'}\` (${action.gate_type || 'unknown'}) action ${action.action_index || '?'} — **${action.status}** at \`${action.timestamp || 'n/a'}\`: ${label} (exit \`${exit}\`)${mdTimeout}`);
|
|
2020
|
+
if (action.stderr_tail) {
|
|
2021
|
+
lines.push(` - stderr: ${action.stderr_tail}`);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
1992
2026
|
if (run.approval_policy_events && run.approval_policy_events.length > 0) {
|
|
1993
2027
|
lines.push('', '## Approval Policy', '');
|
|
1994
2028
|
for (const evt of run.approval_policy_events) {
|
|
@@ -2686,6 +2720,22 @@ function renderRunHtml(report) {
|
|
|
2686
2720
|
sections.push(`<div class="section">${htmlSection('Gate Failures', gfHtml)}</div>`);
|
|
2687
2721
|
}
|
|
2688
2722
|
|
|
2723
|
+
if (run.gate_actions && run.gate_actions.length > 0) {
|
|
2724
|
+
let gaHtml = '<ul>';
|
|
2725
|
+
for (const action of run.gate_actions) {
|
|
2726
|
+
const label = action.action_label || action.command || `action ${action.action_index || '?'}`;
|
|
2727
|
+
const exit = action.exit_code == null ? 'n/a' : String(action.exit_code);
|
|
2728
|
+
const htmlTimeout = action.timed_out ? ` <em>⏱ timed out after ${esc(String(action.timeout_ms))}ms</em>` : '';
|
|
2729
|
+
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>)${htmlTimeout}`;
|
|
2730
|
+
if (action.stderr_tail) {
|
|
2731
|
+
gaHtml += `<br><code>${esc(action.stderr_tail)}</code>`;
|
|
2732
|
+
}
|
|
2733
|
+
gaHtml += '</li>';
|
|
2734
|
+
}
|
|
2735
|
+
gaHtml += '</ul>';
|
|
2736
|
+
sections.push(`<div class="section">${htmlSection('Gate Actions', gaHtml)}</div>`);
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2689
2739
|
// Approval Policy
|
|
2690
2740
|
if (run.approval_policy_events && run.approval_policy_events.length > 0) {
|
|
2691
2741
|
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;
|