From 80aad035d04439f14803b758a3d0cd844fdb6954 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 26 Apr 2026 23:06:13 +0900 Subject: [PATCH] feat(dashboard): expose inbox activity data (#22) --- src/db.ts | 1 + src/db/paired-state.ts | 16 +++ src/db/runtime-paired.ts | 5 + src/web-dashboard-data.test.ts | 142 ++++++++++++++++++++++++- src/web-dashboard-data.ts | 172 ++++++++++++++++++++++++++++++- src/web-dashboard-server.test.ts | 143 +++++++++++++++++++++++++ src/web-dashboard-server.ts | 8 +- 7 files changed, 482 insertions(+), 5 deletions(-) diff --git a/src/db.ts b/src/db.ts index 0732e78..d571488 100644 --- a/src/db.ts +++ b/src/db.ts @@ -86,6 +86,7 @@ export { createServiceHandoff, failPairedTurn, failServiceHandoff, + getAllOpenPairedTasks, getAllChannelOwnerLeases, getAllPendingServiceHandoffs, getChannelOwnerLease, diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index 0ebbb20..05536eb 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -266,6 +266,22 @@ export function getLatestOpenPairedTaskForChatFromDatabase( return row ? hydratePairedTaskRow(database, row) : undefined; } +export function getAllOpenPairedTasksFromDatabase( + database: Database, +): PairedTask[] { + const rows = database + .prepare( + ` + SELECT * + FROM paired_tasks + WHERE status NOT IN ('completed') + ORDER BY updated_at DESC, created_at DESC + `, + ) + .all() as StoredPairedTaskRow[]; + return rows.map((row) => hydratePairedTaskRow(database, row)); +} + export function getLatestPreviousPairedTaskForChatFromDatabase( database: Database, chatJid: string, diff --git a/src/db/runtime-paired.ts b/src/db/runtime-paired.ts index 4d0541b..56bc6f8 100644 --- a/src/db/runtime-paired.ts +++ b/src/db/runtime-paired.ts @@ -24,6 +24,7 @@ import { clearPairedTurnReservationsInDatabase, type PairedTaskUpdates, createPairedTaskInDatabase, + getAllOpenPairedTasksFromDatabase, getLastBotFinalMessageFromDatabase, getLatestOpenPairedTaskForChatFromDatabase, getLatestPreviousPairedTaskForChatFromDatabase, @@ -113,6 +114,10 @@ export function getLatestPreviousPairedTaskForChat( ); } +export function getAllOpenPairedTasks(): PairedTask[] { + return getAllOpenPairedTasksFromDatabase(requireDatabase()); +} + export function updatePairedTask(id: string, updates: PairedTaskUpdates): void { updatePairedTaskInDatabase(requireDatabase(), id, updates); } diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts index 8d3c098..41fc815 100644 --- a/src/web-dashboard-data.test.ts +++ b/src/web-dashboard-data.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { StatusSnapshot } from './status-dashboard.js'; -import type { ScheduledTask } from './types.js'; +import type { PairedTask, ScheduledTask } from './types.js'; import { buildWebDashboardOverview, sanitizeScheduledTask, @@ -28,6 +28,36 @@ function makeTask(overrides: Partial): ScheduledTask { }; } +function makePairedTask(overrides: Partial): PairedTask { + return { + id: 'paired-1', + chat_jid: 'dc:general', + group_folder: 'general', + 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: 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-26T04:30:00.000Z', + ...overrides, + }; +} + describe('web dashboard data', () => { it('builds overview counts from status snapshots and scheduled tasks', () => { const snapshots: StatusSnapshot[] = [ @@ -199,4 +229,114 @@ describe('web dashboard data', () => { ); expect(sanitized.promptPreview).not.toContain('plain-secret-value'); }); + + it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => { + 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: 1000, + pendingMessages: true, + pendingTasks: 0, + }, + { + jid: 'dc:idle', + name: '#idle', + folder: 'idle', + agentType: 'codex', + status: 'inactive', + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, + }, + ], + }, + ]; + + const overview = buildWebDashboardOverview({ + now: '2026-04-26T05:10:00.000Z', + snapshots, + pairedTasks: [ + makePairedTask({ + id: 'review-1', + status: 'review_ready', + review_requested_at: '2026-04-26T05:03:00.000Z', + }), + makePairedTask({ + id: 'merge-1', + status: 'merge_ready', + title: 'Ready to merge', + updated_at: '2026-04-26T05:04:00.000Z', + }), + makePairedTask({ + id: 'done-1', + status: 'completed', + }), + ], + tasks: [ + makeTask({ + id: 'ci-1', + prompt: + '[BACKGROUND CI WATCH]\nWatch target:\nPR #21\n\nCheck instructions:\nwatch', + last_run: '2026-04-26T05:05:00.000Z', + last_result: 'Error: BOT_TOKEN=plain-secret-value failed', + status: 'paused', + }), + makeTask({ + id: 'cron-1', + prompt: 'regular cron', + last_run: '2026-04-26T05:06:00.000Z', + last_result: 'Error: non-watch task failed', + }), + ], + }); + + expect(overview.inbox.map((item) => item.kind)).toEqual([ + 'ci-failure', + 'approval', + 'reviewer-request', + 'pending-room', + ]); + expect(overview.inbox).toContainEqual( + expect.objectContaining({ + id: 'room:codex-main:dc:ops', + kind: 'pending-room', + severity: 'info', + roomJid: 'dc:ops', + serviceId: 'codex-main', + occurredAt: '2026-04-26T05:00:00.000Z', + createdAt: '2026-04-26T05:10:00.000Z', + }), + ); + expect(overview.inbox).toContainEqual( + expect.objectContaining({ + id: 'paired:review-1:review_ready', + kind: 'reviewer-request', + severity: 'warn', + serviceId: 'claude-reviewer', + taskStatus: 'review_ready', + occurredAt: '2026-04-26T05:03:00.000Z', + }), + ); + const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure'); + expect(ciFailure).toMatchObject({ + id: 'ci:ci-1', + severity: 'error', + source: 'scheduled-task', + taskId: 'ci-1', + }); + expect(ciFailure?.summary).toContain('BOT_TOKEN='); + expect(ciFailure?.summary).not.toContain('plain-secret-value'); + expect(overview.inbox.some((item) => item.taskId === 'cron-1')).toBe(false); + expect(overview.inbox.some((item) => item.taskId === 'done-1')).toBe(false); + }); }); diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index 3d45814..ad2a64e 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -1,6 +1,6 @@ import { isWatchCiTask } from './task-watch-status.js'; import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js'; -import type { ScheduledTask } from './types.js'; +import type { PairedTask, ScheduledTask } from './types.js'; export interface SanitizedScheduledTask { id: string; @@ -54,6 +54,34 @@ export interface WebDashboardOverview { rows: UsageRowSnapshot[]; fetchedAt: string | null; }; + inbox: InboxItem[]; +} + +export type InboxItemKind = + | 'pending-room' + | 'reviewer-request' + | 'approval' + | 'arbiter-request' + | 'ci-failure' + | 'mention'; + +export type InboxItemSeverity = 'info' | 'warn' | 'error'; + +export interface InboxItem { + id: string; + kind: InboxItemKind; + severity: InboxItemSeverity; + title: string; + summary: string; + occurredAt: string; + createdAt: string; + source: 'status-snapshot' | 'paired-task' | 'scheduled-task'; + roomJid?: string; + roomName?: string; + groupFolder?: string; + serviceId?: string; + taskId?: string; + taskStatus?: string; } const SECRET_ASSIGNMENT_RE = @@ -76,6 +104,10 @@ function buildPromptPreview(prompt: string): string { return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim()); } +function buildInboxPreview(value: string): string { + return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim()); +} + function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] { const rows: UsageRowSnapshot[] = []; const seen = new Set(); @@ -96,6 +128,134 @@ function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] { return rows; } +function failedTaskResult(task: ScheduledTask): string | null { + if (!task.last_result) return null; + const normalized = task.last_result.toLowerCase(); + if ( + normalized.includes('fail') || + normalized.includes('error') || + normalized.includes('timeout') || + normalized.includes('cancel') || + normalized.includes('reject') + ) { + return buildInboxPreview(task.last_result); + } + return null; +} + +function pairedTaskInboxKind( + status: PairedTask['status'], +): InboxItemKind | null { + if (status === 'merge_ready') return 'approval'; + if (status === 'review_ready' || status === 'in_review') { + return 'reviewer-request'; + } + if (status === 'arbiter_requested' || status === 'in_arbitration') { + return 'arbiter-request'; + } + return null; +} + +function collectInboxItems(args: { + snapshots: StatusSnapshot[]; + tasks: ScheduledTask[]; + pairedTasks: PairedTask[]; + createdAt: string; +}): InboxItem[] { + const items: InboxItem[] = []; + + for (const snapshot of args.snapshots) { + for (const entry of snapshot.entries) { + if (!entry.pendingMessages && entry.pendingTasks === 0) continue; + + const parts: string[] = []; + if (entry.pendingMessages) parts.push('pending messages'); + if (entry.pendingTasks > 0) parts.push(`${entry.pendingTasks} tasks`); + + items.push({ + id: `room:${snapshot.serviceId}:${entry.jid}`, + kind: 'pending-room', + severity: entry.pendingTasks > 0 ? 'warn' : 'info', + title: entry.name || entry.folder || entry.jid, + summary: parts.join(' ยท '), + occurredAt: snapshot.updatedAt, + createdAt: args.createdAt, + source: 'status-snapshot', + roomJid: entry.jid, + roomName: entry.name, + groupFolder: entry.folder, + serviceId: snapshot.serviceId, + }); + } + } + + for (const task of args.pairedTasks) { + const kind = pairedTaskInboxKind(task.status); + if (!kind) continue; + + items.push({ + id: `paired:${task.id}:${task.status}`, + kind, + severity: + kind === 'approval' || kind === 'arbiter-request' ? 'error' : 'warn', + title: task.title || task.group_folder, + summary: task.status, + occurredAt: + kind === 'arbiter-request' + ? (task.arbiter_requested_at ?? task.updated_at) + : kind === 'reviewer-request' + ? (task.review_requested_at ?? task.updated_at) + : task.updated_at, + createdAt: args.createdAt, + source: 'paired-task', + roomJid: task.chat_jid, + groupFolder: task.group_folder, + serviceId: + kind === 'reviewer-request' + ? task.reviewer_service_id + : task.owner_service_id, + taskId: task.id, + taskStatus: task.status, + }); + } + + for (const task of args.tasks) { + if (!isWatchCiTask(task)) continue; + const result = failedTaskResult(task); + if (!result) continue; + + items.push({ + id: `ci:${task.id}`, + kind: 'ci-failure', + severity: task.status === 'paused' ? 'error' : 'warn', + title: 'CI watcher failed', + summary: result, + occurredAt: task.last_run ?? task.created_at, + createdAt: args.createdAt, + source: 'scheduled-task', + roomJid: task.chat_jid, + groupFolder: task.group_folder, + serviceId: task.agent_type ?? undefined, + taskId: task.id, + taskStatus: task.status, + }); + } + + const severityRank: Record = { + error: 0, + warn: 1, + info: 2, + }; + + return items + .sort((a, b) => { + const severityDelta = severityRank[a.severity] - severityRank[b.severity]; + if (severityDelta !== 0) return severityDelta; + return b.occurredAt.localeCompare(a.occurredAt); + }) + .slice(0, 50); +} + export function sanitizeScheduledTask( task: ScheduledTask, ): SanitizedScheduledTask { @@ -125,7 +285,9 @@ export function buildWebDashboardOverview(args: { now?: string; snapshots: StatusSnapshot[]; tasks: ScheduledTask[]; + pairedTasks?: PairedTask[]; }): WebDashboardOverview { + const generatedAt = args.now ?? new Date().toISOString(); const rooms = { total: 0, active: 0, @@ -191,7 +353,7 @@ export function buildWebDashboardOverview(args: { .at(-1) ?? null; return { - generatedAt: args.now ?? new Date().toISOString(), + generatedAt, services, rooms, tasks, @@ -199,5 +361,11 @@ export function buildWebDashboardOverview(args: { rows: usageRows, fetchedAt: usageFetchedAt, }, + inbox: collectInboxItems({ + snapshots: args.snapshots, + tasks: args.tasks, + pairedTasks: args.pairedTasks ?? [], + createdAt: generatedAt, + }), }; } diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index 5223b13..bfbe376 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -4,10 +4,63 @@ import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; +import type { StatusSnapshot } from './status-dashboard.js'; +import type { PairedTask, ScheduledTask } from './types.js'; import { createWebDashboardHandler } from './web-dashboard-server.js'; const tempDirs: string[] = []; +function makeTask(overrides: Partial): ScheduledTask { + return { + id: 'task-1', + group_folder: 'general', + chat_jid: 'dc:general', + agent_type: null, + status_message_id: null, + status_started_at: null, + prompt: 'regular scheduled task', + schedule_type: 'cron', + schedule_value: '* * * * *', + context_mode: 'group', + next_run: '2026-04-26T05:00:00.000Z', + last_run: null, + last_result: null, + status: 'active', + created_at: '2026-04-26T04:00:00.000Z', + ...overrides, + }; +} + +function makePairedTask(overrides: Partial): PairedTask { + return { + id: 'paired-1', + chat_jid: 'dc:general', + group_folder: 'general', + 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: 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-26T04:30:00.000Z', + ...overrides, + }; +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -19,6 +72,7 @@ describe('web dashboard server handler', () => { const handler = createWebDashboardHandler({ readStatusSnapshots: () => [], getTasks: () => [], + getPairedTasks: () => [], }); const health = await handler(new Request('http://localhost/api/health')); @@ -32,9 +86,11 @@ describe('web dashboard server handler', () => { const body = (await overview.json()) as { rooms: { total: number }; tasks: { total: number }; + inbox: unknown[]; }; expect(body.rooms.total).toBe(0); expect(body.tasks.total).toBe(0); + expect(body.inbox).toEqual([]); }); it('serves full Claude, Kimi, and Codex usage rows through overview JSON', async () => { @@ -73,6 +129,7 @@ describe('web dashboard server handler', () => { }, ], getTasks: () => [], + getPairedTasks: () => [], }); const overview = await handler( @@ -90,6 +147,92 @@ describe('web dashboard server handler', () => { ]); }); + it('serves typed inbox items through overview JSON', 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: () => [ + makeTask({ + id: 'ci-1', + prompt: + '[BACKGROUND CI WATCH]\nWatch target:\nPR #21\n\nCheck instructions:\nwatch', + last_run: '2026-04-26T05:05:00.000Z', + last_result: 'failed with API_KEY=plain-secret-value', + status: 'paused', + }), + ], + getPairedTasks: () => [ + makePairedTask({ + id: 'merge-1', + status: 'merge_ready', + title: 'Ready to merge', + updated_at: '2026-04-26T05:04:00.000Z', + }), + ], + now: () => '2026-04-26T05:10:00.000Z', + }); + + const overview = await handler( + new Request('http://localhost/api/overview'), + ); + expect(overview.status).toBe(200); + const body = (await overview.json()) as { + inbox: Array<{ + kind: string; + id: string; + summary: string; + roomJid?: string; + serviceId?: string; + taskId?: string; + }>; + }; + + expect(body.inbox.map((item) => item.kind)).toEqual([ + 'ci-failure', + 'approval', + 'pending-room', + ]); + expect(body.inbox).toContainEqual( + expect.objectContaining({ + kind: 'pending-room', + id: 'room:codex-main:dc:ops', + roomJid: 'dc:ops', + serviceId: 'codex-main', + }), + ); + expect(body.inbox).toContainEqual( + expect.objectContaining({ + kind: 'approval', + id: 'paired:merge-1:merge_ready', + taskId: 'merge-1', + serviceId: 'codex-main', + }), + ); + const ciFailure = body.inbox.find((item) => item.kind === 'ci-failure'); + expect(ciFailure?.summary).toContain('API_KEY='); + expect(ciFailure?.summary).not.toContain('plain-secret-value'); + }); + 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 ac2242c..b7b9776 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -2,13 +2,13 @@ import fs from 'fs'; import path from 'path'; import { WEB_DASHBOARD } from './config.js'; -import { getAllTasks } from './db.js'; +import { getAllOpenPairedTasks, getAllTasks } from './db.js'; import { logger } from './logger.js'; import { readStatusSnapshots, type StatusSnapshot, } from './status-dashboard.js'; -import type { ScheduledTask } from './types.js'; +import type { PairedTask, ScheduledTask } from './types.js'; import { buildWebDashboardOverview, sanitizeScheduledTask, @@ -21,6 +21,7 @@ export interface WebDashboardHandlerOptions { statusMaxAgeMs?: number; readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[]; getTasks?: () => ScheduledTask[]; + getPairedTasks?: () => PairedTask[]; now?: () => string; } @@ -105,6 +106,7 @@ export function createWebDashboardHandler( ): (request: Request) => Response | Promise { const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots; const loadTasks = opts.getTasks ?? getAllTasks; + const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks; const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; return (request: Request): Response => { @@ -121,11 +123,13 @@ export function createWebDashboardHandler( if (url.pathname === '/api/overview') { const snapshots = readSnapshots(statusMaxAgeMs); const tasks = loadTasks(); + const pairedTasks = loadPairedTasks(); return jsonResponse( buildWebDashboardOverview({ now: opts.now?.(), snapshots, tasks, + pairedTasks, }), ); }