agentxchain 2.155.22 → 2.155.24
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/README.md +1 -0
- package/bin/agentxchain.js +13 -1
- package/package.json +1 -1
- package/scripts/release-bump.sh +2 -0
- package/src/commands/watch.js +613 -7
- package/src/lib/intake.js +16 -2
- package/src/lib/watch-events.js +312 -0
- package/src/lib/watch-listener.js +297 -0
package/README.md
CHANGED
|
@@ -205,6 +205,7 @@ Partial coordinator artifacts are first-class here too: `audit` and `report` kee
|
|
|
205
205
|
| `multi init\|status\|step\|resume\|approve-gate\|resync` | Run the multi-repo coordinator lifecycle, including blocked-state recovery via `multi resume` |
|
|
206
206
|
| `intake record\|triage\|approve\|plan\|start\|scan\|resolve` | Continuous-delivery intake: turn delivery signals into governed work items |
|
|
207
207
|
| `intake handoff` | Bridge a planned intake intent to a coordinator workstream for multi-repo execution |
|
|
208
|
+
| `watch --event-file\|--event-dir\|--listen\|--results\|--result` | Normalize external events into governed intake, poll event-file directories, receive signed HTTP webhooks, and inspect durable watch result records |
|
|
208
209
|
| `schedule list\|run-due\|daemon\|status` | Run repo-local lights-out scheduling: inspect schedules, execute due runs, poll in a local daemon loop, continue explicitly unblocked schedule-owned runs, or check daemon heartbeat |
|
|
209
210
|
| `plugin install\|list\|remove` | Install, inspect, or remove governed hook plugins under `.agentxchain/plugins/` |
|
|
210
211
|
| `plugin list-available` | List bundled built-in plugins installable by short name |
|
package/bin/agentxchain.js
CHANGED
|
@@ -248,8 +248,20 @@ generateCmd
|
|
|
248
248
|
|
|
249
249
|
program
|
|
250
250
|
.command('watch')
|
|
251
|
-
.description('Watch lock.json
|
|
251
|
+
.description('Watch lock.json, or ingest an external event into governed intake')
|
|
252
252
|
.option('--daemon', 'Run in background mode')
|
|
253
|
+
.option('--event-file <path>', 'Normalize one external event JSON file into governed intake')
|
|
254
|
+
.option('--event-dir <path>', 'Poll a directory for external event JSON files')
|
|
255
|
+
.option('--poll-seconds <seconds>', 'With --event-dir, polling interval in seconds', '5')
|
|
256
|
+
.option('--dry-run', 'With --event-file, print the normalized intake payload without writing')
|
|
257
|
+
.option('--listen <port>', 'Start an HTTP webhook listener on the given port')
|
|
258
|
+
.option('--listen-host <host>', 'With --listen, bind to a specific host (default: 127.0.0.1)')
|
|
259
|
+
.option('--webhook-secret <secret>', 'With --listen, HMAC-SHA256 secret for signature verification')
|
|
260
|
+
.option('--allow-unsigned', 'With --listen, accept unsigned payloads (local dev only)')
|
|
261
|
+
.option('--results', 'List all watch result records')
|
|
262
|
+
.option('--result <id>', 'Show a single watch result by ID or filename')
|
|
263
|
+
.option('--limit <n>', 'With --results, limit the number of results shown')
|
|
264
|
+
.option('-j, --json', 'Output JSON')
|
|
253
265
|
.action(watchCommand);
|
|
254
266
|
|
|
255
267
|
program
|
package/package.json
CHANGED
package/scripts/release-bump.sh
CHANGED
|
@@ -79,6 +79,7 @@ ALLOWED_RELEASE_PATHS=(
|
|
|
79
79
|
".agentxchain-conformance/capabilities.json"
|
|
80
80
|
"website-v2/docs/protocol-implementor-guide.mdx"
|
|
81
81
|
".planning/LAUNCH_EVIDENCE_REPORT.md"
|
|
82
|
+
".planning/HN_LAUNCH_SURFACE_ALIGNMENT_SPEC.md"
|
|
82
83
|
".planning/SHOW_HN_DRAFT.md"
|
|
83
84
|
".planning/MARKETING/TWITTER_THREAD.md"
|
|
84
85
|
".planning/MARKETING/LINKEDIN_POST.md"
|
|
@@ -88,6 +89,7 @@ ALLOWED_RELEASE_PATHS=(
|
|
|
88
89
|
"website-v2/docs/getting-started.mdx"
|
|
89
90
|
"website-v2/docs/quickstart.mdx"
|
|
90
91
|
"website-v2/docs/five-minute-tutorial.mdx"
|
|
92
|
+
"cli/test/hn-launch-surface-content.test.js"
|
|
91
93
|
"cli/homebrew/agentxchain.rb"
|
|
92
94
|
"cli/homebrew/README.md"
|
|
93
95
|
)
|
package/src/commands/watch.js
CHANGED
|
@@ -1,19 +1,48 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync } from 'fs';
|
|
2
|
-
import { join, dirname } from 'path';
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync, mkdirSync, readdirSync, renameSync, statSync } from 'fs';
|
|
2
|
+
import { join, dirname, basename, resolve } from 'path';
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { loadConfig, loadLock, LOCK_FILE } from '../lib/config.js';
|
|
7
|
+
import { recordEvent, triageIntent, approveIntent, planIntent, startIntent } from '../lib/intake.js';
|
|
8
|
+
import { normalizeWatchEvent, resolveWatchRoute, writeWatchResult } from '../lib/watch-events.js';
|
|
7
9
|
import { safeWriteJson } from '../lib/safe-write.js';
|
|
8
10
|
import { notifyHuman as sendNotification } from '../lib/notify.js';
|
|
9
11
|
import { validateProject } from '../lib/validation.js';
|
|
10
12
|
import { resolveNextAgent, resolveExpectedClaimer } from '../lib/next-owner.js';
|
|
13
|
+
import { requireIntakeWorkspaceOrExit } from './intake-workspace.js';
|
|
14
|
+
import { startWebhookListener } from '../lib/watch-listener.js';
|
|
11
15
|
|
|
12
16
|
const PID_FILE = '.agentxchain-watch.pid';
|
|
13
17
|
|
|
14
18
|
export async function watchCommand(opts) {
|
|
19
|
+
if (opts.results || opts.result) {
|
|
20
|
+
listOrShowWatchResults(opts);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (opts.listen) {
|
|
25
|
+
await listenWebhook(opts);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (opts.eventFile) {
|
|
30
|
+
await ingestWatchEvent(opts);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (opts.daemon && opts.eventDir && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
|
|
35
|
+
startWatchDaemon(opts);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (opts.eventDir) {
|
|
40
|
+
await watchEventDirectory(opts);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
if (opts.daemon && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
|
|
16
|
-
startWatchDaemon();
|
|
45
|
+
startWatchDaemon(opts);
|
|
17
46
|
return;
|
|
18
47
|
}
|
|
19
48
|
|
|
@@ -153,6 +182,574 @@ export async function watchCommand(opts) {
|
|
|
153
182
|
process.on('SIGTERM', cleanup);
|
|
154
183
|
}
|
|
155
184
|
|
|
185
|
+
async function watchEventDirectory(opts) {
|
|
186
|
+
const root = requireIntakeWorkspaceOrExit(opts);
|
|
187
|
+
const eventDir = resolve(process.cwd(), opts.eventDir);
|
|
188
|
+
const pollMs = parsePollMs(opts.pollSeconds);
|
|
189
|
+
mkdirSync(eventDir, { recursive: true });
|
|
190
|
+
writePidFile(root);
|
|
191
|
+
|
|
192
|
+
console.log('');
|
|
193
|
+
console.log(chalk.bold(' AgentXchain Watch Event Directory'));
|
|
194
|
+
console.log(chalk.dim(` Event dir: ${eventDir}`));
|
|
195
|
+
console.log(chalk.dim(` Poll: ${pollMs}ms`));
|
|
196
|
+
console.log(chalk.dim(' Processed files move to processed/; failed files move to failed/.'));
|
|
197
|
+
console.log('');
|
|
198
|
+
|
|
199
|
+
let processing = false;
|
|
200
|
+
const tick = async () => {
|
|
201
|
+
if (processing) return;
|
|
202
|
+
processing = true;
|
|
203
|
+
try {
|
|
204
|
+
const results = await processPendingEventFiles(root, eventDir);
|
|
205
|
+
for (const result of results) {
|
|
206
|
+
if (result.ok) {
|
|
207
|
+
log('claimed', `Processed event file ${basename(result.source)} -> ${basename(result.destination)}`);
|
|
208
|
+
} else {
|
|
209
|
+
log('warn', `Failed event file ${basename(result.source)} -> ${basename(result.destination)}: ${result.error}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
log('error', err.message);
|
|
214
|
+
} finally {
|
|
215
|
+
processing = false;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
await tick();
|
|
220
|
+
const timer = setInterval(tick, pollMs);
|
|
221
|
+
|
|
222
|
+
const cleanup = () => {
|
|
223
|
+
clearInterval(timer);
|
|
224
|
+
removePidFile(root);
|
|
225
|
+
console.log('');
|
|
226
|
+
log('stop', 'Watch event directory stopped.');
|
|
227
|
+
process.exit(0);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
process.on('SIGINT', cleanup);
|
|
231
|
+
process.on('SIGTERM', cleanup);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function processPendingEventFiles(root, eventDir) {
|
|
235
|
+
const entries = readdirSync(eventDir, { withFileTypes: true })
|
|
236
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
237
|
+
.map((entry) => entry.name)
|
|
238
|
+
.sort();
|
|
239
|
+
|
|
240
|
+
const results = [];
|
|
241
|
+
for (const name of entries) {
|
|
242
|
+
const source = join(eventDir, name);
|
|
243
|
+
if (!existsSync(source)) continue;
|
|
244
|
+
|
|
245
|
+
const childResult = await runWatchEventFile(root, source);
|
|
246
|
+
const destinationDir = join(eventDir, childResult.status === 0 ? 'processed' : 'failed');
|
|
247
|
+
const destination = moveEventFile(source, destinationDir);
|
|
248
|
+
results.push({
|
|
249
|
+
ok: childResult.status === 0,
|
|
250
|
+
source,
|
|
251
|
+
destination,
|
|
252
|
+
error: childResult.status === 0 ? null : summarizeChildFailure(childResult),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return results;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const DEFAULT_CHILD_TIMEOUT_MS = 30_000;
|
|
259
|
+
|
|
260
|
+
function runWatchEventFile(root, eventFile, timeoutMs = DEFAULT_CHILD_TIMEOUT_MS) {
|
|
261
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
262
|
+
const cliBin = join(currentDir, '../../bin/agentxchain.js');
|
|
263
|
+
return new Promise((resolvePromise) => {
|
|
264
|
+
const child = spawn(process.execPath, [cliBin, 'watch', '--event-file', eventFile, '--json'], {
|
|
265
|
+
cwd: root,
|
|
266
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
267
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
268
|
+
});
|
|
269
|
+
let stdout = '';
|
|
270
|
+
let stderr = '';
|
|
271
|
+
let settled = false;
|
|
272
|
+
const finish = (status, signal) => {
|
|
273
|
+
if (settled) return;
|
|
274
|
+
settled = true;
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
resolvePromise({ status: status ?? 1, signal, stdout, stderr });
|
|
277
|
+
};
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
if (settled) return;
|
|
280
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
281
|
+
stderr += `\nchild process timed out after ${timeoutMs}ms`;
|
|
282
|
+
finish(1, 'SIGTERM');
|
|
283
|
+
}, timeoutMs);
|
|
284
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
285
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
286
|
+
child.on('close', (status, signal) => finish(status, signal));
|
|
287
|
+
child.on('error', (err) => {
|
|
288
|
+
stderr += err.message;
|
|
289
|
+
finish(1, null);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function moveEventFile(source, destinationDir) {
|
|
295
|
+
mkdirSync(destinationDir, { recursive: true });
|
|
296
|
+
const parsed = basename(source);
|
|
297
|
+
let destination = join(destinationDir, parsed);
|
|
298
|
+
if (existsSync(destination)) {
|
|
299
|
+
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
300
|
+
destination = join(destinationDir, `${parsed}.${suffix}`);
|
|
301
|
+
}
|
|
302
|
+
renameSync(source, destination);
|
|
303
|
+
return destination;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function summarizeChildFailure(result) {
|
|
307
|
+
try {
|
|
308
|
+
const parsed = JSON.parse(result.stdout);
|
|
309
|
+
if (parsed?.error) return parsed.error;
|
|
310
|
+
} catch {}
|
|
311
|
+
return result.stderr.trim() || result.stdout.trim() || `watch --event-file exited with status ${result.status}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parsePollMs(value) {
|
|
315
|
+
const seconds = Number(value);
|
|
316
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return 5000;
|
|
317
|
+
return Math.max(100, Math.round(seconds * 1000));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function listenWebhook(opts) {
|
|
321
|
+
// Mutual exclusion checks
|
|
322
|
+
const incompatible = [
|
|
323
|
+
opts.eventFile && '--event-file',
|
|
324
|
+
opts.eventDir && '--event-dir',
|
|
325
|
+
opts.daemon && '--daemon',
|
|
326
|
+
(opts.results || opts.result) && '--results/--result',
|
|
327
|
+
].filter(Boolean);
|
|
328
|
+
|
|
329
|
+
if (incompatible.length > 0) {
|
|
330
|
+
const message = `--listen cannot be combined with ${incompatible.join(', ')}`;
|
|
331
|
+
if (opts.json) {
|
|
332
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
333
|
+
} else {
|
|
334
|
+
console.log(chalk.red(` ${message}`));
|
|
335
|
+
}
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const root = requireIntakeWorkspaceOrExit(opts);
|
|
340
|
+
const port = parseInt(opts.listen, 10);
|
|
341
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
342
|
+
const message = `invalid port: ${opts.listen}`;
|
|
343
|
+
if (opts.json) {
|
|
344
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
345
|
+
} else {
|
|
346
|
+
console.log(chalk.red(` ${message}`));
|
|
347
|
+
}
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Resolve webhook secret: CLI flag > env var > config
|
|
352
|
+
let secret = opts.webhookSecret || null;
|
|
353
|
+
if (!secret && process.env.AGENTXCHAIN_WEBHOOK_SECRET) {
|
|
354
|
+
secret = process.env.AGENTXCHAIN_WEBHOOK_SECRET;
|
|
355
|
+
}
|
|
356
|
+
if (!secret) {
|
|
357
|
+
try {
|
|
358
|
+
const rawConfig = JSON.parse(readFileSync(join(root, 'agentxchain.json'), 'utf8'));
|
|
359
|
+
secret = rawConfig?.watch?.webhook_secret || null;
|
|
360
|
+
} catch {}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const allowUnsigned = opts.allowUnsigned === true;
|
|
364
|
+
const host = opts.listenHost || '127.0.0.1';
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const server = await startWebhookListener({
|
|
368
|
+
root,
|
|
369
|
+
port,
|
|
370
|
+
host,
|
|
371
|
+
secret,
|
|
372
|
+
allowUnsigned,
|
|
373
|
+
dryRun: opts.dryRun === true,
|
|
374
|
+
onReady: ({ port: boundPort, host: boundHost }) => {
|
|
375
|
+
writePidFile(root);
|
|
376
|
+
console.log('');
|
|
377
|
+
console.log(chalk.bold(' AgentXchain Webhook Listener'));
|
|
378
|
+
console.log(chalk.dim(` Listening: http://${boundHost}:${boundPort}`));
|
|
379
|
+
console.log(chalk.dim(` Webhook: POST /webhook`));
|
|
380
|
+
console.log(chalk.dim(` Health: GET /health`));
|
|
381
|
+
console.log(chalk.dim(` Secret: ${secret ? 'configured' : allowUnsigned ? 'none (unsigned allowed)' : 'REQUIRED but missing — POST /webhook will return 403'}`));
|
|
382
|
+
if (opts.dryRun) console.log(chalk.yellow(' Dry-run: events will NOT be persisted'));
|
|
383
|
+
console.log('');
|
|
384
|
+
console.log(chalk.cyan(' Waiting for webhook deliveries... (Ctrl+C to stop)'));
|
|
385
|
+
console.log('');
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const cleanup = () => {
|
|
390
|
+
server.close();
|
|
391
|
+
removePidFile(root);
|
|
392
|
+
console.log('');
|
|
393
|
+
log('stop', 'Webhook listener stopped.');
|
|
394
|
+
process.exit(0);
|
|
395
|
+
};
|
|
396
|
+
process.on('SIGINT', cleanup);
|
|
397
|
+
process.on('SIGTERM', cleanup);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
if (err.code === 'EADDRINUSE') {
|
|
400
|
+
const message = `port ${port} is already in use`;
|
|
401
|
+
if (opts.json) {
|
|
402
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
403
|
+
} else {
|
|
404
|
+
console.log(chalk.red(` ${message}`));
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
console.log(chalk.red(` failed to start listener: ${err.message}`));
|
|
408
|
+
}
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function ingestWatchEvent(opts) {
|
|
414
|
+
if (opts.daemon) {
|
|
415
|
+
const message = '--daemon cannot be combined with --event-file';
|
|
416
|
+
if (opts.json) {
|
|
417
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
418
|
+
} else {
|
|
419
|
+
console.log(chalk.red(` ${message}`));
|
|
420
|
+
}
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const root = requireIntakeWorkspaceOrExit(opts);
|
|
425
|
+
let raw;
|
|
426
|
+
let parsed;
|
|
427
|
+
try {
|
|
428
|
+
raw = readFileSync(opts.eventFile, 'utf8');
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const message = `failed to read event file: ${err.message}`;
|
|
431
|
+
if (opts.json) {
|
|
432
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
433
|
+
} else {
|
|
434
|
+
console.log(chalk.red(` ${message}`));
|
|
435
|
+
}
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
parsed = JSON.parse(raw);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
const message = `event file is not valid JSON: ${err.message}`;
|
|
443
|
+
if (opts.json) {
|
|
444
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
445
|
+
} else {
|
|
446
|
+
console.log(chalk.red(` ${message}`));
|
|
447
|
+
}
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let payload;
|
|
452
|
+
try {
|
|
453
|
+
payload = normalizeWatchEvent(parsed);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (opts.json) {
|
|
456
|
+
console.log(JSON.stringify({ ok: false, error: err.message }, null, 2));
|
|
457
|
+
} else {
|
|
458
|
+
console.log(chalk.red(` ${err.message}`));
|
|
459
|
+
}
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (opts.dryRun) {
|
|
464
|
+
if (opts.json) {
|
|
465
|
+
console.log(JSON.stringify({ ok: true, dry_run: true, payload }, null, 2));
|
|
466
|
+
} else {
|
|
467
|
+
console.log('');
|
|
468
|
+
console.log(chalk.green(' Watch event normalized (dry run)'));
|
|
469
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
470
|
+
console.log('');
|
|
471
|
+
}
|
|
472
|
+
process.exit(0);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const result = recordEvent(root, payload);
|
|
476
|
+
if (!result.ok) {
|
|
477
|
+
if (opts.json) {
|
|
478
|
+
console.log(JSON.stringify(result, null, 2));
|
|
479
|
+
} else {
|
|
480
|
+
console.log(chalk.red(` ${result.error}`));
|
|
481
|
+
}
|
|
482
|
+
process.exit(result.exitCode);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Route-based auto-triage and auto-approve
|
|
486
|
+
let routed = null;
|
|
487
|
+
if (!result.deduplicated && result.intent) {
|
|
488
|
+
let routes;
|
|
489
|
+
try {
|
|
490
|
+
const rawConfig = JSON.parse(readFileSync(join(root, 'agentxchain.json'), 'utf8'));
|
|
491
|
+
routes = rawConfig?.watch?.routes;
|
|
492
|
+
} catch {
|
|
493
|
+
// non-fatal — no routes if config is unreadable
|
|
494
|
+
}
|
|
495
|
+
const resolved = resolveWatchRoute(payload, routes);
|
|
496
|
+
if (resolved) {
|
|
497
|
+
const triageFields = {
|
|
498
|
+
...resolved.triage,
|
|
499
|
+
};
|
|
500
|
+
if (resolved.preferred_role) {
|
|
501
|
+
triageFields.preferred_role = resolved.preferred_role;
|
|
502
|
+
}
|
|
503
|
+
const triageResult = triageIntent(root, result.intent.intent_id, triageFields);
|
|
504
|
+
if (triageResult.ok) {
|
|
505
|
+
result.intent = triageResult.intent;
|
|
506
|
+
routed = { triaged: true, approved: false, preferred_role: resolved.preferred_role };
|
|
507
|
+
if (resolved.auto_approve) {
|
|
508
|
+
const approveResult = approveIntent(root, result.intent.intent_id, {
|
|
509
|
+
approver: 'watch_route',
|
|
510
|
+
reason: `auto-approved by watch route matching ${payload.category}`,
|
|
511
|
+
});
|
|
512
|
+
if (approveResult.ok) {
|
|
513
|
+
result.intent = approveResult.intent;
|
|
514
|
+
routed.approved = true;
|
|
515
|
+
|
|
516
|
+
// Auto-start: plan + start the governed run
|
|
517
|
+
if (resolved.auto_start) {
|
|
518
|
+
const planResult = planIntent(root, result.intent.intent_id, {
|
|
519
|
+
force: resolved.overwrite_planning_artifacts === true,
|
|
520
|
+
});
|
|
521
|
+
if (planResult.ok) {
|
|
522
|
+
result.intent = planResult.intent;
|
|
523
|
+
routed.planned = true;
|
|
524
|
+
|
|
525
|
+
const startResult = startIntent(root, result.intent.intent_id, {});
|
|
526
|
+
if (startResult.ok) {
|
|
527
|
+
result.intent = startResult.intent;
|
|
528
|
+
routed.started = true;
|
|
529
|
+
routed.run_id = startResult.run_id || null;
|
|
530
|
+
routed.role = startResult.role || null;
|
|
531
|
+
} else {
|
|
532
|
+
routed.started = false;
|
|
533
|
+
routed.auto_start_error = startResult.error;
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
routed.planned = false;
|
|
537
|
+
routed.started = false;
|
|
538
|
+
routed.auto_start_error = planResult.error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} else if (resolved.auto_start) {
|
|
543
|
+
// auto_start without auto_approve — skip silently
|
|
544
|
+
routed.auto_start_skipped = 'requires auto_approve';
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (routed) {
|
|
551
|
+
result.routed = routed;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Write durable watch result file
|
|
555
|
+
const watchResult = writeWatchResult(root, result, payload);
|
|
556
|
+
result.watch_result_id = watchResult.result_id;
|
|
557
|
+
|
|
558
|
+
if (opts.json) {
|
|
559
|
+
console.log(JSON.stringify(result, null, 2));
|
|
560
|
+
} else {
|
|
561
|
+
console.log('');
|
|
562
|
+
if (result.deduplicated) {
|
|
563
|
+
console.log(chalk.yellow(` Watch event already recorded: ${result.event.event_id} (deduplicated)`));
|
|
564
|
+
if (result.intent) {
|
|
565
|
+
console.log(chalk.dim(` Linked intent: ${result.intent.intent_id} (${result.intent.status})`));
|
|
566
|
+
}
|
|
567
|
+
} else {
|
|
568
|
+
console.log(chalk.green(` Recorded watch event ${result.event.event_id}`));
|
|
569
|
+
console.log(chalk.green(` Created intent ${result.intent.intent_id} (${result.intent.status})`));
|
|
570
|
+
if (routed) {
|
|
571
|
+
const parts = [`triaged=${routed.triaged}`, `approved=${routed.approved}`];
|
|
572
|
+
if (routed.preferred_role) parts.push(`role=${routed.preferred_role}`);
|
|
573
|
+
if (routed.planned) parts.push('planned=true');
|
|
574
|
+
if (routed.started) parts.push(`started=true`);
|
|
575
|
+
if (routed.auto_start_error) parts.push(`start_error="${routed.auto_start_error}"`);
|
|
576
|
+
if (routed.auto_start_skipped) parts.push(`auto_start_skipped`);
|
|
577
|
+
console.log(chalk.cyan(` Route matched: ${parts.join(', ')}`));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
console.log('');
|
|
581
|
+
}
|
|
582
|
+
process.exit(result.exitCode);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Watch results inspection
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
function listOrShowWatchResults(opts) {
|
|
590
|
+
const root = requireIntakeWorkspaceOrExit(opts);
|
|
591
|
+
const resultsDir = join(root, '.agentxchain', 'watch-results');
|
|
592
|
+
|
|
593
|
+
if (!existsSync(resultsDir)) {
|
|
594
|
+
if (opts.json) {
|
|
595
|
+
console.log(JSON.stringify(opts.result ? { ok: false, error: 'no watch results found' } : { ok: true, results: [] }, null, 2));
|
|
596
|
+
} else {
|
|
597
|
+
console.log(chalk.dim(' No watch results yet.'));
|
|
598
|
+
}
|
|
599
|
+
process.exit(opts.result ? 1 : 0);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Show a single result by ID or filename
|
|
603
|
+
if (opts.result) {
|
|
604
|
+
const record = loadWatchResult(resultsDir, opts.result);
|
|
605
|
+
if (!record) {
|
|
606
|
+
if (opts.json) {
|
|
607
|
+
console.log(JSON.stringify({ ok: false, error: `watch result not found: ${opts.result}` }, null, 2));
|
|
608
|
+
} else {
|
|
609
|
+
console.log(chalk.red(` Watch result not found: ${opts.result}`));
|
|
610
|
+
}
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
if (opts.json) {
|
|
614
|
+
console.log(JSON.stringify({ ok: true, ...record }, null, 2));
|
|
615
|
+
} else {
|
|
616
|
+
printWatchResult(record);
|
|
617
|
+
}
|
|
618
|
+
process.exit(0);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// List all results
|
|
622
|
+
const entries = readdirSync(resultsDir, { withFileTypes: true })
|
|
623
|
+
.filter((e) => e.isFile() && e.name.endsWith('.json'))
|
|
624
|
+
.map((e) => e.name)
|
|
625
|
+
.sort();
|
|
626
|
+
|
|
627
|
+
const records = [];
|
|
628
|
+
for (const name of entries) {
|
|
629
|
+
try {
|
|
630
|
+
const content = JSON.parse(readFileSync(join(resultsDir, name), 'utf8'));
|
|
631
|
+
records.push(content);
|
|
632
|
+
} catch {
|
|
633
|
+
// skip corrupt files
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Sort by timestamp descending (most recent first)
|
|
638
|
+
records.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
|
639
|
+
|
|
640
|
+
// Apply --limit if provided
|
|
641
|
+
const limit = opts.limit ? parseInt(opts.limit, 10) : 0;
|
|
642
|
+
const display = limit > 0 ? records.slice(0, limit) : records;
|
|
643
|
+
|
|
644
|
+
if (opts.json) {
|
|
645
|
+
console.log(JSON.stringify({ ok: true, total: records.length, results: display }, null, 2));
|
|
646
|
+
process.exit(0);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
console.log('');
|
|
650
|
+
console.log(chalk.bold(` Watch Results (${records.length} total)`));
|
|
651
|
+
console.log('');
|
|
652
|
+
|
|
653
|
+
if (display.length === 0) {
|
|
654
|
+
console.log(chalk.dim(' No watch results yet.'));
|
|
655
|
+
console.log('');
|
|
656
|
+
process.exit(0);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
for (const r of display) {
|
|
660
|
+
const status = formatResultStatus(r);
|
|
661
|
+
const category = r.payload?.category || 'unknown';
|
|
662
|
+
const repo = r.payload?.repo || '';
|
|
663
|
+
const ts = r.timestamp ? new Date(r.timestamp).toLocaleString() : 'unknown';
|
|
664
|
+
const dedup = r.deduplicated ? chalk.yellow(' [dedup]') : '';
|
|
665
|
+
const errors = r.errors?.length > 0 ? chalk.red(` (${r.errors.length} error${r.errors.length > 1 ? 's' : ''})`) : '';
|
|
666
|
+
|
|
667
|
+
console.log(` ${chalk.dim(r.result_id)} ${status}${dedup}${errors}`);
|
|
668
|
+
console.log(` ${chalk.cyan(category)} ${repo ? chalk.dim(repo) : ''} ${chalk.dim(ts)}`);
|
|
669
|
+
if (r.route?.run_id) {
|
|
670
|
+
console.log(` ${chalk.dim('run:')} ${r.route.run_id} ${chalk.dim('role:')} ${r.route.role || '?'}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (limit > 0 && records.length > limit) {
|
|
675
|
+
console.log(chalk.dim(`\n ... and ${records.length - limit} more. Use --limit 0 to show all.`));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
console.log('');
|
|
679
|
+
process.exit(0);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function loadWatchResult(resultsDir, idOrFile) {
|
|
683
|
+
// Try exact filename first
|
|
684
|
+
const directPath = join(resultsDir, idOrFile.endsWith('.json') ? idOrFile : `${idOrFile}.json`);
|
|
685
|
+
if (existsSync(directPath)) {
|
|
686
|
+
try { return JSON.parse(readFileSync(directPath, 'utf8')); } catch { return null; }
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Search by result_id prefix
|
|
690
|
+
const entries = readdirSync(resultsDir, { withFileTypes: true })
|
|
691
|
+
.filter((e) => e.isFile() && e.name.endsWith('.json'));
|
|
692
|
+
for (const entry of entries) {
|
|
693
|
+
try {
|
|
694
|
+
const content = JSON.parse(readFileSync(join(resultsDir, entry.name), 'utf8'));
|
|
695
|
+
if (content.result_id === idOrFile || content.result_id?.startsWith(idOrFile)) {
|
|
696
|
+
return content;
|
|
697
|
+
}
|
|
698
|
+
} catch {
|
|
699
|
+
// skip corrupt
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function formatResultStatus(r) {
|
|
706
|
+
if (r.deduplicated) return chalk.yellow('deduplicated');
|
|
707
|
+
if (!r.route?.matched) return chalk.dim('unrouted');
|
|
708
|
+
if (r.route.started) return chalk.green('started');
|
|
709
|
+
if (r.route.planned) return chalk.blue('planned');
|
|
710
|
+
if (r.route.approved) return chalk.cyan('approved');
|
|
711
|
+
if (r.route.triaged) return chalk.white('triaged');
|
|
712
|
+
return chalk.dim('detected');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function printWatchResult(r) {
|
|
716
|
+
console.log('');
|
|
717
|
+
console.log(chalk.bold(' Watch Result'));
|
|
718
|
+
console.log(` ${chalk.dim('ID:')} ${r.result_id}`);
|
|
719
|
+
console.log(` ${chalk.dim('Time:')} ${r.timestamp ? new Date(r.timestamp).toLocaleString() : 'unknown'}`);
|
|
720
|
+
console.log(` ${chalk.dim('Event:')} ${r.event_id || 'none'}`);
|
|
721
|
+
console.log(` ${chalk.dim('Intent:')} ${r.intent_id || 'none'} (${r.intent_status || '?'})`);
|
|
722
|
+
console.log(` ${chalk.dim('Dedup:')} ${r.deduplicated ? 'yes' : 'no'}`);
|
|
723
|
+
console.log('');
|
|
724
|
+
console.log(chalk.bold(' Payload'));
|
|
725
|
+
console.log(` ${chalk.dim('Source:')} ${r.payload?.source || '?'}`);
|
|
726
|
+
console.log(` ${chalk.dim('Category:')} ${r.payload?.category || '?'}`);
|
|
727
|
+
console.log(` ${chalk.dim('Repo:')} ${r.payload?.repo || 'none'}`);
|
|
728
|
+
console.log(` ${chalk.dim('Ref:')} ${r.payload?.ref || 'none'}`);
|
|
729
|
+
console.log('');
|
|
730
|
+
console.log(chalk.bold(' Route'));
|
|
731
|
+
if (!r.route?.matched) {
|
|
732
|
+
console.log(chalk.dim(' No matching route.'));
|
|
733
|
+
} else {
|
|
734
|
+
console.log(` ${chalk.dim('Triaged:')} ${r.route.triaged ? 'yes' : 'no'}`);
|
|
735
|
+
console.log(` ${chalk.dim('Approved:')} ${r.route.approved ? 'yes' : 'no'}`);
|
|
736
|
+
console.log(` ${chalk.dim('Planned:')} ${r.route.planned ? 'yes' : 'no'}`);
|
|
737
|
+
console.log(` ${chalk.dim('Started:')} ${r.route.started ? 'yes' : 'no'}`);
|
|
738
|
+
console.log(` ${chalk.dim('Auto-start:')} ${r.route.auto_start ? 'yes' : 'no'}`);
|
|
739
|
+
if (r.route.preferred_role) console.log(` ${chalk.dim('Role hint:')} ${r.route.preferred_role}`);
|
|
740
|
+
if (r.route.run_id) console.log(` ${chalk.dim('Run ID:')} ${r.route.run_id}`);
|
|
741
|
+
if (r.route.role) console.log(` ${chalk.dim('Role:')} ${r.route.role}`);
|
|
742
|
+
}
|
|
743
|
+
if (r.errors?.length > 0) {
|
|
744
|
+
console.log('');
|
|
745
|
+
console.log(chalk.bold(' Errors'));
|
|
746
|
+
for (const err of r.errors) {
|
|
747
|
+
console.log(` ${chalk.red('•')} ${err}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
console.log('');
|
|
751
|
+
}
|
|
752
|
+
|
|
156
753
|
function pickNextAgent(root, lock, config) {
|
|
157
754
|
return resolveNextAgent(root, config, lock).next;
|
|
158
755
|
}
|
|
@@ -307,10 +904,15 @@ function log(type, msg) {
|
|
|
307
904
|
console.log(` ${chalk.dim(time)} ${tags[type] || chalk.dim(type)} ${msg}`);
|
|
308
905
|
}
|
|
309
906
|
|
|
310
|
-
function startWatchDaemon() {
|
|
907
|
+
function startWatchDaemon(opts = {}) {
|
|
311
908
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
312
909
|
const cliBin = join(currentDir, '../../bin/agentxchain.js');
|
|
313
|
-
const
|
|
910
|
+
const args = [cliBin, 'watch'];
|
|
911
|
+
if (opts.eventDir) {
|
|
912
|
+
args.push('--event-dir', opts.eventDir);
|
|
913
|
+
if (opts.pollSeconds) args.push('--poll-seconds', String(opts.pollSeconds));
|
|
914
|
+
}
|
|
915
|
+
const child = spawn(process.execPath, args, {
|
|
314
916
|
cwd: process.cwd(),
|
|
315
917
|
detached: true,
|
|
316
918
|
stdio: 'ignore',
|
|
@@ -319,8 +921,12 @@ function startWatchDaemon() {
|
|
|
319
921
|
child.unref();
|
|
320
922
|
|
|
321
923
|
const result = loadConfig();
|
|
322
|
-
|
|
323
|
-
|
|
924
|
+
let pidRoot = result?.root || null;
|
|
925
|
+
if (!pidRoot && opts.eventDir && existsSync(join(process.cwd(), 'agentxchain.json'))) {
|
|
926
|
+
pidRoot = process.cwd();
|
|
927
|
+
}
|
|
928
|
+
if (pidRoot) {
|
|
929
|
+
writeFileSync(join(pidRoot, PID_FILE), String(child.pid));
|
|
324
930
|
}
|
|
325
931
|
|
|
326
932
|
console.log('');
|
package/src/lib/intake.js
CHANGED
|
@@ -537,6 +537,9 @@ export function triageIntent(root, intentId, fields) {
|
|
|
537
537
|
intent.charter = normalizedFields.charter;
|
|
538
538
|
intent.acceptance_contract = normalizedFields.acceptance_contract;
|
|
539
539
|
intent.phase_scope = normalizedFields.phase_scope || null;
|
|
540
|
+
if (normalizedFields.preferred_role) {
|
|
541
|
+
intent.preferred_role = normalizedFields.preferred_role;
|
|
542
|
+
}
|
|
540
543
|
intent.updated_at = now;
|
|
541
544
|
intent.history.push({ from: 'detected', to: 'triaged', at: now, reason: 'triage completed' });
|
|
542
545
|
|
|
@@ -1148,7 +1151,7 @@ export function startIntent(root, intentId, options = {}) {
|
|
|
1148
1151
|
}
|
|
1149
1152
|
|
|
1150
1153
|
// Resolve role
|
|
1151
|
-
const roleId = resolveIntakeRole(options.role, state, config);
|
|
1154
|
+
const roleId = resolveIntakeRole(options.role, intent, state, config);
|
|
1152
1155
|
if (!roleId.ok) {
|
|
1153
1156
|
return { ok: false, error: roleId.error, exitCode: 1 };
|
|
1154
1157
|
}
|
|
@@ -1220,7 +1223,7 @@ export function startIntent(root, intentId, options = {}) {
|
|
|
1220
1223
|
};
|
|
1221
1224
|
}
|
|
1222
1225
|
|
|
1223
|
-
function resolveIntakeRole(roleOverride, state, config) {
|
|
1226
|
+
function resolveIntakeRole(roleOverride, intent, state, config) {
|
|
1224
1227
|
const phase = state.phase;
|
|
1225
1228
|
const routing = config.routing?.[phase];
|
|
1226
1229
|
|
|
@@ -1232,6 +1235,17 @@ function resolveIntakeRole(roleOverride, state, config) {
|
|
|
1232
1235
|
return { ok: true, role: roleOverride };
|
|
1233
1236
|
}
|
|
1234
1237
|
|
|
1238
|
+
const preferredRole = typeof intent?.preferred_role === 'string'
|
|
1239
|
+
? intent.preferred_role.trim()
|
|
1240
|
+
: '';
|
|
1241
|
+
if (preferredRole) {
|
|
1242
|
+
if (!config.roles?.[preferredRole]) {
|
|
1243
|
+
const available = Object.keys(config.roles || {}).join(', ');
|
|
1244
|
+
return { ok: false, error: `unknown preferred_role on intent: "${preferredRole}". Available: ${available}` };
|
|
1245
|
+
}
|
|
1246
|
+
return { ok: true, role: preferredRole };
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1235
1249
|
if (routing?.entry_role) {
|
|
1236
1250
|
return { ok: true, role: routing.entry_role };
|
|
1237
1251
|
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { safeWriteJson } from './safe-write.js';
|
|
4
|
+
|
|
5
|
+
export function normalizeWatchEvent(input) {
|
|
6
|
+
const event = unwrapEvent(input);
|
|
7
|
+
|
|
8
|
+
if (isPullRequestEvent(event)) {
|
|
9
|
+
return normalizePullRequestEvent(event);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (isIssueLabeledEvent(event)) {
|
|
13
|
+
return normalizeIssueLabeledEvent(event);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (isWorkflowFailureEvent(event)) {
|
|
17
|
+
return normalizeWorkflowFailureEvent(event);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (isScheduleEvent(event)) {
|
|
21
|
+
return normalizeScheduleEvent(event);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
throw new Error('unsupported watch event: supported events are GitHub pull_request, issues.labeled, failed workflow_run, and schedule');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unwrapEvent(input) {
|
|
28
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
29
|
+
throw new Error('watch event must be a JSON object');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (input.provider === 'github' && typeof input.event === 'string') {
|
|
33
|
+
return {
|
|
34
|
+
provider: 'github',
|
|
35
|
+
event: input.event,
|
|
36
|
+
...(input.payload && typeof input.payload === 'object' && !Array.isArray(input.payload) ? input.payload : input),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
provider: input.provider || 'github',
|
|
42
|
+
event: inferGitHubEvent(input),
|
|
43
|
+
...input,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function inferGitHubEvent(event) {
|
|
48
|
+
if (event.pull_request) return 'pull_request';
|
|
49
|
+
if (event.issue) return 'issues';
|
|
50
|
+
if (event.workflow_run) return 'workflow_run';
|
|
51
|
+
if (event.schedule || event.event === 'schedule') return 'schedule';
|
|
52
|
+
return event.event || 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPullRequestEvent(event) {
|
|
56
|
+
return event.provider === 'github' && event.event === 'pull_request' && event.pull_request;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isIssueLabeledEvent(event) {
|
|
60
|
+
return event.provider === 'github' && event.event === 'issues' && event.action === 'labeled' && event.issue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isWorkflowFailureEvent(event) {
|
|
64
|
+
if (event.provider !== 'github' || event.event !== 'workflow_run' || !event.workflow_run) return false;
|
|
65
|
+
const conclusion = event.workflow_run.conclusion;
|
|
66
|
+
return typeof conclusion === 'string' && !['success', 'skipped', 'cancelled'].includes(conclusion);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isScheduleEvent(event) {
|
|
70
|
+
return event.provider === 'github' && event.event === 'schedule';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizePullRequestEvent(event) {
|
|
74
|
+
const pr = event.pull_request;
|
|
75
|
+
const repo = repositoryName(event);
|
|
76
|
+
const action = normalizeToken(event.action || 'unknown');
|
|
77
|
+
const signal = removeNullish({
|
|
78
|
+
provider: 'github',
|
|
79
|
+
event: 'pull_request',
|
|
80
|
+
action,
|
|
81
|
+
repository: repo,
|
|
82
|
+
number: pr.number,
|
|
83
|
+
title: pr.title,
|
|
84
|
+
url: pr.html_url,
|
|
85
|
+
head_ref: pr.head?.ref,
|
|
86
|
+
head_sha: pr.head?.sha,
|
|
87
|
+
base_ref: pr.base?.ref,
|
|
88
|
+
draft: pr.draft,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
source: 'git_ref_change',
|
|
93
|
+
category: `github_pull_request_${action}`,
|
|
94
|
+
repo,
|
|
95
|
+
ref: pr.head?.ref || null,
|
|
96
|
+
signal,
|
|
97
|
+
evidence: evidenceFor(pr.html_url, pr.title || `Pull request ${pr.number || ''}`.trim()),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeIssueLabeledEvent(event) {
|
|
102
|
+
const issue = event.issue;
|
|
103
|
+
const repo = repositoryName(event);
|
|
104
|
+
const labelName = typeof event.label?.name === 'string' ? event.label.name : null;
|
|
105
|
+
const signal = removeNullish({
|
|
106
|
+
provider: 'github',
|
|
107
|
+
event: 'issues',
|
|
108
|
+
action: 'labeled',
|
|
109
|
+
repository: repo,
|
|
110
|
+
number: issue.number,
|
|
111
|
+
title: issue.title,
|
|
112
|
+
url: issue.html_url,
|
|
113
|
+
label: labelName,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
source: 'manual',
|
|
118
|
+
category: 'github_issue_labeled',
|
|
119
|
+
repo,
|
|
120
|
+
ref: null,
|
|
121
|
+
signal,
|
|
122
|
+
evidence: evidenceFor(issue.html_url, issue.title || `Issue ${issue.number || ''}`.trim()),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeWorkflowFailureEvent(event) {
|
|
127
|
+
const workflowRun = event.workflow_run;
|
|
128
|
+
const repo = repositoryName(event);
|
|
129
|
+
const signal = removeNullish({
|
|
130
|
+
provider: 'github',
|
|
131
|
+
event: 'workflow_run',
|
|
132
|
+
action: normalizeToken(event.action || 'completed'),
|
|
133
|
+
repository: repo,
|
|
134
|
+
workflow_name: workflowRun.name,
|
|
135
|
+
conclusion: workflowRun.conclusion,
|
|
136
|
+
status: workflowRun.status,
|
|
137
|
+
run_id: workflowRun.id,
|
|
138
|
+
run_number: workflowRun.run_number,
|
|
139
|
+
url: workflowRun.html_url,
|
|
140
|
+
head_branch: workflowRun.head_branch,
|
|
141
|
+
head_sha: workflowRun.head_sha,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
source: 'ci_failure',
|
|
146
|
+
category: 'github_workflow_run_failed',
|
|
147
|
+
repo,
|
|
148
|
+
ref: workflowRun.head_branch || null,
|
|
149
|
+
signal,
|
|
150
|
+
evidence: evidenceFor(workflowRun.html_url, `${workflowRun.name || 'GitHub workflow'} ${workflowRun.conclusion || 'failed'}`),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeScheduleEvent(event) {
|
|
155
|
+
const repo = repositoryName(event);
|
|
156
|
+
const signal = removeNullish({
|
|
157
|
+
provider: 'github',
|
|
158
|
+
event: 'schedule',
|
|
159
|
+
repository: repo,
|
|
160
|
+
schedule: event.schedule,
|
|
161
|
+
workflow: event.workflow,
|
|
162
|
+
ref: event.ref,
|
|
163
|
+
sha: event.sha,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
source: 'schedule',
|
|
168
|
+
category: 'github_schedule',
|
|
169
|
+
repo,
|
|
170
|
+
ref: event.ref || null,
|
|
171
|
+
signal,
|
|
172
|
+
evidence: [{ type: 'text', value: `GitHub schedule event${event.schedule ? `: ${event.schedule}` : ''}` }],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function repositoryName(event) {
|
|
177
|
+
if (typeof event.repository === 'string') return event.repository;
|
|
178
|
+
if (typeof event.repository?.full_name === 'string') return event.repository.full_name;
|
|
179
|
+
if (typeof event.repository?.name === 'string') return event.repository.name;
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function evidenceFor(url, text) {
|
|
184
|
+
if (typeof url === 'string' && url.trim()) {
|
|
185
|
+
return [{ type: 'url', value: url }];
|
|
186
|
+
}
|
|
187
|
+
return [{ type: 'text', value: text || 'GitHub event' }];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeToken(value) {
|
|
191
|
+
return String(value || 'unknown').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'unknown';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function removeNullish(input) {
|
|
195
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== null && value !== undefined && value !== ''));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Route resolution
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
export function resolveWatchRoute(payload, routes) {
|
|
203
|
+
if (!Array.isArray(routes) || routes.length === 0) return null;
|
|
204
|
+
|
|
205
|
+
for (const route of routes) {
|
|
206
|
+
if (!route.match || !route.triage) continue;
|
|
207
|
+
|
|
208
|
+
const match = route.match;
|
|
209
|
+
if (match.source && match.source !== payload.source) continue;
|
|
210
|
+
if (match.category) {
|
|
211
|
+
if (match.category.includes('*')) {
|
|
212
|
+
const escaped = match.category.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
213
|
+
if (!new RegExp(`^${escaped}$`).test(payload.category)) continue;
|
|
214
|
+
} else if (match.category !== payload.category) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const charter = interpolateCharter(route.triage.charter, payload.signal);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
triage: {
|
|
223
|
+
priority: route.triage.priority || 'p2',
|
|
224
|
+
template: route.triage.template || 'generic',
|
|
225
|
+
charter,
|
|
226
|
+
acceptance_contract: Array.isArray(route.triage.acceptance_contract) && route.triage.acceptance_contract.length > 0
|
|
227
|
+
? route.triage.acceptance_contract
|
|
228
|
+
: ['Watch event processed under governance'],
|
|
229
|
+
},
|
|
230
|
+
auto_approve: route.auto_approve === true,
|
|
231
|
+
auto_start: route.auto_start === true,
|
|
232
|
+
overwrite_planning_artifacts: route.overwrite_planning_artifacts === true,
|
|
233
|
+
preferred_role: route.preferred_role || null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Watch result persistence
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Write a durable watch result file to `.agentxchain/watch-results/`.
|
|
246
|
+
*
|
|
247
|
+
* Every non-dry-run `watch --event-file` invocation writes exactly one result
|
|
248
|
+
* file containing the full pipeline trace: event, intent, route match,
|
|
249
|
+
* triage/approve/plan/start statuses, errors, and timestamps.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} root - project root
|
|
252
|
+
* @param {object} pipelineResult - the result object from ingestWatchEvent
|
|
253
|
+
* @param {object} payload - the normalized watch event payload
|
|
254
|
+
* @returns {{ result_id: string, result_path: string }}
|
|
255
|
+
*/
|
|
256
|
+
export function writeWatchResult(root, pipelineResult, payload, metadata = {}) {
|
|
257
|
+
const ts = Date.now();
|
|
258
|
+
const suffix = Math.random().toString(16).slice(2, 10);
|
|
259
|
+
const resultId = `wr_${ts}_${suffix}`;
|
|
260
|
+
|
|
261
|
+
const routed = pipelineResult.routed || null;
|
|
262
|
+
const errors = [];
|
|
263
|
+
|
|
264
|
+
if (routed?.auto_start_error) errors.push(routed.auto_start_error);
|
|
265
|
+
if (routed?.auto_start_skipped) errors.push(`auto_start_skipped: ${routed.auto_start_skipped}`);
|
|
266
|
+
|
|
267
|
+
const record = {
|
|
268
|
+
result_id: resultId,
|
|
269
|
+
timestamp: new Date(ts).toISOString(),
|
|
270
|
+
event_id: pipelineResult.event?.event_id || null,
|
|
271
|
+
intent_id: pipelineResult.intent?.intent_id || null,
|
|
272
|
+
intent_status: pipelineResult.intent?.status || null,
|
|
273
|
+
deduplicated: pipelineResult.deduplicated === true,
|
|
274
|
+
delivery_id: metadata.delivery_id || null,
|
|
275
|
+
payload: {
|
|
276
|
+
source: payload.source,
|
|
277
|
+
category: payload.category,
|
|
278
|
+
repo: payload.repo || null,
|
|
279
|
+
ref: payload.ref || null,
|
|
280
|
+
},
|
|
281
|
+
route: routed
|
|
282
|
+
? {
|
|
283
|
+
matched: true,
|
|
284
|
+
triaged: routed.triaged === true,
|
|
285
|
+
approved: routed.approved === true,
|
|
286
|
+
planned: routed.planned === true,
|
|
287
|
+
started: routed.started === true,
|
|
288
|
+
auto_start: routed.auto_start === true || routed.started === true,
|
|
289
|
+
preferred_role: routed.preferred_role || null,
|
|
290
|
+
run_id: routed.run_id || null,
|
|
291
|
+
role: routed.role || null,
|
|
292
|
+
}
|
|
293
|
+
: { matched: false },
|
|
294
|
+
errors: errors.length > 0 ? errors : [],
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const dir = join(root, '.agentxchain', 'watch-results');
|
|
298
|
+
mkdirSync(dir, { recursive: true });
|
|
299
|
+
const resultPath = join(dir, `${resultId}.json`);
|
|
300
|
+
safeWriteJson(resultPath, record);
|
|
301
|
+
|
|
302
|
+
return { result_id: resultId, result_path: resultPath };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function interpolateCharter(template, signal) {
|
|
306
|
+
if (!template || typeof template !== 'string') return template || 'Watch event intake';
|
|
307
|
+
if (!signal || typeof signal !== 'object') return template;
|
|
308
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
309
|
+
const value = signal[key];
|
|
310
|
+
return value !== undefined && value !== null ? String(value) : `{{${key}}}`;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { recordEvent, triageIntent, approveIntent, planIntent, startIntent } from './intake.js';
|
|
6
|
+
import { normalizeWatchEvent, resolveWatchRoute, writeWatchResult } from './watch-events.js';
|
|
7
|
+
|
|
8
|
+
const MAX_BODY_BYTES = 1_048_576; // 1 MB
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Start an HTTP webhook listener that feeds events through the governed intake pipeline.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} opts
|
|
14
|
+
* @param {string} opts.root - project root
|
|
15
|
+
* @param {number} opts.port - port to bind
|
|
16
|
+
* @param {string} [opts.host='127.0.0.1'] - host to bind
|
|
17
|
+
* @param {string|null} [opts.secret=null] - HMAC-SHA256 webhook secret
|
|
18
|
+
* @param {boolean} [opts.allowUnsigned=false] - accept unsigned payloads
|
|
19
|
+
* @param {boolean} [opts.dryRun=false] - normalize only, do not persist
|
|
20
|
+
* @param {Function} [opts.onReady] - called with { port, host } when listening
|
|
21
|
+
* @returns {Promise<import('http').Server>}
|
|
22
|
+
*/
|
|
23
|
+
export function startWebhookListener(opts) {
|
|
24
|
+
const { root, port, host = '127.0.0.1', secret = null, allowUnsigned = false, dryRun = false, onReady } = opts;
|
|
25
|
+
const startedAt = Date.now();
|
|
26
|
+
let eventsProcessed = 0;
|
|
27
|
+
|
|
28
|
+
let version = 'unknown';
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(join(root, 'node_modules', 'agentxchain', 'package.json'), 'utf8'));
|
|
31
|
+
version = pkg.version;
|
|
32
|
+
} catch {
|
|
33
|
+
try {
|
|
34
|
+
// Fallback: try the CLI's own package.json
|
|
35
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
36
|
+
version = pkg.version;
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const server = createServer(async (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
// Health endpoint
|
|
43
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
44
|
+
writeJson(res, 200, {
|
|
45
|
+
ok: true,
|
|
46
|
+
version,
|
|
47
|
+
uptime_ms: Date.now() - startedAt,
|
|
48
|
+
events_processed: eventsProcessed,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Webhook endpoint
|
|
54
|
+
if (req.method === 'POST' && req.url === '/webhook') {
|
|
55
|
+
const outcome = await handleWebhook(req, res, { root, secret, allowUnsigned, dryRun, startedAt });
|
|
56
|
+
if (outcome?.counted) eventsProcessed++;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Method not allowed on known paths
|
|
61
|
+
if (req.url === '/webhook' || req.url === '/health') {
|
|
62
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Not found
|
|
67
|
+
writeJson(res, 404, { ok: false, error: 'not found' });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
writeJson(res, 500, { ok: false, error: 'internal error' });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
server.on('error', reject);
|
|
75
|
+
server.listen(port, host, () => {
|
|
76
|
+
server.removeListener('error', reject);
|
|
77
|
+
if (onReady) onReady({ port, host });
|
|
78
|
+
resolve(server);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function handleWebhook(req, res, ctx) {
|
|
84
|
+
const { root, secret, allowUnsigned, dryRun } = ctx;
|
|
85
|
+
|
|
86
|
+
// Content-Type check
|
|
87
|
+
const contentType = req.headers['content-type'] || '';
|
|
88
|
+
if (!contentType.includes('application/json')) {
|
|
89
|
+
writeJson(res, 415, { ok: false, error: 'content type must be application/json' });
|
|
90
|
+
return { counted: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Read body with size limit
|
|
94
|
+
let rawBody;
|
|
95
|
+
try {
|
|
96
|
+
rawBody = await readBody(req, MAX_BODY_BYTES);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err.message === 'payload too large') {
|
|
99
|
+
writeJson(res, 413, { ok: false, error: 'payload too large' });
|
|
100
|
+
return { counted: false };
|
|
101
|
+
}
|
|
102
|
+
writeJson(res, 400, { ok: false, error: err.message });
|
|
103
|
+
return { counted: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Signature verification
|
|
107
|
+
if (secret) {
|
|
108
|
+
const sigHeader = req.headers['x-hub-signature-256'];
|
|
109
|
+
if (!sigHeader) {
|
|
110
|
+
writeJson(res, 401, { ok: false, error: 'signature verification failed' });
|
|
111
|
+
return { counted: false };
|
|
112
|
+
}
|
|
113
|
+
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
114
|
+
if (!constantTimeEqual(expected, sigHeader)) {
|
|
115
|
+
writeJson(res, 401, { ok: false, error: 'signature verification failed' });
|
|
116
|
+
return { counted: false };
|
|
117
|
+
}
|
|
118
|
+
} else if (!allowUnsigned) {
|
|
119
|
+
writeJson(res, 403, { ok: false, error: 'webhook secret required' });
|
|
120
|
+
return { counted: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Parse JSON
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(rawBody);
|
|
127
|
+
} catch {
|
|
128
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON' });
|
|
129
|
+
return { counted: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Construct envelope using X-GitHub-Event header if present
|
|
133
|
+
const githubEvent = req.headers['x-github-event'];
|
|
134
|
+
const deliveryId = req.headers['x-github-delivery'] || null;
|
|
135
|
+
let envelope;
|
|
136
|
+
if (parsed.provider && parsed.event) {
|
|
137
|
+
// Already enveloped
|
|
138
|
+
envelope = parsed;
|
|
139
|
+
} else if (githubEvent) {
|
|
140
|
+
envelope = { provider: 'github', event: githubEvent, ...parsed };
|
|
141
|
+
} else {
|
|
142
|
+
envelope = parsed;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Normalize
|
|
146
|
+
let payload;
|
|
147
|
+
try {
|
|
148
|
+
payload = normalizeWatchEvent(envelope);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
writeJson(res, 422, { ok: false, error: err.message });
|
|
151
|
+
return { counted: false };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Dry-run: return normalized payload without persisting
|
|
155
|
+
if (dryRun) {
|
|
156
|
+
writeJson(res, 200, { ok: true, dry_run: true, payload });
|
|
157
|
+
return { counted: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Record event through the governed intake pipeline
|
|
161
|
+
const result = recordEvent(root, payload);
|
|
162
|
+
if (!result.ok) {
|
|
163
|
+
writeJson(res, 422, { ok: false, error: result.error || 'event recording failed' });
|
|
164
|
+
return { counted: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Route-based auto-triage and auto-approve (same logic as ingestWatchEvent)
|
|
168
|
+
let routed = null;
|
|
169
|
+
if (!result.deduplicated && result.intent) {
|
|
170
|
+
let routes;
|
|
171
|
+
try {
|
|
172
|
+
const rawConfig = JSON.parse(readFileSync(join(root, 'agentxchain.json'), 'utf8'));
|
|
173
|
+
routes = rawConfig?.watch?.routes;
|
|
174
|
+
} catch {}
|
|
175
|
+
|
|
176
|
+
const resolved = resolveWatchRoute(payload, routes);
|
|
177
|
+
if (resolved) {
|
|
178
|
+
const triageFields = { ...resolved.triage };
|
|
179
|
+
if (resolved.preferred_role) triageFields.preferred_role = resolved.preferred_role;
|
|
180
|
+
|
|
181
|
+
const triageResult = triageIntent(root, result.intent.intent_id, triageFields);
|
|
182
|
+
if (triageResult.ok) {
|
|
183
|
+
result.intent = triageResult.intent;
|
|
184
|
+
routed = { triaged: true, approved: false, preferred_role: resolved.preferred_role };
|
|
185
|
+
|
|
186
|
+
if (resolved.auto_approve) {
|
|
187
|
+
const approveResult = approveIntent(root, result.intent.intent_id, {
|
|
188
|
+
approver: 'watch_route',
|
|
189
|
+
reason: `auto-approved by watch route matching ${payload.category}`,
|
|
190
|
+
});
|
|
191
|
+
if (approveResult.ok) {
|
|
192
|
+
result.intent = approveResult.intent;
|
|
193
|
+
routed.approved = true;
|
|
194
|
+
|
|
195
|
+
if (resolved.auto_start) {
|
|
196
|
+
const planResult = planIntent(root, result.intent.intent_id, {
|
|
197
|
+
force: resolved.overwrite_planning_artifacts === true,
|
|
198
|
+
});
|
|
199
|
+
if (planResult.ok) {
|
|
200
|
+
result.intent = planResult.intent;
|
|
201
|
+
routed.planned = true;
|
|
202
|
+
const startResult = startIntent(root, result.intent.intent_id, {});
|
|
203
|
+
if (startResult.ok) {
|
|
204
|
+
result.intent = startResult.intent;
|
|
205
|
+
routed.started = true;
|
|
206
|
+
routed.run_id = startResult.run_id || null;
|
|
207
|
+
routed.role = startResult.role || null;
|
|
208
|
+
} else {
|
|
209
|
+
routed.started = false;
|
|
210
|
+
routed.auto_start_error = startResult.error;
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
routed.planned = false;
|
|
214
|
+
routed.started = false;
|
|
215
|
+
routed.auto_start_error = planResult.error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else if (resolved.auto_start) {
|
|
220
|
+
routed.auto_start_skipped = 'requires auto_approve';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (routed) result.routed = routed;
|
|
227
|
+
|
|
228
|
+
// Write durable watch result
|
|
229
|
+
const watchResult = writeWatchResult(root, result, payload, { delivery_id: deliveryId });
|
|
230
|
+
|
|
231
|
+
// Build response
|
|
232
|
+
const response = {
|
|
233
|
+
ok: true,
|
|
234
|
+
result_id: watchResult.result_id,
|
|
235
|
+
event_id: result.event?.event_id || null,
|
|
236
|
+
intent_id: result.intent?.intent_id || null,
|
|
237
|
+
intent_status: result.intent?.status || null,
|
|
238
|
+
deduplicated: result.deduplicated === true,
|
|
239
|
+
delivery_id: deliveryId,
|
|
240
|
+
route: routed
|
|
241
|
+
? {
|
|
242
|
+
matched: true,
|
|
243
|
+
triaged: routed.triaged === true,
|
|
244
|
+
approved: routed.approved === true,
|
|
245
|
+
planned: routed.planned === true,
|
|
246
|
+
started: routed.started === true,
|
|
247
|
+
preferred_role: routed.preferred_role || null,
|
|
248
|
+
run_id: routed.run_id || null,
|
|
249
|
+
role: routed.role || null,
|
|
250
|
+
}
|
|
251
|
+
: { matched: false },
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
writeJson(res, 200, response);
|
|
255
|
+
return { counted: true };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readBody(req, maxBytes) {
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
const chunks = [];
|
|
261
|
+
let size = 0;
|
|
262
|
+
let rejected = false;
|
|
263
|
+
req.on('data', (chunk) => {
|
|
264
|
+
size += chunk.length;
|
|
265
|
+
if (size > maxBytes && !rejected) {
|
|
266
|
+
rejected = true;
|
|
267
|
+
reject(new Error('payload too large'));
|
|
268
|
+
// Resume to drain remaining data so the response can be sent
|
|
269
|
+
req.resume();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!rejected) chunks.push(chunk);
|
|
273
|
+
});
|
|
274
|
+
req.on('end', () => {
|
|
275
|
+
if (!rejected) resolve(Buffer.concat(chunks));
|
|
276
|
+
});
|
|
277
|
+
req.on('error', (err) => {
|
|
278
|
+
if (!rejected) reject(err);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function constantTimeEqual(a, b) {
|
|
284
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
285
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
286
|
+
if (bufA.length !== bufB.length) return false;
|
|
287
|
+
return timingSafeEqual(bufA, bufB);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function writeJson(res, statusCode, payload) {
|
|
291
|
+
if (res.writableEnded) return;
|
|
292
|
+
res.writeHead(statusCode, {
|
|
293
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
294
|
+
'Cache-Control': 'no-cache',
|
|
295
|
+
});
|
|
296
|
+
res.end(JSON.stringify(payload));
|
|
297
|
+
}
|