import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; import type { Sql } from '../db.js'; import { resolveWritePath } from './write_guard.js'; // --- 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'; 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, ): Promise { const resolved = resolveWritePath(projectRoot, filePath); const diff = JSON.stringify({ old: oldString, new: newString }); const [row] = await sql` INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}) RETURNING * `; return row!; } export async function queueCreate( sql: Sql, sessionId: string, taskId: string | null, filePath: string, content: string, projectRoot: string, ): Promise { const resolved = resolveWritePath(projectRoot, filePath); const [row] = await sql` INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}) RETURNING * `; return row!; } export async function queueDelete( sql: Sql, sessionId: string, taskId: string | null, filePath: string, projectRoot: string, ): Promise { const resolved = resolveWritePath(projectRoot, filePath); const [row] = await sql` INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff) VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '') 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 { // 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 writeFile(change.file_path, change.diff, 'utf8'); break; } case 'edit': { const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string }; const content = await readFile(change.file_path, 'utf8'); if (!content.includes(oldStr)) { throw new Error('old_string not found in file — file may have changed since the edit was queued'); } const updated = content.replace(oldStr, newStr); await writeFile(change.file_path, updated, 'utf8'); 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'`; } export async function rejectAll(sql: Sql, sessionId: string): Promise { await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} 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'); if (!content.includes(newStr)) { throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply'); } const reverted = content.replace(newStr, oldStr); await writeFile(change.file_path, reverted, 'utf8'); 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 writeFile(change.file_path, change.diff, 'utf8'); 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 `; }