From 77fe00e4b8554105d136baceb4e580f42e790df2 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Tue, 28 Apr 2026 18:19:42 +0900 Subject: [PATCH] Extract dashboard service routes --- src/web-dashboard-server.ts | 161 ++------------------- src/web-dashboard-service-routes.test.ts | 172 ++++++++++++++++++++++ src/web-dashboard-service-routes.ts | 175 +++++++++++++++++++++++ 3 files changed, 362 insertions(+), 146 deletions(-) create mode 100644 src/web-dashboard-service-routes.test.ts create mode 100644 src/web-dashboard-service-routes.ts diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 48974e2..5ec0a6c 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -47,11 +47,14 @@ import { sanitizeScheduledTask, } from './web-dashboard-data.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'; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; -const SERVICE_RESTART_LOG_LIMIT = 20; const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service'; const WEB_TASK_PROMPT_MAX_LENGTH = 8000; const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = @@ -72,15 +75,7 @@ type WebPairedFollowUpScheduler = (args: { enqueue: () => void; }) => boolean; -export interface ServiceRestartRecord { - id: string; - target: 'stack'; - requestedAt: string; - completedAt: string | null; - status: 'running' | 'success' | 'failed'; - services: string[]; - error?: string; -} +export type { ServiceRestartRecord } from './web-dashboard-service-routes.js'; export interface WebDashboardHandlerOptions { staticDir?: string; @@ -258,7 +253,6 @@ function serveStaticFile(staticDir: string, pathname: string): Response { type TaskAction = 'pause' | 'resume' | 'cancel'; type InboxAction = 'run' | 'decline' | 'dismiss'; -type ServiceAction = 'restart'; interface InboxActionRequest { action: InboxAction; @@ -266,11 +260,6 @@ interface InboxActionRequest { lastOccurredAt: string | null; } -interface ServiceActionRequest { - action: ServiceAction; - requestId: string | null; -} - function parseTaskPath(pathname: string): string | null { const match = pathname.match(/^\/api\/tasks\/([^/]+)$/); if (!match) return null; @@ -321,16 +310,6 @@ function parseRoomTimelinePath(pathname: string): string | null { } } -function parseServiceActionPath(pathname: string): string | null { - const match = pathname.match(/^\/api\/services\/([^/]+)\/actions$/); - if (!match) return null; - try { - return decodeURIComponent(match[1]); - } catch { - return null; - } -} - function parsePairedInboxTarget( inboxId: string, ): { taskId: string; status: PairedTask['status'] } | null { @@ -368,10 +347,6 @@ function isInboxAction(value: unknown): value is InboxAction { return value === 'run' || value === 'decline' || value === 'dismiss'; } -function isServiceAction(value: unknown): value is ServiceAction { - return value === 'restart'; -} - function isPairedTaskStatus(value: unknown): value is PairedTask['status'] { return ( value === 'active' || @@ -416,24 +391,6 @@ async function readInboxAction( } } -async function readServiceAction( - request: Request, -): Promise { - try { - const body = (await request.json()) as { - action?: unknown; - requestId?: unknown; - }; - if (!isServiceAction(body.action)) return null; - return { - action: body.action, - requestId: sanitizeServiceActionRequestId(body.requestId), - }; - } catch { - return null; - } -} - interface ScheduledTaskMutationBody { roomJid?: unknown; groupFolder?: unknown; @@ -585,20 +542,6 @@ function makeWebRunId(prefix: string): string { return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -function sanitizeServiceActionRequestId(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); - return safe || null; -} - -function makeServiceRestartId(requestId: string | null): string { - return requestId - ? `web-restart-${requestId}` - : `web-restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - function sanitizeInboxActionRequestId(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -862,13 +805,6 @@ export function createWebDashboardHandler( ); } - function rememberServiceRestart(record: ServiceRestartRecord): void { - recentServiceRestarts.unshift(record); - if (recentServiceRestarts.length > SERVICE_RESTART_LOG_LIMIT) { - recentServiceRestarts.length = SERVICE_RESTART_LOG_LIMIT; - } - } - return async (request: Request): Promise => { const url = new URL(request.url); const actionTaskId = parseTaskActionPath(url.pathname); @@ -876,7 +812,6 @@ export function createWebDashboardHandler( const actionInboxId = parseInboxActionPath(url.pathname); const messageRoomJid = parseRoomMessagePath(url.pathname); const timelineRoomJid = parseRoomTimelinePath(url.pathname); - const actionServiceId = parseServiceActionPath(url.pathname); if (actionTaskId) { if (request.method !== 'POST') { @@ -1168,82 +1103,16 @@ export function createWebDashboardHandler( ); } - if (actionServiceId) { - if (request.method !== 'POST') { - return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); - } - if (actionServiceId !== 'stack') { - return jsonResponse( - { error: 'Unsupported service restart target' }, - { status: 400 }, - ); - } - - const serviceRequest = await readServiceAction(request); - if (!serviceRequest) { - return jsonResponse( - { error: 'Invalid service action' }, - { status: 400 }, - ); - } - - const id = makeServiceRestartId(serviceRequest.requestId); - const previous = recentServiceRestarts.find((record) => record.id === id); - if (serviceRequest.requestId && previous) { - if (previous.status === 'failed') { - return jsonResponse( - { - error: previous.error ?? 'Service restart failed', - duplicate: true, - restart: previous, - }, - { status: 500 }, - ); - } - return jsonResponse({ - ok: true, - duplicate: true, - restart: previous, - }); - } - - if (activeServiceRestartTargets.has(actionServiceId)) { - return jsonResponse( - { error: 'Service restart is already running' }, - { status: 409 }, - ); - } - - const requestedAt = opts.now?.() ?? new Date().toISOString(); - const record: ServiceRestartRecord = { - id, - target: 'stack', - requestedAt, - completedAt: null, - status: 'running', - services: [], - }; - rememberServiceRestart(record); - activeServiceRestartTargets.add(actionServiceId); - - try { - const services = restartServiceStack(); - record.completedAt = opts.now?.() ?? new Date().toISOString(); - record.status = 'success'; - record.services = services; - return jsonResponse({ ok: true, restart: record }); - } catch (error) { - record.completedAt = opts.now?.() ?? new Date().toISOString(); - record.status = 'failed'; - record.error = error instanceof Error ? error.message : String(error); - return jsonResponse( - { error: record.error, restart: record }, - { status: 500 }, - ); - } finally { - activeServiceRestartTargets.delete(actionServiceId); - } - } + const serviceRoute = await handleServiceRoute({ + url, + request, + jsonResponse, + recentServiceRestarts, + activeServiceRestartTargets, + restartServiceStack, + now: opts.now, + }); + if (serviceRoute) return serviceRoute; const settingsRoute = await handleSettingsRoute({ url, diff --git a/src/web-dashboard-service-routes.test.ts b/src/web-dashboard-service-routes.test.ts new file mode 100644 index 0000000..c8b2857 --- /dev/null +++ b/src/web-dashboard-service-routes.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; + +import { + handleServiceRoute, + 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 request( + pathname: string, + method = 'POST', + body?: Record, +): Request { + return new Request(`http://localhost${pathname}`, { + method, + body: body === undefined ? undefined : JSON.stringify(body), + headers: + body === undefined ? undefined : { 'content-type': 'application/json' }, + }); +} + +async function route({ + activeServiceRestartTargets = new Set(), + body, + method, + now, + pathname, + recentServiceRestarts = [], + restartServiceStack = () => ['ejclaw'], +}: { + activeServiceRestartTargets?: Set; + body?: Record; + method?: string; + now?: () => string; + pathname: string; + recentServiceRestarts?: ServiceRestartRecord[]; + restartServiceStack?: () => string[]; +}): Promise { + return handleServiceRoute({ + url: new URL(`http://localhost${pathname}`), + request: request(pathname, method, body), + jsonResponse, + recentServiceRestarts, + activeServiceRestartTargets, + restartServiceStack, + now, + }); +} + +describe('web dashboard service routes', () => { + it('handles stack restarts and duplicate request ids', async () => { + const recentServiceRestarts: ServiceRestartRecord[] = []; + let restartCalls = 0; + + const restart = async () => + route({ + pathname: '/api/services/stack/actions', + body: { action: 'restart', requestId: 'stack-restart-1' }, + recentServiceRestarts, + restartServiceStack: () => { + restartCalls += 1; + return ['ejclaw', 'reviewer']; + }, + now: () => '2026-04-28T09:20:00.000Z', + }); + + const response = await restart(); + expect(response?.status).toBe(200); + await expect(response?.json()).resolves.toMatchObject({ + ok: true, + restart: { + id: 'web-restart-stack-restart-1', + target: 'stack', + requestedAt: '2026-04-28T09:20:00.000Z', + completedAt: '2026-04-28T09:20:00.000Z', + status: 'success', + services: ['ejclaw', 'reviewer'], + }, + }); + expect(recentServiceRestarts).toHaveLength(1); + expect(restartCalls).toBe(1); + + const duplicate = await restart(); + expect(duplicate?.status).toBe(200); + await expect(duplicate?.json()).resolves.toMatchObject({ + ok: true, + duplicate: true, + restart: { + id: 'web-restart-stack-restart-1', + status: 'success', + }, + }); + expect(restartCalls).toBe(1); + + const unmatched = await route({ pathname: '/api/overview' }); + expect(unmatched).toBeNull(); + }); + + it('rejects invalid service actions without restarting', async () => { + const recentServiceRestarts: ServiceRestartRecord[] = [ + { + id: 'web-restart-failed-request', + target: 'stack', + requestedAt: '2026-04-28T09:10:00.000Z', + completedAt: '2026-04-28T09:10:01.000Z', + status: 'failed', + services: [], + error: 'systemctl failed', + }, + ]; + let restartCalls = 0; + const restartServiceStack = () => { + restartCalls += 1; + return ['ejclaw']; + }; + + const active = await route({ + pathname: '/api/services/stack/actions', + body: { action: 'restart' }, + activeServiceRestartTargets: new Set(['stack']), + restartServiceStack, + }); + expect(active?.status).toBe(409); + + const invalidAction = await route({ + pathname: '/api/services/stack/actions', + body: { action: 'stop' }, + restartServiceStack, + }); + expect(invalidAction?.status).toBe(400); + + const invalidTarget = await route({ + pathname: '/api/services/ejclaw/actions', + body: { action: 'restart' }, + restartServiceStack, + }); + expect(invalidTarget?.status).toBe(400); + + const wrongMethod = await route({ + pathname: '/api/services/stack/actions', + method: 'GET', + restartServiceStack, + }); + expect(wrongMethod?.status).toBe(405); + + const failedDuplicate = await route({ + pathname: '/api/services/stack/actions', + body: { action: 'restart', requestId: 'failed-request' }, + recentServiceRestarts, + restartServiceStack, + }); + expect(failedDuplicate?.status).toBe(500); + await expect(failedDuplicate?.json()).resolves.toMatchObject({ + error: 'systemctl failed', + duplicate: true, + restart: { + id: 'web-restart-failed-request', + status: 'failed', + }, + }); + expect(restartCalls).toBe(0); + }); +}); diff --git a/src/web-dashboard-service-routes.ts b/src/web-dashboard-service-routes.ts new file mode 100644 index 0000000..b91cdbf --- /dev/null +++ b/src/web-dashboard-service-routes.ts @@ -0,0 +1,175 @@ +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + +type ServiceAction = 'restart'; + +interface ServiceActionRequest { + action: ServiceAction; + requestId: string | null; +} + +export interface ServiceRestartRecord { + id: string; + target: 'stack'; + requestedAt: string; + completedAt: string | null; + status: 'running' | 'success' | 'failed'; + services: string[]; + error?: string; +} + +interface ServiceRouteContext { + url: URL; + request: Request; + jsonResponse: JsonResponse; + recentServiceRestarts: ServiceRestartRecord[]; + activeServiceRestartTargets: Set; + restartServiceStack: () => string[]; + now?: () => string; +} + +const SERVICE_RESTART_LOG_LIMIT = 20; + +function parseServiceActionPath(pathname: string): string | null { + const match = pathname.match(/^\/api\/services\/([^/]+)\/actions$/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + +function isServiceAction(value: unknown): value is ServiceAction { + return value === 'restart'; +} + +function sanitizeServiceActionRequestId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); + return safe || null; +} + +function makeServiceRestartId(requestId: string | null): string { + return requestId + ? `web-restart-${requestId}` + : `web-restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function readServiceAction( + request: Request, +): Promise { + try { + const body = (await request.json()) as { + action?: unknown; + requestId?: unknown; + }; + if (!isServiceAction(body.action)) return null; + return { + action: body.action, + requestId: sanitizeServiceActionRequestId(body.requestId), + }; + } catch { + return null; + } +} + +function rememberServiceRestart( + records: ServiceRestartRecord[], + record: ServiceRestartRecord, +): void { + records.unshift(record); + if (records.length > SERVICE_RESTART_LOG_LIMIT) { + records.length = SERVICE_RESTART_LOG_LIMIT; + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export async function handleServiceRoute({ + url, + request, + jsonResponse, + recentServiceRestarts, + activeServiceRestartTargets, + restartServiceStack, + now, +}: ServiceRouteContext): Promise { + const actionServiceId = parseServiceActionPath(url.pathname); + if (!actionServiceId) return null; + + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + if (actionServiceId !== 'stack') { + return jsonResponse( + { error: 'Unsupported service restart target' }, + { status: 400 }, + ); + } + + const serviceRequest = await readServiceAction(request); + if (!serviceRequest) { + return jsonResponse({ error: 'Invalid service action' }, { status: 400 }); + } + + const id = makeServiceRestartId(serviceRequest.requestId); + const previous = recentServiceRestarts.find((record) => record.id === id); + if (serviceRequest.requestId && previous) { + if (previous.status === 'failed') { + return jsonResponse( + { + error: previous.error ?? 'Service restart failed', + duplicate: true, + restart: previous, + }, + { status: 500 }, + ); + } + return jsonResponse({ ok: true, duplicate: true, restart: previous }); + } + + if (activeServiceRestartTargets.has(actionServiceId)) { + return jsonResponse( + { error: 'Service restart is already running' }, + { status: 409 }, + ); + } + + const requestedAt = now?.() ?? new Date().toISOString(); + const record: ServiceRestartRecord = { + id, + target: 'stack', + requestedAt, + completedAt: null, + status: 'running', + services: [], + }; + rememberServiceRestart(recentServiceRestarts, record); + activeServiceRestartTargets.add(actionServiceId); + + try { + const services = restartServiceStack(); + record.completedAt = now?.() ?? new Date().toISOString(); + record.status = 'success'; + record.services = services; + return jsonResponse({ ok: true, restart: record }); + } catch (err) { + record.completedAt = now?.() ?? new Date().toISOString(); + record.status = 'failed'; + record.error = errorMessage(err); + return jsonResponse( + { error: record.error, restart: record }, + { status: 500 }, + ); + } finally { + activeServiceRestartTargets.delete(actionServiceId); + } +}