Files
Codeman/src/web/routes/case-routes.ts
T
Ark0NandClaude Opus 4.6 2ee9ad72e8 refactor: SSE event handlers, LLM context optimization, @fileoverview docs (#29)
* refactor: extract SSE event handlers into named class methods

Replace ~80 inline addListener closures in connectSSE() with a
declarative _SSE_HANDLER_MAP array that drives registration in a
single loop. Each handler is now a named _on* method on CodemanApp,
making them individually addressable for LLM navigation.

Add SSE_EVENTS constant object in constants.js to eliminate magic
event-type strings scattered across the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: fix inaccuracies in CLAUDE.md

- Fix types barrel path: src/types.ts → src/types/index.ts
- Update app.js line count: ~12K → ~11.5K
- Correct route handler counts (113 → 111, per-group fixes)
- Add code style, ESM gotcha, env vars, route test, lifecycle log docs
- Add Node 22 CI note, test teardown timeout, port range

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add mobile screenshots and QR auth security writeup to README

Add 3 mobile screenshots (landing, idle, active) and expand the
mobile section with QR auth security design details and a
touch-optimized interface subsection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook

Build and postinstall now bundle the local xterm-zerolag-input package
as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay
shim. Add git pre-commit hook that runs prettier --check on staged .ts
files to catch format issues before CI.

Also bump constants.js and app.js cache-bust versions to 0.3.0 and add
tunnel upload URL display row in settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add cloudflared install support and interactive launch menu

- Add optional cloudflared dependency detection and installation
  across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE)
- Add tunnel systemd service setup helper
- Replace post-install instructions with interactive launch menu
  (run now / systemd service / skip)
- Uninstall now cleans up both codeman-web and codeman-tunnel services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: gitignore readme-preview.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression

- Migrate broadcast() string literals → SseEvent.* typed constants
- Add @fileoverview with cross-domain references to all 13 type domain files
- Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.)
- Add section dividers to route files for LLM scanability
- Compress CLAUDE.md: flat file list → domain table, fix counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: optimize codebase for LLM context window efficiency

CLAUDE.md: 456 → 309 lines (32% reduction)
- Merge Commands into compact table, remove redundant bash block
- Convert Security section to dense table format
- Merge Performance + Resource Limits, Debugging + Troubleshooting
- Compress Tunnel, Memory Leak, Scripts, Screenshots sections
- Remove Key Patterns that duplicate @fileoverview in source files

Backend @fileoverview enhancements (10 priority files):
- session.ts: key methods, events, cross-domain refs
- respawn-controller.ts: state machine, idle detection layers
- ralph-tracker.ts: exports, circuit breaker, events
- ralph-loop.ts: lifecycle, persistence, events
- subagent-watcher.ts: watched patterns, teammate detection
- server.ts: coordination list, port interfaces
- state-store.ts: dual-file persistence, migration
- session-manager.ts: lifecycle methods, mutex guard
- hooks-config.ts: hook events list, categories
- sse-events.ts: category breakdown (~90 events, 17 categories)

Frontend app.js: add 6 section dividers, update @fileoverview line refs

Fix: escape glob `*/` in JSDoc that broke ESLint parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR #29 review bugs

- server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant
- install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first)
- install.sh: replace Arch pacman (AUR-only) with direct binary download
- index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0
- mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js)
- types/push.ts: fix layer number (4, not 5)
- subagent-watcher.ts: fix watched pattern path to include {session} segment
- constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:40:18 +01:00

490 lines
18 KiB
TypeScript

/**
* @fileoverview Case management routes.
* Handles CRUD for cases (directories under ~/codeman-cases and linked folders),
* fix-plan reading, and ralph-wizard file serving.
*/
import { FastifyInstance } from 'fastify';
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
// ═══════════════════════════════════════════════════════════════
// ========== List Cases ==========
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
const cases: CaseInfo[] = [];
// Get cases from CASES_DIR
try {
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
cases.push({
name: e.name,
path: join(CASES_DIR, e.name),
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
});
}
}
} catch {
// CASES_DIR may not exist yet
}
// Get linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
for (const [name, path] of Object.entries(linkedCases)) {
// Only add if not already in cases (avoid duplicates) and path exists
if (!cases.some((c) => c.name === name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
});
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[Server] Failed to read linked cases:', err);
}
}
return cases;
});
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
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 casePath = join(CASES_DIR, name);
// Security: Path traversal protection - use relative path check
const resolvedPath = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
if (existsSync(casePath)) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
try {
mkdirSync(casePath, { recursive: true });
mkdirSync(join(casePath, 'src'), { recursive: true });
// Read settings to get custom template path
const templatePath = await ctx.getDefaultClaudeMdPath();
const claudeMd = generateClaudeMd(name, description || '', templatePath);
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
await writeHooksConfig(casePath);
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return { success: true, data: { case: { name, path: casePath } } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// Link an existing folder as a case
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const lcResult = LinkCaseSchema.safeParse(req.body);
if (!lcResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { name, path: folderPath } = lcResult.data;
// Expand ~ to home directory
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
// Validate the folder exists
if (!existsSync(expandedPath)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`);
}
// Check if case name already exists in CASES_DIR
const casePath = join(CASES_DIR, name);
if (existsSync(casePath)) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
// Load existing linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
let linkedCases: Record<string, string> = {};
try {
linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[Server] Failed to read linked cases:', err);
}
}
// Check if name is already linked
if (linkedCases[name]) {
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`Case "${name}" is already linked to ${linkedCases[name]}`
);
}
// Save the linked case
linkedCases[name] = expandedPath;
try {
const codemanDir = join(homedir(), '.codeman');
if (!existsSync(codemanDir)) {
mkdirSync(codemanDir, { recursive: true });
}
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
return { success: true, data: { case: { name, path: expandedPath } } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
app.get('/api/cases/:name', async (req) => {
const { name } = req.params as { name: string };
// Security: Path traversal protection
const resolvedPath = resolve(join(CASES_DIR, name));
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// First check linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[name]) {
const linkedPath = linkedCases[name];
return {
name,
path: linkedPath,
hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')),
linked: true,
};
}
} catch {
// ENOENT or parse errors - fall through to CASES_DIR check
}
// Then check CASES_DIR
const casePath = join(CASES_DIR, name);
if (!existsSync(casePath)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
}
return {
name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
};
});
// Read @fix_plan.md from a case directory (for wizard to detect existing plans)
app.get('/api/cases/:name/fix-plan', async (req) => {
const { name } = req.params as { name: string };
// Security: Path traversal protection
const resolvedPath = resolve(join(CASES_DIR, name));
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Get case path (check linked cases first, then CASES_DIR)
let casePath: string | null = null;
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[name]) {
casePath = linkedCases[name];
}
} catch {
// ENOENT or parse errors - fall through to CASES_DIR
}
if (!casePath) {
casePath = join(CASES_DIR, name);
}
const fixPlanPath = join(casePath, '@fix_plan.md');
if (!existsSync(fixPlanPath)) {
return { success: true, exists: false, content: null, todos: [] };
}
try {
const content = await fs.readFile(fixPlanPath, 'utf-8');
// Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown)
const todos: Array<{
content: string;
status: 'pending' | 'in_progress' | 'completed';
priority: string | null;
}> = [];
const todoPattern = /^-\s*\[([ xX-])\]\s*(.+)$/;
const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i;
const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i;
const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i;
const completedHeaderPattern = /^##\s*Completed/i;
let currentPriority: string | null = null;
let inCompletedSection = false;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (p0HeaderPattern.test(trimmed)) {
currentPriority = 'P0';
inCompletedSection = false;
continue;
}
if (p1HeaderPattern.test(trimmed)) {
currentPriority = 'P1';
inCompletedSection = false;
continue;
}
if (p2HeaderPattern.test(trimmed)) {
currentPriority = 'P2';
inCompletedSection = false;
continue;
}
if (completedHeaderPattern.test(trimmed)) {
inCompletedSection = true;
continue;
}
const match = trimmed.match(todoPattern);
if (match) {
const [, checkboxState, taskContent] = match;
let status: 'pending' | 'in_progress' | 'completed';
if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') {
status = 'completed';
} else if (checkboxState === '-') {
status = 'in_progress';
} else {
status = 'pending';
}
todos.push({
content: taskContent.trim(),
status,
priority: inCompletedSection ? null : currentPriority,
});
}
}
// Calculate stats in a single pass for better performance
let pending = 0,
inProgress = 0,
completed = 0;
for (const t of todos) {
if (t.status === 'pending') pending++;
else if (t.status === 'in_progress') inProgress++;
else if (t.status === 'completed') completed++;
}
const stats = { total: todos.length, pending, inProgress, completed };
return {
success: true,
exists: true,
content,
todos,
stats,
};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`);
}
});
// ═══════════════════════════════════════════════════════════════
// Ralph Wizard Files (per-case prompt/result serving)
// ═══════════════════════════════════════════════════════════════
// ========== List Wizard Files ==========
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
const { caseName } = req.params as { caseName: string };
let casePath = join(CASES_DIR, caseName);
// Security: Path traversal protection - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedCase);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Check linked cases if path doesn't exist
if (!existsSync(casePath)) {
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[caseName]) {
casePath = linkedCases[caseName];
}
} catch {
// No linked cases file
}
}
const wizardDir = join(casePath, 'ralph-wizard');
if (!existsSync(wizardDir)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found');
}
// List all subdirectories and their files
const files: Array<{ agentType: string; promptFile?: string; resultFile?: string }> = [];
const entries = readdirSync(wizardDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const agentDir = join(wizardDir, entry.name);
const agentFiles: { agentType: string; promptFile?: string; resultFile?: string } = {
agentType: entry.name,
};
if (existsSync(join(agentDir, 'prompt.md'))) {
agentFiles.promptFile = `${entry.name}/prompt.md`;
}
if (existsSync(join(agentDir, 'result.json'))) {
agentFiles.resultFile = `${entry.name}/result.json`;
}
if (agentFiles.promptFile || agentFiles.resultFile) {
files.push(agentFiles);
}
}
}
return { success: true, data: { files, caseName } };
});
// Read a specific ralph-wizard file
// Cache disabled to ensure fresh prompts when starting new plan generations
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
let casePath = join(CASES_DIR, caseName);
// Prevent browser caching - prompts change between plan generations
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
reply.header('Pragma', 'no-cache');
reply.header('Expires', '0');
// Security: Path traversal protection for case name - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedCase);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Check linked cases if path doesn't exist
if (!existsSync(casePath)) {
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[caseName]) {
casePath = linkedCases[caseName];
}
} catch {
// No linked cases file
}
}
const wizardDir = join(casePath, 'ralph-wizard');
// Decode the file path (it may be URL encoded)
const decodedPath = decodeURIComponent(filePath);
const fullPath = join(wizardDir, decodedPath);
// Security: ensure path is within wizard directory
const resolvedPath = resolve(fullPath);
const resolvedWizard = resolve(wizardDir);
if (!resolvedPath.startsWith(resolvedWizard)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file path');
}
let content: string;
try {
content = await fs.readFile(fullPath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
}
throw err;
}
const isJson = filePath.endsWith('.json');
// Parse JSON content safely (may contain invalid JSON or unescaped control characters)
let parsed: unknown = null;
if (isJson) {
try {
parsed = JSON.parse(content);
} catch {
// Try repairing common JSON issues (unescaped control characters, trailing commas)
try {
let repaired = content;
// Fix trailing commas before closing brackets
repaired = repaired.replace(/,(\s*[\]}])/g, '$1');
// Fix unescaped control characters within JSON strings
repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => {
return match
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(
// eslint-disable-next-line no-control-regex
/[\x00-\x1f]/g,
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`
);
});
parsed = JSON.parse(repaired);
} catch {
// Still invalid - return null for parsed, content available as raw string
}
}
}
return {
success: true,
data: {
content,
filePath: decodedPath,
isJson,
parsed,
},
};
});
}