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.
- package/bin/agentxchain.js +26 -1
- package/package.json +1 -1
- package/src/commands/escalate.js +63 -0
- package/src/commands/export.js +63 -0
- package/src/commands/resume.js +72 -2
- package/src/commands/step.js +22 -15
- package/src/commands/verify.js +60 -0
- package/src/lib/blocked-state.js +7 -3
- package/src/lib/export-verifier.js +426 -0
- package/src/lib/export.js +305 -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
|
@@ -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
|
+
}
|