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'; import { rebaselineWorktreeAfterApply } from '../services/worktrees.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, // Manual RightRail create — no agent staged it; renders as "manual". null, ); 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); // v2.6 Phase 3 (3.5): re-baseline the session worktree's diff to the applied // state, so the next external-agent turn diffs against applied-not-original // and doesn't re-surface the just-applied changes. Best-effort: a worktree // session may not exist (native-only chat), and a re-baseline hiccup must not // fail the apply the user just requested. if (results.some((r) => r.success)) { await rebaselineWorktreeAfterApply(sql, sessionId).catch(() => {}); } 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); } else { // v2.6 Phase 3 (3.5): re-baseline the session worktree after a successful // apply so the next external-agent turn diffs against applied-not-original. // Resolve the change's session; best-effort, never fails the apply. const sessRows = await sql<{ session_id: string }[]>` SELECT session_id FROM pending_changes WHERE id = ${changeId} `; const sessionId = sessRows[0]?.session_id; if (sessionId) await rebaselineWorktreeAfterApply(sql, sessionId).catch(() => {}); } 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; }, ); }