agentxchain 2.107.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 +46 -5
- package/dashboard/components/blocked.js +55 -0
- package/dashboard/components/gate.js +49 -0
- package/dashboard/components/timeline.js +100 -1
- 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/events.js +33 -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 +178 -3
- package/src/lib/normalized-config.js +6 -1
- package/src/lib/notification-runner.js +162 -6
- package/src/lib/report.js +131 -3
- package/src/lib/run-events.js +1 -0
- package/src/lib/run-loop.js +50 -2
|
@@ -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;
|
|
@@ -2652,11 +2714,32 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2652
2714
|
},
|
|
2653
2715
|
});
|
|
2654
2716
|
|
|
2717
|
+
// DEC-RUN-LOOP-CONFLICT-002: Persist turn_conflicted as a durable run event
|
|
2718
|
+
emitRunEvent(root, 'turn_conflicted', {
|
|
2719
|
+
run_id: state.run_id,
|
|
2720
|
+
phase: state.phase,
|
|
2721
|
+
status: updatedState.status,
|
|
2722
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
2723
|
+
payload: {
|
|
2724
|
+
error_code: 'conflict',
|
|
2725
|
+
detection_count: detectionCount,
|
|
2726
|
+
conflicting_files: conflict.conflicting_files,
|
|
2727
|
+
accepted_since_turn_ids: conflict.accepted_since.map(entry => entry.turn_id),
|
|
2728
|
+
overlap_ratio: conflict.overlap_ratio,
|
|
2729
|
+
},
|
|
2730
|
+
});
|
|
2731
|
+
|
|
2655
2732
|
writeState(root, updatedState);
|
|
2656
2733
|
|
|
2657
2734
|
// DEC-RHTR-SPEC: Record conflict_loop blocked outcome in cross-run history (non-fatal)
|
|
2658
2735
|
if (updatedState.status === 'blocked') {
|
|
2659
2736
|
recordRunHistory(root, updatedState, config, 'blocked');
|
|
2737
|
+
// DEC-CONFLICT-NOTIFY-001: Emit run_blocked notification for conflict-loop exhaustion
|
|
2738
|
+
emitBlockedNotification(root, config, updatedState, {
|
|
2739
|
+
category: 'conflict_loop',
|
|
2740
|
+
blockedOn: updatedState.blocked_on,
|
|
2741
|
+
recovery: updatedState.blocked_reason?.recovery || null,
|
|
2742
|
+
}, currentTurn);
|
|
2660
2743
|
}
|
|
2661
2744
|
|
|
2662
2745
|
return {
|
|
@@ -3799,9 +3882,10 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3799
3882
|
*
|
|
3800
3883
|
* @param {string} root - project root directory
|
|
3801
3884
|
* @param {object} [config] - normalized config (optional; required for hook support)
|
|
3885
|
+
* @param {object} [opts] - optional execution controls
|
|
3802
3886
|
* @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, transition?: object, hookResults?: object }}
|
|
3803
3887
|
*/
|
|
3804
|
-
export function approvePhaseTransition(root, config) {
|
|
3888
|
+
export function approvePhaseTransition(root, config, opts = {}) {
|
|
3805
3889
|
const state = readState(root);
|
|
3806
3890
|
if (!state) {
|
|
3807
3891
|
return { ok: false, error: 'No governed state.json found' };
|
|
@@ -3815,6 +3899,23 @@ export function approvePhaseTransition(root, config) {
|
|
|
3815
3899
|
|
|
3816
3900
|
const transition = state.pending_phase_transition;
|
|
3817
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
|
+
|
|
3818
3919
|
// ── before_gate hooks ──────────────────────────────────────────────
|
|
3819
3920
|
const hooksConfig = config?.hooks || {};
|
|
3820
3921
|
if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
|
|
@@ -3856,6 +3957,32 @@ export function approvePhaseTransition(root, config) {
|
|
|
3856
3957
|
}
|
|
3857
3958
|
}
|
|
3858
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
|
+
|
|
3859
3986
|
const updatedState = {
|
|
3860
3987
|
...state,
|
|
3861
3988
|
phase: transition.to,
|
|
@@ -3872,6 +3999,7 @@ export function approvePhaseTransition(root, config) {
|
|
|
3872
3999
|
};
|
|
3873
4000
|
|
|
3874
4001
|
writeState(root, updatedState);
|
|
4002
|
+
clearSlaReminders(root, 'pending_phase_transition');
|
|
3875
4003
|
emitRunEvent(root, 'gate_approved', {
|
|
3876
4004
|
run_id: updatedState.run_id,
|
|
3877
4005
|
phase: updatedState.phase,
|
|
@@ -3897,6 +4025,7 @@ export function approvePhaseTransition(root, config) {
|
|
|
3897
4025
|
ok: true,
|
|
3898
4026
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
3899
4027
|
transition,
|
|
4028
|
+
gateActionRun: gateActions,
|
|
3900
4029
|
};
|
|
3901
4030
|
}
|
|
3902
4031
|
|
|
@@ -3912,9 +4041,10 @@ export function approvePhaseTransition(root, config) {
|
|
|
3912
4041
|
*
|
|
3913
4042
|
* @param {string} root - project root directory
|
|
3914
4043
|
* @param {object} [config] - normalized config (optional; required for hook support)
|
|
4044
|
+
* @param {object} [opts] - optional execution controls
|
|
3915
4045
|
* @returns {{ ok: boolean, error?: string, error_code?: string, state?: object, completion?: object, hookResults?: object }}
|
|
3916
4046
|
*/
|
|
3917
|
-
export function approveRunCompletion(root, config) {
|
|
4047
|
+
export function approveRunCompletion(root, config, opts = {}) {
|
|
3918
4048
|
const state = readState(root);
|
|
3919
4049
|
if (!state) {
|
|
3920
4050
|
return { ok: false, error: 'No governed state.json found' };
|
|
@@ -3928,6 +4058,23 @@ export function approveRunCompletion(root, config) {
|
|
|
3928
4058
|
|
|
3929
4059
|
const completion = state.pending_run_completion;
|
|
3930
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
|
+
|
|
3931
4078
|
// ── before_gate hooks ──────────────────────────────────────────────
|
|
3932
4079
|
const hooksConfig = config?.hooks || {};
|
|
3933
4080
|
if (hooksConfig.before_gate && hooksConfig.before_gate.length > 0) {
|
|
@@ -3969,6 +4116,32 @@ export function approveRunCompletion(root, config) {
|
|
|
3969
4116
|
}
|
|
3970
4117
|
}
|
|
3971
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
|
+
|
|
3972
4145
|
const updatedState = {
|
|
3973
4146
|
...state,
|
|
3974
4147
|
status: 'completed',
|
|
@@ -3984,6 +4157,7 @@ export function approveRunCompletion(root, config) {
|
|
|
3984
4157
|
};
|
|
3985
4158
|
|
|
3986
4159
|
writeState(root, updatedState);
|
|
4160
|
+
clearSlaReminders(root, 'pending_run_completion');
|
|
3987
4161
|
|
|
3988
4162
|
emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
|
|
3989
4163
|
completed_at: updatedState.completed_at,
|
|
@@ -4014,6 +4188,7 @@ export function approveRunCompletion(root, config) {
|
|
|
4014
4188
|
ok: true,
|
|
4015
4189
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
4016
4190
|
completion,
|
|
4191
|
+
gateActionRun: gateActions,
|
|
4017
4192
|
};
|
|
4018
4193
|
}
|
|
4019
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
|
+
}
|