From d1008546f8fcc8be3f3b24afa5de92702da2ab0a Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 18:09:45 +0900 Subject: [PATCH] Extract dashboard settings routes (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/web-dashboard-server.ts | 186 +------------- src/web-dashboard-settings-routes.test.ts | 184 ++++++++++++++ src/web-dashboard-settings-routes.ts | 285 ++++++++++++++++++++++ 3 files changed, 476 insertions(+), 179 deletions(-) create mode 100644 src/web-dashboard-settings-routes.test.ts create mode 100644 src/web-dashboard-settings-routes.ts diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 9ba1b73..48974e2 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -47,20 +47,7 @@ import { sanitizeScheduledTask, } from './web-dashboard-data.js'; import { handleSimpleGetRoute } from './web-dashboard-routes.js'; -import { - addClaudeAccountFromToken, - getActiveCodexSettingsIndex, - getFastMode, - getModelConfig, - listClaudeAccounts, - listCodexAccounts, - refreshAllCodexAccounts, - refreshCodexAccount, - removeAccountDirectory, - setActiveCodexSettingsIndex, - updateFastMode, - updateModelConfig, -} from './settings-store.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; @@ -1258,155 +1245,12 @@ export function createWebDashboardHandler( } } - if ( - url.pathname === '/api/settings/models' && - (request.method === 'PUT' || request.method === 'PATCH') - ) { - let body: unknown = null; - try { - body = await request.json(); - } catch { - return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); - } - if (!body || typeof body !== 'object') { - return jsonResponse( - { error: 'Body must be a JSON object' }, - { status: 400 }, - ); - } - try { - const next = updateModelConfig(body as Record); - return jsonResponse(next); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ error: message }, { status: 500 }); - } - } - - if ( - url.pathname === '/api/settings/fast-mode' && - (request.method === 'PUT' || request.method === 'PATCH') - ) { - let body: unknown = null; - try { - body = await request.json(); - } catch { - return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); - } - if (!body || typeof body !== 'object') { - return jsonResponse( - { error: 'Body must be a JSON object' }, - { status: 400 }, - ); - } - try { - const next = updateFastMode(body as Record); - return jsonResponse(next); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ error: message }, { status: 500 }); - } - } - - { - const accountAddMatch = url.pathname.match( - /^\/api\/settings\/accounts\/(claude)$/, - ); - if (accountAddMatch && request.method === 'POST') { - let body: { token?: unknown } | null = null; - try { - body = (await request.json()) as { token?: unknown }; - } catch { - return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); - } - const token = typeof body?.token === 'string' ? body.token.trim() : ''; - if (!token) { - return jsonResponse({ error: 'token is required' }, { status: 400 }); - } - try { - const result = addClaudeAccountFromToken(token); - return jsonResponse({ ok: true, ...result }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ error: message }, { status: 400 }); - } - } - } - - { - const accountDelMatch = url.pathname.match( - /^\/api\/settings\/accounts\/(claude|codex)\/(\d+)$/, - ); - if (accountDelMatch && request.method === 'DELETE') { - const provider = accountDelMatch[1] as 'claude' | 'codex'; - const index = Number.parseInt(accountDelMatch[2], 10); - try { - removeAccountDirectory(provider, index); - return jsonResponse({ ok: true, provider, index }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ error: message }, { status: 400 }); - } - } - } - - { - const refreshMatch = url.pathname.match( - /^\/api\/settings\/accounts\/codex\/(\d+)\/refresh$/, - ); - if (refreshMatch && request.method === 'POST') { - const index = Number.parseInt(refreshMatch[1], 10); - try { - const updated = await refreshCodexAccount(index); - return jsonResponse({ ok: true, account: updated }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ error: message }, { status: 400 }); - } - } - } - - if ( - url.pathname === '/api/settings/accounts/codex/refresh-all' && - request.method === 'POST' - ) { - try { - const result = await refreshAllCodexAccounts(); - return jsonResponse({ ok: true, ...result }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ error: message }, { status: 500 }); - } - } - - if ( - url.pathname === '/api/settings/accounts/codex/current' && - request.method === 'PUT' - ) { - let body: { index?: unknown } | null = null; - try { - body = (await request.json()) as { index?: unknown }; - } catch { - return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); - } - const idx = typeof body?.index === 'number' ? body.index : Number.NaN; - if (!Number.isInteger(idx)) { - return jsonResponse( - { error: 'index must be an integer' }, - { status: 400 }, - ); - } - try { - setActiveCodexSettingsIndex(idx); - return jsonResponse({ - ok: true, - codexCurrentIndex: getActiveCodexSettingsIndex(), - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return jsonResponse({ error: message }, { status: 400 }); - } - } + const settingsRoute = await handleSettingsRoute({ + url, + request, + jsonResponse, + }); + if (settingsRoute) return settingsRoute; if (url.pathname === '/api/tasks' && request.method === 'POST') { if (!loadRoomBindings) { @@ -1599,22 +1443,6 @@ export function createWebDashboardHandler( }); } - if (url.pathname === '/api/settings/accounts') { - return jsonResponse({ - claude: listClaudeAccounts(), - codex: listCodexAccounts(), - codexCurrentIndex: getActiveCodexSettingsIndex(), - }); - } - - if (url.pathname === '/api/settings/models') { - return jsonResponse(getModelConfig()); - } - - if (url.pathname === '/api/settings/fast-mode') { - return jsonResponse(getFastMode()); - } - if (url.pathname === '/api/stream') { const encoder = new TextEncoder(); const stream = new ReadableStream({ diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts new file mode 100644 index 0000000..04e671c --- /dev/null +++ b/src/web-dashboard-settings-routes.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; + +import { + handleSettingsRoute, + type SettingsRouteDependencies, +} from './web-dashboard-settings-routes.js'; +import type { + ClaudeAccountSummary, + CodexAccountSummary, + FastModeSnapshot, + ModelConfigSnapshot, +} from './settings-store.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 = 'GET', + 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' }, + }); +} + +const modelConfig: ModelConfigSnapshot = { + owner: { model: 'gpt-5.4', effort: 'medium' }, + reviewer: { model: 'claude-sonnet', effort: 'high' }, + arbiter: { model: 'gpt-5.4', effort: 'high' }, +}; + +const fastMode: FastModeSnapshot = { codex: true, claude: false }; + +function makeClaudeAccount(): ClaudeAccountSummary { + return { + index: 0, + expiresAt: 1777348544000, + scopes: ['openid'], + exists: true, + }; +} + +function makeCodexAccount(): CodexAccountSummary { + return { + index: 1, + accountId: 'acct_1', + email: 'codex@example.com', + planType: 'pro', + subscriptionUntil: null, + subscriptionLastChecked: '2026-04-28T08:00:00.000Z', + exists: true, + }; +} + +function makeDeps( + overrides: Partial = {}, +): SettingsRouteDependencies { + return { + addClaudeAccountFromToken: () => ({ index: 2, accountId: null }), + getActiveCodexSettingsIndex: () => 1, + getFastMode: () => fastMode, + getModelConfig: () => modelConfig, + listClaudeAccounts: () => [makeClaudeAccount()], + listCodexAccounts: () => [makeCodexAccount()], + refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }), + refreshCodexAccount: async () => makeCodexAccount(), + removeAccountDirectory: () => undefined, + setActiveCodexSettingsIndex: () => undefined, + updateFastMode: () => fastMode, + updateModelConfig: () => modelConfig, + ...overrides, + }; +} + +async function route( + pathname: string, + method = 'GET', + body?: Record, + deps: SettingsRouteDependencies = makeDeps(), +): Promise { + return handleSettingsRoute({ + url: new URL(`http://localhost${pathname}`), + request: request(pathname, method, body), + jsonResponse, + deps, + }); +} + +describe('web dashboard settings routes', () => { + it('serves settings snapshots without touching the server router', async () => { + const accounts = await route('/api/settings/accounts'); + expect(accounts?.status).toBe(200); + await expect(accounts?.json()).resolves.toMatchObject({ + claude: [{ index: 0, exists: true }], + codex: [{ index: 1, email: 'codex@example.com' }], + codexCurrentIndex: 1, + }); + + const models = await route('/api/settings/models'); + expect(models?.status).toBe(200); + await expect(models?.json()).resolves.toEqual(modelConfig); + + const mode = await route('/api/settings/fast-mode'); + expect(mode?.status).toBe(200); + await expect(mode?.json()).resolves.toEqual(fastMode); + + const unmatched = await route('/api/overview'); + expect(unmatched).toBeNull(); + }); + + it('handles settings mutations through injected dependencies', async () => { + const calls: string[] = []; + const deps = makeDeps({ + addClaudeAccountFromToken: (token) => { + calls.push(`add:${token}`); + return { index: 3, accountId: null }; + }, + removeAccountDirectory: (provider, index) => { + calls.push(`delete:${provider}:${index}`); + }, + setActiveCodexSettingsIndex: (index) => { + calls.push(`current:${index}`); + }, + updateModelConfig: (input) => { + calls.push(`models:${JSON.stringify(input)}`); + return modelConfig; + }, + }); + + const modelUpdate = await route( + '/api/settings/models', + 'PATCH', + { owner: { model: 'gpt-5.5' } }, + deps, + ); + expect(modelUpdate?.status).toBe(200); + + const add = await route( + '/api/settings/accounts/claude', + 'POST', + { token: ' claude-token ' }, + deps, + ); + expect(add?.status).toBe(200); + await expect(add?.json()).resolves.toMatchObject({ ok: true, index: 3 }); + + const del = await route( + '/api/settings/accounts/codex/4', + 'DELETE', + undefined, + deps, + ); + expect(del?.status).toBe(200); + + const current = await route( + '/api/settings/accounts/codex/current', + 'PUT', + { index: 4 }, + deps, + ); + expect(current?.status).toBe(200); + await expect(current?.json()).resolves.toMatchObject({ + ok: true, + codexCurrentIndex: 1, + }); + expect(calls).toEqual([ + 'models:{"owner":{"model":"gpt-5.5"}}', + 'add:claude-token', + 'delete:codex:4', + 'current:4', + ]); + }); +}); diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts new file mode 100644 index 0000000..6b61889 --- /dev/null +++ b/src/web-dashboard-settings-routes.ts @@ -0,0 +1,285 @@ +import { + addClaudeAccountFromToken, + getActiveCodexSettingsIndex, + getFastMode, + getModelConfig, + listClaudeAccounts, + listCodexAccounts, + refreshAllCodexAccounts, + refreshCodexAccount, + removeAccountDirectory, + setActiveCodexSettingsIndex, + updateFastMode, + updateModelConfig, + type ClaudeAccountSummary, + type CodexAccountSummary, + type FastModeSnapshot, + type ModelConfigSnapshot, +} from './settings-store.js'; + +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + +export interface SettingsRouteDependencies { + addClaudeAccountFromToken: typeof addClaudeAccountFromToken; + getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex; + getFastMode: typeof getFastMode; + getModelConfig: typeof getModelConfig; + listClaudeAccounts: typeof listClaudeAccounts; + listCodexAccounts: typeof listCodexAccounts; + refreshAllCodexAccounts: typeof refreshAllCodexAccounts; + refreshCodexAccount: typeof refreshCodexAccount; + removeAccountDirectory: typeof removeAccountDirectory; + setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex; + updateFastMode: typeof updateFastMode; + updateModelConfig: typeof updateModelConfig; +} + +interface SettingsRouteContext { + url: URL; + request: Request; + jsonResponse: JsonResponse; + deps?: SettingsRouteDependencies; +} + +const defaultSettingsRouteDependencies: SettingsRouteDependencies = { + addClaudeAccountFromToken, + getActiveCodexSettingsIndex, + getFastMode, + getModelConfig, + listClaudeAccounts, + listCodexAccounts, + refreshAllCodexAccounts, + refreshCodexAccount, + removeAccountDirectory, + setActiveCodexSettingsIndex, + updateFastMode, + updateModelConfig, +}; + +function readMethod(method: string): boolean { + return method === 'GET' || method === 'HEAD'; +} + +async function readJsonObject( + request: Request, + jsonResponse: JsonResponse, +): Promise | Response> { + let body: unknown = null; + try { + body = await request.json(); + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return jsonResponse( + { error: 'Body must be a JSON object' }, + { status: 400 }, + ); + } + return body as Record; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +async function handleModelSettingsRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (readMethod(request.method)) return jsonResponse(deps.getModelConfig()); + if (request.method !== 'PUT' && request.method !== 'PATCH') return null; + + const body = await readJsonObject(request, jsonResponse); + if (body instanceof Response) return body; + try { + return jsonResponse(deps.updateModelConfig(body)); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 500 }); + } +} + +async function handleFastModeRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (readMethod(request.method)) return jsonResponse(deps.getFastMode()); + if (request.method !== 'PUT' && request.method !== 'PATCH') return null; + + const body = await readJsonObject(request, jsonResponse); + if (body instanceof Response) return body; + try { + return jsonResponse(deps.updateFastMode(body)); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 500 }); + } +} + +function handleAccountsRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Response | null { + if (!readMethod(request.method)) return null; + return jsonResponse({ + claude: deps.listClaudeAccounts(), + codex: deps.listCodexAccounts(), + codexCurrentIndex: deps.getActiveCodexSettingsIndex(), + }); +} + +async function handleClaudeAccountAddRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (request.method !== 'POST') return null; + + let body: { token?: unknown } | null = null; + try { + body = (await request.json()) as { token?: unknown }; + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); + } + const token = typeof body?.token === 'string' ? body.token.trim() : ''; + if (!token) + return jsonResponse({ error: 'token is required' }, { status: 400 }); + + try { + const result = deps.addClaudeAccountFromToken(token); + return jsonResponse({ ok: true, ...result }); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 400 }); + } +} + +function handleAccountDeleteRoute( + request: Request, + accountMatch: RegExpMatchArray, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Response | null { + if (request.method !== 'DELETE') return null; + const provider = accountMatch[1] as 'claude' | 'codex'; + const index = Number.parseInt(accountMatch[2], 10); + try { + deps.removeAccountDirectory(provider, index); + return jsonResponse({ ok: true, provider, index }); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 400 }); + } +} + +async function handleCodexRefreshRoute( + request: Request, + refreshMatch: RegExpMatchArray, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (request.method !== 'POST') return null; + const index = Number.parseInt(refreshMatch[1], 10); + try { + const updated = await deps.refreshCodexAccount(index); + return jsonResponse({ ok: true, account: updated }); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 400 }); + } +} + +async function handleCodexRefreshAllRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (request.method !== 'POST') return null; + try { + const result = await deps.refreshAllCodexAccounts(); + return jsonResponse({ ok: true, ...result }); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 500 }); + } +} + +async function handleCodexCurrentRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (request.method !== 'PUT') return null; + + let body: { index?: unknown } | null = null; + try { + body = (await request.json()) as { index?: unknown }; + } catch { + return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 }); + } + const idx = typeof body?.index === 'number' ? body.index : Number.NaN; + if (!Number.isInteger(idx)) { + return jsonResponse({ error: 'index must be an integer' }, { status: 400 }); + } + + try { + deps.setActiveCodexSettingsIndex(idx); + return jsonResponse({ + ok: true, + codexCurrentIndex: deps.getActiveCodexSettingsIndex(), + }); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 400 }); + } +} + +export async function handleSettingsRoute({ + url, + request, + jsonResponse, + deps = defaultSettingsRouteDependencies, +}: SettingsRouteContext): Promise { + if (url.pathname === '/api/settings/accounts') { + return handleAccountsRoute(request, jsonResponse, deps); + } + if (url.pathname === '/api/settings/models') { + return handleModelSettingsRoute(request, jsonResponse, deps); + } + if (url.pathname === '/api/settings/fast-mode') { + return handleFastModeRoute(request, jsonResponse, deps); + } + if (url.pathname === '/api/settings/accounts/claude') { + return handleClaudeAccountAddRoute(request, jsonResponse, deps); + } + if (url.pathname === '/api/settings/accounts/codex/refresh-all') { + return handleCodexRefreshAllRoute(request, jsonResponse, deps); + } + if (url.pathname === '/api/settings/accounts/codex/current') { + return handleCodexCurrentRoute(request, jsonResponse, deps); + } + + const accountMatch = url.pathname.match( + /^\/api\/settings\/accounts\/(claude|codex)\/(\d+)$/, + ); + if (accountMatch) { + return handleAccountDeleteRoute(request, accountMatch, jsonResponse, deps); + } + + const refreshMatch = url.pathname.match( + /^\/api\/settings\/accounts\/codex\/(\d+)\/refresh$/, + ); + if (refreshMatch) { + return handleCodexRefreshRoute(request, refreshMatch, jsonResponse, deps); + } + + return null; +} + +export type { + ClaudeAccountSummary, + CodexAccountSummary, + FastModeSnapshot, + ModelConfigSnapshot, +};