import fs from 'fs'; import path from 'path'; import { execFileSync } from 'child_process'; import { timingSafeEqual } from 'crypto'; import { WEB_DASHBOARD } from './config.js'; import { createTask, deleteTask, getAllOpenPairedTasks, getAllTasks, getLatestPairedTaskForChat, getPairedTaskById, getPairedTurnAttempts, getPairedTurnOutputs, getRecentPairedTurnOutputsForChat, getPairedTurnsForTask, getLatestPairedTurnForTask, getRecentDeliveredWorkItemsForChat, getRecentChatMessages, getTaskById, hasMessage, storeChatMetadata, storeMessage, updatePairedTaskIfUnchanged, updateTask, } from './db.js'; import { logger } from './logger.js'; import { schedulePairedFollowUpIntent } from './message-runtime-follow-up.js'; import { readStatusSnapshots, type StatusSnapshot, } from './status-dashboard.js'; import type { AgentType, NewMessage, PairedTask, RegisteredGroup, ScheduledTask, } from './types.js'; import { createInboxDismissTracker, handleInboxActionRoute, type InboxFollowUpScheduler, } from './web-dashboard-inbox-routes.js'; import { handleOverviewRoute } from './web-dashboard-overview-routes.js'; import { createRoomMessageIdCache, handleRoomMessageRoute, } from './web-dashboard-room-message-routes.js'; import { handleRoomTimelineRoute, startRoomsTimelineCacheRefresh, type RoomsTimelineRouteDependencies, } from './web-dashboard-room-routes.js'; import { handleSimpleGetRoute } from './web-dashboard-routes.js'; import { handleServiceRoute, type ServiceRestartRecord, } from './web-dashboard-service-routes.js'; import { handleSettingsRoute } from './web-dashboard-settings-routes.js'; import { handleScheduledTaskRoute } from './web-dashboard-task-routes.js'; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service'; const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = 'Stack restart unit is not installed yet. Run setup service from an external shell before retrying from a managed EJClaw service.'; const UNIT_NOT_FOUND_PATTERN = /(Unit .* not found|Could not find the requested service|not-found)/i; type JsonResponse = ( value: unknown, init?: ResponseInit, request?: Request, ) => Response; export type { ServiceRestartRecord } from './web-dashboard-service-routes.js'; export interface WebDashboardHandlerOptions { staticDir?: string; statusMaxAgeMs?: number; startBackgroundCacheRefresh?: boolean; readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[]; getTasks?: () => ScheduledTask[]; getTaskById?: (id: string) => ScheduledTask | undefined; updateTask?: ( id: string, updates: Partial< Pick< ScheduledTask, | 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' | 'suspended_until' > >, ) => void; createTask?: (task: { id: string; group_folder: string; chat_jid: string; agent_type?: AgentType | null; prompt: string; schedule_type: ScheduledTask['schedule_type']; schedule_value: string; context_mode: ScheduledTask['context_mode']; next_run: string | null; status: ScheduledTask['status']; created_at: string; }) => void; deleteTask?: (id: string) => void; getPairedTasks?: () => PairedTask[]; getLatestPairedTaskForChat?: (chatJid: string) => PairedTask | undefined; getPairedTurnsForTask?: typeof getPairedTurnsForTask; getLatestPairedTurnForTask?: typeof getLatestPairedTurnForTask; getPairedTurnAttempts?: typeof getPairedTurnAttempts; getPairedTurnOutputs?: typeof getPairedTurnOutputs; getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat; getRecentDeliveredWorkItemsForChat?: typeof getRecentDeliveredWorkItemsForChat; getRecentChatMessages?: typeof getRecentChatMessages; getPairedTaskById?: (id: string) => PairedTask | undefined; updatePairedTaskIfUnchanged?: ( id: string, expectedUpdatedAt: string, updates: Partial< Pick< PairedTask, 'status' | 'updated_at' | 'arbiter_requested_at' | 'completion_reason' > >, ) => boolean; schedulePairedFollowUp?: InboxFollowUpScheduler; getRoomBindings?: () => Record; storeChatMetadata?: ( chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean, ) => void; storeMessage?: (message: NewMessage) => void; hasMessage?: (chatJid: string, id: string) => boolean; enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void; nudgeScheduler?: () => void; restartServiceStack?: () => string[]; authToken?: string; now?: () => string; } export interface StartedWebDashboardServer { url: string; stop: () => void; } function jsonResponse( value: unknown, init?: ResponseInit, request?: Request, ): Response { const body = JSON.stringify(value); const acceptsGzip = request?.headers.get('accept-encoding')?.includes('gzip') ?? false; const baseHeaders: Record = { 'content-type': 'application/json; charset=utf-8', ...(init?.headers as Record | undefined), }; if (acceptsGzip && body.length > 1024) { const compressed = Bun.gzipSync(new TextEncoder().encode(body)); return new Response(compressed, { ...init, headers: { ...baseHeaders, 'content-encoding': 'gzip', vary: 'accept-encoding', }, }); } return new Response(body, { ...init, headers: baseHeaders, }); } function getContentType(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); switch (ext) { case '.html': return 'text/html; charset=utf-8'; case '.js': case '.mjs': return 'text/javascript; charset=utf-8'; case '.css': return 'text/css; charset=utf-8'; case '.json': return 'application/json; charset=utf-8'; case '.svg': return 'image/svg+xml'; case '.png': return 'image/png'; case '.jpg': case '.jpeg': return 'image/jpeg'; case '.webp': return 'image/webp'; case '.ico': return 'image/x-icon'; default: return 'application/octet-stream'; } } function resolveStaticFile(staticDir: string, pathname: string): string | null { const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, ''); const candidate = path.resolve(staticDir, normalizedPath || 'index.html'); const root = path.resolve(staticDir); if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) { return null; } if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { return candidate; } const indexPath = path.join(root, 'index.html'); if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { return indexPath; } return null; } function serveStaticFile(staticDir: string, pathname: string): Response { const filePath = resolveStaticFile(staticDir, pathname); if (!filePath) { return new Response('Not found', { status: 404 }); } return new Response(fs.readFileSync(filePath), { headers: { 'content-type': getContentType(filePath), }, }); } function extractDashboardAuthToken(request: Request): string { const authorization = request.headers.get('authorization') ?? ''; const bearer = authorization.match(/^Bearer\s+(.+)$/i); if (bearer) return bearer[1]!.trim(); return request.headers.get('x-ejclaw-dashboard-token')?.trim() ?? ''; } function safeTokenEquals(actual: string, expected: string): boolean { const actualBytes = Buffer.from(actual); const expectedBytes = Buffer.from(expected); if (actualBytes.length !== expectedBytes.length) return false; return timingSafeEqual(actualBytes, expectedBytes); } function isDashboardApiAuthorized(request: Request, token: string): boolean { if (!token) return true; const actual = extractDashboardAuthToken(request); return Boolean(actual) && safeTokenEquals(actual, token); } function rejectUnauthorizedApi( url: URL, request: Request, token: string, jsonResponse: JsonResponse, ): Response | null { if (!url.pathname.startsWith('/api/')) return null; if (isDashboardApiAuthorized(request, token)) return null; return jsonResponse( { error: 'Unauthorized' }, { status: 401, headers: { 'www-authenticate': 'Bearer' }, }, ); } function isRoot(): boolean { return typeof process.getuid === 'function' && process.getuid() === 0; } function restartEjclawStackServices(): string[] { if (process.platform !== 'linux') { throw new Error('Service restart only supports Linux systemd services'); } const services = ['ejclaw']; const systemctlArgs = isRoot() ? [] : ['--user']; try { execFileSync( 'systemctl', [...systemctlArgs, 'start', '--wait', STACK_RESTART_UNIT_NAME], { stdio: 'ignore' }, ); } catch (error) { const stderr = error && typeof error === 'object' && 'stderr' in error ? String((error as { stderr?: unknown }).stderr || '') : ''; const stdout = error && typeof error === 'object' && 'stdout' in error ? String((error as { stdout?: unknown }).stdout || '') : ''; const message = error instanceof Error ? error.message : `${stdout}\n${stderr}`.trim(); const combined = `${message}\n${stdout}\n${stderr}`; if (!UNIT_NOT_FOUND_PATTERN.test(combined)) { throw error; } if (process.env.SERVICE_ID) { throw new Error(MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE, { cause: error, }); } execFileSync('systemctl', [...systemctlArgs, 'restart', ...services], { stdio: 'ignore', }); for (const service of services) { execFileSync( 'systemctl', [...systemctlArgs, 'is-active', '--quiet', service], { stdio: 'ignore' }, ); } } return services; } export function createWebDashboardHandler( opts: WebDashboardHandlerOptions = {}, ): (request: Request) => Response | Promise { const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots; const loadTasks = opts.getTasks ?? getAllTasks; const loadTaskById = opts.getTaskById ?? getTaskById; const mutateTask = opts.updateTask ?? updateTask; const createScheduledTask = opts.createTask ?? createTask; const removeTask = opts.deleteTask ?? deleteTask; const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks; const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById; const mutatePairedTaskIfUnchanged = opts.updatePairedTaskIfUnchanged ?? updatePairedTaskIfUnchanged; const loadLatestPairedTaskForChat = opts.getLatestPairedTaskForChat ?? getLatestPairedTaskForChat; const loadPairedTurnsForTask = opts.getPairedTurnsForTask ?? getPairedTurnsForTask; const loadLatestPairedTurnForTask = opts.getLatestPairedTurnForTask ?? getLatestPairedTurnForTask; const loadPairedTurnOutputs = opts.getPairedTurnOutputs ?? getPairedTurnOutputs; const loadRecentPairedTurnOutputsForChat = opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat; const loadRecentDeliveredWorkItemsForChat = opts.getRecentDeliveredWorkItemsForChat ?? getRecentDeliveredWorkItemsForChat; const loadPairedTurnAttempts = opts.getPairedTurnAttempts ?? getPairedTurnAttempts; const loadRecentChatMessages = opts.getRecentChatMessages ?? getRecentChatMessages; const schedulePairedFollowUp = opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent; const loadRoomBindings = opts.getRoomBindings; const writeChatMetadata = opts.storeChatMetadata ?? storeChatMetadata; const writeMessage = opts.storeMessage ?? storeMessage; const messageExists = opts.hasMessage ?? hasMessage; const enqueueMessageCheck = opts.enqueueMessageCheck; const nudgeScheduler = opts.nudgeScheduler; const restartServiceStack = opts.restartServiceStack ?? restartEjclawStackServices; const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; const authToken = opts.authToken ?? WEB_DASHBOARD.token; const roomsTimelineDeps: RoomsTimelineRouteDependencies = { statusMaxAgeMs, readSnapshots, loadLatestPairedTaskForChat, loadPairedTurnsForTask, loadLatestPairedTurnForTask, loadPairedTurnAttempts, loadPairedTurnOutputs, loadRecentPairedTurnOutputsForChat, loadRecentDeliveredWorkItemsForChat, loadRecentChatMessages, }; if (opts.startBackgroundCacheRefresh !== false) { startRoomsTimelineCacheRefresh(roomsTimelineDeps); } const rememberRoomMessageId = createRoomMessageIdCache(); const inboxDismissTracker = createInboxDismissTracker(); const recentServiceRestarts: ServiceRestartRecord[] = []; const activeServiceRestartTargets = new Set(); return async (request: Request): Promise => { const url = new URL(request.url); const authFailure = rejectUnauthorizedApi( url, request, authToken, jsonResponse, ); if (authFailure) return authFailure; const scheduledTaskRoute = await handleScheduledTaskRoute({ url, request, jsonResponse, createScheduledTask, loadRoomBindings, loadTaskById, mutateTask, nudgeScheduler, removeTask, now: opts.now, }); if (scheduledTaskRoute) return scheduledTaskRoute; const inboxActionRoute = await handleInboxActionRoute({ url, request, jsonResponse, dismissTracker: inboxDismissTracker, enqueueMessageCheck, loadPairedTaskById, messageExists, mutatePairedTaskIfUnchanged, schedulePairedFollowUp, writeChatMetadata, writeMessage, now: opts.now, }); if (inboxActionRoute) return inboxActionRoute; const roomMessageRoute = await handleRoomMessageRoute({ url, request, jsonResponse, enqueueMessageCheck, loadRoomBindings, messageExists, rememberRoomMessageId, writeChatMetadata, writeMessage, now: opts.now, }); if (roomMessageRoute) return roomMessageRoute; const roomTimelineRoute = handleRoomTimelineRoute({ url, request, jsonResponse, ...roomsTimelineDeps, }); if (roomTimelineRoute) return roomTimelineRoute; const serviceRoute = await handleServiceRoute({ url, request, jsonResponse, recentServiceRestarts, activeServiceRestartTargets, restartServiceStack, now: opts.now, }); if (serviceRoute) return serviceRoute; const settingsRoute = await handleSettingsRoute({ url, request, jsonResponse, }); if (settingsRoute) return settingsRoute; if (request.method !== 'GET' && request.method !== 'HEAD') { return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); } const simpleGetRoute = handleSimpleGetRoute({ url, statusMaxAgeMs, readSnapshots, loadTasks, jsonResponse, }); if (simpleGetRoute) return simpleGetRoute; const overviewRoute = handleOverviewRoute({ url, jsonResponse, isInboxItemDismissed: inboxDismissTracker.isDismissed, loadPairedTasks, loadTasks, readSnapshots, recentServiceRestarts, statusMaxAgeMs, now: opts.now, }); if (overviewRoute) return overviewRoute; if (url.pathname.startsWith('/api/')) { return jsonResponse({ error: 'Not found' }, { status: 404 }); } if (!opts.staticDir) { return new Response('Dashboard static directory is not configured', { status: 404, }); } return serveStaticFile(opts.staticDir, url.pathname); }; } export function startWebDashboardServer( opts: { enabled?: boolean; host?: string; port?: number; staticDir?: string; getRoomBindings?: () => Record; enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void; nudgeScheduler?: () => void; } = {}, ): StartedWebDashboardServer | null { const enabled = opts.enabled ?? WEB_DASHBOARD.enabled; if (!enabled) return null; const host = opts.host ?? WEB_DASHBOARD.host; const port = opts.port ?? WEB_DASHBOARD.port; const staticDir = opts.staticDir ?? WEB_DASHBOARD.staticDir; const server = Bun.serve({ hostname: host, port, fetch: createWebDashboardHandler({ staticDir, getRoomBindings: opts.getRoomBindings, enqueueMessageCheck: opts.enqueueMessageCheck, nudgeScheduler: opts.nudgeScheduler, }), }); const url = `http://${host}:${server.port}`; logger.info({ url, staticDir }, 'Web dashboard started'); return { url, stop: () => server.stop(true), }; }