src / tools / editTool.ts
src / tools / editTool.ts
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { errorResult, okResult } from "../core/result";
import { applyGate, consumeApproval, hashOperation, modeFacts } from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
import {
describeDestructiveCauses,
normalizeEditOperations,
type RawEditOperation,
type TransactionPlan,
} from "../workspace/transactions";
const operationSchema = z.object({
type: z.enum(["create", "rewrite", "replace", "splice", "delete", "move", "copy", "mkdir"]),
path: z.string().optional(),
content: z.string().optional(),
overwrite: z.boolean().optional(),
create_if_missing: z.boolean().optional(),
search: z.string().optional(),
replacement: z.string().optional(),
expected_matches: z.number().int().optional(),
start_line: z.number().int().optional(),
delete_count: z.number().int().optional(),
ignore_missing: z.boolean().optional(),
from: z.string().optional(),
to: z.string().optional(),
allow_line_numbers: z.boolean().optional(),
});
/**
* The listing shape shared by `workspace_edit list` and `workspace_inspect
* changes`. Directories are counted and named alongside files: a `mkdir`-only
* transaction has no files at all, so a files-only entry reported `fileCount:
* 0` and read as a transaction that changed nothing. Both lists are capped at
* 20 paths — this is the recovery index, not bulk content.
*/
export function transactionListEntry(plan: TransactionPlan) {
return {
status: plan.status,
summary: plan.summary,
fileCount: plan.files.length,
files: plan.files.slice(0, 20).map((file) => file.path),
filesTruncated: plan.files.length > 20,
directoryCount: plan.directories.length,
directories: plan.directories.slice(0, 20).map((directory) => directory.path),
directoriesTruncated: plan.directories.length > 20,
updatedAt: plan.updatedAt,
diffPath: plan.diffPath,
reviewPath: plan.reviewPath,
};
}
function planListData(plan: TransactionPlan) {
return {
transactionId: plan.id,
destructive: plan.destructive,
requiresReview: plan.requiresReview,
addedLines: plan.addedLines,
removedLines: plan.removedLines,
...transactionListEntry(plan),
};
}
function planData(plan: TransactionPlan) {
return {
transactionId: plan.id,
status: plan.status,
summary: plan.summary,
destructive: plan.destructive,
requiresReview: plan.requiresReview,
addedLines: plan.addedLines,
removedLines: plan.removedLines,
files: plan.files.map((file) => ({
path: file.path,
added: file.addedLines,
removed: file.removedLines,
beforeSha256: file.before.sha256,
afterSha256: file.after.sha256,
beforeExists: file.before.exists,
afterExists: file.after.exists,
beforeSnapshotPath: file.before.snapshotPath,
afterSnapshotPath: file.after.snapshotPath,
beforeMode: file.before.mode,
afterMode: file.after.mode,
})),
directories: plan.directories,
diffPath: plan.diffPath,
reviewPath: plan.reviewPath,
};
}
function directoryFacts(plan: TransactionPlan, committed: boolean): string[] {
return plan.directories.map((directory) =>
directory.existedBefore
? `${directory.path} directory existed`
: `${directory.path} directory ${committed ? "created" : "planned"}`,
);
}
/**
* The gate request for a transaction mutation. `commit` is keyed by the plan's
* operation hash plus the before-hash of every file it touches: the same edit
* re-issued after /accept (the `commit tx_…` resume call, or an identical
* `apply` against unchanged files) matches its approval, while the same
* operations against a file that changed on disk preview a new transaction
* whose approval hash differs. `rollback` gets its own hash so approving a
* commit never silently authorises undoing it.
*/
function transactionGate(plan: TransactionPlan, resumeAction: "commit" | "rollback", force = false) {
const files = [
...plan.files.map((file) => `${file.path} +${file.addedLines}/-${file.removedLines}`),
...plan.directories.map((directory) => `${directory.path}/ mkdir`),
];
return {
kind: "transaction" as const,
operationHash:
resumeAction === "commit"
? hashOperation("transaction", {
ops: plan.operationHash,
before: plan.files.map((file) => file.before.sha256 ?? null),
})
: hashOperation("transaction", { rollback: plan.id, force }),
title: resumeAction === "commit" ? `Commit ${plan.id}` : `Roll back ${plan.id}`,
description: `${plan.summary}\n${files.join("\n")}`.slice(0, 4000),
destructive: resumeAction === "commit" ? plan.destructive : force,
...(resumeAction === "commit" && plan.destructiveCauses?.length
? { destructiveDetail: describeDestructiveCauses(plan) }
: {}),
reference: { transactionId: plan.id },
resume: {
tool: "workspace_edit",
arguments:
resumeAction === "commit"
? { action: "commit", transaction_id: plan.id }
: { action: "rollback", transaction_id: plan.id, ...(force ? { force: true } : {}) },
},
};
}
async function commitEnvelope(runtime: PluginRuntime, plan: TransactionPlan) {
return okResult({
operation: "edit.commit",
summary: plan.summary,
data: planData(plan),
artifacts: [
runtime.transactions.diffArtifact(plan),
await runtime.transactions.reviewArtifact(plan),
],
importance: "critical" as const,
// Directories first: the per-file list is unbounded (a scaffold can be 70
// files) while the compressor keeps only the first 60 facts, so anything
// after it is dropped exactly when the transaction is large.
facts: [
`transaction ${plan.id} applied`,
...directoryFacts(plan, true),
...plan.files.map((file) => `${file.path} after ${file.after.sha256 ?? "deleted"}`),
],
});
}
async function rollbackEnvelope(runtime: PluginRuntime, plan: TransactionPlan) {
return okResult({
operation: "edit.rollback",
summary: plan.summary,
data: planData(plan),
artifacts: [
runtime.transactions.diffArtifact(plan),
await runtime.transactions.reviewArtifact(plan),
],
importance: "critical" as const,
facts: [`transaction ${plan.id} rolled back`],
});
}
export function createEditTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_edit",
description:
"Transactional file editing; apply returns a transaction id and diff. Actions: preview, apply, commit, rollback, show, list. Operations: create, rewrite, replace (search + replacement), splice, copy, move, delete, mkdir. mkdir creates an empty directory; create makes parent directories automatically, so scaffold a project in one apply. Strip the `12 | ` prefix workspace_inspect read adds. Manual mode stages mutations: pending_approval + an id, report it and stop, re-issue after /accept (for edits: the resume call). Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["preview", "apply", "commit", "rollback", "show", "list"]),
operations: z.array(operationSchema).optional(),
transaction_id: z.string().optional(),
idempotency_key: z.string().optional(),
start_line: z.number().int().min(1).optional(),
max_lines: z.number().int().min(1).max(2000).optional(),
max_preview_chars: z.number().int().min(1000).max(100000).optional(),
force: z.boolean().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
action: "preview" | "apply" | "commit" | "rollback" | "show" | "list";
operations?: RawEditOperation[];
transaction_id?: string;
idempotency_key?: string;
start_line?: number;
max_lines?: number;
max_preview_chars?: number;
force?: boolean;
limit?: number;
}, ctx: ToolCallContext) => {
try {
if (input.action === "preview" || input.action === "apply") {
if (!input.operations) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_edit ${input.action} requires operations.`,
);
}
const operations = normalizeEditOperations(input.operations);
const preview = await runtime.transactions.preview(operations, {
idempotencyKey: input.idempotency_key,
maxPreviewChars: Math.min(
input.max_preview_chars ?? runtime.settings.maxToolResultChars,
runtime.settings.maxToolResultChars,
),
});
let plan = preview.plan;
let committed = false;
if (input.action === "apply") {
const planned = plan;
const gate = await applyGate(runtime, ctx, {
...transactionGate(planned, "commit"),
operation: "edit.apply",
// Resolved only when the gate stages: the allow path hashes the
// review receipt once, after the commit rewrites it.
extra: async () => ({
data: {
...planData(planned),
diffPreview: preview.diffPreview,
diffTruncated: preview.diffTruncated,
diffOmittedChars: preview.diffOmittedChars,
committed: false,
},
artifacts: [
preview.artifact,
await runtime.transactions.reviewArtifact(planned),
],
facts: [`transaction ${planned.id} planned`],
omit: ["data.diffPreview"],
}),
});
if (gate.outcome === "stage") return gate.result;
plan = await runtime.transactions.commit(plan.id);
await consumeApproval(runtime, ctx, gate, { transactionId: plan.id });
committed = true;
}
const reviewArtifact = await runtime.transactions.reviewArtifact(plan);
return okResult({
operation: committed ? "edit.commit" : `edit.${input.action}`,
summary: plan.summary,
data: {
...planData(plan),
diffPreview: preview.diffPreview,
diffTruncated: preview.diffTruncated,
diffOmittedChars: preview.diffOmittedChars,
committed,
},
artifacts: [preview.artifact, reviewArtifact],
importance: "critical",
// The bounded facts (permission mode, directories) come before the
// per-file list, which is as long as the transaction is: at 70
// files the compressor's 60-fact cap used to drop every directory
// fact and the mode fact — the two a model needs to know what state
// it is in — while keeping the 60th file's line counts.
facts: [
`transaction ${plan.id} ${plan.status}`,
...modeFacts(runtime),
...directoryFacts(plan, committed),
...plan.files.map((file) => `${file.path} +${file.addedLines}/-${file.removedLines}`),
],
omit: ["data.diffPreview"],
});
}
if (input.action === "list") {
const plans = await runtime.transactions.list(input.limit ?? 20);
return okResult({
operation: "edit.list",
summary: `Listed ${plans.length} transaction(s).`,
data: plans.map(planListData),
facts: plans.slice(0, 10).map((plan) => `${plan.id} ${plan.status}`),
});
}
if (!input.transaction_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_edit ${input.action} requires transaction_id.`,
);
}
if (input.action === "show") {
const [plan, diff] = await Promise.all([
runtime.transactions.get(input.transaction_id),
runtime.transactions.readDiff(
input.transaction_id,
input.start_line ?? 1,
input.max_lines ?? 300,
),
]);
const reviewArtifact = await runtime.transactions.reviewArtifact(plan);
return okResult({
operation: "edit.show",
summary: `${plan.id}: ${plan.summary} Showing diff lines ${diff.startLine}-${diff.endLine} of ${diff.totalLines}.`,
data: { ...planData(plan), diff },
artifacts: [runtime.transactions.diffArtifact(plan), reviewArtifact],
importance: "high",
facts: [`transaction ${plan.id} ${plan.status}`],
omit: ["data.diff.text"],
});
}
if (input.action === "commit") {
const existing = await runtime.transactions.get(input.transaction_id);
// Re-committing an applied transaction is a no-op the model reaches
// after compaction; it must not stage a fresh approval.
if (existing.status === "applied") return await commitEnvelope(runtime, existing);
const gate = await applyGate(runtime, ctx, {
...transactionGate(existing, "commit"),
operation: "edit.commit",
extra: async () => ({
data: planData(existing),
artifacts: [
runtime.transactions.diffArtifact(existing),
await runtime.transactions.reviewArtifact(existing),
],
}),
});
if (gate.outcome === "stage") return gate.result;
const plan = await runtime.transactions.commit(existing.id);
await consumeApproval(runtime, ctx, gate, { transactionId: plan.id });
return await commitEnvelope(runtime, plan);
}
const existing = await runtime.transactions.get(input.transaction_id);
if (input.force && !runtime.settings.allowDestructiveEdits) {
throw new AgenticError(
"PROTECTED_PATH",
`Forced rollback of ${existing.id} can overwrite later user changes. Enable destructive commits before using force.`,
{ transactionId: existing.id },
);
}
// Re-rolling back is a no-op the model reaches after compaction.
if (existing.status === "rolled_back") return await rollbackEnvelope(runtime, existing);
const gate = await applyGate(runtime, ctx, {
...transactionGate(existing, "rollback", input.force === true),
operation: "edit.rollback",
extra: () => ({ data: planData(existing) }),
});
if (gate.outcome === "stage") return gate.result;
const plan = await runtime.transactions.rollback(input.transaction_id, {
force: input.force,
});
await consumeApproval(runtime, ctx, gate, { transactionId: plan.id });
return await rollbackEnvelope(runtime, plan);
} catch (error) {
return errorResult(`edit.${input.action}`, error, "critical");
}
},
});
}
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { errorResult, okResult } from "../core/result";
import { applyGate, consumeApproval, hashOperation, modeFacts } from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
import {
describeDestructiveCauses,
normalizeEditOperations,
type RawEditOperation,
type TransactionPlan,
} from "../workspace/transactions";
const operationSchema = z.object({
type: z.enum(["create", "rewrite", "replace", "splice", "delete", "move", "copy", "mkdir"]),
path: z.string().optional(),
content: z.string().optional(),
overwrite: z.boolean().optional(),
create_if_missing: z.boolean().optional(),
search: z.string().optional(),
replacement: z.string().optional(),
expected_matches: z.number().int().optional(),
start_line: z.number().int().optional(),
delete_count: z.number().int().optional(),
ignore_missing: z.boolean().optional(),
from: z.string().optional(),
to: z.string().optional(),
allow_line_numbers: z.boolean().optional(),
});
/**
* The listing shape shared by `workspace_edit list` and `workspace_inspect
* changes`. Directories are counted and named alongside files: a `mkdir`-only
* transaction has no files at all, so a files-only entry reported `fileCount:
* 0` and read as a transaction that changed nothing. Both lists are capped at
* 20 paths — this is the recovery index, not bulk content.
*/
export function transactionListEntry(plan: TransactionPlan) {
return {
status: plan.status,
summary: plan.summary,
fileCount: plan.files.length,
files: plan.files.slice(0, 20).map((file) => file.path),
filesTruncated: plan.files.length > 20,
directoryCount: plan.directories.length,
directories: plan.directories.slice(0, 20).map((directory) => directory.path),
directoriesTruncated: plan.directories.length > 20,
updatedAt: plan.updatedAt,
diffPath: plan.diffPath,
reviewPath: plan.reviewPath,
};
}
function planListData(plan: TransactionPlan) {
return {
transactionId: plan.id,
destructive: plan.destructive,
requiresReview: plan.requiresReview,
addedLines: plan.addedLines,
removedLines: plan.removedLines,
...transactionListEntry(plan),
};
}
function planData(plan: TransactionPlan) {
return {
transactionId: plan.id,
status: plan.status,
summary: plan.summary,
destructive: plan.destructive,
requiresReview: plan.requiresReview,
addedLines: plan.addedLines,
removedLines: plan.removedLines,
files: plan.files.map((file) => ({
path: file.path,
added: file.addedLines,
removed: file.removedLines,
beforeSha256: file.before.sha256,
afterSha256: file.after.sha256,
beforeExists: file.before.exists,
afterExists: file.after.exists,
beforeSnapshotPath: file.before.snapshotPath,
afterSnapshotPath: file.after.snapshotPath,
beforeMode: file.before.mode,
afterMode: file.after.mode,
})),
directories: plan.directories,
diffPath: plan.diffPath,
reviewPath: plan.reviewPath,
};
}
function directoryFacts(plan: TransactionPlan, committed: boolean): string[] {
return plan.directories.map((directory) =>
directory.existedBefore
? `${directory.path} directory existed`
: `${directory.path} directory ${committed ? "created" : "planned"}`,
);
}
/**
* The gate request for a transaction mutation. `commit` is keyed by the plan's
* operation hash plus the before-hash of every file it touches: the same edit
* re-issued after /accept (the `commit tx_…` resume call, or an identical
* `apply` against unchanged files) matches its approval, while the same
* operations against a file that changed on disk preview a new transaction
* whose approval hash differs. `rollback` gets its own hash so approving a
* commit never silently authorises undoing it.
*/
function transactionGate(plan: TransactionPlan, resumeAction: "commit" | "rollback", force = false) {
const files = [
...plan.files.map((file) => `${file.path} +${file.addedLines}/-${file.removedLines}`),
...plan.directories.map((directory) => `${directory.path}/ mkdir`),
];
return {
kind: "transaction" as const,
operationHash:
resumeAction === "commit"
? hashOperation("transaction", {
ops: plan.operationHash,
before: plan.files.map((file) => file.before.sha256 ?? null),
})
: hashOperation("transaction", { rollback: plan.id, force }),
title: resumeAction === "commit" ? `Commit ${plan.id}` : `Roll back ${plan.id}`,
description: `${plan.summary}\n${files.join("\n")}`.slice(0, 4000),
destructive: resumeAction === "commit" ? plan.destructive : force,
...(resumeAction === "commit" && plan.destructiveCauses?.length
? { destructiveDetail: describeDestructiveCauses(plan) }
: {}),
reference: { transactionId: plan.id },
resume: {
tool: "workspace_edit",
arguments:
resumeAction === "commit"
? { action: "commit", transaction_id: plan.id }
: { action: "rollback", transaction_id: plan.id, ...(force ? { force: true } : {}) },
},
};
}
async function commitEnvelope(runtime: PluginRuntime, plan: TransactionPlan) {
return okResult({
operation: "edit.commit",
summary: plan.summary,
data: planData(plan),
artifacts: [
runtime.transactions.diffArtifact(plan),
await runtime.transactions.reviewArtifact(plan),
],
importance: "critical" as const,
// Directories first: the per-file list is unbounded (a scaffold can be 70
// files) while the compressor keeps only the first 60 facts, so anything
// after it is dropped exactly when the transaction is large.
facts: [
`transaction ${plan.id} applied`,
...directoryFacts(plan, true),
...plan.files.map((file) => `${file.path} after ${file.after.sha256 ?? "deleted"}`),
],
});
}
async function rollbackEnvelope(runtime: PluginRuntime, plan: TransactionPlan) {
return okResult({
operation: "edit.rollback",
summary: plan.summary,
data: planData(plan),
artifacts: [
runtime.transactions.diffArtifact(plan),
await runtime.transactions.reviewArtifact(plan),
],
importance: "critical" as const,
facts: [`transaction ${plan.id} rolled back`],
});
}
export function createEditTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_edit",
description:
"Transactional file editing; apply returns a transaction id and diff. Actions: preview, apply, commit, rollback, show, list. Operations: create, rewrite, replace (search + replacement), splice, copy, move, delete, mkdir. mkdir creates an empty directory; create makes parent directories automatically, so scaffold a project in one apply. Strip the `12 | ` prefix workspace_inspect read adds. Manual mode stages mutations: pending_approval + an id, report it and stop, re-issue after /accept (for edits: the resume call). Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["preview", "apply", "commit", "rollback", "show", "list"]),
operations: z.array(operationSchema).optional(),
transaction_id: z.string().optional(),
idempotency_key: z.string().optional(),
start_line: z.number().int().min(1).optional(),
max_lines: z.number().int().min(1).max(2000).optional(),
max_preview_chars: z.number().int().min(1000).max(100000).optional(),
force: z.boolean().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
action: "preview" | "apply" | "commit" | "rollback" | "show" | "list";
operations?: RawEditOperation[];
transaction_id?: string;
idempotency_key?: string;
start_line?: number;
max_lines?: number;
max_preview_chars?: number;
force?: boolean;
limit?: number;
}, ctx: ToolCallContext) => {
try {
if (input.action === "preview" || input.action === "apply") {
if (!input.operations) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_edit ${input.action} requires operations.`,
);
}
const operations = normalizeEditOperations(input.operations);
const preview = await runtime.transactions.preview(operations, {
idempotencyKey: input.idempotency_key,
maxPreviewChars: Math.min(
input.max_preview_chars ?? runtime.settings.maxToolResultChars,
runtime.settings.maxToolResultChars,
),
});
let plan = preview.plan;
let committed = false;
if (input.action === "apply") {
const planned = plan;
const gate = await applyGate(runtime, ctx, {
...transactionGate(planned, "commit"),
operation: "edit.apply",
// Resolved only when the gate stages: the allow path hashes the
// review receipt once, after the commit rewrites it.
extra: async () => ({
data: {
...planData(planned),
diffPreview: preview.diffPreview,
diffTruncated: preview.diffTruncated,
diffOmittedChars: preview.diffOmittedChars,
committed: false,
},
artifacts: [
preview.artifact,
await runtime.transactions.reviewArtifact(planned),
],
facts: [`transaction ${planned.id} planned`],
omit: ["data.diffPreview"],
}),
});
if (gate.outcome === "stage") return gate.result;
plan = await runtime.transactions.commit(plan.id);
await consumeApproval(runtime, ctx, gate, { transactionId: plan.id });
committed = true;
}
const reviewArtifact = await runtime.transactions.reviewArtifact(plan);
return okResult({
operation: committed ? "edit.commit" : `edit.${input.action}`,
summary: plan.summary,
data: {
...planData(plan),
diffPreview: preview.diffPreview,
diffTruncated: preview.diffTruncated,
diffOmittedChars: preview.diffOmittedChars,
committed,
},
artifacts: [preview.artifact, reviewArtifact],
importance: "critical",
// The bounded facts (permission mode, directories) come before the
// per-file list, which is as long as the transaction is: at 70
// files the compressor's 60-fact cap used to drop every directory
// fact and the mode fact — the two a model needs to know what state
// it is in — while keeping the 60th file's line counts.
facts: [
`transaction ${plan.id} ${plan.status}`,
...modeFacts(runtime),
...directoryFacts(plan, committed),
...plan.files.map((file) => `${file.path} +${file.addedLines}/-${file.removedLines}`),
],
omit: ["data.diffPreview"],
});
}
if (input.action === "list") {
const plans = await runtime.transactions.list(input.limit ?? 20);
return okResult({
operation: "edit.list",
summary: `Listed ${plans.length} transaction(s).`,
data: plans.map(planListData),
facts: plans.slice(0, 10).map((plan) => `${plan.id} ${plan.status}`),
});
}
if (!input.transaction_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_edit ${input.action} requires transaction_id.`,
);
}
if (input.action === "show") {
const [plan, diff] = await Promise.all([
runtime.transactions.get(input.transaction_id),
runtime.transactions.readDiff(
input.transaction_id,
input.start_line ?? 1,
input.max_lines ?? 300,
),
]);
const reviewArtifact = await runtime.transactions.reviewArtifact(plan);
return okResult({
operation: "edit.show",
summary: `${plan.id}: ${plan.summary} Showing diff lines ${diff.startLine}-${diff.endLine} of ${diff.totalLines}.`,
data: { ...planData(plan), diff },
artifacts: [runtime.transactions.diffArtifact(plan), reviewArtifact],
importance: "high",
facts: [`transaction ${plan.id} ${plan.status}`],
omit: ["data.diff.text"],
});
}
if (input.action === "commit") {
const existing = await runtime.transactions.get(input.transaction_id);
// Re-committing an applied transaction is a no-op the model reaches
// after compaction; it must not stage a fresh approval.
if (existing.status === "applied") return await commitEnvelope(runtime, existing);
const gate = await applyGate(runtime, ctx, {
...transactionGate(existing, "commit"),
operation: "edit.commit",
extra: async () => ({
data: planData(existing),
artifacts: [
runtime.transactions.diffArtifact(existing),
await runtime.transactions.reviewArtifact(existing),
],
}),
});
if (gate.outcome === "stage") return gate.result;
const plan = await runtime.transactions.commit(existing.id);
await consumeApproval(runtime, ctx, gate, { transactionId: plan.id });
return await commitEnvelope(runtime, plan);
}
const existing = await runtime.transactions.get(input.transaction_id);
if (input.force && !runtime.settings.allowDestructiveEdits) {
throw new AgenticError(
"PROTECTED_PATH",
`Forced rollback of ${existing.id} can overwrite later user changes. Enable destructive commits before using force.`,
{ transactionId: existing.id },
);
}
// Re-rolling back is a no-op the model reaches after compaction.
if (existing.status === "rolled_back") return await rollbackEnvelope(runtime, existing);
const gate = await applyGate(runtime, ctx, {
...transactionGate(existing, "rollback", input.force === true),
operation: "edit.rollback",
extra: () => ({ data: planData(existing) }),
});
if (gate.outcome === "stage") return gate.result;
const plan = await runtime.transactions.rollback(input.transaction_id, {
force: input.force,
});
await consumeApproval(runtime, ctx, gate, { transactionId: plan.id });
return await rollbackEnvelope(runtime, plan);
} catch (error) {
return errorResult(`edit.${input.action}`, error, "critical");
}
},
});
}