#3 Fuzzy patch applier: new pure fuzzy-match.ts (locateMatch, exact→trim→ unicode-canon→Levenshtein≥0.66, refuse-on-ambiguous) wired into pending_changes applyOne/rewindOne so local-model whitespace/unicode drift in old_string no longer loses the edit. #4 Worktree checkpoint + conversation-trim: checkpoints table + checkpoints.ts (shadow-commit of tracked+untracked into refs/boocode/checkpoints, hooked into the 3 external-agent dispatcher paths) + POST restore route (reset --hard + clean -fd -> transcript trim -> backend-session reset) + "Restore to here" UI. Built by 3 parallel agents; DB-integration testing caught a created_at self-deletion bug. Coder suite 234 passing; server+coder build + web tsc clean. Builds on v2.7.0-mit. openspec write-edit-robustness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
255 lines
8.9 KiB
TypeScript
255 lines
8.9 KiB
TypeScript
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';
|
|
import { locateMatch } from './fuzzy-match.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';
|
|
// 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<PendingChange> {
|
|
const resolved = resolveWritePath(projectRoot, filePath);
|
|
const diff = JSON.stringify({ old: oldString, new: newString });
|
|
|
|
const [row] = await sql<PendingChange[]>`
|
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
|
|
RETURNING *
|
|
`;
|
|
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<PendingChange> {
|
|
const resolved = resolveWritePath(projectRoot, filePath);
|
|
|
|
const [row] = await sql<PendingChange[]>`
|
|
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<PendingChange> {
|
|
const resolved = resolveWritePath(projectRoot, filePath);
|
|
|
|
const [row] = await sql<PendingChange[]>`
|
|
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<ApplyResult> {
|
|
const [change] = await sql<PendingChange[]>`
|
|
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');
|
|
const match = locateMatch(content, oldStr);
|
|
if (match.kind === 'ambiguous') {
|
|
throw new Error(
|
|
`old_string matches ${match.count} locations — add surrounding context to disambiguate`,
|
|
);
|
|
}
|
|
if (match.kind === 'not_found') {
|
|
throw new Error(
|
|
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
|
|
);
|
|
}
|
|
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
|
|
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<ApplyResult[]> {
|
|
const pending = await sql<PendingChange[]>`
|
|
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<void> {
|
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
|
|
}
|
|
|
|
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
|
|
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<ApplyResult> {
|
|
const [change] = await sql<PendingChange[]>`
|
|
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 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<PendingChange[]> {
|
|
return sql<PendingChange[]>`
|
|
SELECT * FROM pending_changes
|
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
|
ORDER BY created_at ASC
|
|
`;
|
|
}
|