diff --git a/src/cli/dashboard/webhooklogs/list.ts b/src/cli/dashboard/webhooklogs/list.ts index c5a886ef..62c4bd6e 100644 --- a/src/cli/dashboard/webhooklogs/list.ts +++ b/src/cli/dashboard/webhooklogs/list.ts @@ -35,6 +35,11 @@ export default class WebhookLogsList extends DashboardCommand { { key: 'eventType', header: 'Event' }, { key: 'statusCode', header: 'Status' }, { key: 'processed', header: 'Processed', format: (v) => (v ? 'yes' : 'no') }, + { + key: 'decisionReason', + header: 'Reason', + format: (v) => (v ? String(v).slice(0, 50) : '-'), + }, { key: 'receivedAt', header: 'Time', format: formatDate }, ]); } catch (err) { diff --git a/src/cli/dashboard/webhooklogs/show.ts b/src/cli/dashboard/webhooklogs/show.ts index bf42ad34..98488ff5 100644 --- a/src/cli/dashboard/webhooklogs/show.ts +++ b/src/cli/dashboard/webhooklogs/show.ts @@ -33,6 +33,7 @@ export default class WebhookLogsShow extends DashboardCommand { statusCode: { label: 'Status Code' }, processed: { label: 'Processed', format: (v) => (v ? 'yes' : 'no') }, projectId: { label: 'Project ID' }, + decisionReason: { label: 'Decision Reason' }, receivedAt: { label: 'Received At', format: formatDate }, }); diff --git a/src/db/migrations/0027_webhook_decision_reason.sql b/src/db/migrations/0027_webhook_decision_reason.sql new file mode 100644 index 00000000..d5b29e68 --- /dev/null +++ b/src/db/migrations/0027_webhook_decision_reason.sql @@ -0,0 +1 @@ +ALTER TABLE webhook_logs ADD COLUMN decision_reason TEXT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 111cbe76..3c397c33 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1761000000000, "tag": "0026_agent_type_concurrency", "breakpoints": false + }, + { + "idx": 27, + "version": "7", + "when": 1762000000000, + "tag": "0027_webhook_decision_reason", + "breakpoints": false } ] } diff --git a/src/db/repositories/webhookLogsRepository.ts b/src/db/repositories/webhookLogsRepository.ts index 1cc18736..5e193a64 100644 --- a/src/db/repositories/webhookLogsRepository.ts +++ b/src/db/repositories/webhookLogsRepository.ts @@ -17,6 +17,7 @@ export interface InsertWebhookLogInput { projectId?: string; eventType?: string; processed?: boolean; + decisionReason?: string; } export interface ListWebhookLogsInput { @@ -47,6 +48,7 @@ export async function insertWebhookLog(input: InsertWebhookLogInput): Promise [ index('idx_webhook_logs_received_at').on(table.receivedAt), diff --git a/src/router/index.ts b/src/router/index.ts index a738b65f..feb3e603 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -74,6 +74,7 @@ app.post( return { processed: result.shouldProcess, projectId: result.projectId, + decisionReason: result.decisionReason, }; }, }), @@ -94,7 +95,11 @@ app.post( const adapter = new GitHubRouterAdapter(); const augmented = injectEventType(payload, eventType ?? 'unknown'); const result = await processRouterWebhook(adapter, augmented, triggerRegistry); - return { processed: result.shouldProcess }; + return { + processed: result.shouldProcess, + projectId: result.projectId, + decisionReason: result.decisionReason, + }; }, }), ); @@ -116,6 +121,7 @@ app.post( return { processed: result.shouldProcess, projectId: result.projectId, + decisionReason: result.decisionReason, }; }, }), diff --git a/src/router/webhook-processor.ts b/src/router/webhook-processor.ts index 20914d18..885ed984 100644 --- a/src/router/webhook-processor.ts +++ b/src/router/webhook-processor.ts @@ -26,6 +26,8 @@ export interface ProcessRouterWebhookResult { shouldProcess: boolean; /** The resolved project identifier, if any. */ projectId?: string; + /** Human-readable explanation of why the event was processed or skipped. */ + decisionReason?: string; } /** @@ -53,13 +55,16 @@ export async function processRouterWebhook( const event = await adapter.parseWebhook(payload); if (!event) { logger.debug(`Ignoring ${adapter.type} event (unparseable or not processable)`); - return { shouldProcess: false }; + return { shouldProcess: false, decisionReason: 'Event unparseable or not processable' }; } // Step 2: Filter if (!adapter.isProcessableEvent(event)) { logger.debug(`Ignoring ${adapter.type} event`, { eventType: event.eventType }); - return { shouldProcess: false }; + return { + shouldProcess: false, + decisionReason: `Event type not processable: ${event.eventType}`, + }; } // Step 3: Self-authored check @@ -68,7 +73,7 @@ export async function processRouterWebhook( eventType: event.eventType, projectIdentifier: event.projectIdentifier, }); - return { shouldProcess: true }; + return { shouldProcess: true, decisionReason: 'Self-authored event (loop prevention)' }; } // Step 4: Fire acknowledgment reaction (fire-and-forget) @@ -80,7 +85,10 @@ export async function processRouterWebhook( logger.info(`No project config found for ${adapter.type} event`, { projectIdentifier: event.projectIdentifier, }); - return { shouldProcess: true }; + return { + shouldProcess: true, + decisionReason: `No project config for identifier ${event.projectIdentifier ?? '(unknown)'}`, + }; } // Step 6: Dispatch triggers with credential scope @@ -99,7 +107,11 @@ export async function processRouterWebhook( eventType: event.eventType, workItemId: event.workItemId, }); - return { shouldProcess: true, projectId: project.id }; + return { + shouldProcess: true, + projectId: project.id, + decisionReason: 'No trigger matched for event', + }; } logger.info(`${adapter.type} trigger matched`, { @@ -112,7 +124,11 @@ export async function processRouterWebhook( // dispatch already performed PM operations — no job queuing needed if (!result.agentType) { logger.info('Trigger completed without agent (PM operation done)'); - return { shouldProcess: true, projectId: project.id }; + return { + shouldProcess: true, + projectId: project.id, + decisionReason: 'Trigger completed without agent (PM operation)', + }; } // Step 7: Work-item concurrency lock @@ -125,7 +141,11 @@ export async function processRouterWebhook( agentType: result.agentType, reason: lockStatus.reason, }); - return { shouldProcess: true, projectId: project.id }; + return { + shouldProcess: true, + projectId: project.id, + decisionReason: `Work item locked: ${lockStatus.reason ?? 'active run exists'}`, + }; } } @@ -139,7 +159,11 @@ export async function processRouterWebhook( ); agentTypeMaxConcurrency = concurrencyCheck.maxConcurrency; if (concurrencyCheck.blocked) { - return { shouldProcess: true, projectId: project.id }; + return { + shouldProcess: true, + projectId: project.id, + decisionReason: 'Agent type concurrency limit reached', + }; } } @@ -170,7 +194,11 @@ export async function processRouterWebhook( eventType: event.eventType, workItemId: event.workItemId, }); - return { shouldProcess: true, projectId: project.id }; + return { + shouldProcess: true, + projectId: project.id, + decisionReason: 'Failed to enqueue job to Redis', + }; } // Step 11: Post acknowledgment comment and patch ack info onto the enqueued job. @@ -199,5 +227,9 @@ export async function processRouterWebhook( } } - return { shouldProcess: true, projectId: project.id }; + return { + shouldProcess: true, + projectId: project.id, + decisionReason: `Job queued: ${result.agentType} agent for work item ${event.workItemId ?? '(unknown)'}`, + }; } diff --git a/src/utils/webhookLogger.ts b/src/utils/webhookLogger.ts index 614d4762..ffb96347 100644 --- a/src/utils/webhookLogger.ts +++ b/src/utils/webhookLogger.ts @@ -17,6 +17,7 @@ export interface WebhookLogInput { projectId?: string; eventType?: string; processed: boolean; + decisionReason?: string; } /** @@ -42,6 +43,7 @@ async function _logWebhookCallAsync(input: WebhookLogInput): Promise { projectId: input.projectId, eventType: input.eventType, processed: input.processed, + decisionReason: input.decisionReason, }); insertCount += 1; diff --git a/src/webhook/webhookHandlers.ts b/src/webhook/webhookHandlers.ts index 6e270541..d0209751 100644 --- a/src/webhook/webhookHandlers.ts +++ b/src/webhook/webhookHandlers.ts @@ -63,6 +63,7 @@ export function createWebhookHandler(config: WebhookHandlerConfig): Handler { statusCode: 400, eventType: parseResult.eventType, processed: false, + decisionReason: `Parse failed: ${parseResult.error}`, }); return c.text('Bad Request', 400); } diff --git a/src/webhook/webhookLogging.ts b/src/webhook/webhookLogging.ts index a881381d..f22697c1 100644 --- a/src/webhook/webhookLogging.ts +++ b/src/webhook/webhookLogging.ts @@ -34,6 +34,7 @@ export function logSuccessfulWebhook( eventType, processed: logOverrides?.processed ?? true, projectId: logOverrides?.projectId, + decisionReason: logOverrides?.decisionReason, }); } diff --git a/src/webhook/webhookTypes.ts b/src/webhook/webhookTypes.ts index b7112ff4..0e2c2c34 100644 --- a/src/webhook/webhookTypes.ts +++ b/src/webhook/webhookTypes.ts @@ -16,6 +16,7 @@ export type ParseResult = export interface WebhookLogOverrides { processed?: boolean; projectId?: string; + decisionReason?: string; } /** diff --git a/tests/unit/router/webhook-processor.test.ts b/tests/unit/router/webhook-processor.test.ts index 17f63d3f..71e8d800 100644 --- a/tests/unit/router/webhook-processor.test.ts +++ b/tests/unit/router/webhook-processor.test.ts @@ -82,6 +82,7 @@ describe('processRouterWebhook', () => { }); const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(false); + expect(result.decisionReason).toBe('Event unparseable or not processable'); expect(addJob).not.toHaveBeenCalled(); }); @@ -91,6 +92,7 @@ describe('processRouterWebhook', () => { }); const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(false); + expect(result.decisionReason).toBe('Event type not processable: commentCard'); expect(addJob).not.toHaveBeenCalled(); }); @@ -100,6 +102,7 @@ describe('processRouterWebhook', () => { }); const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(true); + expect(result.decisionReason).toBe('Self-authored event (loop prevention)'); expect(addJob).not.toHaveBeenCalled(); }); @@ -115,6 +118,7 @@ describe('processRouterWebhook', () => { }); const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(true); + expect(result.decisionReason).toMatch(/No project config for identifier/); expect(addJob).not.toHaveBeenCalled(); }); @@ -124,6 +128,7 @@ describe('processRouterWebhook', () => { }); const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(true); + expect(result.decisionReason).toBe('No trigger matched for event'); expect(addJob).not.toHaveBeenCalled(); }); @@ -138,6 +143,7 @@ describe('processRouterWebhook', () => { const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(true); expect(result.projectId).toBe('p1'); + expect(result.decisionReason).toMatch(/Job queued: implementation agent for work item/); // buildJob is called without ack params (ack is patched after enqueue) expect(adapter.buildJob).toHaveBeenCalledWith( expect.objectContaining({ eventType: 'commentCard' }), @@ -217,6 +223,7 @@ describe('processRouterWebhook', () => { const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(true); + expect(result.decisionReason).toBe('Trigger completed without agent (PM operation)'); expect(addJob).not.toHaveBeenCalled(); }); @@ -240,6 +247,7 @@ describe('processRouterWebhook', () => { // Should not throw const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(true); + expect(result.decisionReason).toBe('Failed to enqueue job to Redis'); }); it('works with adapters that do not implement firePreActions', async () => { @@ -273,6 +281,7 @@ describe('processRouterWebhook', () => { const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(true); expect(result.projectId).toBe('p1'); + expect(result.decisionReason).toBe('Work item locked: db: active run exists'); expect(addJob).not.toHaveBeenCalled(); expect(adapter.postAck).not.toHaveBeenCalled(); }); @@ -309,6 +318,7 @@ describe('processRouterWebhook', () => { const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); expect(result.shouldProcess).toBe(true); + expect(result.decisionReason).toBe('Agent type concurrency limit reached'); expect(addJob).not.toHaveBeenCalled(); }); diff --git a/tests/unit/utils/webhookLogger.test.ts b/tests/unit/utils/webhookLogger.test.ts index 3e837e1e..2e2cb75a 100644 --- a/tests/unit/utils/webhookLogger.test.ts +++ b/tests/unit/utils/webhookLogger.test.ts @@ -70,6 +70,7 @@ describe('logWebhookCall', () => { projectId: undefined, eventType: undefined, processed: true, + decisionReason: undefined, }); }); @@ -92,6 +93,21 @@ describe('logWebhookCall', () => { ); }); + it('passes decisionReason when provided', async () => { + logWebhookCall({ + ...sampleInput, + decisionReason: 'No trigger matched for event', + }); + + await vi.runAllTimersAsync(); + + expect(mockInsertWebhookLog).toHaveBeenCalledWith( + expect.objectContaining({ + decisionReason: 'No trigger matched for event', + }), + ); + }); + it('handles github source', async () => { logWebhookCall({ ...sampleInput, source: 'github' }); diff --git a/tests/unit/webhook/webhookHandlers.test.ts b/tests/unit/webhook/webhookHandlers.test.ts index 9b49cb5c..33bd6b36 100644 --- a/tests/unit/webhook/webhookHandlers.test.ts +++ b/tests/unit/webhook/webhookHandlers.test.ts @@ -76,6 +76,7 @@ describe('createWebhookHandler', () => { statusCode: 400, processed: false, bodyRaw: 'bad json', + decisionReason: 'Parse failed: bad json', }), ); }); @@ -210,6 +211,26 @@ describe('createWebhookHandler', () => { ); }); + it('threads decisionReason from processWebhook return value to log', async () => { + const handler = createWebhookHandler({ + source: 'github', + parsePayload: async () => ({ ok: true, payload: {}, eventType: 'push' }), + processWebhook: vi.fn().mockResolvedValue({ + processed: false, + decisionReason: 'No trigger matched for event', + }), + }); + + const app = buildApp(handler); + await postJson(app, {}); + + expect(mockLogWebhookCall).toHaveBeenCalledWith( + expect.objectContaining({ + decisionReason: 'No trigger matched for event', + }), + ); + }); + it('propagates processWebhook errors to Hono error handler', async () => { const handler = createWebhookHandler({ source: 'jira', diff --git a/web/src/components/webhooklogs/webhooklog-detail-dialog.tsx b/web/src/components/webhooklogs/webhooklog-detail-dialog.tsx index 6ca5faf3..420a5f7d 100644 --- a/web/src/components/webhooklogs/webhooklog-detail-dialog.tsx +++ b/web/src/components/webhooklogs/webhooklog-detail-dialog.tsx @@ -84,6 +84,10 @@ export function WebhookLogDetailDialog({ logId, onClose }: WebhookLogDetailDialo
Received At
{log.receivedAt ? new Date(log.receivedAt).toLocaleString() : '-'}
+
+
Decision Reason
+
{log.decisionReason ?? '-'}
+
{!!log.headers && ( diff --git a/web/src/components/webhooklogs/webhooklogs-table.tsx b/web/src/components/webhooklogs/webhooklogs-table.tsx index eb9a9616..db1aedd1 100644 --- a/web/src/components/webhooklogs/webhooklogs-table.tsx +++ b/web/src/components/webhooklogs/webhooklogs-table.tsx @@ -10,6 +10,7 @@ interface WebhookLog { receivedAt: string | null; method: string; path: string; + decisionReason: string | null; } interface WebhookLogsTableProps { @@ -59,13 +60,14 @@ export function WebhookLogsTable({ Method Status Processed + Reason Time {logs.length === 0 && ( - + No webhook logs found @@ -110,6 +112,12 @@ export function WebhookLogsTable({ )} + + {log.decisionReason ?? '-'} + {formatRelativeTime(log.receivedAt)}