diff --git a/src/board/boardConfig.ts b/src/board/boardConfig.ts index 0bcf0d20..25d52fd4 100644 --- a/src/board/boardConfig.ts +++ b/src/board/boardConfig.ts @@ -6,16 +6,20 @@ export const DETAIL_PANE_MAX_WIDTH = 800; export type ThemePreference = 'dark' | 'light' | 'system'; export const VALID_THEMES: ThemePreference[] = ['dark', 'light', 'system']; +export type LlmPreference = 'codex' | 'claude'; +export const VALID_LLMS: LlmPreference[] = ['codex', 'claude']; export interface BoardConfig { detailPaneWidth?: number; theme?: ThemePreference; + llm?: LlmPreference; } interface RawConfigFile { board?: { detailPaneWidth?: unknown; theme?: unknown; + llm?: unknown; [key: string]: unknown; }; [key: string]: unknown; @@ -23,29 +27,29 @@ interface RawConfigFile { /** * Read board config from .agkan/config.yml (or the provided directory). - * Returns an empty object if the file does not exist, is invalid, or values are out of range. + * Defaults llm to "claude" when absent/invalid. */ export function readBoardConfig(configDir: string): BoardConfig { const configFile = path.join(configDir, 'config.yml'); - if (!fs.existsSync(configFile)) { - return {}; + if (fs.existsSync(configFile) === false) { + return { llm: 'claude' }; } try { const content = fs.readFileSync(configFile, 'utf8'); const raw = yaml.load(content) as RawConfigFile | null | undefined; - if (!raw || typeof raw !== 'object') { - return {}; + if (raw == null || typeof raw !== 'object') { + return { llm: 'claude' }; } const board = raw.board; - if (!board || typeof board !== 'object') { - return {}; + if (board == null || typeof board !== 'object') { + return { llm: 'claude' }; } - const result: BoardConfig = {}; + const result: BoardConfig = { llm: 'claude' }; const detailPaneWidth = board.detailPaneWidth; if (typeof detailPaneWidth === 'number' && detailPaneWidth <= DETAIL_PANE_MAX_WIDTH) { @@ -57,9 +61,14 @@ export function readBoardConfig(configDir: string): BoardConfig { result.theme = theme as ThemePreference; } + const llm = board.llm; + if (typeof llm === 'string' && (VALID_LLMS as string[]).includes(llm)) { + result.llm = llm as LlmPreference; + } + return result; } catch { - return {}; + return { llm: 'claude' }; } } @@ -92,6 +101,7 @@ export function writeBoardConfig(configDir: string, config: BoardConfig): void { ...(existing.board ?? {}), ...(config.detailPaneWidth !== undefined ? { detailPaneWidth: config.detailPaneWidth } : {}), ...(config.theme !== undefined ? { theme: config.theme } : {}), + ...(config.llm !== undefined ? { llm: config.llm } : {}), }, }; diff --git a/src/board/boardRenderer.ts b/src/board/boardRenderer.ts index e5606add..1453f2f2 100644 --- a/src/board/boardRenderer.ts +++ b/src/board/boardRenderer.ts @@ -203,6 +203,21 @@ function getPurgeAndVersionModals(): string { + + `; } @@ -243,7 +258,7 @@ export function renderBoard( -

agkan board

${titleHtml}
🗑 Purge Tasks
📦 Archive Tasks
↓ Export Tasks
↑ Import Tasks
ℹ Version Info
Dark Mode
Light Mode
System Setting
+

agkan board

${titleHtml}
🗑 Purge Tasks
📦 Archive Tasks
↓ Export Tasks
↑ Import Tasks
⚙ Settings
ℹ Version Info
Dark Mode
Light Mode
System Setting
diff --git a/src/board/boardRoutes.ts b/src/board/boardRoutes.ts index 0f154303..5a24453c 100644 --- a/src/board/boardRoutes.ts +++ b/src/board/boardRoutes.ts @@ -9,11 +9,19 @@ import { MetadataService } from '../services/MetadataService'; import { CommentService } from '../services/CommentService'; import { TaskBlockService } from '../services/TaskBlockService'; import { ExportImportService, ExportData } from '../services/ExportImportService'; -import { ClaudeProcessService } from '../services/ClaudeProcessService'; +import type { IProcessService } from '../services/IProcessService'; import { TaskStatus, isPriority, Priority } from '../models'; import { ConflictError } from '../errors'; import { StorageBackend } from '../db/types/repository'; -import { readBoardConfig, writeBoardConfig, DETAIL_PANE_MAX_WIDTH, VALID_THEMES, ThemePreference } from './boardConfig'; +import { + readBoardConfig, + writeBoardConfig, + DETAIL_PANE_MAX_WIDTH, + VALID_THEMES, + ThemePreference, + VALID_LLMS, + LlmPreference, +} from './boardConfig'; import { daysAgoIso } from '../utils/date'; import { buildTasksByStatus, @@ -34,7 +42,7 @@ export type BoardServices = { database: StorageBackend; boardTitle?: string; configDir: string; - claudeProcessService?: ClaudeProcessService; + processService?: IProcessService; }; type TaskPatchBody = { @@ -373,7 +381,7 @@ export function registerConfigApiRoutes(app: Hono, configDir: string): void { }); app.put('/api/config', async (c) => { - const body = await c.req.json<{ board?: { detailPaneWidth?: unknown; theme?: unknown } }>(); + const body = await c.req.json<{ board?: { detailPaneWidth?: unknown; theme?: unknown; llm?: unknown } }>(); const boardBody = body?.board ?? {}; if (boardBody.detailPaneWidth !== undefined) { @@ -395,6 +403,14 @@ export function registerConfigApiRoutes(app: Hono, configDir: string): void { writeBoardConfig(configDir, { theme: theme as ThemePreference }); } + if (boardBody.llm !== undefined) { + const llm = boardBody.llm; + if (typeof llm !== 'string' || !(VALID_LLMS as string[]).includes(llm)) { + return c.json({ error: 'llm must be one of: ' + VALID_LLMS.join(', ') }, 400); + } + writeBoardConfig(configDir, { llm: llm as LlmPreference }); + } + return c.json({ success: true }); }); } @@ -437,7 +453,7 @@ function parseBoardCardFilters(query: { return filters; } -function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts: TaskService): void { +function registerProcessRoutes(app: Hono, processService: IProcessService, ts: TaskService): void { app.post('/api/claude/tasks/:taskId/run', async (c) => { const taskId = Number(c.req.param('taskId')); if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400); @@ -453,11 +469,11 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts : `Task ID: ${taskId}\n/agkan-subtask-direct`; try { - claudeProcess.startProcess(taskId, prompt, command); + processService.startProcess(taskId, prompt, command); } catch (e) { if (e instanceof ConflictError) { console.error( - `[boardRoutes] 409 already running taskId=${taskId} command=${command} running=${JSON.stringify(claudeProcess.listRunningTasks())}` + `[boardRoutes] 409 already running taskId=${taskId} command=${command} running=${JSON.stringify(processService.listRunningTasks())}` ); return c.json({ error: e.message }, 409); } @@ -466,7 +482,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts if (command === 'pr' || command === 'run') { const targetStatus = command === 'pr' ? 'review' : 'done'; - const unsubscribe = claudeProcess.subscribeOutput(taskId, (evt) => { + const unsubscribe = processService.subscribeOutput(taskId, (evt) => { if (evt.kind === 'done' && evt.exitCode === 0) { ts.updateTask(taskId, { status: targetStatus }); } @@ -482,7 +498,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts app.delete('/api/claude/tasks/:taskId/run', (c) => { const taskId = Number(c.req.param('taskId')); if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400); - const stopped = claudeProcess.stopProcess(taskId); + const stopped = processService.stopProcess(taskId); if (!stopped) return c.json({ error: 'No running process for this taskId' }, 404); return c.json({ success: true }); }); @@ -500,7 +516,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts return new TextEncoder().encode(payload); }; - const stop = claudeProcess.subscribeOutput(taskId, (evt) => { + const stop = processService.subscribeOutput(taskId, (evt) => { if (evt.kind === 'text') { controller.enqueue(encode('text', { text: evt.text })); } else if (evt.kind === 'tool_use') { @@ -531,7 +547,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts }); app.get('/api/running-tasks', (c) => { - const tasks = claudeProcess.listRunningTasks(); + const tasks = processService.listRunningTasks(); return c.json({ tasks }); }); @@ -539,7 +555,7 @@ function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts const taskId = Number(c.req.param('taskId')); if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400); if (!ts.getTask(taskId)) return c.json({ error: 'Task not found' }, 404); - const logs = claudeProcess.getRunLogs(taskId); + const logs = processService.getRunLogs(taskId); return c.json({ logs }); }); } @@ -592,7 +608,7 @@ export function registerBoardRoutes(app: Hono, services: BoardServices): void { }); registerTaskApiRoutes(app, services); registerConfigApiRoutes(app, configDir); - if (services.claudeProcessService) { - registerClaudeRoutes(app, services.claudeProcessService, ts); + if (services.processService) { + registerProcessRoutes(app, services.processService, ts); } } diff --git a/src/board/client/burgerMenu.ts b/src/board/client/burgerMenu.ts index 07787523..346affa6 100644 --- a/src/board/client/burgerMenu.ts +++ b/src/board/client/burgerMenu.ts @@ -1,8 +1,14 @@ -// Burger menu, purge tasks, version info, and dark mode functionality +// Burger menu, purge tasks, version info, settings, and dark mode functionality import { initDarkMode } from './darkMode'; import { refreshBoardCards } from './boardPolling'; +type LlmPreference = 'codex' | 'claude'; + +function normalizeLlm(value: unknown): LlmPreference { + return value === 'codex' ? 'codex' : 'claude'; +} + function initBurgerToggle(burgerBtn: HTMLButtonElement, burgerDropdown: HTMLElement): void { burgerBtn.addEventListener('click', (e: MouseEvent) => { e.stopPropagation(); @@ -133,6 +139,72 @@ function initVersionModal(burgerDropdown: HTMLElement): void { }); } +function initSettingsModal(burgerDropdown: HTMLElement): void { + const settingsModal = document.getElementById('settings-modal') as HTMLElement | null; + const settingsSelect = document.getElementById('settings-llm-select') as HTMLSelectElement | null; + const settingsResultEl = document.getElementById('settings-result') as HTMLElement | null; + const settingsCancelBtn = document.getElementById('settings-cancel-btn') as HTMLButtonElement | null; + const settingsSaveBtn = document.getElementById('settings-save-btn') as HTMLButtonElement | null; + + if (!settingsModal || !settingsSelect || !settingsResultEl || !settingsCancelBtn || !settingsSaveBtn) { + return; + } + + document.getElementById('burger-settings')?.addEventListener('click', async () => { + burgerDropdown.classList.remove('open'); + settingsResultEl.textContent = ''; + settingsResultEl.style.color = '#64748b'; + settingsSelect.value = 'claude'; + settingsModal.classList.add('show'); + + try { + const res = await fetch('/api/config'); + if (res.ok) { + const data = (await res.json()) as { board?: { llm?: unknown } }; + settingsSelect.value = normalizeLlm(data?.board?.llm); + } + } catch { + // Ignore load errors and keep default + } + }); + + settingsCancelBtn.addEventListener('click', () => { + settingsModal.classList.remove('show'); + }); + + settingsSaveBtn.addEventListener('click', async () => { + const selected = normalizeLlm(settingsSelect.value); + settingsSaveBtn.disabled = true; + settingsSaveBtn.textContent = 'Saving...'; + + try { + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board: { llm: selected } }), + }); + + if (res.ok) { + settingsResultEl.textContent = 'Saved.'; + settingsResultEl.style.color = '#16a34a'; + setTimeout(() => { + settingsModal.classList.remove('show'); + }, 250); + } else { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + settingsResultEl.textContent = data.error || 'Failed to save settings.'; + settingsResultEl.style.color = '#dc2626'; + } + } catch { + settingsResultEl.textContent = 'Failed to save settings.'; + settingsResultEl.style.color = '#dc2626'; + } finally { + settingsSaveBtn.disabled = false; + settingsSaveBtn.textContent = 'Save'; + } + }); +} + function initExportModal(burgerDropdown: HTMLElement): void { document.getElementById('burger-export-tasks')?.addEventListener('click', () => { burgerDropdown.classList.remove('open'); @@ -250,6 +322,7 @@ export function initBurgerMenu(): void { initArchiveModal(burgerDropdown); initExportModal(burgerDropdown); initImportModal(burgerDropdown); + initSettingsModal(burgerDropdown); initVersionModal(burgerDropdown); initDarkMode(); } diff --git a/src/board/server.ts b/src/board/server.ts index 433baafa..0e93a4d6 100644 --- a/src/board/server.ts +++ b/src/board/server.ts @@ -7,11 +7,13 @@ import { TagService } from '../services/TagService'; import { MetadataService } from '../services/MetadataService'; import { CommentService } from '../services/CommentService'; import { TaskBlockService } from '../services/TaskBlockService'; -import { ClaudeProcessService } from '../services/ClaudeProcessService'; +import { ProcessServiceFactory } from '../services/ProcessServiceFactory'; +import type { IProcessService } from '../services/IProcessService'; import { getStorageBackend } from '../db/connection'; import { StorageBackend } from '../db/types/repository'; import { getDefaultDirName } from '../db/config'; import { registerBoardRoutes, BoardServices } from './boardRoutes'; +import { readBoardConfig } from './boardConfig'; export function createBoardApp( taskService?: TaskService, @@ -23,7 +25,7 @@ export function createBoardApp( configDir?: string, commentService?: CommentService, taskBlockService?: TaskBlockService, - claudeProcessService?: ClaudeProcessService + processService?: IProcessService ): Hono { const app = new Hono(); const resolvedConfigDir = configDir ?? path.join(process.cwd(), getDefaultDirName()); @@ -38,24 +40,29 @@ export function createBoardApp( database: resolvedDb, boardTitle, configDir: resolvedConfigDir, - claudeProcessService, + processService, }; registerBoardRoutes(app, services); return app; } export function startBoardServer(port: number, boardTitle?: string): void { + const configDir = path.join(process.cwd(), getDefaultDirName()); + const config = readBoardConfig(configDir); + const db = getStorageBackend(); + const processService = ProcessServiceFactory.create(config.llm, db); + const app = createBoardApp( undefined, undefined, undefined, - undefined, + db, boardTitle, undefined, + configDir, undefined, undefined, - undefined, - new ClaudeProcessService(getStorageBackend()) + processService ); serve( { diff --git a/src/services/ClaudeProcessService.ts b/src/services/ClaudeProcessService.ts index 697a474d..f79d393b 100644 --- a/src/services/ClaudeProcessService.ts +++ b/src/services/ClaudeProcessService.ts @@ -3,6 +3,7 @@ import type { StorageBackend } from '../db/types/repository'; import type { RunLogRow } from '../db/types/repository'; import { verboseLog } from '../utils/logger'; import { ConflictError } from '../errors'; +import type { IProcessService, SubscribeCallback as IProcessServiceSubscribeCallback } from './IProcessService'; function resolveClaudePath(): string { try { @@ -40,7 +41,8 @@ export type OutputEvent = | { kind: 'done'; exitCode: number } | { kind: 'error'; message: string }; -export type SubscribeCallback = (event: OutputEvent) => void; +// Re-export for backward compatibility +export type SubscribeCallback = IProcessServiceSubscribeCallback; export interface RunLog { id: number; @@ -71,8 +73,9 @@ interface ProcessInfo { * ClaudeProcessService * Manages claude CLI processes running in stream-json mode. * Keyed by taskId, supports start/stop/subscribe/list operations. + * Implements IProcessService for use with ProcessServiceFactory. */ -export class ClaudeProcessService { +export class ClaudeProcessService implements IProcessService { private processes: Map = new Map(); private db: StorageBackend | null; diff --git a/src/services/CodexProcessService.ts b/src/services/CodexProcessService.ts new file mode 100644 index 00000000..846cc564 --- /dev/null +++ b/src/services/CodexProcessService.ts @@ -0,0 +1,318 @@ +import { spawn, ChildProcess } from 'child_process'; +import type { StorageBackend } from '../db/types/repository'; +import type { RunLogRow } from '../db/types/repository'; +import { verboseLog } from '../utils/logger'; +import { ConflictError } from '../errors'; +import type { IProcessService } from './IProcessService'; +import type { OutputEvent, RunLog, SubscribeCallback } from './ClaudeProcessService'; + +// Codex output event types from JSONL +type CodexStreamEvent = + | { type: 'thread.started'; thread_id: string; [key: string]: unknown } + | { + type: 'item.completed'; + item: { + type: string; + content?: string; + [key: string]: unknown; + }; + [key: string]: unknown; + } + | { type: 'turn.completed'; [key: string]: unknown } + | { type: string; [key: string]: unknown }; + +interface ProcessInfo { + taskId: number; + command: string; + process: ChildProcess; + startedAt: Date; + status: 'running' | 'stopped'; + outputBuffer: CodexStreamEvent[]; + processedEvents: OutputEvent[]; + subscribers: Set; + runLogId: number | null; + sessionId: string | null; +} + +/** + * CodexProcessService + * Manages codex CLI processes running in JSON mode. + * Parses JSONL output from codex and converts to OutputEvent format. + * Implements IProcessService for use with ProcessServiceFactory. + */ +export class CodexProcessService implements IProcessService { + private processes: Map = new Map(); + private db: StorageBackend | null; + + constructor(db?: StorageBackend | null) { + this.db = db ?? null; + } + + /** + * Start a codex process for the given taskId and prompt. + * Prevents duplicate processes for the same taskId. + */ + startProcess(taskId: number, prompt: string, command: string = 'run'): void { + if (this.processes.has(taskId)) { + const existing = this.processes.get(taskId)!; + const pid = existing.process.pid; + const killed = existing.process.killed; + const exitCode = existing.process.exitCode; + const signalCode = existing.process.signalCode; + const aliveMs = Date.now() - existing.startedAt.getTime(); + verboseLog( + `[CodexProcessService] startProcess DUPLICATE taskId=${taskId} existing pid=${pid} killed=${killed} exitCode=${exitCode} signalCode=${signalCode} aliveMs=${aliveMs} command=${existing.command}` + ); + throw new ConflictError(`Process for taskId ${taskId} is already running`); + } + + verboseLog(`[CodexProcessService] startProcess taskId=${taskId} command=${command}`); + + const child = spawn('codex', ['exec', '--json', '--dangerously-bypass-approvals-and-sandbox', prompt], { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const info: ProcessInfo = { + taskId, + command, + process: child, + startedAt: new Date(), + status: 'running', + outputBuffer: [], + processedEvents: [], + subscribers: new Set(), + runLogId: null, + sessionId: null, + }; + + this.processes.set(taskId, info); + verboseLog(`[CodexProcessService] process added to map taskId=${taskId} total=${this.processes.size}`); + + if (this.db) { + info.runLogId = this.db.runLogs.create(info.taskId, info.startedAt.toISOString()); + verboseLog(`[CodexProcessService] run log created id=${info.runLogId} taskId=${taskId}`); + } + + let lineBuffer = ''; + let spawnError = false; + + child.on('error', (err: NodeJS.ErrnoException) => { + spawnError = true; + const message = err.code === 'ENOENT' ? 'codex CLI not found in PATH' : err.message; + console.error(`[CodexProcessService] spawn error for taskId=${taskId}: ${message}`, err); + const errorEvent: OutputEvent = { kind: 'error', message }; + info.processedEvents.push(errorEvent); + info.subscribers.forEach((cb) => cb(errorEvent)); + + const doneEvent: OutputEvent = { kind: 'done', exitCode: 1 }; + info.subscribers.forEach((cb) => cb(doneEvent)); + + if (this.db && info.runLogId) { + const finishedAt = new Date().toISOString(); + this.db.runLogs.updateFinished(info.runLogId, finishedAt, 1, JSON.stringify(info.processedEvents)); + verboseLog(`[CodexProcessService] run log updated (error) id=${info.runLogId} taskId=${taskId}`); + } + + if (this.processes.get(taskId) === info) { + this.processes.delete(taskId); + verboseLog( + `[CodexProcessService] process removed from map (error) taskId=${taskId} total=${this.processes.size}` + ); + } else { + verboseLog(`[CodexProcessService] process error skipped map delete (stale entry) taskId=${taskId}`); + } + }); + + child.stdout?.on('data', (chunk: Buffer) => { + lineBuffer += chunk.toString(); + const lines = lineBuffer.split('\n'); + // Keep the last (potentially incomplete) line in the buffer + lineBuffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let parsed: CodexStreamEvent; + try { + parsed = JSON.parse(trimmed) as CodexStreamEvent; + } catch { + // Skip non-JSON lines + continue; + } + + info.outputBuffer.push(parsed); + this._notifySubscribers(info, parsed); + } + }); + + let stderrBuffer = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderrBuffer += chunk.toString(); + }); + + child.on('close', (code) => { + if (spawnError) return; + // Process any remaining buffered line + const remaining = lineBuffer.trim(); + if (remaining) { + try { + const parsed = JSON.parse(remaining) as CodexStreamEvent; + info.outputBuffer.push(parsed); + this._notifySubscribers(info, parsed); + } catch { + // Ignore + } + } + + if (stderrBuffer) { + console.error(`[CodexProcessService] stderr for taskId=${taskId}:\n${stderrBuffer}`); + const errorEvent: OutputEvent = { kind: 'error', message: stderrBuffer }; + info.processedEvents.push(errorEvent); + info.subscribers.forEach((cb) => cb(errorEvent)); + } + + const exitCode = code ?? 0; + verboseLog(`[CodexProcessService] process close taskId=${taskId} exitCode=${exitCode}`); + if (exitCode !== 0) { + console.error(`[CodexProcessService] process exited with code ${exitCode} for taskId=${taskId}`); + } + const doneEvent: OutputEvent = { kind: 'done', exitCode }; + info.subscribers.forEach((cb) => cb(doneEvent)); + + // Finalize log in DB before removing process + if (this.db && info.runLogId) { + const finishedAt = new Date().toISOString(); + this.db.runLogs.updateFinished(info.runLogId, finishedAt, exitCode, JSON.stringify(info.processedEvents)); + verboseLog(`[CodexProcessService] run log finalized id=${info.runLogId} taskId=${taskId} exitCode=${exitCode}`); + // Rotate: keep only latest 5 per task + const ids = this.db.runLogs.findIdsByTaskId(info.taskId); + if (ids.length > 5) { + const toDelete = ids.slice(5); + this.db.runLogs.deleteMany(toDelete); + verboseLog(`[CodexProcessService] rotated run logs taskId=${taskId} deleted=${toDelete.length}`); + } + } + + if (this.processes.get(taskId) === info) { + this.processes.delete(taskId); + verboseLog( + `[CodexProcessService] process removed from map (close) taskId=${taskId} total=${this.processes.size}` + ); + } else { + verboseLog(`[CodexProcessService] process close skipped map delete (stale entry) taskId=${taskId}`); + } + }); + } + + /** + * Stop the process for the given taskId. + * Returns true if the process was found and signalled, false otherwise. + */ + stopProcess(taskId: number): boolean { + const info = this.processes.get(taskId); + if (!info) { + verboseLog(`[CodexProcessService] stopProcess taskId=${taskId} not found`); + return false; + } + verboseLog(`[CodexProcessService] stopProcess taskId=${taskId} sending SIGTERM`); + info.process.kill('SIGTERM'); + this.processes.delete(taskId); + verboseLog(`[CodexProcessService] process removed from map (stop) taskId=${taskId} total=${this.processes.size}`); + return true; + } + + /** + * List all currently running tasks with their command type. + */ + listRunningTasks(): { taskId: number; command: string }[] { + return Array.from(this.processes.values()).map((info) => ({ taskId: info.taskId, command: info.command })); + } + + /** + * Subscribe to output events for a given taskId. + * If process is running: replay past events and subscribe to future events. + * If no process but DB available: replay last saved log from DB. + * Returns an unsubscribe function. + */ + subscribeOutput(taskId: number, callback: SubscribeCallback): () => void { + const info = this.processes.get(taskId); + if (!info) { + // Try to replay from DB + if (this.db) { + const row = this.db.runLogs.findLatestByTaskId(taskId); + + if (row) { + const events = JSON.parse(row.events) as OutputEvent[]; + events.forEach((evt) => callback(evt)); + callback({ kind: 'done', exitCode: row.exit_code ?? 0 }); + return () => {}; + } + } + + // No process and no log found — emit error + callback({ kind: 'error', message: `No running process for taskId ${taskId}` }); + return () => {}; + } + + // Replay past events to the new subscriber before registering + info.processedEvents.forEach((evt) => callback(evt)); + info.subscribers.add(callback); + return () => { + info.subscribers.delete(callback); + }; + } + + /** + * Get saved run logs for a task from DB (most recent first, up to 5). + */ + getRunLogs(taskId: number): RunLog[] { + if (!this.db) return []; + const rows = this.db.runLogs.findByTaskId(taskId, 5); + return rows.map((r: RunLogRow) => ({ + id: r.id, + task_id: r.task_id, + started_at: r.started_at, + finished_at: r.finished_at, + exit_code: r.exit_code, + session_id: r.session_id, + events: JSON.parse(r.events) as OutputEvent[], + })); + } + + // ---- Private helpers ---- + + private _notifySubscribers(info: ProcessInfo, event: CodexStreamEvent): void { + // Map Codex events to OutputEvent format + if (event.type === 'thread.started') { + // Extract thread_id as session_id + const threadId = (event as Extract).thread_id; + if (threadId && !info.sessionId) { + info.sessionId = threadId; + if (this.db && info.runLogId) { + this.db.runLogs.updateSessionId(info.runLogId, info.sessionId); + } + } + } + + if (event.type === 'item.completed') { + const itemEvent = event as Extract; + const item = itemEvent.item; + + // Treat agent_message items as text output + if (item.type === 'agent_message' && item.content) { + const outputEvent: OutputEvent = { kind: 'text', text: String(item.content) }; + info.processedEvents.push(outputEvent); + info.subscribers.forEach((cb) => cb(outputEvent)); + + if (this.db && info.runLogId) { + this.db.runLogs.updateEvents(info.runLogId, JSON.stringify(info.processedEvents)); + } + } + } + // 'turn.completed' events are buffered but not forwarded as OutputEvents + // (done/error are sent on process close) + } +} diff --git a/src/services/IProcessService.ts b/src/services/IProcessService.ts new file mode 100644 index 00000000..2cd90fc2 --- /dev/null +++ b/src/services/IProcessService.ts @@ -0,0 +1,48 @@ +// These types are defined in ClaudeProcessService and re-exported here for use by IProcessService +import type { OutputEvent, RunLog } from './ClaudeProcessService'; + +// SubscribeCallback is defined inline here since it uses OutputEvent which may be shared across implementations +export type SubscribeCallback = (event: OutputEvent) => void; + +/** + * IProcessService + * Common interface for managing LLM processes (Claude, Codex, etc.) + * Abstracts the details of process spawning, output parsing, and event streaming. + */ +export interface IProcessService { + /** + * Start a process for the given taskId with the provided prompt. + * @param taskId Unique task identifier + * @param prompt The input prompt for the LLM + * @param command Type of command (run, planning, pr, etc.) + * @throws ConflictError if a process is already running for this taskId + */ + startProcess(taskId: number, prompt: string, command?: string): void; + + /** + * Stop the process for the given taskId. + * @param taskId Unique task identifier + * @returns true if the process was found and stopped, false if no process was running + */ + stopProcess(taskId: number): boolean; + + /** + * List all currently running tasks with their command type. + */ + listRunningTasks(): { taskId: number; command: string }[]; + + /** + * Subscribe to output events for a given taskId. + * If process is running: replays past events and subscribes to future events. + * If no process but storage available: replays last saved log from storage. + * @param taskId Unique task identifier + * @param callback Function called for each output event + * @returns Unsubscribe function + */ + subscribeOutput(taskId: number, callback: SubscribeCallback): () => void; + + /** + * Get saved run logs for a task from storage (most recent first, up to 5). + */ + getRunLogs(taskId: number): RunLog[]; +} diff --git a/src/services/ProcessServiceFactory.ts b/src/services/ProcessServiceFactory.ts new file mode 100644 index 00000000..257f7e73 --- /dev/null +++ b/src/services/ProcessServiceFactory.ts @@ -0,0 +1,26 @@ +import type { LlmPreference } from '../board/boardConfig'; +import type { StorageBackend } from '../db/types/repository'; +import type { IProcessService } from './IProcessService'; +import { ClaudeProcessService } from './ClaudeProcessService'; +import { CodexProcessService } from './CodexProcessService'; + +/** + * ProcessServiceFactory + * Creates the appropriate IProcessService implementation based on LlmPreference. + * Defaults to ClaudeProcessService if llm is invalid or undefined. + */ +export class ProcessServiceFactory { + /** + * Create a process service for the given LLM preference. + * @param llm The LLM preference ('claude' or 'codex') + * @param db Optional database backend for persisting run logs + * @returns An IProcessService implementation + */ + static create(llm: LlmPreference | undefined, db?: StorageBackend | null): IProcessService { + // Default to claude if not specified or invalid + if (llm === 'codex') { + return new CodexProcessService(db); + } + return new ClaudeProcessService(db); + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 0fc220a5..eda653b3 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -14,3 +14,6 @@ export { ExportImportService } from './ExportImportService'; export type { ExportData, ExportedTask, ExportedComment, ImportResult } from './ExportImportService'; export { ClaudeProcessService } from './ClaudeProcessService'; export type { ClaudeStreamEvent, OutputEvent, SubscribeCallback } from './ClaudeProcessService'; +export { CodexProcessService } from './CodexProcessService'; +export type { IProcessService } from './IProcessService'; +export { ProcessServiceFactory } from './ProcessServiceFactory'; diff --git a/tests/TaskService.test.ts b/tests/TaskService.test.ts index 4c115674..d25cf2c3 100644 --- a/tests/TaskService.test.ts +++ b/tests/TaskService.test.ts @@ -604,13 +604,40 @@ describe('TaskService', () => { }); it('ソートオプションでupdated_at降順にソートできる', () => { - const task1 = taskService.createTask({ title: 'Task A', status: 'backlog' }); - taskService.createTask({ title: 'Task B', status: 'backlog' }); - // Update task1 to make its updated_at newer - taskService.updateTask(task1.id, { title: 'Task A Updated' }); + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const task1 = taskService.createTask({ title: 'Task A', status: 'backlog' }); + + vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')); + taskService.createTask({ title: 'Task B', status: 'backlog' }); + + // task1 を明示的に最新時刻へ更新して、updated_at 降順の先頭を確定させる + vi.setSystemTime(new Date('2026-01-01T00:00:02.000Z')); + taskService.updateTask(task1.id, { title: 'Task A Updated' }); + + const tasks = taskService.listTasks({}, 'updated_at', 'desc'); + expect(tasks[0].title).toBe('Task A Updated'); + } finally { + vi.useRealTimers(); + } + }); - const tasks = taskService.listTasks({}, 'updated_at', 'desc'); - expect(tasks[0].title).toBe('Task A Updated'); + it('updated_atが同値の場合はid降順でソートされる', () => { + vi.useFakeTimers(); + try { + const fixedNow = new Date('2026-01-01T00:00:00.000Z'); + vi.setSystemTime(fixedNow); + const task1 = taskService.createTask({ title: 'Task A', status: 'backlog' }); + const task2 = taskService.createTask({ title: 'Task B', status: 'backlog' }); + taskService.updateTask(task1.id, { title: 'Task A Updated' }); + + const tasks = taskService.listTasks({}, 'updated_at', 'desc'); + expect(tasks[0].id).toBe(task2.id); + expect(tasks[1].id).toBe(task1.id); + } finally { + vi.useRealTimers(); + } }); it('デフォルトソートはcreated_at降順のまま', () => { diff --git a/tests/board/boardConfig.test.ts b/tests/board/boardConfig.test.ts index 6e650506..9593b96a 100644 --- a/tests/board/boardConfig.test.ts +++ b/tests/board/boardConfig.test.ts @@ -6,7 +6,13 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; -import { readBoardConfig, writeBoardConfig, DETAIL_PANE_MAX_WIDTH, VALID_THEMES } from '../../src/board/boardConfig'; +import { + readBoardConfig, + writeBoardConfig, + DETAIL_PANE_MAX_WIDTH, + VALID_THEMES, + VALID_LLMS, +} from '../../src/board/boardConfig'; describe('boardConfig', () => { const testConfigDir = path.join(process.cwd(), '.agkan-test'); @@ -27,9 +33,9 @@ describe('boardConfig', () => { }); describe('readBoardConfig', () => { - it('should return empty config when config file does not exist', () => { + it('should return default llm when config file does not exist', () => { const config = readBoardConfig(testConfigDir); - expect(config).toEqual({}); + expect(config.llm).toBe('claude'); }); it('should read detailPaneWidth from config file', () => { @@ -38,30 +44,32 @@ describe('boardConfig', () => { const config = readBoardConfig(testConfigDir); expect(config.detailPaneWidth).toBe(500); + expect(config.llm).toBe('claude'); }); - it('should return empty config when config file is invalid YAML', () => { + it('should return default llm when config file is invalid YAML', () => { fs.mkdirSync(testConfigDir, { recursive: true }); fs.writeFileSync(testConfigFile, 'invalid: yaml: content: [broken', 'utf8'); const config = readBoardConfig(testConfigDir); - expect(config).toEqual({}); + expect(config).toEqual({ llm: 'claude' }); }); - it('should return empty config when board section is missing', () => { + it('should return default llm when board section is missing', () => { fs.mkdirSync(testConfigDir, { recursive: true }); fs.writeFileSync(testConfigFile, yaml.dump({ other: 'data' }), 'utf8'); const config = readBoardConfig(testConfigDir); - expect(config).toEqual({}); + expect(config).toEqual({ llm: 'claude' }); }); - it('should return empty config when detailPaneWidth is not a number', () => { + it('should ignore detailPaneWidth when it is not a number', () => { fs.mkdirSync(testConfigDir, { recursive: true }); fs.writeFileSync(testConfigFile, yaml.dump({ board: { detailPaneWidth: 'not-a-number' } }), 'utf8'); const config = readBoardConfig(testConfigDir); - expect(config).toEqual({}); + expect(config.detailPaneWidth).toBeUndefined(); + expect(config.llm).toBe('claude'); }); it('should ignore detailPaneWidth when it exceeds DETAIL_PANE_MAX_WIDTH', () => { @@ -70,6 +78,7 @@ describe('boardConfig', () => { const config = readBoardConfig(testConfigDir); expect(config.detailPaneWidth).toBeUndefined(); + expect(config.llm).toBe('claude'); }); it('should accept detailPaneWidth equal to DETAIL_PANE_MAX_WIDTH', () => { @@ -110,14 +119,31 @@ describe('boardConfig', () => { const config = readBoardConfig(testConfigDir); expect(config.theme).toBeUndefined(); + expect(config.llm).toBe('claude'); }); - it('should ignore theme when value is not a string', () => { + it('should read llm "codex" from config file', () => { fs.mkdirSync(testConfigDir, { recursive: true }); - fs.writeFileSync(testConfigFile, yaml.dump({ board: { theme: 123 } }), 'utf8'); + fs.writeFileSync(testConfigFile, yaml.dump({ board: { llm: 'codex' } }), 'utf8'); const config = readBoardConfig(testConfigDir); - expect(config.theme).toBeUndefined(); + expect(config.llm).toBe('codex'); + }); + + it('should read llm "claude" from config file', () => { + fs.mkdirSync(testConfigDir, { recursive: true }); + fs.writeFileSync(testConfigFile, yaml.dump({ board: { llm: 'claude' } }), 'utf8'); + + const config = readBoardConfig(testConfigDir); + expect(config.llm).toBe('claude'); + }); + + it('should default llm to "claude" when llm value is invalid', () => { + fs.mkdirSync(testConfigDir, { recursive: true }); + fs.writeFileSync(testConfigFile, yaml.dump({ board: { llm: 'invalid-llm' } }), 'utf8'); + + const config = readBoardConfig(testConfigDir); + expect(config.llm).toBe('claude'); }); }); @@ -163,6 +189,14 @@ describe('boardConfig', () => { expect(parsed.board?.theme).toBe('dark'); }); + it('should write llm to config file', () => { + writeBoardConfig(testConfigDir, { llm: 'codex' }); + + const content = fs.readFileSync(testConfigFile, 'utf8'); + const parsed = yaml.load(content) as { board?: { llm?: string } }; + expect(parsed.board?.llm).toBe('codex'); + }); + it('should preserve detailPaneWidth when writing theme', () => { fs.mkdirSync(testConfigDir, { recursive: true }); fs.writeFileSync(testConfigFile, yaml.dump({ board: { detailPaneWidth: 500 } }), 'utf8'); @@ -189,4 +223,11 @@ describe('boardConfig', () => { expect(VALID_THEMES).toContain('system'); }); }); + + describe('VALID_LLMS', () => { + it('should contain codex and claude', () => { + expect(VALID_LLMS).toContain('codex'); + expect(VALID_LLMS).toContain('claude'); + }); + }); }); diff --git a/tests/board/boardRoutes.test.ts b/tests/board/boardRoutes.test.ts index 325633cc..38653cdd 100644 --- a/tests/board/boardRoutes.test.ts +++ b/tests/board/boardRoutes.test.ts @@ -807,7 +807,7 @@ describe('GET /api/config', () => { const res = await app.fetch(new Request('http://localhost/api/config')); expect(res.status).toBe(200); const data = (await res.json()) as { board: Record }; - expect(data.board).toEqual({}); + expect(data.board).toEqual({ llm: 'claude' }); }); }); @@ -931,6 +931,58 @@ describe('PUT /api/config', () => { ); expect(res.status).toBe(400); }); + + it('returns success when updating llm to codex', async () => { + const app = buildApp(buildServices()); + const res = await app.fetch( + new Request('http://localhost/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board: { llm: 'codex' } }), + }) + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean }; + expect(data.success).toBe(true); + }); + + it('returns success when updating llm to claude', async () => { + const app = buildApp(buildServices()); + const res = await app.fetch( + new Request('http://localhost/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board: { llm: 'claude' } }), + }) + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean }; + expect(data.success).toBe(true); + }); + + it('returns 400 when llm is invalid', async () => { + const app = buildApp(buildServices()); + const res = await app.fetch( + new Request('http://localhost/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board: { llm: 'invalid' } }), + }) + ); + expect(res.status).toBe(400); + }); + + it('returns 400 when llm is not a string', async () => { + const app = buildApp(buildServices()); + const res = await app.fetch( + new Request('http://localhost/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board: { llm: 123 } }), + }) + ); + expect(res.status).toBe(400); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/board/claudeRoutes.test.ts b/tests/board/claudeRoutes.test.ts index ef8f6eff..31ecc39b 100644 --- a/tests/board/claudeRoutes.test.ts +++ b/tests/board/claudeRoutes.test.ts @@ -14,25 +14,24 @@ import { MetadataService } from '../../src/services/MetadataService'; import { CommentService } from '../../src/services/CommentService'; import { TaskBlockService } from '../../src/services/TaskBlockService'; import { ConflictError } from '../../src/errors'; -import { ClaudeProcessService, SubscribeCallback } from '../../src/services/ClaudeProcessService'; +import type { IProcessService, SubscribeCallback } from '../../src/services/IProcessService'; import { getStorageBackend } from '../../src/db/connection'; import { registerBoardRoutes, BoardServices } from '../../src/board/boardRoutes'; const TEST_CONFIG_DIR = path.join(process.cwd(), '.agkan-test-claude-routes-' + process.pid); -function buildMockClaudeProcessService(): ClaudeProcessService { +function buildMockProcessService(): IProcessService { const mock = { startProcess: vi.fn(), stopProcess: vi.fn().mockReturnValue(true), listRunningTasks: vi.fn().mockReturnValue([]), subscribeOutput: vi.fn().mockReturnValue(() => {}), - getOutputBuffer: vi.fn().mockReturnValue([]), getRunLogs: vi.fn().mockReturnValue([]), - } as unknown as ClaudeProcessService; + } as unknown as IProcessService; return mock; } -function buildServices(claudeProcessService?: ClaudeProcessService): BoardServices { +function buildServices(processService?: IProcessService): BoardServices { const database = getStorageBackend(); return { ts: new TaskService(database), @@ -43,7 +42,7 @@ function buildServices(claudeProcessService?: ClaudeProcessService): BoardServic tbs: new TaskBlockService(database), database, configDir: TEST_CONFIG_DIR, - claudeProcessService, + processService, }; } @@ -71,7 +70,7 @@ afterEach(() => { // ───────────────────────────────────────────────────────────────────────────── describe('POST /api/claude/tasks/:taskId/run', () => { it('returns 201 and starts a process for a valid task', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const services = buildServices(mock); const task = services.ts.createTask({ title: 'Test Task', status: 'backlog' }); const app = buildApp(services); @@ -92,7 +91,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => { }); it('uses planning prompt when command is planning', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const services = buildServices(mock); const task = services.ts.createTask({ title: 'Planning Task', status: 'backlog' }); const app = buildApp(services); @@ -110,7 +109,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => { }); it('defaults to run command when no command specified', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const services = buildServices(mock); const task = services.ts.createTask({ title: 'Default Task', status: 'backlog' }); const app = buildApp(services); @@ -128,7 +127,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => { }); it('returns 400 for invalid taskId', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const app = buildApp(buildServices(mock)); const res = await app.fetch( @@ -145,7 +144,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => { }); it('returns 404 when task does not exist', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const app = buildApp(buildServices(mock)); const res = await app.fetch( @@ -162,7 +161,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => { }); it('returns 409 when process is already running', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.startProcess as ReturnType).mockImplementation(() => { throw new ConflictError('Process for taskId 1 is already running'); }); @@ -206,7 +205,7 @@ describe('POST /api/claude/tasks/:taskId/run', () => { describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => { it('updates status to done when run command completes with exitCode 0', async () => { let capturedCallback: SubscribeCallback | null = null; - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.subscribeOutput as ReturnType).mockImplementation((_taskId: number, cb: SubscribeCallback) => { capturedCallback = cb; return () => {}; @@ -233,7 +232,7 @@ describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => { it('updates status to review when pr command completes with exitCode 0', async () => { let capturedCallback: SubscribeCallback | null = null; - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.subscribeOutput as ReturnType).mockImplementation((_taskId: number, cb: SubscribeCallback) => { capturedCallback = cb; return () => {}; @@ -260,7 +259,7 @@ describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => { it('does not update status when exitCode is non-zero', async () => { let capturedCallback: SubscribeCallback | null = null; - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.subscribeOutput as ReturnType).mockImplementation((_taskId: number, cb: SubscribeCallback) => { capturedCallback = cb; return () => {}; @@ -286,7 +285,7 @@ describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => { }); it('does not update status for planning command', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const services = buildServices(mock); const task = services.ts.createTask({ title: 'Planning Task', status: 'ready' }); const app = buildApp(services); @@ -312,7 +311,7 @@ describe('POST /api/claude/tasks/:taskId/run - status auto-update', () => { // ───────────────────────────────────────────────────────────────────────────── describe('DELETE /api/claude/tasks/:taskId/run', () => { it('returns 200 and stops the process', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.stopProcess as ReturnType).mockReturnValue(true); const app = buildApp(buildServices(mock)); @@ -329,7 +328,7 @@ describe('DELETE /api/claude/tasks/:taskId/run', () => { }); it('returns 400 for invalid taskId', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const app = buildApp(buildServices(mock)); const res = await app.fetch( @@ -344,7 +343,7 @@ describe('DELETE /api/claude/tasks/:taskId/run', () => { }); it('returns 404 when process does not exist', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.stopProcess as ReturnType).mockReturnValue(false); const app = buildApp(buildServices(mock)); @@ -377,7 +376,7 @@ describe('DELETE /api/claude/tasks/:taskId/run', () => { // ───────────────────────────────────────────────────────────────────────────── describe('GET /api/running-tasks', () => { it('returns 200 with empty tasks array when nothing is running', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.listRunningTasks as ReturnType).mockReturnValue([]); const app = buildApp(buildServices(mock)); @@ -389,7 +388,7 @@ describe('GET /api/running-tasks', () => { }); it('returns list of running tasks with command info', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.listRunningTasks as ReturnType).mockReturnValue([ { taskId: 1, command: 'run' }, { taskId: 2, command: 'planning' }, @@ -422,7 +421,7 @@ describe('GET /api/running-tasks', () => { // ───────────────────────────────────────────────────────────────────────────── describe('GET /api/claude/tasks/:taskId/stream', () => { it('returns SSE response with correct headers', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const app = buildApp(buildServices(mock)); const res = await app.fetch(new Request('http://localhost/api/claude/tasks/1/stream')); @@ -434,7 +433,7 @@ describe('GET /api/claude/tasks/:taskId/stream', () => { }); it('returns 400 for invalid taskId', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const app = buildApp(buildServices(mock)); const res = await app.fetch(new Request('http://localhost/api/claude/tasks/abc/stream')); @@ -444,7 +443,7 @@ describe('GET /api/claude/tasks/:taskId/stream', () => { it('sends SSE events from subscribeOutput callback', async () => { let capturedCallback: SubscribeCallback | null = null; - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); (mock.subscribeOutput as ReturnType).mockImplementation((_taskId: number, cb: SubscribeCallback) => { capturedCallback = cb; return () => {}; @@ -483,7 +482,7 @@ describe('GET /api/claude/tasks/:taskId/stream', () => { // ───────────────────────────────────────────────────────────────────────────── describe('GET /api/claude/tasks/:taskId/run-logs', () => { it('returns 200 with empty logs when no runs exist', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const services = buildServices(mock); const task = services.ts.createTask({ title: 'Test Task', status: 'done' }); const app = buildApp(services); @@ -497,7 +496,7 @@ describe('GET /api/claude/tasks/:taskId/run-logs', () => { }); it('returns 200 with logs from getRunLogs', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const fakeLogs = [ { id: 1, @@ -524,7 +523,7 @@ describe('GET /api/claude/tasks/:taskId/run-logs', () => { }); it('returns 400 for invalid taskId', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const app = buildApp(buildServices(mock)); const res = await app.fetch(new Request('http://localhost/api/claude/tasks/abc/run-logs')); @@ -533,7 +532,7 @@ describe('GET /api/claude/tasks/:taskId/run-logs', () => { }); it('returns 404 when task does not exist', async () => { - const mock = buildMockClaudeProcessService(); + const mock = buildMockProcessService(); const app = buildApp(buildServices(mock)); const res = await app.fetch(new Request('http://localhost/api/claude/tasks/9999/run-logs')); diff --git a/tests/board/client/burgerMenuSettings.test.ts b/tests/board/client/burgerMenuSettings.test.ts new file mode 100644 index 00000000..89a6ac75 --- /dev/null +++ b/tests/board/client/burgerMenuSettings.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { initBurgerMenu } from '../../../src/board/client/burgerMenu'; + +function setupDOM(): void { + document.body.innerHTML = ` + +
+
Purge Tasks
+
Export Tasks
+
Import Tasks
+
Settings
+
Version Info
+
+
Dark Mode
+
Light Mode
+
System Setting
+
+ + + + + `; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.restoreAllMocks(); + setupDOM(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('burger menu settings modal', () => { + it('opens settings modal from burger menu', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ board: { llm: 'claude' } }), { status: 200 }) + ); + + initBurgerMenu(); + document.getElementById('burger-settings')!.click(); + + const modal = document.getElementById('settings-modal')!; + expect(modal.classList.contains('show')).toBe(true); + }); + + it('loads llm selection from server config', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ board: { llm: 'codex' } }), { status: 200 }) + ); + + initBurgerMenu(); + document.getElementById('burger-settings')!.click(); + + await vi.waitFor(() => { + expect((document.getElementById('settings-llm-select') as HTMLSelectElement).value).toBe('codex'); + }); + }); + + it('uses claude as fallback when llm is missing', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({ board: {} }), { status: 200 })); + + initBurgerMenu(); + document.getElementById('burger-settings')!.click(); + + await vi.waitFor(() => { + expect((document.getElementById('settings-llm-select') as HTMLSelectElement).value).toBe('claude'); + }); + }); + + it('saves selected llm via /api/config', async () => { + const fetchMock = vi + .spyOn(global, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ board: { llm: 'claude' } }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 })); + + initBurgerMenu(); + document.getElementById('burger-settings')!.click(); + + const select = document.getElementById('settings-llm-select') as HTMLSelectElement; + select.value = 'codex'; + + document.getElementById('settings-save-btn')!.click(); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/config', + expect.objectContaining({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board: { llm: 'codex' } }), + }) + ); + }); + + vi.advanceTimersByTime(300); + expect(document.getElementById('settings-modal')!.classList.contains('show')).toBe(false); + }); +}); diff --git a/tests/board/server.test.ts b/tests/board/server.test.ts index 4a466ec0..2a751326 100644 --- a/tests/board/server.test.ts +++ b/tests/board/server.test.ts @@ -1860,6 +1860,7 @@ describe('createBoardApp', () => { const data = (await res.json()) as { board: { detailPaneWidth?: number } }; expect(data.board).toBeDefined(); expect(data.board.detailPaneWidth).toBeUndefined(); + expect((data.board as { llm?: string }).llm).toBe('claude'); }); it('should return detailPaneWidth from config file when it exists', async () => { @@ -1934,6 +1935,32 @@ describe('createBoardApp', () => { expect(saved.board?.detailPaneWidth).toBe(450); }); + it('should save llm to config file', async () => { + const app = createBoardApp( + taskService, + taskTagService, + metadataService, + undefined, + undefined, + undefined, + testConfigDir + ); + const res = await app.fetch( + new Request('http://localhost/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board: { llm: 'codex' } }), + }) + ); + + expect(res.status).toBe(200); + + const configFile = path.join(testConfigDir, 'config.yml'); + expect(fs.existsSync(configFile)).toBe(true); + const saved = yaml.load(fs.readFileSync(configFile, 'utf8')) as { board?: { llm?: string } }; + expect(saved.board?.llm).toBe('codex'); + }); + it('should return 400 when detailPaneWidth exceeds DETAIL_PANE_MAX_WIDTH', async () => { const app = createBoardApp( taskService, diff --git a/tests/services/index.test.ts b/tests/services/index.test.ts index cfecd0bd..3193be80 100644 --- a/tests/services/index.test.ts +++ b/tests/services/index.test.ts @@ -47,8 +47,23 @@ describe('services/index', () => { expect(typeof servicesIndex.ClaudeProcessService).toBe('function'); }); - it('should export exactly 9 named exports', () => { + it('should export CodexProcessService', () => { + expect(servicesIndex.CodexProcessService).toBeDefined(); + expect(typeof servicesIndex.CodexProcessService).toBe('function'); + }); + + it('should export IProcessService type', () => { + // Type exports don't appear as runtime values, but we can verify the import works + expect(servicesIndex).toBeDefined(); + }); + + it('should export ProcessServiceFactory', () => { + expect(servicesIndex.ProcessServiceFactory).toBeDefined(); + expect(typeof servicesIndex.ProcessServiceFactory).toBe('function'); + }); + + it('should export exactly 11 named exports', () => { const exportedKeys = Object.keys(servicesIndex); - expect(exportedKeys).toHaveLength(9); + expect(exportedKeys).toHaveLength(11); }); });