import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import { listPending, applyOne, applyAll, rejectOne, rewindOne, queueCreate, } from '../services/pending_changes.js'; import { WriteGuardError } from '../services/write_guard.js'; const CreateBody = z.object({ file_path: z.string().min(1), content: z.string(), }); /** * Resolve project root from a session's project path. */ async function resolveProjectRoot(sql: Sql, sessionId: string): Promise { const rows = await sql<{ path: string }[]>` SELECT p.path FROM sessions s JOIN projects p ON s.project_id = p.id WHERE s.id = ${sessionId} `; return rows.length > 0 ? rows[0]!.path : null; } /** * Resolve project root from a pending change's session. */ async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise { const rows = await sql<{ path: string }[]>` SELECT p.path FROM pending_changes pc JOIN sessions s ON pc.session_id = s.id JOIN projects p ON s.project_id = p.id WHERE pc.id = ${changeId} `; return rows.length > 0 ? rows[0]!.path : null; } export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void { // GET /api/sessions/:sessionId/pending — list pending changes for a session app.get<{ Params: { sessionId: string } }>( '/api/sessions/:sessionId/pending', async (req, reply) => { const sessionId = req.params.sessionId; const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`; if (session.length === 0) { reply.code(404); return { error: 'session not found' }; } const pending = await listPending(sql, sessionId); return pending; }, ); // POST /api/sessions/:sessionId/pending/create — queue a new-file create // (manual create from the RightRail file browser; no inference involved). // queueCreate runs resolveWritePath internally, so a path that escapes the // project root or hits a secret file throws WriteGuardError → 422 with the // guard message. Mirrors the { error } 404 shape used by the other routes // and the 422 status used by apply/rewind on failure. app.post<{ Params: { sessionId: string } }>( '/api/sessions/:sessionId/pending/create', async (req, reply) => { const sessionId = req.params.sessionId; const parsed = CreateBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const projectRoot = await resolveProjectRoot(sql, sessionId); if (!projectRoot) { reply.code(404); return { error: 'session or project not found' }; } try { const change = await queueCreate( sql, sessionId, null, parsed.data.file_path, parsed.data.content, projectRoot, ); return change; } catch (err) { if (err instanceof WriteGuardError) { reply.code(422); return { error: err.message }; } throw err; } }, ); // POST /api/sessions/:sessionId/pending/apply — apply all pending changes app.post<{ Params: { sessionId: string } }>( '/api/sessions/:sessionId/pending/apply', async (req, reply) => { const sessionId = req.params.sessionId; const projectRoot = await resolveProjectRoot(sql, sessionId); if (!projectRoot) { reply.code(404); return { error: 'session or project not found' }; } const results = await applyAll(sql, sessionId, projectRoot); return { results }; }, ); // POST /api/pending/:id/apply — apply a single pending change app.post<{ Params: { id: string } }>( '/api/pending/:id/apply', async (req, reply) => { const changeId = req.params.id; const projectRoot = await resolveProjectRootForChange(sql, changeId); if (!projectRoot) { reply.code(404); return { error: 'pending change or project not found' }; } const result = await applyOne(sql, changeId, projectRoot); if (!result.success) { reply.code(422); } return result; }, ); // POST /api/pending/:id/reject — reject a single pending change app.post<{ Params: { id: string } }>( '/api/pending/:id/reject', async (req, reply) => { const changeId = req.params.id; await rejectOne(sql, changeId); return { ok: true }; }, ); // POST /api/pending/:id/rewind — rewind (undo) an applied change app.post<{ Params: { id: string } }>( '/api/pending/:id/rewind', async (req, reply) => { const changeId = req.params.id; const projectRoot = await resolveProjectRootForChange(sql, changeId); if (!projectRoot) { reply.code(404); return { error: 'pending change or project not found' }; } const result = await rewindOne(sql, changeId, projectRoot); if (!result.success) { reply.code(422); } return result; }, ); }