agentxchain 2.6.0 → 2.7.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 +10 -0
- package/package.json +1 -1
- package/src/commands/escalate.js +63 -0
- package/src/commands/resume.js +72 -2
- package/src/commands/step.js +22 -15
- package/src/lib/blocked-state.js +7 -3
- package/src/lib/export-verifier.js +4 -0
- package/src/lib/export.js +2 -0
- package/src/lib/governed-state.js +255 -6
- package/src/lib/normalized-config.js +9 -0
- package/src/lib/notification-runner.js +330 -0
package/bin/agentxchain.js
CHANGED
|
@@ -65,6 +65,7 @@ import { rebindCommand } from '../src/commands/rebind.js';
|
|
|
65
65
|
import { branchCommand } from '../src/commands/branch.js';
|
|
66
66
|
import { migrateCommand } from '../src/commands/migrate.js';
|
|
67
67
|
import { resumeCommand } from '../src/commands/resume.js';
|
|
68
|
+
import { escalateCommand } from '../src/commands/escalate.js';
|
|
68
69
|
import { acceptTurnCommand } from '../src/commands/accept-turn.js';
|
|
69
70
|
import { rejectTurnCommand } from '../src/commands/reject-turn.js';
|
|
70
71
|
import { stepCommand } from '../src/commands/step.js';
|
|
@@ -260,6 +261,15 @@ program
|
|
|
260
261
|
.option('--turn <id>', 'Target a specific retained turn when multiple exist')
|
|
261
262
|
.action(resumeCommand);
|
|
262
263
|
|
|
264
|
+
program
|
|
265
|
+
.command('escalate')
|
|
266
|
+
.description('Raise an operator escalation and block the governed run intentionally')
|
|
267
|
+
.requiredOption('--reason <reason>', 'Operator escalation summary')
|
|
268
|
+
.option('--detail <detail>', 'Longer escalation detail for status and ledger surfaces')
|
|
269
|
+
.option('--action <action>', 'Override the default recovery action string')
|
|
270
|
+
.option('--turn <id>', 'Target a specific active turn when multiple turns exist')
|
|
271
|
+
.action(escalateCommand);
|
|
272
|
+
|
|
263
273
|
program
|
|
264
274
|
.command('accept-turn')
|
|
265
275
|
.description('Accept the currently staged governed turn result')
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
3
|
+
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
4
|
+
import { raiseOperatorEscalation } from '../lib/governed-state.js';
|
|
5
|
+
|
|
6
|
+
export async function escalateCommand(opts) {
|
|
7
|
+
const context = loadProjectContext();
|
|
8
|
+
if (!context) {
|
|
9
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { root, config } = context;
|
|
14
|
+
|
|
15
|
+
if (config.protocol_mode !== 'governed') {
|
|
16
|
+
console.log(chalk.red('The escalate command is only available for governed projects.'));
|
|
17
|
+
console.log(chalk.dim('Legacy projects use: agentxchain claim / release'));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const state = loadProjectState(root, config);
|
|
22
|
+
if (!state) {
|
|
23
|
+
console.log(chalk.red('No governed state.json found. Run `agentxchain init --governed` first.'));
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = raiseOperatorEscalation(root, config, {
|
|
28
|
+
reason: opts.reason,
|
|
29
|
+
detail: opts.detail,
|
|
30
|
+
action: opts.action,
|
|
31
|
+
turnId: opts.turn,
|
|
32
|
+
});
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
console.log(chalk.red(result.error));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const recovery = deriveRecoveryDescriptor(result.state);
|
|
39
|
+
|
|
40
|
+
console.log('');
|
|
41
|
+
console.log(chalk.yellow(' Run Escalated'));
|
|
42
|
+
console.log(chalk.dim(' ' + '─'.repeat(44)));
|
|
43
|
+
console.log('');
|
|
44
|
+
console.log(` ${chalk.dim('Reason:')} ${result.escalation.reason}`);
|
|
45
|
+
if (result.escalation.detail && result.escalation.detail !== result.escalation.reason) {
|
|
46
|
+
console.log(` ${chalk.dim('Detail:')} ${result.escalation.detail}`);
|
|
47
|
+
}
|
|
48
|
+
console.log(` ${chalk.dim('Blocked:')} ${result.state.blocked_on}`);
|
|
49
|
+
console.log(` ${chalk.dim('Source:')} operator`);
|
|
50
|
+
console.log(` ${chalk.dim('Turn:')} ${result.escalation.from_turn_id || 'none retained'}`);
|
|
51
|
+
if (result.escalation.from_role) {
|
|
52
|
+
console.log(` ${chalk.dim('Role:')} ${result.escalation.from_role}`);
|
|
53
|
+
}
|
|
54
|
+
console.log('');
|
|
55
|
+
|
|
56
|
+
if (recovery) {
|
|
57
|
+
console.log(` ${chalk.dim('Typed:')} ${recovery.typed_reason}`);
|
|
58
|
+
console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
|
|
59
|
+
console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
|
|
60
|
+
console.log(` ${chalk.dim('Retained:')} ${recovery.turn_retained ? 'yes' : 'no'}`);
|
|
61
|
+
}
|
|
62
|
+
console.log('');
|
|
63
|
+
}
|
package/src/commands/resume.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
markRunBlocked,
|
|
25
25
|
getActiveTurns,
|
|
26
26
|
getActiveTurnCount,
|
|
27
|
+
reactivateGovernedRun,
|
|
27
28
|
STATE_PATH,
|
|
28
29
|
} from '../lib/governed-state.js';
|
|
29
30
|
import { writeDispatchBundle, getDispatchTurnDir, getTurnStagingResultPath } from '../lib/dispatch-bundle.js';
|
|
@@ -154,6 +155,62 @@ export async function resumeCommand(opts) {
|
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
if (state.status === 'blocked' && activeCount > 0) {
|
|
159
|
+
let retainedTurn = null;
|
|
160
|
+
if (opts.turn) {
|
|
161
|
+
retainedTurn = activeTurns[opts.turn];
|
|
162
|
+
if (!retainedTurn) {
|
|
163
|
+
console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
} else if (activeCount > 1) {
|
|
167
|
+
console.log(chalk.red('Multiple retained turns exist. Use --turn <id> to specify which to re-dispatch.'));
|
|
168
|
+
for (const turn of Object.values(activeTurns)) {
|
|
169
|
+
console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${turn.status})`);
|
|
170
|
+
}
|
|
171
|
+
console.log('');
|
|
172
|
+
console.log(chalk.dim('Example: agentxchain resume --turn <turn_id>'));
|
|
173
|
+
process.exit(1);
|
|
174
|
+
} else {
|
|
175
|
+
retainedTurn = Object.values(activeTurns)[0];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (retainedTurn.status === 'conflicted') {
|
|
179
|
+
console.log(chalk.red(`Turn ${retainedTurn.turn_id} is conflicted. Resolve the conflict before resuming.`));
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(chalk.yellow(`Re-dispatching blocked turn: ${retainedTurn.turn_id}`));
|
|
184
|
+
console.log(` Role: ${retainedTurn.assigned_role}`);
|
|
185
|
+
console.log(` Attempt: ${retainedTurn.attempt}`);
|
|
186
|
+
console.log('');
|
|
187
|
+
|
|
188
|
+
const reactivated = reactivateGovernedRun(root, state, { via: 'resume --turn', notificationConfig: config });
|
|
189
|
+
if (!reactivated.ok) {
|
|
190
|
+
console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
state = reactivated.state;
|
|
194
|
+
|
|
195
|
+
const bundleResult = writeDispatchBundle(root, state, config, { turnId: retainedTurn.turn_id });
|
|
196
|
+
if (!bundleResult.ok) {
|
|
197
|
+
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
printDispatchBundleWarnings(bundleResult);
|
|
201
|
+
|
|
202
|
+
const hooksConfig = config.hooks || {};
|
|
203
|
+
if (hooksConfig.after_dispatch?.length > 0) {
|
|
204
|
+
const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, retainedTurn, config);
|
|
205
|
+
if (!afterDispatchResult.ok) {
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
printDispatchSummary(state, config, retainedTurn);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
157
214
|
// §47: idle + no run_id → initialize new run
|
|
158
215
|
if (state.status === 'idle' && !state.run_id) {
|
|
159
216
|
const initResult = initializeGovernedRun(root, config);
|
|
@@ -165,10 +222,22 @@ export async function resumeCommand(opts) {
|
|
|
165
222
|
console.log(chalk.green(`Initialized governed run: ${state.run_id}`));
|
|
166
223
|
}
|
|
167
224
|
|
|
225
|
+
// §47: paused + run_id exists → resume same run
|
|
226
|
+
if (state.status === 'blocked' && state.run_id) {
|
|
227
|
+
const reactivated = reactivateGovernedRun(root, state, { via: 'resume', notificationConfig: config });
|
|
228
|
+
if (!reactivated.ok) {
|
|
229
|
+
console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
state = reactivated.state;
|
|
233
|
+
console.log(chalk.green(`Resumed blocked run: ${state.run_id}`));
|
|
234
|
+
}
|
|
235
|
+
|
|
168
236
|
// §47: paused + run_id exists → resume same run
|
|
169
237
|
if (state.status === 'paused' && state.run_id) {
|
|
170
238
|
state.status = 'active';
|
|
171
239
|
state.blocked_on = null;
|
|
240
|
+
state.blocked_reason = null;
|
|
172
241
|
state.escalation = null;
|
|
173
242
|
safeWriteJson(statePath, state);
|
|
174
243
|
console.log(chalk.green(`Resumed governed run: ${state.run_id}`));
|
|
@@ -203,7 +272,7 @@ export async function resumeCommand(opts) {
|
|
|
203
272
|
const hooksConfig = config.hooks || {};
|
|
204
273
|
const turn = state.current_turn;
|
|
205
274
|
if (hooksConfig.after_dispatch?.length > 0) {
|
|
206
|
-
const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, turn);
|
|
275
|
+
const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, turn, config);
|
|
207
276
|
if (!afterDispatchResult.ok) {
|
|
208
277
|
process.exit(1);
|
|
209
278
|
}
|
|
@@ -259,7 +328,7 @@ function resolveTargetRole(opts, state, config) {
|
|
|
259
328
|
return null;
|
|
260
329
|
}
|
|
261
330
|
|
|
262
|
-
function runAfterDispatchHooks(root, hooksConfig, state, turn) {
|
|
331
|
+
function runAfterDispatchHooks(root, hooksConfig, state, turn, config) {
|
|
263
332
|
const turnId = turn.turn_id;
|
|
264
333
|
const roleId = turn.assigned_role;
|
|
265
334
|
|
|
@@ -300,6 +369,7 @@ function runAfterDispatchHooks(root, hooksConfig, state, turn) {
|
|
|
300
369
|
detail,
|
|
301
370
|
},
|
|
302
371
|
turnId,
|
|
372
|
+
notificationConfig: config,
|
|
303
373
|
});
|
|
304
374
|
|
|
305
375
|
printDispatchHookFailure({
|
package/src/commands/step.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
markRunBlocked,
|
|
33
33
|
getActiveTurnCount,
|
|
34
34
|
getActiveTurns,
|
|
35
|
+
reactivateGovernedRun,
|
|
35
36
|
STATE_PATH,
|
|
36
37
|
} from '../lib/governed-state.js';
|
|
37
38
|
import { getMaxConcurrentTurns } from '../lib/normalized-config.js';
|
|
@@ -203,8 +204,12 @@ export async function stepCommand(opts) {
|
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
console.log(chalk.yellow(`Re-dispatching blocked turn: ${targetTurn.turn_id}`));
|
|
206
|
-
|
|
207
|
-
|
|
207
|
+
const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
|
|
208
|
+
if (!reactivated.ok) {
|
|
209
|
+
console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
state = reactivated.state;
|
|
208
213
|
skipAssignment = true;
|
|
209
214
|
|
|
210
215
|
const bundleResult = writeDispatchBundle(root, state, config);
|
|
@@ -251,8 +256,12 @@ export async function stepCommand(opts) {
|
|
|
251
256
|
|
|
252
257
|
// paused → resume
|
|
253
258
|
if (!skipAssignment && state.status === 'blocked' && state.run_id) {
|
|
254
|
-
|
|
255
|
-
|
|
259
|
+
const reactivated = reactivateGovernedRun(root, state, { via: 'step', notificationConfig: config });
|
|
260
|
+
if (!reactivated.ok) {
|
|
261
|
+
console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
state = reactivated.state;
|
|
256
265
|
console.log(chalk.green(`Resumed blocked run: ${state.run_id}`));
|
|
257
266
|
}
|
|
258
267
|
|
|
@@ -332,6 +341,7 @@ export async function stepCommand(opts) {
|
|
|
332
341
|
hookResults: afterDispatchHooks,
|
|
333
342
|
phase: 'after_dispatch',
|
|
334
343
|
defaultDetail: `after_dispatch hook blocked dispatch for turn ${turn.turn_id}`,
|
|
344
|
+
config,
|
|
335
345
|
});
|
|
336
346
|
printLifecycleHookFailure('Dispatch Blocked By Hook', blocked.result, {
|
|
337
347
|
turnId: turn.turn_id,
|
|
@@ -382,6 +392,7 @@ export async function stepCommand(opts) {
|
|
|
382
392
|
},
|
|
383
393
|
turnId: turn.turn_id,
|
|
384
394
|
hooksConfig,
|
|
395
|
+
notificationConfig: config,
|
|
385
396
|
});
|
|
386
397
|
if (blocked.ok) {
|
|
387
398
|
state = blocked.state;
|
|
@@ -462,6 +473,7 @@ export async function stepCommand(opts) {
|
|
|
462
473
|
},
|
|
463
474
|
turnId: turn.turn_id,
|
|
464
475
|
hooksConfig,
|
|
476
|
+
notificationConfig: config,
|
|
465
477
|
});
|
|
466
478
|
if (blocked.ok) {
|
|
467
479
|
state = blocked.state;
|
|
@@ -530,6 +542,7 @@ export async function stepCommand(opts) {
|
|
|
530
542
|
},
|
|
531
543
|
turnId: turn.turn_id,
|
|
532
544
|
hooksConfig,
|
|
545
|
+
notificationConfig: config,
|
|
533
546
|
});
|
|
534
547
|
if (blocked.ok) {
|
|
535
548
|
state = blocked.state;
|
|
@@ -556,6 +569,7 @@ export async function stepCommand(opts) {
|
|
|
556
569
|
},
|
|
557
570
|
turnId: turn.turn_id,
|
|
558
571
|
hooksConfig,
|
|
572
|
+
notificationConfig: config,
|
|
559
573
|
});
|
|
560
574
|
if (blocked.ok) {
|
|
561
575
|
state = blocked.state;
|
|
@@ -643,6 +657,7 @@ export async function stepCommand(opts) {
|
|
|
643
657
|
hookResults: beforeValidationHooks,
|
|
644
658
|
phase: 'before_validation',
|
|
645
659
|
defaultDetail: `before_validation hook blocked validation for turn ${turn.turn_id}`,
|
|
660
|
+
config,
|
|
646
661
|
});
|
|
647
662
|
printLifecycleHookFailure('Validation Blocked By Hook', blocked.result, {
|
|
648
663
|
turnId: turn.turn_id,
|
|
@@ -674,6 +689,7 @@ export async function stepCommand(opts) {
|
|
|
674
689
|
hookResults: afterValidationHooks,
|
|
675
690
|
phase: 'after_validation',
|
|
676
691
|
defaultDetail: `after_validation hook blocked acceptance for turn ${turn.turn_id}`,
|
|
692
|
+
config,
|
|
677
693
|
});
|
|
678
694
|
printLifecycleHookFailure('Validation Blocked By Hook', blocked.result, {
|
|
679
695
|
turnId: turn.turn_id,
|
|
@@ -758,7 +774,7 @@ function loadHookStagedTurn(root, stagingRel) {
|
|
|
758
774
|
}
|
|
759
775
|
}
|
|
760
776
|
|
|
761
|
-
function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail }) {
|
|
777
|
+
function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail, config }) {
|
|
762
778
|
const hookName = hookResults.blocker?.hook_name
|
|
763
779
|
|| hookResults.results?.find((entry) => entry.hook_name)?.hook_name
|
|
764
780
|
|| 'unknown';
|
|
@@ -777,6 +793,7 @@ function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail }
|
|
|
777
793
|
detail,
|
|
778
794
|
},
|
|
779
795
|
turnId: turn.turn_id,
|
|
796
|
+
notificationConfig: config,
|
|
780
797
|
});
|
|
781
798
|
|
|
782
799
|
return {
|
|
@@ -860,16 +877,6 @@ function resolveTargetRole(opts, state, config) {
|
|
|
860
877
|
return null;
|
|
861
878
|
}
|
|
862
879
|
|
|
863
|
-
function clearBlockedState(state) {
|
|
864
|
-
return {
|
|
865
|
-
...state,
|
|
866
|
-
status: 'active',
|
|
867
|
-
blocked_on: null,
|
|
868
|
-
blocked_reason: null,
|
|
869
|
-
escalation: null,
|
|
870
|
-
};
|
|
871
|
-
}
|
|
872
|
-
|
|
873
880
|
function printRecoverySummary(state, heading) {
|
|
874
881
|
const recovery = deriveRecoveryDescriptor(state);
|
|
875
882
|
console.log(chalk.yellow(heading));
|
package/src/lib/blocked-state.js
CHANGED
|
@@ -65,12 +65,16 @@ export function deriveRecoveryDescriptor(state) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
if (state.blocked_on.startsWith('escalation:')) {
|
|
68
|
+
const isOperatorEscalation = state.blocked_on.startsWith('escalation:operator:') || state.escalation?.source === 'operator';
|
|
69
|
+
const recoveryAction = turnRetained
|
|
70
|
+
? 'Resolve the escalation, then run agentxchain step --resume'
|
|
71
|
+
: 'Resolve the escalation, then run agentxchain step';
|
|
68
72
|
return {
|
|
69
|
-
typed_reason: 'retries_exhausted',
|
|
73
|
+
typed_reason: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
|
|
70
74
|
owner: 'human',
|
|
71
|
-
recovery_action:
|
|
75
|
+
recovery_action: recoveryAction,
|
|
72
76
|
turn_retained: turnRetained,
|
|
73
|
-
detail: state.blocked_on,
|
|
77
|
+
detail: state.escalation?.detail || state.escalation?.reason || state.blocked_on,
|
|
74
78
|
};
|
|
75
79
|
}
|
|
76
80
|
|
|
@@ -187,6 +187,7 @@ function verifyRunExport(artifact, errors) {
|
|
|
187
187
|
const expectedHistoryEntries = countJsonl(artifact.files, '.agentxchain/history.jsonl');
|
|
188
188
|
const expectedDecisionEntries = countJsonl(artifact.files, '.agentxchain/decision-ledger.jsonl');
|
|
189
189
|
const expectedHookAuditEntries = countJsonl(artifact.files, '.agentxchain/hook-audit.jsonl');
|
|
190
|
+
const expectedNotificationAuditEntries = countJsonl(artifact.files, '.agentxchain/notification-audit.jsonl');
|
|
190
191
|
const expectedDispatchFiles = countDirectoryFiles(artifact.files, '.agentxchain/dispatch');
|
|
191
192
|
const expectedStagingFiles = countDirectoryFiles(artifact.files, '.agentxchain/staging');
|
|
192
193
|
const expectedIntakePresent = Object.keys(artifact.files).some((path) => path.startsWith('.agentxchain/intake/'));
|
|
@@ -201,6 +202,9 @@ function verifyRunExport(artifact, errors) {
|
|
|
201
202
|
if (artifact.summary.hook_audit_entries !== expectedHookAuditEntries) {
|
|
202
203
|
addError(errors, 'summary.hook_audit_entries', 'must match .agentxchain/hook-audit.jsonl entry count');
|
|
203
204
|
}
|
|
205
|
+
if (artifact.summary.notification_audit_entries !== expectedNotificationAuditEntries) {
|
|
206
|
+
addError(errors, 'summary.notification_audit_entries', 'must match .agentxchain/notification-audit.jsonl entry count');
|
|
207
|
+
}
|
|
204
208
|
if (artifact.summary.dispatch_artifact_files !== expectedDispatchFiles) {
|
|
205
209
|
addError(errors, 'summary.dispatch_artifact_files', 'must match .agentxchain/dispatch file count');
|
|
206
210
|
}
|
package/src/lib/export.js
CHANGED
|
@@ -24,6 +24,7 @@ const INCLUDED_ROOTS = [
|
|
|
24
24
|
'.agentxchain/decision-ledger.jsonl',
|
|
25
25
|
'.agentxchain/hook-audit.jsonl',
|
|
26
26
|
'.agentxchain/hook-annotations.jsonl',
|
|
27
|
+
'.agentxchain/notification-audit.jsonl',
|
|
27
28
|
'.agentxchain/dispatch',
|
|
28
29
|
'.agentxchain/staging',
|
|
29
30
|
'.agentxchain/transactions/accept',
|
|
@@ -172,6 +173,7 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
172
173
|
history_entries: countJsonl(files, '.agentxchain/history.jsonl'),
|
|
173
174
|
decision_entries: countJsonl(files, '.agentxchain/decision-ledger.jsonl'),
|
|
174
175
|
hook_audit_entries: countJsonl(files, '.agentxchain/hook-audit.jsonl'),
|
|
176
|
+
notification_audit_entries: countJsonl(files, '.agentxchain/notification-audit.jsonl'),
|
|
175
177
|
dispatch_artifact_files: countDirectoryFiles(files, '.agentxchain/dispatch'),
|
|
176
178
|
staging_artifact_files: countDirectoryFiles(files, '.agentxchain/staging'),
|
|
177
179
|
intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
35
35
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir } from './turn-paths.js';
|
|
36
36
|
import { runHooks } from './hook-runner.js';
|
|
37
|
+
import { emitNotifications } from './notification-runner.js';
|
|
37
38
|
|
|
38
39
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
39
40
|
|
|
@@ -53,6 +54,29 @@ function generateId(prefix) {
|
|
|
53
54
|
return `${prefix}_${randomBytes(8).toString('hex')}`;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
function emitBlockedNotification(root, config, state, details = {}, turn = null) {
|
|
58
|
+
if (!config?.notifications?.webhooks?.length) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const recovery = state?.blocked_reason?.recovery || details.recovery || null;
|
|
63
|
+
emitNotifications(root, config, state, 'run_blocked', {
|
|
64
|
+
category: state?.blocked_reason?.category || details.category || 'unknown_block',
|
|
65
|
+
blocked_on: state?.blocked_on || details.blockedOn || null,
|
|
66
|
+
typed_reason: recovery?.typed_reason || null,
|
|
67
|
+
owner: recovery?.owner || null,
|
|
68
|
+
recovery_action: recovery?.recovery_action || null,
|
|
69
|
+
detail: recovery?.detail || null,
|
|
70
|
+
}, turn);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function emitPendingLifecycleNotification(root, config, state, eventType, payload, turn = null) {
|
|
74
|
+
if (!config?.notifications?.webhooks?.length) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
emitNotifications(root, config, state, eventType, payload, turn);
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
function normalizeActiveTurns(activeTurns) {
|
|
57
81
|
if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
|
|
58
82
|
return {};
|
|
@@ -555,6 +579,22 @@ function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date()
|
|
|
555
579
|
};
|
|
556
580
|
}
|
|
557
581
|
|
|
582
|
+
function slugifyEscalationReason(reason) {
|
|
583
|
+
if (typeof reason !== 'string') {
|
|
584
|
+
return 'operator';
|
|
585
|
+
}
|
|
586
|
+
const slug = reason
|
|
587
|
+
.trim()
|
|
588
|
+
.toLowerCase()
|
|
589
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
590
|
+
.replace(/^-+|-+$/g, '');
|
|
591
|
+
return slug || 'operator';
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function isOperatorEscalationBlockedOn(blockedOn) {
|
|
595
|
+
return typeof blockedOn === 'string' && blockedOn.startsWith('escalation:operator:');
|
|
596
|
+
}
|
|
597
|
+
|
|
558
598
|
function canApprovePendingGate(state) {
|
|
559
599
|
return state?.status === 'paused' || state?.status === 'blocked';
|
|
560
600
|
}
|
|
@@ -599,7 +639,7 @@ function deriveHookRecovery(state, { phase, hookName, detail, errorCode, turnId,
|
|
|
599
639
|
};
|
|
600
640
|
}
|
|
601
641
|
|
|
602
|
-
function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, errorCode, turnRetained }) {
|
|
642
|
+
function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, errorCode, turnRetained, notificationConfig }) {
|
|
603
643
|
const blockedAt = new Date().toISOString();
|
|
604
644
|
const typedReason = errorCode?.includes('_tamper') ? 'hook_tamper' : 'hook_block';
|
|
605
645
|
const recovery = deriveHookRecovery(state, {
|
|
@@ -622,6 +662,11 @@ function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, er
|
|
|
622
662
|
}),
|
|
623
663
|
};
|
|
624
664
|
writeState(root, blockedState);
|
|
665
|
+
emitBlockedNotification(root, notificationConfig, blockedState, {
|
|
666
|
+
category: typedReason,
|
|
667
|
+
blockedOn: blockedState.blocked_on,
|
|
668
|
+
recovery,
|
|
669
|
+
}, turnId ? getActiveTurns(blockedState)[turnId] || null : null);
|
|
625
670
|
return attachLegacyCurrentTurnAlias(blockedState);
|
|
626
671
|
}
|
|
627
672
|
|
|
@@ -694,14 +739,18 @@ function inferBlockedReasonFromState(state) {
|
|
|
694
739
|
}
|
|
695
740
|
|
|
696
741
|
if (state.blocked_on.startsWith('escalation:')) {
|
|
742
|
+
const isOperatorEscalation = isOperatorEscalationBlockedOn(state.blocked_on) || state.escalation?.source === 'operator';
|
|
743
|
+
const recoveryAction = turnRetained
|
|
744
|
+
? 'Resolve the escalation, then run agentxchain step --resume'
|
|
745
|
+
: 'Resolve the escalation, then run agentxchain step';
|
|
697
746
|
return buildBlockedReason({
|
|
698
|
-
category: 'retries_exhausted',
|
|
747
|
+
category: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
|
|
699
748
|
recovery: {
|
|
700
|
-
typed_reason: 'retries_exhausted',
|
|
749
|
+
typed_reason: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
|
|
701
750
|
owner: 'human',
|
|
702
|
-
recovery_action:
|
|
751
|
+
recovery_action: recoveryAction,
|
|
703
752
|
turn_retained: turnRetained,
|
|
704
|
-
detail: state.blocked_on,
|
|
753
|
+
detail: state.escalation?.detail || state.escalation?.reason || state.blocked_on,
|
|
705
754
|
},
|
|
706
755
|
turnId: activeTurn?.turn_id ?? null,
|
|
707
756
|
});
|
|
@@ -819,6 +868,12 @@ export function markRunBlocked(root, details) {
|
|
|
819
868
|
|
|
820
869
|
writeState(root, updatedState);
|
|
821
870
|
|
|
871
|
+
emitBlockedNotification(root, details.notificationConfig, updatedState, {
|
|
872
|
+
category: details.category,
|
|
873
|
+
blockedOn: details.blockedOn,
|
|
874
|
+
recovery: details.recovery,
|
|
875
|
+
}, turnId ? getActiveTurns(updatedState)[turnId] || null : null);
|
|
876
|
+
|
|
822
877
|
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
823
878
|
// Only fire for non-hook-caused blocks to prevent circular invocations.
|
|
824
879
|
if (details.hooksConfig?.on_escalation?.length > 0) {
|
|
@@ -837,6 +892,141 @@ export function markRunBlocked(root, details) {
|
|
|
837
892
|
return { ok: true, state: updatedState };
|
|
838
893
|
}
|
|
839
894
|
|
|
895
|
+
export function raiseOperatorEscalation(root, config, details) {
|
|
896
|
+
const state = readState(root);
|
|
897
|
+
if (!state) {
|
|
898
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const reason = typeof details.reason === 'string' ? details.reason.trim() : '';
|
|
902
|
+
if (!reason) {
|
|
903
|
+
return { ok: false, error: 'Escalation reason is required.' };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (state.status !== 'active') {
|
|
907
|
+
return { ok: false, error: `Cannot escalate run: status is "${state.status}", expected "active"` };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const activeTurns = getActiveTurns(state);
|
|
911
|
+
let targetTurn = null;
|
|
912
|
+
if (details.turnId) {
|
|
913
|
+
targetTurn = activeTurns[details.turnId] || null;
|
|
914
|
+
if (!targetTurn) {
|
|
915
|
+
return { ok: false, error: `No active turn found for --turn ${details.turnId}` };
|
|
916
|
+
}
|
|
917
|
+
} else {
|
|
918
|
+
const turns = Object.values(activeTurns);
|
|
919
|
+
if (turns.length > 1) {
|
|
920
|
+
return { ok: false, error: 'Multiple active turns exist. Use --turn <id> to target the escalation.' };
|
|
921
|
+
}
|
|
922
|
+
targetTurn = turns[0] || null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const turnRetained = Boolean(targetTurn);
|
|
926
|
+
const recoveryAction = details.action
|
|
927
|
+
|| (turnRetained
|
|
928
|
+
? 'Resolve the escalation, then run agentxchain step --resume'
|
|
929
|
+
: 'Resolve the escalation, then run agentxchain step');
|
|
930
|
+
const detail = typeof details.detail === 'string' && details.detail.trim()
|
|
931
|
+
? details.detail.trim()
|
|
932
|
+
: reason;
|
|
933
|
+
const blockedOn = `escalation:operator:${slugifyEscalationReason(reason)}`;
|
|
934
|
+
const escalatedAt = new Date().toISOString();
|
|
935
|
+
|
|
936
|
+
const escalation = {
|
|
937
|
+
source: 'operator',
|
|
938
|
+
raised_by: 'human',
|
|
939
|
+
from_role: targetTurn?.assigned_role || null,
|
|
940
|
+
from_turn_id: targetTurn?.turn_id || null,
|
|
941
|
+
reason,
|
|
942
|
+
detail,
|
|
943
|
+
recovery_action: recoveryAction,
|
|
944
|
+
escalated_at: escalatedAt,
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const blocked = markRunBlocked(root, {
|
|
948
|
+
blockedOn,
|
|
949
|
+
category: 'operator_escalation',
|
|
950
|
+
recovery: {
|
|
951
|
+
typed_reason: 'operator_escalation',
|
|
952
|
+
owner: 'human',
|
|
953
|
+
recovery_action: recoveryAction,
|
|
954
|
+
turn_retained: turnRetained,
|
|
955
|
+
detail,
|
|
956
|
+
},
|
|
957
|
+
turnId: targetTurn?.turn_id || null,
|
|
958
|
+
escalation,
|
|
959
|
+
hooksConfig: config?.hooks || {},
|
|
960
|
+
notificationConfig: config,
|
|
961
|
+
});
|
|
962
|
+
if (!blocked.ok) {
|
|
963
|
+
return blocked;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
967
|
+
timestamp: escalatedAt,
|
|
968
|
+
decision: 'operator_escalated',
|
|
969
|
+
run_id: blocked.state.run_id,
|
|
970
|
+
phase: blocked.state.phase,
|
|
971
|
+
blocked_on: blockedOn,
|
|
972
|
+
escalation,
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
emitPendingLifecycleNotification(root, config, blocked.state, 'operator_escalation_raised', {
|
|
976
|
+
source: 'operator',
|
|
977
|
+
blocked_on: blockedOn,
|
|
978
|
+
reason,
|
|
979
|
+
detail,
|
|
980
|
+
recovery_action: recoveryAction,
|
|
981
|
+
}, targetTurn);
|
|
982
|
+
|
|
983
|
+
return {
|
|
984
|
+
ok: true,
|
|
985
|
+
state: attachLegacyCurrentTurnAlias(blocked.state),
|
|
986
|
+
escalation,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function reactivateGovernedRun(root, state, details = {}) {
|
|
991
|
+
if (!state || typeof state !== 'object') {
|
|
992
|
+
return { ok: false, error: 'State is required.' };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const now = new Date().toISOString();
|
|
996
|
+
const wasEscalation = state.status === 'blocked' && typeof state.blocked_on === 'string' && state.blocked_on.startsWith('escalation:');
|
|
997
|
+
const nextState = {
|
|
998
|
+
...state,
|
|
999
|
+
status: 'active',
|
|
1000
|
+
blocked_on: null,
|
|
1001
|
+
blocked_reason: null,
|
|
1002
|
+
escalation: null,
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
writeState(root, nextState);
|
|
1006
|
+
|
|
1007
|
+
if (wasEscalation) {
|
|
1008
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
1009
|
+
timestamp: now,
|
|
1010
|
+
decision: 'escalation_resolved',
|
|
1011
|
+
run_id: state.run_id,
|
|
1012
|
+
phase: state.phase,
|
|
1013
|
+
resolved_via: details.via || 'unknown',
|
|
1014
|
+
blocked_on: state.blocked_on,
|
|
1015
|
+
escalation: state.escalation || null,
|
|
1016
|
+
turn_id: state.escalation?.from_turn_id ?? getActiveTurn(state)?.turn_id ?? null,
|
|
1017
|
+
role: state.escalation?.from_role ?? getActiveTurn(state)?.assigned_role ?? null,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
emitPendingLifecycleNotification(details.root || root, details.notificationConfig, nextState, 'escalation_resolved', {
|
|
1021
|
+
blocked_on: state.blocked_on,
|
|
1022
|
+
resolved_via: details.via || 'unknown',
|
|
1023
|
+
previous_escalation: state.escalation || null,
|
|
1024
|
+
}, state.escalation?.from_turn_id ? getActiveTurns(state)[state.escalation.from_turn_id] || getActiveTurn(state) : getActiveTurn(state));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
840
1030
|
// ── Core Operations ──────────────────────────────────────────────────────────
|
|
841
1031
|
|
|
842
1032
|
/**
|
|
@@ -1014,6 +1204,7 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
1014
1204
|
detail,
|
|
1015
1205
|
errorCode: beforeAssignmentHooks.tamper.error_code,
|
|
1016
1206
|
turnRetained: activeCount > 0,
|
|
1207
|
+
notificationConfig: config,
|
|
1017
1208
|
});
|
|
1018
1209
|
return {
|
|
1019
1210
|
ok: false,
|
|
@@ -1240,6 +1431,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1240
1431
|
detail,
|
|
1241
1432
|
errorCode: beforeValidationHooks.tamper?.error_code || 'hook_blocked',
|
|
1242
1433
|
turnRetained: true,
|
|
1434
|
+
notificationConfig: config,
|
|
1243
1435
|
});
|
|
1244
1436
|
return {
|
|
1245
1437
|
ok: false,
|
|
@@ -1281,6 +1473,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1281
1473
|
detail,
|
|
1282
1474
|
errorCode: afterValidationHooks.tamper?.error_code || 'hook_blocked',
|
|
1283
1475
|
turnRetained: true,
|
|
1476
|
+
notificationConfig: config,
|
|
1284
1477
|
});
|
|
1285
1478
|
return {
|
|
1286
1479
|
ok: false,
|
|
@@ -1426,6 +1619,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1426
1619
|
detail,
|
|
1427
1620
|
errorCode: beforeAcceptanceHooks.tamper?.error_code || 'hook_blocked',
|
|
1428
1621
|
turnRetained: true,
|
|
1622
|
+
notificationConfig: config,
|
|
1429
1623
|
});
|
|
1430
1624
|
return {
|
|
1431
1625
|
ok: false,
|
|
@@ -1698,6 +1892,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1698
1892
|
detail,
|
|
1699
1893
|
errorCode: hookResults.tamper?.error_code || 'hook_post_commit_error',
|
|
1700
1894
|
turnRetained: Object.keys(getActiveTurns(updatedState)).length > 0,
|
|
1895
|
+
notificationConfig: config,
|
|
1701
1896
|
});
|
|
1702
1897
|
return {
|
|
1703
1898
|
ok: false,
|
|
@@ -1713,6 +1908,39 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1713
1908
|
}
|
|
1714
1909
|
}
|
|
1715
1910
|
|
|
1911
|
+
if (updatedState.status === 'blocked') {
|
|
1912
|
+
emitBlockedNotification(root, config, updatedState, {
|
|
1913
|
+
category: updatedState.blocked_reason?.category || 'needs_human',
|
|
1914
|
+
blockedOn: updatedState.blocked_on,
|
|
1915
|
+
recovery: updatedState.blocked_reason?.recovery || null,
|
|
1916
|
+
}, currentTurn);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
if (updatedState.pending_phase_transition) {
|
|
1920
|
+
emitPendingLifecycleNotification(root, config, updatedState, 'phase_transition_pending', {
|
|
1921
|
+
from: updatedState.pending_phase_transition.from,
|
|
1922
|
+
to: updatedState.pending_phase_transition.to,
|
|
1923
|
+
gate: updatedState.pending_phase_transition.gate,
|
|
1924
|
+
requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
|
|
1925
|
+
}, currentTurn);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
if (updatedState.pending_run_completion) {
|
|
1929
|
+
emitPendingLifecycleNotification(root, config, updatedState, 'run_completion_pending', {
|
|
1930
|
+
gate: updatedState.pending_run_completion.gate,
|
|
1931
|
+
requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
|
|
1932
|
+
requested_at: updatedState.pending_run_completion.requested_at,
|
|
1933
|
+
}, currentTurn);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
if (updatedState.status === 'completed') {
|
|
1937
|
+
emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
|
|
1938
|
+
completed_at: updatedState.completed_at || now,
|
|
1939
|
+
completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
|
|
1940
|
+
requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
|
|
1941
|
+
}, currentTurn);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1716
1944
|
return {
|
|
1717
1945
|
ok: true,
|
|
1718
1946
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -1898,6 +2126,12 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
1898
2126
|
|
|
1899
2127
|
writeState(root, updatedState);
|
|
1900
2128
|
|
|
2129
|
+
emitBlockedNotification(root, config, updatedState, {
|
|
2130
|
+
category: 'retries_exhausted',
|
|
2131
|
+
blockedOn: updatedState.blocked_on,
|
|
2132
|
+
recovery: updatedState.blocked_reason?.recovery || null,
|
|
2133
|
+
}, updatedState.active_turns[currentTurn.turn_id]);
|
|
2134
|
+
|
|
1901
2135
|
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
1902
2136
|
const hooksConfig = config?.hooks || {};
|
|
1903
2137
|
if (hooksConfig.on_escalation?.length > 0) {
|
|
@@ -1977,6 +2211,7 @@ export function approvePhaseTransition(root, config) {
|
|
|
1977
2211
|
detail,
|
|
1978
2212
|
errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
|
|
1979
2213
|
turnRetained: false,
|
|
2214
|
+
notificationConfig: config,
|
|
1980
2215
|
});
|
|
1981
2216
|
return {
|
|
1982
2217
|
ok: false,
|
|
@@ -2067,6 +2302,7 @@ export function approveRunCompletion(root, config) {
|
|
|
2067
2302
|
detail,
|
|
2068
2303
|
errorCode: gateHooks.tamper?.error_code || 'hook_blocked',
|
|
2069
2304
|
turnRetained: false,
|
|
2305
|
+
notificationConfig: config,
|
|
2070
2306
|
});
|
|
2071
2307
|
return {
|
|
2072
2308
|
ok: false,
|
|
@@ -2093,6 +2329,13 @@ export function approveRunCompletion(root, config) {
|
|
|
2093
2329
|
|
|
2094
2330
|
writeState(root, updatedState);
|
|
2095
2331
|
|
|
2332
|
+
emitPendingLifecycleNotification(root, config, updatedState, 'run_completed', {
|
|
2333
|
+
completed_at: updatedState.completed_at,
|
|
2334
|
+
completed_via: 'approve_run_completion',
|
|
2335
|
+
gate: completion.gate,
|
|
2336
|
+
requested_by_turn: completion.requested_by_turn || null,
|
|
2337
|
+
}, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
|
|
2338
|
+
|
|
2096
2339
|
return {
|
|
2097
2340
|
ok: true,
|
|
2098
2341
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -2136,4 +2379,10 @@ function deriveNextRecommendedRole(turnResult, state, config) {
|
|
|
2136
2379
|
return routing?.entry_role || null;
|
|
2137
2380
|
}
|
|
2138
2381
|
|
|
2139
|
-
export {
|
|
2382
|
+
export {
|
|
2383
|
+
STATE_PATH,
|
|
2384
|
+
HISTORY_PATH,
|
|
2385
|
+
LEDGER_PATH,
|
|
2386
|
+
STAGING_PATH,
|
|
2387
|
+
TALK_PATH,
|
|
2388
|
+
};
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { validateHooksConfig } from './hook-runner.js';
|
|
16
|
+
import { validateNotificationsConfig } from './notification-runner.js';
|
|
16
17
|
import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
|
|
17
18
|
|
|
18
19
|
const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
|
|
@@ -425,6 +426,12 @@ export function validateV4Config(data, projectRoot) {
|
|
|
425
426
|
errors.push(...hookValidation.errors);
|
|
426
427
|
}
|
|
427
428
|
|
|
429
|
+
// Notifications (optional but validated if present)
|
|
430
|
+
if (data.notifications) {
|
|
431
|
+
const notificationValidation = validateNotificationsConfig(data.notifications);
|
|
432
|
+
errors.push(...notificationValidation.errors);
|
|
433
|
+
}
|
|
434
|
+
|
|
428
435
|
return { ok: errors.length === 0, errors };
|
|
429
436
|
}
|
|
430
437
|
|
|
@@ -467,6 +474,7 @@ export function normalizeV3(raw) {
|
|
|
467
474
|
routing: buildLegacyRouting(Object.keys(agents)),
|
|
468
475
|
gates: {},
|
|
469
476
|
hooks: {},
|
|
477
|
+
notifications: {},
|
|
470
478
|
budget: null,
|
|
471
479
|
retention: {
|
|
472
480
|
talk_strategy: 'append_only',
|
|
@@ -526,6 +534,7 @@ export function normalizeV4(raw) {
|
|
|
526
534
|
routing: raw.routing || {},
|
|
527
535
|
gates: raw.gates || {},
|
|
528
536
|
hooks: raw.hooks || {},
|
|
537
|
+
notifications: raw.notifications || {},
|
|
529
538
|
budget: raw.budget || null,
|
|
530
539
|
retention: raw.retention || {
|
|
531
540
|
talk_strategy: 'append_only',
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import { interpolateHeaders } from './hook-runner.js';
|
|
7
|
+
|
|
8
|
+
export const NOTIFICATION_AUDIT_PATH = '.agentxchain/notification-audit.jsonl';
|
|
9
|
+
export const VALID_NOTIFICATION_EVENTS = [
|
|
10
|
+
'run_blocked',
|
|
11
|
+
'operator_escalation_raised',
|
|
12
|
+
'escalation_resolved',
|
|
13
|
+
'phase_transition_pending',
|
|
14
|
+
'run_completion_pending',
|
|
15
|
+
'run_completed',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const NOTIFICATION_NAME_RE = /^[a-z0-9_-]+$/;
|
|
19
|
+
const MAX_NOTIFICATION_WEBHOOKS = 8;
|
|
20
|
+
const HEADER_VAR_RE = /\$\{([^}]+)\}/g;
|
|
21
|
+
const SIGKILL_GRACE_MS = 2000;
|
|
22
|
+
const MAX_STDERR_CAPTURE = 4096;
|
|
23
|
+
|
|
24
|
+
function collectMissingHeaderVars(headers, webhookEnv) {
|
|
25
|
+
if (!headers) return [];
|
|
26
|
+
const missing = [];
|
|
27
|
+
const mergedEnv = { ...process.env };
|
|
28
|
+
|
|
29
|
+
for (const [key, value] of Object.entries(webhookEnv || {})) {
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
mergedEnv[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const [headerName, value] of Object.entries(headers)) {
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = HEADER_VAR_RE.exec(value)) !== null) {
|
|
38
|
+
if (mergedEnv[match[1]] === undefined) {
|
|
39
|
+
missing.push({ header: headerName, varName: match[1] });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
HEADER_VAR_RE.lastIndex = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return missing;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function appendAudit(root, entry) {
|
|
49
|
+
const filePath = join(root, NOTIFICATION_AUDIT_PATH);
|
|
50
|
+
if (!existsSync(dirname(filePath))) {
|
|
51
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
appendFileSync(filePath, `${JSON.stringify(entry)}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function generateEventId() {
|
|
57
|
+
return `notif_${randomBytes(8).toString('hex')}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function executeWebhook(webhook, envelope) {
|
|
61
|
+
const startedAt = Date.now();
|
|
62
|
+
let headers;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
headers = interpolateHeaders(webhook.headers, webhook.env);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return {
|
|
68
|
+
delivered: false,
|
|
69
|
+
timed_out: false,
|
|
70
|
+
status_code: null,
|
|
71
|
+
duration_ms: Date.now() - startedAt,
|
|
72
|
+
message: String(error?.message || error),
|
|
73
|
+
stderr_excerpt: String(error?.message || error).slice(0, MAX_STDERR_CAPTURE),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
headers['Content-Type'] = 'application/json';
|
|
78
|
+
|
|
79
|
+
const script = `
|
|
80
|
+
const url = process.argv[1];
|
|
81
|
+
const body = process.argv[2];
|
|
82
|
+
const headers = JSON.parse(process.argv[3]);
|
|
83
|
+
headers.Connection = 'close';
|
|
84
|
+
(async () => {
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(url, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers,
|
|
89
|
+
body,
|
|
90
|
+
signal: AbortSignal.timeout(${webhook.timeout_ms}),
|
|
91
|
+
});
|
|
92
|
+
const text = await response.text();
|
|
93
|
+
process.stdout.write(JSON.stringify({ status: response.status, body: text }));
|
|
94
|
+
process.exit(0);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
|
97
|
+
process.stderr.write('timeout');
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
process.stderr.write(error?.message || String(error));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const result = spawnSync(process.execPath, ['-e', script, webhook.url, JSON.stringify(envelope), JSON.stringify(headers)], {
|
|
107
|
+
timeout: webhook.timeout_ms + SIGKILL_GRACE_MS,
|
|
108
|
+
maxBuffer: 1024 * 1024,
|
|
109
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
+
env: { ...process.env, ...(webhook.env || {}) },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const durationMs = Date.now() - startedAt;
|
|
114
|
+
const timedOut = result.error?.code === 'ETIMEDOUT' || durationMs > webhook.timeout_ms;
|
|
115
|
+
const stdout = result.stdout ? result.stdout.toString('utf8') : '';
|
|
116
|
+
const stderr = result.stderr ? result.stderr.toString('utf8').slice(0, MAX_STDERR_CAPTURE) : '';
|
|
117
|
+
|
|
118
|
+
if (timedOut) {
|
|
119
|
+
return {
|
|
120
|
+
delivered: false,
|
|
121
|
+
timed_out: true,
|
|
122
|
+
status_code: null,
|
|
123
|
+
duration_ms: durationMs,
|
|
124
|
+
message: `Timed out after ${webhook.timeout_ms}ms`,
|
|
125
|
+
stderr_excerpt: stderr,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (result.status !== 0) {
|
|
130
|
+
return {
|
|
131
|
+
delivered: false,
|
|
132
|
+
timed_out: false,
|
|
133
|
+
status_code: null,
|
|
134
|
+
duration_ms: durationMs,
|
|
135
|
+
message: stderr || `Webhook delivery failed with exit code ${result.status}`,
|
|
136
|
+
stderr_excerpt: stderr,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const bridge = JSON.parse(stdout || '{}');
|
|
142
|
+
const statusCode = Number.isInteger(bridge.status) ? bridge.status : null;
|
|
143
|
+
const delivered = statusCode >= 200 && statusCode < 300;
|
|
144
|
+
return {
|
|
145
|
+
delivered,
|
|
146
|
+
timed_out: false,
|
|
147
|
+
status_code: statusCode,
|
|
148
|
+
duration_ms: durationMs,
|
|
149
|
+
message: delivered ? 'Delivered' : `Webhook returned HTTP ${statusCode}`,
|
|
150
|
+
stderr_excerpt: stderr,
|
|
151
|
+
};
|
|
152
|
+
} catch {
|
|
153
|
+
return {
|
|
154
|
+
delivered: false,
|
|
155
|
+
timed_out: false,
|
|
156
|
+
status_code: null,
|
|
157
|
+
duration_ms: durationMs,
|
|
158
|
+
message: 'Failed to parse webhook bridge response',
|
|
159
|
+
stderr_excerpt: stderr || stdout.slice(0, MAX_STDERR_CAPTURE),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function validateNotificationsConfig(notifications) {
|
|
165
|
+
const errors = [];
|
|
166
|
+
|
|
167
|
+
if (!notifications || typeof notifications !== 'object' || Array.isArray(notifications)) {
|
|
168
|
+
errors.push('notifications must be an object');
|
|
169
|
+
return { ok: false, errors };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const allowedKeys = new Set(['webhooks']);
|
|
173
|
+
for (const key of Object.keys(notifications)) {
|
|
174
|
+
if (!allowedKeys.has(key)) {
|
|
175
|
+
errors.push(`notifications contains unknown field "${key}"`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!('webhooks' in notifications)) {
|
|
180
|
+
return { ok: errors.length === 0, errors };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!Array.isArray(notifications.webhooks)) {
|
|
184
|
+
errors.push('notifications.webhooks must be an array');
|
|
185
|
+
return { ok: false, errors };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (notifications.webhooks.length > MAX_NOTIFICATION_WEBHOOKS) {
|
|
189
|
+
errors.push(`notifications.webhooks: maximum ${MAX_NOTIFICATION_WEBHOOKS} webhooks`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const names = new Set();
|
|
193
|
+
notifications.webhooks.forEach((webhook, index) => {
|
|
194
|
+
const label = `notifications.webhooks[${index}]`;
|
|
195
|
+
|
|
196
|
+
if (!webhook || typeof webhook !== 'object' || Array.isArray(webhook)) {
|
|
197
|
+
errors.push(`${label} must be an object`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (typeof webhook.name !== 'string' || !webhook.name.trim()) {
|
|
202
|
+
errors.push(`${label}: name must be a non-empty string`);
|
|
203
|
+
} else if (!NOTIFICATION_NAME_RE.test(webhook.name)) {
|
|
204
|
+
errors.push(`${label}: name must match ^[a-z0-9_-]+$`);
|
|
205
|
+
} else if (names.has(webhook.name)) {
|
|
206
|
+
errors.push(`${label}: duplicate webhook name "${webhook.name}"`);
|
|
207
|
+
} else {
|
|
208
|
+
names.add(webhook.name);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (typeof webhook.url !== 'string' || !webhook.url.trim()) {
|
|
212
|
+
errors.push(`${label}: url must be a non-empty string`);
|
|
213
|
+
} else if (!/^https?:\/\/.+/.test(webhook.url)) {
|
|
214
|
+
errors.push(`${label}: url must be a valid HTTP or HTTPS URL`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!Array.isArray(webhook.events) || webhook.events.length === 0) {
|
|
218
|
+
errors.push(`${label}: events must be a non-empty array`);
|
|
219
|
+
} else {
|
|
220
|
+
for (const eventName of webhook.events) {
|
|
221
|
+
if (!VALID_NOTIFICATION_EVENTS.includes(eventName)) {
|
|
222
|
+
errors.push(
|
|
223
|
+
`${label}: events contains unknown event "${eventName}". Valid events: ${VALID_NOTIFICATION_EVENTS.join(', ')}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!Number.isInteger(webhook.timeout_ms) || webhook.timeout_ms < 100 || webhook.timeout_ms > 30000) {
|
|
230
|
+
errors.push(`${label}: timeout_ms must be an integer between 100 and 30000`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if ('headers' in webhook && webhook.headers !== undefined) {
|
|
234
|
+
if (!webhook.headers || typeof webhook.headers !== 'object' || Array.isArray(webhook.headers)) {
|
|
235
|
+
errors.push(`${label}: headers must be an object`);
|
|
236
|
+
} else {
|
|
237
|
+
for (const [key, value] of Object.entries(webhook.headers)) {
|
|
238
|
+
if (typeof value !== 'string') {
|
|
239
|
+
errors.push(`${label}: headers.${key} must be a string`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const missingHeaderVars = collectMissingHeaderVars(webhook.headers, webhook.env);
|
|
243
|
+
if (missingHeaderVars.length > 0) {
|
|
244
|
+
errors.push(
|
|
245
|
+
`${label}: unresolved header env vars ${missingHeaderVars.map(({ header, varName }) => `${header}:${varName}`).join(', ')}`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if ('env' in webhook && webhook.env !== undefined) {
|
|
252
|
+
if (!webhook.env || typeof webhook.env !== 'object' || Array.isArray(webhook.env)) {
|
|
253
|
+
errors.push(`${label}: env must be an object`);
|
|
254
|
+
} else {
|
|
255
|
+
for (const [key, value] of Object.entries(webhook.env)) {
|
|
256
|
+
if (typeof value !== 'string') {
|
|
257
|
+
errors.push(`${label}: env.${key} must be a string`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return { ok: errors.length === 0, errors };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function emitNotifications(root, config, state, eventType, payload = {}, turn = null) {
|
|
268
|
+
const webhooks = config?.notifications?.webhooks;
|
|
269
|
+
if (!Array.isArray(webhooks) || webhooks.length === 0) {
|
|
270
|
+
return { ok: true, results: [] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!VALID_NOTIFICATION_EVENTS.includes(eventType)) {
|
|
274
|
+
return { ok: false, error: `Unknown notification event "${eventType}"`, results: [] };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const eventId = generateEventId();
|
|
278
|
+
const emittedAt = new Date().toISOString();
|
|
279
|
+
const envelope = {
|
|
280
|
+
schema_version: '0.1',
|
|
281
|
+
event_id: eventId,
|
|
282
|
+
event_type: eventType,
|
|
283
|
+
emitted_at: emittedAt,
|
|
284
|
+
project: {
|
|
285
|
+
id: config?.project?.id || 'unknown',
|
|
286
|
+
name: config?.project?.name || 'Unknown',
|
|
287
|
+
root,
|
|
288
|
+
},
|
|
289
|
+
run: {
|
|
290
|
+
run_id: state?.run_id || null,
|
|
291
|
+
status: state?.status || null,
|
|
292
|
+
phase: state?.phase || null,
|
|
293
|
+
},
|
|
294
|
+
turn: turn ? {
|
|
295
|
+
turn_id: turn.turn_id || null,
|
|
296
|
+
role_id: turn.assigned_role || turn.role_id || null,
|
|
297
|
+
attempt: Number.isInteger(turn.attempt) ? turn.attempt : null,
|
|
298
|
+
assigned_sequence: Number.isInteger(turn.assigned_sequence) ? turn.assigned_sequence : null,
|
|
299
|
+
} : null,
|
|
300
|
+
payload,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const results = [];
|
|
304
|
+
for (const webhook of webhooks) {
|
|
305
|
+
if (!webhook.events.includes(eventType)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const delivery = executeWebhook(webhook, envelope);
|
|
310
|
+
const auditEntry = {
|
|
311
|
+
event_id: eventId,
|
|
312
|
+
event_type: eventType,
|
|
313
|
+
notification_name: webhook.name,
|
|
314
|
+
transport: 'webhook',
|
|
315
|
+
delivered: delivery.delivered,
|
|
316
|
+
status_code: delivery.status_code,
|
|
317
|
+
timed_out: delivery.timed_out,
|
|
318
|
+
duration_ms: delivery.duration_ms,
|
|
319
|
+
message: delivery.message,
|
|
320
|
+
emitted_at: emittedAt,
|
|
321
|
+
};
|
|
322
|
+
if (delivery.stderr_excerpt) {
|
|
323
|
+
auditEntry.stderr_excerpt = delivery.stderr_excerpt;
|
|
324
|
+
}
|
|
325
|
+
appendAudit(root, auditEntry);
|
|
326
|
+
results.push(auditEntry);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { ok: true, event_id: eventId, results };
|
|
330
|
+
}
|