src / workspace / transactions.ts
src / workspace / transactions.ts
import { chmod, lstat, mkdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { ArtifactRef } from "../core/artifacts";
import { AgenticError, redactWorkspacePaths } from "../core/errors";
import { sha256Text } from "../core/hash";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
import type { Journal } from "../core/journal";
import { boundedPreview } from "../core/result";
import type { WorkspaceBoundary } from "./boundary";
import { createMultiFileDiff } from "./diff";
import {
countLines,
looksLineNumbered,
readTextSnapshot,
snapshotFromContent,
stripLineNumberGutter,
} from "./text";
export type EditOperation =
| {
type: "create";
path: string;
content: string;
overwrite?: boolean;
}
| {
type: "rewrite";
path: string;
content: string;
createIfMissing?: boolean;
}
| {
type: "replace";
path: string;
search: string;
replacement: string;
expectedMatches?: number;
}
| {
type: "splice";
path: string;
startLine: number;
deleteCount: number;
content: string;
}
| {
type: "delete";
path: string;
ignoreMissing?: boolean;
}
| {
type: "move";
from: string;
to: string;
overwrite?: boolean;
}
| {
type: "copy";
from: string;
to: string;
overwrite?: boolean;
}
| {
type: "mkdir";
path: string;
};
export interface RawEditOperation {
type: string;
path?: string;
content?: string;
overwrite?: boolean;
create_if_missing?: boolean;
search?: string;
replacement?: string;
expected_matches?: number;
start_line?: number;
delete_count?: number;
ignore_missing?: boolean;
from?: string;
to?: string;
/** Opt out of the line-number guard for content that really does look like `12 | text`. */
allow_line_numbers?: boolean;
}
export type TransactionStatus =
| "planned"
| "applied"
| "rolled_back"
| "failed";
export interface StoredFileSide {
exists: boolean;
sha256?: string;
bytes?: number;
mode?: number;
snapshotPath?: string;
}
export interface TransactionFile {
path: string;
before: StoredFileSide;
after: StoredFileSide;
addedLines: number;
removedLines: number;
}
export interface TransactionDirectory {
path: string;
existedBefore: boolean;
}
export interface TransactionPlan {
version: 1;
id: string;
status: TransactionStatus;
createdAt: string;
updatedAt: string;
appliedAt?: string;
rolledBackAt?: string;
failedAt?: string;
failure?: string;
idempotencyKey?: string;
operationHash: string;
operations: EditOperation[];
files: TransactionFile[];
directories: TransactionDirectory[];
destructive: boolean;
/**
* Why this plan is destructive, one clause per operation that made it so
* ("rewrite of the existing src/a.ts (use replace…)"). The gate refusal
* quotes these: naming only the setting left a model with nothing to change,
* and in the live matrix one recovered by guessing and one never did.
* Optional because plans persisted before 0.3.0 have no such field.
*/
destructiveCauses?: string[];
requiresReview: boolean;
addedLines: number;
removedLines: number;
diffPath: string;
reviewPath: string;
diffSha256: string;
diffBytes: number;
summary: string;
}
export interface TransactionPreview {
plan: TransactionPlan;
diffPreview: string;
diffTruncated: boolean;
diffOmittedChars: number;
artifact: ArtifactRef;
}
interface WorkingFile {
path: string;
absolutePath: string;
before: string | null;
after: string | null;
beforeMode?: number;
afterMode?: number;
}
const workspaceMutationLocks = new Map<string, Promise<void>>();
const transactionLocks = new Map<string, Promise<void>>();
async function withWorkspaceMutationLock<T>(
workspaceRoot: string,
action: () => Promise<T>,
): Promise<T> {
const previous = workspaceMutationLocks.get(workspaceRoot) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
workspaceMutationLocks.set(workspaceRoot, queued);
await previous;
try {
return await action();
} finally {
release();
if (workspaceMutationLocks.get(workspaceRoot) === queued) {
workspaceMutationLocks.delete(workspaceRoot);
}
}
}
function requireString(
value: unknown,
field: string,
operationIndex: number,
allowEmpty = false,
): string {
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
throw new AgenticError(
"INVALID_INPUT",
`Operation ${operationIndex + 1} requires string field '${field}'.`,
);
}
return value;
}
function requireInteger(
value: unknown,
field: string,
operationIndex: number,
minimum: number,
): number {
if (!Number.isInteger(value) || (value as number) < minimum) {
throw new AgenticError(
"INVALID_INPUT",
`Operation ${operationIndex + 1} requires integer '${field}' >= ${minimum}.`,
);
}
return value as number;
}
/**
* Rejects edit text that was copied straight out of a line-numbered
* `workspace_inspect read` result. Writing it would corrupt the file, and an
* exact `replace` whose `search` carries the gutter can never match — a live
* 1.7B run burned four rounds on exactly that. The refusal names the cause so
* the model can fix the call instead of retrying it verbatim.
*/
function rejectLineNumbers(
operation: RawEditOperation,
index: number,
fields: ReadonlyArray<"content" | "replacement" | "search">,
): void {
if (operation.allow_line_numbers === true) return;
for (const field of fields) {
const value = operation[field];
if (typeof value !== "string" || !looksLineNumbered(value)) continue;
throw new AgenticError(
"INVALID_INPUT",
`Operation ${index + 1} field '${field}' still has the line-number gutter: strip the "N | " prefixes that workspace_inspect read adds and send the raw file text (set allow_line_numbers true if the text really looks like that).`,
{ operationIndex: index, field },
);
}
}
/**
* The extra sentence on an `EDIT_CONFLICT` for a `replace` that matched
* nothing, when stripping the `12 | ` gutter would have matched.
*
* `looksLineNumbered` cannot help here: the failure that started all of this
* was a one-line file read back as `1 | module.exports = …`, and one line is
* below any threshold a majority rule can safely use. This probe runs only
* after the edit has already failed, so it can never reject a legitimate call
* — which is not the same as being right. `2 | beta` is also a GFM table row
* and `1 | 2 => Foo,` a Rust match arm, and against a file holding only the
* stripped text the probe fires on both. So the sentence states a condition the
* model can check ("if this came from a read") rather than ordering a retry: a
* model that obeys the order edits a narrower span than it meant to, possibly
* at an occurrence it never looked at. It explains; it never repairs.
*/
function gutterHint(content: string, search: string): string {
const stripped = stripLineNumberGutter(search);
if (stripped === search || stripped.trim() === "") return "";
const count = countOccurrences(content, stripped);
if (count === 0) return "";
return ` If this text was copied from a workspace_inspect read result, the "N | " gutter is still on it; without that prefix the search would match ${count} time(s).`;
}
/**
* The clause a destructive-ceiling refusal appends, naming what made the plan
* destructive. Bounded on purpose: a transaction may hold 100 operations and
* this goes into an error message a small model has to read.
*/
export function describeDestructiveCauses(plan: TransactionPlan): string {
const causes = plan.destructiveCauses ?? [];
if (causes.length === 0) return "";
const shown = causes.slice(0, 4);
const hidden = causes.length - shown.length;
return `Destructive because of: ${shown.join("; ")}${
hidden > 0 ? `; and ${hidden} more operation(s)` : ""
}.`;
}
export function normalizeEditOperations(raw: RawEditOperation[]): EditOperation[] {
if (!Array.isArray(raw) || raw.length === 0) {
throw new AgenticError("INVALID_INPUT", "At least one edit operation is required.");
}
if (raw.length > 100) {
throw new AgenticError("INVALID_INPUT", "A transaction may contain at most 100 operations.");
}
return raw.map((operation, index): EditOperation => {
rejectLineNumbers(operation, index, ["content", "replacement", "search"]);
switch (operation.type) {
case "create":
return {
type: "create",
path: requireString(operation.path, "path", index),
content: requireString(operation.content, "content", index, true),
overwrite: operation.overwrite ?? false,
};
case "rewrite":
return {
type: "rewrite",
path: requireString(operation.path, "path", index),
content: requireString(operation.content, "content", index, true),
createIfMissing: operation.create_if_missing ?? false,
};
case "replace":
return {
type: "replace",
path: requireString(operation.path, "path", index),
search: requireString(operation.search, "search", index),
replacement: requireString(operation.replacement, "replacement", index, true),
expectedMatches:
operation.expected_matches === undefined
? 1
: requireInteger(operation.expected_matches, "expected_matches", index, 1),
};
case "splice":
return {
type: "splice",
path: requireString(operation.path, "path", index),
startLine: requireInteger(operation.start_line, "start_line", index, 1),
deleteCount:
operation.delete_count === undefined
? 0
: requireInteger(operation.delete_count, "delete_count", index, 0),
content: requireString(operation.content, "content", index, true),
};
case "delete":
return {
type: "delete",
path: requireString(operation.path, "path", index),
ignoreMissing: operation.ignore_missing ?? false,
};
case "move": {
const from = requireString(operation.from, "from", index);
const to = requireString(operation.to, "to", index);
if (from === to) {
throw new AgenticError("INVALID_INPUT", "Move source and destination must differ.");
}
return { type: "move", from, to, overwrite: operation.overwrite ?? false };
}
case "copy": {
const from = requireString(operation.from, "from", index);
const to = requireString(operation.to, "to", index);
if (from === to) {
throw new AgenticError("INVALID_INPUT", "Copy source and destination must differ.");
}
return { type: "copy", from, to, overwrite: operation.overwrite ?? false };
}
case "mkdir":
return {
type: "mkdir",
path: requireString(operation.path, "path", index),
};
default:
throw new AgenticError(
"INVALID_INPUT",
`Unsupported edit operation '${operation.type}' at index ${index}.`,
);
}
});
}
/**
* `lstat` or `null` when the path cannot exist. `ENOTDIR` (a path component is
* a regular file — how POSIX reports what Windows reports as `ENOENT`) means
* the same thing as `ENOENT` here, and is normalized so the caller never has to
* handle a raw Node error carrying an absolute host path.
*/
async function lstatOrNull(path: string): Promise<Awaited<ReturnType<typeof lstat>> | null> {
try {
return await lstat(path);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") return null;
throw error;
}
}
async function pathExists(path: string): Promise<boolean> {
return (await lstatOrNull(path)) !== null;
}
function countOccurrences(content: string, search: string): number {
let count = 0;
let offset = 0;
while (true) {
const index = content.indexOf(search, offset);
if (index === -1) return count;
count++;
offset = index + search.length;
}
}
function normalizeNewlines(value: string, newline: string): string {
return value.replace(/\r\n|\r|\n/g, newline);
}
function snapshotName(path: string, side: "before" | "after"): string {
return `${sha256Text(path).slice(0, 24)}-${side}.txt`;
}
function validateTransactionId(id: string): void {
if (!/^tx_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid transaction id: ${id}`);
}
}
function escapeMarkdownCell(value: string): string {
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
}
function operationPath(operation: EditOperation): string {
return operation.type === "move" || operation.type === "copy"
? `${operation.from} -> ${operation.to}`
: operation.path;
}
function directoryClause(directories: TransactionDirectory[]): string {
if (directories.length === 0) return "";
return ` and ${directories.length} director${directories.length === 1 ? "y" : "ies"}`;
}
/**
* What to do instead, for the two `TRANSACTION_STATE` dead ends a model
* actually walks into. Naming only the status left it re-issuing the same call:
* committing a transaction it had already rolled back, or trying to roll back
* one that was never applied and therefore changed nothing.
*/
function commitRepair(status: TransactionStatus): string {
// `applied` returns early, so only these two reach the refusal.
if (status === "rolled_back") {
return " Its snapshots were already restored, so it can never be committed again: preview the operations again to get a fresh transaction.";
}
if (status === "failed") {
return " Read plan.failure with workspace_edit show, fix the cause, then preview the operations again.";
}
return "";
}
function rollbackRepair(status: TransactionStatus): string {
// `rolled_back` returns early and `applied` is the allowed path.
if (status === "planned") {
return " It was never committed, so it changed nothing on disk and can simply be abandoned — there is nothing to undo.";
}
if (status === "failed") {
return " A failed commit already restores what it touched; pass force: true only if workspace_edit show still reports changes on disk.";
}
return "";
}
/** Names the created directories a rollback left in place, in plan order. */
function skippedClause(skipped: string[]): string {
if (skipped.length === 0) return "";
return ` Left in place (no longer an empty directory this transaction owns): ${skipped.join(", ")}.`;
}
/** Older persisted plan.json files predate `directories`; default to none. */
function normalizeStoredPlan(
plan: Omit<TransactionPlan, "directories"> & { directories?: TransactionDirectory[] },
): TransactionPlan {
return { ...plan, directories: plan.directories ?? [] };
}
function renderTransactionReview(plan: TransactionPlan): string {
const fileRows = plan.files
.map((file) => {
const before = file.before.exists
? `${file.before.sha256 ?? "unknown"} (${file.before.bytes ?? 0} bytes${
file.before.mode === undefined ? "" : `, mode ${file.before.mode.toString(8)}`
})`
: "absent";
const after = file.after.exists
? `${file.after.sha256 ?? "unknown"} (${file.after.bytes ?? 0} bytes${
file.after.mode === undefined ? "" : `, mode ${file.after.mode.toString(8)}`
})`
: "absent";
return `| \`${escapeMarkdownCell(file.path)}\` | +${file.addedLines}/-${file.removedLines} | ${before} | ${after} |`;
})
.join("\n");
const snapshotLines = plan.files.flatMap((file) => [
...(file.before.snapshotPath
? [`- \`${file.path}\` before: \`${file.before.snapshotPath}\``]
: []),
...(file.after.snapshotPath
? [`- \`${file.path}\` after: \`${file.after.snapshotPath}\``]
: []),
]);
const operationLines = plan.operations.map(
(operation, index) =>
`${index + 1}. \`${operation.type}\` — \`${escapeMarkdownCell(operationPath(operation))}\``,
);
const directoryLines = plan.directories.map(
(directory) =>
`- \`${escapeMarkdownCell(directory.path)}\` — ${
directory.existedBefore ? "existed before" : "created by this transaction"
}`,
);
return [
`# Transaction ${plan.id}`,
"",
`- **Status:** \`${plan.status}\``,
`- **Created:** ${plan.createdAt}`,
`- **Updated:** ${plan.updatedAt}`,
`- **Destructive:** ${plan.destructive ? "yes" : "no"}`,
`- **Review required:** ${plan.requiresReview ? "yes" : "no"}`,
`- **Line delta:** +${plan.addedLines}/-${plan.removedLines}`,
`- **Operation hash:** \`${plan.operationHash}\``,
`- **Diff:** \`${plan.diffPath}\``,
`- **Plan:** \`.agentic/transactions/${plan.id}/plan.json\``,
...(plan.appliedAt ? [`- **Applied:** ${plan.appliedAt}`] : []),
...(plan.rolledBackAt ? [`- **Rolled back:** ${plan.rolledBackAt}`] : []),
...(plan.failedAt ? [`- **Failed:** ${plan.failedAt}`] : []),
"",
"## Summary",
"",
plan.summary,
...(plan.failure ? ["", "## Failure", "", `\`${plan.failure.replaceAll("`", "'")}\``] : []),
"",
"## Operations",
"",
...operationLines,
"",
"## Files",
"",
"| Path | Lines | Before | After |",
"|---|---:|---|---|",
fileRows,
...(directoryLines.length > 0 ? ["", "## Directories", "", ...directoryLines] : []),
"",
"## Snapshots",
"",
...(snapshotLines.length > 0 ? snapshotLines : ["No content snapshots were required."]),
"",
"This receipt is generated from the durable transaction plan. Verify the diff and snapshots before committing high-risk changes.",
"",
].join("\n");
}
export class TransactionManager {
private readonly transactionsPath: string;
public constructor(
private readonly boundary: WorkspaceBoundary,
private readonly storage: InternalStorage,
private readonly journal: Journal,
private readonly maxEditableBytes: number,
) {
this.transactionsPath = storage.relative("transactions");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.transactionsPath);
}
public async preview(
operations: EditOperation[],
options: { idempotencyKey?: string; maxPreviewChars?: number; runId?: string } = {},
): Promise<TransactionPreview> {
await this.initialize();
const operationHash = sha256Text(JSON.stringify(operations));
const id = options.idempotencyKey
? `tx_${sha256Text(`agentic-workspace/v1:${options.idempotencyKey}`).slice(0, 24)}`
: createId("tx");
return await this.withLock(id, async () => {
const existingPath = this.planPath(id);
if (await this.storage.exists(existingPath)) {
const existing = await this.storage.readJson<TransactionPlan>(existingPath);
if (existing.operationHash !== operationHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The idempotency key was already used with different edit operations. Use a new idempotency_key (or omit it) for a different set of operations.",
{ transactionId: id },
);
}
return await this.previewFromPlan(
normalizeStoredPlan(existing),
options.maxPreviewChars ?? 12_000,
);
}
const working = new Map<string, WorkingFile>();
const directories = new Map<string, TransactionDirectory>();
// One clause per operation that puts existing content at risk. Replaces
// the old `broadOverwrite` flag: the refusal has to say which operation
// and which path, not just that something was destructive.
const destructiveCauses: string[] = [];
const getFile = async (requestedPath: string): Promise<WorkingFile> => {
const absolutePath = await this.boundary.resolveWrite(requestedPath);
const path = this.boundary.relativePath(absolutePath);
if (directories.has(path)) {
throw new AgenticError(
"EDIT_CONFLICT",
`Path is already planned as a directory in this transaction: ${path}`,
{ path },
);
}
// A file may not sit above a directory this transaction plans to create:
// committing would either turn the file into a directory or fail halfway.
for (const planned of directories.keys()) {
if (planned.startsWith(`${path}/`)) {
throw new AgenticError(
"EDIT_CONFLICT",
`${path} is a parent of ${planned}, which this transaction plans to create as a directory.`,
{ path, conflictingPath: planned },
);
}
}
const cached = working.get(path);
if (cached) return cached;
let before: string | null = null;
let beforeMode: number | undefined;
if (await pathExists(absolutePath)) {
const info = await lstat(absolutePath);
if (!info.isFile()) {
throw new AgenticError(
"INVALID_INPUT",
`Edit target must be a regular file: ${path}`,
);
}
before = (await readTextSnapshot(absolutePath, this.maxEditableBytes)).content;
beforeMode = process.platform === "win32" ? undefined : info.mode & 0o7777;
}
const file: WorkingFile = {
path,
absolutePath,
before,
after: before,
...(beforeMode === undefined ? {} : { beforeMode, afterMode: beforeMode }),
};
working.set(path, file);
return file;
};
for (const operation of operations) {
if (operation.type === "create") {
const file = await getFile(operation.path);
if (file.after !== null && !operation.overwrite) {
throw new AgenticError(
"EDIT_CONFLICT",
`Create target already exists: ${file.path}. Use overwrite: true to replace the whole file, or a replace operation to change part of it.`,
);
}
// Only an existing file is at risk. Classifying every
// `overwrite: true` as destructive refused a whole scaffold into an
// empty directory under the default ceiling.
if (file.before !== null && operation.overwrite) {
destructiveCauses.push(
`create with overwrite over the existing ${file.path} (use replace to change part of it, or create it at a different path)`,
);
}
file.after = operation.content;
} else if (operation.type === "rewrite") {
const file = await getFile(operation.path);
if (file.after === null && !operation.createIfMissing) {
throw new AgenticError("NOT_FOUND", `Rewrite target does not exist: ${file.path}. Use a create operation, or create_if_missing: true on this rewrite.`);
}
if (file.before !== null) {
destructiveCauses.push(
`rewrite of the existing ${file.path} (use replace to change part of it)`,
);
}
file.after = operation.content;
} else if (operation.type === "replace") {
const file = await getFile(operation.path);
if (file.after === null) {
throw new AgenticError("NOT_FOUND", `Replace target does not exist: ${file.path}. Use a create operation to make the file first.`);
}
const actual = countOccurrences(file.after, operation.search);
const expected = operation.expectedMatches ?? 1;
if (actual !== expected) {
// Name the repair: a model that only sees the count re-sends the same
// search until its budget runs out (observed 15 times in a row live).
const repair =
actual === 0
? ` Re-read ${file.path} with workspace_inspect read and copy the search text exactly from its current content (a shorter snippet that is still unique is safer); do not resend the same search.`
: ` Narrow the search to a snippet that occurs exactly ${expected} time(s), or set expected_matches to ${actual} to replace every occurrence.`;
throw new AgenticError(
"EDIT_CONFLICT",
`Expected ${expected} exact match(es) in ${file.path}, found ${actual}.${
actual === 0 ? gutterHint(file.after, operation.search) : ""
}${repair}`,
{ path: file.path, expected, actual },
);
}
file.after = file.after.split(operation.search).join(operation.replacement);
} else if (operation.type === "splice") {
const file = await getFile(operation.path);
if (file.after === null) {
throw new AgenticError("NOT_FOUND", `Splice target does not exist: ${file.path}`);
}
const newline = file.after.includes("\r\n") ? "\r\n" : "\n";
const normalized = normalizeNewlines(file.after, "\n");
const lines = normalized.length === 0 ? [] : normalized.split("\n");
if (operation.startLine > lines.length + 1) {
throw new AgenticError(
"INVALID_INPUT",
`start_line ${operation.startLine} exceeds ${file.path}'s insertion boundary ${lines.length + 1}.`,
);
}
if (operation.startLine - 1 + operation.deleteCount > lines.length) {
throw new AgenticError(
"INVALID_INPUT",
`Splice deletes beyond the end of ${file.path}.`,
);
}
const insertion =
operation.content.length === 0
? []
: normalizeNewlines(operation.content, "\n").split("\n");
lines.splice(operation.startLine - 1, operation.deleteCount, ...insertion);
file.after = normalizeNewlines(lines.join("\n"), newline);
} else if (operation.type === "delete") {
const file = await getFile(operation.path);
if (file.after === null && !operation.ignoreMissing) {
throw new AgenticError("NOT_FOUND", `Delete target does not exist: ${file.path}. Use ignore_missing: true if its absence is acceptable.`);
}
// A delete that removes nothing (ignore_missing on an absent file) must not
// be named as a destructive cause: the clause would assert a removal that
// never happens. The plan's destructive flag is computed from the causes.
if (file.after !== null) destructiveCauses.push(`delete of ${file.path}`);
file.after = null;
} else if (operation.type === "mkdir") {
const absolutePath = await this.boundary.resolveWrite(operation.path);
const path = this.boundary.relativePath(absolutePath);
if (working.has(path)) {
throw new AgenticError(
"EDIT_CONFLICT",
`mkdir target is already planned as a file in this transaction: ${path}`,
{ path },
);
}
// Every ancestor must already be — or be free to become — a directory.
// Without this, `mkdir -p` at commit time either raises a raw ENOTDIR
// or silently replaces a file the same transaction planned to write.
const levels = this.pathLevels(absolutePath);
for (const ancestor of levels.slice(0, -1)) {
if (directories.has(ancestor.path)) continue;
if (working.has(ancestor.path)) {
throw new AgenticError(
"EDIT_CONFLICT",
`mkdir target ${path} is inside ${ancestor.path}, which this transaction plans as a file.`,
{ path, conflictingPath: ancestor.path },
);
}
const ancestorInfo = await lstatOrNull(ancestor.absolute);
if (ancestorInfo && !ancestorInfo.isDirectory()) {
throw new AgenticError(
"EDIT_CONFLICT",
`mkdir target ${path} is inside ${ancestor.path}, which exists and is not a directory.`,
{ path, conflictingPath: ancestor.path },
);
}
}
if (!directories.has(path)) {
const info = await lstatOrNull(absolutePath);
if (info && !info.isDirectory()) {
throw new AgenticError(
"EDIT_CONFLICT",
`mkdir target exists and is not a directory: ${path}`,
{ path },
);
}
directories.set(path, { path, existedBefore: info !== null });
}
} else if (operation.type === "move" || operation.type === "copy") {
const source = await getFile(operation.from);
const destination = await getFile(operation.to);
if (source.path === destination.path) {
throw new AgenticError(
"INVALID_INPUT",
`${operation.type === "move" ? "Move" : "Copy"} source and destination resolve to the same path: ${source.path}`,
);
}
if (source.after === null) {
throw new AgenticError(
"NOT_FOUND",
`${operation.type === "move" ? "Move" : "Copy"} source does not exist: ${source.path}`,
);
}
if (destination.after !== null && !operation.overwrite) {
throw new AgenticError(
"EDIT_CONFLICT",
`${operation.type === "move" ? "Move" : "Copy"} destination already exists: ${destination.path}`,
);
}
if (destination.before !== null && operation.overwrite) {
destructiveCauses.push(
`${operation.type} over the existing ${destination.path} (choose a destination path that does not exist yet)`,
);
}
destination.after = source.after;
destination.afterMode = source.afterMode;
if (operation.type === "move") {
destructiveCauses.push(`move of ${source.path}, which removes it (copy keeps it)`);
source.after = null;
source.afterMode = undefined;
}
}
}
const changed = [...working.values()].filter((file) => file.before !== file.after);
const directoryList = [...directories.values()];
if (changed.length === 0 && directoryList.every((directory) => directory.existedBefore)) {
// A model re-issuing a mkdir after compaction lands here; name the
// directories so the bare "no changes" wording is not read as a failure.
if (directoryList.length > 0) {
const names = directoryList.map((directory) => directory.path).join(", ");
throw new AgenticError(
"INVALID_INPUT",
`The proposed operations produce no changes: every requested directory already exists (${names}).`,
{ directories: directoryList.map((directory) => directory.path) },
);
}
throw new AgenticError("INVALID_INPUT", "The proposed operations produce no changes.");
}
for (const file of changed) {
if (file.after !== null && Buffer.byteLength(file.after) > this.maxEditableBytes) {
throw new AgenticError(
"FILE_TOO_LARGE",
`Edited content for ${file.path} exceeds the configured limit.`,
);
}
}
const diff = createMultiFileDiff(
changed.map((file) => ({ path: file.path, before: file.before, after: file.after })),
);
await this.storage.ensureDirectory(this.snapshotsPath(id));
const files: TransactionFile[] = [];
for (const file of changed) {
const before = await this.persistSide(
id,
file.path,
file.before,
file.beforeMode,
"before",
);
const after = await this.persistSide(
id,
file.path,
file.after,
file.afterMode,
"after",
);
const fileDiff = diff.files.find((item) => item.path === file.path);
files.push({
path: file.path,
before,
after,
addedLines: fileDiff?.added ?? 0,
removedLines: fileDiff?.removed ?? 0,
});
}
const diffPath = this.diffPath(id);
await this.storage.writeText(diffPath, diff.text);
const now = new Date().toISOString();
const causes = [...new Set(destructiveCauses)];
const destructive = causes.length > 0;
const plan: TransactionPlan = {
version: 1,
id,
status: "planned",
createdAt: now,
updatedAt: now,
...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
operationHash,
operations,
files,
directories: directoryList,
destructive,
...(destructive ? { destructiveCauses: causes } : {}),
requiresReview: destructive,
addedLines: diff.added,
removedLines: diff.removed,
diffPath,
reviewPath: this.reviewPath(id),
diffSha256: sha256Text(diff.text),
diffBytes: Buffer.byteLength(diff.text),
summary: `Planned ${files.length} file change(s)${directoryClause(directoryList)} (+${diff.added}/-${diff.removed}).`,
};
await this.persistPlan(plan);
await this.journal.record({
category: "edit",
action: "preview",
summary: plan.summary,
transactionId: id,
...(options.runId ? { runId: options.runId } : {}),
details: {
files: files.map((file) => file.path),
directories: directoryList.map((directory) => directory.path),
destructive,
addedLines: diff.added,
removedLines: diff.removed,
reviewPath: plan.reviewPath,
},
});
return await this.previewFromPlan(plan, options.maxPreviewChars ?? 12_000);
});
}
public async commit(id: string, runId?: string): Promise<TransactionPlan> {
validateTransactionId(id);
return await this.withLock(id, async () =>
await withWorkspaceMutationLock(this.boundary.realRoot, async () => {
const plan = await this.get(id);
if (plan.status === "applied") return plan;
if (plan.status !== "planned") {
throw new AgenticError(
"TRANSACTION_STATE",
`Transaction ${id} cannot be committed from status '${plan.status}'.${commitRepair(plan.status)}`,
);
}
await this.verifyDirectoryTargets(plan);
await this.verifyPlanSide(plan, "before");
const createdDirectories: string[] = [];
try {
await this.createDirectories(plan, createdDirectories);
await this.restoreSide(plan, "after");
await this.verifyPlanSide(plan, "after");
const now = new Date().toISOString();
const applied: TransactionPlan = {
...plan,
status: "applied",
appliedAt: now,
updatedAt: now,
summary: `Applied ${plan.files.length} file change(s)${directoryClause(plan.directories)} (+${plan.addedLines}/-${plan.removedLines}).`,
};
await this.persistPlan(applied);
await this.journal.record({
category: "edit",
action: "commit",
summary: applied.summary,
transactionId: id,
...(runId ? { runId } : {}),
details: { files: plan.files.map((file) => file.path), reviewPath: plan.reviewPath },
});
return applied;
} catch (error) {
try {
await this.restoreSide(plan, "before");
// Compensation undoes every level this commit created, including the
// parents `mkdir -p` made implicitly: a failed commit must leave the
// workspace exactly as it found it.
await this.removeDirectories(createdDirectories);
} catch {
// Preserve the original failure; snapshots remain available for manual recovery.
}
const now = new Date().toISOString();
const failure = this.failureMessage(error);
const failed: TransactionPlan = {
...plan,
status: "failed",
failedAt: now,
updatedAt: now,
failure,
summary: `Commit failed and recovery was attempted: ${failure}`,
};
await this.persistPlan(failed);
await this.journal.record({
category: "edit",
action: "commit_failed",
summary: failed.summary,
transactionId: id,
...(runId ? { runId } : {}),
details: { reviewPath: plan.reviewPath },
});
// Rethrown scrubbed as well: this same message becomes the model-facing
// errorResult, so scrubbing only the persisted copy would still leak.
throw error instanceof AgenticError ? error : new AgenticError("INTERNAL", failure);
}
}),
);
}
/**
* The commit failure text that is written into `plan.failure`, the plan
* summary, the journal and the tool envelope.
*
* `writeWorkspaceText` wraps its own errno into workspace terms, and every
* deliberate refusal is an `AgenticError` already. What reaches here
* otherwise is a raw Node error carrying an absolute host path — a snapshot
* read against a target that was swapped for a directory is one live route —
* and all four sinks are read by the model.
*/
private failureMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof AgenticError) return message;
return redactWorkspacePaths(message, this.boundary.realRoot, this.boundary.root);
}
public async rollback(
id: string,
options: { force?: boolean; runId?: string } = {},
): Promise<TransactionPlan> {
validateTransactionId(id);
return await this.withLock(id, async () =>
await withWorkspaceMutationLock(this.boundary.realRoot, async () => {
const plan = await this.get(id);
if (plan.status === "rolled_back") return plan;
if (plan.status !== "applied" && !(options.force && plan.status === "failed")) {
throw new AgenticError(
"TRANSACTION_STATE",
`Transaction ${id} cannot be rolled back from status '${plan.status}'.${rollbackRepair(plan.status)}`,
);
}
if (!options.force) await this.verifyPlanSide(plan, "after");
await this.restoreSide(plan, "before");
const skippedDirectories = await this.removeCreatedDirectories(plan);
await this.verifyPlanSide(plan, "before");
const now = new Date().toISOString();
const rolledBack: TransactionPlan = {
...plan,
status: "rolled_back",
rolledBackAt: now,
updatedAt: now,
summary: `Rolled back ${plan.files.length} file change(s)${directoryClause(plan.directories)}.${skippedClause(skippedDirectories)}`,
};
await this.persistPlan(rolledBack);
await this.journal.record({
category: "edit",
action: "rollback",
summary: rolledBack.summary,
transactionId: id,
...(options.runId ? { runId: options.runId } : {}),
details: {
force: options.force ?? false,
reviewPath: plan.reviewPath,
skippedDirectories,
},
});
return rolledBack;
}),
);
}
public async get(id: string): Promise<TransactionPlan> {
validateTransactionId(id);
try {
return normalizeStoredPlan(await this.storage.readJson<TransactionPlan>(this.planPath(id)));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new AgenticError("NOT_FOUND", `Transaction not found: ${id}`);
}
throw error;
}
}
public async list(limit = 20): Promise<TransactionPlan[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.transactionsPath, {
withFileTypes: true,
});
const plans: TransactionPlan[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("tx_")) continue;
try {
plans.push(
normalizeStoredPlan(
await this.storage.readJson<TransactionPlan>(this.planPath(entry.name)),
),
);
} catch {
// Ignore incomplete/corrupt directories but leave them on disk for inspection.
}
}
return plans
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, Math.max(1, Math.min(limit, 100)));
}
public async readDiff(
id: string,
startLine = 1,
maxLines = 300,
): Promise<{
text: string;
startLine: number;
endLine: number;
totalLines: number;
truncated: boolean;
}> {
const plan = await this.get(id);
const content = await this.storage.readText(plan.diffPath);
const lines = content.split("\n");
// A terminating newline leaves a phantom empty element; count real lines only (see text.ts countLines).
const totalLines = content.length === 0 ? 0 : countLines(lines);
const start = Math.max(1, Math.min(startLine, Math.max(1, totalLines)));
const end = Math.min(totalLines, start - 1 + Math.max(1, Math.min(maxLines, 2000)));
return {
text: lines.slice(start - 1, end).join("\n"),
startLine: start,
endLine: end,
totalLines,
truncated: end < totalLines,
};
}
public diffArtifact(plan: TransactionPlan): ArtifactRef {
return {
kind: "diff",
path: plan.diffPath,
sha256: plan.diffSha256,
bytes: plan.diffBytes,
description: `Full diff for transaction ${plan.id}`,
};
}
public async reviewArtifact(plan: TransactionPlan): Promise<ArtifactRef> {
const content = await this.storage.readText(plan.reviewPath);
return {
kind: "text",
path: plan.reviewPath,
sha256: sha256Text(content),
bytes: Buffer.byteLength(content),
description: `Human-readable review receipt for transaction ${plan.id}`,
};
}
private async previewFromPlan(
plan: TransactionPlan,
maxPreviewChars: number,
): Promise<TransactionPreview> {
const content = await this.storage.readText(plan.diffPath);
const bounded = boundedPreview(content, maxPreviewChars);
return {
plan,
diffPreview: bounded.preview,
diffTruncated: bounded.truncated,
diffOmittedChars: bounded.omittedChars,
artifact: this.diffArtifact(plan),
};
}
private async persistSide(
id: string,
path: string,
content: string | null,
mode: number | undefined,
side: "before" | "after",
): Promise<StoredFileSide> {
if (content === null) return { exists: false };
const snapshot = snapshotFromContent(content);
const snapshotPath = this.storage.relative(
"transactions",
id,
"snapshots",
snapshotName(path, side),
);
await this.storage.writeText(snapshotPath, content);
return {
exists: true,
sha256: snapshot.sha256,
bytes: snapshot.bytes,
...(mode === undefined ? {} : { mode }),
snapshotPath,
};
}
/**
* Commit pre-flight for `mkdir` targets: every level of every planned
* directory must be missing or already a directory. An existing directory is
* fine — creation is idempotent — but a regular file or symlink anywhere on
* the chain must fail the commit *before* anything is written, so no
* half-created tree survives. Rollback never runs this: it creates nothing.
*/
private async verifyDirectoryTargets(plan: TransactionPlan): Promise<void> {
for (const directory of plan.directories) {
const absolute = await this.boundary.resolveWrite(directory.path);
for (const level of this.pathLevels(absolute)) {
const info = await lstatOrNull(level.absolute);
if (!info || info.isDirectory()) continue;
throw new AgenticError(
"EDIT_CONFLICT",
level.path === directory.path
? `${directory.path} exists and is not a directory; refusing to create it for transaction ${plan.id}.`
: `${directory.path} is inside ${level.path}, which exists and is not a directory; refusing to create it for transaction ${plan.id}.`,
{
path: directory.path,
...(level.path === directory.path ? {} : { conflictingPath: level.path }),
transactionId: plan.id,
side: "before",
},
);
}
}
}
/**
* Workspace-relative levels of `absolutePath`, outermost first and including
* the path itself, paired with their absolute form.
*/
private pathLevels(absolutePath: string): Array<{ path: string; absolute: string }> {
const levels: Array<{ path: string; absolute: string }> = [];
let current = absolutePath;
for (;;) {
const path = this.boundary.relativePath(current);
if (path === "." || path === "" || path.startsWith("..")) break;
levels.push({ path, absolute: current });
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return levels.reverse();
}
private async verifyPlanSide(
plan: TransactionPlan,
side: "before" | "after",
): Promise<void> {
for (const file of plan.files) {
const expected = file[side];
const absolute = await this.boundary.resolveWrite(file.path);
const present = await pathExists(absolute);
if (expected.exists !== present) {
throw new AgenticError(
"EDIT_CONFLICT",
`${file.path} ${present ? "exists" : "is missing"}; expected ${
expected.exists ? "an existing file" : "no file"
} for transaction ${plan.id}.`,
{ path: file.path, transactionId: plan.id, side },
);
}
if (!present) continue;
const currentInfo = await lstat(absolute);
const currentMode = process.platform === "win32" ? undefined : currentInfo.mode & 0o7777;
if (expected.mode !== undefined && currentMode !== expected.mode) {
throw new AgenticError(
"EDIT_CONFLICT",
`${file.path} permissions changed since the transaction snapshot was created.`,
{
path: file.path,
expectedMode: expected.mode,
actualMode: currentMode,
transactionId: plan.id,
side,
},
);
}
const current = await readTextSnapshot(absolute, this.maxEditableBytes);
if (current.sha256 !== expected.sha256) {
throw new AgenticError(
"EDIT_CONFLICT",
`${file.path} changed since the transaction snapshot was created.`,
{
path: file.path,
expectedSha256: expected.sha256,
actualSha256: current.sha256,
transactionId: plan.id,
side,
},
);
}
}
}
private async restoreSide(
plan: TransactionPlan,
side: "before" | "after",
): Promise<void> {
const existingTargets = plan.files.filter((file) => file[side].exists);
const absentTargets = plan.files.filter((file) => !file[side].exists);
for (const file of existingTargets) {
const stored = file[side];
if (!stored.snapshotPath) {
throw new AgenticError(
"INTERNAL",
`Missing ${side} snapshot for ${file.path} in ${plan.id}.`,
);
}
const content = await this.storage.readText(stored.snapshotPath);
const absolute = await this.boundary.resolveWrite(file.path);
await mkdir(dirname(absolute), { recursive: true });
// Re-check after parent creation to fail closed if a directory was replaced.
const verified = await this.boundary.resolveWrite(file.path);
await this.writeWorkspaceText(verified, content, stored.mode);
}
for (const file of absentTargets) {
const absolute = await this.boundary.resolveWrite(file.path);
let info;
try {
info = await lstat(absolute);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
throw error;
}
if (!info.isFile() || info.isSymbolicLink()) {
throw new AgenticError(
"EDIT_CONFLICT",
`Refusing to remove a non-regular-file target while restoring ${side}: ${file.path}`,
{ path: file.path, transactionId: plan.id, side },
);
}
try {
await unlink(absolute);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
/**
* Creates every planned directory, appending each level it had to create — the
* target and any parent `mkdir -p` makes implicitly — to `created` *before*
* attempting the write, so the caller can undo a partially applied commit.
* Node's own failures are wrapped: a raw `ENOTDIR`/`EEXIST` carries the
* absolute host path, which must never reach an envelope, `plan.failure` or
* `review.md`.
*/
private async createDirectories(plan: TransactionPlan, created: string[]): Promise<void> {
for (const directory of plan.directories) {
const absolute = await this.boundary.resolveWrite(directory.path);
for (const level of this.pathLevels(absolute)) {
if (created.includes(level.path)) continue;
if (!(await pathExists(level.absolute))) created.push(level.path);
}
try {
await mkdir(absolute, { recursive: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code ?? "UNKNOWN";
if (code === "ENOTDIR" || code === "EEXIST" || code === "ENOENT") {
throw new AgenticError(
"EDIT_CONFLICT",
`Could not create ${directory.path}: a path component exists and is not a directory (${code}).`,
{ path: directory.path, transactionId: plan.id, cause: code },
);
}
throw new AgenticError(
"INTERNAL",
`Could not create ${directory.path} for transaction ${plan.id} (${code}).`,
{ path: directory.path, transactionId: plan.id, cause: code },
);
}
}
}
/**
* Removes the directories this transaction created (`existedBefore: false`),
* deepest first, and returns the ones it deliberately left alone. Parents that
* `mkdir -p` created implicitly are not listed in the plan and stay in place,
* exactly as `create` behaves.
*/
private async removeCreatedDirectories(plan: TransactionPlan): Promise<string[]> {
return await this.removeDirectories(
plan.directories
.filter((directory) => !directory.existedBefore)
.map((directory) => directory.path),
);
}
/**
* Deletes `paths` deepest-first, and only while each is an empty directory
* this plan owns. Skipping is always the safe direction, so a path is left
* untouched — and returned, in the given order — whenever it is no longer
* something we may delete: the boundary refuses to resolve it (a symlink or
* junction was substituted), it is no longer a plain directory (a file was
* substituted), or it is not empty (the user added content after the commit).
* Never throws, so a rollback always reaches a terminal status.
*/
private async removeDirectories(paths: string[]): Promise<string[]> {
const ordered = [...paths].sort(
(a, b) => b.split("/").length - a.split("/").length || a.localeCompare(b),
);
const skipped = new Set<string>();
for (const path of ordered) {
let absolute: string;
try {
absolute = await this.boundary.resolveWrite(path);
} catch {
skipped.add(path);
continue;
}
let info;
try {
info = await lstatOrNull(absolute);
} catch {
skipped.add(path);
continue;
}
if (!info) continue;
if (!info.isDirectory()) {
skipped.add(path);
continue;
}
try {
await rmdir(absolute);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") continue;
skipped.add(path);
}
}
return paths.filter((path) => skipped.has(path));
}
/**
* Writes one workspace file through a temp file and a rename.
*
* Node's own failures are wrapped, exactly as `createDirectories` wraps
* `mkdir`'s: a raw errno carries the absolute host path, and a failed commit
* copies `error.message` verbatim into `plan.failure`, the plan summary, the
* rendered `review.md` and the envelope the model reads. The wrapped message
* keeps the two facts a caller can act on — which workspace-relative file,
* and which errno — and nothing about where the workspace lives.
*/
private async writeWorkspaceText(
absolutePath: string,
content: string,
mode?: number,
): Promise<void> {
const relativePath = this.boundary.relativePath(absolutePath);
const target = await this.boundary.resolveWrite(relativePath);
const tempPath = `${target}.agentic-${createId("tmp")}`;
try {
const tempRelative = this.boundary.relativePath(tempPath);
const safeTemp = await this.boundary.resolveWrite(tempRelative);
await writeFile(safeTemp, content, { encoding: "utf8", flag: "wx" });
const finalTarget = await this.boundary.resolveWrite(relativePath);
try {
await rename(safeTemp, finalTarget);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST" && code !== "EPERM") throw error;
await rm(finalTarget, { force: true });
await rename(safeTemp, finalTarget);
}
if (mode !== undefined && process.platform !== "win32") {
await chmod(finalTarget, mode);
}
} catch (error) {
// A boundary refusal already says what it means in workspace terms.
if (error instanceof AgenticError) throw error;
const code = (error as NodeJS.ErrnoException).code ?? "UNKNOWN";
const conflict =
code === "EACCES" ||
code === "EPERM" ||
code === "EBUSY" ||
code === "EEXIST" ||
code === "EISDIR" ||
code === "ENOTDIR" ||
code === "ENOENT";
throw new AgenticError(
conflict ? "EDIT_CONFLICT" : "INTERNAL",
`Could not write ${relativePath}: the file could not be opened or replaced (${code}).`,
{ path: relativePath, cause: code },
);
} finally {
await rm(tempPath, { force: true }).catch(() => undefined);
}
}
private async persistPlan(plan: TransactionPlan): Promise<void> {
await this.storage.writeText(plan.reviewPath, renderTransactionReview(plan));
await this.storage.writeJson(this.planPath(plan.id), plan);
}
private transactionPath(id: string): string {
validateTransactionId(id);
return this.storage.relative("transactions", id);
}
private snapshotsPath(id: string): string {
validateTransactionId(id);
return this.storage.relative("transactions", id, "snapshots");
}
private planPath(id: string): string {
return `${this.transactionPath(id)}/plan.json`;
}
private diffPath(id: string): string {
return `${this.transactionPath(id)}/changes.diff`;
}
private reviewPath(id: string): string {
return `${this.transactionPath(id)}/review.md`;
}
private async withLock<T>(id: string, action: () => Promise<T>): Promise<T> {
const key = `${this.boundary.realRoot}\0${id}`;
const previous = transactionLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
transactionLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (transactionLocks.get(key) === queued) transactionLocks.delete(key);
}
}
}
import { chmod, lstat, mkdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { ArtifactRef } from "../core/artifacts";
import { AgenticError, redactWorkspacePaths } from "../core/errors";
import { sha256Text } from "../core/hash";
import { createId } from "../core/id";
import type { InternalStorage } from "../core/internalStorage";
import type { Journal } from "../core/journal";
import { boundedPreview } from "../core/result";
import type { WorkspaceBoundary } from "./boundary";
import { createMultiFileDiff } from "./diff";
import {
countLines,
looksLineNumbered,
readTextSnapshot,
snapshotFromContent,
stripLineNumberGutter,
} from "./text";
export type EditOperation =
| {
type: "create";
path: string;
content: string;
overwrite?: boolean;
}
| {
type: "rewrite";
path: string;
content: string;
createIfMissing?: boolean;
}
| {
type: "replace";
path: string;
search: string;
replacement: string;
expectedMatches?: number;
}
| {
type: "splice";
path: string;
startLine: number;
deleteCount: number;
content: string;
}
| {
type: "delete";
path: string;
ignoreMissing?: boolean;
}
| {
type: "move";
from: string;
to: string;
overwrite?: boolean;
}
| {
type: "copy";
from: string;
to: string;
overwrite?: boolean;
}
| {
type: "mkdir";
path: string;
};
export interface RawEditOperation {
type: string;
path?: string;
content?: string;
overwrite?: boolean;
create_if_missing?: boolean;
search?: string;
replacement?: string;
expected_matches?: number;
start_line?: number;
delete_count?: number;
ignore_missing?: boolean;
from?: string;
to?: string;
/** Opt out of the line-number guard for content that really does look like `12 | text`. */
allow_line_numbers?: boolean;
}
export type TransactionStatus =
| "planned"
| "applied"
| "rolled_back"
| "failed";
export interface StoredFileSide {
exists: boolean;
sha256?: string;
bytes?: number;
mode?: number;
snapshotPath?: string;
}
export interface TransactionFile {
path: string;
before: StoredFileSide;
after: StoredFileSide;
addedLines: number;
removedLines: number;
}
export interface TransactionDirectory {
path: string;
existedBefore: boolean;
}
export interface TransactionPlan {
version: 1;
id: string;
status: TransactionStatus;
createdAt: string;
updatedAt: string;
appliedAt?: string;
rolledBackAt?: string;
failedAt?: string;
failure?: string;
idempotencyKey?: string;
operationHash: string;
operations: EditOperation[];
files: TransactionFile[];
directories: TransactionDirectory[];
destructive: boolean;
/**
* Why this plan is destructive, one clause per operation that made it so
* ("rewrite of the existing src/a.ts (use replace…)"). The gate refusal
* quotes these: naming only the setting left a model with nothing to change,
* and in the live matrix one recovered by guessing and one never did.
* Optional because plans persisted before 0.3.0 have no such field.
*/
destructiveCauses?: string[];
requiresReview: boolean;
addedLines: number;
removedLines: number;
diffPath: string;
reviewPath: string;
diffSha256: string;
diffBytes: number;
summary: string;
}
export interface TransactionPreview {
plan: TransactionPlan;
diffPreview: string;
diffTruncated: boolean;
diffOmittedChars: number;
artifact: ArtifactRef;
}
interface WorkingFile {
path: string;
absolutePath: string;
before: string | null;
after: string | null;
beforeMode?: number;
afterMode?: number;
}
const workspaceMutationLocks = new Map<string, Promise<void>>();
const transactionLocks = new Map<string, Promise<void>>();
async function withWorkspaceMutationLock<T>(
workspaceRoot: string,
action: () => Promise<T>,
): Promise<T> {
const previous = workspaceMutationLocks.get(workspaceRoot) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
workspaceMutationLocks.set(workspaceRoot, queued);
await previous;
try {
return await action();
} finally {
release();
if (workspaceMutationLocks.get(workspaceRoot) === queued) {
workspaceMutationLocks.delete(workspaceRoot);
}
}
}
function requireString(
value: unknown,
field: string,
operationIndex: number,
allowEmpty = false,
): string {
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
throw new AgenticError(
"INVALID_INPUT",
`Operation ${operationIndex + 1} requires string field '${field}'.`,
);
}
return value;
}
function requireInteger(
value: unknown,
field: string,
operationIndex: number,
minimum: number,
): number {
if (!Number.isInteger(value) || (value as number) < minimum) {
throw new AgenticError(
"INVALID_INPUT",
`Operation ${operationIndex + 1} requires integer '${field}' >= ${minimum}.`,
);
}
return value as number;
}
/**
* Rejects edit text that was copied straight out of a line-numbered
* `workspace_inspect read` result. Writing it would corrupt the file, and an
* exact `replace` whose `search` carries the gutter can never match — a live
* 1.7B run burned four rounds on exactly that. The refusal names the cause so
* the model can fix the call instead of retrying it verbatim.
*/
function rejectLineNumbers(
operation: RawEditOperation,
index: number,
fields: ReadonlyArray<"content" | "replacement" | "search">,
): void {
if (operation.allow_line_numbers === true) return;
for (const field of fields) {
const value = operation[field];
if (typeof value !== "string" || !looksLineNumbered(value)) continue;
throw new AgenticError(
"INVALID_INPUT",
`Operation ${index + 1} field '${field}' still has the line-number gutter: strip the "N | " prefixes that workspace_inspect read adds and send the raw file text (set allow_line_numbers true if the text really looks like that).`,
{ operationIndex: index, field },
);
}
}
/**
* The extra sentence on an `EDIT_CONFLICT` for a `replace` that matched
* nothing, when stripping the `12 | ` gutter would have matched.
*
* `looksLineNumbered` cannot help here: the failure that started all of this
* was a one-line file read back as `1 | module.exports = …`, and one line is
* below any threshold a majority rule can safely use. This probe runs only
* after the edit has already failed, so it can never reject a legitimate call
* — which is not the same as being right. `2 | beta` is also a GFM table row
* and `1 | 2 => Foo,` a Rust match arm, and against a file holding only the
* stripped text the probe fires on both. So the sentence states a condition the
* model can check ("if this came from a read") rather than ordering a retry: a
* model that obeys the order edits a narrower span than it meant to, possibly
* at an occurrence it never looked at. It explains; it never repairs.
*/
function gutterHint(content: string, search: string): string {
const stripped = stripLineNumberGutter(search);
if (stripped === search || stripped.trim() === "") return "";
const count = countOccurrences(content, stripped);
if (count === 0) return "";
return ` If this text was copied from a workspace_inspect read result, the "N | " gutter is still on it; without that prefix the search would match ${count} time(s).`;
}
/**
* The clause a destructive-ceiling refusal appends, naming what made the plan
* destructive. Bounded on purpose: a transaction may hold 100 operations and
* this goes into an error message a small model has to read.
*/
export function describeDestructiveCauses(plan: TransactionPlan): string {
const causes = plan.destructiveCauses ?? [];
if (causes.length === 0) return "";
const shown = causes.slice(0, 4);
const hidden = causes.length - shown.length;
return `Destructive because of: ${shown.join("; ")}${
hidden > 0 ? `; and ${hidden} more operation(s)` : ""
}.`;
}
export function normalizeEditOperations(raw: RawEditOperation[]): EditOperation[] {
if (!Array.isArray(raw) || raw.length === 0) {
throw new AgenticError("INVALID_INPUT", "At least one edit operation is required.");
}
if (raw.length > 100) {
throw new AgenticError("INVALID_INPUT", "A transaction may contain at most 100 operations.");
}
return raw.map((operation, index): EditOperation => {
rejectLineNumbers(operation, index, ["content", "replacement", "search"]);
switch (operation.type) {
case "create":
return {
type: "create",
path: requireString(operation.path, "path", index),
content: requireString(operation.content, "content", index, true),
overwrite: operation.overwrite ?? false,
};
case "rewrite":
return {
type: "rewrite",
path: requireString(operation.path, "path", index),
content: requireString(operation.content, "content", index, true),
createIfMissing: operation.create_if_missing ?? false,
};
case "replace":
return {
type: "replace",
path: requireString(operation.path, "path", index),
search: requireString(operation.search, "search", index),
replacement: requireString(operation.replacement, "replacement", index, true),
expectedMatches:
operation.expected_matches === undefined
? 1
: requireInteger(operation.expected_matches, "expected_matches", index, 1),
};
case "splice":
return {
type: "splice",
path: requireString(operation.path, "path", index),
startLine: requireInteger(operation.start_line, "start_line", index, 1),
deleteCount:
operation.delete_count === undefined
? 0
: requireInteger(operation.delete_count, "delete_count", index, 0),
content: requireString(operation.content, "content", index, true),
};
case "delete":
return {
type: "delete",
path: requireString(operation.path, "path", index),
ignoreMissing: operation.ignore_missing ?? false,
};
case "move": {
const from = requireString(operation.from, "from", index);
const to = requireString(operation.to, "to", index);
if (from === to) {
throw new AgenticError("INVALID_INPUT", "Move source and destination must differ.");
}
return { type: "move", from, to, overwrite: operation.overwrite ?? false };
}
case "copy": {
const from = requireString(operation.from, "from", index);
const to = requireString(operation.to, "to", index);
if (from === to) {
throw new AgenticError("INVALID_INPUT", "Copy source and destination must differ.");
}
return { type: "copy", from, to, overwrite: operation.overwrite ?? false };
}
case "mkdir":
return {
type: "mkdir",
path: requireString(operation.path, "path", index),
};
default:
throw new AgenticError(
"INVALID_INPUT",
`Unsupported edit operation '${operation.type}' at index ${index}.`,
);
}
});
}
/**
* `lstat` or `null` when the path cannot exist. `ENOTDIR` (a path component is
* a regular file — how POSIX reports what Windows reports as `ENOENT`) means
* the same thing as `ENOENT` here, and is normalized so the caller never has to
* handle a raw Node error carrying an absolute host path.
*/
async function lstatOrNull(path: string): Promise<Awaited<ReturnType<typeof lstat>> | null> {
try {
return await lstat(path);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") return null;
throw error;
}
}
async function pathExists(path: string): Promise<boolean> {
return (await lstatOrNull(path)) !== null;
}
function countOccurrences(content: string, search: string): number {
let count = 0;
let offset = 0;
while (true) {
const index = content.indexOf(search, offset);
if (index === -1) return count;
count++;
offset = index + search.length;
}
}
function normalizeNewlines(value: string, newline: string): string {
return value.replace(/\r\n|\r|\n/g, newline);
}
function snapshotName(path: string, side: "before" | "after"): string {
return `${sha256Text(path).slice(0, 24)}-${side}.txt`;
}
function validateTransactionId(id: string): void {
if (!/^tx_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid transaction id: ${id}`);
}
}
function escapeMarkdownCell(value: string): string {
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
}
function operationPath(operation: EditOperation): string {
return operation.type === "move" || operation.type === "copy"
? `${operation.from} -> ${operation.to}`
: operation.path;
}
function directoryClause(directories: TransactionDirectory[]): string {
if (directories.length === 0) return "";
return ` and ${directories.length} director${directories.length === 1 ? "y" : "ies"}`;
}
/**
* What to do instead, for the two `TRANSACTION_STATE` dead ends a model
* actually walks into. Naming only the status left it re-issuing the same call:
* committing a transaction it had already rolled back, or trying to roll back
* one that was never applied and therefore changed nothing.
*/
function commitRepair(status: TransactionStatus): string {
// `applied` returns early, so only these two reach the refusal.
if (status === "rolled_back") {
return " Its snapshots were already restored, so it can never be committed again: preview the operations again to get a fresh transaction.";
}
if (status === "failed") {
return " Read plan.failure with workspace_edit show, fix the cause, then preview the operations again.";
}
return "";
}
function rollbackRepair(status: TransactionStatus): string {
// `rolled_back` returns early and `applied` is the allowed path.
if (status === "planned") {
return " It was never committed, so it changed nothing on disk and can simply be abandoned — there is nothing to undo.";
}
if (status === "failed") {
return " A failed commit already restores what it touched; pass force: true only if workspace_edit show still reports changes on disk.";
}
return "";
}
/** Names the created directories a rollback left in place, in plan order. */
function skippedClause(skipped: string[]): string {
if (skipped.length === 0) return "";
return ` Left in place (no longer an empty directory this transaction owns): ${skipped.join(", ")}.`;
}
/** Older persisted plan.json files predate `directories`; default to none. */
function normalizeStoredPlan(
plan: Omit<TransactionPlan, "directories"> & { directories?: TransactionDirectory[] },
): TransactionPlan {
return { ...plan, directories: plan.directories ?? [] };
}
function renderTransactionReview(plan: TransactionPlan): string {
const fileRows = plan.files
.map((file) => {
const before = file.before.exists
? `${file.before.sha256 ?? "unknown"} (${file.before.bytes ?? 0} bytes${
file.before.mode === undefined ? "" : `, mode ${file.before.mode.toString(8)}`
})`
: "absent";
const after = file.after.exists
? `${file.after.sha256 ?? "unknown"} (${file.after.bytes ?? 0} bytes${
file.after.mode === undefined ? "" : `, mode ${file.after.mode.toString(8)}`
})`
: "absent";
return `| \`${escapeMarkdownCell(file.path)}\` | +${file.addedLines}/-${file.removedLines} | ${before} | ${after} |`;
})
.join("\n");
const snapshotLines = plan.files.flatMap((file) => [
...(file.before.snapshotPath
? [`- \`${file.path}\` before: \`${file.before.snapshotPath}\``]
: []),
...(file.after.snapshotPath
? [`- \`${file.path}\` after: \`${file.after.snapshotPath}\``]
: []),
]);
const operationLines = plan.operations.map(
(operation, index) =>
`${index + 1}. \`${operation.type}\` — \`${escapeMarkdownCell(operationPath(operation))}\``,
);
const directoryLines = plan.directories.map(
(directory) =>
`- \`${escapeMarkdownCell(directory.path)}\` — ${
directory.existedBefore ? "existed before" : "created by this transaction"
}`,
);
return [
`# Transaction ${plan.id}`,
"",
`- **Status:** \`${plan.status}\``,
`- **Created:** ${plan.createdAt}`,
`- **Updated:** ${plan.updatedAt}`,
`- **Destructive:** ${plan.destructive ? "yes" : "no"}`,
`- **Review required:** ${plan.requiresReview ? "yes" : "no"}`,
`- **Line delta:** +${plan.addedLines}/-${plan.removedLines}`,
`- **Operation hash:** \`${plan.operationHash}\``,
`- **Diff:** \`${plan.diffPath}\``,
`- **Plan:** \`.agentic/transactions/${plan.id}/plan.json\``,
...(plan.appliedAt ? [`- **Applied:** ${plan.appliedAt}`] : []),
...(plan.rolledBackAt ? [`- **Rolled back:** ${plan.rolledBackAt}`] : []),
...(plan.failedAt ? [`- **Failed:** ${plan.failedAt}`] : []),
"",
"## Summary",
"",
plan.summary,
...(plan.failure ? ["", "## Failure", "", `\`${plan.failure.replaceAll("`", "'")}\``] : []),
"",
"## Operations",
"",
...operationLines,
"",
"## Files",
"",
"| Path | Lines | Before | After |",
"|---|---:|---|---|",
fileRows,
...(directoryLines.length > 0 ? ["", "## Directories", "", ...directoryLines] : []),
"",
"## Snapshots",
"",
...(snapshotLines.length > 0 ? snapshotLines : ["No content snapshots were required."]),
"",
"This receipt is generated from the durable transaction plan. Verify the diff and snapshots before committing high-risk changes.",
"",
].join("\n");
}
export class TransactionManager {
private readonly transactionsPath: string;
public constructor(
private readonly boundary: WorkspaceBoundary,
private readonly storage: InternalStorage,
private readonly journal: Journal,
private readonly maxEditableBytes: number,
) {
this.transactionsPath = storage.relative("transactions");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.transactionsPath);
}
public async preview(
operations: EditOperation[],
options: { idempotencyKey?: string; maxPreviewChars?: number; runId?: string } = {},
): Promise<TransactionPreview> {
await this.initialize();
const operationHash = sha256Text(JSON.stringify(operations));
const id = options.idempotencyKey
? `tx_${sha256Text(`agentic-workspace/v1:${options.idempotencyKey}`).slice(0, 24)}`
: createId("tx");
return await this.withLock(id, async () => {
const existingPath = this.planPath(id);
if (await this.storage.exists(existingPath)) {
const existing = await this.storage.readJson<TransactionPlan>(existingPath);
if (existing.operationHash !== operationHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The idempotency key was already used with different edit operations. Use a new idempotency_key (or omit it) for a different set of operations.",
{ transactionId: id },
);
}
return await this.previewFromPlan(
normalizeStoredPlan(existing),
options.maxPreviewChars ?? 12_000,
);
}
const working = new Map<string, WorkingFile>();
const directories = new Map<string, TransactionDirectory>();
// One clause per operation that puts existing content at risk. Replaces
// the old `broadOverwrite` flag: the refusal has to say which operation
// and which path, not just that something was destructive.
const destructiveCauses: string[] = [];
const getFile = async (requestedPath: string): Promise<WorkingFile> => {
const absolutePath = await this.boundary.resolveWrite(requestedPath);
const path = this.boundary.relativePath(absolutePath);
if (directories.has(path)) {
throw new AgenticError(
"EDIT_CONFLICT",
`Path is already planned as a directory in this transaction: ${path}`,
{ path },
);
}
// A file may not sit above a directory this transaction plans to create:
// committing would either turn the file into a directory or fail halfway.
for (const planned of directories.keys()) {
if (planned.startsWith(`${path}/`)) {
throw new AgenticError(
"EDIT_CONFLICT",
`${path} is a parent of ${planned}, which this transaction plans to create as a directory.`,
{ path, conflictingPath: planned },
);
}
}
const cached = working.get(path);
if (cached) return cached;
let before: string | null = null;
let beforeMode: number | undefined;
if (await pathExists(absolutePath)) {
const info = await lstat(absolutePath);
if (!info.isFile()) {
throw new AgenticError(
"INVALID_INPUT",
`Edit target must be a regular file: ${path}`,
);
}
before = (await readTextSnapshot(absolutePath, this.maxEditableBytes)).content;
beforeMode = process.platform === "win32" ? undefined : info.mode & 0o7777;
}
const file: WorkingFile = {
path,
absolutePath,
before,
after: before,
...(beforeMode === undefined ? {} : { beforeMode, afterMode: beforeMode }),
};
working.set(path, file);
return file;
};
for (const operation of operations) {
if (operation.type === "create") {
const file = await getFile(operation.path);
if (file.after !== null && !operation.overwrite) {
throw new AgenticError(
"EDIT_CONFLICT",
`Create target already exists: ${file.path}. Use overwrite: true to replace the whole file, or a replace operation to change part of it.`,
);
}
// Only an existing file is at risk. Classifying every
// `overwrite: true` as destructive refused a whole scaffold into an
// empty directory under the default ceiling.
if (file.before !== null && operation.overwrite) {
destructiveCauses.push(
`create with overwrite over the existing ${file.path} (use replace to change part of it, or create it at a different path)`,
);
}
file.after = operation.content;
} else if (operation.type === "rewrite") {
const file = await getFile(operation.path);
if (file.after === null && !operation.createIfMissing) {
throw new AgenticError("NOT_FOUND", `Rewrite target does not exist: ${file.path}. Use a create operation, or create_if_missing: true on this rewrite.`);
}
if (file.before !== null) {
destructiveCauses.push(
`rewrite of the existing ${file.path} (use replace to change part of it)`,
);
}
file.after = operation.content;
} else if (operation.type === "replace") {
const file = await getFile(operation.path);
if (file.after === null) {
throw new AgenticError("NOT_FOUND", `Replace target does not exist: ${file.path}. Use a create operation to make the file first.`);
}
const actual = countOccurrences(file.after, operation.search);
const expected = operation.expectedMatches ?? 1;
if (actual !== expected) {
// Name the repair: a model that only sees the count re-sends the same
// search until its budget runs out (observed 15 times in a row live).
const repair =
actual === 0
? ` Re-read ${file.path} with workspace_inspect read and copy the search text exactly from its current content (a shorter snippet that is still unique is safer); do not resend the same search.`
: ` Narrow the search to a snippet that occurs exactly ${expected} time(s), or set expected_matches to ${actual} to replace every occurrence.`;
throw new AgenticError(
"EDIT_CONFLICT",
`Expected ${expected} exact match(es) in ${file.path}, found ${actual}.${
actual === 0 ? gutterHint(file.after, operation.search) : ""
}${repair}`,
{ path: file.path, expected, actual },
);
}
file.after = file.after.split(operation.search).join(operation.replacement);
} else if (operation.type === "splice") {
const file = await getFile(operation.path);
if (file.after === null) {
throw new AgenticError("NOT_FOUND", `Splice target does not exist: ${file.path}`);
}
const newline = file.after.includes("\r\n") ? "\r\n" : "\n";
const normalized = normalizeNewlines(file.after, "\n");
const lines = normalized.length === 0 ? [] : normalized.split("\n");
if (operation.startLine > lines.length + 1) {
throw new AgenticError(
"INVALID_INPUT",
`start_line ${operation.startLine} exceeds ${file.path}'s insertion boundary ${lines.length + 1}.`,
);
}
if (operation.startLine - 1 + operation.deleteCount > lines.length) {
throw new AgenticError(
"INVALID_INPUT",
`Splice deletes beyond the end of ${file.path}.`,
);
}
const insertion =
operation.content.length === 0
? []
: normalizeNewlines(operation.content, "\n").split("\n");
lines.splice(operation.startLine - 1, operation.deleteCount, ...insertion);
file.after = normalizeNewlines(lines.join("\n"), newline);
} else if (operation.type === "delete") {
const file = await getFile(operation.path);
if (file.after === null && !operation.ignoreMissing) {
throw new AgenticError("NOT_FOUND", `Delete target does not exist: ${file.path}. Use ignore_missing: true if its absence is acceptable.`);
}
// A delete that removes nothing (ignore_missing on an absent file) must not
// be named as a destructive cause: the clause would assert a removal that
// never happens. The plan's destructive flag is computed from the causes.
if (file.after !== null) destructiveCauses.push(`delete of ${file.path}`);
file.after = null;
} else if (operation.type === "mkdir") {
const absolutePath = await this.boundary.resolveWrite(operation.path);
const path = this.boundary.relativePath(absolutePath);
if (working.has(path)) {
throw new AgenticError(
"EDIT_CONFLICT",
`mkdir target is already planned as a file in this transaction: ${path}`,
{ path },
);
}
// Every ancestor must already be — or be free to become — a directory.
// Without this, `mkdir -p` at commit time either raises a raw ENOTDIR
// or silently replaces a file the same transaction planned to write.
const levels = this.pathLevels(absolutePath);
for (const ancestor of levels.slice(0, -1)) {
if (directories.has(ancestor.path)) continue;
if (working.has(ancestor.path)) {
throw new AgenticError(
"EDIT_CONFLICT",
`mkdir target ${path} is inside ${ancestor.path}, which this transaction plans as a file.`,
{ path, conflictingPath: ancestor.path },
);
}
const ancestorInfo = await lstatOrNull(ancestor.absolute);
if (ancestorInfo && !ancestorInfo.isDirectory()) {
throw new AgenticError(
"EDIT_CONFLICT",
`mkdir target ${path} is inside ${ancestor.path}, which exists and is not a directory.`,
{ path, conflictingPath: ancestor.path },
);
}
}
if (!directories.has(path)) {
const info = await lstatOrNull(absolutePath);
if (info && !info.isDirectory()) {
throw new AgenticError(
"EDIT_CONFLICT",
`mkdir target exists and is not a directory: ${path}`,
{ path },
);
}
directories.set(path, { path, existedBefore: info !== null });
}
} else if (operation.type === "move" || operation.type === "copy") {
const source = await getFile(operation.from);
const destination = await getFile(operation.to);
if (source.path === destination.path) {
throw new AgenticError(
"INVALID_INPUT",
`${operation.type === "move" ? "Move" : "Copy"} source and destination resolve to the same path: ${source.path}`,
);
}
if (source.after === null) {
throw new AgenticError(
"NOT_FOUND",
`${operation.type === "move" ? "Move" : "Copy"} source does not exist: ${source.path}`,
);
}
if (destination.after !== null && !operation.overwrite) {
throw new AgenticError(
"EDIT_CONFLICT",
`${operation.type === "move" ? "Move" : "Copy"} destination already exists: ${destination.path}`,
);
}
if (destination.before !== null && operation.overwrite) {
destructiveCauses.push(
`${operation.type} over the existing ${destination.path} (choose a destination path that does not exist yet)`,
);
}
destination.after = source.after;
destination.afterMode = source.afterMode;
if (operation.type === "move") {
destructiveCauses.push(`move of ${source.path}, which removes it (copy keeps it)`);
source.after = null;
source.afterMode = undefined;
}
}
}
const changed = [...working.values()].filter((file) => file.before !== file.after);
const directoryList = [...directories.values()];
if (changed.length === 0 && directoryList.every((directory) => directory.existedBefore)) {
// A model re-issuing a mkdir after compaction lands here; name the
// directories so the bare "no changes" wording is not read as a failure.
if (directoryList.length > 0) {
const names = directoryList.map((directory) => directory.path).join(", ");
throw new AgenticError(
"INVALID_INPUT",
`The proposed operations produce no changes: every requested directory already exists (${names}).`,
{ directories: directoryList.map((directory) => directory.path) },
);
}
throw new AgenticError("INVALID_INPUT", "The proposed operations produce no changes.");
}
for (const file of changed) {
if (file.after !== null && Buffer.byteLength(file.after) > this.maxEditableBytes) {
throw new AgenticError(
"FILE_TOO_LARGE",
`Edited content for ${file.path} exceeds the configured limit.`,
);
}
}
const diff = createMultiFileDiff(
changed.map((file) => ({ path: file.path, before: file.before, after: file.after })),
);
await this.storage.ensureDirectory(this.snapshotsPath(id));
const files: TransactionFile[] = [];
for (const file of changed) {
const before = await this.persistSide(
id,
file.path,
file.before,
file.beforeMode,
"before",
);
const after = await this.persistSide(
id,
file.path,
file.after,
file.afterMode,
"after",
);
const fileDiff = diff.files.find((item) => item.path === file.path);
files.push({
path: file.path,
before,
after,
addedLines: fileDiff?.added ?? 0,
removedLines: fileDiff?.removed ?? 0,
});
}
const diffPath = this.diffPath(id);
await this.storage.writeText(diffPath, diff.text);
const now = new Date().toISOString();
const causes = [...new Set(destructiveCauses)];
const destructive = causes.length > 0;
const plan: TransactionPlan = {
version: 1,
id,
status: "planned",
createdAt: now,
updatedAt: now,
...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
operationHash,
operations,
files,
directories: directoryList,
destructive,
...(destructive ? { destructiveCauses: causes } : {}),
requiresReview: destructive,
addedLines: diff.added,
removedLines: diff.removed,
diffPath,
reviewPath: this.reviewPath(id),
diffSha256: sha256Text(diff.text),
diffBytes: Buffer.byteLength(diff.text),
summary: `Planned ${files.length} file change(s)${directoryClause(directoryList)} (+${diff.added}/-${diff.removed}).`,
};
await this.persistPlan(plan);
await this.journal.record({
category: "edit",
action: "preview",
summary: plan.summary,
transactionId: id,
...(options.runId ? { runId: options.runId } : {}),
details: {
files: files.map((file) => file.path),
directories: directoryList.map((directory) => directory.path),
destructive,
addedLines: diff.added,
removedLines: diff.removed,
reviewPath: plan.reviewPath,
},
});
return await this.previewFromPlan(plan, options.maxPreviewChars ?? 12_000);
});
}
public async commit(id: string, runId?: string): Promise<TransactionPlan> {
validateTransactionId(id);
return await this.withLock(id, async () =>
await withWorkspaceMutationLock(this.boundary.realRoot, async () => {
const plan = await this.get(id);
if (plan.status === "applied") return plan;
if (plan.status !== "planned") {
throw new AgenticError(
"TRANSACTION_STATE",
`Transaction ${id} cannot be committed from status '${plan.status}'.${commitRepair(plan.status)}`,
);
}
await this.verifyDirectoryTargets(plan);
await this.verifyPlanSide(plan, "before");
const createdDirectories: string[] = [];
try {
await this.createDirectories(plan, createdDirectories);
await this.restoreSide(plan, "after");
await this.verifyPlanSide(plan, "after");
const now = new Date().toISOString();
const applied: TransactionPlan = {
...plan,
status: "applied",
appliedAt: now,
updatedAt: now,
summary: `Applied ${plan.files.length} file change(s)${directoryClause(plan.directories)} (+${plan.addedLines}/-${plan.removedLines}).`,
};
await this.persistPlan(applied);
await this.journal.record({
category: "edit",
action: "commit",
summary: applied.summary,
transactionId: id,
...(runId ? { runId } : {}),
details: { files: plan.files.map((file) => file.path), reviewPath: plan.reviewPath },
});
return applied;
} catch (error) {
try {
await this.restoreSide(plan, "before");
// Compensation undoes every level this commit created, including the
// parents `mkdir -p` made implicitly: a failed commit must leave the
// workspace exactly as it found it.
await this.removeDirectories(createdDirectories);
} catch {
// Preserve the original failure; snapshots remain available for manual recovery.
}
const now = new Date().toISOString();
const failure = this.failureMessage(error);
const failed: TransactionPlan = {
...plan,
status: "failed",
failedAt: now,
updatedAt: now,
failure,
summary: `Commit failed and recovery was attempted: ${failure}`,
};
await this.persistPlan(failed);
await this.journal.record({
category: "edit",
action: "commit_failed",
summary: failed.summary,
transactionId: id,
...(runId ? { runId } : {}),
details: { reviewPath: plan.reviewPath },
});
// Rethrown scrubbed as well: this same message becomes the model-facing
// errorResult, so scrubbing only the persisted copy would still leak.
throw error instanceof AgenticError ? error : new AgenticError("INTERNAL", failure);
}
}),
);
}
/**
* The commit failure text that is written into `plan.failure`, the plan
* summary, the journal and the tool envelope.
*
* `writeWorkspaceText` wraps its own errno into workspace terms, and every
* deliberate refusal is an `AgenticError` already. What reaches here
* otherwise is a raw Node error carrying an absolute host path — a snapshot
* read against a target that was swapped for a directory is one live route —
* and all four sinks are read by the model.
*/
private failureMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof AgenticError) return message;
return redactWorkspacePaths(message, this.boundary.realRoot, this.boundary.root);
}
public async rollback(
id: string,
options: { force?: boolean; runId?: string } = {},
): Promise<TransactionPlan> {
validateTransactionId(id);
return await this.withLock(id, async () =>
await withWorkspaceMutationLock(this.boundary.realRoot, async () => {
const plan = await this.get(id);
if (plan.status === "rolled_back") return plan;
if (plan.status !== "applied" && !(options.force && plan.status === "failed")) {
throw new AgenticError(
"TRANSACTION_STATE",
`Transaction ${id} cannot be rolled back from status '${plan.status}'.${rollbackRepair(plan.status)}`,
);
}
if (!options.force) await this.verifyPlanSide(plan, "after");
await this.restoreSide(plan, "before");
const skippedDirectories = await this.removeCreatedDirectories(plan);
await this.verifyPlanSide(plan, "before");
const now = new Date().toISOString();
const rolledBack: TransactionPlan = {
...plan,
status: "rolled_back",
rolledBackAt: now,
updatedAt: now,
summary: `Rolled back ${plan.files.length} file change(s)${directoryClause(plan.directories)}.${skippedClause(skippedDirectories)}`,
};
await this.persistPlan(rolledBack);
await this.journal.record({
category: "edit",
action: "rollback",
summary: rolledBack.summary,
transactionId: id,
...(options.runId ? { runId: options.runId } : {}),
details: {
force: options.force ?? false,
reviewPath: plan.reviewPath,
skippedDirectories,
},
});
return rolledBack;
}),
);
}
public async get(id: string): Promise<TransactionPlan> {
validateTransactionId(id);
try {
return normalizeStoredPlan(await this.storage.readJson<TransactionPlan>(this.planPath(id)));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new AgenticError("NOT_FOUND", `Transaction not found: ${id}`);
}
throw error;
}
}
public async list(limit = 20): Promise<TransactionPlan[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.transactionsPath, {
withFileTypes: true,
});
const plans: TransactionPlan[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith("tx_")) continue;
try {
plans.push(
normalizeStoredPlan(
await this.storage.readJson<TransactionPlan>(this.planPath(entry.name)),
),
);
} catch {
// Ignore incomplete/corrupt directories but leave them on disk for inspection.
}
}
return plans
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, Math.max(1, Math.min(limit, 100)));
}
public async readDiff(
id: string,
startLine = 1,
maxLines = 300,
): Promise<{
text: string;
startLine: number;
endLine: number;
totalLines: number;
truncated: boolean;
}> {
const plan = await this.get(id);
const content = await this.storage.readText(plan.diffPath);
const lines = content.split("\n");
// A terminating newline leaves a phantom empty element; count real lines only (see text.ts countLines).
const totalLines = content.length === 0 ? 0 : countLines(lines);
const start = Math.max(1, Math.min(startLine, Math.max(1, totalLines)));
const end = Math.min(totalLines, start - 1 + Math.max(1, Math.min(maxLines, 2000)));
return {
text: lines.slice(start - 1, end).join("\n"),
startLine: start,
endLine: end,
totalLines,
truncated: end < totalLines,
};
}
public diffArtifact(plan: TransactionPlan): ArtifactRef {
return {
kind: "diff",
path: plan.diffPath,
sha256: plan.diffSha256,
bytes: plan.diffBytes,
description: `Full diff for transaction ${plan.id}`,
};
}
public async reviewArtifact(plan: TransactionPlan): Promise<ArtifactRef> {
const content = await this.storage.readText(plan.reviewPath);
return {
kind: "text",
path: plan.reviewPath,
sha256: sha256Text(content),
bytes: Buffer.byteLength(content),
description: `Human-readable review receipt for transaction ${plan.id}`,
};
}
private async previewFromPlan(
plan: TransactionPlan,
maxPreviewChars: number,
): Promise<TransactionPreview> {
const content = await this.storage.readText(plan.diffPath);
const bounded = boundedPreview(content, maxPreviewChars);
return {
plan,
diffPreview: bounded.preview,
diffTruncated: bounded.truncated,
diffOmittedChars: bounded.omittedChars,
artifact: this.diffArtifact(plan),
};
}
private async persistSide(
id: string,
path: string,
content: string | null,
mode: number | undefined,
side: "before" | "after",
): Promise<StoredFileSide> {
if (content === null) return { exists: false };
const snapshot = snapshotFromContent(content);
const snapshotPath = this.storage.relative(
"transactions",
id,
"snapshots",
snapshotName(path, side),
);
await this.storage.writeText(snapshotPath, content);
return {
exists: true,
sha256: snapshot.sha256,
bytes: snapshot.bytes,
...(mode === undefined ? {} : { mode }),
snapshotPath,
};
}
/**
* Commit pre-flight for `mkdir` targets: every level of every planned
* directory must be missing or already a directory. An existing directory is
* fine — creation is idempotent — but a regular file or symlink anywhere on
* the chain must fail the commit *before* anything is written, so no
* half-created tree survives. Rollback never runs this: it creates nothing.
*/
private async verifyDirectoryTargets(plan: TransactionPlan): Promise<void> {
for (const directory of plan.directories) {
const absolute = await this.boundary.resolveWrite(directory.path);
for (const level of this.pathLevels(absolute)) {
const info = await lstatOrNull(level.absolute);
if (!info || info.isDirectory()) continue;
throw new AgenticError(
"EDIT_CONFLICT",
level.path === directory.path
? `${directory.path} exists and is not a directory; refusing to create it for transaction ${plan.id}.`
: `${directory.path} is inside ${level.path}, which exists and is not a directory; refusing to create it for transaction ${plan.id}.`,
{
path: directory.path,
...(level.path === directory.path ? {} : { conflictingPath: level.path }),
transactionId: plan.id,
side: "before",
},
);
}
}
}
/**
* Workspace-relative levels of `absolutePath`, outermost first and including
* the path itself, paired with their absolute form.
*/
private pathLevels(absolutePath: string): Array<{ path: string; absolute: string }> {
const levels: Array<{ path: string; absolute: string }> = [];
let current = absolutePath;
for (;;) {
const path = this.boundary.relativePath(current);
if (path === "." || path === "" || path.startsWith("..")) break;
levels.push({ path, absolute: current });
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return levels.reverse();
}
private async verifyPlanSide(
plan: TransactionPlan,
side: "before" | "after",
): Promise<void> {
for (const file of plan.files) {
const expected = file[side];
const absolute = await this.boundary.resolveWrite(file.path);
const present = await pathExists(absolute);
if (expected.exists !== present) {
throw new AgenticError(
"EDIT_CONFLICT",
`${file.path} ${present ? "exists" : "is missing"}; expected ${
expected.exists ? "an existing file" : "no file"
} for transaction ${plan.id}.`,
{ path: file.path, transactionId: plan.id, side },
);
}
if (!present) continue;
const currentInfo = await lstat(absolute);
const currentMode = process.platform === "win32" ? undefined : currentInfo.mode & 0o7777;
if (expected.mode !== undefined && currentMode !== expected.mode) {
throw new AgenticError(
"EDIT_CONFLICT",
`${file.path} permissions changed since the transaction snapshot was created.`,
{
path: file.path,
expectedMode: expected.mode,
actualMode: currentMode,
transactionId: plan.id,
side,
},
);
}
const current = await readTextSnapshot(absolute, this.maxEditableBytes);
if (current.sha256 !== expected.sha256) {
throw new AgenticError(
"EDIT_CONFLICT",
`${file.path} changed since the transaction snapshot was created.`,
{
path: file.path,
expectedSha256: expected.sha256,
actualSha256: current.sha256,
transactionId: plan.id,
side,
},
);
}
}
}
private async restoreSide(
plan: TransactionPlan,
side: "before" | "after",
): Promise<void> {
const existingTargets = plan.files.filter((file) => file[side].exists);
const absentTargets = plan.files.filter((file) => !file[side].exists);
for (const file of existingTargets) {
const stored = file[side];
if (!stored.snapshotPath) {
throw new AgenticError(
"INTERNAL",
`Missing ${side} snapshot for ${file.path} in ${plan.id}.`,
);
}
const content = await this.storage.readText(stored.snapshotPath);
const absolute = await this.boundary.resolveWrite(file.path);
await mkdir(dirname(absolute), { recursive: true });
// Re-check after parent creation to fail closed if a directory was replaced.
const verified = await this.boundary.resolveWrite(file.path);
await this.writeWorkspaceText(verified, content, stored.mode);
}
for (const file of absentTargets) {
const absolute = await this.boundary.resolveWrite(file.path);
let info;
try {
info = await lstat(absolute);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
throw error;
}
if (!info.isFile() || info.isSymbolicLink()) {
throw new AgenticError(
"EDIT_CONFLICT",
`Refusing to remove a non-regular-file target while restoring ${side}: ${file.path}`,
{ path: file.path, transactionId: plan.id, side },
);
}
try {
await unlink(absolute);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
/**
* Creates every planned directory, appending each level it had to create — the
* target and any parent `mkdir -p` makes implicitly — to `created` *before*
* attempting the write, so the caller can undo a partially applied commit.
* Node's own failures are wrapped: a raw `ENOTDIR`/`EEXIST` carries the
* absolute host path, which must never reach an envelope, `plan.failure` or
* `review.md`.
*/
private async createDirectories(plan: TransactionPlan, created: string[]): Promise<void> {
for (const directory of plan.directories) {
const absolute = await this.boundary.resolveWrite(directory.path);
for (const level of this.pathLevels(absolute)) {
if (created.includes(level.path)) continue;
if (!(await pathExists(level.absolute))) created.push(level.path);
}
try {
await mkdir(absolute, { recursive: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code ?? "UNKNOWN";
if (code === "ENOTDIR" || code === "EEXIST" || code === "ENOENT") {
throw new AgenticError(
"EDIT_CONFLICT",
`Could not create ${directory.path}: a path component exists and is not a directory (${code}).`,
{ path: directory.path, transactionId: plan.id, cause: code },
);
}
throw new AgenticError(
"INTERNAL",
`Could not create ${directory.path} for transaction ${plan.id} (${code}).`,
{ path: directory.path, transactionId: plan.id, cause: code },
);
}
}
}
/**
* Removes the directories this transaction created (`existedBefore: false`),
* deepest first, and returns the ones it deliberately left alone. Parents that
* `mkdir -p` created implicitly are not listed in the plan and stay in place,
* exactly as `create` behaves.
*/
private async removeCreatedDirectories(plan: TransactionPlan): Promise<string[]> {
return await this.removeDirectories(
plan.directories
.filter((directory) => !directory.existedBefore)
.map((directory) => directory.path),
);
}
/**
* Deletes `paths` deepest-first, and only while each is an empty directory
* this plan owns. Skipping is always the safe direction, so a path is left
* untouched — and returned, in the given order — whenever it is no longer
* something we may delete: the boundary refuses to resolve it (a symlink or
* junction was substituted), it is no longer a plain directory (a file was
* substituted), or it is not empty (the user added content after the commit).
* Never throws, so a rollback always reaches a terminal status.
*/
private async removeDirectories(paths: string[]): Promise<string[]> {
const ordered = [...paths].sort(
(a, b) => b.split("/").length - a.split("/").length || a.localeCompare(b),
);
const skipped = new Set<string>();
for (const path of ordered) {
let absolute: string;
try {
absolute = await this.boundary.resolveWrite(path);
} catch {
skipped.add(path);
continue;
}
let info;
try {
info = await lstatOrNull(absolute);
} catch {
skipped.add(path);
continue;
}
if (!info) continue;
if (!info.isDirectory()) {
skipped.add(path);
continue;
}
try {
await rmdir(absolute);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") continue;
skipped.add(path);
}
}
return paths.filter((path) => skipped.has(path));
}
/**
* Writes one workspace file through a temp file and a rename.
*
* Node's own failures are wrapped, exactly as `createDirectories` wraps
* `mkdir`'s: a raw errno carries the absolute host path, and a failed commit
* copies `error.message` verbatim into `plan.failure`, the plan summary, the
* rendered `review.md` and the envelope the model reads. The wrapped message
* keeps the two facts a caller can act on — which workspace-relative file,
* and which errno — and nothing about where the workspace lives.
*/
private async writeWorkspaceText(
absolutePath: string,
content: string,
mode?: number,
): Promise<void> {
const relativePath = this.boundary.relativePath(absolutePath);
const target = await this.boundary.resolveWrite(relativePath);
const tempPath = `${target}.agentic-${createId("tmp")}`;
try {
const tempRelative = this.boundary.relativePath(tempPath);
const safeTemp = await this.boundary.resolveWrite(tempRelative);
await writeFile(safeTemp, content, { encoding: "utf8", flag: "wx" });
const finalTarget = await this.boundary.resolveWrite(relativePath);
try {
await rename(safeTemp, finalTarget);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST" && code !== "EPERM") throw error;
await rm(finalTarget, { force: true });
await rename(safeTemp, finalTarget);
}
if (mode !== undefined && process.platform !== "win32") {
await chmod(finalTarget, mode);
}
} catch (error) {
// A boundary refusal already says what it means in workspace terms.
if (error instanceof AgenticError) throw error;
const code = (error as NodeJS.ErrnoException).code ?? "UNKNOWN";
const conflict =
code === "EACCES" ||
code === "EPERM" ||
code === "EBUSY" ||
code === "EEXIST" ||
code === "EISDIR" ||
code === "ENOTDIR" ||
code === "ENOENT";
throw new AgenticError(
conflict ? "EDIT_CONFLICT" : "INTERNAL",
`Could not write ${relativePath}: the file could not be opened or replaced (${code}).`,
{ path: relativePath, cause: code },
);
} finally {
await rm(tempPath, { force: true }).catch(() => undefined);
}
}
private async persistPlan(plan: TransactionPlan): Promise<void> {
await this.storage.writeText(plan.reviewPath, renderTransactionReview(plan));
await this.storage.writeJson(this.planPath(plan.id), plan);
}
private transactionPath(id: string): string {
validateTransactionId(id);
return this.storage.relative("transactions", id);
}
private snapshotsPath(id: string): string {
validateTransactionId(id);
return this.storage.relative("transactions", id, "snapshots");
}
private planPath(id: string): string {
return `${this.transactionPath(id)}/plan.json`;
}
private diffPath(id: string): string {
return `${this.transactionPath(id)}/changes.diff`;
}
private reviewPath(id: string): string {
return `${this.transactionPath(id)}/review.md`;
}
private async withLock<T>(id: string, action: () => Promise<T>): Promise<T> {
const key = `${this.boundary.realRoot}\0${id}`;
const previous = transactionLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
transactionLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (transactionLocks.get(key) === queued) transactionLocks.delete(key);
}
}
}