Files
boocode/apps/coder/src/routes/pending.ts
indifferentketchup ce31577d1e v2.0.0-beta: write tools, pending-changes queue, inference loop, API routes
Phase 2 of v2.0. BooCoder is now a functional write-capable chatbot.

Write-path guard: resolveWritePath() uses resolve() (no realpath — files may
not exist for creates) + prefix-check + secret-file deny list (.env, *.pem,
id_rsa*, etc.). 23 unit tests cover traversal attacks.

Pending-changes service: queueEdit/Create/Delete → applyOne/All →
rejectOne/All → rewindOne. Edit diffs stored as JSON {old, new}. All writes
queue before touching disk; apply re-validates the path guard.

5 write tools: edit_file, create_file, delete_file, apply_pending, rewind.
Registered alongside 25 read-only tools from BooChat (30 total, alpha-sorted).
Write tools use a module-level inference context for sql+sessionId injection.

Inference loop via workspace dependency: apps/coder imports
createInferenceRunner, createBroker, ALL_TOOLS from @boocode/server (dist/).
apps/server gains declaration: true + exports map with typed subpath entries.
No code duplication — one inference engine shared by both apps.

API routes: POST /api/sessions/:id/messages (user msg → inference), POST stop,
GET/POST pending-changes CRUD (5 endpoints), WebSocket session streaming.

Dockerfile updated to build apps/server first (coder depends on its .d.ts).
Health endpoint reports tool count: {"ok":true,"db":true,"tools":30}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:53:38 +00:00

122 lines
3.5 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import {
listPending,
applyOne,
applyAll,
rejectOne,
rewindOne,
} from '../services/pending_changes.js';
/**
* Resolve project root from a session's project path.
*/
async function resolveProjectRoot(sql: Sql, sessionId: string): Promise<string | null> {
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<string | null> {
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/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;
},
);
}