From 23056f7cc7dc7dee1dadda1880b1a488c553df2e Mon Sep 17 00:00:00 2001 From: ejclaw Date: Tue, 28 Apr 2026 19:34:43 +0900 Subject: [PATCH] Extract dashboard inbox action route --- src/web-dashboard-inbox-routes.test.ts | 253 +++++++++++++++++ src/web-dashboard-inbox-routes.ts | 379 +++++++++++++++++++++++++ src/web-dashboard-server.ts | 326 ++------------------- 3 files changed, 655 insertions(+), 303 deletions(-) create mode 100644 src/web-dashboard-inbox-routes.test.ts create mode 100644 src/web-dashboard-inbox-routes.ts diff --git a/src/web-dashboard-inbox-routes.test.ts b/src/web-dashboard-inbox-routes.test.ts new file mode 100644 index 0000000..476571a --- /dev/null +++ b/src/web-dashboard-inbox-routes.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest'; + +import type { NewMessage, PairedTask } from './types.js'; +import { + createInboxDismissTracker, + handleInboxActionRoute, + type InboxActionRouteDependencies, +} from './web-dashboard-inbox-routes.js'; + +function makePairedTask(overrides: Partial): PairedTask { + return { + id: 'paired-1', + chat_jid: 'dc:general', + group_folder: 'general', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude-reviewer', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + title: 'Dashboard PR', + source_ref: null, + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: 0, + task_done_then_user_reopen_count: 0, + empty_step_done_streak: 0, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-26T04:00:00.000Z', + updated_at: '2026-04-26T04:30:00.000Z', + ...overrides, + }; +} + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { 'content-type': 'application/json', ...init?.headers }, + }); +} + +function inboxRequest( + inboxId: string, + body: unknown, + method = 'POST', +): Request { + return new Request( + `http://localhost/api/inbox/${encodeURIComponent(inboxId)}/actions`, + { + method, + headers: { 'content-type': 'application/json' }, + body: method === 'POST' ? JSON.stringify(body) : undefined, + }, + ); +} + +function makeDeps( + task: PairedTask | undefined, + overrides: Partial = {}, +): InboxActionRouteDependencies { + return { + dismissTracker: createInboxDismissTracker(), + enqueueMessageCheck: () => undefined, + loadPairedTaskById: (id) => (id === task?.id ? task : undefined), + messageExists: () => false, + mutatePairedTaskIfUnchanged: () => true, + schedulePairedFollowUp: () => true, + writeChatMetadata: () => undefined, + writeMessage: () => undefined, + ...overrides, + }; +} + +describe('web dashboard inbox action routes', () => { + it('dismisses inbox items and queues paired follow-up intents', async () => { + const dismissTracker = createInboxDismissTracker(); + const task = makePairedTask({ id: 'merge-1', status: 'merge_ready' }); + const scheduled: Array<{ taskId: string; intentKind: string }> = []; + const queued: Array<{ chatJid: string; groupFolder: string }> = []; + const deps = makeDeps(task, { + dismissTracker, + enqueueMessageCheck: (chatJid, groupFolder) => { + queued.push({ chatJid, groupFolder }); + }, + schedulePairedFollowUp: (args) => { + scheduled.push({ + taskId: args.task.id, + intentKind: args.intentKind, + }); + args.enqueue(); + return true; + }, + }); + + const fallThrough = await handleInboxActionRoute({ + ...deps, + jsonResponse, + request: new Request('http://localhost/api/overview'), + url: new URL('http://localhost/api/overview'), + }); + expect(fallThrough).toBeNull(); + + const dismiss = await handleInboxActionRoute({ + ...deps, + jsonResponse, + request: inboxRequest('ci:task-1', { + action: 'dismiss', + lastOccurredAt: '2026-04-26T05:00:00.000Z', + }), + url: new URL('http://localhost/api/inbox/ci%3Atask-1/actions'), + }); + expect(dismiss?.status).toBe(200); + expect( + dismissTracker.isDismissed({ + id: 'ci:task-1', + lastOccurredAt: '2026-04-26T05:00:00.000Z', + }), + ).toBe(true); + + const run = await handleInboxActionRoute({ + ...deps, + jsonResponse, + request: inboxRequest('paired:merge-1:merge_ready', { action: 'run' }), + url: new URL( + 'http://localhost/api/inbox/paired%3Amerge-1%3Amerge_ready/actions', + ), + }); + expect(run?.status).toBe(200); + await expect(run?.json()).resolves.toMatchObject({ + intentKind: 'finalize-owner-turn', + ok: true, + queued: true, + taskId: 'merge-1', + }); + expect(scheduled).toEqual([ + { taskId: 'merge-1', intentKind: 'finalize-owner-turn' }, + ]); + expect(queued).toEqual([{ chatJid: 'dc:general', groupFolder: 'general' }]); + }); + + it('declines paired tasks and rejects invalid inbox actions', async () => { + const task = makePairedTask({ + id: 'arbiter-1', + status: 'arbiter_requested', + arbiter_requested_at: '2026-04-26T05:00:00.000Z', + }); + const messages: NewMessage[] = []; + const updates: unknown[] = []; + const deps = makeDeps(task, { + enqueueMessageCheck: () => undefined, + messageExists: (chatJid, id) => + messages.some( + (message) => message.chat_jid === chatJid && message.id === id, + ), + mutatePairedTaskIfUnchanged: (_id, _expectedUpdatedAt, update) => { + updates.push(update); + return true; + }, + writeMessage: (message) => { + messages.push(message); + }, + }); + + const decline = () => + handleInboxActionRoute({ + ...deps, + jsonResponse, + now: () => '2026-04-26T05:15:00.000Z', + request: inboxRequest('paired:arbiter-1:arbiter_requested', { + action: 'decline', + requestId: 'decline 1', + }), + url: new URL( + 'http://localhost/api/inbox/paired%3Aarbiter-1%3Aarbiter_requested/actions', + ), + }); + + const response = await decline(); + expect(response?.status).toBe(200); + await expect(response?.json()).resolves.toMatchObject({ + ok: true, + queued: true, + status: 'active', + taskId: 'arbiter-1', + }); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ + arbiter_requested_at: null, + status: 'active', + updated_at: '2026-04-26T05:15:00.000Z', + }); + expect(messages[0]).toMatchObject({ + id: 'web-inbox-decline-1', + content: + 'Dashboard declined arbiter escalation. Continue with the owner turn.', + message_source_kind: 'ipc_injected_human', + }); + + const duplicate = await decline(); + expect(duplicate?.status).toBe(200); + await expect(duplicate?.json()).resolves.toMatchObject({ + duplicate: true, + queued: false, + }); + expect(messages).toHaveLength(1); + + const invalidBody = await handleInboxActionRoute({ + ...deps, + jsonResponse, + request: inboxRequest('paired:arbiter-1:active', { action: 'approve' }), + url: new URL( + 'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions', + ), + }); + expect(invalidBody?.status).toBe(400); + + const stale = await handleInboxActionRoute({ + ...deps, + jsonResponse, + request: inboxRequest('paired:arbiter-1:merge_ready', { action: 'run' }), + url: new URL( + 'http://localhost/api/inbox/paired%3Aarbiter-1%3Amerge_ready/actions', + ), + }); + expect(stale?.status).toBe(409); + + const notWired = await handleInboxActionRoute({ + ...deps, + enqueueMessageCheck: undefined, + jsonResponse, + request: inboxRequest('paired:arbiter-1:active', { action: 'run' }), + url: new URL( + 'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions', + ), + }); + expect(notWired?.status).toBe(503); + + const wrongMethod = await handleInboxActionRoute({ + ...deps, + jsonResponse, + request: inboxRequest('paired:arbiter-1:active', null, 'GET'), + url: new URL( + 'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions', + ), + }); + expect(wrongMethod?.status).toBe(405); + }); +}); diff --git a/src/web-dashboard-inbox-routes.ts b/src/web-dashboard-inbox-routes.ts new file mode 100644 index 0000000..fb85a45 --- /dev/null +++ b/src/web-dashboard-inbox-routes.ts @@ -0,0 +1,379 @@ +import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js'; +import type { NewMessage, PairedTask } from './types.js'; + +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + +type InboxAction = 'run' | 'decline' | 'dismiss'; + +interface InboxActionRequest { + action: InboxAction; + requestId: string | null; + lastOccurredAt: string | null; +} + +type InboxFollowUpTask = Pick< + PairedTask, + 'id' | 'status' | 'round_trip_count' | 'updated_at' +>; + +export type InboxFollowUpScheduler = (args: { + chatJid: string; + runId: string; + task: InboxFollowUpTask; + intentKind: ScheduledPairedFollowUpIntentKind; + enqueue: () => void; +}) => boolean; + +export interface InboxDismissTracker { + dismiss: (inboxId: string, lastOccurredAt: string | null) => void; + isDismissed: (item: { id: string; lastOccurredAt: string }) => boolean; +} + +export interface InboxActionRouteDependencies { + dismissTracker: InboxDismissTracker; + enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void; + loadPairedTaskById: (id: string) => PairedTask | undefined; + messageExists: (chatJid: string, id: string) => boolean; + mutatePairedTaskIfUnchanged: ( + id: string, + expectedUpdatedAt: string, + updates: Partial< + Pick< + PairedTask, + 'status' | 'updated_at' | 'arbiter_requested_at' | 'completion_reason' + > + >, + ) => boolean; + schedulePairedFollowUp: InboxFollowUpScheduler; + writeChatMetadata: ( + chatJid: string, + timestamp: string, + name?: string, + channel?: string, + isGroup?: boolean, + ) => void; + writeMessage: (message: NewMessage) => void; +} + +interface InboxActionRouteContext extends InboxActionRouteDependencies { + jsonResponse: JsonResponse; + now?: () => string; + request: Request; + url: URL; +} + +export function createInboxDismissTracker(): InboxDismissTracker { + const dismissedInboxKeys = new Set(); + return { + dismiss: (inboxId, lastOccurredAt) => { + dismissedInboxKeys.add(makeInboxDismissKey(inboxId, lastOccurredAt)); + }, + isDismissed: (item) => + dismissedInboxKeys.has(item.id) || + dismissedInboxKeys.has(makeInboxDismissKey(item.id, item.lastOccurredAt)), + }; +} + +function parseInboxActionPath(pathname: string): string | null { + const match = pathname.match(/^\/api\/inbox\/([^/]+)\/actions$/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + +function parsePairedInboxTarget( + inboxId: string, +): { taskId: string; status: PairedTask['status'] } | null { + if (!inboxId.startsWith('paired:')) return null; + const rest = inboxId.slice('paired:'.length); + const separatorIndex = rest.lastIndexOf(':'); + if (separatorIndex <= 0 || separatorIndex === rest.length - 1) return null; + const status = rest.slice(separatorIndex + 1); + if (!isPairedTaskStatus(status)) return null; + return { + taskId: rest.slice(0, separatorIndex), + status, + }; +} + +function isInboxAction(value: unknown): value is InboxAction { + return value === 'run' || value === 'decline' || value === 'dismiss'; +} + +function isPairedTaskStatus(value: unknown): value is PairedTask['status'] { + return ( + value === 'active' || + value === 'review_ready' || + value === 'in_review' || + value === 'merge_ready' || + value === 'completed' || + value === 'arbiter_requested' || + value === 'in_arbitration' + ); +} + +async function readInboxAction( + request: Request, +): Promise { + try { + const body = (await request.json()) as { + action?: unknown; + requestId?: unknown; + lastOccurredAt?: unknown; + }; + if (!isInboxAction(body.action)) return null; + return { + action: body.action, + requestId: sanitizeInboxActionRequestId(body.requestId), + lastOccurredAt: + typeof body.lastOccurredAt === 'string' && body.lastOccurredAt.trim() + ? body.lastOccurredAt.trim() + : null, + }; + } catch { + return null; + } +} + +function makeWebRunId(prefix: string): string { + return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function sanitizeInboxActionRequestId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); + return safe || null; +} + +function makeInboxDismissKey( + inboxId: string, + lastOccurredAt: string | null, +): string { + return lastOccurredAt ? `${inboxId}\0${lastOccurredAt}` : inboxId; +} + +function makeWebInboxMessageId(requestId: string | null): string { + return requestId + ? `web-inbox-${requestId}` + : `web-inbox-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function declinedInboxMessage(status: PairedTask['status']): string { + if (status === 'review_ready' || status === 'in_review') { + return 'Dashboard declined review. Continue with the owner turn.'; + } + if (status === 'merge_ready') { + return 'Dashboard declined finalization. Continue with the owner turn.'; + } + if (status === 'arbiter_requested' || status === 'in_arbitration') { + return 'Dashboard declined arbiter escalation. Continue with the owner turn.'; + } + return 'Dashboard declined this inbox item. Continue with the owner turn.'; +} + +function pairedFollowUpIntentForStatus( + status: PairedTask['status'], +): ScheduledPairedFollowUpIntentKind | null { + if (status === 'review_ready' || status === 'in_review') { + return 'reviewer-turn'; + } + if (status === 'merge_ready') { + return 'finalize-owner-turn'; + } + if (status === 'arbiter_requested' || status === 'in_arbitration') { + return 'arbiter-turn'; + } + return null; +} + +export async function handleInboxActionRoute({ + dismissTracker, + enqueueMessageCheck, + jsonResponse, + loadPairedTaskById, + messageExists, + mutatePairedTaskIfUnchanged, + now, + request, + schedulePairedFollowUp, + url, + writeChatMetadata, + writeMessage, +}: InboxActionRouteContext): Promise { + const actionInboxId = parseInboxActionPath(url.pathname); + if (!actionInboxId) return null; + + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + + const inboxRequest = await readInboxAction(request); + if (!inboxRequest) { + return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 }); + } + + if (inboxRequest.action === 'dismiss') { + dismissTracker.dismiss(actionInboxId, inboxRequest.lastOccurredAt); + return jsonResponse({ ok: true, id: actionInboxId, dismissed: true }); + } + + const pairedTarget = parsePairedInboxTarget(actionInboxId); + if (!pairedTarget) { + return jsonResponse( + { error: 'Unsupported inbox action target' }, + { status: 400 }, + ); + } + + if (!enqueueMessageCheck) { + return jsonResponse( + { error: 'Paired follow-up queue is not configured' }, + { status: 503 }, + ); + } + + const task = loadPairedTaskById(pairedTarget.taskId); + if (!task) { + return jsonResponse({ error: 'Paired task not found' }, { status: 404 }); + } + + if (inboxRequest.action === 'decline') { + const messageId = makeWebInboxMessageId(inboxRequest.requestId); + if (inboxRequest.requestId && messageExists(task.chat_jid, messageId)) { + return jsonResponse({ + ok: true, + id: actionInboxId, + taskId: task.id, + queued: false, + duplicate: true, + }); + } + } + + if (task.status !== pairedTarget.status) { + return jsonResponse( + { + error: 'Inbox item is stale', + status: task.status, + }, + { status: 409 }, + ); + } + + if (inboxRequest.action === 'decline') { + return handleDeclineInboxAction({ + actionInboxId, + enqueueMessageCheck, + jsonResponse, + messageId: makeWebInboxMessageId(inboxRequest.requestId), + mutatePairedTaskIfUnchanged, + now, + task, + writeChatMetadata, + writeMessage, + }); + } + + const intentKind = pairedFollowUpIntentForStatus(task.status); + if (!intentKind) { + return jsonResponse( + { error: 'Paired task is not actionable' }, + { status: 409 }, + ); + } + + const queued = schedulePairedFollowUp({ + chatJid: task.chat_jid, + runId: makeWebRunId('inbox'), + task, + intentKind, + enqueue: () => enqueueMessageCheck(task.chat_jid, task.group_folder), + }); + + return jsonResponse({ + ok: true, + id: actionInboxId, + taskId: task.id, + intentKind, + queued, + }); +} + +function handleDeclineInboxAction({ + actionInboxId, + enqueueMessageCheck, + jsonResponse, + messageId, + mutatePairedTaskIfUnchanged, + now, + task, + writeChatMetadata, + writeMessage, +}: { + actionInboxId: string; + enqueueMessageCheck: (chatJid: string, groupFolder: string) => void; + jsonResponse: JsonResponse; + messageId: string; + mutatePairedTaskIfUnchanged: InboxActionRouteDependencies['mutatePairedTaskIfUnchanged']; + now?: () => string; + task: PairedTask; + writeChatMetadata: InboxActionRouteDependencies['writeChatMetadata']; + writeMessage: InboxActionRouteDependencies['writeMessage']; +}): Response { + const timestamp = now?.() ?? new Date().toISOString(); + const updates: Partial< + Pick< + PairedTask, + 'status' | 'updated_at' | 'arbiter_requested_at' | 'completion_reason' + > + > = { + status: 'active', + updated_at: timestamp, + }; + if (task.status === 'arbiter_requested' || task.status === 'in_arbitration') { + updates.arbiter_requested_at = null; + } + + const updated = mutatePairedTaskIfUnchanged( + task.id, + task.updated_at, + updates, + ); + if (!updated) { + return jsonResponse( + { error: 'Inbox item is stale', status: task.status }, + { status: 409 }, + ); + } + + writeChatMetadata(task.chat_jid, timestamp, undefined, 'web-dashboard', true); + writeMessage({ + id: messageId, + chat_jid: task.chat_jid, + sender: 'web-dashboard', + sender_name: 'Web Dashboard', + content: declinedInboxMessage(task.status), + timestamp, + is_from_me: false, + is_bot_message: false, + message_source_kind: 'ipc_injected_human', + }); + enqueueMessageCheck(task.chat_jid, task.group_folder); + + return jsonResponse({ + ok: true, + id: actionInboxId, + taskId: task.id, + status: 'active', + queued: true, + }); +} diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 31eaf8c..c12354c 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -26,7 +26,6 @@ import { } from './db.js'; import { logger } from './logger.js'; import { schedulePairedFollowUpIntent } from './message-runtime-follow-up.js'; -import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js'; import { readStatusSnapshots, type StatusSnapshot, @@ -38,6 +37,11 @@ import type { RegisteredGroup, ScheduledTask, } from './types.js'; +import { + createInboxDismissTracker, + handleInboxActionRoute, + type InboxFollowUpScheduler, +} from './web-dashboard-inbox-routes.js'; import { handleOverviewRoute } from './web-dashboard-overview-routes.js'; import { createRoomMessageIdCache, @@ -63,19 +67,6 @@ const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = const UNIT_NOT_FOUND_PATTERN = /(Unit .* not found|Could not find the requested service|not-found)/i; -type PairedFollowUpTask = Pick< - PairedTask, - 'id' | 'status' | 'round_trip_count' | 'updated_at' ->; - -type WebPairedFollowUpScheduler = (args: { - chatJid: string; - runId: string; - task: PairedFollowUpTask; - intentKind: ScheduledPairedFollowUpIntentKind; - enqueue: () => void; -}) => boolean; - export type { ServiceRestartRecord } from './web-dashboard-service-routes.js'; export interface WebDashboardHandlerOptions { @@ -132,7 +123,7 @@ export interface WebDashboardHandlerOptions { > >, ) => boolean; - schedulePairedFollowUp?: WebPairedFollowUpScheduler; + schedulePairedFollowUp?: InboxFollowUpScheduler; getRoomBindings?: () => Record; storeChatMetadata?: ( chatJid: string, @@ -244,131 +235,6 @@ function serveStaticFile(staticDir: string, pathname: string): Response { }); } -type InboxAction = 'run' | 'decline' | 'dismiss'; - -interface InboxActionRequest { - action: InboxAction; - requestId: string | null; - lastOccurredAt: string | null; -} - -function parseInboxActionPath(pathname: string): string | null { - const match = pathname.match(/^\/api\/inbox\/([^/]+)\/actions$/); - if (!match) return null; - try { - return decodeURIComponent(match[1]); - } catch { - return null; - } -} - -function parsePairedInboxTarget( - inboxId: string, -): { taskId: string; status: PairedTask['status'] } | null { - if (!inboxId.startsWith('paired:')) return null; - const rest = inboxId.slice('paired:'.length); - const separatorIndex = rest.lastIndexOf(':'); - if (separatorIndex <= 0 || separatorIndex === rest.length - 1) return null; - const status = rest.slice(separatorIndex + 1); - if (!isPairedTaskStatus(status)) return null; - return { - taskId: rest.slice(0, separatorIndex), - status, - }; -} - -function isInboxAction(value: unknown): value is InboxAction { - return value === 'run' || value === 'decline' || value === 'dismiss'; -} - -function isPairedTaskStatus(value: unknown): value is PairedTask['status'] { - return ( - value === 'active' || - value === 'review_ready' || - value === 'in_review' || - value === 'merge_ready' || - value === 'completed' || - value === 'arbiter_requested' || - value === 'in_arbitration' - ); -} - -async function readInboxAction( - request: Request, -): Promise { - try { - const body = (await request.json()) as { - action?: unknown; - requestId?: unknown; - lastOccurredAt?: unknown; - }; - if (!isInboxAction(body.action)) return null; - return { - action: body.action, - requestId: sanitizeInboxActionRequestId(body.requestId), - lastOccurredAt: - typeof body.lastOccurredAt === 'string' && body.lastOccurredAt.trim() - ? body.lastOccurredAt.trim() - : null, - }; - } catch { - return null; - } -} - -function makeWebRunId(prefix: string): string { - return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function sanitizeInboxActionRequestId(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); - return safe || null; -} - -function makeInboxDismissKey( - inboxId: string, - lastOccurredAt: string | null, -): string { - return lastOccurredAt ? `${inboxId}\0${lastOccurredAt}` : inboxId; -} - -function makeWebInboxMessageId(requestId: string | null): string { - return requestId - ? `web-inbox-${requestId}` - : `web-inbox-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function declinedInboxMessage(status: PairedTask['status']): string { - if (status === 'review_ready' || status === 'in_review') { - return 'Dashboard declined review. Continue with the owner turn.'; - } - if (status === 'merge_ready') { - return 'Dashboard declined finalization. Continue with the owner turn.'; - } - if (status === 'arbiter_requested' || status === 'in_arbitration') { - return 'Dashboard declined arbiter escalation. Continue with the owner turn.'; - } - return 'Dashboard declined this inbox item. Continue with the owner turn.'; -} - -function pairedFollowUpIntentForStatus( - status: PairedTask['status'], -): ScheduledPairedFollowUpIntentKind | null { - if (status === 'review_ready' || status === 'in_review') { - return 'reviewer-turn'; - } - if (status === 'merge_ready') { - return 'finalize-owner-turn'; - } - if (status === 'arbiter_requested' || status === 'in_arbitration') { - return 'arbiter-turn'; - } - return null; -} - function isRoot(): boolean { return typeof process.getuid === 'function' && process.getuid() === 0; } @@ -477,23 +343,12 @@ export function createWebDashboardHandler( } const rememberRoomMessageId = createRoomMessageIdCache(); - const dismissedInboxKeys = new Set(); + const inboxDismissTracker = createInboxDismissTracker(); const recentServiceRestarts: ServiceRestartRecord[] = []; const activeServiceRestartTargets = new Set(); - function isInboxItemDismissed(item: { - id: string; - lastOccurredAt: string; - }): boolean { - return ( - dismissedInboxKeys.has(item.id) || - dismissedInboxKeys.has(makeInboxDismissKey(item.id, item.lastOccurredAt)) - ); - } - return async (request: Request): Promise => { const url = new URL(request.url); - const actionInboxId = parseInboxActionPath(url.pathname); const scheduledTaskRoute = await handleScheduledTaskRoute({ url, @@ -509,156 +364,21 @@ export function createWebDashboardHandler( }); if (scheduledTaskRoute) return scheduledTaskRoute; - if (actionInboxId) { - if (request.method !== 'POST') { - return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); - } - - const inboxRequest = await readInboxAction(request); - if (!inboxRequest) { - return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 }); - } - - if (inboxRequest.action === 'dismiss') { - dismissedInboxKeys.add( - makeInboxDismissKey(actionInboxId, inboxRequest.lastOccurredAt), - ); - return jsonResponse({ ok: true, id: actionInboxId, dismissed: true }); - } - - const pairedTarget = parsePairedInboxTarget(actionInboxId); - if (!pairedTarget) { - return jsonResponse( - { error: 'Unsupported inbox action target' }, - { status: 400 }, - ); - } - - if (!enqueueMessageCheck) { - return jsonResponse( - { error: 'Paired follow-up queue is not configured' }, - { status: 503 }, - ); - } - - const task = loadPairedTaskById(pairedTarget.taskId); - if (!task) { - return jsonResponse( - { error: 'Paired task not found' }, - { status: 404 }, - ); - } - - if (inboxRequest.action === 'decline') { - const messageId = makeWebInboxMessageId(inboxRequest.requestId); - if (inboxRequest.requestId && messageExists(task.chat_jid, messageId)) { - return jsonResponse({ - ok: true, - id: actionInboxId, - taskId: task.id, - queued: false, - duplicate: true, - }); - } - } - - if (task.status !== pairedTarget.status) { - return jsonResponse( - { - error: 'Inbox item is stale', - status: task.status, - }, - { status: 409 }, - ); - } - - if (inboxRequest.action === 'decline') { - const timestamp = opts.now?.() ?? new Date().toISOString(); - const messageId = makeWebInboxMessageId(inboxRequest.requestId); - const updates: Partial< - Pick< - PairedTask, - | 'status' - | 'updated_at' - | 'arbiter_requested_at' - | 'completion_reason' - > - > = { - status: 'active', - updated_at: timestamp, - }; - if ( - task.status === 'arbiter_requested' || - task.status === 'in_arbitration' - ) { - updates.arbiter_requested_at = null; - } - - const updated = mutatePairedTaskIfUnchanged( - task.id, - task.updated_at, - updates, - ); - if (!updated) { - return jsonResponse( - { error: 'Inbox item is stale', status: task.status }, - { status: 409 }, - ); - } - - writeChatMetadata( - task.chat_jid, - timestamp, - undefined, - 'web-dashboard', - true, - ); - writeMessage({ - id: messageId, - chat_jid: task.chat_jid, - sender: 'web-dashboard', - sender_name: 'Web Dashboard', - content: declinedInboxMessage(task.status), - timestamp, - is_from_me: false, - is_bot_message: false, - message_source_kind: 'ipc_injected_human', - }); - enqueueMessageCheck(task.chat_jid, task.group_folder); - - return jsonResponse({ - ok: true, - id: actionInboxId, - taskId: task.id, - status: 'active', - queued: true, - }); - } - - const intentKind = pairedFollowUpIntentForStatus(task.status); - if (!intentKind) { - return jsonResponse( - { error: 'Paired task is not actionable' }, - { status: 409 }, - ); - } - - const queued = schedulePairedFollowUp({ - chatJid: task.chat_jid, - runId: makeWebRunId('inbox'), - task, - intentKind, - enqueue: () => enqueueMessageCheck(task.chat_jid, task.group_folder), - }); - - return jsonResponse({ - ok: true, - id: actionInboxId, - taskId: task.id, - intentKind, - queued, - }); - } + const inboxActionRoute = await handleInboxActionRoute({ + url, + request, + jsonResponse, + dismissTracker: inboxDismissTracker, + enqueueMessageCheck, + loadPairedTaskById, + messageExists, + mutatePairedTaskIfUnchanged, + schedulePairedFollowUp, + writeChatMetadata, + writeMessage, + now: opts.now, + }); + if (inboxActionRoute) return inboxActionRoute; const roomMessageRoute = await handleRoomMessageRoute({ url, @@ -716,7 +436,7 @@ export function createWebDashboardHandler( const overviewRoute = handleOverviewRoute({ url, jsonResponse, - isInboxItemDismissed, + isInboxItemDismissed: inboxDismissTracker.isDismissed, loadPairedTasks, loadTasks, readSnapshots,