|
| 1 | +import os from 'os'; |
| 2 | + |
| 3 | +export function register(deps) { |
| 4 | + const { sendJSON, parseBody, queries, broadcastSync, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, discoveredAgents, STARTUP_CWD } = deps; |
| 5 | + |
| 6 | + const routes = {}; |
| 7 | + |
| 8 | + routes['_match'] = (method, pathOnly) => { |
| 9 | + const key = `${method} ${pathOnly}`; |
| 10 | + if (routes[key]) return routes[key]; |
| 11 | + let m; |
| 12 | + if ((m = pathOnly.match(/^\/api\/runs\/([^/]+)$/))) return (req, res) => handleRunById(req, res, m[1]); |
| 13 | + if (method === 'GET' && (m = pathOnly.match(/^\/api\/runs\/([^/]+)\/wait$/))) return (req, res) => handleRunWait(req, res, m[1]); |
| 14 | + if (method === 'GET' && (m = pathOnly.match(/^\/api\/runs\/([^/]+)\/stream$/))) return (req, res) => { res.writeHead(410); res.end(JSON.stringify({ error: 'SSE removed, use WebSocket' })); }; |
| 15 | + if (method === 'POST' && (m = pathOnly.match(/^\/api\/runs\/([^/]+)\/cancel$/))) return (req, res) => handleRunCancel(req, res, m[1]); |
| 16 | + if (method === 'POST' && (m = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)\/cancel$/))) return (req, res) => handleThreadRunCancel(req, res, m[1], m[2]); |
| 17 | + if (method === 'GET' && (m = pathOnly.match(/^\/api\/threads\/([^/]+)\/runs\/([^/]+)\/wait$/))) return (req, res) => handleThreadRunWait(req, res, m[1], m[2]); |
| 18 | + return null; |
| 19 | + }; |
| 20 | + |
| 21 | + routes['POST /api/runs'] = async (req, res) => { |
| 22 | + let body = ''; |
| 23 | + for await (const chunk of req) { body += chunk; } |
| 24 | + let parsed = {}; |
| 25 | + try { parsed = body ? JSON.parse(body) : {}; } catch {} |
| 26 | + const { input, agentId } = parsed; |
| 27 | + if (!input) { sendJSON(req, res, 400, { error: 'Missing input in request body' }); return; } |
| 28 | + const resolvedAgentId = agentId || 'claude-code'; |
| 29 | + const resolvedModel = parsed.model || null; |
| 30 | + const cwd = parsed.workingDirectory || STARTUP_CWD; |
| 31 | + const thread = queries.createConversation(resolvedAgentId, 'Stateless Run', cwd); |
| 32 | + const session = queries.createSession(thread.id, resolvedAgentId, 'pending'); |
| 33 | + const content = typeof input === 'string' ? input : JSON.stringify(input); |
| 34 | + const message = queries.createMessage(thread.id, 'user', content); |
| 35 | + processMessageWithStreaming(thread.id, message.id, session.id, content, resolvedAgentId, resolvedModel); |
| 36 | + sendJSON(req, res, 200, { id: session.id, status: 'pending', started_at: session.started_at, agentId: resolvedAgentId }); |
| 37 | + }; |
| 38 | + |
| 39 | + routes['POST /api/runs/search'] = async (req, res) => { |
| 40 | + const sessions = queries.getAllSessions(); |
| 41 | + const runs = sessions.slice(0, 50).map(s => ({ id: s.id, status: s.status, started_at: s.started_at, completed_at: s.completed_at, agentId: s.agentId, input: null, output: null })).reverse(); |
| 42 | + sendJSON(req, res, 200, runs); |
| 43 | + }; |
| 44 | + |
| 45 | + routes['POST /api/runs/stream'] = (req, res) => { res.writeHead(410); res.end(JSON.stringify({ error: 'SSE removed, use WebSocket' })); }; |
| 46 | + |
| 47 | + routes['POST /api/runs/wait'] = async (req, res) => { |
| 48 | + const body = await parseBody(req); |
| 49 | + const { agent_id, input, config } = body; |
| 50 | + if (!agent_id) { sendJSON(req, res, 422, { error: 'agent_id is required' }); return; } |
| 51 | + const agent = discoveredAgents.find(a => a.id === agent_id); |
| 52 | + if (!agent) { sendJSON(req, res, 404, { error: 'Agent not found' }); return; } |
| 53 | + const run = queries.createRun(agent_id, null, input, config); |
| 54 | + sendJSON(req, res, 200, run); |
| 55 | + }; |
| 56 | + |
| 57 | + async function handleRunById(req, res, runId) { |
| 58 | + if (req.method === 'GET') { |
| 59 | + const run = queries.getRun(runId); |
| 60 | + if (!run) { sendJSON(req, res, 404, { error: 'Run not found' }); return; } |
| 61 | + sendJSON(req, res, 200, run); |
| 62 | + return; |
| 63 | + } |
| 64 | + if (req.method === 'POST') { |
| 65 | + const run = queries.getRun(runId); |
| 66 | + if (!run) { sendJSON(req, res, 404, { error: 'Run not found' }); return; } |
| 67 | + if (run.status !== 'pending') { sendJSON(req, res, 409, { error: 'Run is not resumable' }); return; } |
| 68 | + sendJSON(req, res, 200, run); |
| 69 | + return; |
| 70 | + } |
| 71 | + if (req.method === 'DELETE') { |
| 72 | + try { queries.deleteRun(runId); res.writeHead(204); res.end(); } catch { sendJSON(req, res, 404, { error: 'Run not found' }); } |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + async function handleRunWait(req, res, runId) { |
| 77 | + const run = queries.getRun(runId); |
| 78 | + if (!run) { sendJSON(req, res, 404, { error: 'Run not found' }); return; } |
| 79 | + const startTime = Date.now(); |
| 80 | + const poll = setInterval(() => { |
| 81 | + const cur = queries.getRun(runId); |
| 82 | + const done = cur && ['success', 'error', 'cancelled'].includes(cur.status); |
| 83 | + if (done) { clearInterval(poll); sendJSON(req, res, 200, cur); } |
| 84 | + else if (Date.now() - startTime > 30000) { clearInterval(poll); sendJSON(req, res, 408, { error: 'Run still pending after 30s', run_id: runId, status: cur?.status || run.status }); } |
| 85 | + }, 500); |
| 86 | + req.on('close', () => clearInterval(poll)); |
| 87 | + } |
| 88 | + |
| 89 | + async function handleRunCancel(req, res, runId) { |
| 90 | + try { |
| 91 | + const run = queries.getRun(runId); |
| 92 | + if (!run) { sendJSON(req, res, 404, { error: 'Run not found' }); return; } |
| 93 | + if (['success', 'error', 'cancelled'].includes(run.status)) { sendJSON(req, res, 409, { error: 'Run already completed or cancelled' }); return; } |
| 94 | + const cancelled = queries.cancelRun(runId); |
| 95 | + const threadId = run.thread_id; |
| 96 | + if (threadId) { |
| 97 | + const execution = activeExecutions.get(threadId); |
| 98 | + if (execution?.pid) { |
| 99 | + try { process.kill(-execution.pid, 'SIGTERM'); } catch { try { process.kill(execution.pid, 'SIGTERM'); } catch {} } |
| 100 | + setTimeout(() => { try { process.kill(-execution.pid, 'SIGKILL'); } catch { try { process.kill(execution.pid, 'SIGKILL'); } catch {} } }, 3000); |
| 101 | + } |
| 102 | + if (execution?.sessionId) queries.updateSession(execution.sessionId, { status: 'error', error: 'Cancelled by user', completed_at: Date.now() }); |
| 103 | + activeExecutions.delete(threadId); |
| 104 | + queries.setIsStreaming(threadId, false); |
| 105 | + broadcastSync({ type: 'streaming_cancelled', sessionId: execution?.sessionId || runId, conversationId: threadId, runId, timestamp: Date.now() }); |
| 106 | + } |
| 107 | + sendJSON(req, res, 200, cancelled); |
| 108 | + } catch (err) { |
| 109 | + if (err.message === 'Run not found') sendJSON(req, res, 404, { error: err.message }); |
| 110 | + else if (err.message.includes('already completed')) sendJSON(req, res, 409, { error: err.message }); |
| 111 | + else sendJSON(req, res, 500, { error: err.message }); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + async function handleThreadRunCancel(req, res, threadId, runId) { |
| 116 | + try { |
| 117 | + const run = queries.getRun(runId); |
| 118 | + if (!run) { sendJSON(req, res, 404, { error: 'Run not found' }); return; } |
| 119 | + if (run.thread_id !== threadId) { sendJSON(req, res, 400, { error: 'Run does not belong to specified thread' }); return; } |
| 120 | + if (['success', 'error', 'cancelled'].includes(run.status)) { sendJSON(req, res, 409, { error: 'Run already completed or cancelled' }); return; } |
| 121 | + const cancelled = queries.cancelRun(runId); |
| 122 | + const execution = activeExecutions.get(threadId); |
| 123 | + if (execution?.pid) { |
| 124 | + try { process.kill(-execution.pid, 'SIGTERM'); } catch { try { process.kill(execution.pid, 'SIGTERM'); } catch {} } |
| 125 | + setTimeout(() => { try { process.kill(-execution.pid, 'SIGKILL'); } catch { try { process.kill(execution.pid, 'SIGKILL'); } catch {} } }, 3000); |
| 126 | + } |
| 127 | + if (execution?.sessionId) queries.updateSession(execution.sessionId, { status: 'error', error: 'Cancelled by user', completed_at: Date.now() }); |
| 128 | + activeExecutions.delete(threadId); |
| 129 | + activeProcessesByRunId.delete(runId); |
| 130 | + queries.setIsStreaming(threadId, false); |
| 131 | + broadcastSync({ type: 'run_cancelled', runId, threadId, sessionId: execution?.sessionId, timestamp: Date.now() }); |
| 132 | + broadcastSync({ type: 'streaming_cancelled', sessionId: execution?.sessionId || runId, conversationId: threadId, runId, timestamp: Date.now() }); |
| 133 | + sendJSON(req, res, 200, cancelled); |
| 134 | + } catch (err) { |
| 135 | + if (err.message === 'Run not found') sendJSON(req, res, 404, { error: err.message }); |
| 136 | + else if (err.message.includes('already completed')) sendJSON(req, res, 409, { error: err.message }); |
| 137 | + else sendJSON(req, res, 500, { error: err.message }); |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + async function handleThreadRunWait(req, res, threadId, runId) { |
| 142 | + const run = queries.getRun(runId); |
| 143 | + if (!run) { sendJSON(req, res, 404, { error: 'Run not found' }); return; } |
| 144 | + if (run.thread_id !== threadId) { sendJSON(req, res, 400, { error: 'Run does not belong to specified thread' }); return; } |
| 145 | + const startTime = Date.now(); |
| 146 | + const poll = setInterval(() => { |
| 147 | + const cur = queries.getRun(runId); |
| 148 | + const done = cur && ['success', 'error', 'cancelled'].includes(cur.status); |
| 149 | + if (done) { clearInterval(poll); sendJSON(req, res, 200, cur); } |
| 150 | + else if (Date.now() - startTime > 30000) { clearInterval(poll); sendJSON(req, res, 408, { error: 'Run still pending after 30s', run_id: runId, status: cur?.status || run.status }); } |
| 151 | + }, 500); |
| 152 | + req.on('close', () => clearInterval(poll)); |
| 153 | + } |
| 154 | + |
| 155 | + return routes; |
| 156 | +} |
0 commit comments