Extract dashboard service routes (#72)

* review: harden readonly git checks and lint verification

* test: satisfy readonly reviewer sandbox typing

* test: fix readonly reviewer sandbox assertions

* test: remove impossible readonly sandbox guards

* test: relax readonly sandbox assertions

* test: cast readonly sandbox expectation shape

* test: cast readonly sandbox expectation through unknown

* Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke

* Add STEP_DONE guards, verdict storage, and stale delivery suppression

* style: format paired stepdone telemetry files

* Route STEP_DONE through reviewer

* Add structured Discord attachments

* style: format structured attachment test

* Fix room thread bot output parity

* Remove duplicate work item attachment migration from owner branch

* Remove legacy dashboard rooms renderer

* Extract dashboard parsed body renderer

* Extract dashboard redaction helpers

* Extract dashboard RoomCardV2 component

* Include RoomCardV2 test in vitest suite

* Extract dashboard RoomBoardV2 component

* Extract dashboard EmptyState component

* Extract dashboard InboxPanel component

* Split dashboard InboxPanel card renderer

* Extract dashboard TaskPanel component

* Extract dashboard UsagePanel component

* Fix dashboard room thread chunk rendering

* Extract dashboard ServicePanel component

* Render dashboard live progress markdown

* Extract dashboard SettingsPanel component

* Fix structured attachment rendering

* Extract dashboard simple route table

* Extract dashboard settings routes

* Extract dashboard service routes
This commit is contained in:
Eyejoker
2026-04-28 18:26:46 +09:00
committed by GitHub
parent d1008546f8
commit 3a68db4854
3 changed files with 362 additions and 146 deletions

View File

@@ -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<ServiceActionRequest | null> {
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<Response> => {
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,

View File

@@ -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<string, string> | undefined),
},
});
}
function request(
pathname: string,
method = 'POST',
body?: Record<string, unknown>,
): 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<string>(),
body,
method,
now,
pathname,
recentServiceRestarts = [],
restartServiceStack = () => ['ejclaw'],
}: {
activeServiceRestartTargets?: Set<string>;
body?: Record<string, unknown>;
method?: string;
now?: () => string;
pathname: string;
recentServiceRestarts?: ServiceRestartRecord[];
restartServiceStack?: () => string[];
}): Promise<Response | null> {
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);
});
});

View File

@@ -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<string>;
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<ServiceActionRequest | null> {
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<Response | null> {
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);
}
}