From 16aeb0449d25ae221e60d4522f105f3a0f15b418 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 02:39:41 +0900 Subject: [PATCH] feat(dashboard): run paired inbox actions (#34) --- apps/dashboard/src/App.tsx | 76 ++++++++++++ apps/dashboard/src/api.ts | 16 +++ apps/dashboard/src/i18n.ts | 35 ++++++ src/web-dashboard-server.test.ts | 206 +++++++++++++++++++++++++++++++ src/web-dashboard-server.ts | 160 ++++++++++++++++++++++++ 5 files changed, 493 insertions(+) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 78a752b..422b959 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { + type DashboardInboxAction, type DashboardTaskAction, type DashboardOverview, type DashboardTask, type StatusSnapshot, fetchDashboardData, + runInboxAction, runScheduledTaskAction, sendRoomMessage, } from './api'; @@ -36,6 +38,7 @@ type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled'; type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; type TaskResultTone = 'ok' | 'fail' | 'none'; type TaskActionKey = `${string}:${DashboardTaskAction}`; +type InboxActionKey = `${string}:${DashboardInboxAction}`; type InboxFilter = 'all' | InboxItem['kind']; type HealthLevel = 'ok' | 'stale' | 'down'; @@ -292,6 +295,30 @@ function taskActionsFor(task: DashboardTask): DashboardTaskAction[] { return []; } +function inboxActionsFor(item: InboxItem): DashboardInboxAction[] { + if (item.source !== 'paired-task') return []; + if ( + item.kind === 'reviewer-request' || + item.kind === 'approval' || + item.kind === 'arbiter-request' + ) { + return ['run']; + } + return []; +} + +function inboxActionLabel( + item: InboxItem, + action: DashboardInboxAction, + t: Messages, +): string { + if (action !== 'run') return action; + 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; + return t.inbox.actions.run; +} + const INBOX_FILTERS: InboxFilter[] = [ 'all', 'ci-failure', @@ -600,14 +627,18 @@ function InboxPanel({ overview, tasks, locale, + onInboxAction, onTaskAction, + inboxActionKey, taskActionKey, t, }: { overview: DashboardOverview; tasks: DashboardTask[]; locale: Locale; + onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void; onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void; + inboxActionKey: InboxActionKey | null; taskActionKey: TaskActionKey | null; t: Messages; }) { @@ -690,6 +721,7 @@ function InboxPanel({ const linkedTaskActions = linkedTask ? taskActionsFor(linkedTask) : []; + const inboxActions = inboxActionsFor(item); return (
) : null} + {inboxActions.length > 0 ? ( +
+ {inboxActions.map((action) => { + const actionKey: InboxActionKey = `${item.id}:${action}`; + const busy = inboxActionKey === actionKey; + return ( + + ); + })} +
+ ) : null}
); })} @@ -1429,6 +1482,9 @@ function App() { const [taskActionKey, setTaskActionKey] = useState( null, ); + const [inboxActionKey, setInboxActionKey] = useState( + null, + ); const [roomMessageKey, setRoomMessageKey] = useState(null); const t = messages[locale]; @@ -1478,6 +1534,22 @@ function App() { } } + async function handleInboxAction( + item: InboxItem, + action: DashboardInboxAction, + ) { + const actionKey: InboxActionKey = `${item.id}:${action}`; + setInboxActionKey(actionKey); + try { + await runInboxAction(item.id, action); + await refresh(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setInboxActionKey(null); + } + } + async function handleRoomMessage( roomJid: string, text: string, @@ -1591,7 +1663,11 @@ function App() { {t.panels.inboxQueue} + void handleInboxAction(item, action) + } onTaskAction={(task, action) => void handleTaskAction(task, action) } diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 0083b97..54a4bbf 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -101,6 +101,7 @@ export interface DashboardTask { } export type DashboardTaskAction = 'pause' | 'resume' | 'cancel'; +export type DashboardInboxAction = 'run'; async function fetchJson(path: string): Promise { const response = await fetch(path, { @@ -162,6 +163,21 @@ export async function runScheduledTaskAction( }); } +export async function runInboxAction( + inboxId: string, + action: DashboardInboxAction, +): Promise<{ + ok: true; + id: string; + taskId: string; + intentKind: string; + queued: boolean; +}> { + return postJson(`/api/inbox/${encodeURIComponent(inboxId)}/actions`, { + action, + }); +} + export async function sendRoomMessage( roomJid: string, text: string, diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index 7bb267d..9a12442 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -104,6 +104,13 @@ export interface Messages { warn: string; error: string; }; + actions: { + run: string; + busy: string; + runReview: string; + finalize: string; + runArbiter: string; + }; }; rooms: { empty: string; @@ -324,6 +331,13 @@ export const messages = { warn: '주의', error: '위험', }, + actions: { + run: 'Run', + busy: 'Running...', + runReview: 'Run review', + finalize: 'Finalize', + runArbiter: 'Run arbiter', + }, }, rooms: { empty: '룸 없음.', @@ -528,6 +542,13 @@ export const messages = { warn: 'Warn', error: 'Risk', }, + actions: { + run: 'Run', + busy: 'Running...', + runReview: 'Run review', + finalize: 'Finalize', + runArbiter: 'Run arbiter', + }, }, rooms: { empty: 'No rooms yet.', @@ -732,6 +753,13 @@ export const messages = { warn: '关注', error: '风险', }, + actions: { + run: '运行', + busy: '运行中...', + runReview: '运行评审', + finalize: '完成', + runArbiter: '运行仲裁', + }, }, rooms: { empty: '暂无房间。', @@ -936,6 +964,13 @@ export const messages = { warn: '注意', error: '危険', }, + actions: { + run: '実行', + busy: '実行中...', + runReview: 'レビュー実行', + finalize: 'Finalize', + runArbiter: '仲裁実行', + }, }, rooms: { empty: 'ルームなし。', diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index 25bb230..59d2cf7 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -349,6 +349,212 @@ describe('web dashboard server handler', () => { expect(wrongMethod.status).toBe(405); }); + it('queues paired inbox actions through the paired follow-up scheduler', async () => { + const pairedTasks = new Map([ + [ + 'review-1', + makePairedTask({ + id: 'review-1', + status: 'review_ready', + updated_at: '2026-04-26T05:01:00.000Z', + }), + ], + [ + 'merge-1', + makePairedTask({ + id: 'merge-1', + status: 'merge_ready', + updated_at: '2026-04-26T05:02:00.000Z', + }), + ], + [ + 'arbiter-1', + makePairedTask({ + id: 'arbiter-1', + status: 'arbiter_requested', + updated_at: '2026-04-26T05:03:00.000Z', + }), + ], + ]); + const scheduled: Array<{ + chatJid: string; + taskId: string; + intentKind: string; + }> = []; + const queued: Array<{ chatJid: string; groupFolder: string }> = []; + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [...pairedTasks.values()], + getPairedTaskById: (id) => pairedTasks.get(id), + schedulePairedFollowUp: (args) => { + scheduled.push({ + chatJid: args.chatJid, + taskId: args.task.id, + intentKind: args.intentKind, + }); + args.enqueue(); + return true; + }, + enqueueMessageCheck: (chatJid, groupFolder) => { + queued.push({ chatJid, groupFolder }); + }, + }); + const run = (inboxId: string) => + handler( + new Request( + `http://localhost/api/inbox/${encodeURIComponent(inboxId)}/actions`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'run' }), + }, + ), + ); + + const reviewer = await run('paired:review-1:review_ready'); + const finalize = await run('paired:merge-1:merge_ready'); + const arbiter = await run('paired:arbiter-1:arbiter_requested'); + + expect(reviewer.status).toBe(200); + expect(finalize.status).toBe(200); + expect(arbiter.status).toBe(200); + await expect(reviewer.json()).resolves.toMatchObject({ + ok: true, + taskId: 'review-1', + intentKind: 'reviewer-turn', + queued: true, + }); + await expect(finalize.json()).resolves.toMatchObject({ + ok: true, + taskId: 'merge-1', + intentKind: 'finalize-owner-turn', + queued: true, + }); + await expect(arbiter.json()).resolves.toMatchObject({ + ok: true, + taskId: 'arbiter-1', + intentKind: 'arbiter-turn', + queued: true, + }); + expect(scheduled).toEqual([ + { + chatJid: 'dc:general', + taskId: 'review-1', + intentKind: 'reviewer-turn', + }, + { + chatJid: 'dc:general', + taskId: 'merge-1', + intentKind: 'finalize-owner-turn', + }, + { + chatJid: 'dc:general', + taskId: 'arbiter-1', + intentKind: 'arbiter-turn', + }, + ]); + expect(queued).toEqual([ + { chatJid: 'dc:general', groupFolder: 'general' }, + { chatJid: 'dc:general', groupFolder: 'general' }, + { chatJid: 'dc:general', groupFolder: 'general' }, + ]); + }); + + it('rejects invalid paired inbox actions', async () => { + const pairedTask = makePairedTask({ + id: 'paired-1', + status: 'review_ready', + }); + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [pairedTask], + getPairedTaskById: (id) => + id === pairedTask.id ? pairedTask : undefined, + schedulePairedFollowUp: () => { + throw new Error('schedulePairedFollowUp should not be called'); + }, + enqueueMessageCheck: () => undefined, + }); + const post = (inboxId: string, body: unknown = { action: 'run' }) => + handler( + new Request( + `http://localhost/api/inbox/${encodeURIComponent(inboxId)}/actions`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, + ), + ); + + const invalidBody = await post('paired:paired-1:review_ready', { + action: 'approve', + }); + expect(invalidBody.status).toBe(400); + + const unsupportedTarget = await post('ci:task-1'); + expect(unsupportedTarget.status).toBe(400); + + const missingTask = await post('paired:missing:review_ready'); + expect(missingTask.status).toBe(404); + + const stale = await post('paired:paired-1:merge_ready'); + expect(stale.status).toBe(409); + + const completedHandler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [], + getPairedTaskById: () => + makePairedTask({ id: 'done-1', status: 'completed' }), + schedulePairedFollowUp: () => { + throw new Error('schedulePairedFollowUp should not be called'); + }, + enqueueMessageCheck: () => undefined, + }); + const completed = await completedHandler( + new Request( + 'http://localhost/api/inbox/paired%3Adone-1%3Acompleted/actions', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'run' }), + }, + ), + ); + expect(completed.status).toBe(409); + + const wrongMethod = await handler( + new Request( + 'http://localhost/api/inbox/paired%3Apaired-1%3Areview_ready/actions', + { + method: 'GET', + }, + ), + ); + expect(wrongMethod.status).toBe(405); + + const notWired = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [], + getPairedTaskById: () => pairedTask, + }); + const noQueue = await notWired( + new Request( + 'http://localhost/api/inbox/paired%3Apaired-1%3Areview_ready/actions', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'run' }), + }, + ), + ); + expect(noQueue.status).toBe(503); + }); + it('injects room messages and queues room work from the web dashboard', async () => { const messages: NewMessage[] = []; const metadata: Array<{ diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index a1f28e6..dd48223 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -6,6 +6,7 @@ import { deleteTask, getAllOpenPairedTasks, getAllTasks, + getPairedTaskById, getTaskById, hasMessage, storeChatMetadata, @@ -13,6 +14,8 @@ import { updateTask, } 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, @@ -31,6 +34,19 @@ import { const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; +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 interface WebDashboardHandlerOptions { staticDir?: string; statusMaxAgeMs?: number; @@ -43,6 +59,8 @@ export interface WebDashboardHandlerOptions { ) => void; deleteTask?: (id: string) => void; getPairedTasks?: () => PairedTask[]; + getPairedTaskById?: (id: string) => PairedTask | undefined; + schedulePairedFollowUp?: WebPairedFollowUpScheduler; getRoomBindings?: () => Record; storeChatMetadata?: ( chatJid: string, @@ -134,6 +152,7 @@ function serveStaticFile(staticDir: string, pathname: string): Response { } type TaskAction = 'pause' | 'resume' | 'cancel'; +type InboxAction = 'run'; function parseTaskActionPath(pathname: string): string | null { const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/); @@ -145,6 +164,16 @@ function parseTaskActionPath(pathname: string): 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 parseRoomMessagePath(pathname: string): string | null { const match = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/); if (!match) return null; @@ -155,10 +184,41 @@ function parseRoomMessagePath(pathname: string): string | 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 isTaskAction(value: unknown): value is TaskAction { return value === 'pause' || value === 'resume' || value === 'cancel'; } +function isInboxAction(value: unknown): value is InboxAction { + return value === 'run'; +} + +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 readTaskAction(request: Request): Promise { try { const body = (await request.json()) as { action?: unknown }; @@ -168,6 +228,15 @@ async function readTaskAction(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; + } catch { + return null; + } +} + function sanitizeRoomMessageRequestId(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -204,6 +273,25 @@ function makeWebMessageIdFromRequest(requestId: string | null): string { return requestId ? `web-${requestId}` : makeWebMessageId(); } +function makeWebRunId(prefix: string): string { + return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +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 function createWebDashboardHandler( opts: WebDashboardHandlerOptions = {}, ): (request: Request) => Response | Promise { @@ -213,6 +301,9 @@ export function createWebDashboardHandler( const mutateTask = opts.updateTask ?? updateTask; const removeTask = opts.deleteTask ?? deleteTask; const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks; + const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById; + const schedulePairedFollowUp = + opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent; const loadRoomBindings = opts.getRoomBindings; const writeChatMetadata = opts.storeChatMetadata ?? storeChatMetadata; const writeMessage = opts.storeMessage ?? storeMessage; @@ -236,6 +327,7 @@ export function createWebDashboardHandler( return async (request: Request): Promise => { const url = new URL(request.url); const actionTaskId = parseTaskActionPath(url.pathname); + const actionInboxId = parseInboxActionPath(url.pathname); const messageRoomJid = parseRoomMessagePath(url.pathname); if (actionTaskId) { @@ -277,6 +369,74 @@ export function createWebDashboardHandler( }); } + if (actionInboxId) { + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + + const action = await readInboxAction(request); + if (!action) { + return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 }); + } + + 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 (task.status !== pairedTarget.status) { + return jsonResponse( + { + error: 'Inbox item is stale', + status: task.status, + }, + { status: 409 }, + ); + } + + 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, + }); + } + if (messageRoomJid) { if (request.method !== 'POST') { return jsonResponse({ error: 'Method not allowed' }, { status: 405 });