From a9d83ec4e306c66050d2f9593e99835c38d3dd62 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 22 Mar 2026 23:55:39 +0100 Subject: [PATCH] chore: version packages Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 14 ++ CLAUDE.md | 6 +- package.json | 2 +- src/bash-tool-parser.ts | 15 +- src/web/public/app.js | 30 ++-- src/web/public/panels-ui.js | 16 +-- src/web/public/ralph-panel.js | 8 +- src/web/route-helpers.ts | 26 ++++ src/web/routes/case-routes.ts | 14 +- src/web/routes/hook-event-routes.ts | 8 +- src/web/routes/orchestrator-routes.ts | 15 +- src/web/routes/plan-routes.ts | 36 ++--- src/web/routes/push-routes.ts | 14 +- src/web/routes/ralph-routes.ts | 81 +++-------- src/web/routes/respawn-routes.ts | 14 +- src/web/routes/scheduled-routes.ts | 7 +- src/web/routes/session-routes.ts | 199 ++++++-------------------- src/web/routes/system-routes.ts | 39 ++--- 18 files changed, 175 insertions(+), 369 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13852e8b..9bda48a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # aicodeman +## 0.5.1 + +### Patch Changes + +- refactor: codebase cleanup — extract route helpers, eliminate boilerplate, optimize hot paths + - Add `parseBody()` helper to route-helpers.ts: validates request body against Zod schema with structured 400 error on failure, replacing 37 identical safeParse + error-check blocks across 10 route files + - Add `persistAndBroadcastSession()` helper: combines persist + SessionUpdated broadcast into one call, replacing 5 repeated 2-line pairs + - Migrate session-routes.ts to use `findSessionOrFail()` consistently (17 inline session lookups replaced) and `parseBody()` (12 patterns) + - Migrate ralph-routes.ts to use `findSessionOrFail()` (9 lookups) and `parseBody()` (4 patterns) + - Migrate 8 remaining route files to use `parseBody()` (21 patterns total) + - Fix O(n log n) eviction in bash-tool-parser.ts: replace `Array.from().sort()[0]` with O(n) min-scan for oldest active tool + - Extract `_debouncedCall()` utility in frontend: replaces 4 manual debounce patterns (7 lines each → 1 line) in app.js, panels-ui.js, ralph-panel.js + - Net reduction: 208 lines removed across 16 files + ## 0.5.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index 3d104b36..37df8a02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ When user says "COM": 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` -**Version**: 0.5.0 (must match `package.json`) +**Version**: 0.5.1 (must match `package.json`) ## Project Overview @@ -111,7 +111,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | | **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | | **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | | -| **Types** | `src/types/index.ts` → 15 domain files | See `@fileoverview` in index.ts | +| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts | ★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. @@ -119,7 +119,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Config**: `src/config/` — 9 files. Import from specific files, not barrel. -**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks). +**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority). ### Data Flow diff --git a/package.json b/package.json index 0698f3ae..335d58d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.5.0", + "version": "0.5.1", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/bash-tool-parser.ts b/src/bash-tool-parser.ts index 7f7a4a11..4ec5cdfa 100644 --- a/src/bash-tool-parser.ts +++ b/src/bash-tool-parser.ts @@ -498,10 +498,17 @@ export class BashToolParser extends EventEmitter { // Enforce max tools limit if (this._activeTools.size >= MAX_ACTIVE_TOOLS) { - // Remove oldest tool - const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0]; - if (oldest) { - this._activeTools.delete(oldest[0]); + // Remove oldest tool (O(n) min-scan instead of O(n log n) sort) + let oldestKey: string | undefined; + let oldestTime = Infinity; + for (const [key, entry] of this._activeTools) { + if (entry.startedAt < oldestTime) { + oldestTime = entry.startedAt; + oldestKey = key; + } + } + if (oldestKey) { + this._activeTools.delete(oldestKey); } } diff --git a/src/web/public/app.js b/src/web/public/app.js index e1e6fc78..39bf4a1b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -380,11 +380,8 @@ class CodemanApp { this.flickerFilterActive = false; this.flickerFilterTimeout = null; - // Render debouncing - this.renderSessionTabsTimeout = null; - this.renderRalphStatePanelTimeout = null; - this.renderTaskPanelTimeout = null; - this.renderMuxSessionsTimeout = null; + // Render debounce timers (managed by _debouncedCall) + this._debounceTimers = Object.create(null); // System stats polling this.systemStatsInterval = null; @@ -1543,18 +1540,27 @@ class CodemanApp { } } + // ═══════════════════════════════════════════════════════════════ + // Debounce Utility + // ═══════════════════════════════════════════════════════════════ + + /** Debounce a method call using a named timer key. */ + _debouncedCall(timerKey, fn, delayMs = 100) { + if (this._debounceTimers[timerKey]) { + clearTimeout(this._debounceTimers[timerKey]); + } + this._debounceTimers[timerKey] = setTimeout(() => { + this._debounceTimers[timerKey] = null; + fn.call(this); + }, delayMs); + } + // ═══════════════════════════════════════════════════════════════ // Session Tabs // ═══════════════════════════════════════════════════════════════ renderSessionTabs() { - // Debounce renders at 100ms to prevent excessive DOM updates - if (this.renderSessionTabsTimeout) { - clearTimeout(this.renderSessionTabsTimeout); - } - this.renderSessionTabsTimeout = setTimeout(() => { - this._renderSessionTabsImmediate(); - }, 100); + this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate); } /** Toggle .active class on tabs immediately (no debounce). Used by selectSession(). */ diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index a182247d..4de9b489 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -613,13 +613,7 @@ Object.assign(CodemanApp.prototype, { }, renderTaskPanel() { - // Debounce renders at 100ms to prevent excessive DOM updates - if (this.renderTaskPanelTimeout) { - clearTimeout(this.renderTaskPanelTimeout); - } - this.renderTaskPanelTimeout = setTimeout(() => { - this._renderTaskPanelImmediate(); - }, 100); + this._debouncedCall('taskPanel', this._renderTaskPanelImmediate); }, _renderTaskPanelImmediate() { @@ -2974,13 +2968,7 @@ Object.assign(CodemanApp.prototype, { renderMuxSessions() { - // Debounce renders at 100ms to prevent excessive DOM updates - if (this.renderMuxSessionsTimeout) { - clearTimeout(this.renderMuxSessionsTimeout); - } - this.renderMuxSessionsTimeout = setTimeout(() => { - this._renderMuxSessionsImmediate(); - }, 100); + this._debouncedCall('muxSessions', this._renderMuxSessionsImmediate); }, _renderMuxSessionsImmediate() { diff --git a/src/web/public/ralph-panel.js b/src/web/public/ralph-panel.js index 2eb6705b..1bf39b02 100644 --- a/src/web/public/ralph-panel.js +++ b/src/web/public/ralph-panel.js @@ -471,13 +471,7 @@ Object.assign(CodemanApp.prototype, { }, renderRalphStatePanel() { - // Debounce renders at 50ms to prevent excessive DOM updates - if (this.renderRalphStatePanelTimeout) { - clearTimeout(this.renderRalphStatePanelTimeout); - } - this.renderRalphStatePanelTimeout = setTimeout(() => { - this._renderRalphStatePanelImmediate(); - }, 50); + this._debouncedCall('ralphStatePanel', this._renderRalphStatePanelImmediate, 50); }, _renderRalphStatePanelImmediate() { diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 3d5479ae..b00c2c7c 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -7,6 +7,7 @@ import { join, resolve, relative, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; +import type { z } from 'zod'; import { Session } from '../session.js'; import { ApiErrorCode, createErrorResponse } from '../types.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; @@ -50,6 +51,31 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session return session; } +/** + * Parse and validate a request body against a Zod schema, or throw a structured 400 error. + * Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`. + */ +export function parseBody(schema: z.ZodType, body: unknown, errorMessage?: string): T { + const result = schema.safeParse(body); + if (!result.success) { + const msg = errorMessage ?? result.error.issues[0]?.message ?? 'Validation failed'; + throw Object.assign(new Error(msg), { + statusCode: 400, + body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg), + }); + } + return result.data; +} + +/** + * Persist session state and broadcast a SessionUpdated event. + * Replaces the repeated two-line pattern across route handlers. + */ +export function persistAndBroadcastSession(ctx: SessionPort & EventPort, session: Session): void { + ctx.persistSessionState(session); + ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); +} + /** * Formats uptime in seconds to a human-readable string (e.g., "1d 2h 30m 15s"). */ diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 89e9232b..0d375c9e 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -14,7 +14,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types. import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; -import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; +import { CASES_DIR, validatePathWithinBase, parseBody } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; @@ -83,11 +83,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }); app.post('/api/cases', async (req): Promise> => { - const result = CreateCaseSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { name, description } = result.data; + const { name, description } = parseBody(CreateCaseSchema, req.body); const casePath = validatePathWithinBase(name, CASES_DIR); if (!casePath) { @@ -120,11 +116,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Link an existing folder as a case app.post('/api/cases/link', async (req): Promise> => { - const lcResult = LinkCaseSchema.safeParse(req.body); - if (!lcResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { name, path: folderPath } = lcResult.data; + const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body'); // Expand ~ to home directory const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath; diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index a2ee1ced..fb19b5eb 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -7,7 +7,7 @@ import { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { HookEventSchema, isValidWorkingDir } from '../schemas.js'; -import { sanitizeHookData } from '../route-helpers.js'; +import { sanitizeHookData, parseBody } from '../route-helpers.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; export function registerHookEventRoutes( @@ -15,11 +15,7 @@ export function registerHookEventRoutes( ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort ): void { app.post('/api/hook-event', async (req) => { - const result = HookEventSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { event, sessionId, data } = result.data; + const { event, sessionId, data } = parseBody(HookEventSchema, req.body); if (!ctx.sessions.has(sessionId)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } diff --git a/src/web/routes/orchestrator-routes.ts b/src/web/routes/orchestrator-routes.ts index 5d8d686a..5f330b0a 100644 --- a/src/web/routes/orchestrator-routes.ts +++ b/src/web/routes/orchestrator-routes.ts @@ -19,6 +19,7 @@ import { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js'; +import { parseBody } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, OrchestratorPort } from '../ports/index.js'; @@ -85,12 +86,7 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat // ═══════════════════════════════════════════════════════════════ app.post('/api/orchestrator/start', async (req) => { - const parsed = OrchestratorStartSchema.safeParse(req.body); - if (!parsed.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - - const { goal, config } = parsed.data; + const { goal, config } = parseBody(OrchestratorStartSchema, req.body, 'Invalid request body'); // Initialize loop if needed let loop = ctx.orchestratorLoop; @@ -141,13 +137,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat app.post('/api/orchestrator/reject', async (req) => { const loop = getLoop(); - const parsed = OrchestratorRejectSchema.safeParse(req.body); - if (!parsed.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Feedback is required'); - } + const { feedback } = parseBody(OrchestratorRejectSchema, req.body, 'Feedback is required'); try { - loop.reject(parsed.data.feedback).catch((err) => { + loop.reject(feedback).catch((err) => { console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err)); }); return { ok: true, state: loop.state }; diff --git a/src/web/routes/plan-routes.ts b/src/web/routes/plan-routes.ts index 913ab557..a6fcec53 100644 --- a/src/web/routes/plan-routes.ts +++ b/src/web/routes/plan-routes.ts @@ -17,7 +17,7 @@ import { PlanTaskUpdateSchema, PlanTaskAddSchema, } from '../schemas.js'; -import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; +import { findSessionOrFail, parseBody, CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; @@ -29,11 +29,11 @@ export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & Even // ========== Generate Plan (Simple) ========== app.post('/api/generate-plan', async (req): Promise => { - const gpResult = GeneratePlanSchema.safeParse(req.body); - if (!gpResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { taskDescription, detailLevel = 'standard' } = gpResult.data; + const { taskDescription, detailLevel = 'standard' } = parseBody( + GeneratePlanSchema, + req.body, + 'Invalid request body' + ); // Build sophisticated prompt based on Ralph Wiggum methodology const detailConfig = { @@ -223,11 +223,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // ========== Generate Plan (Detailed Orchestration) ========== app.post('/api/generate-plan-detailed', async (req): Promise => { - const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body); - if (!gpdResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { taskDescription, caseName } = gpdResult.data; + const { taskDescription, caseName } = parseBody(GeneratePlanDetailedSchema, req.body, 'Invalid request body'); // Determine output directory for saving wizard results let outputDir: string | undefined; @@ -327,11 +323,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // ========== Cancel Plan Generation ========== app.post('/api/cancel-plan-generation', async (req): Promise => { - const cpResult = CancelPlanSchema.safeParse(req.body); - if (!cpResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { orchestratorId } = cpResult.data; + const { orchestratorId } = parseBody(CancelPlanSchema, req.body, 'Invalid request body'); // If specific orchestrator ID provided, cancel just that one if (orchestratorId) { @@ -374,11 +366,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } - const ptuResult = PlanTaskUpdateSchema.safeParse(req.body); - if (!ptuResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const update = ptuResult.data as { + const update = parseBody(PlanTaskUpdateSchema, req.body, 'Invalid request body') as { status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; error?: string; incrementAttempts?: boolean; @@ -454,11 +442,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } - const ptaResult = PlanTaskAddSchema.safeParse(req.body); - if (!ptaResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const task = ptaResult.data; + const task = parseBody(PlanTaskAddSchema, req.body, 'Invalid request body'); const result = tracker.addPlanTask(task); ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task }); diff --git a/src/web/routes/push-routes.ts b/src/web/routes/push-routes.ts index 9da9219d..a0ac9e03 100644 --- a/src/web/routes/push-routes.ts +++ b/src/web/routes/push-routes.ts @@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js'; +import { parseBody } from '../route-helpers.js'; import type { InfraPort } from '../ports/index.js'; export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void { @@ -15,11 +16,7 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void { }); app.post('/api/push/subscribe', async (req) => { - const result = PushSubscribeSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { endpoint, keys, userAgent, pushPreferences } = result.data; + const { endpoint, keys, userAgent, pushPreferences } = parseBody(PushSubscribeSchema, req.body); const record = ctx.pushStore.addSubscription({ id: uuidv4(), endpoint, @@ -33,11 +30,8 @@ export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void { app.put('/api/push/subscribe/:id', async (req) => { const { id } = req.params as { id: string }; - const result = PushPreferencesUpdateSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const updated = ctx.pushStore.updatePreferences(id, result.data.pushPreferences); + const { pushPreferences } = parseBody(PushPreferencesUpdateSchema, req.body); + const updated = ctx.pushStore.updatePreferences(id, pushPreferences); if (!updated) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); } diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 51d332b9..b8a2f133 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -13,7 +13,7 @@ import { Session } from '../../session.js'; import { RespawnController } from '../../respawn-controller.js'; import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js'; import { SseEvent } from '../sse-events.js'; -import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js'; +import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; @@ -31,22 +31,18 @@ export function registerRalphRoutes( // Configure Ralph tracker for a session app.post('/api/sessions/:id/ralph-config', async (req) => { const { id } = req.params as { id: string }; - const ralphResult = RalphConfigSchema.safeParse(req.body); - if (!ralphResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as { + const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = parseBody( + RalphConfigSchema, + req.body, + 'Invalid request body' + ) as { enabled?: boolean; completionPhrase?: string; maxIterations?: number; reset?: boolean | 'full'; disableAutoEnable?: boolean; }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); // Ralph tracker is not supported for opencode sessions if (session.mode === 'opencode') { @@ -111,11 +107,7 @@ export function registerRalphRoutes( // Reset circuit breaker for Ralph tracker app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); session.ralphTracker.resetCircuitBreaker(); return { success: true }; @@ -124,11 +116,7 @@ export function registerRalphRoutes( // Get Ralph status block and circuit breaker state app.get('/api/sessions/:id/ralph-status', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); return { success: true, @@ -148,11 +136,7 @@ export function registerRalphRoutes( // Generate @fix_plan.md content from todos app.get('/api/sessions/:id/fix-plan', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); const content = session.ralphTracker.generateFixPlanMarkdown(); return { @@ -167,16 +151,8 @@ export function registerRalphRoutes( // Import todos from @fix_plan.md content app.post('/api/sessions/:id/fix-plan/import', async (req) => { const { id } = req.params as { id: string }; - const importResult = FixPlanImportSchema.safeParse(req.body); - if (!importResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { content } = importResult.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); const importedCount = session.ralphTracker.importFixPlanMarkdown(content); ctx.persistSessionState(session); @@ -193,11 +169,7 @@ export function registerRalphRoutes( // Write @fix_plan.md to session's working directory app.post('/api/sessions/:id/fix-plan/write', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); const workingDir = session.workingDir; if (!workingDir) { @@ -224,11 +196,7 @@ export function registerRalphRoutes( // Read @fix_plan.md from session's working directory and import app.post('/api/sessions/:id/fix-plan/read', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); const workingDir = session.workingDir; if (!workingDir) { @@ -266,16 +234,8 @@ export function registerRalphRoutes( // This avoids mux input escaping issues with long multi-line prompts app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { const { id } = req.params as { id: string }; - const promptResult = RalphPromptWriteSchema.safeParse(req.body); - if (!promptResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { content } = promptResult.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); const workingDir = session.workingDir; if (!workingDir) { @@ -308,11 +268,10 @@ export function registerRalphRoutes( ); } - const rlResult = RalphLoopStartSchema.safeParse(req.body); - if (!rlResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed'); - } - const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data; + const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody( + RalphLoopStartSchema, + req.body + ); const casePath = join(CASES_DIR, caseName); diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index ae86729d..02bdd169 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -8,7 +8,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespa import { RespawnController, type RespawnConfig } from '../../respawn-controller.js'; import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js'; import { SseEvent } from '../sse-events.js'; -import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js'; +import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { @@ -84,11 +84,7 @@ export function registerRespawnRoutes( const { id } = req.params as { id: string }; let body: Partial | undefined; if (req.body) { - const result = RespawnConfigSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config'); - } - body = result.data as Partial; + body = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial; } const session = findSessionOrFail(ctx, id); @@ -162,11 +158,7 @@ export function registerRespawnRoutes( app.put('/api/sessions/:id/respawn/config', async (req) => { const { id } = req.params as { id: string }; // Validate respawn config to prevent arbitrary field injection - const parseResult = RespawnConfigSchema.safeParse(req.body); - if (!parseResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`); - } - const config = parseResult.data as Partial; + const config = parseBody(RespawnConfigSchema, req.body, 'Invalid respawn config') as Partial; const session = findSessionOrFail(ctx, id); const controller = ctx.respawnControllers.get(id); diff --git a/src/web/routes/scheduled-routes.ts b/src/web/routes/scheduled-routes.ts index e7afe6ed..a219eefa 100644 --- a/src/web/routes/scheduled-routes.ts +++ b/src/web/routes/scheduled-routes.ts @@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify'; import { statSync } from 'node:fs'; import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; import { ScheduledRunSchema } from '../schemas.js'; +import { parseBody } from '../route-helpers.js'; import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js'; export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void { @@ -15,11 +16,7 @@ export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & }); app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse> => { - const srResult = ScheduledRunSchema.safeParse(req.body); - if (!srResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { prompt, workingDir, durationMinutes } = srResult.data; + const { prompt, workingDir, durationMinutes } = parseBody(ScheduledRunSchema, req.body, 'Invalid request body'); // Validate workingDir exists and is a directory if (workingDir) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index ae105246..3b9e5bf7 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -32,7 +32,15 @@ import { QuickRunSchema, QuickStartSchema, } from '../schemas.js'; -import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js'; +import { + autoConfigureRalph, + CASES_DIR, + findSessionOrFail, + parseBody, + persistAndBroadcastSession, + SETTINGS_PATH, + validatePathWithinBase, +} from '../route-helpers.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; @@ -138,11 +146,7 @@ export function registerSessionRoutes( ); } - const result = CreateSessionSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const body = result.data; + const body = parseBody(CreateSessionSchema, req.body); const workingDir = body.workingDir || process.cwd(); // Validate workingDir exists and is a directory @@ -210,23 +214,14 @@ export function registerSessionRoutes( app.put('/api/sessions/:id/name', async (req) => { const { id } = req.params as { id: string }; - const result = SessionNameSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = result.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const body = parseBody(SessionNameSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH); session.name = name; // Also update the mux session name if applicable ctx.mux.updateSessionName(id, session.name); - ctx.persistSessionState(session); - ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); + persistAndBroadcastSession(ctx, session); return { success: true, name: session.name }; }); @@ -234,16 +229,8 @@ export function registerSessionRoutes( app.put('/api/sessions/:id/color', async (req) => { const { id } = req.params as { id: string }; - const result = SessionColorSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = result.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const body = parseBody(SessionColorSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']; if (!validColors.includes(body.color)) { @@ -251,8 +238,7 @@ export function registerSessionRoutes( } session.setColor(body.color as SessionColor); - ctx.persistSessionState(session); - ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); + persistAndBroadcastSession(ctx, session); return { success: true, color: session.color }; }); @@ -291,11 +277,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); // Use light state (no full buffers) — terminal buffer available via /terminal endpoint. // Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard). @@ -310,11 +292,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/output', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); return { success: true, @@ -330,11 +308,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/ralph-state', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); return { success: true, @@ -350,11 +324,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/run-summary', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); const tracker = ctx.runSummaryTrackers.get(id); if (!tracker) { @@ -374,11 +344,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/active-tools', async (req) => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); return { success: true, @@ -396,16 +362,8 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/run', async (req): Promise => { const { id } = req.params as { id: string }; - const result = RunPromptSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { prompt } = result.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const { prompt } = parseBody(RunPromptSchema, req.body); + const session = findSessionOrFail(ctx, id); if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); @@ -424,11 +382,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/interactive', async (req): Promise => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); @@ -468,11 +422,7 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/shell', async (req): Promise => { const { id } = req.params as { id: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); @@ -502,16 +452,8 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/input', async (req): Promise => { const { id } = req.params as { id: string }; - const result = SessionInputWithLimitSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { input, useMux } = result.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body); + const session = findSessionOrFail(ctx, id); const inputStr = String(input); if (inputStr.length > MAX_INPUT_LENGTH) { @@ -547,16 +489,8 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/resize', async (req): Promise => { const { id } = req.params as { id: string }; - const result = ResizeSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { cols, rows } = result.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const { cols, rows } = parseBody(ResizeSchema, req.body); + const session = findSessionOrFail(ctx, id); session.resize(cols, rows); return { success: true }; @@ -569,11 +503,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/terminal', async (req) => { const { id } = req.params as { id: string }; const query = req.query as { tail?: string }; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const session = findSessionOrFail(ctx, id); const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; const fullSize = session.terminalBufferLength; @@ -632,20 +562,11 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/auto-clear', async (req) => { const { id } = req.params as { id: string }; - const acResult = AutoClearSchema.safeParse(req.body); - if (!acResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = acResult.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const body = parseBody(AutoClearSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); session.setAutoClear(body.enabled, body.threshold); - ctx.persistSessionState(session); - ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); + persistAndBroadcastSession(ctx, session); return { success: true, @@ -662,20 +583,11 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/auto-compact', async (req) => { const { id } = req.params as { id: string }; - const compactResult = AutoCompactSchema.safeParse(req.body); - if (!compactResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = compactResult.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); session.setAutoCompact(body.enabled, body.threshold, body.prompt); - ctx.persistSessionState(session); - ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); + persistAndBroadcastSession(ctx, session); return { success: true, @@ -693,16 +605,8 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/image-watcher', async (req) => { const { id } = req.params as { id: string }; - const iwResult = ImageWatcherSchema.safeParse(req.body); - if (!iwResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = iwResult.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); if (body.enabled) { imageWatcher.watchSession(session.id, session.workingDir); @@ -726,20 +630,11 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/flicker-filter', async (req) => { const { id } = req.params as { id: string }; - const ffResult = FlickerFilterSchema.safeParse(req.body); - if (!ffResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = ffResult.data; - const session = ctx.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } + const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); session.flickerFilterEnabled = body.enabled; - ctx.persistSessionState(session); - ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session)); + persistAndBroadcastSession(ctx, session); return { success: true, @@ -764,11 +659,7 @@ export function registerSessionRoutes( ); } - const qrResult = QuickRunSchema.safeParse(req.body); - if (!qrResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { prompt, workingDir } = qrResult.data; + const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body'); if (!prompt.trim()) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); @@ -824,11 +715,7 @@ export function registerSessionRoutes( ); } - const result = QuickStartSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data; + const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, req.body); // Check OpenCode availability if requested if (mode === 'opencode') { diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4adf6834..4005e845 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -24,7 +24,7 @@ import { import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; -import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js'; +import { findSessionOrFail, formatUptime, parseBody, SETTINGS_PATH } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; @@ -324,11 +324,8 @@ export function registerSystemRoutes( }); app.put('/api/config', async (req) => { - const parseResult = ConfigUpdateSchema.safeParse(req.body); - if (!parseResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`); - } - ctx.store.setConfig(parseResult.data as Partial>); + const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config'); + ctx.store.setConfig(configData as Partial>); return { success: true, config: ctx.store.getConfig() }; }); @@ -408,11 +405,7 @@ export function registerSystemRoutes( }); app.put('/api/settings', async (req) => { - const settingsResult = SettingsUpdateSchema.safeParse(req.body); - if (!settingsResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings'); - } - const settings = settingsResult.data as Record; + const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record; try { const dir = dirname(SETTINGS_PATH); @@ -492,11 +485,7 @@ export function registerSystemRoutes( }); app.put('/api/execution/model-config', async (req) => { - const mcResult = ModelConfigUpdateSchema.safeParse(req.body); - if (!mcResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config'); - } - const modelConfig = mcResult.data as Record; + const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record; try { let existingSettings: Record = {}; @@ -536,11 +525,7 @@ export function registerSystemRoutes( const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); - const clResult = CpuLimitSchema.safeParse(req.body); - if (!clResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = clResult.data as Partial; + const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial; session.setNice(body); ctx.persistSessionState(session); @@ -572,11 +557,7 @@ export function registerSystemRoutes( }); app.put('/api/subagent-window-states', async (req) => { - const swResult = SubagentWindowStatesSchema.safeParse(req.body); - if (!swResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states'); - } - const states = swResult.data as Record; + const states = parseBody(SubagentWindowStatesSchema, req.body, 'Invalid window states') as Record; try { const dir = dirname(windowStatesPath); if (!existsSync(dir)) { @@ -604,11 +585,7 @@ export function registerSystemRoutes( }); app.put('/api/subagent-parents', async (req) => { - const spResult = SubagentParentMapSchema.safeParse(req.body); - if (!spResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map'); - } - const parentMap = spResult.data; + const parentMap = parseBody(SubagentParentMapSchema, req.body, 'Invalid parent map'); try { const dir = dirname(parentMapPath); if (!existsSync(dir)) {