agentxchain 2.5.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.
@@ -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
+ }