From d28068f646bf19f8edc9a3bec2df2ee71058dd3e Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 11:46:35 +0900 Subject: [PATCH] Add dashboard room activity timeline --- apps/dashboard/src/App.tsx | 192 +++++++++++++++++++++++++++++++ apps/dashboard/src/api.ts | 59 ++++++++++ apps/dashboard/src/i18n.ts | 70 +++++++++++ apps/dashboard/src/styles.css | 169 +++++++++++++++++++++++++++ src/web-dashboard-data.test.ts | 128 ++++++++++++++++++++- src/web-dashboard-data.ts | 182 ++++++++++++++++++++++++++++- src/web-dashboard-server.test.ts | 160 ++++++++++++++++++++++++++ src/web-dashboard-server.ts | 73 ++++++++++++ 8 files changed, 1031 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index f8dcd61..1380aeb 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { type CreateScheduledTaskInput, type DashboardInboxAction, + type DashboardRoomActivity, type DashboardTaskContextMode, type DashboardTaskScheduleType, type DashboardTaskAction, @@ -12,6 +13,7 @@ import { type StatusSnapshot, createScheduledTask, fetchDashboardData, + fetchRoomTimeline, runInboxAction, runServiceAction, runScheduledTaskAction, @@ -36,6 +38,8 @@ interface DashboardState { tasks: DashboardTask[]; } +type RoomActivityMap = Record; + type UsageRow = DashboardOverview['usage']['rows'][number]; type InboxItem = DashboardOverview['inbox'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; @@ -1289,9 +1293,131 @@ function RoomMessageForm({ ); } +function RoomActivityPanel({ + activity, + loading, + locale, + t, +}: { + activity: DashboardRoomActivity | undefined; + loading: boolean; + locale: Locale; + t: Messages; +}) { + if (!activity) { + return ( +
+ {loading ? t.rooms.loadingActivity : t.rooms.noActivity} +
+ ); + } + + const task = activity.pairedTask; + const turn = task?.currentTurn ?? null; + const outputs = task?.outputs ?? []; + + return ( +
+
+ + {t.rooms.task} + {task?.title || task?.id || t.rooms.noTask} + + + {t.rooms.currentTurn} + + {turn + ? `${turn.role} · ${t.rooms.attempt} ${turn.attemptNo}` + : t.rooms.noTurn} + + + + {t.rooms.round} + {task ? task.roundTripCount : '-'} + + + {t.rooms.updated} + + {formatDate(turn?.updatedAt ?? task?.updatedAt, locale)} + + +
+ + {turn ? ( +
+ + {statusLabel(turn.state, t)} + + {turn.intentKind} + {turn.executorServiceId ? ( + {turn.executorServiceId} + ) : null} + {turn.activeRunId ? ( + {turn.activeRunId} + ) : null} +
+ ) : null} + + {turn?.lastError ? ( +

{turn.lastError}

+ ) : null} + +
+
+ {t.rooms.output} + {outputs.length === 0 ? ( +

{t.rooms.noOutput}

+ ) : ( +
+ {[...outputs].reverse().map((output) => ( +
+
+ + #{output.turnNumber} · {output.role} + {output.verdict ? ` · ${output.verdict}` : ''} + + +
+

{output.outputText}

+
+ ))} +
+ )} +
+
+ {t.rooms.recentMessages} + {activity.messages.length === 0 ? ( +

{t.rooms.noMessages}

+ ) : ( +
+ {activity.messages.map((message) => ( +
+
+ {message.senderName} + +
+

{message.content}

+
+ ))} +
+ )} +
+
+
+ ); +} + function RoomPanel({ onSendRoomMessage, + roomActivity, + roomActivityLoading, roomMessageKey, + locale, snapshots, t, }: { @@ -1300,7 +1426,10 @@ function RoomPanel({ text: string, requestId: string, ) => Promise; + roomActivity: RoomActivityMap; + roomActivityLoading: boolean; roomMessageKey: string | null; + locale: Locale; snapshots: StatusSnapshot[]; t: Messages; }) { @@ -1339,6 +1468,7 @@ function RoomPanel({ {t.rooms.status} {t.rooms.queue} {t.rooms.elapsed} + {t.rooms.activity} {t.rooms.message} @@ -1364,6 +1494,14 @@ function RoomPanel({ {queueLabel(entry.pendingTasks, entry.pendingMessages, t)} {formatDuration(entry.elapsedMs, t)} + + + {formatDuration(entry.elapsedMs, t)} + setDraft(entry.jid, value)} @@ -1950,6 +2094,8 @@ function App() { const [serviceActionKey, setServiceActionKey] = useState(null); const [roomMessageKey, setRoomMessageKey] = useState(null); + const [roomActivity, setRoomActivity] = useState({}); + const [roomActivityLoading, setRoomActivityLoading] = useState(false); const t = messages[locale]; const secureContext = typeof window === 'undefined' ? true : window.isSecureContext; @@ -2196,6 +2342,49 @@ function App() { return () => window.clearInterval(id); }, []); + useEffect(() => { + if (activeView !== 'rooms' || !data) return; + + const jids = [ + ...new Set( + data.snapshots.flatMap((snapshot) => + snapshot.entries.map((entry) => entry.jid), + ), + ), + ]; + if (jids.length === 0) { + setRoomActivity({}); + return; + } + + let cancelled = false; + setRoomActivityLoading(true); + void Promise.all( + jids.map(async (jid) => { + try { + return [jid, await fetchRoomTimeline(jid)] as const; + } catch { + return [jid, null] as const; + } + }), + ) + .then((entries) => { + if (cancelled) return; + const next: RoomActivityMap = {}; + for (const [jid, activity] of entries) { + if (activity) next[jid] = activity; + } + setRoomActivity(next); + }) + .finally(() => { + if (!cancelled) setRoomActivityLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [activeView, data]); + useEffect(() => { if (!drawerOpen) return; @@ -2335,7 +2524,10 @@ function App() { diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 34021a2..dab4a71 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -92,6 +92,57 @@ export interface StatusSnapshot { }>; } +export interface DashboardRoomActivity { + serviceId: string; + jid: string; + name: string; + folder: string; + agentType: string; + status: 'processing' | 'waiting' | 'inactive'; + elapsedMs: number | null; + pendingMessages: boolean; + pendingTasks: number; + messages: Array<{ + id: string; + sender: string; + senderName: string; + content: string; + timestamp: string; + isFromMe: boolean; + isBotMessage: boolean; + sourceKind: string; + }>; + pairedTask: { + id: string; + title: string | null; + status: string; + roundTripCount: number; + updatedAt: string; + currentTurn: { + turnId: string; + role: string; + intentKind: string; + state: string; + attemptNo: number; + executorServiceId: string | null; + executorAgentType: string | null; + activeRunId: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; + lastError: string | null; + } | null; + outputs: Array<{ + id: number; + turnNumber: number; + role: string; + verdict: string | null; + createdAt: string; + outputText: string; + }>; + } | null; +} + export interface DashboardTask { id: string; groupFolder: string; @@ -180,6 +231,14 @@ export async function fetchDashboardData(): Promise<{ return { overview, snapshots, tasks }; } +export async function fetchRoomTimeline( + roomJid: string, +): Promise { + return fetchJson( + `/api/rooms/${encodeURIComponent(roomJid)}/timeline`, + ); +} + export async function runScheduledTaskAction( taskId: string, action: DashboardTaskAction, diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index 836e66b..b1c8df4 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -147,6 +147,20 @@ export interface Messages { status: string; queue: string; elapsed: string; + activity: string; + loadingActivity: string; + noActivity: string; + task: string; + noTask: string; + currentTurn: string; + noTurn: string; + round: string; + attempt: string; + updated: string; + output: string; + noOutput: string; + recentMessages: string; + noMessages: string; details: string; message: string; messagePlaceholder: string; @@ -421,6 +435,20 @@ export const messages = { status: '상태', queue: '큐', elapsed: '경과', + activity: '진행', + loadingActivity: '진행 로딩 중', + noActivity: '진행 없음', + task: '태스크', + noTask: '태스크 없음', + currentTurn: '현재 턴', + noTurn: '턴 없음', + round: '라운드', + attempt: '시도', + updated: '갱신', + output: '출력', + noOutput: '출력 없음', + recentMessages: '최근 메시지', + noMessages: '메시지 없음', details: '세부', message: 'Message', messagePlaceholder: 'Type request...', @@ -679,6 +707,20 @@ export const messages = { status: 'status', queue: 'queue', elapsed: 'elapsed', + activity: 'activity', + loadingActivity: 'Loading activity', + noActivity: 'No activity', + task: 'task', + noTask: 'No task', + currentTurn: 'Current turn', + noTurn: 'No turn', + round: 'round', + attempt: 'attempt', + updated: 'updated', + output: 'output', + noOutput: 'No output', + recentMessages: 'Recent messages', + noMessages: 'No messages', details: 'details', message: 'message', messagePlaceholder: 'Type request...', @@ -937,6 +979,20 @@ export const messages = { status: '状态', queue: '队列', elapsed: '耗时', + activity: '进展', + loadingActivity: '正在加载进展', + noActivity: '暂无进展', + task: '任务', + noTask: '无任务', + currentTurn: '当前回合', + noTurn: '无回合', + round: '轮次', + attempt: '尝试', + updated: '更新', + output: '输出', + noOutput: '暂无输出', + recentMessages: '最近消息', + noMessages: '暂无消息', details: '详情', message: 'Message', messagePlaceholder: 'Type request...', @@ -1195,6 +1251,20 @@ export const messages = { status: '状態', queue: 'キュー', elapsed: '経過', + activity: '進行', + loadingActivity: '進行を読み込み中', + noActivity: '進行なし', + task: 'タスク', + noTask: 'タスクなし', + currentTurn: '現在ターン', + noTurn: 'ターンなし', + round: 'ラウンド', + attempt: '試行', + updated: '更新', + output: '出力', + noOutput: '出力なし', + recentMessages: '最近のメッセージ', + noMessages: 'メッセージなし', details: '詳細', message: 'Message', messagePlaceholder: 'Type request...', diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index c781d43..d4f8e82 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -1327,6 +1327,165 @@ progress::-moz-progress-bar { opacity: 0.44; } +.room-activity-cell { + min-width: 360px; +} + +.room-activity { + display: grid; + min-width: min(360px, 100%); + gap: 9px; + padding: 10px; + border: 1px solid rgba(43, 55, 38, 0.1); + border-radius: 18px; + background: rgba(255, 250, 240, 0.58); +} + +.room-activity-empty, +.room-muted { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.room-activity-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; +} + +.room-activity-grid > span { + min-width: 0; + padding: 8px; + border-radius: 13px; + background: rgba(43, 55, 38, 0.045); +} + +.room-activity-grid small, +.room-activity-grid strong { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.room-activity-grid small { + color: var(--muted); + font-size: 10px; + font-weight: 850; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.room-activity-grid strong { + margin-top: 3px; + font-size: 12px; +} + +.room-turn-line { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.room-turn-line .mono-chip { + margin-top: 0; +} + +.room-activity-error { + margin: 0; + color: #7c2518; + font-size: 12px; + font-weight: 850; + overflow-wrap: anywhere; +} + +.room-activity-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 9px; +} + +.room-activity-columns > section { + display: grid; + min-width: 0; + align-content: start; + gap: 7px; +} + +.room-activity-columns > section > strong { + color: var(--bg-ink); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.room-output-list, +.room-message-list { + display: grid; + gap: 7px; +} + +.room-output-item, +.room-message-item { + min-width: 0; + padding: 8px; + border-radius: 13px; + background: rgba(43, 55, 38, 0.045); +} + +.room-message-bot { + background: rgba(191, 95, 44, 0.08); +} + +.room-output-item header, +.room-message-item header { + display: flex; + gap: 8px; + align-items: baseline; + justify-content: space-between; + color: var(--muted); + font-size: 11px; + font-weight: 850; +} + +.room-output-item header span, +.room-message-item header span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.room-output-item time, +.room-message-item time { + flex: none; +} + +.room-output-item p, +.room-message-item p, +.room-muted { + margin: 0; +} + +.room-output-item p, +.room-message-item p { + display: -webkit-box; + overflow: hidden; + margin-top: 5px; + color: var(--ink); + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; +} + .task-board { display: grid; gap: 12px; @@ -2001,6 +2160,16 @@ progress::-moz-progress-bar { grid-template-columns: 1fr 1fr; } + .room-activity, + .room-activity-cell { + min-width: 0; + } + + .room-activity-grid, + .room-activity-columns { + grid-template-columns: 1fr; + } + .inbox-summary, .health-signals { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts index 1831e68..6632451 100644 --- a/src/web-dashboard-data.test.ts +++ b/src/web-dashboard-data.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it } from 'vitest'; import type { StatusSnapshot } from './status-dashboard.js'; -import type { PairedTask, ScheduledTask } from './types.js'; +import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; +import type { + NewMessage, + PairedTask, + PairedTurnOutput, + ScheduledTask, +} from './types.js'; import { + buildWebDashboardRoomActivity, buildWebDashboardOverview, sanitizeScheduledTask, } from './web-dashboard-data.js'; @@ -230,6 +237,125 @@ describe('web dashboard data', () => { expect(sanitized.promptPreview).not.toContain('plain-secret-value'); }); + it('builds redacted room activity from messages and paired turn output', () => { + const task = makePairedTask({ + id: 'paired-room-1', + chat_jid: 'dc:ops', + status: 'in_review', + round_trip_count: 3, + updated_at: '2026-04-26T05:30:00.000Z', + }); + const turn: PairedTurnRecord = { + turn_id: 'turn-1', + task_id: task.id, + task_updated_at: task.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'queued', + executor_service_id: null, + executor_agent_type: null, + attempt_no: 0, + created_at: '2026-04-26T05:19:00.000Z', + updated_at: '2026-04-26T05:31:00.000Z', + completed_at: null, + last_error: null, + }; + const attempt: PairedTurnAttemptRecord = { + attempt_id: 'turn-1:attempt:2', + parent_attempt_id: null, + parent_handoff_id: null, + continuation_handoff_id: null, + turn_id: 'turn-1', + task_id: task.id, + task_updated_at: task.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'running', + executor_service_id: 'claude-reviewer', + executor_agent_type: 'claude-code', + active_run_id: 'run-reviewer-1', + attempt_no: 2, + created_at: '2026-04-26T05:20:00.000Z', + updated_at: '2026-04-26T05:31:00.000Z', + completed_at: null, + last_error: 'OPENAI_API_KEY=plain-secret-value', + }; + const outputs: PairedTurnOutput[] = [ + { + id: 1, + task_id: task.id, + turn_number: 1, + role: 'owner', + output_text: 'owner output', + verdict: 'step_done', + created_at: '2026-04-26T05:25:00.000Z', + }, + { + id: 2, + task_id: task.id, + turn_number: 2, + role: 'reviewer', + output_text: 'reviewer output with BOT_TOKEN=plain-secret-value', + verdict: null, + created_at: '2026-04-26T05:30:00.000Z', + }, + ]; + const messages: NewMessage[] = [ + { + id: 'msg-1', + chat_jid: 'dc:ops', + sender: 'user-1', + sender_name: '눈쟁이', + content: 'latest message', + timestamp: '2026-04-26T05:29:00.000Z', + is_from_me: false, + is_bot_message: false, + message_source_kind: 'human', + }, + ]; + + const activity = buildWebDashboardRoomActivity({ + serviceId: 'codex-main', + entry: { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex', + status: 'processing', + elapsedMs: 15_000, + pendingMessages: true, + pendingTasks: 1, + }, + pairedTask: task, + turns: [turn], + attempts: [attempt], + outputs, + messages, + outputLimit: 1, + }); + + expect(activity.pairedTask).toMatchObject({ + id: 'paired-room-1', + roundTripCount: 3, + currentTurn: { + role: 'reviewer', + state: 'running', + attemptNo: 2, + lastError: 'OPENAI_API_KEY=', + }, + outputs: [ + { + turnNumber: 2, + role: 'reviewer', + outputText: 'reviewer output with BOT_TOKEN=', + }, + ], + }); + expect(activity.messages).toMatchObject([ + { senderName: '눈쟁이', content: 'latest message' }, + ]); + }); + it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => { const snapshots: StatusSnapshot[] = [ { diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index 239c5c7..06b9029 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -1,6 +1,12 @@ import { isWatchCiTask } from './task-watch-status.js'; +import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js'; -import type { PairedTask, ScheduledTask } from './types.js'; +import type { + NewMessage, + PairedTask, + PairedTurnOutput, + ScheduledTask, +} from './types.js'; export interface SanitizedScheduledTask { id: string; @@ -57,6 +63,63 @@ export interface WebDashboardOverview { inbox: InboxItem[]; } +export interface WebDashboardRoomMessage { + id: string; + sender: string; + senderName: string; + content: string; + timestamp: string; + isFromMe: boolean; + isBotMessage: boolean; + sourceKind: NonNullable; +} + +export interface WebDashboardRoomTurn { + turnId: string; + role: PairedTurnRecord['role']; + intentKind: PairedTurnRecord['intent_kind']; + state: PairedTurnRecord['state']; + attemptNo: number; + executorServiceId: string | null; + executorAgentType: PairedTurnRecord['executor_agent_type']; + activeRunId: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; + lastError: string | null; +} + +export interface WebDashboardRoomTurnOutput { + id: number; + turnNumber: number; + role: PairedTurnOutput['role']; + verdict: PairedTurnOutput['verdict'] | null; + createdAt: string; + outputText: string; +} + +export interface WebDashboardRoomActivity { + serviceId: string; + jid: string; + name: string; + folder: string; + agentType: StatusSnapshot['agentType']; + status: StatusSnapshot['entries'][number]['status']; + elapsedMs: number | null; + pendingMessages: boolean; + pendingTasks: number; + messages: WebDashboardRoomMessage[]; + pairedTask: { + id: string; + title: string | null; + status: PairedTask['status']; + roundTripCount: number; + updatedAt: string; + currentTurn: WebDashboardRoomTurn | null; + outputs: WebDashboardRoomTurnOutput[]; + } | null; +} + export type InboxItemKind = | 'pending-room' | 'reviewer-request' @@ -91,6 +154,8 @@ const SECRET_ASSIGNMENT_RE = /\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi; const SECRET_VALUE_RE = /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g; +const ROOM_MESSAGE_PREVIEW_MAX_LENGTH = 900; +const ROOM_OUTPUT_PREVIEW_MAX_LENGTH = 1800; function redactSensitiveText(value: string): string { return value @@ -111,6 +176,10 @@ function buildInboxPreview(value: string): string { return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim()); } +function buildRoomPreview(value: string, maxLength: number): string { + return truncateText(redactSensitiveText(value).trim(), maxLength); +} + function stableHash(value: string): string { let hash = 0; for (let index = 0; index < value.length; index += 1) { @@ -334,6 +403,117 @@ export function sanitizeScheduledTask( }; } +function sanitizeRoomMessage(message: NewMessage): WebDashboardRoomMessage { + return { + id: message.id, + sender: message.sender, + senderName: message.sender_name || message.sender, + content: buildRoomPreview( + message.content ?? '', + ROOM_MESSAGE_PREVIEW_MAX_LENGTH, + ), + timestamp: message.timestamp, + isFromMe: !!message.is_from_me, + isBotMessage: !!message.is_bot_message, + sourceKind: message.message_source_kind ?? 'human', + }; +} + +function sanitizeRoomTurn( + turn: PairedTurnRecord, + attempt: PairedTurnAttemptRecord | null, +): WebDashboardRoomTurn { + return { + turnId: turn.turn_id, + role: attempt?.role ?? turn.role, + intentKind: attempt?.intent_kind ?? turn.intent_kind, + state: attempt?.state ?? turn.state, + attemptNo: attempt?.attempt_no ?? turn.attempt_no, + executorServiceId: attempt?.executor_service_id ?? turn.executor_service_id, + executorAgentType: attempt?.executor_agent_type ?? turn.executor_agent_type, + activeRunId: attempt?.active_run_id ?? null, + createdAt: attempt?.created_at ?? turn.created_at, + updatedAt: attempt?.updated_at ?? turn.updated_at, + completedAt: attempt?.completed_at ?? turn.completed_at, + lastError: + (attempt?.last_error ?? turn.last_error) + ? buildRoomPreview( + attempt?.last_error ?? turn.last_error ?? '', + ROOM_MESSAGE_PREVIEW_MAX_LENGTH, + ) + : null, + }; +} + +function sanitizeRoomTurnOutput( + output: PairedTurnOutput, +): WebDashboardRoomTurnOutput { + return { + id: output.id, + turnNumber: output.turn_number, + role: output.role, + verdict: output.verdict ?? null, + createdAt: output.created_at, + outputText: buildRoomPreview( + output.output_text, + ROOM_OUTPUT_PREVIEW_MAX_LENGTH, + ), + }; +} + +export function buildWebDashboardRoomActivity(args: { + serviceId: string; + entry: StatusSnapshot['entries'][number]; + pairedTask: PairedTask | null; + turns: PairedTurnRecord[]; + attempts: PairedTurnAttemptRecord[]; + outputs: PairedTurnOutput[]; + messages: NewMessage[]; + outputLimit?: number; +}): WebDashboardRoomActivity { + const latestAttemptByTurnId = new Map(); + for (const attempt of args.attempts) { + const previous = latestAttemptByTurnId.get(attempt.turn_id); + if (!previous || attempt.attempt_no > previous.attempt_no) { + latestAttemptByTurnId.set(attempt.turn_id, attempt); + } + } + const currentTurn = + [...args.turns].sort((a, b) => + b.updated_at.localeCompare(a.updated_at), + )[0] ?? null; + const currentAttempt = currentTurn + ? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null) + : null; + const outputLimit = args.outputLimit ?? 4; + + return { + serviceId: args.serviceId, + jid: args.entry.jid, + name: args.entry.name, + folder: args.entry.folder, + agentType: args.entry.agentType, + status: args.entry.status, + elapsedMs: args.entry.elapsedMs, + pendingMessages: args.entry.pendingMessages, + pendingTasks: args.entry.pendingTasks, + messages: args.messages.map(sanitizeRoomMessage), + pairedTask: args.pairedTask + ? { + id: args.pairedTask.id, + title: args.pairedTask.title, + status: args.pairedTask.status, + roundTripCount: args.pairedTask.round_trip_count, + updatedAt: args.pairedTask.updated_at, + currentTurn: currentTurn + ? sanitizeRoomTurn(currentTurn, currentAttempt) + : null, + outputs: args.outputs.slice(-outputLimit).map(sanitizeRoomTurnOutput), + } + : null, + }; +} + export function buildWebDashboardOverview(args: { now?: string; snapshots: StatusSnapshot[]; diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index e40cc5d..2615947 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -5,9 +5,11 @@ import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; import type { StatusSnapshot } from './status-dashboard.js'; +import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; import type { NewMessage, PairedTask, + PairedTurnOutput, RegisteredGroup, ScheduledTask, } from './types.js'; @@ -1432,6 +1434,164 @@ describe('web dashboard server handler', () => { expect(response.status).toBe(503); }); + it('serves room timeline with paired turn progress and recent messages', async () => { + const pairedTask = makePairedTask({ + id: 'paired-room-1', + chat_jid: 'dc:ops', + group_folder: 'ops-room', + status: 'in_review', + round_trip_count: 2, + updated_at: '2026-04-26T05:20:00.000Z', + }); + const turns: PairedTurnRecord[] = [ + { + turn_id: 'paired-room-1:reviewer-turn', + task_id: pairedTask.id, + task_updated_at: pairedTask.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'queued', + executor_service_id: null, + executor_agent_type: null, + attempt_no: 0, + created_at: '2026-04-26T05:18:30.000Z', + updated_at: '2026-04-26T05:21:00.000Z', + completed_at: null, + last_error: null, + }, + ]; + const attempts: PairedTurnAttemptRecord[] = [ + { + attempt_id: 'paired-room-1:reviewer-turn:attempt:2', + parent_attempt_id: null, + parent_handoff_id: null, + continuation_handoff_id: null, + turn_id: 'paired-room-1:reviewer-turn', + task_id: pairedTask.id, + task_updated_at: pairedTask.updated_at, + role: 'reviewer', + intent_kind: 'reviewer-turn', + state: 'running', + executor_service_id: 'claude-reviewer', + executor_agent_type: 'claude-code', + active_run_id: 'run-reviewer-1', + attempt_no: 2, + created_at: '2026-04-26T05:19:00.000Z', + updated_at: '2026-04-26T05:21:00.000Z', + completed_at: null, + last_error: 'OPENAI_API_KEY=plain-secret-value', + }, + ]; + const outputs: PairedTurnOutput[] = [ + { + id: 1, + task_id: pairedTask.id, + turn_number: 1, + role: 'owner', + output_text: 'owner final output', + verdict: 'step_done', + created_at: '2026-04-26T05:18:00.000Z', + }, + ]; + const messages: NewMessage[] = [ + { + id: 'msg-1', + chat_jid: 'dc:ops', + sender: 'u1', + sender_name: '눈쟁이', + content: '진행 어디까지야? BOT_TOKEN=plain-secret-value', + timestamp: '2026-04-26T05:17:00.000Z', + is_from_me: false, + is_bot_message: false, + message_source_kind: 'human', + }, + ]; + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [ + { + serviceId: 'codex-main', + agentType: 'codex', + assistantName: 'Codex', + updatedAt: '2026-04-26T05:22:00.000Z', + entries: [ + { + jid: 'dc:ops', + name: '#ops', + folder: 'ops-room', + agentType: 'codex', + status: 'processing', + elapsedMs: 120_000, + pendingMessages: true, + pendingTasks: 1, + }, + ], + }, + ], + getTasks: () => [], + getPairedTasks: () => [pairedTask], + getLatestPairedTaskForChat: () => pairedTask, + getPairedTurnsForTask: (taskId) => + taskId === pairedTask.id ? turns : [], + getPairedTurnAttempts: (turnId) => + turnId === turns[0]!.turn_id ? attempts : [], + getPairedTurnOutputs: () => outputs, + getRecentChatMessages: () => messages, + }); + + const response = await handler( + new Request( + `http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`, + ), + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + jid: string; + pairedTask: { + id: string; + roundTripCount: number; + currentTurn: { + role: string; + state: string; + attemptNo: number; + lastError: string; + }; + outputs: Array<{ outputText: string; turnNumber: number }>; + }; + messages: Array<{ content: string; senderName: string }>; + }; + expect(body.jid).toBe('dc:ops'); + expect(body.pairedTask.id).toBe('paired-room-1'); + expect(body.pairedTask.roundTripCount).toBe(2); + expect(body.pairedTask.currentTurn).toMatchObject({ + role: 'reviewer', + state: 'running', + attemptNo: 2, + lastError: 'OPENAI_API_KEY=', + }); + expect(body.pairedTask.outputs).toMatchObject([ + { turnNumber: 1, outputText: 'owner final output' }, + ]); + expect(body.messages[0]?.content).toContain('BOT_TOKEN='); + expect(body.messages[0]?.senderName).toBe('눈쟁이'); + }); + + it('returns 404 for missing room timelines', async () => { + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getPairedTasks: () => [], + }); + + const response = await handler( + new Request( + `http://localhost/api/rooms/${encodeURIComponent('dc:missing')}/timeline`, + ), + ); + + expect(response.status).toBe(404); + }); + it('serves Vite static assets and falls back to index for SPA routes', async () => { const staticDir = fs.mkdtempSync( path.join(os.tmpdir(), 'ejclaw-dashboard-'), diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 0d65537..ddf2d79 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -10,7 +10,12 @@ import { deleteTask, getAllOpenPairedTasks, getAllTasks, + getLatestPairedTaskForChat, getPairedTaskById, + getPairedTurnAttempts, + getPairedTurnOutputs, + getPairedTurnsForTask, + getRecentChatMessages, getTaskById, hasMessage, storeChatMetadata, @@ -34,6 +39,7 @@ import type { } from './types.js'; import { isWatchCiTask } from './task-watch-status.js'; import { + buildWebDashboardRoomActivity, buildWebDashboardOverview, sanitizeScheduledTask, } from './web-dashboard-data.js'; @@ -106,6 +112,11 @@ export interface WebDashboardHandlerOptions { }) => void; deleteTask?: (id: string) => void; getPairedTasks?: () => PairedTask[]; + getLatestPairedTaskForChat?: (chatJid: string) => PairedTask | undefined; + getPairedTurnsForTask?: typeof getPairedTurnsForTask; + getPairedTurnAttempts?: typeof getPairedTurnAttempts; + getPairedTurnOutputs?: typeof getPairedTurnOutputs; + getRecentChatMessages?: typeof getRecentChatMessages; getPairedTaskById?: (id: string) => PairedTask | undefined; updatePairedTaskIfUnchanged?: ( id: string, @@ -265,6 +276,16 @@ function parseRoomMessagePath(pathname: string): string | null { } } +function parseRoomTimelinePath(pathname: string): string | null { + const match = pathname.match(/^\/api\/rooms\/([^/]+)\/timeline$/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + function parseServiceActionPath(pathname: string): string | null { const match = pathname.match(/^\/api\/services\/([^/]+)\/actions$/); if (!match) return null; @@ -648,6 +669,16 @@ export function createWebDashboardHandler( const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById; const mutatePairedTaskIfUnchanged = opts.updatePairedTaskIfUnchanged ?? updatePairedTaskIfUnchanged; + const loadLatestPairedTaskForChat = + opts.getLatestPairedTaskForChat ?? getLatestPairedTaskForChat; + const loadPairedTurnsForTask = + opts.getPairedTurnsForTask ?? getPairedTurnsForTask; + const loadPairedTurnOutputs = + opts.getPairedTurnOutputs ?? getPairedTurnOutputs; + const loadPairedTurnAttempts = + opts.getPairedTurnAttempts ?? getPairedTurnAttempts; + const loadRecentChatMessages = + opts.getRecentChatMessages ?? getRecentChatMessages; const schedulePairedFollowUp = opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent; const loadRoomBindings = opts.getRoomBindings; @@ -699,6 +730,7 @@ export function createWebDashboardHandler( const taskId = parseTaskPath(url.pathname); const actionInboxId = parseInboxActionPath(url.pathname); const messageRoomJid = parseRoomMessagePath(url.pathname); + const timelineRoomJid = parseRoomTimelinePath(url.pathname); const actionServiceId = parseServiceActionPath(url.pathname); if (actionTaskId) { @@ -949,6 +981,47 @@ export function createWebDashboardHandler( return jsonResponse({ ok: true, id, queued: true }); } + if (timelineRoomJid) { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + + const snapshots = readSnapshots(statusMaxAgeMs); + const matched = snapshots + .flatMap((snapshot) => + snapshot.entries + .filter((entry) => entry.jid === timelineRoomJid) + .map((entry) => ({ snapshot, entry })), + ) + .sort((a, b) => + b.snapshot.updatedAt.localeCompare(a.snapshot.updatedAt), + ) + .at(0); + if (!matched) { + return jsonResponse( + { error: 'Room timeline not found' }, + { status: 404 }, + ); + } + + const pairedTask = loadLatestPairedTaskForChat(timelineRoomJid) ?? null; + const turns = pairedTask ? loadPairedTurnsForTask(pairedTask.id) : []; + const attempts = turns.flatMap((turn) => + loadPairedTurnAttempts(turn.turn_id), + ); + return jsonResponse( + buildWebDashboardRoomActivity({ + serviceId: matched.snapshot.serviceId, + entry: matched.entry, + pairedTask, + turns, + attempts, + outputs: pairedTask ? loadPairedTurnOutputs(pairedTask.id) : [], + messages: loadRecentChatMessages(timelineRoomJid, 8), + }), + ); + } + if (actionServiceId) { if (request.method !== 'POST') { return jsonResponse({ error: 'Method not allowed' }, { status: 405 });