Files
Codeman/src/web/route-helpers.ts
T
arkonandClaude Opus 4.6 28537de39d refactor: pass 3 — extract helpers, split long functions, deduplicate patterns
Backend:
- subagent-watcher: split 176L processEntry() into 5 focused methods; extract
  _resolveDescription() deduplicating 3 call sites for description acquisition
- bash-tool-parser: split 152L processCleanLine() into 4 handlers; extract
  _createActiveTool() factory and _scheduleAutoRemove() helper
- session: extract _setupOrAttachMuxSession() deduplicating ~80L between
  startInteractive/startShell; extract _handleTerminalOutput()
- respawn-controller: split 180L handleTerminalData() into 3 detection layers;
  data-driven validation loop replacing 9 individual calls
- plan-orchestrator: extract _extractJsonFromResponse(), _emitAgentFailure(),
  _formatResearchSection() helpers
- orchestrator-loop: extract _finalizeTask() unifying task completion/failure;
  _clearTimer() utility for correct clearInterval/clearTimeout dispatch
- ralph-status-parser: config-driven FIELD_PARSERS[] replacing 8 near-identical
  field-matching blocks; split updateCircuitBreaker() into focused handlers
- state-store: extract _mergeWithInitialState() and _resetCircuitBreaker()

Frontend:
- app.js: add _notifySession() helper used by 18 call sites across 5 modules
- panels-ui.js: extract _addActivityEntry() replacing 4 duplicate blocks
- settings-ui.js: extract _updateTunnelUrlRow() deduplicating 2 blocks
- ralph-panel.js, respawn-ui.js: convert to _notifySession()

Routes:
- route-helpers: add toggleService() helper
- system-routes: use toggleService() for watcher toggles; extract collectActiveTokens()
- orchestrator-routes: data-driven EVENT_MAP replacing 10 identical listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 22:50:25 +01:00

257 lines
9.2 KiB
TypeScript

/**
* @fileoverview Shared helper functions for route modules.
*
* Contains pure functions extracted from server.ts and a session lookup helper
* that replaces ~43 inline not-found checks across route handlers.
*/
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
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';
import { SseEvent } from './sse-events.js';
import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-port.js';
// Shared path constants used across route modules
export const CASES_DIR = join(homedir(), 'codeman-cases');
export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json');
/**
* Validates that a path component doesn't escape the base directory.
* Returns the resolved full path, or null if the path is a traversal attempt.
*/
export function validatePathWithinBase(name: string, baseDir: string): string | null {
const fullPath = resolve(join(baseDir, name));
const resolvedBase = resolve(baseDir);
const relPath = relative(resolvedBase, fullPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return null;
}
return fullPath;
}
/**
* Reads and parses a JSON config file, returning a default value on ENOENT.
* Logs an error for any I/O failure other than a missing file.
*/
export async function readJsonConfig<T>(filePath: string, logLabel: string, defaultValue: T): Promise<T> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content) as T;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(`Failed to read ${logLabel}:`, err);
}
return defaultValue;
}
}
/**
* Validates that a file path (possibly containing symlinks) resolves to a location
* within the given session working directory. Returns the resolved and relative paths,
* or null if the path escapes the directory or doesn't exist.
*/
export function validateSessionFilePath(
sessionWorkingDir: string,
filePath: string
): { resolvedPath: string; relativePath: string } | null {
const fullPath = resolve(sessionWorkingDir, filePath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
return null;
}
const relativePath = relative(sessionWorkingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return null;
}
return { resolvedPath, relativePath };
}
// Maximum hook data size (prevents oversized SSE broadcasts)
const MAX_HOOK_DATA_SIZE = 8 * 1024;
/**
* Look up a session by ID or throw a structured error.
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
*/
export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session {
const session = ctx.sessions.get(sessionId);
if (!session) {
throw Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
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<T>(schema: z.ZodType<T>, 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").
*/
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
return parts.join(' ');
}
/**
* Sanitizes hook event data before broadcasting via SSE.
* Extracts only relevant fields and limits total size to prevent
* oversized payloads from being broadcast to all connected clients.
*/
export function sanitizeHookData(data: Record<string, unknown> | null | undefined): Record<string, unknown> {
if (!data || typeof data !== 'object') return {};
// Only forward known safe fields from Claude Code hook stdin
const safeFields: Record<string, unknown> = {};
const allowedKeys = [
'hook_event_name',
'tool_name',
'tool_input',
'session_id',
'cwd',
'permission_mode',
'stop_hook_active',
'transcript_path',
];
for (const key of allowedKeys) {
if (key in data && data[key] !== undefined) {
safeFields[key] = data[key];
}
}
// For tool_input, extract only summary fields (not full file content)
if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
const input = safeFields.tool_input as Record<string, unknown>;
const summary: Record<string, unknown> = {};
if (input.command) summary.command = String(input.command).slice(0, 500);
if (input.file_path) summary.file_path = String(input.file_path).slice(0, 500);
if (input.description) summary.description = String(input.description).slice(0, 200);
if (input.query) summary.query = String(input.query).slice(0, 200);
if (input.url) summary.url = String(input.url).slice(0, 500);
if (input.pattern) summary.pattern = String(input.pattern).slice(0, 200);
if (input.prompt) summary.prompt = String(input.prompt).slice(0, 200);
safeFields.tool_input = summary;
}
// Final size check - drop if serialized data exceeds limit
const serialized = JSON.stringify(safeFields);
if (serialized.length > MAX_HOOK_DATA_SIZE) {
return { tool_name: safeFields.tool_name, _truncated: true };
}
return safeFields;
}
/**
* Toggles a service (watcher/manager) on or off based on an enabled flag.
* Logs start/stop to console with the given label. Runs an optional callback after starting.
*/
export function toggleService(
enabled: boolean,
service: { isRunning(): boolean; start(): void; stop(): void },
label: string,
onStart?: () => void
): void {
if (enabled && !service.isRunning()) {
service.start();
onStart?.();
console.log(`${label} started via settings change`);
} else if (!enabled && service.isRunning()) {
service.stop();
console.log(`${label} stopped via settings change`);
}
}
/**
* Auto-configure Ralph tracker for a session.
*
* Priority order:
* 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state)
* 2. CLAUDE.md <promise> tags (fallback)
*
* The ralph-loop.local.md file has priority because it contains
* the exact configuration from an active Ralph loop session.
*/
export function autoConfigureRalph(session: Session, workingDir: string, ctx: EventPort): void {
// First, try to read the official Ralph Wiggum plugin state file
const ralphConfig = parseRalphLoopConfig(workingDir);
if (ralphConfig && ralphConfig.completionPromise) {
session.ralphTracker.enable();
session.ralphTracker.startLoop(ralphConfig.completionPromise, ralphConfig.maxIterations ?? undefined);
// Restore iteration count if available
if (ralphConfig.iteration > 0) {
// The tracker's cycleCount will be updated when we detect iteration patterns
// in the terminal output, but we can set maxIterations now
console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`);
}
console.log(
`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`
);
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
sessionId: session.id,
state: session.ralphTracker.loopState,
});
return;
}
// Fallback: try CLAUDE.md
const claudeMdPath = join(workingDir, 'CLAUDE.md');
const completionPhrase = extractCompletionPhrase(claudeMdPath);
if (completionPhrase) {
session.ralphTracker.enable();
session.ralphTracker.startLoop(completionPhrase);
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`);
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
sessionId: session.id,
state: session.ralphTracker.loopState,
});
}
}