From 46e6ab7a51b17219ff59a490a7e091113c9330d2 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Tue, 28 Apr 2026 18:39:46 +0900 Subject: [PATCH] Extract dashboard room timeline routes --- src/web-dashboard-room-routes.test.ts | 256 +++++++++++++++++++ src/web-dashboard-room-routes.ts | 345 ++++++++++++++++++++++++++ src/web-dashboard-server.ts | 266 ++------------------ 3 files changed, 625 insertions(+), 242 deletions(-) create mode 100644 src/web-dashboard-room-routes.test.ts create mode 100644 src/web-dashboard-room-routes.ts diff --git a/src/web-dashboard-room-routes.test.ts b/src/web-dashboard-room-routes.test.ts new file mode 100644 index 0000000..7066c71 --- /dev/null +++ b/src/web-dashboard-room-routes.test.ts @@ -0,0 +1,256 @@ +import { gunzipSync } from 'node:zlib'; + +import { describe, expect, it } from 'vitest'; + +import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; +import type { StatusSnapshot } from './status-dashboard.js'; +import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; +import { + handleRoomTimelineRoute, + type RoomsTimelineRouteDependencies, +} from './web-dashboard-room-routes.js'; + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { + 'content-type': 'application/json; charset=utf-8', + ...(init?.headers as Record | undefined), + }, + }); +} + +function makePairedTask(overrides: Partial = {}): PairedTask { + return { + id: 'paired-room-1', + chat_jid: 'dc:ops', + group_folder: 'ops-room', + 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: 2, + 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: 'in_review', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-26T04:00:00.000Z', + updated_at: '2026-04-26T05:20:00.000Z', + ...overrides, + }; +} + +function snapshot(updatedAt = '2026-04-26T05:22:00.000Z'): StatusSnapshot { + return { + serviceId: 'codex-main', + agentType: 'codex', + assistantName: 'Codex', + updatedAt, + entries: [ + { + jid: 'dc:ops', + name: '#ops', + folder: 'ops-room', + agentType: 'codex', + status: 'processing', + elapsedMs: 120_000, + pendingMessages: true, + pendingTasks: 1, + }, + ], + }; +} + +function roomDeps( + overrides: Partial = {}, +): RoomsTimelineRouteDependencies { + return { + statusMaxAgeMs: 1234, + readSnapshots: () => [snapshot()], + loadLatestPairedTaskForChat: () => null, + loadPairedTurnsForTask: () => [], + loadLatestPairedTurnForTask: () => null, + loadPairedTurnAttempts: () => [], + loadPairedTurnOutputs: () => [], + loadRecentPairedTurnOutputsForChat: () => [], + loadRecentChatMessages: () => [], + ...overrides, + }; +} + +describe('web dashboard room routes', () => { + it('serves room timeline with paired progress and recent messages', async () => { + const pairedTask = makePairedTask(); + 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 progressMessages: NewMessage[] = [ + ...messages, + { + id: 'msg-progress', + chat_jid: 'dc:ops', + sender: 'reviewer-bot', + sender_name: '리뷰어', + content: '⁣⁣⁣checking current output\n\n9s', + timestamp: '2026-04-26T05:20:00.000Z', + is_from_me: true, + is_bot_message: true, + message_source_kind: 'bot', + }, + ]; + const requestedMessageLimits: number[] = []; + const response = handleRoomTimelineRoute({ + url: new URL( + `http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`, + ), + request: new Request('http://localhost'), + jsonResponse, + ...roomDeps({ + loadLatestPairedTaskForChat: () => pairedTask, + loadPairedTurnsForTask: () => turns, + loadPairedTurnAttempts: () => attempts, + loadPairedTurnOutputs: () => outputs, + loadRecentChatMessages: (_jid, limit) => { + requestedMessageLimits.push(limit ?? 20); + return limit && limit > 8 ? progressMessages : messages; + }, + }), + }); + + expect(response?.status).toBe(200); + const body = (await response?.json()) as { + pairedTask: { + currentTurn: { lastError: string; progressText: string }; + outputs: Array<{ outputText: string }>; + }; + messages: Array<{ content: string; senderName: string }>; + }; + expect(body.pairedTask.currentTurn).toMatchObject({ + lastError: 'OPENAI_API_KEY=', + progressText: 'checking current output', + }); + expect(body.pairedTask.outputs).toMatchObject([ + { outputText: 'owner final output' }, + ]); + expect(requestedMessageLimits).toEqual([8, 40]); + expect(body.messages[0]).toMatchObject({ + content: expect.stringContaining('BOT_TOKEN='), + senderName: '눈쟁이', + }); + }); + + it('serves cached room timelines and falls through outside room routes', async () => { + const pairedTask = makePairedTask({ id: 'paired-cache-1' }); + const response = handleRoomTimelineRoute({ + url: new URL('http://localhost/api/rooms-timeline'), + request: new Request('http://localhost/api/rooms-timeline', { + headers: { 'accept-encoding': 'gzip' }, + }), + jsonResponse, + ...roomDeps({ + readSnapshots: () => [snapshot('2026-04-26T06:22:00.000Z')], + loadLatestPairedTaskForChat: () => pairedTask, + loadLatestPairedTurnForTask: () => null, + loadRecentChatMessages: () => [ + { + id: 'msg-cache', + chat_jid: 'dc:ops', + sender: 'u1', + sender_name: '눈쟁이', + content: 'cache message', + timestamp: '2026-04-26T06:17:00.000Z', + is_from_me: false, + is_bot_message: false, + message_source_kind: 'human', + }, + ], + }), + }); + + expect(response?.status).toBe(200); + expect(response?.headers.get('content-encoding')).toBe('gzip'); + const bytes = new Uint8Array(await response!.arrayBuffer()); + const body = JSON.parse( + new TextDecoder().decode(gunzipSync(bytes)), + ) as Record; + expect(body['dc:ops']?.pairedTask.id).toBe('paired-cache-1'); + expect( + handleRoomTimelineRoute({ + url: new URL('http://localhost/api/overview'), + request: new Request('http://localhost/api/overview'), + jsonResponse, + ...roomDeps(), + }), + ).toBeNull(); + }); +}); diff --git a/src/web-dashboard-room-routes.ts b/src/web-dashboard-room-routes.ts new file mode 100644 index 0000000..8e98b47 --- /dev/null +++ b/src/web-dashboard-room-routes.ts @@ -0,0 +1,345 @@ +import { gzipSync } from 'node:zlib'; + +import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; +import type { StatusSnapshot } from './status-dashboard.js'; +import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; +import { + buildWebDashboardRoomActivity, + type WebDashboardRoomActivity, +} from './web-dashboard-data.js'; + +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + +export interface RoomsTimelineRouteDependencies { + statusMaxAgeMs: number; + readSnapshots: (maxAgeMs: number) => StatusSnapshot[]; + loadLatestPairedTaskForChat: ( + chatJid: string, + ) => PairedTask | null | undefined; + loadPairedTurnsForTask: (taskId: string) => PairedTurnRecord[]; + loadLatestPairedTurnForTask: ( + taskId: string, + ) => PairedTurnRecord | null | undefined; + loadPairedTurnAttempts: (turnId: string) => PairedTurnAttemptRecord[]; + loadPairedTurnOutputs: (taskId: string) => PairedTurnOutput[]; + loadRecentPairedTurnOutputsForChat: ( + chatJid: string, + limit: number, + ) => PairedTurnOutput[]; + loadRecentChatMessages: (chatJid: string, limit?: number) => NewMessage[]; +} + +interface RoomsTimelineRouteContext extends RoomsTimelineRouteDependencies { + url: URL; + request: Request; + jsonResponse: JsonResponse; +} + +interface RoomsTimelineCache { + key: string; + builtAt: number; + rawJson: string; + gzipBuffer: Uint8Array; +} + +const ROOMS_TIMELINE_BG_INTERVAL_MS = 2000; + +let roomsTimelineCache: RoomsTimelineCache | null = 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 isReadMethod(method: string): boolean { + return method === 'GET' || method === 'HEAD'; +} + +function latestSnapshotEntry( + snapshots: StatusSnapshot[], + jid: string, +): + | { snapshot: StatusSnapshot; entry: StatusSnapshot['entries'][number] } + | undefined { + return snapshots + .flatMap((snapshot) => + snapshot.entries + .filter((entry) => entry.jid === jid) + .map((entry) => ({ snapshot, entry })), + ) + .sort((a, b) => b.snapshot.updatedAt.localeCompare(a.snapshot.updatedAt)) + .at(0); +} + +function buildRoomActivity( + deps: RoomsTimelineRouteDependencies, + snapshot: StatusSnapshot, + entry: StatusSnapshot['entries'][number], +): WebDashboardRoomActivity { + const pairedTask = deps.loadLatestPairedTaskForChat(entry.jid) ?? null; + const turns = pairedTask ? deps.loadPairedTurnsForTask(pairedTask.id) : []; + const attempts = turns.flatMap((turn) => + deps.loadPairedTurnAttempts(turn.turn_id), + ); + return buildWebDashboardRoomActivity({ + serviceId: snapshot.serviceId, + entry, + pairedTask, + turns, + attempts, + outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [], + messages: deps.loadRecentChatMessages(entry.jid, 8), + progressMessages: deps.loadRecentChatMessages(entry.jid, 40), + }); +} + +export function buildRoomsTimelineResult( + deps: RoomsTimelineRouteDependencies, +): Record { + const snapshots = deps.readSnapshots(deps.statusMaxAgeMs); + const uniqueByJid = new Map< + string, + { + snapshot: StatusSnapshot; + entry: StatusSnapshot['entries'][number]; + } + >(); + for (const snapshot of snapshots) { + for (const entry of snapshot.entries) { + const existing = uniqueByJid.get(entry.jid); + if ( + !existing || + existing.snapshot.updatedAt.localeCompare(snapshot.updatedAt) < 0 + ) { + uniqueByJid.set(entry.jid, { snapshot, entry }); + } + } + } + + const result: Record = {}; + for (const [jid, { snapshot, entry }] of uniqueByJid) { + const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null; + const messages = deps.loadRecentChatMessages(jid, 8); + const progressMessages = pairedTask + ? deps.loadRecentChatMessages(jid, 40) + : messages; + const outputs = deps.loadRecentPairedTurnOutputsForChat(jid, 8); + if (!pairedTask && messages.length === 0 && outputs.length === 0) continue; + + const latestTurn = pairedTask + ? deps.loadLatestPairedTurnForTask(pairedTask.id) + : null; + const turns = latestTurn ? [latestTurn] : []; + result[jid] = buildWebDashboardRoomActivity({ + serviceId: snapshot.serviceId, + entry, + pairedTask, + turns, + attempts: [], + outputs, + messages, + progressMessages, + outputLimit: 8, + }); + } + return result; +} + +function computeRoomsCacheKey(deps: RoomsTimelineRouteDependencies): string { + return deps + .readSnapshots(deps.statusMaxAgeMs) + .map((s) => s.updatedAt) + .sort() + .join('|'); +} + +export function ensureRoomsTimelineCache( + deps: RoomsTimelineRouteDependencies, +): RoomsTimelineCache { + const key = computeRoomsCacheKey(deps); + if (roomsTimelineCache && roomsTimelineCache.key === key) { + return roomsTimelineCache; + } + const result = buildRoomsTimelineResult(deps); + const rawJson = JSON.stringify(result); + const gzipBuffer = gzipSync(new TextEncoder().encode(rawJson)); + roomsTimelineCache = { + key, + builtAt: Date.now(), + rawJson, + gzipBuffer, + }; + return roomsTimelineCache; +} + +export function startRoomsTimelineCacheRefresh( + deps: RoomsTimelineRouteDependencies, +): void { + setTimeout(() => { + try { + ensureRoomsTimelineCache(deps); + } catch { + /* warm-up failure is non-fatal */ + } + }, 0); + setInterval(() => { + try { + ensureRoomsTimelineCache(deps); + } catch { + /* refresh failure is non-fatal */ + } + }, ROOMS_TIMELINE_BG_INTERVAL_MS).unref(); +} + +function createRoomsTimelineStream( + deps: RoomsTimelineRouteDependencies, + request: Request, +): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + let lastBuiltAt = 0; + let closed = false; + + const enqueue = (chunk: string) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(chunk)); + } catch { + closed = true; + } + }; + + enqueue(`retry: 3000\n\n`); + try { + const cache = ensureRoomsTimelineCache(deps); + lastBuiltAt = cache.builtAt; + enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`); + } catch { + /* warm-up failure is non-fatal */ + } + + const tick = () => { + if (closed) return; + try { + const cache = ensureRoomsTimelineCache(deps); + if (cache.builtAt !== lastBuiltAt) { + lastBuiltAt = cache.builtAt; + enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`); + } else { + enqueue(`: ping ${Date.now()}\n\n`); + } + } catch { + /* skip this tick */ + } + }; + + const interval = setInterval(tick, 1500); + const close = () => { + if (closed) return; + closed = true; + clearInterval(interval); + try { + controller.close(); + } catch { + /* already closed */ + } + }; + request.signal.addEventListener('abort', close); + }, + }); +} + +function handleRoomsStream( + deps: RoomsTimelineRouteDependencies, + request: Request, + jsonResponse: JsonResponse, +): Response { + if (!isReadMethod(request.method)) { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + return new Response(createRoomsTimelineStream(deps, request), { + headers: { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + }, + }); +} + +function handleRoomsTimelineSnapshot( + deps: RoomsTimelineRouteDependencies, + request: Request, + jsonResponse: JsonResponse, +): Response { + if (!isReadMethod(request.method)) { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + const cache = ensureRoomsTimelineCache(deps); + const acceptsGzip = + request.headers.get('accept-encoding')?.includes('gzip') ?? false; + if (acceptsGzip) { + return new Response(cache.gzipBuffer, { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-encoding': 'gzip', + vary: 'accept-encoding', + 'x-cache-age': String(Date.now() - cache.builtAt), + }, + }); + } + return new Response(cache.rawJson, { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'x-cache-age': String(Date.now() - cache.builtAt), + }, + }); +} + +function handleSingleRoomTimeline({ + request, + jsonResponse, + ...deps +}: RoomsTimelineRouteContext & { timelineRoomJid: string }): Response { + if (!isReadMethod(request.method)) { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + + const snapshots = deps.readSnapshots(deps.statusMaxAgeMs); + const matched = latestSnapshotEntry(snapshots, deps.timelineRoomJid); + if (!matched) { + return jsonResponse({ error: 'Room timeline not found' }, { status: 404 }); + } + + return jsonResponse(buildRoomActivity(deps, matched.snapshot, matched.entry)); +} + +export function handleRoomTimelineRoute( + context: RoomsTimelineRouteContext, +): Response | null { + const timelineRoomJid = parseRoomTimelinePath(context.url.pathname); + if (timelineRoomJid) { + return handleSingleRoomTimeline({ ...context, timelineRoomJid }); + } + if (context.url.pathname === '/api/stream') { + return handleRoomsStream(context, context.request, context.jsonResponse); + } + if (context.url.pathname === '/api/rooms-timeline') { + return handleRoomsTimelineSnapshot( + context, + context.request, + context.jsonResponse, + ); + } + return null; +} diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 5ec0a6c..51f4c43 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -42,10 +42,14 @@ import type { } from './types.js'; import { isWatchCiTask } from './task-watch-status.js'; import { - buildWebDashboardRoomActivity, buildWebDashboardOverview, sanitizeScheduledTask, } from './web-dashboard-data.js'; +import { + handleRoomTimelineRoute, + startRoomsTimelineCacheRefresh, + type RoomsTimelineRouteDependencies, +} from './web-dashboard-room-routes.js'; import { handleSimpleGetRoute } from './web-dashboard-routes.js'; import { handleServiceRoute, @@ -153,14 +157,6 @@ export interface StartedWebDashboardServer { stop: () => void; } -const ROOMS_TIMELINE_BG_INTERVAL_MS = 2000; -let roomsTimelineCache: { - key: string; - builtAt: number; - rawJson: string; - gzipBuffer: Uint8Array; -} | null = null; - function jsonResponse( value: unknown, init?: ResponseInit, @@ -300,16 +296,6 @@ 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 parsePairedInboxTarget( inboxId: string, ): { taskId: string; status: PairedTask['status'] } | null { @@ -682,100 +668,20 @@ export function createWebDashboardHandler( opts.restartServiceStack ?? restartEjclawStackServices; const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; - function buildRoomsTimelineResult(): Record< - string, - ReturnType - > { - const snapshots = readSnapshots(statusMaxAgeMs); - const uniqueByJid = new Map< - string, - { - snapshot: (typeof snapshots)[number]; - entry: (typeof snapshots)[number]['entries'][number]; - } - >(); - for (const snapshot of snapshots) { - for (const entry of snapshot.entries) { - const existing = uniqueByJid.get(entry.jid); - if ( - !existing || - existing.snapshot.updatedAt.localeCompare(snapshot.updatedAt) < 0 - ) { - uniqueByJid.set(entry.jid, { snapshot, entry }); - } - } - } - const result: Record< - string, - ReturnType - > = {}; - for (const [jid, { snapshot, entry }] of uniqueByJid) { - const pairedTask = loadLatestPairedTaskForChat(jid) ?? null; - const messages = loadRecentChatMessages(jid, 8); - const progressMessages = pairedTask - ? loadRecentChatMessages(jid, 40) - : messages; - const outputs = loadRecentPairedTurnOutputsForChat(jid, 8); - if (!pairedTask && messages.length === 0 && outputs.length === 0) - continue; - const latestTurn = pairedTask - ? loadLatestPairedTurnForTask(pairedTask.id) - : null; - const turns = latestTurn ? [latestTurn] : []; - result[jid] = buildWebDashboardRoomActivity({ - serviceId: snapshot.serviceId, - entry, - pairedTask, - turns, - attempts: [], - outputs, - messages, - progressMessages, - outputLimit: 8, - }); - } - return result; - } - - function computeRoomsCacheKey(): string { - return readSnapshots(statusMaxAgeMs) - .map((s) => s.updatedAt) - .sort() - .join('|'); - } - - function ensureRoomsTimelineCache(): NonNullable { - const key = computeRoomsCacheKey(); - if (roomsTimelineCache && roomsTimelineCache.key === key) { - return roomsTimelineCache; - } - const result = buildRoomsTimelineResult(); - const rawJson = JSON.stringify(result); - const gzipBuffer = Bun.gzipSync(new TextEncoder().encode(rawJson)); - roomsTimelineCache = { - key, - builtAt: Date.now(), - rawJson, - gzipBuffer, - }; - return roomsTimelineCache; - } + const roomsTimelineDeps: RoomsTimelineRouteDependencies = { + statusMaxAgeMs, + readSnapshots, + loadLatestPairedTaskForChat, + loadPairedTurnsForTask, + loadLatestPairedTurnForTask, + loadPairedTurnAttempts, + loadPairedTurnOutputs, + loadRecentPairedTurnOutputsForChat, + loadRecentChatMessages, + }; if (opts.startBackgroundCacheRefresh !== false) { - setTimeout(() => { - try { - ensureRoomsTimelineCache(); - } catch { - /* warm-up failure is non-fatal */ - } - }, 0); - setInterval(() => { - try { - ensureRoomsTimelineCache(); - } catch { - /* refresh failure is non-fatal */ - } - }, ROOMS_TIMELINE_BG_INTERVAL_MS).unref(); + startRoomsTimelineCacheRefresh(roomsTimelineDeps); } const seenRoomMessageIds: string[] = []; @@ -811,7 +717,6 @@ export function createWebDashboardHandler( const taskId = parseTaskPath(url.pathname); const actionInboxId = parseInboxActionPath(url.pathname); const messageRoomJid = parseRoomMessagePath(url.pathname); - const timelineRoomJid = parseRoomTimelinePath(url.pathname); if (actionTaskId) { if (request.method !== 'POST') { @@ -1061,47 +966,13 @@ 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), - progressMessages: loadRecentChatMessages(timelineRoomJid, 40), - }), - ); - } + const roomTimelineRoute = handleRoomTimelineRoute({ + url, + request, + jsonResponse, + ...roomsTimelineDeps, + }); + if (roomTimelineRoute) return roomTimelineRoute; const serviceRoute = await handleServiceRoute({ url, @@ -1312,95 +1183,6 @@ export function createWebDashboardHandler( }); } - if (url.pathname === '/api/stream') { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - let lastBuiltAt = 0; - let closed = false; - - const enqueue = (chunk: string) => { - if (closed) return; - try { - controller.enqueue(encoder.encode(chunk)); - } catch { - closed = true; - } - }; - - // Initial: send retry hint and seed event - enqueue(`retry: 3000\n\n`); - try { - const cache = ensureRoomsTimelineCache(); - lastBuiltAt = cache.builtAt; - enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`); - } catch { - /* warm-up failure is non-fatal */ - } - - const tick = () => { - if (closed) return; - try { - const cache = ensureRoomsTimelineCache(); - if (cache.builtAt !== lastBuiltAt) { - lastBuiltAt = cache.builtAt; - enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`); - } else { - // heartbeat to keep connection alive through proxies - enqueue(`: ping ${Date.now()}\n\n`); - } - } catch { - /* skip this tick */ - } - }; - - const interval = setInterval(tick, 1500); - const close = () => { - if (closed) return; - closed = true; - clearInterval(interval); - try { - controller.close(); - } catch { - /* already closed */ - } - }; - request.signal.addEventListener('abort', close); - }, - }); - - return new Response(stream, { - headers: { - 'content-type': 'text/event-stream; charset=utf-8', - 'cache-control': 'no-cache, no-transform', - connection: 'keep-alive', - 'x-accel-buffering': 'no', - }, - }); - } - - if (url.pathname === '/api/rooms-timeline') { - const cache = ensureRoomsTimelineCache(); - const acceptsGzip = - request.headers.get('accept-encoding')?.includes('gzip') ?? false; - if (acceptsGzip) { - return new Response(cache.gzipBuffer, { - headers: { - 'content-type': 'application/json; charset=utf-8', - 'content-encoding': 'gzip', - vary: 'accept-encoding', - 'x-cache-age': String(Date.now() - cache.builtAt), - }, - }); - } - return new Response(cache.rawJson, { - headers: { - 'content-type': 'application/json; charset=utf-8', - 'x-cache-age': String(Date.now() - cache.builtAt), - }, - }); - } - if (url.pathname.startsWith('/api/')) { return jsonResponse({ error: 'Not found' }, { status: 404 }); }