import type { FastifyInstance } from 'fastify'; import type { CreateSessionRequest, SessionResponse, SessionsListResponse } from '@arboretum/shared'; import type { PtyManager } from '../core/pty-manager.js'; import { mergeSessions, type DiscoveryService } from '../core/discovery-service.js'; export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService): void { app.get('/api/v1/sessions', async (): Promise => { return { sessions: mergeSessions(manager.list(), discovery.list()) }; }); app.post('/api/v1/sessions', async (req, reply) => { const body = req.body as Partial | null; if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'cwd (absolute path) is required' } }); } if (body.command !== undefined && body.command !== 'claude' && body.command !== 'bash') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } }); } try { const session = manager.spawn({ cwd: body.cwd, ...(body.command ? { command: body.command } : {}) }); const res: SessionResponse = { session }; return reply.status(201).send(res); } catch (err) { const statusCode = (err as { statusCode?: number }).statusCode ?? 500; return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } }); } }); // Reprise d'une session morte : nouveau PTY managé `--resume ` DANS SON CWD D'ORIGINE (spike S1). // Le cwd n'est jamais fourni par le client : il est lu sur disque via la découverte. app.post('/api/v1/sessions/:id/resume', async (req, reply) => { const { id } = req.params as { id: string }; const discovered = discovery.getDiscovered(id); if (!discovered) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No resumable session with this id' } }); } // Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE). if (discovery.isClaudeSessionLive(id) || manager.findLiveByClaudeSessionId(id)) { return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } }); } try { // Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume. const ctx = manager.groupSessionContext(id); const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id }, ...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}), ...(ctx?.groupId ? { groupId: ctx.groupId } : {}), }); const res: SessionResponse = { session }; return reply.status(201).send(res); } catch (err) { const statusCode = (err as { statusCode?: number }).statusCode ?? 500; return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } }); } }); // Fork : duplique une session (vivante ou morte) sans la corrompre (`--resume --fork-session`). app.post('/api/v1/sessions/:id/fork', async (req, reply) => { const { id } = req.params as { id: string }; const discovered = discovery.getDiscovered(id); if (!discovered) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to fork' } }); } try { const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id, fork: true } }); const res: SessionResponse = { session }; return reply.status(201).send(res); } catch (err) { const statusCode = (err as { statusCode?: number }).statusCode ?? 500; return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } }); } }); app.delete('/api/v1/sessions/:id', async (req, reply) => { const { id } = req.params as { id: string }; if (!manager.kill(id)) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No live session with this id' } }); } return reply.send({ ok: true }); }); }