import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises'; import { dirname, join, basename } from 'node:path'; import { randomBytes } from 'node:crypto'; import type { Sql } from '../db.js'; import { resolveWritePath } from './write_guard.js'; import { locateMatch } from './fuzzy-match.js'; /** * Write a file atomically: stage to a sibling temp file, then rename over the * target. rename(2) on the same filesystem is atomic, so a crash mid-write can * never leave a half-written (truncated/corrupt) source file — readers see * either the old content or the complete new content. The temp lives in the same * directory to guarantee a same-filesystem rename. * * Symlinks: a plain writeFile FOLLOWS a symlink and writes through to its target; * a bare rename would REPLACE the link with a regular file. We realpath an * existing target first so the rename lands on the real file and the link * survives — preserving the prior follow-through behavior. A missing target * (create, or a broken link) just writes the literal path. */ async function writeFileAtomic(filePath: string, content: string): Promise { let target = filePath; try { target = await realpath(filePath); } catch { // ENOENT (new file) or broken link — write the literal path. } const tmp = join(dirname(target), `.${basename(target)}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`); await writeFile(tmp, content, 'utf8'); try { await rename(tmp, target); } catch (err) { await unlink(tmp).catch(() => {}); throw err; } } /** Detect a file's dominant line ending so an edit can preserve it. */ function detectEol(text: string): '\r\n' | '\n' { return text.includes('\r\n') ? '\r\n' : '\n'; } /** * Serialize the read-modify-write of a single file so two concurrent applies * (e.g. two chat tabs sharing one worktree, or a Bypass write racing an * apply_pending) can't lose an update. In-process keying is sufficient — * BooCoder is a single Fastify process. One Map entry per distinct path. */ const fileLocks = new Map>(); async function withFileLock(filePath: string, fn: () => Promise): Promise { const prev = fileLocks.get(filePath) ?? Promise.resolve(); let release!: () => void; const current = new Promise((r) => { release = r; }); fileLocks.set(filePath, prev.then(() => current)); await prev.catch(() => {}); try { return await fn(); } finally { release(); } } // --- Edit-apply planning (pure, unit-tested) --------------------------------- /** * Decision for applying one queued edit to a file's current content. Pulled out * of `applyOne` so the splice — the part that actually corrupted files — is pure * and testable without a DB or filesystem. Mirrors how opencode/cline/qwen keep * their matchers fail-closed and idempotent. */ export type EditPlan = | { kind: 'apply'; updated: string } | { kind: 'noop'; reason: 'identical' | 'already-applied' } | { kind: 'ambiguous'; count: number } | { kind: 'not_found' }; /** * Decide how (or whether) to apply an `old → new` edit to `content`. * * Idempotency is the whole point here: a queued edit can legitimately be * re-applied (a local model re-emits the same tool call; a turn is retried; the * same change sits in the queue twice). A naive splice stamps the new text again * each time — the 2–3× block duplication. Two guards make re-application a no-op: * * - already-applied (anchored insert): when `new` is `old` + an appended block * (`old="anchor"`, `new="anchor\n"`), `old` still matches uniquely after * the first apply, so a second apply would duplicate ``. If the full * `new` text is already present at the match site, the edit is already applied. * - already-applied (old gone): if `old` can't be located but `new` is already * in the file, the change landed on a prior pass — treat as a no-op, not an error. * - identical: the splice would not change the file. * * Anything ambiguous or genuinely absent fails CLOSED so the caller surfaces a * correctable error instead of writing a guess. */ export function planEdit(content: string, oldStr: string, newStr: string): EditPlan { const match = locateMatch(content, oldStr); if (match.kind === 'ambiguous') return { kind: 'ambiguous', count: match.count }; if (match.kind === 'not_found') { if (newStr.length > 0 && content.includes(newStr)) { return { kind: 'noop', reason: 'already-applied' }; } return { kind: 'not_found' }; } const updated = content.slice(0, match.start) + newStr + content.slice(match.end); // No-change splice first (covers old === new), then the anchored re-stamp guard: // the full replacement already sits at the match site (re-emitted anchored insert). if (updated === content) return { kind: 'noop', reason: 'identical' }; if (content.slice(match.start, match.start + newStr.length) === newStr) { return { kind: 'noop', reason: 'already-applied' }; } return { kind: 'apply', updated }; } // --- Types ------------------------------------------------------------------- export interface PendingChange { id: string; session_id: string; task_id: string | null; file_path: string; operation: 'create' | 'edit' | 'delete'; diff: string; status: 'pending' | 'applied' | 'rejected' | 'reverted'; // v2.6 Phase 1-UX: which agent staged this change (DiffPanel attribution). // Native boocode write tools stamp 'boocode'; the manual RightRail create path // passes null (renders as "manual"). NULL on legacy rows queued pre-v2.6. agent: string | null; created_at: string; } export interface ApplyResult { id: string; file_path: string; operation: string; success: boolean; error?: string; } // --- Queue functions --------------------------------------------------------- export async function queueEdit( sql: Sql, sessionId: string, taskId: string | null, filePath: string, oldString: string, newString: string, projectRoot: string, // v2.6 Phase 1-UX: attribution. Defaults to 'boocode' because the only callers // that omit it are the native write tools (edit_file/create_file/delete_file). // Pass null explicitly for the manual RightRail create path. agent: string | null = 'boocode', ): Promise { const resolved = resolveWritePath(projectRoot, filePath); const diff = JSON.stringify({ old: oldString, new: newString }); // Idempotent queue: collapse an identical edit that is still pending. Local // quantized models re-emit the same edit_file call within a turn, and a retried // turn re-queues — each duplicate row would apply and stamp another copy. One // pending row per (session, file, operation, diff) is enough. const existing = await findPendingDuplicate(sql, sessionId, resolved, 'edit', diff); if (existing) return existing; const [row] = await sql` INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent}) RETURNING * `; return row!; } /** Return an identical still-pending change for this (session, file, op, diff), * or undefined. Used to keep the queue idempotent against re-emitted edits. */ async function findPendingDuplicate( sql: Sql, sessionId: string, resolvedPath: string, operation: 'create' | 'edit' | 'delete', diff: string, ): Promise { const [row] = await sql` SELECT * FROM pending_changes WHERE session_id = ${sessionId} AND file_path = ${resolvedPath} AND operation = ${operation} AND diff = ${diff} AND status = 'pending' ORDER BY created_at ASC LIMIT 1 `; return row; } export async function queueCreate( sql: Sql, sessionId: string, taskId: string | null, filePath: string, content: string, projectRoot: string, // See queueEdit: defaults to 'boocode' for the native write tools; the manual // RightRail create route passes null. agent: string | null = 'boocode', ): Promise { const resolved = resolveWritePath(projectRoot, filePath); const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content); if (existing) return existing; const [row] = await sql` INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent}) RETURNING * `; return row!; } export async function queueDelete( sql: Sql, sessionId: string, taskId: string | null, filePath: string, projectRoot: string, // See queueEdit: defaults to 'boocode' for the native write tools. agent: string | null = 'boocode', ): Promise { const resolved = resolveWritePath(projectRoot, filePath); const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', ''); if (existing) return existing; const [row] = await sql` INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent) VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent}) RETURNING * `; return row!; } // --- Apply functions --------------------------------------------------------- export async function applyOne( sql: Sql, changeId: string, projectRoot: string, ): Promise { const [change] = await sql` SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending' `; if (!change) { return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' }; } try { return await withFileLock(change.file_path, async () => { // Re-validate path in case projectRoot has shifted resolveWritePath(projectRoot, change.file_path); switch (change.operation) { case 'create': { await mkdir(dirname(change.file_path), { recursive: true }); await writeFileAtomic(change.file_path, change.diff); break; } case 'edit': { const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string }; const raw = await readFile(change.file_path, 'utf8'); // Normalize to LF for matching, then write back in the file's native EOL // so an LF-emitting model doesn't leave a CRLF file with mixed endings. const eol = detectEol(raw); const toLf = (t: string) => t.replaceAll('\r\n', '\n'); const plan = planEdit(toLf(raw), toLf(oldStr), toLf(newStr)); if (plan.kind === 'ambiguous') { throw new Error( `old_string matches ${plan.count} locations — add surrounding context to disambiguate`, ); } if (plan.kind === 'not_found') { throw new Error( 'old_string not found in file (even fuzzily) — file may have changed since the edit was queued', ); } if (plan.kind === 'apply') { const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated; await writeFileAtomic(change.file_path, out); } else { // noop: the edit is already applied (re-emitted / retried) or a no-change. // Mark it applied without rewriting so it can't stamp a duplicate. console.log(`[pending] edit ${change.file_path} is a no-op (${plan.reason}) — not rewriting`); } break; } case 'delete': { // Stash current content in diff for potential rewind try { const existing = await readFile(change.file_path, 'utf8'); await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`; } catch { // File may already be gone — proceed with status update } await unlink(change.file_path); break; } } await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`; return { id: change.id, file_path: change.file_path, operation: change.operation, success: true }; }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message }; } } export async function applyAll( sql: Sql, sessionId: string, projectRoot: string, ): Promise { const pending = await sql` SELECT * FROM pending_changes WHERE session_id = ${sessionId} AND status = 'pending' ORDER BY created_at ASC `; const results: ApplyResult[] = []; for (const change of pending) { results.push(await applyOne(sql, change.id, projectRoot)); } return results; } // --- Reject functions -------------------------------------------------------- export async function rejectOne(sql: Sql, changeId: string): Promise { await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`; } // --- Rewind functions -------------------------------------------------------- export async function rewindOne( sql: Sql, changeId: string, projectRoot: string, ): Promise { const [change] = await sql` SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied' `; if (!change) { return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' }; } try { resolveWritePath(projectRoot, change.file_path); switch (change.operation) { case 'create': { // Reverse a create: delete the file await unlink(change.file_path); break; } case 'edit': { // Reverse an edit: swap old and new const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string }; const content = await readFile(change.file_path, 'utf8'); const match = locateMatch(content, newStr); if (match.kind === 'ambiguous') { throw new Error( `new_string matches ${match.count} locations — cannot rewind; add surrounding context to disambiguate`, ); } if (match.kind === 'not_found') { throw new Error( 'new_string not found in file (even fuzzily) — cannot rewind; file may have been modified since apply', ); } const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end); await writeFileAtomic(change.file_path, reverted); break; } case 'delete': { // Reverse a delete: recreate the file (diff holds the original content stashed at apply time) await mkdir(dirname(change.file_path), { recursive: true }); await writeFileAtomic(change.file_path, change.diff); break; } } await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`; return { id: change.id, file_path: change.file_path, operation: change.operation, success: true }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message }; } } // --- Query functions --------------------------------------------------------- export async function listPending(sql: Sql, sessionId: string): Promise { return sql` SELECT * FROM pending_changes WHERE session_id = ${sessionId} AND status = 'pending' ORDER BY created_at ASC `; }