agentxchain 2.155.23 → 2.155.25
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 -1
- package/bin/agentxchain.js +4 -0
- package/dashboard/app.js +3 -0
- package/dashboard/components/watch.js +131 -0
- package/dashboard/index.html +1 -0
- package/package.json +1 -1
- package/src/commands/watch.js +99 -0
- package/src/lib/dashboard/bridge-server.js +7 -0
- package/src/lib/dashboard/file-watcher.js +4 -0
- package/src/lib/dashboard/state-reader.js +4 -0
- package/src/lib/dashboard/watch-results-reader.js +86 -0
- package/src/lib/watch-events.js +2 -1
- package/src/lib/watch-listener.js +297 -0
package/README.md
CHANGED
|
@@ -205,7 +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\|--results\|--result` | Normalize external events into governed intake, poll event-file directories, and inspect durable watch result records |
|
|
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 |
|
|
209
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 |
|
|
210
210
|
| `plugin install\|list\|remove` | Install, inspect, or remove governed hook plugins under `.agentxchain/plugins/` |
|
|
211
211
|
| `plugin list-available` | List bundled built-in plugins installable by short name |
|
package/bin/agentxchain.js
CHANGED
|
@@ -254,6 +254,10 @@ program
|
|
|
254
254
|
.option('--event-dir <path>', 'Poll a directory for external event JSON files')
|
|
255
255
|
.option('--poll-seconds <seconds>', 'With --event-dir, polling interval in seconds', '5')
|
|
256
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)')
|
|
257
261
|
.option('--results', 'List all watch result records')
|
|
258
262
|
.option('--result <id>', 'Show a single watch result by ID or filename')
|
|
259
263
|
.option('--limit <n>', 'With --results, limit the number of results shown')
|
package/dashboard/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import { render as renderChain } from './components/chain.js';
|
|
|
21
21
|
import { render as renderRunHistory } from './components/run-history.js';
|
|
22
22
|
import { render as renderTimeouts } from './components/timeouts.js';
|
|
23
23
|
import { render as renderCoordinatorTimeouts } from './components/coordinator-timeouts.js';
|
|
24
|
+
import { render as renderWatch } from './components/watch.js';
|
|
24
25
|
import {
|
|
25
26
|
buildLiveMeta,
|
|
26
27
|
createLiveEventFromMessage,
|
|
@@ -42,6 +43,7 @@ const VIEWS = {
|
|
|
42
43
|
mission: { fetch: ['missions', 'plans'], render: renderMission },
|
|
43
44
|
chain: { fetch: ['chainReports'], render: renderChain },
|
|
44
45
|
'run-history': { fetch: ['runHistory'], render: renderRunHistory },
|
|
46
|
+
watch: { fetch: ['watchResults'], render: renderWatch },
|
|
45
47
|
timeouts: { fetch: ['timeouts'], render: renderTimeouts },
|
|
46
48
|
'coordinator-timeouts': { fetch: ['coordinatorTimeouts'], render: renderCoordinatorTimeouts },
|
|
47
49
|
};
|
|
@@ -70,6 +72,7 @@ const API_MAP = {
|
|
|
70
72
|
chainReports: '/api/chain-reports',
|
|
71
73
|
connectors: '/api/connectors',
|
|
72
74
|
runHistory: '/api/run-history',
|
|
75
|
+
watchResults: '/api/watch-results',
|
|
73
76
|
timeouts: '/api/timeouts',
|
|
74
77
|
coordinatorTimeouts: '/api/coordinator/timeouts',
|
|
75
78
|
gateActions: '/api/gate-actions',
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
function esc(str) {
|
|
2
|
+
if (str == null) return '';
|
|
3
|
+
return String(str)
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, ''');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function badge(label, color = 'var(--text-dim)') {
|
|
12
|
+
return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function classify(record) {
|
|
16
|
+
if (Array.isArray(record?.errors) && record.errors.length > 0) return 'error';
|
|
17
|
+
if (record?.deduplicated === true) return 'deduplicated';
|
|
18
|
+
if (record?.route?.started === true) return 'started';
|
|
19
|
+
if (record?.route?.planned === true) return 'planned';
|
|
20
|
+
if (record?.route?.approved === true) return 'approved';
|
|
21
|
+
if (record?.route?.triaged === true) return 'triaged';
|
|
22
|
+
if (record?.route?.matched === false) return 'unrouted';
|
|
23
|
+
return 'detected';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function statusBadge(record) {
|
|
27
|
+
const status = classify(record);
|
|
28
|
+
const colors = {
|
|
29
|
+
error: 'var(--red)',
|
|
30
|
+
deduplicated: 'var(--yellow)',
|
|
31
|
+
started: 'var(--green)',
|
|
32
|
+
planned: 'var(--accent)',
|
|
33
|
+
approved: 'var(--accent)',
|
|
34
|
+
triaged: 'var(--text)',
|
|
35
|
+
unrouted: 'var(--text-dim)',
|
|
36
|
+
detected: 'var(--text-dim)',
|
|
37
|
+
};
|
|
38
|
+
return badge(status, colors[status] || 'var(--text-dim)');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatTime(value) {
|
|
42
|
+
if (!value) return '—';
|
|
43
|
+
const date = new Date(value);
|
|
44
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
45
|
+
return date.toLocaleString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderResultRow(record) {
|
|
49
|
+
const payload = record.payload || {};
|
|
50
|
+
const route = record.route || {};
|
|
51
|
+
const errors = Array.isArray(record.errors) ? record.errors : [];
|
|
52
|
+
const routeDetail = route.matched === false
|
|
53
|
+
? 'No route'
|
|
54
|
+
: [
|
|
55
|
+
route.run_id ? `run ${route.run_id}` : null,
|
|
56
|
+
route.role ? `role ${route.role}` : null,
|
|
57
|
+
route.preferred_role ? `hint ${route.preferred_role}` : null,
|
|
58
|
+
].filter(Boolean).join(' · ') || 'Route matched';
|
|
59
|
+
|
|
60
|
+
return `<tr${errors.length > 0 ? ' style="border-left:3px solid var(--red)"' : ''}>
|
|
61
|
+
<td>
|
|
62
|
+
<div class="mono">${esc(record.result_id || '—')}</div>
|
|
63
|
+
<div class="turn-status">${esc(formatTime(record.timestamp))}</div>
|
|
64
|
+
</td>
|
|
65
|
+
<td>${statusBadge(record)}</td>
|
|
66
|
+
<td>
|
|
67
|
+
<div class="mono">${esc(payload.category || 'unknown')}</div>
|
|
68
|
+
<div class="turn-status">${esc(payload.repo || '—')}${payload.ref ? ` · ${esc(payload.ref)}` : ''}</div>
|
|
69
|
+
</td>
|
|
70
|
+
<td>
|
|
71
|
+
<div>${esc(routeDetail)}</div>
|
|
72
|
+
<div class="turn-status">Intent ${esc(record.intent_id || '—')} · Event ${esc(record.event_id || '—')}</div>
|
|
73
|
+
</td>
|
|
74
|
+
<td class="mono">${esc(record.delivery_id || '—')}</td>
|
|
75
|
+
<td>${errors.length > 0 ? esc(errors.join(' | ')) : '—'}</td>
|
|
76
|
+
</tr>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function render({ watchResults }) {
|
|
80
|
+
if (!watchResults) {
|
|
81
|
+
return `<div class="placeholder"><h2>Watch</h2><p>No watch result data available.</p></div>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (watchResults.ok === false) {
|
|
85
|
+
return `<div class="placeholder"><h2>Watch</h2><p>${esc(watchResults.error || 'Failed to load watch results.')}</p></div>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const recent = Array.isArray(watchResults.recent) ? watchResults.recent : [];
|
|
89
|
+
const summary = watchResults.summary || {};
|
|
90
|
+
const byStatus = summary.by_status || {};
|
|
91
|
+
|
|
92
|
+
if ((watchResults.total || 0) === 0 && (watchResults.corrupt || 0) === 0) {
|
|
93
|
+
return `<div class="placeholder"><h2>Watch</h2><p>No watch intake results yet. Use <code>agentxchain watch --listen</code>, <code>--event-file</code>, or <code>--event-dir</code> to ingest events.</p></div>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let html = `<div class="watch-view"><div class="run-header"><div class="run-meta">`;
|
|
97
|
+
html += badge(`${watchResults.total || 0} results`, 'var(--accent)');
|
|
98
|
+
html += badge(`${summary.routed || 0} routed`, 'var(--green)');
|
|
99
|
+
html += badge(`${summary.unrouted || 0} unrouted`, summary.unrouted ? 'var(--yellow)' : 'var(--text-dim)');
|
|
100
|
+
html += badge(`${summary.deduplicated || 0} deduped`, summary.deduplicated ? 'var(--yellow)' : 'var(--text-dim)');
|
|
101
|
+
html += badge(`${summary.errored || 0} errors`, summary.errored ? 'var(--red)' : 'var(--text-dim)');
|
|
102
|
+
if (watchResults.corrupt) html += badge(`${watchResults.corrupt} corrupt`, 'var(--red)');
|
|
103
|
+
html += `</div></div>`;
|
|
104
|
+
|
|
105
|
+
html += `<div class="section"><h3>Intake Summary</h3>
|
|
106
|
+
<p class="section-subtitle">Last result: ${esc(formatTime(summary.last_timestamp))}</p>
|
|
107
|
+
<table class="data-table">
|
|
108
|
+
<thead><tr><th>Status</th><th>Count</th></tr></thead>
|
|
109
|
+
<tbody>${Object.keys(byStatus).sort().map((status) => `<tr><td>${esc(status)}</td><td>${esc(byStatus[status])}</td></tr>`).join('')}</tbody>
|
|
110
|
+
</table>
|
|
111
|
+
</div>`;
|
|
112
|
+
|
|
113
|
+
html += `<div class="section"><h3>Recent Watch Results</h3>
|
|
114
|
+
<table class="data-table">
|
|
115
|
+
<thead>
|
|
116
|
+
<tr>
|
|
117
|
+
<th>Result</th>
|
|
118
|
+
<th>Status</th>
|
|
119
|
+
<th>Payload</th>
|
|
120
|
+
<th>Route</th>
|
|
121
|
+
<th>Delivery</th>
|
|
122
|
+
<th>Errors</th>
|
|
123
|
+
</tr>
|
|
124
|
+
</thead>
|
|
125
|
+
<tbody>${recent.map(renderResultRow).join('')}</tbody>
|
|
126
|
+
</table>
|
|
127
|
+
</div>`;
|
|
128
|
+
|
|
129
|
+
html += `</div>`;
|
|
130
|
+
return html;
|
|
131
|
+
}
|
package/dashboard/index.html
CHANGED
package/package.json
CHANGED
package/src/commands/watch.js
CHANGED
|
@@ -11,6 +11,7 @@ import { notifyHuman as sendNotification } from '../lib/notify.js';
|
|
|
11
11
|
import { validateProject } from '../lib/validation.js';
|
|
12
12
|
import { resolveNextAgent, resolveExpectedClaimer } from '../lib/next-owner.js';
|
|
13
13
|
import { requireIntakeWorkspaceOrExit } from './intake-workspace.js';
|
|
14
|
+
import { startWebhookListener } from '../lib/watch-listener.js';
|
|
14
15
|
|
|
15
16
|
const PID_FILE = '.agentxchain-watch.pid';
|
|
16
17
|
|
|
@@ -20,6 +21,11 @@ export async function watchCommand(opts) {
|
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
if (opts.listen) {
|
|
25
|
+
await listenWebhook(opts);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
if (opts.eventFile) {
|
|
24
30
|
await ingestWatchEvent(opts);
|
|
25
31
|
return;
|
|
@@ -311,6 +317,99 @@ function parsePollMs(value) {
|
|
|
311
317
|
return Math.max(100, Math.round(seconds * 1000));
|
|
312
318
|
}
|
|
313
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
|
+
|
|
314
413
|
async function ingestWatchEvent(opts) {
|
|
315
414
|
if (opts.daemon) {
|
|
316
415
|
const message = '--daemon cannot be combined with --event-file';
|
|
@@ -33,6 +33,7 @@ import { readGateActionSnapshot } from './gate-action-reader.js';
|
|
|
33
33
|
import { readChainReportSnapshot } from './chain-report-reader.js';
|
|
34
34
|
import { readMissionSnapshot } from './mission-reader.js';
|
|
35
35
|
import { readPlanSnapshot } from './plan-reader.js';
|
|
36
|
+
import { readWatchResultsSnapshot } from './watch-results-reader.js';
|
|
36
37
|
|
|
37
38
|
const MIME_TYPES = {
|
|
38
39
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -504,6 +505,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
|
|
|
504
505
|
return;
|
|
505
506
|
}
|
|
506
507
|
|
|
508
|
+
if (pathname === '/api/watch-results') {
|
|
509
|
+
const limit = url.searchParams.get('limit') ?? undefined;
|
|
510
|
+
writeJson(res, 200, readWatchResultsSnapshot(workspacePath, { limit }));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
507
514
|
// API routes
|
|
508
515
|
if (pathname.startsWith('/api/')) {
|
|
509
516
|
const result = readResource(agentxchainDir, pathname);
|
|
@@ -47,6 +47,10 @@ export class FileWatcher extends EventEmitter {
|
|
|
47
47
|
if (!relativeDir && fileSegment === 'multirepo') {
|
|
48
48
|
this.#watchPath('multirepo');
|
|
49
49
|
}
|
|
50
|
+
if (!relativeDir && fileSegment === 'watch-results') {
|
|
51
|
+
this.#watchPath('watch-results');
|
|
52
|
+
this.emit('invalidate', { resource: '/api/watch-results' });
|
|
53
|
+
}
|
|
50
54
|
return;
|
|
51
55
|
}
|
|
52
56
|
|
|
@@ -66,6 +66,7 @@ export const WATCH_DIRECTORIES = [
|
|
|
66
66
|
MULTIREPO_DIR,
|
|
67
67
|
'missions',
|
|
68
68
|
'reports',
|
|
69
|
+
'watch-results',
|
|
69
70
|
];
|
|
70
71
|
|
|
71
72
|
/**
|
|
@@ -94,6 +95,9 @@ export function resourcesForRelativePath(filePath) {
|
|
|
94
95
|
if (normalized.startsWith('reports/chain-') && normalized.endsWith('.json')) {
|
|
95
96
|
return ['/api/chain-reports', '/api/missions'];
|
|
96
97
|
}
|
|
98
|
+
if (normalized === 'watch-results' || (normalized.startsWith('watch-results/') && normalized.endsWith('.json'))) {
|
|
99
|
+
return ['/api/watch-results'];
|
|
100
|
+
}
|
|
97
101
|
return FILE_TO_RESOURCE[normalized] ? [FILE_TO_RESOURCE[normalized]] : [];
|
|
98
102
|
}
|
|
99
103
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_LIMIT = 25;
|
|
5
|
+
|
|
6
|
+
export function readWatchResultsSnapshot(workspacePath, { limit = DEFAULT_LIMIT } = {}) {
|
|
7
|
+
const resultsDir = join(workspacePath, '.agentxchain', 'watch-results');
|
|
8
|
+
const parsedLimit = parseLimit(limit);
|
|
9
|
+
const records = [];
|
|
10
|
+
let corrupt = 0;
|
|
11
|
+
|
|
12
|
+
if (existsSync(resultsDir)) {
|
|
13
|
+
const entries = readdirSync(resultsDir, { withFileTypes: true })
|
|
14
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
|
|
15
|
+
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
try {
|
|
18
|
+
const record = JSON.parse(readFileSync(join(resultsDir, entry.name), 'utf8'));
|
|
19
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
20
|
+
corrupt += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
records.push(record);
|
|
24
|
+
} catch {
|
|
25
|
+
corrupt += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
records.sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')));
|
|
31
|
+
const recent = parsedLimit === 0 ? records : records.slice(0, parsedLimit);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
total: records.length,
|
|
36
|
+
corrupt,
|
|
37
|
+
recent,
|
|
38
|
+
summary: summarize(records, corrupt),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseLimit(value) {
|
|
43
|
+
const parsed = Number.parseInt(value, 10);
|
|
44
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_LIMIT;
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function summarize(records, corrupt) {
|
|
49
|
+
const byStatus = {};
|
|
50
|
+
let errored = 0;
|
|
51
|
+
let deduplicated = 0;
|
|
52
|
+
let routed = 0;
|
|
53
|
+
let unrouted = 0;
|
|
54
|
+
let lastTimestamp = null;
|
|
55
|
+
|
|
56
|
+
for (const record of records) {
|
|
57
|
+
const status = classifyWatchResult(record);
|
|
58
|
+
byStatus[status] = (byStatus[status] || 0) + 1;
|
|
59
|
+
if (status === 'error') errored += 1;
|
|
60
|
+
if (record.deduplicated === true) deduplicated += 1;
|
|
61
|
+
if (record.route?.matched === false) unrouted += 1;
|
|
62
|
+
else if (record.route?.matched === true) routed += 1;
|
|
63
|
+
if (!lastTimestamp && record.timestamp) lastTimestamp = record.timestamp;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
by_status: byStatus,
|
|
68
|
+
errored,
|
|
69
|
+
deduplicated,
|
|
70
|
+
routed,
|
|
71
|
+
unrouted,
|
|
72
|
+
corrupt,
|
|
73
|
+
last_timestamp: lastTimestamp,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function classifyWatchResult(record) {
|
|
78
|
+
if (Array.isArray(record?.errors) && record.errors.length > 0) return 'error';
|
|
79
|
+
if (record?.deduplicated === true) return 'deduplicated';
|
|
80
|
+
if (record?.route?.started === true) return 'started';
|
|
81
|
+
if (record?.route?.planned === true) return 'planned';
|
|
82
|
+
if (record?.route?.approved === true) return 'approved';
|
|
83
|
+
if (record?.route?.triaged === true) return 'triaged';
|
|
84
|
+
if (record?.route?.matched === false) return 'unrouted';
|
|
85
|
+
return 'detected';
|
|
86
|
+
}
|
package/src/lib/watch-events.js
CHANGED
|
@@ -253,7 +253,7 @@ export function resolveWatchRoute(payload, routes) {
|
|
|
253
253
|
* @param {object} payload - the normalized watch event payload
|
|
254
254
|
* @returns {{ result_id: string, result_path: string }}
|
|
255
255
|
*/
|
|
256
|
-
export function writeWatchResult(root, pipelineResult, payload) {
|
|
256
|
+
export function writeWatchResult(root, pipelineResult, payload, metadata = {}) {
|
|
257
257
|
const ts = Date.now();
|
|
258
258
|
const suffix = Math.random().toString(16).slice(2, 10);
|
|
259
259
|
const resultId = `wr_${ts}_${suffix}`;
|
|
@@ -271,6 +271,7 @@ export function writeWatchResult(root, pipelineResult, payload) {
|
|
|
271
271
|
intent_id: pipelineResult.intent?.intent_id || null,
|
|
272
272
|
intent_status: pipelineResult.intent?.status || null,
|
|
273
273
|
deduplicated: pipelineResult.deduplicated === true,
|
|
274
|
+
delivery_id: metadata.delivery_id || null,
|
|
274
275
|
payload: {
|
|
275
276
|
source: payload.source,
|
|
276
277
|
category: payload.category,
|
|
@@ -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
|
+
}
|