diff --git a/src/web-dashboard-overview-routes.test.ts b/src/web-dashboard-overview-routes.test.ts new file mode 100644 index 0000000..f516fdb --- /dev/null +++ b/src/web-dashboard-overview-routes.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import type { StatusSnapshot } from './status-dashboard.js'; +import type { PairedTask, ScheduledTask } from './types.js'; +import { + handleOverviewRoute, + type OverviewRouteDependencies, +} from './web-dashboard-overview-routes.js'; +import type { ServiceRestartRecord } from './web-dashboard-service-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 makeTask(overrides: Partial): ScheduledTask { + return { + id: 'task-1', + group_folder: 'ops', + chat_jid: 'dc:ops', + agent_type: null, + status_message_id: null, + status_started_at: null, + prompt: 'run task', + schedule_type: 'once', + schedule_value: '2026-04-26T05:20:00.000Z', + context_mode: 'isolated', + next_run: '2026-04-26T05:20:00.000Z', + last_run: null, + last_result: null, + status: 'active', + suspended_until: null, + created_at: '2026-04-26T05:00:00.000Z', + ...overrides, + }; +} + +function makePairedTask(overrides: Partial): PairedTask { + return { + id: 'paired-1', + chat_jid: 'dc:ops', + group_folder: 'ops', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude-reviewer', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + title: 'Review dashboard', + source_ref: null, + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: 0, + task_done_then_user_reopen_count: 0, + empty_step_done_streak: 0, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-26T04:00:00.000Z', + updated_at: '2026-04-26T05:00:00.000Z', + ...overrides, + }; +} + +function route( + pathname: string, + overrides: Partial = {}, +): Response | null { + return handleOverviewRoute({ + url: new URL(`http://localhost${pathname}`), + jsonResponse, + loadPairedTasks: () => [], + loadTasks: () => [], + readSnapshots: () => [], + recentServiceRestarts: [], + statusMaxAgeMs: 1234, + now: () => '2026-04-26T05:10:00.000Z', + ...overrides, + }); +} + +describe('web dashboard overview route', () => { + it('serves overview JSON with operation restart records', async () => { + const recentServiceRestarts: ServiceRestartRecord[] = [ + { + id: 'web-restart-1', + target: 'stack', + requestedAt: '2026-04-26T05:00:00.000Z', + completedAt: '2026-04-26T05:00:01.000Z', + status: 'success', + services: ['ejclaw'], + }, + ]; + const response = route('/api/overview', { + loadTasks: () => [makeTask({ id: 'task-active' })], + recentServiceRestarts, + }); + + expect(response?.status).toBe(200); + await expect(response?.json()).resolves.toMatchObject({ + generatedAt: '2026-04-26T05:10:00.000Z', + tasks: { total: 1, active: 1 }, + operations: { + serviceRestarts: [ + { + id: 'web-restart-1', + status: 'success', + services: ['ejclaw'], + }, + ], + }, + }); + }); + + it('filters dismissed inbox items and falls through for other paths', 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 overview = route('/api/overview', { + loadPairedTasks: () => [makePairedTask({ id: 'review-1' })], + readSnapshots: () => snapshots, + isInboxItemDismissed: () => true, + }); + + expect(overview?.status).toBe(200); + await expect(overview?.json()).resolves.toMatchObject({ inbox: [] }); + + const unmatched = route('/api/tasks'); + expect(unmatched).toBeNull(); + }); +}); diff --git a/src/web-dashboard-overview-routes.ts b/src/web-dashboard-overview-routes.ts new file mode 100644 index 0000000..354ae51 --- /dev/null +++ b/src/web-dashboard-overview-routes.ts @@ -0,0 +1,63 @@ +import type { StatusSnapshot } from './status-dashboard.js'; +import type { PairedTask, ScheduledTask } from './types.js'; +import { buildWebDashboardOverview } from './web-dashboard-data.js'; +import type { ServiceRestartRecord } from './web-dashboard-service-routes.js'; + +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + +type DismissableInboxItem = { + id: string; + lastOccurredAt: string; +}; + +export interface OverviewRouteDependencies { + isInboxItemDismissed?: (item: DismissableInboxItem) => boolean; + loadPairedTasks: () => PairedTask[]; + loadTasks: () => ScheduledTask[]; + readSnapshots: (maxAgeMs: number) => StatusSnapshot[]; + recentServiceRestarts: ServiceRestartRecord[]; + statusMaxAgeMs: number; + now?: () => string; +} + +interface OverviewRouteContext extends OverviewRouteDependencies { + url: URL; + jsonResponse: JsonResponse; +} + +export function handleOverviewRoute({ + isInboxItemDismissed, + jsonResponse, + loadPairedTasks, + loadTasks, + now, + readSnapshots, + recentServiceRestarts, + statusMaxAgeMs, + url, +}: OverviewRouteContext): Response | null { + if (url.pathname !== '/api/overview') return null; + + const snapshots = readSnapshots(statusMaxAgeMs); + const tasks = loadTasks(); + const pairedTasks = loadPairedTasks(); + const overview = buildWebDashboardOverview({ + now: now?.(), + snapshots, + tasks, + pairedTasks, + }); + return jsonResponse({ + ...overview, + operations: { + serviceRestarts: recentServiceRestarts, + }, + inbox: isInboxItemDismissed + ? overview.inbox.filter((item) => !isInboxItemDismissed(item)) + : overview.inbox, + }); +} diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index ad69677..112326e 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -38,7 +38,7 @@ import type { RegisteredGroup, ScheduledTask, } from './types.js'; -import { buildWebDashboardOverview } from './web-dashboard-data.js'; +import { handleOverviewRoute } from './web-dashboard-overview-routes.js'; import { handleRoomTimelineRoute, startRoomsTimelineCacheRefresh, @@ -822,24 +822,18 @@ export function createWebDashboardHandler( }); if (simpleGetRoute) return simpleGetRoute; - if (url.pathname === '/api/overview') { - const snapshots = readSnapshots(statusMaxAgeMs); - const tasks = loadTasks(); - const pairedTasks = loadPairedTasks(); - const overview = buildWebDashboardOverview({ - now: opts.now?.(), - snapshots, - tasks, - pairedTasks, - }); - return jsonResponse({ - ...overview, - operations: { - serviceRestarts: recentServiceRestarts, - }, - inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)), - }); - } + const overviewRoute = handleOverviewRoute({ + url, + jsonResponse, + isInboxItemDismissed, + loadPairedTasks, + loadTasks, + readSnapshots, + recentServiceRestarts, + statusMaxAgeMs, + now: opts.now, + }); + if (overviewRoute) return overviewRoute; if (url.pathname.startsWith('/api/')) { return jsonResponse({ error: 'Not found' }, { status: 404 });