From 38c3430156660f271edad15cde69c24e398b1b1f Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 03:34:36 +0900 Subject: [PATCH] Add dashboard inbox decline and dismiss actions Add dashboard inbox decline and dismiss actions --- apps/dashboard/src/App.tsx | 28 +++-- apps/dashboard/src/api.ts | 14 ++- apps/dashboard/src/i18n.ts | 15 +++ src/web-dashboard-server.test.ts | 181 +++++++++++++++++++++++++++++ src/web-dashboard-server.ts | 193 ++++++++++++++++++++++++++++--- 5 files changed, 404 insertions(+), 27 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index b0423d0..127a82c 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -390,15 +390,15 @@ function readTaskForm( } function inboxActionsFor(item: InboxItem): DashboardInboxAction[] { - if (item.source !== 'paired-task') return []; if ( - item.kind === 'reviewer-request' || - item.kind === 'approval' || - item.kind === 'arbiter-request' + item.source === 'paired-task' && + (item.kind === 'reviewer-request' || + item.kind === 'approval' || + item.kind === 'arbiter-request') ) { - return ['run']; + return ['run', 'decline', 'dismiss']; } - return []; + return ['dismiss']; } function inboxActionLabel( @@ -406,7 +406,8 @@ function inboxActionLabel( action: DashboardInboxAction, t: Messages, ): string { - if (action !== 'run') return action; + if (action === 'dismiss') return t.inbox.actions.dismiss; + if (action === 'decline') return t.inbox.actions.decline; if (item.kind === 'reviewer-request') return t.inbox.actions.runReview; if (item.kind === 'approval') return t.inbox.actions.finalize; if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter; @@ -1785,10 +1786,21 @@ function App() { item: InboxItem, action: DashboardInboxAction, ) { + if ( + action === 'decline' && + typeof window !== 'undefined' && + !window.confirm(t.inbox.actions.confirmDecline) + ) { + return; + } + const actionKey: InboxActionKey = `${item.id}:${action}`; setInboxActionKey(actionKey); try { - await runInboxAction(item.id, action); + await runInboxAction(item.id, action, { + lastOccurredAt: item.lastOccurredAt, + requestId: makeClientRequestId(), + }); await refresh(false); } catch (err) { setError(err instanceof Error ? err.message : String(err)); diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index bb603d4..271e952 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -101,7 +101,7 @@ export interface DashboardTask { } export type DashboardTaskAction = 'pause' | 'resume' | 'cancel'; -export type DashboardInboxAction = 'run'; +export type DashboardInboxAction = 'run' | 'decline' | 'dismiss'; export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once'; export type DashboardTaskContextMode = 'group' | 'isolated'; @@ -214,15 +214,21 @@ export async function updateScheduledTask( export async function runInboxAction( inboxId: string, action: DashboardInboxAction, + options: { requestId?: string; lastOccurredAt?: string } = {}, ): Promise<{ ok: true; id: string; - taskId: string; - intentKind: string; - queued: boolean; + taskId?: string; + intentKind?: string; + status?: string; + queued?: boolean; + dismissed?: boolean; + duplicate?: boolean; }> { return postJson(`/api/inbox/${encodeURIComponent(inboxId)}/actions`, { action, + lastOccurredAt: options.lastOccurredAt, + requestId: options.requestId, }); } diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index 80fd09a..e864818 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -110,6 +110,9 @@ export interface Messages { runReview: string; finalize: string; runArbiter: string; + decline: string; + dismiss: string; + confirmDecline: string; }; }; rooms: { @@ -358,6 +361,9 @@ export const messages = { runReview: 'Run review', finalize: 'Finalize', runArbiter: 'Run arbiter', + decline: 'Decline', + dismiss: 'Dismiss', + confirmDecline: 'Decline this item?', }, }, rooms: { @@ -590,6 +596,9 @@ export const messages = { runReview: 'Run review', finalize: 'Finalize', runArbiter: 'Run arbiter', + decline: 'Decline', + dismiss: 'Dismiss', + confirmDecline: 'Decline this item?', }, }, rooms: { @@ -822,6 +831,9 @@ export const messages = { runReview: '运行评审', finalize: '完成', runArbiter: '运行仲裁', + decline: '拒绝', + dismiss: '忽略', + confirmDecline: '确认拒绝此项?', }, }, rooms: { @@ -1054,6 +1066,9 @@ export const messages = { runReview: 'レビュー実行', finalize: 'Finalize', runArbiter: '仲裁実行', + decline: '拒否', + dismiss: '非表示', + confirmDecline: 'この項目を拒否しますか?', }, }, rooms: { diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index 6d913c1..1fe99cb 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -238,6 +238,79 @@ describe('web dashboard server handler', () => { expect(ciFailure?.summary).not.toContain('plain-secret-value'); }); + it('dismisses inbox items until they change', async () => { + const snapshots: StatusSnapshot[] = [ + { + serviceId: 'codex-main', + agentType: 'codex', + assistantName: 'Codex', + updatedAt: '2026-04-26T05:00:00.000Z', + entries: [ + { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex', + status: 'waiting', + elapsedMs: 2500, + pendingMessages: true, + pendingTasks: 1, + }, + ], + }, + ]; + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => snapshots, + getTasks: () => [], + getPairedTasks: () => [], + now: () => '2026-04-26T05:10:00.000Z', + }); + + const firstOverview = await handler( + new Request('http://localhost/api/overview'), + ); + expect(firstOverview.status).toBe(200); + const firstBody = (await firstOverview.json()) as { + inbox: Array<{ id: string; lastOccurredAt: string }>; + }; + expect(firstBody.inbox).toHaveLength(1); + + const dismiss = await handler( + new Request( + `http://localhost/api/inbox/${encodeURIComponent(firstBody.inbox[0]!.id)}/actions`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'dismiss', + lastOccurredAt: firstBody.inbox[0]!.lastOccurredAt, + requestId: 'dismiss-1', + }), + }, + ), + ); + expect(dismiss.status).toBe(200); + + const dismissedOverview = await handler( + new Request('http://localhost/api/overview'), + ); + expect(dismissedOverview.status).toBe(200); + await expect(dismissedOverview.json()).resolves.toMatchObject({ + inbox: [], + }); + + snapshots[0] = { + ...snapshots[0]!, + updatedAt: '2026-04-26T05:01:00.000Z', + }; + const changedOverview = await handler( + new Request('http://localhost/api/overview'), + ); + expect(changedOverview.status).toBe(200); + const changedBody = (await changedOverview.json()) as { inbox: unknown[] }; + expect(changedBody.inbox).toHaveLength(1); + }); + it('mutates scheduled tasks through explicit action endpoints', async () => { let task: ScheduledTask | undefined = makeTask({ id: 'ci-watch-1', @@ -744,6 +817,114 @@ describe('web dashboard server handler', () => { ]); }); + it('declines paired inbox actions back to the owner queue', async () => { + const pairedTasks = new Map([ + [ + 'merge-1', + makePairedTask({ + id: 'merge-1', + status: 'merge_ready', + updated_at: '2026-04-26T05:02:00.000Z', + }), + ], + ]); + const messages: NewMessage[] = []; + const metadata: Array<{ + chatJid: string; + timestamp: string; + channel?: string; + isGroup?: boolean; + }> = []; + const queued: Array<{ chatJid: string; groupFolder: string }> = []; + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [...pairedTasks.values()], + getPairedTaskById: (id) => pairedTasks.get(id), + updatePairedTaskIfUnchanged: (id, expectedUpdatedAt, updates) => { + const task = pairedTasks.get(id); + if (!task || task.updated_at !== expectedUpdatedAt) return false; + pairedTasks.set(id, { ...task, ...updates }); + return true; + }, + schedulePairedFollowUp: () => { + throw new Error('schedulePairedFollowUp should not be called'); + }, + storeChatMetadata: (chatJid, timestamp, _name, channel, isGroup) => { + metadata.push({ chatJid, timestamp, channel, isGroup }); + }, + storeMessage: (message) => { + messages.push(message); + }, + hasMessage: (chatJid, id) => + messages.some( + (message) => message.chat_jid === chatJid && message.id === id, + ), + enqueueMessageCheck: (chatJid, groupFolder) => { + queued.push({ chatJid, groupFolder }); + }, + now: () => '2026-04-26T05:15:00.000Z', + }); + const decline = () => + handler( + new Request( + 'http://localhost/api/inbox/paired%3Amerge-1%3Amerge_ready/actions', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'decline', + requestId: 'decline-merge-1', + }), + }, + ), + ); + + const response = await decline(); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + taskId: 'merge-1', + status: 'active', + queued: true, + }); + expect(pairedTasks.get('merge-1')).toMatchObject({ + status: 'active', + updated_at: '2026-04-26T05:15:00.000Z', + }); + expect(metadata).toEqual([ + { + chatJid: 'dc:general', + timestamp: '2026-04-26T05:15:00.000Z', + channel: 'web-dashboard', + isGroup: true, + }, + ]); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + id: 'web-inbox-decline-merge-1', + chat_jid: 'dc:general', + sender: 'web-dashboard', + sender_name: 'Web Dashboard', + content: 'Dashboard declined finalization. Continue with the owner turn.', + timestamp: '2026-04-26T05:15:00.000Z', + is_from_me: false, + is_bot_message: false, + message_source_kind: 'ipc_injected_human', + }); + expect(queued).toEqual([{ chatJid: 'dc:general', groupFolder: 'general' }]); + + const duplicate = await decline(); + expect(duplicate.status).toBe(200); + await expect(duplicate.json()).resolves.toMatchObject({ + ok: true, + duplicate: true, + queued: false, + }); + expect(messages).toHaveLength(1); + expect(queued).toHaveLength(1); + }); + it('rejects invalid paired inbox actions', async () => { const pairedTask = makePairedTask({ id: 'paired-1', diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 0bd750e..16f0fcd 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -14,6 +14,7 @@ import { hasMessage, storeChatMetadata, storeMessage, + updatePairedTaskIfUnchanged, updateTask, } from './db.js'; import { logger } from './logger.js'; @@ -89,6 +90,16 @@ export interface WebDashboardHandlerOptions { deleteTask?: (id: string) => void; getPairedTasks?: () => PairedTask[]; getPairedTaskById?: (id: string) => PairedTask | undefined; + updatePairedTaskIfUnchanged?: ( + id: string, + expectedUpdatedAt: string, + updates: Partial< + Pick< + PairedTask, + 'status' | 'updated_at' | 'arbiter_requested_at' | 'completion_reason' + > + >, + ) => boolean; schedulePairedFollowUp?: WebPairedFollowUpScheduler; getRoomBindings?: () => Record; storeChatMetadata?: ( @@ -182,7 +193,13 @@ function serveStaticFile(staticDir: string, pathname: string): Response { } type TaskAction = 'pause' | 'resume' | 'cancel'; -type InboxAction = 'run'; +type InboxAction = 'run' | 'decline' | 'dismiss'; + +interface InboxActionRequest { + action: InboxAction; + requestId: string | null; + lastOccurredAt: string | null; +} function parseTaskPath(pathname: string): string | null { const match = pathname.match(/^\/api\/tasks\/([^/]+)$/); @@ -258,7 +275,7 @@ function isAgentType(value: unknown): value is AgentType { } function isInboxAction(value: unknown): value is InboxAction { - return value === 'run'; + return value === 'run' || value === 'decline' || value === 'dismiss'; } function isPairedTaskStatus(value: unknown): value is PairedTask['status'] { @@ -282,10 +299,24 @@ async function readTaskAction(request: Request): Promise { } } -async function readInboxAction(request: Request): Promise { +async function readInboxAction( + request: Request, +): Promise { try { - const body = (await request.json()) as { action?: unknown }; - return isInboxAction(body.action) ? body.action : null; + 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; } @@ -433,6 +464,40 @@ 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 { @@ -459,6 +524,8 @@ export function createWebDashboardHandler( const removeTask = opts.deleteTask ?? deleteTask; const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks; const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById; + const mutatePairedTaskIfUnchanged = + opts.updatePairedTaskIfUnchanged ?? updatePairedTaskIfUnchanged; const schedulePairedFollowUp = opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent; const loadRoomBindings = opts.getRoomBindings; @@ -470,6 +537,7 @@ export function createWebDashboardHandler( const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; const seenRoomMessageIds: string[] = []; const seenRoomMessageIdSet = new Set(); + const dismissedInboxKeys = new Set(); function rememberRoomMessageId(id: string): boolean { if (seenRoomMessageIdSet.has(id)) return false; @@ -482,6 +550,16 @@ export function createWebDashboardHandler( return true; } + 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 actionTaskId = parseTaskActionPath(url.pathname); @@ -533,11 +611,18 @@ export function createWebDashboardHandler( return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); } - const action = await readInboxAction(request); - if (!action) { + 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( @@ -561,6 +646,19 @@ export function createWebDashboardHandler( ); } + 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( { @@ -571,6 +669,69 @@ export function createWebDashboardHandler( ); } + 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( @@ -825,14 +986,16 @@ export function createWebDashboardHandler( const snapshots = readSnapshots(statusMaxAgeMs); const tasks = loadTasks(); const pairedTasks = loadPairedTasks(); - return jsonResponse( - buildWebDashboardOverview({ - now: opts.now?.(), - snapshots, - tasks, - pairedTasks, - }), - ); + const overview = buildWebDashboardOverview({ + now: opts.now?.(), + snapshots, + tasks, + pairedTasks, + }); + return jsonResponse({ + ...overview, + inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)), + }); } if (url.pathname === '/api/status-snapshots') {