diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 2a59d0c5fd..84d86ffef8 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -12,6 +12,7 @@ import { } from "./provider-settings.js" import { telemetrySettingsSchema } from "./telemetry.js" import { toolNamesSchema } from "./tool.js" +import { changeCardDetailSchema, type ChangeCardDetail } from "./message.js" import { type Keys } from "./type-fu.js" import { languagesSchema } from "./vscode.js" import { providerIdentifiers } from "./provider-identifiers.js" @@ -111,6 +112,21 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Whether per-write checkpoints and task-start baseline are enabled by default. + * Master switch for the B cluster of checkpoint features. + * @default true + */ +export const DEFAULT_PER_WRITE_CHECKPOINTS = true + +/** + * Default detail level for per-step change cards (B3a). + * "summary" keeps cards compact (file list with +/− counts; the UI fetches + * diffs lazily); "full" carries the unified diff inline per file. + * @default "summary" + */ +export const DEFAULT_CHANGE_CARD_DETAIL: ChangeCardDetail = "summary" + /** * GlobalSettings */ @@ -220,6 +236,19 @@ export const globalSettingsSchema = z.object({ .min(MIN_CHECKPOINT_TIMEOUT_SECONDS) .max(MAX_CHECKPOINT_TIMEOUT_SECONDS) .optional(), + /** + * Whether to record a shadow-git checkpoint after every successful write_to_file, + * edit_file, and apply_patch (per-write checkpoints), plus a task-start baseline. + * @default true + */ + perWriteCheckpoints: z.boolean().optional(), + /** + * Detail level for per-step change cards: "full" includes the unified diff + * inline for every changed file, "summary" carries only the file list with + * +/− counts (diffs are fetched lazily by the UI). + * @default "summary" + */ + changeCardDetail: changeCardDetailSchema.optional(), ttsEnabled: z.boolean().optional(), ttsSpeed: z.number().optional(), diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 47cc1ec796..b8d282cb60 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -134,6 +134,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk { * - `mcp_server_response`: Response received from MCP server * - `subtask_result`: Result of a completed subtask * - `checkpoint_saved`: Indicates a checkpoint has been saved + * - `change_card`: Per-step change card summarizing the files a completed tool step wrote (B3a) * - `rooignore_error`: Error related to .rooignore file processing * - `diff_error`: Error occurred while applying a diff/patch * - `condense_context`: Context condensation/summarization has started @@ -163,6 +164,7 @@ export const clineSays = [ "mcp_server_response", "subtask_result", "checkpoint_saved", + "change_card", "rooignore_error", "diff_error", "condense_context", @@ -237,6 +239,49 @@ export const contextTruncationSchema = z.object({ export type ContextTruncation = z.infer +/** + * ChangeCard + * + * Payload of the per-step change card (B3a). The extension host emits one + * `say: "change_card"` message per completed tool write step, keyed by the + * shadow-git checkpoint the step produced. The JSON payload (see + * {@link ChangeCardData}) is carried in the message `text` field, the same + * way tool approval messages carry their serialized ClineSayTool. + * + * `detail: "full"` carries the unified diff inline for every file so the UI + * can render it directly; `detail: "summary"` carries only the file list with + * +/− counts and the UI fetches diffs lazily (B3b). Auto-approved steps are + * always emitted with `detail: "summary"` regardless of the user setting. + */ +export const changeCardDetailSchema = z.enum(["full", "summary"]) + +export type ChangeCardDetail = z.infer + +export const changeCardFileSchema = z.object({ + path: z.string(), + additions: z.number(), + deletions: z.number(), + /** + * Unified diff for this file. Only present when the card was emitted with + * `detail: "full"`; summary cards leave it out to stay compact. + */ + diff: z.string().optional(), +}) + +export type ChangeCardFile = z.infer + +export const changeCardSchema = z.object({ + /** Opaque step identifier, reserved for future tool-step tracking. */ + stepId: z.string().optional(), + /** Checkpoint commit SHAs produced by the step (one per per-write checkpoint). */ + checkpointIds: z.array(z.string()), + files: z.array(changeCardFileSchema), + totalFiles: z.number(), + detail: changeCardDetailSchema, +}) + +export type ChangeCardData = z.infer + /** * ClineMessage * diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 51671d895c..7b608cb0bf 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -5,7 +5,7 @@ import type { ProviderSettings, ProviderSettingsEntry } from "./provider-setting import type { HistoryItem } from "./history.js" import type { ModeConfig, PromptComponent } from "./mode.js" import type { Experiments } from "./experiment.js" -import type { ClineMessage, QueuedMessage } from "./message.js" +import type { ChangeCardDetail, ClineMessage, QueuedMessage } from "./message.js" import type { MarketplaceItem, MarketplaceInstalledMetadata, InstallMarketplaceItemOptions } from "./marketplace.js" import type { TodoItem } from "./todo.js" import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, ShareVisibility } from "./cloud.js" @@ -111,6 +111,7 @@ export interface ExtensionMessage { | "originalContent" | "rooHistoryImportProgress" | "themeFixtureProbeRequest" + | "checkpointRollbackResult" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -268,6 +269,34 @@ export interface ExtensionMessage { copyProgressItemName?: string // folderSelected path?: string + /** For checkpointRollbackResult: outcome of a change-card rollback request (B3b). */ + checkpointRollbackResult?: CheckpointRollbackResult +} + +/** + * CheckpointRollbackResult + * + * Outcome of a change-card restore request (B3b), posted back to the webview + * that sent `checkpointRollbackFile` / `checkpointRollbackStep` / + * `checkpointRestoreLatestFile`. `cardTs` echoes the change-card message + * timestamp so the requesting card can correlate the result: per-file results + * carry `filePath`, per-step results carry the per-file outcomes in + * `files`, and `kind` tells a per-file result which control it belongs to + * (absent = rollback, so results posted before `kind` existed still route). + */ +export interface CheckpointRollbackResult { + /** The `ts` of the change_card message the result belongs to. */ + cardTs: number + /** Per-file scope: the file that was restored. */ + filePath?: string + success: boolean + error?: string + /** Per-step scope: the per-file outcomes. */ + files?: { filePath: string; success: boolean; error?: string }[] + /** Per-file scope: which control the result belongs to. Absent = rollback. */ + kind?: "rollback" | "restore-latest" + /** Per-file scope: true when a restore-latest found no recorded write and left the file as-is. */ + noOp?: boolean } export interface OpenAiCodexRateLimitsMessage { @@ -363,6 +392,8 @@ export type ExtensionState = Pick< enableCheckpoints: boolean checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15) + perWriteCheckpoints: boolean + changeCardDetail: ChangeCardDetail maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings @@ -565,6 +596,9 @@ export interface WebviewMessage { | "openCustomModesSettings" | "checkpointDiff" | "checkpointRestore" + | "checkpointRollbackFile" + | "checkpointRollbackStep" + | "checkpointRestoreLatestFile" | "completionCheckpointDiff" | "completionCheckpointRestore" | "deleteMcpServer" @@ -808,6 +842,46 @@ export const checkoutRestorePayloadSchema = z.object({ export type CheckpointRestorePayload = z.infer +/** + * Payload of the `checkpointRollbackFile` webview message (B3b): restore one + * change-card file to the checkpoint commit the card was keyed by. + */ +export const checkpointRollbackFilePayloadSchema = z.object({ + /** The `ts` of the change_card message the request comes from (echoed on the result). */ + cardTs: z.number(), + checkpointId: z.string(), + filePath: z.string(), +}) + +export type CheckpointRollbackFilePayload = z.infer + +/** + * Payload of the `checkpointRollbackStep` webview message (B3b): restore + * every file of a change-card step to the step's checkpoint. + */ +export const checkpointRollbackStepPayloadSchema = z.object({ + cardTs: z.number(), + /** The step's checkpoint commit (the card's first checkpointId); optional. */ + checkpointId: z.string().optional(), + filePaths: z.array(z.string()).min(1), +}) + +export type CheckpointRollbackStepPayload = z.infer + +/** + * Payload of the `checkpointRestoreLatestFile` webview message (B3b): restore + * one change-card file to the latest recorded version of that file (the + * content of its most recent write checkpoint — the forward direction to a + * rollback). + */ +export const checkpointRestoreLatestFilePayloadSchema = z.object({ + /** The `ts` of the change_card message the request comes from (echoed on the result). */ + cardTs: z.number(), + filePath: z.string(), +}) + +export type CheckpointRestoreLatestFilePayload = z.infer + export interface IndexingStatusPayload { state: "Standby" | "Indexing" | "Indexed" | "Error" | "Stopping" message: string @@ -821,6 +895,9 @@ export interface IndexClearedPayload { export type WebViewMessagePayload = | CheckpointDiffPayload | CheckpointRestorePayload + | CheckpointRollbackFilePayload + | CheckpointRollbackStepPayload + | CheckpointRestoreLatestFilePayload | IndexingStatusPayload | IndexClearedPayload | UpdateTodoListPayload diff --git a/src/core/checkpoints/__tests__/changeCard.spec.ts b/src/core/checkpoints/__tests__/changeCard.spec.ts new file mode 100644 index 0000000000..82190638a1 --- /dev/null +++ b/src/core/checkpoints/__tests__/changeCard.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest" + +import type { ChangeCardData } from "@roo-code/types" + +import { + buildChangeCard, + buildChangeCardPayload, + isAutoApprovedStep, + resolveChangeCardDetail, + type ChangeCardWrite, +} from "../changeCard" + +describe("changeCard (B3a)", () => { + function write(overrides: Partial = {}): ChangeCardWrite { + return { + path: "src/a.ts", + diffStats: { additions: 2, deletions: 1 }, + diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2", + ...overrides, + } + } + + describe("isAutoApprovedStep", () => { + it("returns false for an empty step", () => { + expect(isAutoApprovedStep([])).toBe(false) + }) + + it("returns true only when every write was auto-approved", () => { + expect(isAutoApprovedStep([write({ autoApproved: true }), write({ autoApproved: true })])).toBe(true) + expect(isAutoApprovedStep([write({ autoApproved: true }), write()])).toBe(false) + expect(isAutoApprovedStep([write()])).toBe(false) + }) + }) + + describe("resolveChangeCardDetail", () => { + it("forces summary for auto-approved steps even when the setting is full", () => { + const writes = [write({ autoApproved: true })] + expect(resolveChangeCardDetail(writes, "full")).toBe("summary") + expect(resolveChangeCardDetail(writes, undefined)).toBe("summary") + }) + + it("follows the setting for interactive steps, defaulting to summary when unset", () => { + const writes = [write()] + expect(resolveChangeCardDetail(writes, "full")).toBe("full") + expect(resolveChangeCardDetail(writes, "summary")).toBe("summary") + expect(resolveChangeCardDetail(writes, undefined)).toBe("summary") + }) + }) + + describe("buildChangeCard", () => { + it("carries the inline diff per file for full detail on a multi-file step", () => { + const card = buildChangeCard( + "sha-1", + [write(), write({ path: "src/b.ts", diffStats: { additions: 1, deletions: 0 }, diff: "+b" })], + "full", + ) + + expect(card).toEqual({ + checkpointIds: ["sha-1"], + files: [ + { + path: "src/a.ts", + additions: 2, + deletions: 1, + diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2", + }, + { path: "src/b.ts", additions: 1, deletions: 0, diff: "+b" }, + ], + totalFiles: 2, + detail: "full", + }) + }) + + it("omits the diff per file for summary detail (lazy fetch is B3b)", () => { + const card = buildChangeCard("sha-1", [write()], "summary") + + expect(card.files).toEqual([{ path: "src/a.ts", additions: 2, deletions: 1 }]) + expect(card.files[0]).not.toHaveProperty("diff") + expect(card.detail).toBe("summary") + expect(card.totalFiles).toBe(1) + }) + + it("defaults missing diffStats to zero counts and keeps full detail without diff for a write without one", () => { + const card = buildChangeCard("sha-1", [write({ diffStats: undefined, diff: undefined })], "full") + + expect(card.files[0]).toEqual({ path: "src/a.ts", additions: 0, deletions: 0 }) + }) + }) + + describe("buildChangeCardPayload", () => { + it("resolves the detail level and builds the payload in one call", () => { + // The expectations are typed against the shared ChangeCardData + // contract in @roo-code/types, so the builder's output is checked + // against the same single source of truth the webview consumes. + // Interactive step with the full setting: diff inline. + const full: ChangeCardData = buildChangeCardPayload("sha-1", [write()], "full") + expect(full.detail).toBe("full") + expect(full.files[0].diff).toBe("--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2") + + // Auto-approved step with the full setting: compact summary, no diff. + const compact: ChangeCardData = buildChangeCardPayload("sha-1", [write({ autoApproved: true })], "full") + expect(compact.detail).toBe("summary") + expect(compact.files[0]).not.toHaveProperty("diff") + expect(compact.checkpointIds).toEqual(["sha-1"]) + expect(compact.totalFiles).toBe(1) + }) + }) +}) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts new file mode 100644 index 0000000000..5fb7422f21 --- /dev/null +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -0,0 +1,517 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { appendChanges, journalPath, journalQueueDepth, loadChanges, type ChangeJournalEntry } from "../changeJournal" + +// appendChange (single-entry wrapper) is production code only in B2; these +// units journal batch writes, so the spec keeps a local helper. +const appendChange = (globalStorageDir: string, taskId: string, entry: ChangeJournalEntry) => + appendChanges(globalStorageDir, taskId, [entry]) + +describe("changeJournal", () => { + const taskId = "test-task" + + let tmpRoot: string + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-")) + }) + + afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }) + }) + + function entry(overrides: Partial = {}): ChangeJournalEntry { + return { + path: "src/foo.ts", + operation: "create", + checkpointId: "abc12345", + ...overrides, + } + } + + describe("appendChange", () => { + it("writes one JSON line per entry with the documented field shape", async () => { + await appendChange(tmpRoot, taskId, entry({ operation: "create", checkpointId: "aaa" })) + + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + const lines = raw.split("\n").filter((line) => line !== "") + expect(lines).toHaveLength(1) + const parsed = JSON.parse(lines[0]) as ChangeJournalEntry + expect(parsed.path).toBe("src/foo.ts") + expect(parsed.operation).toBe("create") + expect(parsed.checkpointId).toBe("aaa") + }) + + it("appends multiple entries sequentially", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" })) + + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) + }) + + it("rejects an append when the final record is torn instead of truncating it", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + // Simulate a crash that tore the last append mid-write: the file now + // ends with an unterminated JSON fragment. That fragment may be the + // truncated journal record of a committed write, so the append must + // not silently truncate it away and continue as if the history + // were complete. + const filePath = journalPath(tmpRoot, taskId) + const raw = await fs.readFile(filePath, "utf8") + await fs.writeFile(filePath, raw + '{"path":"src/half.ts","operation":"upd') + + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toThrow( + /torn-tail recovery requires explicit repair/, + ) + + // The journal is left untouched for an explicit recovery: neither + // the fragment was dropped nor the new entry added. + const after = await fs.readFile(filePath, "utf8") + expect(after).toBe(raw + '{"path":"src/half.ts","operation":"upd') + }) + + it("rejects an append when a malformed final record kept its newline", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + // A final line of valid JSON that is not an entry (a bare null) is the + // same hazard with or without its newline: accepting it here would + // demote it to a skipped middle line after the append, so loadChanges + // would report an incomplete rollback history as valid and + // restore-latest could pick an older entry for the same file. + const filePath = journalPath(tmpRoot, taskId) + const raw = await fs.readFile(filePath, "utf8") + await fs.writeFile(filePath, raw + "null\n") + + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toThrow( + /final record is not a complete entry/, + ) + + // The journal is left untouched for an explicit recovery. + const after = await fs.readFile(filePath, "utf8") + expect(after).toBe(raw + "null\n") + }) + + it("does not insert a blank line when the journal ends cleanly", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" })) + + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + expect(raw).not.toContain("\n\n") + expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) + }) + + it("syncs the parent directory after every journal replacement (POSIX)", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + const dirPath = path.dirname(journalPath(tmpRoot, taskId)) + const realOpen = fs.open + const synced: string[] = [] + const closed: string[] = [] + // Windows cannot open directories, so the directory handle is doubled + // here: the code under test only opens it on non-win32 platforms. + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { + const openAs = realOpen as (p: string | URL, f?: string) => Promise + const p = typeof pathish === "string" ? pathish : String(pathish) + if (p === dirPath && flags === "r") { + return { + sync: async () => { + synced.push(p) + }, + close: async () => { + closed.push(p) + }, + } as unknown as fs.FileHandle + } + return openAs(p, flags as string | undefined) + }) + try { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + expect(synced).toEqual([dirPath]) + expect(closed).toEqual([dirPath]) + // Every replacement renames a new file into place, so the directory + // entry is re-synced on each update. + await appendChange(tmpRoot, taskId, entry({ checkpointId: "second" })) + expect(synced).toHaveLength(2) + } finally { + openSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["first", "second"]) + }) + + it("propagates a non-ENOENT journal-read failure instead of replacing blind", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + // The current content cannot be read (I/O error): replacing would + // discard whatever the unreadable journal really held, so appendChange + // must fail loudly. + const readSpy = vi + .spyOn(fs, "readFile") + .mockRejectedValueOnce(Object.assign(new Error("simulated I/O error"), { code: "EIO" })) + try { + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "after" }))).rejects.toMatchObject({ + code: "EIO", + }) + } finally { + readSpy.mockRestore() + } + // The failed append wrote nothing: the next append still loads and appends. + await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["good", "after"]) + }) + + it("writes the full record when the underlying write completes partially", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + const realOpen = fs.open + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { + const openAs = realOpen as (p: string | URL, f?: string) => Promise + const handle = await openAs(pathish as string | URL, flags as string | undefined) + if (flags === "w") { + const realWrite = handle.write.bind(handle) + let first = true + handle.write = (async (buf: Uint8Array, off?: number, len?: number) => { + const length = len ?? buf.length + if (first && length > 1) { + first = false + await realWrite(buf, off, 1) + return { bytesWritten: 1, buffer: Buffer.from(buf) } + } + return realWrite(buf, off, length) + }) as typeof handle.write + } + return handle + }) + try { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "second" })) + } finally { + openSpy.mockRestore() + } + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["first", "second"]) + }) + + it("preserves a complete final record that lost only its newline", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "good" })) + // Simulate a crash that flushed a complete record but lost its + // terminating newline: the record is entry-shaped and parseable, so + // the repair must keep it and re-terminate instead of truncating. + const filePath = journalPath(tmpRoot, taskId) + const raw = await fs.readFile(filePath, "utf8") + await fs.writeFile(filePath, raw + JSON.stringify(entry({ checkpointId: "complete" }))) + + await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["good", "complete", "after"]) + }) + + it("retries a Windows rename rejected with EPERM", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "win32", configurable: true }) + const realRename = fs.rename + let attempts = 0 + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (from, to) => { + attempts++ + if (attempts === 1) { + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + } + await realRename(from as string | URL, to as string | URL) + }) + try { + // An antivirus/indexer transiently holding the destination must + // not fail the append: the rename is retried with a short backoff. + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + expect(attempts).toBe(2) + } finally { + renameSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["first"]) + }) + + it("does not retry a POSIX rename rejection", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + let attempts = 0 + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async () => { + attempts++ + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + }) + try { + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "x" }))).rejects.toMatchObject({ + code: "EPERM", + }) + expect(attempts).toBe(1) + } finally { + renameSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + }) + + it("tolerates an unsupported directory-sync errno after the rename (POSIX)", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + const dirPath = path.dirname(journalPath(tmpRoot, taskId)) + const realOpen = fs.open + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { + const openAs = realOpen as (p: string | URL, f?: string) => Promise + const p = typeof pathish === "string" ? pathish : String(pathish) + if (p === dirPath && flags === "r") { + return { + sync: async () => { + throw Object.assign(new Error("EINVAL: invalid argument"), { code: "EINVAL" }) + }, + close: async () => {}, + } as unknown as fs.FileHandle + } + return openAs(p, flags as string | undefined) + }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + // The rename already replaced the journal: an fsync-unsupported + // mount must not fail the update, only reduce crash durability. + await appendChange(tmpRoot, taskId, entry({ checkpointId: "kept" })) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("directory sync unsupported")) + } finally { + openSpy.mockRestore() + warnSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["kept"]) + }) + + it("propagates a genuine directory-sync failure (POSIX)", async () => { + const platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "linux", configurable: true }) + const dirPath = path.dirname(journalPath(tmpRoot, taskId)) + const realOpen = fs.open + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (pathish, flags) => { + const openAs = realOpen as (p: string | URL, f?: string) => Promise + const p = typeof pathish === "string" ? pathish : String(pathish) + if (p === dirPath && flags === "r") { + return { + sync: async () => { + throw Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) + }, + close: async () => {}, + } as unknown as fs.FileHandle + } + return openAs(p, flags as string | undefined) + }) + try { + // A real I/O failure is not an "unsupported" case: it still + // propagates so callers know durability is in doubt. + await expect(appendChange(tmpRoot, taskId, entry({ checkpointId: "x" }))).rejects.toMatchObject({ + code: "EIO", + }) + } finally { + openSpy.mockRestore() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + } + }) + + it("serializes concurrent updates to the same task", async () => { + await Promise.all([ + appendChange(tmpRoot, taskId, entry({ checkpointId: "c1" })), + appendChange(tmpRoot, taskId, entry({ checkpointId: "c2" })), + ]) + + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId).sort()).toEqual(["c1", "c2"]) + }) + + it("leaves the journal untouched when the atomic rename fails mid-update", async () => { + await appendChanges(tmpRoot, taskId, [entry({ checkpointId: "kept" })]) + const renameSpy = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("simulated rename failure")) + try { + await expect( + appendChanges(tmpRoot, taskId, [ + entry({ checkpointId: "m1", path: "src/m1.ts" }), + entry({ checkpointId: "m2", path: "src/m2.ts" }), + ]), + ).rejects.toThrow("simulated rename failure") + } finally { + renameSpy.mockRestore() + } + // Neither of the two new entries survived: the update is all-or-nothing. + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["kept"]) + // The failed temp file was cleaned up. + const leftovers = (await fs.readdir(path.dirname(journalPath(tmpRoot, taskId)))).filter((name) => + name.endsWith(".tmp"), + ) + expect(leftovers).toEqual([]) + }) + + it("recovers after a crash left a stale temp file", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "before" })) + const dir = path.dirname(journalPath(tmpRoot, taskId)) + // A crash between the temp write and the rename leaves an orphan temp + // file; a crash mid-write can also leave the tail unterminated. + await fs.writeFile(path.join(dir, "changes.jsonl.4242.orphan.tmp"), "partial") + + await appendChange(tmpRoot, taskId, entry({ checkpointId: "after" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result.map((e) => e.checkpointId)).toEqual(["before", "after"]) + }) + + it("does not create a journal for an empty update", async () => { + await appendChanges(tmpRoot, taskId, []) + await expect(fs.stat(journalPath(tmpRoot, taskId))).rejects.toMatchObject({ code: "ENOENT" }) + }) + }) + + describe("task ID containment", () => { + it("rejects task IDs that could escape the tasks directory", async () => { + for (const bad of ["../../outside", "a/b", "a\\b", "", ".", ".."]) { + expect(() => journalPath(tmpRoot, bad)).toThrow(/Unsafe task ID/) + await expect(appendChange(tmpRoot, bad, entry({ checkpointId: "x" }))).rejects.toThrow(/Unsafe task ID/) + await expect(loadChanges(tmpRoot, bad)).rejects.toThrow(/Unsafe task ID/) + // Nothing was created outside the task directory. + await expect(fs.stat(path.join(tmpRoot, "..", "outside"))).rejects.toMatchObject({ code: "ENOENT" }) + } + }) + + it("keeps a safe task ID under the tasks directory", () => { + expect(journalPath(tmpRoot, "task-42")).toBe( + path.join(tmpRoot, "tasks", "task-42", "checkpoints", "changes.jsonl"), + ) + }) + }) + + describe("loadChanges", () => { + it("returns [] for an absent journal file", async () => { + expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + }) + + it("returns [] for an empty journal file", async () => { + await fs.mkdir(path.dirname(journalPath(tmpRoot, taskId)), { recursive: true }) + await fs.writeFile(journalPath(tmpRoot, taskId), "") + + expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + }) + + it("propagates non-ENOENT read failures instead of reporting an empty journal", async () => { + // A directory at the journal path makes readFile fail with EISDIR — + // a stand-in for any permission or I/O failure (EACCES etc.). Such a + // failure must not be swallowed into "no changes": it would let a + // rollback report a no-op success without reading the history. + await fs.mkdir(journalPath(tmpRoot, taskId), { recursive: true }) + + await expect(loadChanges(tmpRoot, taskId)).rejects.toMatchObject({ code: "EISDIR" }) + }) + + it("parses all entries in order with a clean tail", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "x" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "y" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "z" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(3) + expect(result[0].checkpointId).toBe("x") + expect(result[1].checkpointId).toBe("y") + expect(result[2].checkpointId).toBe("z") + }) + + it("parses a journal whose final line has no trailing newline", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Rewrite the file without the trailing newline of the last line. + const filePath = journalPath(tmpRoot, taskId) + const content = (await fs.readFile(filePath, "utf8")).replace(/\n$/, "") + await fs.writeFile(filePath, content) + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(1) + expect(result[0].checkpointId).toBe("ok") + }) + + it("rejects a torn final line instead of silently dropping it", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Append a second line truncated mid-content, with no trailing + // newline. Without verified truncation there is no evidence this + // is a crash tail rather than a corrupted record, and silently + // dropping it would report an incomplete history as valid. + await fs.appendFile(journalPath(tmpRoot, taskId), '{"path":"src/half.ts","operation":"upd') + + await expect(loadChanges(tmpRoot, taskId)).rejects.toThrow(/Corrupt change journal entry at line 2/) + }) + + it("rejects a corrupt middle line instead of silently losing history", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Corrupt the first line in place, then append a valid entry after it. + await fs.writeFile( + journalPath(tmpRoot, taskId), + '{"path":"src/corrupt.ts","operation":"update"\n' + + JSON.stringify(entry({ checkpointId: "after" })) + + "\n", + ) + + // A lost middle write must not let a later entry mask the gap: + // rollback completeness is unknown and the load fails loudly. + await expect(loadChanges(tmpRoot, taskId)).rejects.toThrow(/Corrupt change journal entry at line 1/) + }) + it("rejects a journal whose only record is torn", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + + // Truncate to a single character — definitely invalid JSON. The + // append path rejects such a tail on the next write; a read must + // not report the lost record as a complete (empty) history. + await fs.writeFile(journalPath(tmpRoot, taskId), "{") + + await expect(loadChanges(tmpRoot, taskId)).rejects.toThrow(/Corrupt change journal entry at line 1/) + }) + + it("rejects a final line that parses but is not entry-shaped", async () => { + // A torn tail cannot parse at all; a parsed-but-malformed final + // line is corruption, not a crash tail, and completeness is unknown. + await fs.mkdir(path.dirname(journalPath(tmpRoot, taskId)), { recursive: true }) + await fs.writeFile(journalPath(tmpRoot, taskId), '{"a":1}\n') + + await expect(loadChanges(tmpRoot, taskId)).rejects.toThrow(/Malformed change journal entry at line 1/) + }) + it("includes diffStats when present", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "s", diffStats: { additions: 5, deletions: 2 } })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result[0].diffStats).toEqual({ additions: 5, deletions: 2 }) + }) + + it("omits diffStats when not provided", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "n" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result[0].diffStats).toBeUndefined() + }) + }) + + describe("journal queue lifecycle", () => { + it("prunes the per-task queue entry once the update settles", async () => { + const before = journalQueueDepth() + + await appendChange(tmpRoot, taskId, entry({ checkpointId: "settled" })) + // The pruning hook runs one microtask after the caller's promise + // settles; drain it before asserting. + await new Promise((resolve) => setImmediate(resolve)) + expect(journalQueueDepth()).toBe(before) + + // In-flight updates hold their entries; settled ones prune again. + const first = appendChange(tmpRoot, taskId, entry({ checkpointId: "one" })) + const second = appendChange(tmpRoot, "other-task", entry({ checkpointId: "two" })) + expect(journalQueueDepth()).toBe(before + 2) + await Promise.all([first, second]) + await new Promise((resolve) => setImmediate(resolve)) + expect(journalQueueDepth()).toBe(before) + }) + }) +}) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts new file mode 100644 index 0000000000..1ac531247e --- /dev/null +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -0,0 +1,435 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest" + +import type { Task } from "../../task/Task" +import { loadChanges } from "../changeJournal" +import { checkpointSave, type CheckpointWriteInfo } from "../index" + +// Mock the VS Code API surface (index.ts imports vscode at module level). +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({})), + }, + Uri: { + file: vi.fn((p: string) => ({ fsPath: p })), + parse: vi.fn((_uri: string) => ({ with: vi.fn(() => ({})) })), + }, + commands: { + executeCommand: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureCheckpointCreated: vi.fn(), + captureCheckpointRestored: vi.fn(), + captureCheckpointDiffed: vi.fn(), + }, + }, +})) + +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/test/workspace"), +})) + +vi.mock("../../../utils/git", () => ({ + checkGitInstalled: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn(), +})) + +// The real service would require a git repo; the checkpointSave under test +// only needs the pre-initialized service on the task. +vi.mock("../../../services/checkpoints") + +const TASK_ID = "journal-test-task" +const COMMIT = "test-commit-hash" + +interface ServiceLike { + isInitialized: boolean + saveCheckpoint: (...args: unknown[]) => Promise + isPathIgnored: (filePath: string) => Promise +} + +interface ProviderLike { + context: { globalStorageUri: { fsPath: string } } + log: (...args: unknown[]) => void + postMessageToWebview: (...args: unknown[]) => void + getState: () => Promise> +} + +interface TaskLike { + taskId: string + enableCheckpoints: boolean + checkpointService: ServiceLike + checkpointServiceInitializing: boolean + providerRef: { deref: () => ProviderLike | undefined } + say: (...args: unknown[]) => Promise +} + +describe("checkpointSave change-journal wiring (B2)", () => { + let tmpStorageDir: string + let saveCheckpointSpy: Mock + let mockProvider: ProviderLike + let mockTask: TaskLike + const write: CheckpointWriteInfo = { + path: "src/foo.ts", + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + } + + beforeEach(async () => { + tmpStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-wiring-")) + saveCheckpointSpy = vi.fn().mockResolvedValue({ commit: COMMIT }) + mockProvider = { + context: { globalStorageUri: { fsPath: tmpStorageDir } }, + log: vi.fn(), + postMessageToWebview: vi.fn(), + // B3a: the card emission reads the live settings through getState. + getState: vi.fn().mockResolvedValue({}), + } + // Structural test double for Task (the class is not instantiated at + // this unit layer); the cast is safe because the fields checkpointSave + // reads are exactly these. + mockTask = { + taskId: TASK_ID, + enableCheckpoints: true, + checkpointService: { + isInitialized: true, + saveCheckpoint: saveCheckpointSpy, + // Default: nothing is excluded from the shadow repo, so journaling + // behavior is unchanged unless a test overrides this. + isPathIgnored: vi.fn().mockResolvedValue(false), + }, + checkpointServiceInitializing: false, + providerRef: { deref: () => mockProvider }, + // B3a: the card emission calls task.say; a resolved double keeps the + // test double complete instead of letting the emission take the + // error path. + say: vi.fn().mockResolvedValue(undefined), + } + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(tmpStorageDir, { recursive: true, force: true }) + }) + + it("appends exactly one journal line referencing the B1 checkpoint id for a per-write save", async () => { + await checkpointSave(mockTask as Task, false, true, write) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(1) + expect(entries[0]).toEqual({ + path: "src/foo.ts", + operation: "create", + checkpointId: COMMIT, + diffStats: { additions: 3, deletions: 0 }, + }) + + // The raw file holds exactly one JSON line. + const journalFile = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints", "changes.jsonl") + const raw = await fs.readFile(journalFile, "utf8") + expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(1) + }) + + it("keeps shadow-repo-excluded paths out of the journal and the card", async () => { + // Excluded paths are absent from the shadow commit by design; journaling + // them would route a later rollback into restoreFile's delete branch and + // remove the user's live file. They must not appear in the journal, must + // not be advertised by the card, and a step whose writes are ALL excluded + // must produce neither a journal update nor a card. + mockTask.checkpointService.isPathIgnored = vi + .fn() + .mockImplementation(async (p: string) => p.startsWith("node_modules/")) + + await checkpointSave(mockTask as Task, false, true, [ + { path: "src/kept.ts", operation: "create", diffStats: { additions: 1, deletions: 0 } }, + { path: "node_modules/pkg/index.js", operation: "create", diffStats: { additions: 1, deletions: 0 } }, + ]) + + let entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries.map((e) => e.path)).toEqual(["src/kept.ts"]) + const sayMock = mockTask.say as Mock + expect(sayMock).toHaveBeenCalledTimes(1) + expect(String(sayMock.mock.calls[0][1])).not.toContain("node_modules") + + // A step whose every write is excluded journals nothing and emits no card. + await checkpointSave(mockTask as Task, false, true, { + path: "node_modules/pkg/other.js", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + }) + entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries.map((e) => e.path)).toEqual(["src/kept.ts"]) + expect(sayMock).toHaveBeenCalledTimes(1) + }) + + it("resolves an all-excluded write when the checkpoint produces no commit", async () => { + // An excluded path is never staged, so an all-excluded step has nothing + // to commit: the save must resolve (the write succeeded and no rollback + // metadata is expected) instead of failing on the no-commit rejection, + // whose outcome would otherwise depend on unrelated workspace changes. + saveCheckpointSpy.mockResolvedValueOnce(undefined) + mockTask.checkpointService.isPathIgnored = vi.fn().mockResolvedValue(true) + + await expect( + checkpointSave(mockTask as Task, false, true, { + path: "node_modules/pkg/index.js", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + }), + ).resolves.toBeUndefined() + + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + expect(mockTask.say as Mock).not.toHaveBeenCalled() + }) + + it("omits diffStats in the journal entry when not provided", async () => { + await checkpointSave(mockTask as Task, false, true, { path: "src/bar.ts", operation: "update" }) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(1) + expect(entries[0].path).toBe("src/bar.ts") + expect(entries[0].operation).toBe("update") + expect(entries[0].checkpointId).toBe(COMMIT) + expect(entries[0].diffStats).toBeUndefined() + }) + + it("does not write a journal entry for non-write checkpoint saves (task-start baseline)", async () => { + await checkpointSave(mockTask as Task) + + const journalFile = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints", "changes.jsonl") + await expect(fs.stat(journalFile)).rejects.toThrow() + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("appends one entry per file change for a multi-file write (apply-patch shape)", async () => { + await checkpointSave(mockTask as Task, false, true, [ + { path: "src/a.ts", operation: "create" }, + { path: "src/b.ts", operation: "update" }, + { path: "src/c.ts", operation: "delete" }, + ]) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(3) + // Every entry references the single checkpoint of the whole patch. + expect(entries.map((entry) => entry.checkpointId)).toEqual([COMMIT, COMMIT, COMMIT]) + expect(entries.map((entry) => entry.path)).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]) + expect(entries.map((entry) => entry.operation)).toEqual(["create", "update", "delete"]) + }) + + it("keeps the existing error-swallowing behavior for non-write saves on failure", async () => { + // The legacy swallow (log + disable + resolve undefined) is reserved + // for checkpoint calls without write metadata, e.g. the task-start + // baseline: no file write has happened, so there is no orphaned write + // to report. + saveCheckpointSpy.mockRejectedValueOnce(new Error("git exploded")) + + await expect(checkpointSave(mockTask as Task)).resolves.toBeUndefined() + expect(mockTask.enableCheckpoints).toBe(false) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("propagates a saveCheckpoint rejection for a write-metadata save", async () => { + // The file write already happened: swallowing the commit failure + // would report a tool success whose rollback metadata does not exist. + saveCheckpointSpy.mockRejectedValueOnce(new Error("git exploded")) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow(/no rollback metadata/) + + // Checkpoints are still disabled on failure, and neither the journal + // nor the change card is written for a commit that never happened. + expect(mockTask.enableCheckpoints).toBe(false) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + const cardCalls = vi.mocked(mockTask.say).mock.calls.filter((call) => call[0] === "change_card") + expect(cardCalls).toHaveLength(0) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("caught unexpected error"), + expect.anything(), + ) + consoleErrorSpy.mockRestore() + }) + + it("resolves a write-metadata save when the user disabled checkpoints", async () => { + // Documented contract: user-disabled checkpoints means the absence of + // rollback metadata is the user's choice, so the save must not fail the + // write tool (no journal entry, no change card, no throw). Genuine + // init-failure coverage lives in checkpointSave.spec.ts. No + // checkpointsDisabledByFailure flag is set: the flip is a user choice. + mockTask.enableCheckpoints = false + + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined() + expect(saveCheckpointSpy).not.toHaveBeenCalled() + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("keeps non-write saves a no-op when the checkpoint service is unavailable", async () => { + // The task-start baseline tolerates an unavailable service: no write + // metadata, no rollback expectation, historical behavior preserved. + mockTask.enableCheckpoints = false + + await expect(checkpointSave(mockTask as Task)).resolves.toBeUndefined() + expect(saveCheckpointSpy).not.toHaveBeenCalled() + }) + + it("keeps non-write saves succeeding when the checkpoint produces no commit", async () => { + // The task-start baseline tolerates an empty shadow repository: no + // write metadata means no rollback expectation. + saveCheckpointSpy.mockResolvedValueOnce(undefined) + + await expect(checkpointSave(mockTask as Task)).resolves.toBeUndefined() + }) + + it("fails a write-metadata save on an empty checkpoint without disabling checkpoints", async () => { + // A no-op checkpoint resolves without a commit: with write metadata + // there is no rollback point, so the save fails explicitly - but an + // empty checkpoint is not a service failure, so checkpoints stay + // enabled for later writes. + saveCheckpointSpy.mockResolvedValueOnce(undefined) + + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow(/did not produce a commit/) + + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + expect(mockTask.enableCheckpoints).toBe(true) + }) + + it("serializes the whole checkpoint transaction per task (commit + journal)", async () => { + // Two concurrent saves for the same task must not interleave their + // staging/commit with each other's journal replacement. + let inTransaction = 0 + let maxConcurrent = 0 + saveCheckpointSpy.mockImplementation(async () => { + inTransaction++ + maxConcurrent = Math.max(maxConcurrent, inTransaction) + await new Promise((resolve) => setTimeout(resolve, 20)) + inTransaction-- + return { commit: COMMIT } + }) + + await Promise.all([ + checkpointSave(mockTask as Task, false, true, write), + checkpointSave(mockTask as Task, false, true, { path: "src/second.ts", operation: "update" }), + ]) + + expect(maxConcurrent).toBe(1) + expect((await loadChanges(tmpStorageDir, TASK_ID)).map((e) => e.path)).toEqual(["src/foo.ts", "src/second.ts"]) + }) + + it("fails a write-metadata save before committing when the provider has no globalStorageDir", async () => { + // A commit whose journal update would be silently skipped is worse + // than no commit: rollback metadata would be incomplete with no error + // surfaced, so the save must fail before the Git commit runs. + mockTask.providerRef = { deref: () => undefined } + + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toThrow(/stable journal location/) + expect(saveCheckpointSpy).not.toHaveBeenCalled() + expect(mockTask.enableCheckpoints).toBe(true) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("persists the journal through the captured location when the provider disappears during the commit", async () => { + // The weak provider reference dies while the Git save is awaited: the + // journal location captured before the transaction (for the lock key) + // must still be used, so a successful commit never silently loses its + // rollback metadata. + saveCheckpointSpy.mockImplementation(async () => { + mockTask.providerRef = { deref: () => undefined } + return { commit: COMMIT } + }) + + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT }) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ path: "src/foo.ts", checkpointId: COMMIT }) + expect(mockTask.enableCheckpoints).toBe(true) + }) + + it("still runs non-write checkpoint saves when the provider is gone (task-start baseline)", async () => { + // The pre-commit journal-location guard applies only to saves that + // carry write metadata; the baseline save has none and keeps the + // historical behavior. + mockTask.providerRef = { deref: () => undefined } + + await expect(checkpointSave(mockTask as Task)).resolves.toMatchObject({ commit: COMMIT }) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("propagates a journal persistence failure after the commit and emits no change card", async () => { + // Block the per-task checkpoint dir so the journal mkdir/read fails. + const taskDir = path.join(tmpStorageDir, "tasks", TASK_ID) + await fs.mkdir(taskDir, { recursive: true }) + await fs.writeFile(path.join(taskDir, "checkpoints"), "blocker") + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + // The checkpoint committed but the journal update did not persist: the + // save must not report a fully durable checkpoint, and the change card + // (which advertises the journaled files) must not be emitted. + await expect(checkpointSave(mockTask as Task, false, true, write)).rejects.toMatchObject({ + name: "CheckpointPartialPersistenceError", + checkpointId: COMMIT, + writes: [{ path: "src/foo.ts", operation: "create", diffStats: { additions: 3, deletions: 0 } }], + }) + + // Propagation is not a checkpoint error: checkpoints stay enabled. + expect(mockTask.enableCheckpoints).toBe(true) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("failed to persist change journal entries"), + expect.anything(), + ) + const cardCalls = vi.mocked(mockTask.say).mock.calls.filter((call) => call[0] === "change_card") + expect(cardCalls).toHaveLength(0) + }) +}) + +describe("loadChanges entry-shape validation (B2)", () => { + let tmpStorageDir: string + + beforeEach(async () => { + tmpStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-shape-")) + }) + + afterEach(async () => { + await fs.rm(tmpStorageDir, { recursive: true, force: true }) + }) + + it("rejects middle lines that are valid JSON but not entry-shaped", async () => { + const dir = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints") + await fs.mkdir(dir, { recursive: true }) + const valid = JSON.stringify({ path: "src/a.ts", operation: "create", checkpointId: "c1" }) + await fs.writeFile( + path.join(dir, "changes.jsonl"), + [ + // A torn append can leave valid JSON that is not an entry. + "1", + "null", + // Missing checkpointId. + JSON.stringify({ path: "src/b.ts", operation: "update" }), + // Unknown operation. + JSON.stringify({ path: "src/c.ts", operation: "rename", checkpointId: "c2" }), + // Non-string path. + JSON.stringify({ path: 42, operation: "create", checkpointId: "c3" }), + valid, + ].join("\n") + "\n", + ) + + // Middle malformed records mean history was lost: the load fails + // loudly instead of returning a partial view. + await expect(loadChanges(tmpStorageDir, TASK_ID)).rejects.toThrow(/Malformed change journal entry at line 1/) + }) +}) diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts new file mode 100644 index 0000000000..50690ea5df --- /dev/null +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -0,0 +1,392 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest" + +import type { Task } from "../../task/Task" +import { RepoPerTaskCheckpointService } from "../../../services/checkpoints" +import { checkGitInstalled } from "../../../utils/git" +import { journalPath } from "../changeJournal" +import { checkpointSave } from "../index" + +// The recovery test that must re-initialize reaches RepoPerTaskCheckpointService +// .create; every other test presets a service double or has no storage dir, +// so the factory is only ever called through these mocks. +vi.mock("../../../services/checkpoints", () => ({ + RepoPerTaskCheckpointService: { create: vi.fn() }, +})) + +vi.mock("../../../utils/git", () => ({ + checkGitInstalled: vi.fn(), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureCheckpointCreated: vi.fn(), + captureCheckpointRestored: vi.fn(), + captureCheckpointDiffed: vi.fn(), + }, + }, +})) + +/** + * Minimal Task double for the checkpointSave wiring tests. Only the members + * touched by getCheckpointService + checkpointSave are provided: a pre-set + * checkpointService (so no git installation happens), the provider context + * (journal dir + setting state), and say. Structural cast at the boundary, + * matching the documented test-double style of the tool specs. + */ +function makeTask(options: { + saveCheckpoint?: unknown + state?: Record + enableCheckpoints?: boolean + storageDir?: string | null +}) { + const say = vi.fn().mockResolvedValue(undefined) + // An explicit `saveCheckpoint: undefined` (a checkpoint that produced no + // commit) must be preserved as-is; only an omitted option falls back to + // the default commit result. + const saveCheckpoint = vi + .fn() + .mockResolvedValue("saveCheckpoint" in options ? options.saveCheckpoint : { commit: "sha-card-1" }) + // `storageDir: null` simulates a checkpoint-service init failure: the + // service lookup dereferences globalStorageUri, throws, and resolves to + // no service while the task still expects checkpoints (enableCheckpoints + // stays true until the failed lookup flips it). + const providerDeref = { + context: { globalStorageUri: options.storageDir === null ? undefined : { fsPath: globalStorageDir } }, + getState: vi.fn().mockResolvedValue(options.state ?? {}), + // A re-initialized service sends an init warning through the provider; + // the recovery test that re-initializes reaches it. + postMessageToWebview: vi.fn(), + } + + const task = { + taskId: "task-card", + cwd: "/workspace", + enableCheckpoints: options.enableCheckpoints ?? true, + // With `storageDir: null` there is no preset service either, so the + // lookup runs for real and fails on the missing storage dir. + checkpointService: + options.storageDir === null + ? undefined + : { saveCheckpoint, isInitialized: true, isPathIgnored: vi.fn().mockResolvedValue(false) }, + providerRef: { deref: vi.fn().mockReturnValue(providerDeref) }, + say, + } as unknown as Task + + return { task, say, saveCheckpoint } +} + +let globalStorageDir: string + +beforeEach(async () => { + globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3a-save-")) +}) + +afterEach(async () => { + await fs.rm(globalStorageDir, { recursive: true, force: true }) +}) + +describe("checkpointSave change-card emission (B3a)", () => { + it("emits a full-detail change card after a successful per-write checkpoint and still appends the journal", async () => { + const { task, say, saveCheckpoint } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 2, deletions: 1 }, + diff: "+line-a\n+line-b\n-old", + }) + + expect(saveCheckpoint).toHaveBeenCalledWith(expect.stringContaining("task-card"), expect.any(Object)) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(1) + const [type, text, images, _partial, _sayOptions, _progress, options] = cardCalls[0] as unknown as [ + string, + string, + undefined, + undefined, + undefined, + undefined, + { isNonInteractive?: boolean }, + ] + expect(type).toBe("change_card") + expect(images).toBeUndefined() + expect(options).toEqual({ isNonInteractive: true }) + const card = JSON.parse(text as string) as { + checkpointIds: string[] + files: Array<{ path: string; additions: number; deletions: number; diff?: string }> + totalFiles: number + detail: string + } + expect(card.checkpointIds).toEqual(["sha-card-1"]) + expect(card.totalFiles).toBe(1) + expect(card.detail).toBe("full") + expect(card.files).toEqual([{ path: "src/a.ts", additions: 2, deletions: 1, diff: "+line-a\n+line-b\n-old" }]) + + // B2 regression: the journal entry is still appended with the commit id. + const journalRaw = await fs.readFile(journalPath(globalStorageDir, "task-card"), "utf8") + const entries = journalRaw + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line)) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ path: "src/a.ts", operation: "create", checkpointId: "sha-card-1" }) + }) + + it("emits a summary card without diffs for the default (summary) setting", async () => { + const { task, say } = makeTask({}) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "update", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + }) + + const cardCall = say.mock.calls.find(([type]) => type === "change_card") + expect(cardCall).toBeDefined() + const card = JSON.parse((cardCall as unknown as [string, string])[1]) as { + files: Array> + detail: string + } + expect(card.detail).toBe("summary") + expect(card.files[0]).not.toHaveProperty("diff") + }) + + it("emits a compact card for auto-approved steps even when the setting is full", async () => { + const { task, say } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + autoApproved: true, + }) + + const cardCall = say.mock.calls.find(([type]) => type === "change_card") + expect(cardCall).toBeDefined() + const card = JSON.parse((cardCall as unknown as [string, string])[1]) as { + files: Array> + detail: string + } + expect(card.detail).toBe("summary") + expect(card.files[0]).not.toHaveProperty("diff") + }) + + it("emits one card with all writes for a multi-file step", async () => { + const { task, say } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, [ + { path: "src/a.ts", operation: "create", diffStats: { additions: 2, deletions: 0 }, diff: "+a1\n+a2" }, + { path: "src/b.ts", operation: "delete", diffStats: { additions: 0, deletions: 3 } }, + ]) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(1) + const card = JSON.parse((cardCalls[0] as unknown as [string, string])[1]) as { + files: Array<{ path: string; diff?: string }> + totalFiles: number + } + expect(card.totalFiles).toBe(2) + expect(card.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]) + expect(card.files[0].diff).toBe("+a1\n+a2") + expect(card.files[1]).not.toHaveProperty("diff") + }) + + it("emits no change card for baseline checkpoints without write info", async () => { + const { task, say } = makeTask({}) + + await checkpointSave(task) + + // `say` is invoked with seven arguments, so a three-argument + // `toHaveBeenCalledWith` negative assertion can never fail; filter the + // recorded calls by type instead. + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(0) + }) + + it("fails a write-metadata save when the checkpoint produced no commit and emits no card", async () => { + const { task, say } = makeTask({ saveCheckpoint: undefined }) + + await expect(checkpointSave(task, false, true, { path: "src/a.ts", operation: "create" })).rejects.toThrow( + /did not produce a commit/, + ) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(0) + }) + + it("emits no change card for a no-op checkpoint with an empty writes array but still saves", async () => { + const { task, say, saveCheckpoint } = makeTask({}) + + // A no-op patch still reaches checkpointSave with [] write metadata; the + // commit may cover unrelated workspace changes, but a zero-file card must + // not be emitted. + await checkpointSave(task, false, true, []) + + expect(saveCheckpoint).toHaveBeenCalled() + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(0) + }) + + it("resolves a write-metadata save when checkpoints are disabled for the task and emits no card", async () => { + // Documented contract: user-disabled checkpoints means the absence of + // rollback metadata is the user's choice, so the save must not fail the + // write tool. + const { task, say, saveCheckpoint } = makeTask({ + enableCheckpoints: false, + // The persisted setting agrees the user disabled checkpoints; an unset or + // true setting would mean an earlier failure flipped the task flag. + state: { enableCheckpoints: false }, + }) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).resolves.toBeUndefined() + + expect(saveCheckpoint).not.toHaveBeenCalled() + expect(say).not.toHaveBeenCalled() + }) + + it("fails a write-metadata save when the checkpoint-service lookup fails and emits no card", async () => { + // Documented contract: with checkpoints requested (enableCheckpoints + // true) a failed service lookup is a genuine init-failure - the write + // would be orphaned, so the save must surface it. + const { task, say } = makeTask({ storageDir: null }) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).rejects.toThrow(/could not initialize the checkpoint service/) + + expect(say).not.toHaveBeenCalled() + }) + + it("recovers the next write-metadata save after a transient save failure", async () => { + // A failed save flips the task flag (checkpointsDisabledByFailure). + // The next write-metadata save must attempt recovery - reusing the + // live service handle - instead of failing every later write in the + // task with a misleading init-failure error. + const { task } = makeTask({}) + const svc = task.checkpointService as unknown as { saveCheckpoint: Mock } + svc.saveCheckpoint = vi + .fn() + .mockRejectedValueOnce(new Error("index.lock contention")) + .mockResolvedValue({ commit: "sha-recovered-1" }) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).rejects.toThrow(/Checkpoint save failed after a file write/) + expect(task.checkpointsDisabledByFailure).toBe(true) + + await expect( + checkpointSave(task, false, true, { path: "src/b.ts", operation: "create", diff: "+y" }), + ).resolves.toMatchObject({ commit: "sha-recovered-1" }) + expect(task.enableCheckpoints).toBe(true) + const journalRaw = await fs.readFile(journalPath(globalStorageDir, "task-card"), "utf8") + const entries = journalRaw + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line)) + expect(entries).toEqual([ + expect.objectContaining({ path: "src/b.ts", operation: "create", checkpointId: "sha-recovered-1" }), + ]) + }) + + it("does not reuse an uninitialized stored service during recovery", async () => { + // A stored service whose shadow-git init never completed must not be + // handed to saveCheckpoint by the recovery path: recovery must clear + // it and re-initialize instead. + vi.mocked(checkGitInstalled).mockResolvedValue(true) + const { task } = makeTask({}) + const svc = task.checkpointService as unknown as { saveCheckpoint: Mock; isInitialized: boolean } + svc.saveCheckpoint = vi.fn().mockRejectedValueOnce(new Error("not initialized")) + svc.isInitialized = false + + const freshListeners = new Map void>() + let freshInit = false + const fresh = { + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => freshListeners.set(event, cb)), + initShadowGit: vi.fn(async () => { + freshInit = true + freshListeners.get("initialize")?.() + }), + get isInitialized() { + return freshInit + }, + saveCheckpoint: vi.fn().mockResolvedValue({ commit: "sha-recovered-2" }), + isPathIgnored: vi.fn().mockResolvedValue(false), + } + vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue(fresh as unknown as RepoPerTaskCheckpointService) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).rejects.toThrow(/Checkpoint save failed after a file write/) + expect(task.checkpointsDisabledByFailure).toBe(true) + + await expect( + checkpointSave(task, false, true, { path: "src/b.ts", operation: "create", diff: "+y" }), + ).resolves.toMatchObject({ commit: "sha-recovered-2" }) + // Recovery replaced the dead handle instead of reusing it: the failed + // save ran exactly once and the live handle is the fresh service. + expect(svc.saveCheckpoint).toHaveBeenCalledOnce() + expect(task.checkpointService).toBe(fresh) + }) + + it("fails a write-metadata save when recovery after an earlier failure cannot re-initialize", async () => { + // No live handle to reuse and initialization fails again: the write + // has no rollback metadata and must surface the failure. + const { task, say } = makeTask({ storageDir: null }) + task.enableCheckpoints = false + task.checkpointsDisabledByFailure = true + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).rejects.toThrow(/could not initialize the checkpoint service/) + + expect(say).not.toHaveBeenCalled() + }) + + it("resolves silently when checkpoints were off for the task and no failure occurred", async () => { + // Enabling checkpoints in settings mid-task does not retroactively + // fail writes: the task was constructed without checkpoints and no + // failure flipped the flag, so the absence of rollback metadata is + // the construction-time user choice. + const { task, say, saveCheckpoint } = makeTask({ + enableCheckpoints: false, + state: { enableCheckpoints: true }, + }) + + await expect( + checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }), + ).resolves.toBeUndefined() + + expect(saveCheckpoint).not.toHaveBeenCalled() + expect(say).not.toHaveBeenCalled() + }) + + it("keeps the journal append when a card emission failure occurs", async () => { + const { task, say } = makeTask({}) + say.mockImplementation(async (type: string) => { + if (type === "change_card") { + throw new Error("task aborted") + } + }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + }) + + // The say failure is contained: the journal is still written. + const journalRaw = await fs.readFile(journalPath(globalStorageDir, "task-card"), "utf8") + expect(journalRaw).toContain("src/a.ts") + }) +}) diff --git a/src/core/checkpoints/__tests__/getCheckpointService.spec.ts b/src/core/checkpoints/__tests__/getCheckpointService.spec.ts new file mode 100644 index 0000000000..0121fce786 --- /dev/null +++ b/src/core/checkpoints/__tests__/getCheckpointService.spec.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { Task } from "../../task/Task" +import { RepoPerTaskCheckpointService } from "../../../services/checkpoints" +import { checkGitInstalled } from "../../../utils/git" +import { getCheckpointService } from "../index" + +vi.mock("../../../services/checkpoints", () => ({ + RepoPerTaskCheckpointService: { create: vi.fn() }, +})) + +vi.mock("../../../utils/git", () => ({ + checkGitInstalled: vi.fn(), +})) + +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/workspace"), +})) + +vi.mock("../../../i18n", () => ({ + t: (key: string) => key, +})) + +/** + * Structural service double: getCheckpointService only registers listeners, + * awaits initShadowGit, and reads isInitialized. The double assertion at the + * create() call sites is confined to this file (AGENTS.md last-resort cast): + * the real class carries a Git handle and event surface the test never + * exercises. + */ +function serviceDouble(initShadowGitImpl: () => Promise) { + const listeners = new Map void>() + let initialized = false + const svc = { + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + listeners.set(event, cb) + }), + initShadowGit: vi.fn(() => initShadowGitImpl()), + get isInitialized() { + return initialized + }, + completeInit: () => { + initialized = true + listeners.get("initialize")?.() + }, + } + return svc +} + +function makeTask(): Task { + return { + taskId: "task-init", + cwd: "/workspace", + enableCheckpoints: true, + checkpointService: undefined, + checkpointServiceInitializing: false, + checkpointTimeout: 1, + providerRef: { + deref: vi.fn().mockReturnValue({ + context: { globalStorageUri: { fsPath: "/tmp/b3-init-storage" } }, + postMessageToWebview: vi.fn(), + log: vi.fn(), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + } as unknown as Task +} + +describe("getCheckpointService initialization failures", () => { + beforeEach(() => { + vi.mocked(checkGitInstalled).mockReset() + vi.mocked(RepoPerTaskCheckpointService.create).mockReset() + }) + + it("does not store a service whose shadow-git initialization failed", async () => { + vi.mocked(checkGitInstalled).mockResolvedValue(true) + const failing = serviceDouble(async () => { + throw new Error("shadow git init exploded") + }) + vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue( + failing as unknown as RepoPerTaskCheckpointService, + ) + + const task = makeTask() + const service = await getCheckpointService(task) + + // The failed service must not be stored: a stored uninitialized handle + // would be returned by the fast path to every later save, and the + // initializing flag must not strand later lookups in a wait for an + // initialization that already died. + expect(service).toBeUndefined() + expect(task.checkpointService).toBeUndefined() + expect(task.checkpointServiceInitializing).toBe(false) + expect(task.enableCheckpoints).toBe(false) + expect(task.checkpointsDisabledByFailure).toBe(true) + }) + + it("does not store a service when Git is not installed", async () => { + vi.mocked(checkGitInstalled).mockResolvedValue(false) + const svc = serviceDouble(async () => {}) + vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue(svc as unknown as RepoPerTaskCheckpointService) + + const task = makeTask() + const service = await getCheckpointService(task) + + expect(service).toBeUndefined() + expect(task.checkpointService).toBeUndefined() + expect(task.checkpointServiceInitializing).toBe(false) + expect(task.enableCheckpoints).toBe(false) + }) + + it("stores an initialized service on the success path", async () => { + vi.mocked(checkGitInstalled).mockResolvedValue(true) + const svc = serviceDouble(async () => { + svc.completeInit() + }) + vi.mocked(RepoPerTaskCheckpointService.create).mockReturnValue(svc as unknown as RepoPerTaskCheckpointService) + + const task = makeTask() + const service = await getCheckpointService(task) + + expect(service).toBe(svc) + expect(task.checkpointService).toBe(svc) + expect(task.checkpointServiceInitializing).toBe(false) + expect(task.enableCheckpoints).toBe(true) + }) +}) diff --git a/src/core/checkpoints/__tests__/rollback.spec.ts b/src/core/checkpoints/__tests__/rollback.spec.ts new file mode 100644 index 0000000000..d08c0fb2cb --- /dev/null +++ b/src/core/checkpoints/__tests__/rollback.spec.ts @@ -0,0 +1,487 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { Task } from "../../task/Task" +import type { RepoPerTaskCheckpointService } from "../../../services/checkpoints" +import type { ChangeJournalEntry } from "../changeJournal" +import { getCheckpointService } from "../index" +import * as changeJournal from "../changeJournal" +import { appendChanges, journalPath } from "../changeJournal" +import { restoreLatestFile, rollbackFile, rollbackStep } from "../rollback" + +vi.mock("../index", () => ({ + getCheckpointService: vi.fn(), + checkpointSave: vi.fn(), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), +})) + +const mockedGetCheckpointService = vi.mocked(getCheckpointService) + +/** + * A minimal Task double: the rollback code reads only taskId and + * providerRef.deref(). The double assertion is confined to this factory + * (AGENTS.md last resort - the real Task carries the full session surface) + * so every call site stays typed and cast-free. + */ +function makeTask(provider: unknown = { context: { globalStorageUri: { fsPath: globalStorageDir } } }): Task { + return { + taskId: "task-rollback", + providerRef: { deref: vi.fn().mockReturnValue(provider) }, + } as unknown as Task +} + +/** + * A checkpoint-service double with a recording restoreFile and a baseline. + * The rollback code consumes only these two members; the double assertion + * is confined to this factory so the vi.mocked handle stays typed. + */ +function serviceWith(baseHash: string | undefined): RepoPerTaskCheckpointService { + const restoreFile = vi.fn().mockResolvedValue(undefined) + return { baseHash, restoreFile } as unknown as RepoPerTaskCheckpointService +} + +async function seedJournal(entries: ChangeJournalEntry[]): Promise { + for (const entry of entries) { + await appendChanges(globalStorageDir, "task-rollback", [entry]) + } +} + +let globalStorageDir: string + +beforeEach(async () => { + globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3c-rollback-")) + mockedGetCheckpointService.mockReset() +}) + +afterEach(async () => { + await fs.rm(globalStorageDir, { recursive: true, force: true }) +}) + +describe("rollbackFile (B3c: undo the step's write to the file)", () => { + it("restores the file to the PREVIOUS step's checkpoint when an earlier entry exists", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-2", "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true }) + // The pre-step state is the previous step's post-write checkpoint — not + // the step's own (post-write) checkpoint. + expect(service.restoreFile).toHaveBeenCalledTimes(1) + expect(service.restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") + }) + + it("restores from the task-start baseline when the file has no earlier entry", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true }) + expect(service.restoreFile).toHaveBeenCalledWith("base-0", "src/a.ts") + }) + + it("resolves through the first entry of a multi-write step", async () => { + // One patch writes the same file twice: two entries share the step + // checkpoint. The pre-step state is still the entry before the first + // one of the step. + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-2", "src/a.ts") + + expect(outcome.success).toBe(true) + expect(service.restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") + }) + + it("rejects rolling back a step that is not the file's latest change", async () => { + // The file was written again by a later step (sha-2): rolling back the + // older step (sha-1) would overwrite the newer state, so it is + // rejected instead of silently destroying it. + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "File was modified in a later step; roll back the latest change card first", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails when the journal location is unavailable (no global storage)", async () => { + // No context on the provider double → the journal cannot even be + // located: a clear failure, not a silent miss on the file lookup. + const task = makeTask(null) + + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(task, "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Change journal is unavailable for this task", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails cleanly when the file is not part of the given step checkpoint", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-2", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "File is not part of this step's checkpoint", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails cleanly when no earlier checkpoint exists and the baseline is unavailable", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith(undefined) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "No checkpoint available to restore", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails cleanly when checkpoints are not enabled", async () => { + mockedGetCheckpointService.mockResolvedValue(undefined) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Checkpoints are not enabled for this task", + }) + }) + + it("reports the service error without throwing", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + vi.mocked(service.restoreFile).mockRejectedValue(new Error("pathspec did not match")) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("pathspec did not match") + }) + + it("stringifies non-Error rejections into the outcome", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + vi.mocked(service.restoreFile).mockRejectedValue("raw failure") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: false, error: "raw failure" }) + }) +}) + +describe("rollbackStep (B3c: undo every file of the step)", () => { + it("restores every step file to its pre-step state", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/b.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-2") + + expect(outcome.checkpointId).toBe("sha-2") + expect(outcome.files).toEqual([ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ]) + expect(service.restoreFile).toHaveBeenCalledTimes(2) + expect(service.restoreFile).toHaveBeenNthCalledWith(1, "sha-1", "src/a.ts") + // src/b.ts has no earlier entry: its pre-step state is the baseline. + expect(service.restoreFile).toHaveBeenNthCalledWith(2, "base-0", "src/b.ts") + }) + + it("keeps per-file failures isolated from the other step files", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/b.ts", operation: "create", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + vi.mocked(service.restoreFile).mockRejectedValueOnce(new Error("pathspec did not match")) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-2") + + expect(outcome.files[0].success).toBe(false) + expect(outcome.files[0].error).toContain("pathspec did not match") + expect(outcome.files[1]).toEqual({ filePath: "src/b.ts", success: true }) + }) + + it("fails the file cleanly when no earlier checkpoint exists and the baseline is unavailable", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-2" }]) + const service = serviceWith(undefined) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts"], "sha-2") + + expect(outcome.checkpointId).toBe("sha-2") + expect(outcome.files).toEqual([ + { filePath: "src/a.ts", success: false, error: "No checkpoint available to restore" }, + ]) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("rejects a file that is not part of the given step checkpoint", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-2" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/other.ts"], "sha-2") + + expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true }) + expect(outcome.files[1].success).toBe(false) + expect(outcome.files[1].error).toBe("File is not part of this step's checkpoint") + expect(service.restoreFile).toHaveBeenCalledTimes(1) + }) + + it("rejects the stale file of a step while restoring the others", async () => { + // src/a.ts was written again after this step's checkpoint, so its + // sha-2 entry is no longer the file's latest: only src/b.ts (whose + // latest entry IS sha-2) is restored. + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/b.ts", operation: "create", checkpointId: "sha-2" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-3" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-2") + + expect(outcome.files[0].success).toBe(false) + expect(outcome.files[0].error).toBe("File was modified in a later step; roll back the latest change card first") + expect(outcome.files[1]).toEqual({ filePath: "src/b.ts", success: true }) + expect(service.restoreFile).toHaveBeenCalledTimes(1) + expect(service.restoreFile).toHaveBeenCalledWith("base-0", "src/b.ts") + }) + + it("falls back to the latest journal entry per file without a step checkpoint id", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts"]) + + expect(outcome.checkpointId).toBeUndefined() + expect(outcome.files).toEqual([{ filePath: "src/a.ts", success: true }]) + expect(service.restoreFile).toHaveBeenCalledWith("sha-2", "src/a.ts") + }) + + it("fails listed files without journal entries", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/missing.ts"]) + + expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true }) + expect(outcome.files[1].success).toBe(false) + expect(outcome.files[1].error).toBe("No change journal entry for this file") + }) + + it("fails per file when the journal location is unavailable (no global storage)", async () => { + // No context on the provider double → the journal cannot even be + // located. That is a failure, not an empty journal: reporting the + // step as merely "not part of this checkpoint" would be misleading. + const task = makeTask(null) + + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(task, ["src/a.ts"], "sha-2") + + expect(outcome.checkpointId).toBe("sha-2") + expect(outcome.files[0].success).toBe(false) + expect(outcome.files[0].error).toBe("Change journal is unavailable for this task") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails every file when checkpoints are not enabled", async () => { + mockedGetCheckpointService.mockResolvedValue(undefined) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts"], "sha-2") + + expect(outcome.checkpointId).toBe("sha-2") + expect(outcome.files).toEqual([ + { filePath: "src/a.ts", success: false, error: "Checkpoints are not enabled for this task" }, + ]) + }) +}) + +describe("restoreLatestFile (B3c: forward direction)", () => { + it("restores the file to its most recent recorded write checkpoint", async () => { + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/b.ts", operation: "update", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true }) + expect(service.restoreFile).toHaveBeenCalledWith("sha-2", "src/a.ts") + }) + + it("is a successful no-op for a file the task never wrote", async () => { + await seedJournal([{ path: "src/b.ts", operation: "update", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true, noOp: true }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails when the journal location is unavailable (no global storage)", async () => { + // An unavailable journal is not "the task wrote nothing": a no-op + // success would claim a restore that never happened. + const task = makeTask(null) + + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(task, "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Change journal is unavailable for this task", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails when the journal cannot be read (an I/O error is not an empty journal)", async () => { + // A directory at the journal path makes readFile fail with EISDIR — + // a stand-in for any permission or I/O failure (EACCES etc.). + await fs.mkdir(journalPath(globalStorageDir, "task-rollback"), { recursive: true }) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("Change journal could not be read") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("stringifies a non-Error journal read failure into the outcome", async () => { + vi.spyOn(changeJournal, "loadChanges").mockRejectedValueOnce("raw journal failure") + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toBe("Change journal could not be read: raw journal failure") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails restore-latest on a corrupt final journal record without restoring", async () => { + // A committed write whose final journal record was truncated or + // corrupted must surface as an explicit failure: reporting a + // successful no-op would hide that rollback metadata is incomplete. + await seedJournal([{ path: "src/a.ts", operation: "update", checkpointId: "sha-1" }]) + await fs.appendFile(journalPath(globalStorageDir, "task-rollback"), '{"path":"src/a.ts","ope') + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("Corrupt change journal entry at line 2") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails rollbackFile and rollbackStep on a corrupt final journal record without restoring", async () => { + await seedJournal([{ path: "src/a.ts", operation: "update", checkpointId: "sha-1" }]) + await fs.appendFile(journalPath(globalStorageDir, "task-rollback"), '{"path":"src/a.ts","ope') + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const single = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + expect(single.success).toBe(false) + expect(single.error).toContain("Corrupt change journal entry at line 2") + + const step = await rollbackStep(makeTask(), ["src/a.ts"], "sha-1") + expect(step.files[0].success).toBe(false) + expect(step.files[0].error).toContain("Corrupt change journal entry at line 2") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails cleanly when checkpoints are not enabled", async () => { + mockedGetCheckpointService.mockResolvedValue(undefined) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Checkpoints are not enabled for this task", + }) + }) + + it("reports the service error without throwing", async () => { + await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) + const service = serviceWith("base-0") + vi.mocked(service.restoreFile).mockRejectedValue(new Error("index.lock held")) + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("index.lock") + }) +}) diff --git a/src/core/checkpoints/changeCard.ts b/src/core/checkpoints/changeCard.ts new file mode 100644 index 0000000000..b13f03320d --- /dev/null +++ b/src/core/checkpoints/changeCard.ts @@ -0,0 +1,91 @@ +/** + * Per-step change card builder (B3a). + * + * The card is emitted from `checkpointSave` (index.ts) once the per-write + * checkpoint commit exists, so the payload can key the card by the real + * checkpoint ID and reuse the approval diff + stats the tool already + * computed (threaded through {@link CheckpointWriteInfo}). The card is + * informational and always emitted for write steps, including auto-approved + * ones — which always get the compact ("summary") form regardless of the + * `changeCardDetail` setting. + */ +import { DEFAULT_CHANGE_CARD_DETAIL, type ChangeCardData, type ChangeCardDetail } from "@roo-code/types" + +/** + * The write data a change card is built from. Structurally compatible with + * `CheckpointWriteInfo` (src/core/checkpoints/index.ts), minus the + * `operation` field the card does not need. + */ +export interface ChangeCardWrite { + /** The file path as the tool knows it (relative to the task cwd). */ + path: string + /** { additions, deletions } from the approval diff, when computable. */ + diffStats?: { additions: number; deletions: number } + /** The unified approval diff for this file (reused, not recomputed). */ + diff?: string + /** Whether the tool step was auto-approved (no human interaction). */ + autoApproved?: boolean +} + +/** + * Whether every write of the step was auto-approved. Empty steps are not + * auto-approved (there is nothing for the user to have skipped). + */ +export function isAutoApprovedStep(writes: readonly ChangeCardWrite[]): boolean { + return writes.length > 0 && writes.every((write) => write.autoApproved === true) +} + +/** + * Resolve the card detail level for a step: + * - auto-approved steps always get the compact "summary" card, regardless of + * the user setting (cards for steps the user never saw approving are + * informational only); + * - otherwise the `changeCardDetail` setting applies, defaulting to + * "summary" when unset. + */ +export function resolveChangeCardDetail( + writes: readonly ChangeCardWrite[], + setting: ChangeCardDetail | undefined, +): ChangeCardDetail { + if (isAutoApprovedStep(writes)) { + return "summary" + } + return setting ?? DEFAULT_CHANGE_CARD_DETAIL +} + +/** + * Build the typed change-card payload for one step (one per-write checkpoint). + * + * With `detail: "full"` each file carries its unified diff inline; with + * `detail: "summary"` the diff is omitted and the UI fetches it lazily + * (B3b). + */ +export function buildChangeCard( + checkpointId: string, + writes: readonly ChangeCardWrite[], + detail: ChangeCardDetail, +): ChangeCardData { + return { + checkpointIds: [checkpointId], + files: writes.map((write) => ({ + path: write.path, + additions: write.diffStats?.additions ?? 0, + deletions: write.diffStats?.deletions ?? 0, + ...(detail === "full" && write.diff ? { diff: write.diff } : {}), + })), + totalFiles: writes.length, + detail, + } +} + +/** + * Convenience wrapper: resolve the detail level from the step + setting, then + * build the payload. This is what `checkpointSave` calls. + */ +export function buildChangeCardPayload( + checkpointId: string, + writes: readonly ChangeCardWrite[], + setting: ChangeCardDetail | undefined, +): ChangeCardData { + return buildChangeCard(checkpointId, writes, resolveChangeCardDetail(writes, setting)) +} diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts new file mode 100644 index 0000000000..3eab566ca0 --- /dev/null +++ b/src/core/checkpoints/changeJournal.ts @@ -0,0 +1,317 @@ +import fs from "fs/promises" +import * as path from "path" + +/** + * A single entry in the per-task change journal (changes.jsonl). + * + * One line is appended for every successful file write that goes through a + * B1 per-write checkpoint hook. WriteToFileTool and EditFileTool emit one + * entry per write; ApplyPatchTool emits one entry per file change of a fully + * successful patch — those entries all reference the single B1 checkpoint + * that the patch's post-loop hook saves for the whole patch. The task-start + * baseline never produces an entry (it is not a file write). + */ +export interface ChangeJournalEntry { + /** The file path as the tool knows it (relative to task cwd). */ + path: string + /** "create" | "update" | "delete" — derived from what the tool did. */ + operation: "create" | "update" | "delete" + /** The B1 checkpoint commit SHA for this write (from checkpointSave result). */ + checkpointId: string + /** { additions, deletions } from the approval diff; null/omit when not computable. */ + diffStats?: { additions: number; deletions: number } +} + +const JOURNAL_FILENAME = "changes.jsonl" + +/** + * Task IDs arrive from task creation and are used as a single path segment + * under /tasks. Reject anything that could escape that + * directory — separators, traversal, absolute paths, empty or dot-only IDs — + * before any filesystem operation touches the journal path. + */ +function assertSafeTaskId(taskId: string): void { + if (!/^[A-Za-z0-9._-]+$/.test(taskId) || taskId === "." || taskId === "..") { + throw new Error(`Unsafe task ID for checkpoint journal path: ${JSON.stringify(taskId)}`) + } +} + +/** Derive the per-task checkpoint directory from globalStorageDir and taskId. */ +function taskCheckpointDir(globalStorageDir: string, taskId: string): string { + return path.join(globalStorageDir, "tasks", taskId, "checkpoints") +} + +/** Journal file path for a given task. */ +export function journalPath(globalStorageDir: string, taskId: string): string { + assertSafeTaskId(taskId) + return path.join(taskCheckpointDir(globalStorageDir, taskId), JOURNAL_FILENAME) +} + +/** + * Per-task update queue: journal replacements for one task never overlap, + * so concurrent checkpoint saves cannot lose each other's entries through + * the read-modify-write cycle below. + */ +const journalQueues = new Map>() + +/** @internal Test seam: number of live per-task journal queue entries. */ +export function journalQueueDepth(): number { + return journalQueues.size +} + +export function withTaskLock(key: string, run: () => Promise): Promise { + const prev = journalQueues.get(key) ?? Promise.resolve() + // The next update runs after the previous one settles, whatever it did. + const next = prev.then(run, run) + // The queued promise swallows its outcome so a failed update neither + // blocks later updates nor surfaces as an unhandled rejection; callers + // observe the failure through `next`. + const tail = next.then( + () => undefined, + () => undefined, + ) + journalQueues.set(key, tail) + // Prune settled entries: a queue entry must not outlive its work + // (deleting a task removes its files but not this map). The identity + // check keeps a late completion from deleting a newer queued update + // for the same key. + void tail.finally(() => { + if (journalQueues.get(key) === tail) { + journalQueues.delete(key) + } + }) + return next +} + +/** True when a parsed JSON value is a complete journal record. */ +function isEntryShaped(value: unknown): value is ChangeJournalEntry { + return ( + !!value && + typeof value === "object" && + typeof (value as ChangeJournalEntry).path === "string" && + typeof (value as ChangeJournalEntry).checkpointId === "string" && + ["create", "update", "delete"].includes((value as ChangeJournalEntry).operation) + ) +} + +/** + * Append change-journal entries to the per-task changes.jsonl file as one + * serialized, atomic update. + * + * The whole multi-entry update is a crash-safe replacement: the current + * journal is read and its final record verified - an unterminated but + * complete, entry-shaped tail is re-terminated, any other damaged final + * record rejects the append - then the complete next content is written + * to a temporary file in the same directory and fsynced, atomically + * renamed onto the journal, and the parent directory is fsynced (POSIX; + * Windows cannot open directories). A crash at any point leaves either + * the old complete journal or the new complete journal - never a + * half-applied multi-file update - and a durable checkpoint implies a + * durable journal entry. Creates parent directories if they don't exist + * yet (e.g. first checkpoint). + * + * Failures propagate: callers must not report a fully durable checkpoint + * when the journal update did not complete. + */ +export async function appendChanges( + globalStorageDir: string, + taskId: string, + entries: ChangeJournalEntry[], +): Promise { + if (entries.length === 0) { + return + } + const filePath = journalPath(globalStorageDir, taskId) + return withTaskLock(filePath, async () => { + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true }) + let content = "" + try { + content = await fs.readFile(filePath, "utf8") + } catch (error) { + // A missing journal is a fresh history. Any other read failure + // means the complete next content cannot be built, so propagate + // instead of replacing the journal with a partial view of it. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error + } + } + // Torn-tail handling: the FINAL record must be complete and + // entry-shaped whether or not it ends with a newline. A crash can + // leave the final line unterminated; if that line is a complete, + // entry-shaped JSON record it was fully written and only its newline + // was lost - verified recovery: keep it and re-terminate. An + // unparseable or non-entry-shaped final record is NOT verified + // recovery material: it may be the truncated journal record of a + // committed write, and silently truncating it would let this append + // succeed while the rollback history is already incomplete (a later + // rollback could then resolve the file to the wrong checkpoint or + // lose the lost write's metadata). A malformed final record that + // still ends with its newline (e.g. "null\n") is the same hazard: + // accepting it here would demote it to a skipped middle line after + // the append, reporting an incomplete history as valid. Reject the + // append in both cases; torn-tail repair is a separately verified + // recovery operation, not a side effect of the next write. + if (content) { + const unterminated = !content.endsWith("\n") + const body = unterminated ? content : content.slice(0, -1) + const cut = body.lastIndexOf("\n") + 1 + const tailText = body.slice(cut) + if (tailText !== "") { + let tail: unknown + try { + tail = JSON.parse(tailText) + } catch { + tail = undefined + } + if (!isEntryShaped(tail)) { + throw new Error( + `Cannot append to change journal ${filePath}: ${unterminated ? "unterminated " : ""}final record is not a complete entry (torn-tail recovery requires explicit repair)`, + ) + } + } + if (unterminated) { + content = content + "\n" + } + } + const payload = entries.map((entry) => JSON.stringify(entry) + "\n").join("") + const tmpPath = path.join(dir, `${JOURNAL_FILENAME}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`) + const data = Buffer.from(content + payload) + try { + const handle = await fs.open(tmpPath, "w") + try { + // A single write can complete partially; sync only after the + // whole content is on the handle, or a short write would + // rename a torn journal into place. + let written = 0 + while (written < data.length) { + const res = await handle.write(data, written, data.length - written) + written += res.bytesWritten + } + await handle.sync() + } finally { + await handle.close() + } + // Windows can transiently reject a rename onto the existing journal + // (an antivirus or indexer holding the destination): retry with a + // short backoff before failing. The destination is never unlinked + // to make room, so atomicity is preserved on every failure. + const renameAttempts = process.platform === "win32" ? 5 : 1 + for (let attempt = 1; ; attempt++) { + try { + await fs.rename(tmpPath, filePath) + break + } catch (error) { + const renameCode = (error as NodeJS.ErrnoException).code + if ( + process.platform !== "win32" || + attempt >= renameAttempts || + !["EPERM", "EBUSY", "EACCES"].includes(renameCode ?? "") + ) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 20 * attempt)) + } + } + if (process.platform !== "win32") { + // The rename replaced the directory entry: on POSIX the + // directory itself must be synced for the new entry to + // survive a crash (Windows cannot open directories). The + // journal content is already in place at this point, so a + // sync failure means reduced crash durability, not a lost + // update: errnos that mean "fsync is unsupported on this + // mount" are tolerated and logged; any other failure still + // propagates. + try { + const dirHandle = await fs.open(dir, "r") + try { + await dirHandle.sync() + } finally { + await dirHandle.close() + } + } catch (error) { + const syncCode = (error as NodeJS.ErrnoException).code + if (!["EINVAL", "EISDIR", "EBADF", "ENOTSUP", "EOPNOTSUPP", "EPERM"].includes(syncCode ?? "")) { + throw error + } + console.warn( + `[changeJournal] directory sync unsupported after journal rename (${syncCode}); crash durability reduced`, + ) + } + } + } catch (error) { + // Best-effort cleanup: the rename never completed, so the journal + // still holds the previous complete content; drop the temp file. + await fs.rm(tmpPath, { force: true }).catch(() => {}) + throw error + } + }) +} + +/** + * Load all change-journal entries for a task. + * + * Every line must parse to an entry-shaped record: any corrupt or + * non-entry-shaped line - including a truncated FINAL line - rejects, + * because a torn tail may be the truncated journal record of a committed + * write and silently dropping it reports an incomplete rollback history + * as valid. Callers treat the rejection as "journal completeness + * unknown" and fail closed. Entries are returned in order. An absent + * or empty journal returns [] — but only an ABSENT file. Any other read + * failure (permissions, I/O) is rethrown: a journal that cannot be read must + * not be indistinguishable from one that is legitimately empty. + * @internal Wired by a later unit in the split chain; remove when the chain completes. + */ +export async function loadChanges(globalStorageDir: string, taskId: string): Promise { + const filePath = journalPath(globalStorageDir, taskId) + + let content: string + try { + content = await fs.readFile(filePath, "utf8") + } catch (error) { + // A missing journal is a legitimate empty history; any other read + // failure (permissions, I/O) must propagate. Swallowing it would let + // a rollback report a no-op success without reading the history. + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return [] + } + throw error + } + + if (!content.trim()) { + return [] + } + + const lines = content.split("\n") + // Remove trailing empty line from a file that ends with \n. + if (lines[lines.length - 1] === "") { + lines.pop() + } + + const entries: ChangeJournalEntry[] = [] + for (let i = 0; i < lines.length; i++) { + let parsed: unknown + try { + parsed = JSON.parse(lines[i]) + } catch { + // A parse failure anywhere - including the final line - means + // journal completeness is unknown. Silently dropping an + // unverified final record can report an incomplete history as + // valid: restore-latest would no-op over the lost write. + // Torn-tail repair is verified truncation and belongs to the + // append path (which re-terminates or truncates the tail before + // writing); a read must reject instead. A corrupt MIDDLE line + // means a write was lost from the history, so rollback must fail + // loudly instead of picking an older entry as the restore target. + throw new Error(`Corrupt change journal entry at line ${i + 1} of ${filePath}`) + } + // A parsed line that is not entry-shaped is malformed, not a torn + // tail (a torn tail cannot parse at all) - reject it anywhere. + if (!isEntryShaped(parsed)) { + throw new Error(`Malformed change journal entry at line ${i + 1} of ${filePath}`) + } + entries.push(parsed as ChangeJournalEntry) + } + + return entries +} diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index de424b03f7..7bfa59fd24 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -16,6 +16,9 @@ import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints" +import { appendChanges, withTaskLock } from "./changeJournal" +import { buildChangeCardPayload } from "./changeCard" + const WARNING_THRESHOLD_MS = 5000 function sendCheckpointInitWarn(task: Task, type?: "WAIT_TIMEOUT" | "INIT_TIMEOUT", timeout?: number) { @@ -25,6 +28,16 @@ function sendCheckpointInitWarn(task: Task, type?: "WAIT_TIMEOUT" | "INIT_TIMEOU }) } +// Every checkpoint disable in this module is failure-driven (init, save, +// restore, or diff). Route the flip through this helper so checkpointSave +// can tell an earlier failure apart from a user who never enabled +// checkpoints: the persisted setting cannot make that distinction (it can +// change after the task started). +function disableCheckpointsAfterFailure(task: Task): void { + task.enableCheckpoints = false + task.checkpointsDisabledByFailure = true +} + export async function getCheckpointService(task: Task, { interval = 250 }: { interval?: number } = {}) { if (!task.enableCheckpoints) { return undefined @@ -56,7 +69,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int if (!workspaceDir) { log("[Task#getCheckpointService] workspace folder not found, disabling checkpoints") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) return undefined } @@ -64,7 +77,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int if (!globalStorageDir) { log("[Task#getCheckpointService] globalStorageDir not found, disabling checkpoints") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) return undefined } @@ -98,7 +111,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int ) if (!task?.checkpointService) { sendCheckpointInitWarn(task, "INIT_TIMEOUT", task.checkpointTimeout) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) return undefined } else { sendCheckpointInitWarn(task) @@ -113,6 +126,15 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int const service = RepoPerTaskCheckpointService.create(options) task.checkpointServiceInitializing = true await checkGitInstallation(task, service, log, provider) + if (!service.isInitialized) { + // checkGitInstallation disables checkpoints when Git is unavailable and + // logs initShadowGit failures without throwing: storing such a service + // would hand later saves (and the fast path above) an uninitialized + // handle whose isInitialized can never flip, so a failed + // initialization must resolve to no service at all. + task.checkpointServiceInitializing = false + return undefined + } task.checkpointService = service if (task.enableCheckpoints) { sendCheckpointInitWarn(task) @@ -123,7 +145,7 @@ export async function getCheckpointService(task: Task, { interval = 250 }: { int sendCheckpointInitWarn(task, "INIT_TIMEOUT", task.checkpointTimeout) } log(`[Task#getCheckpointService] ${err.message}`) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) task.checkpointServiceInitializing = false return undefined } @@ -140,7 +162,7 @@ async function checkGitInstallation( if (!gitInstalled) { log("[Task#getCheckpointService] Git is not installed, disabling checkpoints") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) task.checkpointServiceInitializing = false // Show user-friendly notification @@ -189,7 +211,7 @@ async function checkGitInstallation( } catch (err) { log("[Task#getCheckpointService] caught unexpected error in on('checkpoint'), disabling checkpoints") console.error(err) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) } }) @@ -199,32 +221,276 @@ async function checkGitInstallation( await service.initShadowGit() } catch (err) { log(`[Task#getCheckpointService] initShadowGit -> ${err.message}`) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) + // The "initialize" event never fires for a failed initShadowGit, so + // clear the flag here or later lookups wait on an initialization + // that already died. + task.checkpointServiceInitializing = false } } catch (err) { log(`[Task#getCheckpointService] Unexpected error during Git check: ${err.message}`) console.error("Git check error:", err) - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) task.checkpointServiceInitializing = false } } -export async function checkpointSave(task: Task, force = false, suppressMessage = false) { - const service = await getCheckpointService(task) +/** + * Write metadata for the per-task change journal (B2). + * + * `path` is the file path as the tool knows it (relative to the task cwd), + * consistent with what the B1 per-write checkpoint hooks see. `diffStats` is + * the { additions, deletions } pair from the approval diff when it was + * computable; omitted otherwise. ApplyPatchTool passes one entry per file + * change of a fully successful patch (an array), all sharing the single + * checkpoint the patch's post-loop hook saves. + */ +export type CheckpointWriteInfo = { + path: string + operation: "create" | "update" | "delete" + diffStats?: { additions: number; deletions: number } + /** + * The unified approval diff for this write, reused verbatim by the B3a + * change card (never recomputed). + */ + diff?: string + /** + * Whether the tool step was auto-approved (no human interaction). Auto- + * approved steps always get the compact ("summary") change card. + */ + autoApproved?: boolean +} +/** + * Thrown when a checkpoint commit succeeded but its journal/card persistence + * did not. Carries the committed checkpoint id and the complete affected write + * set so tool callers can report an explicit partial-persistence state + * instead of a silent full success. + */ +export class CheckpointPartialPersistenceError extends Error { + constructor( + message: string, + readonly checkpointId: string, + readonly writes: CheckpointWriteInfo[], + ) { + super(message) + this.name = "CheckpointPartialPersistenceError" + } +} + +export async function checkpointSave( + task: Task, + force = false, + suppressMessage = false, + write?: CheckpointWriteInfo | CheckpointWriteInfo[], +) { + // A save that carries write metadata must end with rollback metadata + // existing: every failure below (service init-failure, no commit, no + // journal location, journal write failure) surfaces explicitly instead + // of resolving to a tool success over an orphaned write. A user-disabled + // checkpoint setting is NOT such a failure: absence of rollback metadata + // is then the user's explicit choice, so the save resolves silently + // (documented contract, see PR description). + const journalWrites = write === undefined ? undefined : Array.isArray(write) ? write : [write] + const hasWriteMetadata = !!journalWrites && journalWrites.length > 0 + + // Captured before the lookup: getCheckpointService flips the flag off when + // initialization fails, so reading it after the call cannot distinguish + // "user disabled checkpoints" (resolve silently) from "init failed while + // the user expects checkpoints" (throw). + const checkpointsRequested = task.enableCheckpoints + + let service = await getCheckpointService(task) if (!service) { - return + if (hasWriteMetadata && !checkpointsRequested && task.checkpointsDisabledByFailure) { + // An earlier failure (init or save) disabled checkpoints while the + // user never turned them off. Attempt one recovery so a single + // transient failure (e.g. index.lock contention) does not + // permanently fail every later write in the task: reuse a live + // service handle when one exists (a save failure does not + // invalidate the handle), otherwise retry initialization once. The + // persisted setting cannot make this decision - it can change + // after the task started, and enabling checkpoints mid-task must + // not retroactively fail writes that were never promised a + // checkpoint. + task.enableCheckpoints = true + // Only a live, initialized handle may be reused: an uninitialized + // stored service would go straight to saveCheckpoint (which throws) + // instead of retrying initialization. + const live = task.checkpointService?.isInitialized ? task.checkpointService : undefined + if (!live) { + task.checkpointService = undefined + task.checkpointServiceInitializing = false + } + service = live ?? (await getCheckpointService(task)) + } + if (!service) { + if (hasWriteMetadata && (checkpointsRequested || task.checkpointsDisabledByFailure)) { + throw new Error( + "Checkpoint save with write metadata could not initialize the checkpoint service; the write has no rollback metadata.", + ) + } + return + } } TelemetryService.instance.captureCheckpointCreated(task.taskId) - // Start the checkpoint process in the background. - return service - .saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage }) - .catch((err) => { - console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) - task.enableCheckpoints = false + // Serialize the complete per-task checkpoint transaction (staging + + // commit + journal replacement + card): a background save can no longer + // interleave its staging with a later per-write transaction, and every + // subsequent save awaits the earlier caller's promise before proceeding. + // The journal-file lock inside appendChanges uses a distinct key, so no + // reentrancy is required here. + const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath + // A save that carries write metadata needs a stable journal location for + // the whole commit + journal transaction. The location is already + // captured above (for the transaction lock key), so it survives the + // provider reference disappearing while the Git save is awaited. Without + // a location at all, fail before committing so the tool reports an + // explicit failure instead of a checkpoint nobody can roll back. + if (hasWriteMetadata && !globalStorageDir) { + throw new Error( + "Checkpoint save with write metadata requires a stable journal location, but the provider reference or global storage path is unavailable.", + ) + } + const runTransaction = async () => { + // Save failures keep the historical behavior for non-write calls: + // logged, checkpoints disabled, and the save resolves undefined. A + // write-metadata save must not swallow a failed commit: the file write + // already happened, so the caller must see an explicit failure instead + // of a success whose rollback metadata does not exist. Journal/card + // work below runs only for a real commit, and its failures propagate to + // the caller instead of flowing through this handler. + const savePromise = service.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { + allowEmpty: force, + suppressMessage, }) + // `void` keeps the historical union member of the awaited legacy + // catch path (and of this function's return type) unchanged. + let result: Awaited | void + if (hasWriteMetadata) { + try { + result = await savePromise + } catch (err) { + console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) + disableCheckpointsAfterFailure(task) + throw new Error( + `Checkpoint save failed after a file write; the write has no rollback metadata: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ) + } + } else { + result = await savePromise.catch((err) => { + console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) + disableCheckpointsAfterFailure(task) + }) + } + // Paths excluded from the shadow repository (node_modules, build + // output, .gitignore'd files) never enter the journal: the shadow + // commit cannot contain them, so a journal entry would route a later + // rollback into restoreFile's delete branch, where absence from the + // commit would delete the user's live file. Skipping excluded paths + // at journaling time keeps rollback metadata honest and removes + // excluded-path noise from the journal and the change card. The + // filter runs BEFORE the no-commit rejection below: an all-excluded + // step stages nothing, so requiring a commit would make the tool's + // outcome depend on unrelated workspace changes. + const writes: CheckpointWriteInfo[] = [] + if (hasWriteMetadata) { + for (const w of journalWrites ?? []) { + if (await service.isPathIgnored(w.path)) { + continue + } + writes.push(w) + } + if (writes.length === 0) { + // Every write landed on an excluded path: nothing to journal and + // nothing to show, and no rollback metadata is expected - the + // save resolves whether or not unrelated changes produced a + // commit. + return result + } + } + + // A save that resolves without a commit despite non-excluded writes + // produced no rollback point: journaling is skipped and a later + // restore reports no entry. With write metadata that is a partial + // persistence, not a success. + if (hasWriteMetadata && !result?.commit) { + throw new Error( + "Checkpoint save with write metadata did not produce a commit; the write has no rollback metadata.", + ) + } + // B2: record successful file writes in the per-task change journal. + // Only a real commit produces an entry (an empty or failed save + // resolves to undefined / rejects), and non-write checkpoint calls + // (e.g. the task-start baseline) pass no `write` value at all. + // A no-op checkpoint carries an empty writes array: unrelated workspace + // changes can still produce a commit, and emitting a card for it would + // show a change card with zero files. The checkpoint itself still runs. + if (result?.commit && hasWriteMetadata) { + if (!globalStorageDir) { + // Defensive: the pre-commit guard above normally makes this + // unreachable. If the location is nonetheless gone while the + // commit succeeded, surface the partial state instead of + // skipping the journal silently. + throw new CheckpointPartialPersistenceError( + `Checkpoint ${result.commit} was committed but no stable journal location was available (files: ${writes + .map((w) => w.path) + .join(", ")}); rollback metadata is incomplete.`, + result.commit, + writes, + ) + } + // Persist the whole step's journal update as one serialized, + // atomic replacement (temp file + fsync + rename + directory + // sync), preserving write order. A journal failure is logged and + // propagated: the checkpoint must not be reported fully durable + // while its journal entries were lost. + try { + await appendChanges( + globalStorageDir, + task.taskId, + writes.map((w) => ({ + path: w.path, + operation: w.operation, + checkpointId: result.commit, + ...(w.diffStats ? { diffStats: w.diffStats } : {}), + })), + ) + } catch (err) { + console.error("[Task#checkpointSave] failed to persist change journal entries", err) + throw new CheckpointPartialPersistenceError( + `Checkpoint ${result.commit} was committed but its change journal could not be persisted (files: ${writes + .map((w) => w.path) + .join(", ")}); rollback metadata is incomplete.`, + result.commit, + writes, + ) + } + + // B3a: emit the per-step change card now that the checkpoint commit + // exists and the journal update is durable (a journal failure above + // propagated and skips the card). The card reuses the approval diff/stats the tool already + // computed and is always emitted (auto-approved steps included); + // a card failure is logged and never disables checkpoints. + try { + const state = await task.providerRef.deref()?.getState() + const card = buildChangeCardPayload(result.commit, writes, state?.changeCardDetail) + await task.say("change_card", JSON.stringify(card), undefined, undefined, undefined, undefined, { + isNonInteractive: true, + }) + } catch (err) { + console.error("[Task#checkpointSave] failed to emit change card", err) + } + } + return result + } + if (globalStorageDir) { + return withTaskLock(`${globalStorageDir}\u0000${task.taskId}`, runTransaction) + } + return runTransaction() } export type CheckpointRestoreOptions = { @@ -297,7 +563,7 @@ export async function checkpointRestore( provider?.cancelTask() } catch { provider?.log("[checkpointRestore] disabling checkpoints for this task") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) } } @@ -387,6 +653,6 @@ export async function checkpointDiff(task: Task, { commitHash, mode }: Checkpoin } catch { const provider = task.providerRef.deref() provider?.log("[checkpointDiff] disabling checkpoints for this task") - task.enableCheckpoints = false + disableCheckpointsAfterFailure(task) } } diff --git a/src/core/checkpoints/rollback.ts b/src/core/checkpoints/rollback.ts new file mode 100644 index 0000000000..508789c957 --- /dev/null +++ b/src/core/checkpoints/rollback.ts @@ -0,0 +1,277 @@ +/** + * Per-file / per-step checkpoint rollback (B3c). + * + * "Rollback" means UNDOING the change-card step: every file the step touched + * is restored to the state it had BEFORE the step ran. The restore target is + * resolved from the B2 change journal (`changes.jsonl`), whose entries record + * — in write order — the checkpoint commit each successful write produced: + * + * - a file written by an earlier step resolves to that earlier entry's + * checkpoint (the file's post-write state after the previous step, i.e. its + * pre-step state); + * - a file no earlier step wrote resolves to the task-start baseline + * (`service.baseHash`): undoing the step that created a file removes it, + * and undoing the step that deleted one brings it back; + * - `restoreLatestFile` is the forward direction: it brings a file back to + * the content of its most recent recorded write (a successful no-op when + * the task never wrote the file). + * + * A file is only rolled back from the card of its most recent step: undoing + * an older step for a file that a later step wrote again would overwrite the + * newer state, so such a rollback is rejected (a full checkpoint restore + * still reaches any older state). A journal that cannot be located or read + * fails the restore instead of masquerading as "the task wrote nothing". + * + * Restores reuse the existing shadow-git service (`getCheckpointService` → + * `RepoPerTaskCheckpointService.restoreFile`, the same instance whose + * `restoreCheckpoint` the checkpoints UI uses) — nothing is forked. Only the + * named file's working-tree content is replaced; the shadow repo's HEAD and + * the checkpoint list are untouched (unlike a full `restoreCheckpoint`). + */ +import type { Task } from "../task/Task" + +import { getCheckpointService } from "./index" +import { loadChanges, type ChangeJournalEntry } from "./changeJournal" + +export interface RollbackFileOutcome { + filePath: string + success: boolean + error?: string + /** True when `restoreLatestFile` found no recorded write: the working tree was left as-is. */ + noOp?: boolean +} + +export interface RollbackStepOutcome { + /** The step checkpoint the files were resolved against, when provided. */ + checkpointId?: string + files: RollbackFileOutcome[] +} + +const NOT_ENABLED_ERROR = "Checkpoints are not enabled for this task" +const NO_TARGET_ERROR = "No checkpoint available to restore" +const NOT_IN_STEP_ERROR = "File is not part of this step's checkpoint" +const NO_ENTRY_ERROR = "No change journal entry for this file" +const NO_JOURNAL_ERROR = "Change journal is unavailable for this task" +const NOT_LATEST_ERROR = "File was modified in a later step; roll back the latest change card first" + +type CheckpointService = NonNullable>> + +/** A readable journal (possibly legitimately empty) or the reason it could not be loaded. */ +type LoadedJournal = { entries: ChangeJournalEntry[] } | { error: string } + +// `undefined` = the journal cannot be located (provider reference gone); read failures (permissions, I/O) propagate. +async function loadTaskEntries(task: Task): Promise { + const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath + + return globalStorageDir ? loadChanges(globalStorageDir, task.taskId) : undefined +} + +// Single discriminated result for callers: a readable journal (possibly empty) or a failure. +async function loadTaskJournal(task: Task): Promise { + try { + const entries = await loadTaskEntries(task) + + if (entries === undefined) { + return { error: NO_JOURNAL_ERROR } + } + + return { entries } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Change journal could not be read: ${message}` } + } +} + +/** The most recent journal entry recorded for `filePath`, if any. */ +function latestEntry(entries: ChangeJournalEntry[], filePath: string): ChangeJournalEntry | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].path === filePath) { + return entries[i] + } + } + return undefined +} + +/** + * Resolve the checkpoint a file must be restored from in order to undo the + * step identified by `stepCheckpointId` (see the module docstring for the + * resolution rules). + */ +function preStepRestoreTarget( + entries: ChangeJournalEntry[], + filePath: string, + stepCheckpointId: string, +): { target?: string; baseline?: boolean; error?: string } { + const fileEntries = entries.filter((entry) => entry.path === filePath) + const stepIndex = fileEntries.findIndex((entry) => entry.checkpointId === stepCheckpointId) + + if (stepIndex === -1) { + return { error: NOT_IN_STEP_ERROR } + } + + // Only the file's most recent step may be rolled back: restoring an older + // state would overwrite the file's newer writes. A multi-write step shares + // one checkpoint id, so compare on the latest entry's checkpoint id. + const latest = fileEntries[fileEntries.length - 1] + + if (latest.checkpointId !== stepCheckpointId) { + return { error: NOT_LATEST_ERROR } + } + + if (stepIndex === 0) { + return { baseline: true } + } + return { target: fileEntries[stepIndex - 1].checkpointId } +} + +/** + * Run one `restoreFile` and shape the outcome. A failed restore never throws + * out of the rollback API; it is reported on the per-file outcome instead. + */ +async function performRestore( + service: CheckpointService, + target: string, + filePath: string, +): Promise { + try { + await service.restoreFile(target, filePath) + return { filePath, success: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[checkpointRollback] failed to restore ${filePath} from checkpoint ${target}: ${message}`) + return { filePath, success: false, error: message } + } +} + +/** + * Restore a single file to the state it had BEFORE the change-card step + * identified by `stepCheckpointId` — undoing that step's write to the file. + */ +export async function rollbackFile( + task: Task, + stepCheckpointId: string, + filePath: string, +): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { filePath, success: false, error: NOT_ENABLED_ERROR } + } + + const journal = await loadTaskJournal(task) + + if ("error" in journal) { + return { filePath, success: false, error: journal.error } + } + + const resolved = preStepRestoreTarget(journal.entries, filePath, stepCheckpointId) + + if (resolved.error) { + return { filePath, success: false, error: resolved.error } + } + + const target = resolved.baseline ? service.baseHash : resolved.target + + if (!target) { + return { filePath, success: false, error: NO_TARGET_ERROR } + } + + return performRestore(service, target, filePath) +} + +/** + * Restore every file of a step to the state it had before the step ran. + * + * `stepFiles` comes from the change-card payload (the B2 journal entries for + * the step's checkpoint id). Each file is resolved to its pre-step checkpoint + * through the journal (see the module docstring); without a step checkpoint id + * the latest journal entry per file is used instead (restoring to the file's + * last recorded state, the same direction as `restoreLatestFile`). + */ +export async function rollbackStep( + task: Task, + stepFiles: string[], + stepCheckpointId?: string, +): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { + checkpointId: stepCheckpointId, + files: stepFiles.map((filePath) => ({ filePath, success: false, error: NOT_ENABLED_ERROR })), + } + } + + const journal = await loadTaskJournal(task) + const files: RollbackFileOutcome[] = [] + + if ("error" in journal) { + const journalError = journal.error + return { + checkpointId: stepCheckpointId, + files: stepFiles.map((filePath) => ({ filePath, success: false, error: journalError })), + } + } + + const entries = journal.entries + + for (const filePath of stepFiles) { + if (stepCheckpointId) { + const resolved = preStepRestoreTarget(entries, filePath, stepCheckpointId) + + if (resolved.error) { + files.push({ filePath, success: false, error: resolved.error }) + continue + } + + const target = resolved.baseline ? service.baseHash : resolved.target + + if (!target) { + files.push({ filePath, success: false, error: NO_TARGET_ERROR }) + continue + } + + files.push(await performRestore(service, target, filePath)) + continue + } + + const latest = latestEntry(entries, filePath) + + if (!latest) { + files.push({ filePath, success: false, error: NO_ENTRY_ERROR }) + continue + } + + files.push(await performRestore(service, latest.checkpointId, filePath)) + } + + return { checkpointId: stepCheckpointId, files } +} + +/** + * Restore one file to the latest recorded version: the content of its most + * recent successful write checkpoint (the forward direction to a rollback). + * A file the task never wrote has no recorded version — the working tree is + * left as-is and the outcome is a successful no-op. + */ +export async function restoreLatestFile(task: Task, filePath: string): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { filePath, success: false, error: NOT_ENABLED_ERROR } + } + + const journal = await loadTaskJournal(task) + + if ("error" in journal) { + return { filePath, success: false, error: journal.error } + } + + const latest = latestEntry(journal.entries, filePath) + + if (!latest) { + return { filePath, success: true, noOp: true } + } + + return performRestore(service, latest.checkpointId, filePath) +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 118a7d6494..1360a667a7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -471,6 +471,9 @@ export class Task extends EventEmitter implements TaskLike { public lastMessageTs?: number private autoApprovalTimeoutRef?: NodeJS.Timeout + // B1: task-start baseline, recorded at most once (initiateTaskLoop also runs on resume). + private taskStartBaselineDone = false + // Tool Use consecutiveMistakeCount: number = 0 consecutiveMistakeLimit: number @@ -508,6 +511,11 @@ export class Task extends EventEmitter implements TaskLike { checkpointTimeout: number checkpointService?: RepoPerTaskCheckpointService checkpointServiceInitializing = false + // Set when checkpoints were disabled by a failure (init, save, restore, + // or diff), so checkpointSave can distinguish an earlier failure from a + // user who never enabled checkpoints, without inferring it from live + // settings (which can change after the task started). + checkpointsDisabledByFailure = false // Message Queue Service public readonly messageQueueService: MessageQueueService @@ -1699,7 +1707,12 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() - const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() + // Change cards embed raw unified diffs of approved writes (full + // detail mode): keep them local to the webview so task sync never + // uploads the content of secret-bearing files (.env, PII) to the + // cloud endpoint. + const shouldCaptureMessage = + message.partial !== true && CloudService.isEnabled() && message.say !== "change_card" if (shouldCaptureMessage) { CloudService.instance.captureEvent({ @@ -1749,8 +1762,12 @@ export class Task extends EventEmitter implements TaskLike { await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) this.emit(RooCodeEventName.Message, { action: "updated", message }) - // Check if we should sync to cloud and haven't already synced this message - const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() + // Check if we should sync to cloud and haven't already synced this + // message. Change cards stay out of cloud capture (full diffs embed + // file content; see addToClineMessages) - the guard is repeated here + // because a card message is never tracked as synced. + const shouldCaptureMessage = + message.partial !== true && CloudService.isEnabled() && message.say !== "change_card" const hasNotBeenSynced = !this.cloudSyncedMessageTimestamps.has(message.ts) if (shouldCaptureMessage && hasNotBeenSynced) { @@ -3843,6 +3860,47 @@ export class Task extends EventEmitter implements TaskLike { // arm needed. void getCheckpointService(this) + // B1 task-start baseline: a suppressed pre-task root commit (default-on). + // History resumes skip it: the baseline belongs to the original task start + // (exactly once per task), and the resumed task's shadow repo already carries + // a baseline for the first post-resume write checkpoint to diff against. + // An already-aborted task skips it too: the loop will never run, so the + // save would only add latency to a task that is being disposed. + if (!this.taskStartBaselineDone && !this._isHistoryTask && !this.abort) { + let baselineEnabled: boolean | undefined + try { + baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints + } catch (error) { + // A rejected state lookup must not abort task startup: fall back + // to the default-on semantics used when the setting is absent. + console.error("[Task] task-start baseline settings read failed:", error) + } + // Cancellation can arrive while the state lookup is pending; recheck + // before the save so a canceled task performs no checkpoint work. + if (baselineEnabled === false || this.abort) { + this.taskStartBaselineDone = true + } else { + try { + // allowEmpty=true so a clean workspace still produces the baseline + // commit; awaited so the first per-write checkpoint cannot interleave. + const baseline = await this.checkpointSave(true, true) + // The flag is set only once the baseline actually committed: a save + // that no-ops behind a failed or timed-out service initialization is + // retried on a later loop entry instead of being marked done without + // a rollback point. + if (baseline?.commit) { + this.taskStartBaselineDone = true + } + } catch (error) { + // The baseline is best-effort and runs before TaskStarted: a + // checkpoint error must not abort task startup (startTask rethrows + // non-abort errors from initiateTaskLoop). Log and continue; the + // next loop entry retries the baseline. + console.error("[Task] task-start checkpoint baseline failed:", error) + } + } + } + let nextUserContent = userContent let includeFileDetails = true @@ -4276,7 +4334,6 @@ export class Task extends EventEmitter implements TaskLike { // under the canonical name slip past a name comparison. The first call wins. const idAlreadyUsed = this.assistantMessageContent.some( (entry) => - // Stryker disable next-line ConditionalExpression: only tool_use and mcp_tool_use entries ever carry an id — the text block pushed in the text case has no id field and the start events reaching this guard always carry the string id locked in by processRawChunk — so the type check cannot change the outcome of entry.id === event.id for any entry this pipeline produces; the false replacement is additionally killed by the rejection tests. (entry.type === "tool_use" || entry.type === "mcp_tool_use") && (entry as { id?: string }).id === event.id, ) @@ -4329,7 +4386,6 @@ export class Task extends EventEmitter implements TaskLike { // keep the deprecated single-entry-per-ID lookup. let streamingKey: string | undefined - // Stryker disable next-line ConditionalExpression: event.name is always a string on this path — the only delta events reaching this handler are emitted by processRawChunk with the tracked start name (never undefined), so the legacy nameless branch below is unreachable defensive code and the conditional always takes the named side. if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { @@ -4346,7 +4402,6 @@ export class Task extends EventEmitter implements TaskLike { } const partialToolUse = - // Stryker disable next-line ConditionalExpression: streamingKey can never be undefined here — makeStreamingKey always returns a string and the legacy lookup branch above is unreachable (see the directive on the event.name check) — so the null side is unreachable defensive code. streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( @@ -4355,7 +4410,6 @@ export class Task extends EventEmitter implements TaskLike { nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression, EqualityOperator: streamingKey is always a defined string on this path (makeStreamingKey never returns undefined; the legacy branch is unreachable per the directives above), and processStreamingChunk returns a partial ToolUse for a known key, so both defensive conditions reduce to the partialToolUse check and cannot change the observed behavior. if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. @@ -4752,7 +4806,6 @@ export class Task extends EventEmitter implements TaskLike { if (event.type === "tool_call_end") { // Create compound key for deduplication (same pattern as streaming handler). // End events carry the tool name; fall back to the id for events that don't. - // Stryker disable next-line LogicalOperator: event.name is always a string on this path — finalizeRawChunks emits end events with the tracked start name (a string, set before the tracker can start), so the ?? event.id fallback guards a nameless end event this pipeline cannot produce. let eventName = event.name ?? event.id let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName) @@ -4769,13 +4822,11 @@ export class Task extends EventEmitter implements TaskLike { // name (provider quirk), the compound-key lookup above misses and both the // parser entry and the dedup tracking would stay stale. Resolve the tracked // entry by id and target the real compound key for finalization and cleanup. - // Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: the both-miss case is unreachable via the raw-chunk pipeline — end events carry the locked start name so the compound key resolves the registered entry, and a same-ID rejected-start end is always processed after the accepted start's end has already removed that entry; this defensive path protects against a cross-name end event the pipeline cannot produce. if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression: unreachable defensive region — getStreamingToolCallById can only resolve when the both-miss case above is reachable, which the raw-chunk pipeline cannot produce (see the directive there). if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) @@ -6455,6 +6506,14 @@ export class Task extends EventEmitter implements TaskLike { * @param context - Context string for logging (e.g., the calling tool name) */ public processQueuedMessages(): void { + // An aborted task must not dequeue queued messages: submitUserMessage + // rejects once abort is set, so consuming the queue here would drop the + // message instead of leaving it for the resume/abort flow. Tools await + // checkpoint saves before handing off, so a cancellation can land between + // the write and this call. + if (this.abort) { + return + } try { if (!this.messageQueueService.isEmpty()) { const queued = this.messageQueueService.dequeueMessage() diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 3066613263..40e69e00a4 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6824,6 +6824,255 @@ describe("Cline", () => { }) }) + describe("task-start baseline (B1 perWriteCheckpoints)", () => { + it("records one suppressed baseline checkpoint per Task instance at loop start", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + // A committed baseline: the done flag is set only on a real + // commit, so the double must resolve one for the once-per-instance + // assertion to exercise the flag flip. + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue({ commit: "baseline-commit" }) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + + // End the loop after the first request; abort stays false so the + // baseline block (not the loop) is what the save spy observes. + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledWith(true, true) + }) + + it("does not record a baseline when the task resumes from history", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "resumed-baseline-task", + number: 1, + ts: Date.now(), + task: "resumed baseline task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + + // A history resume must not re-record the baseline: it belongs to the + // original task start (exactly once per task). + expect(saveSpy).not.toHaveBeenCalled() + }) + + it("records the baseline checkpoint when the setting is unset (default-on)", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline unset task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + // Unset: the property is absent from the state, so default-on applies. + const unsetState = { ...state } + Reflect.deleteProperty(unsetState, "perWriteCheckpoints") + vi.spyOn(mockProvider, "getState").mockResolvedValue(unsetState as typeof state) + + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledWith(true, true) + }) + + it("does not record a baseline checkpoint when perWriteCheckpoints is disabled", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline disabled task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...state, perWriteCheckpoints: false }) + + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).not.toHaveBeenCalled() + }) + + it("does not record a baseline when the task is already aborted", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "aborted baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + + task.abort = true + + await taskAccess.initiateTaskLoop([]) + + // A task cancelled before the loop starts must not pay for (or await) + // a baseline checkpoint it will never use. + expect(saveSpy).not.toHaveBeenCalled() + }) + + it("does not record a baseline when cancellation arrives during the state lookup", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "racy baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + type StateResult = Awaited> + let resolveState: (value: StateResult | PromiseLike) => void = () => {} + const statePromise = new Promise((resolve) => (resolveState = resolve)) + vi.spyOn(mockProvider, "getState").mockReturnValue(statePromise) + + const loopPromise = taskAccess.initiateTaskLoop([]) + // Cancellation lands while the state lookup is still pending. + task.abort = true + resolveState({ ...state, perWriteCheckpoints: true }) + await loopPromise + + // The post-lookup recheck must skip the save for the canceled task. + expect(saveSpy).not.toHaveBeenCalled() + }) + + it("awaits the baseline checkpoint before entering the request loop", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline await task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveSpy = vi + .spyOn(task, "checkpointSave") + .mockImplementation(() => new Promise((resolve) => (resolveSave = resolve))) + const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...(await mockProvider.getState()) }) + const loopPromise = taskAccess.initiateTaskLoop([]) + + // The loop must not enter while the baseline checkpoint is still in flight. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(saveSpy).toHaveBeenCalledOnce() + expect(requestSpy).not.toHaveBeenCalled() + + resolveSave() + await loopPromise + expect(requestSpy).toHaveBeenCalled() + }) + + it("continues task startup when the baseline checkpoint fails", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline failure task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const boom = new Error("baseline boom") + vi.spyOn(task, "checkpointSave").mockRejectedValue(boom) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // The baseline runs before TaskStarted: a checkpoint error must not + // abort task startup (startTask rethrows non-abort errors from + // initiateTaskLoop). The loop must still be entered. + await taskAccess.initiateTaskLoop([]) + + expect(requestSpy).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith("[Task] task-start checkpoint baseline failed:", boom) + consoleErrorSpy.mockRestore() + }) + + it("retries the baseline on a later loop entry when a save produced no commit", async () => { + // getCheckpointService can resolve to no service (init failure or a + // bounded init wait timing out): the save then completes without a + // commit, and the baseline must not be marked done - a later loop + // entry gets to retry it instead of losing it forever. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "retry baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi + .spyOn(task, "checkpointSave") + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ commit: "baseline-commit" }) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + + vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + expect(saveSpy).toHaveBeenCalledOnce() + + await taskAccess.initiateTaskLoop([]) + expect(saveSpy).toHaveBeenCalledTimes(2) + }) + + it("starts the task default-on when the baseline settings read rejects", async () => { + // ClineProvider.getState awaits custom-mode loading and can reject: + // a failed settings read must not stop task startup. Default-on + // semantics apply (the setting is treated as absent) and the loop + // still runs. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "rejected-read baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue({ commit: "baseline-commit" }) + vi.spyOn(mockProvider, "getState").mockRejectedValueOnce(new Error("custom mode load failed")) + + const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledWith(true, true) + expect(requestSpy).toHaveBeenCalled() + }) + }) + describe("start()", () => { it("should be a no-op if the task was already started in the constructor", () => { const task = new Task({ @@ -7665,6 +7914,90 @@ describe("Cline", () => { ) }) }) + + // Change cards embed raw unified diffs of approved writes (full detail + // mode): task sync must not upload their file content to the cloud. + describe("cloud capture of change cards", () => { + const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)) + + it("keeps change_card messages out of cloud task-message capture", async () => { + const { CloudService } = await import("@roo-code/cloud") + const enabledSpy = vi.spyOn(CloudService, "isEnabled").mockReturnValue(true) + const captureSpy = vi.fn() + // Minimal double for the singleton getter: the capture path only + // calls captureEvent on the instance, and the real singleton is + // not initialized in this harness (the getter would throw). + const cloudDouble = { captureEvent: captureSpy } as unknown as import("@roo-code/cloud").CloudService + const instanceSpy = vi.spyOn(CloudService, "instance", "get").mockReturnValue(cloudDouble) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await flushMicrotasks() + captureSpy.mockClear() + + const cardPayload = JSON.stringify({ + checkpointId: "abc123", + files: [{ path: ".env", additions: 1, deletions: 0, diff: "+SECRET_KEY=hunter2" }], + }) + await task.say("change_card", cardPayload, undefined, undefined, undefined, undefined, { + isNonInteractive: true, + }) + await flushMicrotasks() + + const cardCaptures = captureSpy.mock.calls.filter( + (call) => + (call[0] as { properties?: { message?: { say?: string } } }).properties?.message?.say === + "change_card", + ) + expect(cardCaptures).toHaveLength(0) + + // Control: an ordinary non-partial say still syncs while task + // capture is enabled, so the exclusion is change_card-specific. + await task.say("text", "ordinary message") + await flushMicrotasks() + const textCaptures = captureSpy.mock.calls.filter( + (call) => + (call[0] as { properties?: { message?: { say?: string } } }).properties?.message?.say === "text", + ) + expect(textCaptures.length).toBeGreaterThan(0) + + enabledSpy.mockRestore() + instanceSpy.mockRestore() + }) + + it("keeps change_card messages out of the updateClineMessage capture path", async () => { + const { CloudService } = await import("@roo-code/cloud") + const enabledSpy = vi.spyOn(CloudService, "isEnabled").mockReturnValue(true) + const captureSpy = vi.fn() + const cloudDouble = { captureEvent: captureSpy } as unknown as import("@roo-code/cloud").CloudService + const instanceSpy = vi.spyOn(CloudService, "instance", "get").mockReturnValue(cloudDouble) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await flushMicrotasks() + captureSpy.mockClear() + + const cardMessage = { + ts: Date.now(), + type: "say" as const, + say: "change_card" as const, + text: JSON.stringify({ checkpointId: "abc", files: [{ path: ".env", diff: "+SECRET=x" }] }), + } + await task["updateClineMessage"](cardMessage) + expect(captureSpy).not.toHaveBeenCalled() + + enabledSpy.mockRestore() + instanceSpy.mockRestore() + }) + }) }) describe("Queued message processing after condense", () => { @@ -7747,6 +8080,34 @@ describe("Queued message processing after condense", () => { expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"]) expect(task.messageQueueService.isEmpty()).toBe(true) }) + it("does not dequeue queued messages once the task is aborted", () => { + // Tools await the checkpoint before handing off queued work; a + // cancellation landing during that await must not consume the queued + // message: submitUserMessage rejects once abort is set, so dequeuing + // would drop the input instead of leaving it for the resume flow. + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + const provider = createProvider() + const task = new Task({ + provider, + apiConfiguration: apiConfig, + task: "initial task", + startTask: false, + }) + const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + + task.messageQueueService.addMessage("queued text") + task.abort = true + try { + task.processQueuedMessages() + } finally { + task.abort = false + } + + expect(submitSpy).not.toHaveBeenCalled() + expect(task.messageQueueService.isEmpty()).toBe(false) + }) it("does not cross-drain queues between separate tasks", async () => { const providerA = createProvider() diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 2713e08ffe..968cdb4e42 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -6,6 +6,8 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { Task } from "../task/Task" +import { checkpointSave } from "../checkpoints" +import { checkAutoApproval } from "../auto-approval" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" @@ -20,6 +22,25 @@ interface ApplyPatchParams { patch: string } +/** + * B2: result of a single file operation within a patch. `succeeded` controls + * the whole-patch success state (and therefore the per-patch checkpoint), + * while `wrote` records whether the operation actually wrote a file — a no-op + * update must not produce a change-journal entry for a file that was never + * written. + */ +interface ApplyPatchFileOpResult { + succeeded: boolean + wrote: boolean + /** + * Successful per-file result message. Buffered by execute and emitted only + * after the post-patch checkpoint + journal persistence succeeded, so a + * success is never reported before its journal save completes. Failure + * results stay immediate. + */ + resultMessage?: string +} + export class ApplyPatchTool extends BaseTool<"apply_patch"> { readonly name = "apply_patch" as const @@ -56,6 +77,47 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const { patch } = params const { handleError, pushToolResult } = callbacks + // Declared before the try so the finally block can hand off queued + // messages when a write succeeded but the post-patch checkpoint failed. + const successfulChanges: ApplyPatchFileChange[] = [] + const bufferedResults: string[] = [] + // Set once the success-path flush actually proceeds to checkpointSave, so + // the catch below does not persist the same writes twice while a + // settings-read rejection before that decision leaves the catch flush + // armed with default-on semantics. + let writeMetadataFlushed = false + // Cached perWriteCheckpoints lookup shared by the success-path flush and + // the catch flush: the catch must not call getState() again, because a + // rejecting settings read must not skip persisting writes already on disk. + let perWriteCheckpoints: boolean | undefined + // Journal payload builder shared by the success-path flush and the + // catch-path flush for writes recorded before an unexpected throw. + const journalEntries = () => + successfulChanges.flatMap((change) => { + const written = { + path: change.movePath ?? change.path, + operation: change.type === "add" ? ("create" as const) : change.type, + ...(change.diffStats + ? { + diffStats: { + additions: change.diffStats.added, + deletions: change.diffStats.removed, + }, + } + : {}), + ...(change.diff ? { diff: change.diff } : {}), + ...(change.autoApproved ? { autoApproved: true } : {}), + } + // A completed move also DELETES its source: journal that deletion + // after the destination write so a step rollback restores the + // removed file from its pre-step state. A move whose source unlink + // failed left the source on disk and gets no deletion entry. + if (change.movePath && change.moveSourceDeleted) { + return [written, { path: change.path, operation: "delete" as const }] + } + return [written] + }) + try { // Validate required parameters if (!patch) { @@ -102,7 +164,14 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each file change + // Process each file change. The handlers report whether their file + // operation succeeded (which controls the whole-patch checkpoint); each + // write is recorded through the onWrote callback at the moment it lands + // on disk, so a throw from post-write bookkeeping cannot lose the + // journal entry and change card for a completed write (a no-op update + // never calls the callback). A rejected approval or a failed local + // write never gets checkpointed as a success. + let patchSucceeded = true for (const change of changes) { const relPath = change.path const absolutePath = path.resolve(task.cwd, relPath) @@ -112,7 +181,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!accessAllowed) { await task.say("rooignore_error", relPath) pushToolResult(formatResponse.rooIgnoreError(relPath)) - return + // B2 partial flush: break, not return - an earlier hunk may have + // already written a file, and those writes must still receive the + // checkpoint, journal entry, and change card. Failing the patch + // also keeps the consecutive-mistake counter from resetting. + patchSucceeded = false + break } // Check if file is write-protected @@ -120,20 +194,135 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + const addResult = await this.handleAddFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + () => successfulChanges.push(change), + ) + patchSucceeded = addResult.succeeded && patchSucceeded + if (addResult.resultMessage) { + bufferedResults.push(addResult.resultMessage) + } } else if (change.type === "delete") { // Delete file - await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + const deleteResult = await this.handleDeleteFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + () => successfulChanges.push(change), + ) + patchSucceeded = deleteResult.succeeded && patchSucceeded + if (deleteResult.resultMessage) { + bufferedResults.push(deleteResult.resultMessage) + } } else if (change.type === "update") { - // Update file - await this.handleUpdateFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + // Update file (a no-op update succeeds without writing) + const updateResult = await this.handleUpdateFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + () => successfulChanges.push(change), + ) + patchSucceeded = updateResult.succeeded && patchSucceeded + if (updateResult.resultMessage) { + bufferedResults.push(updateResult.resultMessage) + } + } + } + + // Reset the consecutive-mistake counter only after a fully successful + // patch: a failed operation (missing file, rejected move, ...) increments + // the counter, and the count must survive a partially written patch so + // the auto-approval safety net still engages across consecutive failed + // patches. + if (patchSucceeded) { + task.consecutiveMistakeCount = 0 + } + + // B1: one checkpoint for the whole patch (not per file). Live + // setting with default-on semantics: skip only when explicitly false. + // B3a partial flush: the checkpoint and journal are also taken when at + // least one file operation wrote, even if a later hunk of the same + // patch failed - the journal then documents exactly the subset that + // was written, and the failed operation was already reported through + // pushToolResult. A fully failed patch (nothing written) leaves no + // checkpoint behind. + if (patchSucceeded || successfulChanges.length > 0) { + perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints + if (perWriteCheckpoints !== false) { + writeMetadataFlushed = true + // B2: one journal entry per file that was actually written by + // the patch (the simplest correct design for multi-file patches), + // all referencing the single checkpoint above. A no-op update + // contributes no entry because nothing was written. `movePath`, + // when present, is the file's final location, and a completed move + // additionally journals its source deletion. B3a: the per-file + // approval diff/stats and auto-approval state, retained by the + // handlers, feed the per-step change card. + // Awaited: a later write must not interleave with this patch's + // staging/commit/journal/change-card work. checkpointSave rejects with a + // CheckpointPartialPersistenceError when a committed checkpoint's + // journal update fails; the catch below reports it. + await checkpointSave(task, false, true, journalEntries()) } } - task.consecutiveMistakeCount = 0 + // CR follow-up: successful per-file results are emitted only after + // the post-patch checkpoint + journal persistence above succeeded. + // A persistence failure routes to the catch below and the buffered + // successes are dropped in favor of the tool failure result; + // failure results stayed immediate. + for (const message of bufferedResults) { + pushToolResult(message) + } } catch (error) { - await handleError("apply patch", error as Error) + // A handler that throws after recording its write (post-write + // bookkeeping such as trackFileContext) skips the success-path flush + // above. Persist the recorded writes here, awaited, before the generic + // error handling: the writes are on disk and the journal must document + // them. A flush already attempted above is not repeated, so a journal + // failure keeps the documented drop-buffered-results behavior. + let reportedError: unknown = error + if (!writeMetadataFlushed && successfulChanges.length > 0) { + if (perWriteCheckpoints !== false) { + let flushError: unknown + await checkpointSave(task, false, true, journalEntries()).catch((flushFailure) => { + flushError = flushFailure + }) + if (flushError !== undefined) { + // The completed write lacks rollback metadata: report the + // persistence failure alongside the bookkeeping error without + // replacing it as the reported failure. + const flushMessage = flushError instanceof Error ? flushError.message : String(flushError) + const baseMessage = error instanceof Error ? error.message : String(error) + console.error("[ApplyPatchTool] catch-path checkpoint flush failed:", flushError) + reportedError = new Error(`${baseMessage} (checkpoint flush also failed: ${flushMessage})`, { + cause: error, + }) + } + } + } + await handleError("apply patch", reportedError as Error) await task.diffViewProvider.reset() + } finally { + // B2: deferred from the per-file handlers - a queued write must not + // start until the post-patch checkpoint above has persisted. The + // handoff runs even when that checkpoint/journal persistence + // rejected: the file writes themselves succeeded and upstream + // handed off after every successful write. + if (successfulChanges.length > 0) { + task.processQueuedMessages() + } } } @@ -144,7 +333,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + onWrote?: () => void, + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file already exists @@ -155,7 +345,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File already exists: ${relPath}. Use Update File instead.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return { succeeded: false, wrote: false } } const newContent = change.newContent || "" @@ -194,6 +384,21 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) + // B3a: retain the approval diff/stats and auto-approval state so the + // post-loop checkpoint hook can build the per-step change card. + change.diff = sanitizedDiff + change.diffStats = diffStats + change.autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -209,7 +414,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return + return { succeeded: false, wrote: false } } // Save the changes @@ -219,23 +424,29 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + // Record the completed write before post-write bookkeeping can fail: + // a throw from trackFileContext must not lose the journal entry (and + // change card) for a file that is already on disk. + onWrote?.() + // Track file edit operation await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) - pushToolResult(message) await task.diffViewProvider.reset() - task.processQueuedMessages() + return { succeeded: true, wrote: true, resultMessage: message } } private async handleDeleteFile( + change: ApplyPatchFileChange, absolutePath: string, relPath: string, task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + onWrote?: () => void, + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file exists @@ -246,7 +457,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot delete a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return { succeeded: false, wrote: false } } const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) @@ -264,11 +475,24 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { isProtected: isWriteProtected, } satisfies ClineSayTool) + // B3a: auto-approval state feeds the per-step change card (deletes have + // no diff to thread). + change.autoApproved = + ( + await checkAutoApproval({ + state: await task.providerRef.deref()?.getState(), + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) if (!didApprove) { pushToolResult("Delete operation was rejected by the user.") - return + return { succeeded: false, wrote: false } } // Delete the file @@ -278,12 +502,15 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `Failed to delete file '${relPath}': ${error instanceof Error ? error.message : String(error)}` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return { succeeded: false, wrote: false } } + // The deletion is complete; record it before the buffered result can be + // dropped by a later failure. + onWrote?.() + task.didEditFile = true - pushToolResult(`Successfully deleted ${relPath}`) - task.processQueuedMessages() + return { succeeded: true, wrote: true, resultMessage: `Successfully deleted ${relPath}` } } private async handleUpdateFile( @@ -293,9 +520,14 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + onWrote?: () => void, + ): Promise { const { askApproval, pushToolResult } = callbacks + // A move reports failure when the original file cannot be deleted + // after the copy (both paths would remain on disk). + let moveSucceeded = true + // Check if file exists const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { @@ -304,7 +536,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot update a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return { succeeded: false, wrote: false } } const originalContent = change.originalContent || "" @@ -318,9 +550,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Generate and validate diff const diff = formatResponse.createPrettyPatch(relPath, originalContent, newContent) if (!diff) { - pushToolResult(`No changes needed for '${relPath}'`) + // A no-op change is not a failure: the patch processed cleanly and + // nothing was written, so the whole-patch success state is kept — + // but `wrote` stays false so the change journal does not document a + // write that never happened. await task.diffViewProvider.reset() - return + return { succeeded: true, wrote: false, resultMessage: `No changes needed for '${relPath}'` } } // Check experiment settings @@ -351,6 +586,21 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) + // B3a: retain the approval diff/stats and auto-approval state so the + // post-loop checkpoint hook can build the per-step change card. + change.diff = sanitizedDiff + change.diffStats = diffStats + change.autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -366,7 +616,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return + return { succeeded: false, wrote: false } } // Handle file move if specified @@ -379,7 +629,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("rooignore_error", change.movePath) pushToolResult(formatResponse.rooIgnoreError(change.movePath)) await task.diffViewProvider.reset() - return + return { succeeded: false, wrote: false } } // Check if destination path is write-protected @@ -391,7 +641,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return + return { succeeded: false, wrote: false } } // Check if destination path is outside workspace @@ -403,7 +653,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return + return { succeeded: false, wrote: false } } // Save new content to the new path @@ -422,13 +672,26 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await fs.writeFile(moveAbsolutePath, newContent, "utf8") } - // Delete the original file + // Delete the original file. A failed deletion leaves both paths on + // disk, so the move must be reported as a failure rather than + // checkpointed and journaled as a completed move. try { await fs.unlink(absolutePath) + change.moveSourceDeleted = true } catch (error) { + moveSucceeded = false console.error(`Failed to delete original file after move: ${error}`) + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `Move of '${relPath}' to '${change.movePath}' failed: could not delete the original file.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) } + // The destination write is on disk whether or not the source unlink + // succeeded; record it before bookkeeping can fail. + onWrote?.() + await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) } else { // Save changes to the same file @@ -438,15 +701,23 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + // Record the completed write before post-write bookkeeping can fail. + onWrote?.() + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) } task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) - pushToolResult(message) await task.diffViewProvider.reset() - task.processQueuedMessages() + if (!moveSucceeded) { + // The destination file was written on disk before the source + // deletion failed, so the write must still be checkpointed and + // journaled; the move itself is reported as failed. + return { succeeded: false, wrote: true, resultMessage: message } + } + return { succeeded: true, wrote: true, resultMessage: message } } override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index a7301e2ac9..5fe0d4ee95 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" -import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { type ClineSayTool, DEFAULT_PER_WRITE_CHECKPOINTS, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -11,6 +11,8 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { checkpointSave } from "../../core/checkpoints" +import { checkAutoApproval } from "../auto-approval" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -146,6 +148,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { const { askApproval, handleError, pushToolResult } = callbacks let relPathForErrorHandling: string | undefined let operationPreviewForErrorHandling: string | undefined + let didWrite = false const finalizePartialToolAskIfNeeded = async (relPath: string): Promise => { if (!this.didSendPartialToolAsk) { @@ -392,6 +395,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const perWriteCheckpoints = state?.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, @@ -455,8 +459,39 @@ export class EditFileTool extends BaseTool<"edit_file"> { } task.didEditFile = true + didWrite = true + + if (perWriteCheckpoints) { + // B2: the change-journal entry for this edit is appended inside + // checkpointSave (the hook stays a single call site), keyed by the + // checkpoint commit that call produces. B3a threads the approval + // diff (for the change card) and whether the step was auto- + // approved (auto-approved steps always get the compact card). + const autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Awaited: a later tool block must not interleave with this edit's + // staging/commit/journal/change-card work. checkpointSave rejects with a + // CheckpointPartialPersistenceError when a committed checkpoint's + // journal update fails; the catch below reports it. + await checkpointSave(task, false, true, { + path: relPath, + operation: isNewFile ? "create" : "update", + diffStats: diffStats ? { additions: diffStats.added, deletions: diffStats.removed } : undefined, + ...(sanitizedDiff ? { diff: sanitizedDiff } : {}), + ...(autoApproved ? { autoApproved: true } : {}), + }) + } - // Get the formatted response message + // Emitted only after checkpoint + journal persistence succeeded: a + // persistence failure must not report the edit as fully persisted. const replacementInfo = !isNewFile && expected_replacements > 1 ? ` (${expected_replacements} replacements)` : "" const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, isNewFile) @@ -465,9 +500,6 @@ export class EditFileTool extends BaseTool<"edit_file"> { await task.diffViewProvider.reset() this.resetPartialState() - - // Process any queued messages after file edit completes - task.processQueuedMessages() } catch (error) { if (relPathForErrorHandling) { await finalizePartialToolAskIfNeeded(relPathForErrorHandling) @@ -479,6 +511,13 @@ export class EditFileTool extends BaseTool<"edit_file"> { this.didSendPartialToolAsk = false this.partialToolAskRelPath = undefined this.resetPartialState() + // Hand off queued messages whenever the write itself succeeded - + // including when the post-write checkpoint/journal persistence then + // rejected (the write is real; upstream handed off after every + // successful write). + if (didWrite) { + task.processQueuedMessages() + } } } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 0c5c80abb9..e8f8648a76 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -1,7 +1,7 @@ import path from "path" import fs from "fs/promises" -import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { type ClineSayTool, DEFAULT_PER_WRITE_CHECKPOINTS, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" @@ -12,7 +12,9 @@ import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" +import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff, type DiffStats } from "../diff/stats" +import { checkpointSave } from "../checkpoints" +import { checkAutoApproval } from "../auto-approval" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -95,6 +97,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { isProtected: isWriteProtected, } + let didWrite = false + try { task.consecutiveMistakeCount = 0 @@ -102,11 +106,20 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const perWriteCheckpoints = state?.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, ) + // B2: the approval-diff stats for the write, shared by both the + // approval message and the change-journal entry below. B3a also + // reuses the sanitized unified diff itself for the per-step change + // card (never recomputed). + let approvalDiffStats: DiffStats | null = null + let approvalDiff = "" + let completeMessage = "" + if (isPreventFocusDisruptionEnabled) { task.diffViewProvider.editType = fileExists ? "modify" : "create" if (fileExists) { @@ -120,10 +133,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) - const completeMessage = JSON.stringify({ + approvalDiffStats = computeDiffStats(unified) + approvalDiff = unified + completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, - diffStats: computeDiffStats(unified) || undefined, + diffStats: approvalDiffStats || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) @@ -151,10 +166,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) - const completeMessage = JSON.stringify({ + approvalDiffStats = computeDiffStats(unified) + approvalDiff = unified + completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, - diffStats: computeDiffStats(unified) || undefined, + diffStats: approvalDiffStats || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) @@ -172,7 +189,49 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } task.didEditFile = true + didWrite = true + + // A write whose approval diff is empty leaves the shadow repository + // nothing to stage: the per-write checkpoint would resolve without + // a commit and the write-metadata rejection would report failure + // for a write that succeeded. Skip the checkpoint for that no-op + // (the write result is still pushed), mirroring EditFileTool's + // unchanged-content early return. + const contentUnchanged = fileExists && approvalDiff === "" + + if (perWriteCheckpoints && !contentUnchanged) { + // B2: the change-journal entry for this write is appended inside + // checkpointSave (the hook stays a single call site), keyed by the + // checkpoint commit that call produces. Await so the checkpoint + // (staging + commit + journal) finishes before the next queued write + // starts; otherwise two writes can collapse into one commit. B3a threads + // the approval diff (for the change card) and whether the step was + // auto-approved (auto-approved steps always get the compact card). + // A persistence failure - a committed checkpoint whose journal update + // was lost - propagates to the catch below (explicit tool failure + // handling, surfacing the checkpoint id and affected paths). + const autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + await checkpointSave(task, false, true, { + path: relPath, + operation: fileExists ? "update" : "create", + diffStats: approvalDiffStats + ? { additions: approvalDiffStats.added, deletions: approvalDiffStats.removed } + : undefined, + ...(approvalDiff ? { diff: approvalDiff } : {}), + ...(autoApproved ? { autoApproved: true } : {}), + }) + } + // Emitted only after checkpoint + journal persistence succeeded. const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, !fileExists) pushToolResult(message) @@ -180,14 +239,20 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { await task.diffViewProvider.reset() this.resetPartialState() - task.processQueuedMessages() - return } catch (error) { await handleError("writing file", error as Error) await task.diffViewProvider.reset() this.resetPartialState() return + } finally { + // Hand off queued messages whenever the write itself succeeded - + // including when the post-write checkpoint/journal persistence then + // rejected (the write is real; upstream handed off after every + // successful write). + if (didWrite) { + task.processQueuedMessages() + } } } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index eb89838cad..3a93b18662 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,13 +4,29 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" +import { checkpointSave } from "../../checkpoints" import { ApplyPatchTool } from "../ApplyPatchTool" +// The vi.mock factory exposes the fs/promises functions under a `default` +// property (matching the SUT's default import), which the static module type +// does not declare; cast once at this boundary rather than at each call site. +const mockedFsPromises = vi.mocked( + fsPromises as unknown as { + default: { + unlink: MockedFunction + writeFile: MockedFunction + } + }, +) + vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), unlink: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), }, })) @@ -22,6 +38,13 @@ vi.mock("../../../utils/pathUtils", () => ({ isPathOutsideWorkspace: vi.fn().mockReturnValue(false), })) +vi.mock("../../checkpoints", () => ({ + getCheckpointService: vi.fn(), + checkpointSave: vi.fn().mockResolvedValue(undefined), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), +})) + describe("ApplyPatchTool.execute - delete file success path", () => { const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction @@ -38,6 +61,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { | "say" | "processQueuedMessages" | "didEditFile" + | "providerRef" + | "diffViewProvider" + | "fileContextTracker" > let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> @@ -52,6 +78,11 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({}), + }), + } as unknown as Task["providerRef"], recordToolUsage: vi.fn(), recordToolError: vi.fn(), rooIgnoreController: { @@ -63,6 +94,21 @@ describe("ApplyPatchTool.execute - delete file success path", () => { say: vi.fn().mockResolvedValue(undefined), processQueuedMessages: vi.fn(), didEditFile: false, + diffViewProvider: { + editType: "modify", + originalContent: undefined, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + revertChanges: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + saveDirectly: vi.fn().mockResolvedValue({ finalContent: "saved" }), + saveChanges: vi.fn().mockResolvedValue(undefined), + pushToolWriteResult: vi.fn().mockResolvedValue("File saved successfully"), + } as unknown as Task["diffViewProvider"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], } mockAskApproval = vi.fn().mockResolvedValue(true) @@ -94,4 +140,762 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolUsage).not.toHaveBeenCalled() expect(mockTask.recordToolError).not.toHaveBeenCalled() }) + + describe("per-write checkpoints (B1)", () => { + const deletePatch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** End Patch` + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint for the whole patch (default-on)", async () => { + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + // B2: the delete patch produces one journal write, referencing the + // single checkpoint saved for the whole patch. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/obsolete.ts", operation: "delete" }, + ]) + }) + + it("emits per-file success results only after the post-patch checkpoint persists", async () => { + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // CR follow-up: the buffered success result is emitted only after + // checkpointSave resolved - a success is never reported before its + // journal save completes. + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + const saveOrder = mockedCheckpointSave.mock.invocationCallOrder[0] + const resultOrder = mockPushToolResult.mock.invocationCallOrder[0] + expect(saveOrder).toBeLessThan(resultOrder) + }) + + it("drops buffered success results and still hands off on a journal persistence failure", async () => { + mockedCheckpointSave.mockRejectedValueOnce( + new Error( + "Checkpoint abc123def was committed but its change journal could not be persisted (files: src/obsolete.ts); rollback metadata is incomplete.", + ), + ) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The delete succeeded on disk, so the queued handoff still runs, + // but no success result is emitted: the persistence failure + // surfaces through handleError instead. + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith("apply patch", expect.any(Error)) + expect(vi.mocked(mockTask.processQueuedMessages)).toHaveBeenCalled() + }) + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + // Structural cast for the test double (matches the mock style used for the controllers above). + const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref + ref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ perWriteCheckpoints: false }), + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when patch processing fails", async () => { + // A malformed patch fails at parse time, before the change loop and + // the post-loop checkpoint hook. + const badPatch = `*** Begin Patch +*** This is not a valid hunk +*** End Patch` + + await tool.execute({ patch: badPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + it("awaits the post-patch checkpoint before processing queued messages", async () => { + // A pending checkpoint must not let queued messages (and the + // writes they trigger) start first: execute() awaits the + // post-patch checkpointSave and only then processes queued + // messages, so a queued write's own checkpoint cannot interleave + // with this commit. + const order: string[] = [] + ;(mockTask.processQueuedMessages as MockedFunction<() => void>).mockImplementation(() => { + order.push("processQueuedMessages") + }) + mockedCheckpointSave.mockImplementationOnce(() => { + order.push("checkpointSave:start") + return new Promise((resolve) => { + setTimeout(() => { + order.push("checkpointSave:done") + resolve() + }, 10) + }).then(() => undefined) + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The checkpoint fully completes before queued messages are + // processed. + expect(order).toEqual(["checkpointSave:start", "checkpointSave:done", "processQueuedMessages"]) + }) + }) + + describe("checkpoint only for fully successful patches (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + const deletePatch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** End Patch` + const addPatch = `*** Begin Patch +*** Add File: src/new.ts ++hello ++world +*** End Patch` + const updatePatch = `*** Begin Patch +*** Update File: src/test.ts +@@ +-original file content ++modified content +*** End Patch` + const updateNoDiffPatch = `*** Begin Patch +*** Update File: src/test.ts +@@ +-original file content ++original file content +*** End Patch` + const movePatch = `*** Begin Patch +*** Update File: src/test.ts +*** Move to: src/moved.ts +@@ +-original file content ++modified content +*** End Patch` + + it("does not record a checkpoint when the user rejects the patch", async () => { + // Rejected approval: the handler early-returns without recording a + // tool error, so the success flag must come from the handler itself. + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Delete operation was rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the file to delete does not exist", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the delete write fails", async () => { + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EBUSY")) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Failed to delete file")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the added file already exists", async () => { + // fileExistsAtPath resolves true by default in beforeEach. + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File already exists")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the user rejects the add", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records a checkpoint when the add succeeds", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("does not record a checkpoint when the file to update does not exist", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records a checkpoint when the update is a no-op (no changes needed)", async () => { + // A no-op change is not a failure, so the whole-patch checkpoint still runs. + + await tool.execute({ patch: updateNoDiffPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("No changes needed")) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, []) + }) + + it("does not record a checkpoint when the user rejects the update", async () => { + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the move destination is not allowed", async () => { + // First validateAccess call (source path, in the execute loop) passes; + // the move destination check inside the handler fails. + const validateAccess = ( + mockTask["rooIgnoreController"] as unknown as { validateAccess: MockedFunction<() => boolean> } + ).validateAccess + validateAccess.mockReturnValueOnce(true).mockReturnValue(false) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/moved.ts") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("checkpoints the written subset when a later hunk is access-denied", async () => { + // Hunk 1 (src/first.ts) writes; hunk 2 (src/denied.ts) is rejected by + // validateAccess. The access-denied branch must not bypass the partial + // flush: the earlier write still receives the checkpoint/journal/card. + // Hunk 2's context matches the mocked file content so the patch + // passes pre-processing; the denial happens at the per-file access check. + const partialDenyPatch = `*** Begin Patch +*** Add File: src/first.ts ++hello +*** Update File: src/denied.ts +@@ +-original file content ++new content +*** End Patch` + const validateAccess = ( + mockTask["rooIgnoreController"] as unknown as { validateAccess: MockedFunction<() => boolean> } + ).validateAccess + validateAccess.mockReturnValueOnce(true).mockReturnValueOnce(false) + // The add target does not exist, so hunk 1 writes; fileExistsAtPath + // defaults to true and would otherwise reject the add. + mockedFileExistsAtPath.mockResolvedValueOnce(false) + + await tool.execute({ patch: partialDenyPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/denied.ts") + // Only the first (written) hunk is checkpointed. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, [ + expect.objectContaining({ path: "src/first.ts", operation: "create" }), + ]) + }) + + it("does not record a checkpoint when the move destination is write-protected", async () => { + // Source path check (execute loop) passes; the move destination fails. + const isWriteProtected = ( + mockTask["rooProtectedController"] as unknown as { + isWriteProtected: MockedFunction<(p: string) => boolean> + } + ).isWriteProtected + isWriteProtected.mockReturnValueOnce(false).mockReturnValue(true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to write-protected path"), + ) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the move destination is outside the workspace", async () => { + // Source path (first call) is inside; the move destination (second) + // call is outside the workspace. + mockedIsPathOutsideWorkspace.mockReturnValueOnce(false).mockReturnValue(true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to path outside workspace"), + ) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("keeps the mistake count when a patch operation fails", async () => { + // Source path check (execute loop) passes; the move destination fails. + const isWriteProtected = ( + mockTask["rooProtectedController"] as unknown as { + isWriteProtected: MockedFunction<(p: string) => boolean> + } + ).isWriteProtected + isWriteProtected.mockReturnValueOnce(false).mockReturnValue(true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The failed operation incremented the counter; the end-of-loop reset + // must only run for a fully successful patch, so the count survives. + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("clears the mistake count after a fully successful patch", async () => { + mockTask.consecutiveMistakeCount = 2 + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + }) + + it("records a checkpoint when the move succeeds", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // path is platform-dependent (Windows resolves cwd to a drive path); + // assert on the written content instead. + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith( + expect.any(String), + "modified content\n", + "utf8", + ) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("journals the source deletion alongside the destination write for a completed move", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // A completed move touches two journal-relevant paths: the + // destination write (carrying the content diff) and the source + // deletion (a pure delete record). Step rollback restores the + // removed source from these entries. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + expect.objectContaining({ path: "src/moved.ts", operation: "update" }), + expect.objectContaining({ path: "src/test.ts", operation: "delete" }), + ]) + }) + + it("awaits the patch checkpoint before execute settles", async () => { + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveDeferred = new Promise((resolve) => (resolveSave = resolve)) + mockedCheckpointSave.mockImplementationOnce(() => saveDeferred) + + const executePromise = tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // execute must not settle while the checkpoint is still in flight: + // a later write would otherwise interleave with this patch's staged work. + let settled = false + void executePromise.finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(settled).toBe(false) + + resolveSave() + await executePromise + expect(settled).toBe(true) + }) + + it("records a checkpoint when the in-place update succeeds", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("journals the recorded write when a handler throws after its write", async () => { + // Post-write bookkeeping (trackFileContext) fails after the file is + // already on disk: the write recorded at write time must still receive + // its checkpoint and journal entry through the catch-path flush; the + // buffered success result is dropped in favor of the failure result. + vi.mocked(mockTask.fileContextTracker.trackFileContext).mockRejectedValueOnce(new Error("tracker boom")) + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockedCheckpointSave).toHaveBeenCalledTimes(1) + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask as Task, + false, + true, + expect.arrayContaining([expect.objectContaining({ path: "src/test.ts", operation: "update" })]), + ) + expect(mockPushToolResult).not.toHaveBeenCalledWith("File saved successfully") + expect(mockHandleError).toHaveBeenCalledWith("apply patch", expect.anything()) + // The finally-block handoff still runs for the recorded write. + expect(mockTask.processQueuedMessages).toHaveBeenCalled() + }) + + it("flushes the recorded write from the catch path when the settings read rejects", async () => { + // The handler's settings lookup succeeds; the success-path flush's + // lookup rejects. The flush flag must not have been set before that + // rejection, so the catch path still persists the write already on + // disk using the cached (undefined = default-on) setting. + const getState = vi.fn().mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("state boom")) + mockTask.providerRef = { deref: vi.fn().mockReturnValue({ getState }) } as unknown as Task["providerRef"] + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockedCheckpointSave).toHaveBeenCalledTimes(1) + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask as Task, + false, + true, + expect.arrayContaining([expect.objectContaining({ path: "src/test.ts", operation: "update" })]), + ) + }) + + it("reports the catch-path flush failure alongside the bookkeeping error", async () => { + vi.mocked(mockTask.fileContextTracker.trackFileContext).mockRejectedValueOnce(new Error("tracker boom")) + mockedCheckpointSave.mockRejectedValueOnce(new Error("journal boom")) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The user must see that the completed write lacks rollback + // metadata: the reported error keeps the original message and + // appends the persistence failure instead of replacing it. + expect(mockHandleError).toHaveBeenCalledTimes(1) + const reported = mockHandleError.mock.calls[0][1] as Error + expect(reported.message).toContain("tracker boom") + expect(reported.message).toContain("checkpoint flush also failed: journal boom") + }) + + it("records one journal write per file change for a multi-file patch", async () => { + // src/a.ts does not exist (add); src/b.ts does (update). + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(!String(filePath).toLowerCase().endsWith("a.ts")), + ) + const multiPatch = [ + "*** Begin Patch", + "*** Add File: src/a.ts", + "+alpha", + "*** Update File: src/b.ts", + "@@", + "-original file content", + "+second content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: multiPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + // B3a: each write threads the approval diff and stats computed by its + // handler so the per-step change card can reuse them verbatim. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+alpha"), + }, + { + path: "src/b.ts", + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: expect.stringContaining("+second content"), + }, + ]) + }) + }) + + describe("change-card threading (B3a)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + const deletePatch = `*** Begin Patch + *** Delete File: src/obsolete.ts + *** End Patch` + + it("threads autoApproved into the checkpoint writes for auto-approved steps", async () => { + // B3a: auto-approved steps carry autoApproved on every write so + // checkpointSave can force the compact (summary) change card. + const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref + ref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ autoApprovalEnabled: true, alwaysAllowWrite: true }), + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/obsolete.ts", operation: "delete", autoApproved: true }, + ]) + }) + + it("omits autoApproved when the step is not auto-approved", async () => { + // The default provider state ({}) disables auto-approval, so no write + // carries the autoApproved flag and the card follows the user setting. + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/obsolete.ts", operation: "delete" }, + ]) + }) + + it("journals only the files actually written for a mixed no-op and write patch", async () => { + // src/same.ts exists and the hunk rewrites identical content (a + // no-op update); src/new.ts does not exist (a real write). + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(!String(filePath).toLowerCase().endsWith("new.ts")), + ) + const mixedPatch = [ + "*** Begin Patch", + "*** Update File: src/same.ts", + "@@", + "-original file content", + "+original file content", + "*** Add File: src/new.ts", + "+fresh content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: mixedPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The no-op update is reported to the model... + expect(mockPushToolResult).toHaveBeenCalledWith("No changes needed for 'src/same.ts'") + // ...but the journal documents only the file that was actually + // written, even though the whole patch succeeded. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { + path: "src/new.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+fresh content"), + }, + ]) + }) + + it("still checkpoints the successful subset when a later hunk fails", async () => { + // src/first.ts already exists (the add fails); src/second.ts does not + // (the add writes). The whole patch fails, but the written file is + // still documented by the checkpoint and journal. + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(String(filePath).toLowerCase().endsWith("first.ts")), + ) + const partialPatch = [ + "*** Begin Patch", + "*** Add File: src/first.ts", + "+boom", + "*** Add File: src/second.ts", + "+fresh", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: partialPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The failed operation is reported to the model... + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File already exists")) + // ...and the successful subset is checkpointed and journaled. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { + path: "src/second.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+fresh"), + }, + ]) + }) + + it("reports a failed move when the original file cannot be deleted", async () => { + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EBUSY: resource busy")) + const movePatch = [ + "*** Begin Patch", + "*** Update File: src/old.ts", + "*** Move to: src/new-location.ts", + "@@", + "-original file content", + "+new content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The copy succeeded but the source still exists, so the move is + // reported as a failed tool error - but the destination write was + // made on disk and must still be covered by the checkpoint/journal. + // The single-entry payload below also guards that the source + // deletion is NOT journaled: the source was never removed. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + expect.objectContaining({ path: "src/new-location.ts", operation: "update" }), + ]) + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("could not delete the original file"), + ) + }) + }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 95a67f04d7..ab278492ca 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -7,6 +7,8 @@ import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" +import { checkpointSave } from "../../checkpoints" +import { computeDiffStats } from "../../diff/stats" import { editFileTool } from "../EditFileTool" vi.mock("fs/promises", () => ({ @@ -56,7 +58,16 @@ vi.mock("../../../utils/path", () => ({ vi.mock("../../diff/stats", () => ({ sanitizeUnifiedDiff: vi.fn((diff) => diff), - computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), + // The real computeDiffStats returns { added, removed } (DiffStats) — + // keep the mock faithful to the production shape. + computeDiffStats: vi.fn(() => ({ added: 1, removed: 1 })), +})) + +vi.mock("../../checkpoints", () => ({ + getCheckpointService: vi.fn(), + checkpointSave: vi.fn().mockResolvedValue(undefined), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), })) vi.mock("vscode", () => ({ @@ -777,4 +788,140 @@ describe("editFileTool", () => { expect(mockAskApproval).toHaveBeenCalled() }) }) + + describe("per-write checkpoints (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint after a successful edit (default-on)", async () => { + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + // B2: the write info threads the path, operation, and the approval + // diff stats into the checkpoint hook. B3a: the approval diff itself is + // threaded verbatim for the per-step change card. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + }) + }) + + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + perWriteCheckpoints: false, + }), + }) + + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the edit fails", async () => { + mockTask.diffViewProvider.saveChanges.mockRejectedValue(new Error("save failed")) + + await executeEditFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records the checkpoint with a create operation for a new file", async () => { + await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + }) + }) + + it("omits diff stats from the checkpoint write when the diff has no stats", async () => { + // A null approval diff produces no diffStats on the journal write. + // The diff itself is still threaded for the change card (B3a). + vi.mocked(computeDiffStats).mockReturnValueOnce(null) + + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diff: "mock-diff", + }) + }) + + it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { + // B3a: when the step is auto-approved the checkpoint write carries + // autoApproved so checkpointSave can force the compact change card. + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + autoApprovalEnabled: true, + alwaysAllowWrite: true, + }), + }) + + await executeEditFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + autoApproved: true, + }) + }) + + it("awaits the edit checkpoint before execute settles", async () => { + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveDeferred = new Promise((resolve) => (resolveSave = resolve)) + mockedCheckpointSave.mockImplementationOnce(() => saveDeferred) + + const executePromise = executeEditFileTool({}) + + // execute must not settle while the checkpoint is still in flight: + // a later tool block would otherwise interleave with this edit's staged work. + let settled = false + void executePromise.finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(settled).toBe(false) + + resolveSave() + await executePromise + expect(settled).toBe(true) + }) + it("routes a journal persistence failure to tool failure handling without a success result", async () => { + // The checkpoint committed but its journal update failed: the edit + // must not report the write as fully persisted... + mockedCheckpointSave.mockRejectedValueOnce( + new Error( + "Checkpoint abc123def was committed but its change journal could not be persisted (files: test/file.txt); rollback metadata is incomplete.", + ), + ) + + await executeEditFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) + expect(mockPushToolResult).not.toHaveBeenCalled() + // ...and the queued handoff still runs, because the file write + // itself succeeded (CR follow-up: hand off from finally). + expect(vi.mocked(mockTask.processQueuedMessages)).toHaveBeenCalled() + }) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 7cb9d01585..79b7568f3b 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -8,7 +8,10 @@ import { getReadablePath } from "../../../utils/path" import { unescapeHtmlEntities } from "../../../utils/text-normalization" import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" +import { checkpointSave } from "../../checkpoints" +import { formatResponse } from "../../prompts/responses" import { writeToFileTool } from "../WriteToFileTool" +import { convertNewFileToUnifiedDiff, sanitizeUnifiedDiff } from "../../diff/stats" vi.mock("path", async () => { const originalPath = await vi.importActual("path") @@ -89,6 +92,10 @@ vi.mock("../../ignore/RooIgnoreController", () => ({ }, })) +vi.mock("../../checkpoints", () => ({ + checkpointSave: vi.fn().mockResolvedValue(undefined), +})) + describe("writeToFileTool", () => { // Test data const testFilePath = "test/file.txt" @@ -96,6 +103,10 @@ describe("writeToFileTool", () => { const testContent = "Line 1\nLine 2\nLine 3" const testContentWithMarkdown = "```javascript\nLine 1\nLine 2\n```" + // The exact approval diff the tool computes for a new file (B3a threads it + // into the checkpoint write for the per-step change card). + const newFileApprovalDiff = sanitizeUnifiedDiff(convertNewFileToUnifiedDiff(testContent, testFilePath)) + // Mocked functions with correct types const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction @@ -106,7 +117,9 @@ describe("writeToFileTool", () => { const mockedStripLineNumbers = stripLineNumbers as MockedFunction const mockedPathResolve = path.resolve as MockedFunction - const mockCline: any = {} + const mockCline: any = { + processQueuedMessages: vi.fn(), + } let mockAskApproval: ReturnType> let mockHandleError: ReturnType> let mockPushToolResult: ReturnType> @@ -156,6 +169,7 @@ describe("writeToFileTool", () => { userEdits: null, finalContent: "final content", }), + saveDirectly: vi.fn().mockResolvedValue({ finalContent: "saved" }), scrollToFirstDiff: vi.fn(), updateDiagnosticSettings: vi.fn(), pushToolWriteResult: vi.fn().mockImplementation(async function ( @@ -473,4 +487,173 @@ describe("writeToFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) }) }) + + describe("per-write checkpoints (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint after a successful write (default-on)", async () => { + await executeWriteFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + // B2: the write info threads the path, operation, and the approval + // diff stats (3 added lines, 0 removed) into the checkpoint hook. + // B3a: the approval diff itself is threaded verbatim for the + // per-step change card. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + }) + }) + + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi + .fn() + .mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, perWriteCheckpoints: false }), + }) + + await executeWriteFileTool({}) + + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the write fails", async () => { + mockCline.diffViewProvider.open.mockRejectedValue(new Error("write failed")) + + await executeWriteFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("routes a journal persistence failure to tool failure handling without a success result", async () => { + // The checkpoint committed but its journal update failed: the tool + // must not report the write as a complete success. + mockedCheckpointSave.mockRejectedValueOnce( + new Error( + "Checkpoint abc123def was committed but its change journal could not be persisted (files: test/file.txt); rollback metadata is incomplete.", + ), + ) + + await executeWriteFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + const surfaced = String(mockHandleError.mock.calls[0]?.[1]) + expect(surfaced).toContain("abc123def") + expect(surfaced).toContain("test/file.txt") + // The normal successful tool result is not emitted. + expect(mockPushToolResult).not.toHaveBeenCalled() + + // The write itself succeeded: the queued handoff still runs (finally). + expect(mockCline.processQueuedMessages).toHaveBeenCalled() + }) + + it("waits for the per-write checkpoint before the tool completes", async () => { + let checkpointStarted = false + let releaseCheckpoint: () => void = () => {} + mockedCheckpointSave.mockImplementationOnce(() => { + checkpointStarted = true + return new Promise((resolve) => { + releaseCheckpoint = () => resolve(undefined) + }) + }) + const processQueuedSpy = vi.fn() + mockCline.processQueuedMessages = processQueuedSpy + + const toolPromise = executeWriteFileTool({}) + + // Advance microtasks until the tool reaches the checkpoint call (all + // preceding awaits are mocked resolutions, no real timers involved). + for (let i = 0; i < 50 && !checkpointStarted; i++) { + await Promise.resolve() + } + expect(checkpointStarted).toBe(true) + + let settled = false + void toolPromise.then(() => { + settled = true + }) + + // The tool must not complete while the checkpoint is still + // staging/committing: a later write started by the task loop would + // otherwise collapse into the same (or a missing) commit. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBe(false) + expect(processQueuedSpy).not.toHaveBeenCalled() + + releaseCheckpoint() + await toolPromise + expect(settled).toBe(true) + expect(processQueuedSpy).toHaveBeenCalledOnce() + }) + + it("threads write info with approval diff stats when the prevent-focus-disruption experiment is enabled", async () => { + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }) + + await executeWriteFileTool({}) + + // The experiment branch saves directly (no diff view) and still + // journals the write through the same single checkpoint hook, carrying + // the approval diff for the per-step change card (B3a). + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + testContent, + false, + true, + 1000, + ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + }) + }) + + it("skips the checkpoint when an existing file's content is unchanged", async () => { + // Writing identical content to an existing file produces an empty + // approval diff: the shadow repository has nothing to stage, so a + // checkpoint would resolve without a commit and the write-metadata + // rejection would report failure for a write that succeeded. The + // write result is still pushed. + vi.mocked(formatResponse.createPrettyPatch).mockReturnValueOnce("") + + await executeWriteFileTool({}, { fileExists: true }) + + expect(mockedCheckpointSave).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { + // B3a: when the step is auto-approved the checkpoint write carries + // autoApproved so checkpointSave can force the compact change card. + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + autoApprovalEnabled: true, + alwaysAllowWrite: true, + }), + }) + + await executeWriteFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + autoApproved: true, + }) + }) + }) }) diff --git a/src/core/tools/apply-patch/apply.ts b/src/core/tools/apply-patch/apply.ts index 4ab377f732..8973af8a8d 100644 --- a/src/core/tools/apply-patch/apply.ts +++ b/src/core/tools/apply-patch/apply.ts @@ -29,6 +29,21 @@ export interface ApplyPatchFileChange { originalContent?: string /** New content (for add/update) */ newContent?: string + /** + * B3a: the unified approval diff for this file (computed by the tool + * handler), reused by the per-step change card. + */ + diff?: string + /** B3a: { added, removed } stats of the approval diff, when computable. */ + diffStats?: { added: number; removed: number } + /** B3a: whether this file's approval was auto-approved. */ + autoApproved?: boolean + /** + * Set by the tool handler when a move's source unlink succeeded: the + * change journal then records the source deletion alongside the + * destination write so a step rollback can restore the removed file. + */ + moveSourceDeleted?: boolean } /** diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0c0498ba8a..d4004e8ebb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -54,6 +54,8 @@ import { ORGANIZATION_ALLOW_ALL, DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + DEFAULT_PER_WRITE_CHECKPOINTS, + DEFAULT_CHANGE_CARD_DETAIL, getModelId, isRetiredProvider, modelIdKeys, @@ -2746,6 +2748,8 @@ export class ClineProvider ttsSpeed, enableCheckpoints, checkpointTimeout, + perWriteCheckpoints, + changeCardDetail, soundVolume, writeDelayMs, diffFuzzyThreshold, @@ -2914,6 +2918,8 @@ export class ClineProvider ttsSpeed: ttsSpeed ?? 1.0, enableCheckpoints: enableCheckpoints ?? true, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, shouldShowAnnouncement: telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands: mergedAllowedCommands, @@ -3153,6 +3159,8 @@ export class ClineProvider ttsSpeed: stateValues.ttsSpeed ?? 1.0, enableCheckpoints: stateValues.enableCheckpoints ?? true, checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: stateValues.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: stateValues.changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, soundVolume: stateValues.soundVolume, writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 113fb0f66d..8680ec9607 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1209,6 +1209,8 @@ describe("ClineProvider", () => { soundEnabled: false, ttsEnabled: false, enableCheckpoints: false, + perWriteCheckpoints: false, + changeCardDetail: "summary", writeDelayMs: 1000, mcpEnabled: true, mode: defaultModeSlug, @@ -2168,6 +2170,81 @@ describe("ClineProvider", () => { expect(state.destructiveCommandGuardEnabled).toBe(false) }) + test("getState returns the saved per-write checkpoints setting", async () => { + await provider.contextProxy.setValue("perWriteCheckpoints", false) + + const state = await provider.getState() + + expect(state.perWriteCheckpoints).toBe(false) + }) + + test("getState defaults per-write checkpoints to true when unset", async () => { + const state = await provider.getState() + + expect(state.perWriteCheckpoints).toBe(true) + }) + + test("getStateToPostToWebview returns the saved per-write checkpoints setting", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("perWriteCheckpoints", true) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(true) + }) + + test("getStateToPostToWebview returns false when per-write checkpoints is saved as false", async () => { + // The default is also true, so only an explicit false proves that the + // stored value (rather than the default) reaches the webview state. + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("perWriteCheckpoints", false) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(false) + }) + + test("getStateToPostToWebview defaults per-write checkpoints to true when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(true) + }) + + test("getState returns the saved changeCardDetail setting", async () => { + await provider.contextProxy.setValue("changeCardDetail", "full") + + const state = await provider.getState() + + expect(state.changeCardDetail).toBe("full") + }) + + test("getState defaults changeCardDetail to summary when unset", async () => { + const state = await provider.getState() + + expect(state.changeCardDetail).toBe("summary") + }) + + test("getStateToPostToWebview returns the saved changeCardDetail setting", async () => { + // The default is "summary", so only an explicit "full" proves that the + // stored value (rather than the default) reaches the webview state. + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("changeCardDetail", "full") + + const state = await provider.getStateToPostToWebview() + + expect(state.changeCardDetail).toBe("full") + }) + + test("getStateToPostToWebview defaults changeCardDetail to summary when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.changeCardDetail).toBe("summary") + }) + test("getStateToPostToWebview returns the saved blanket auto-deny setting", async () => { await provider.resolveWebviewView(mockWebviewView) await provider.contextProxy.setValue("alwaysDenyUnapprovedCommands", true) diff --git a/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts index d793737c20..8a8108ada4 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts @@ -274,6 +274,126 @@ describe("webviewMessageHandler - openFile markdown workspace containment", () = expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("link.txt")) }) + // ChangeCard.tsx tags its openFile posts with fromChangeCard: card paths + // are model-derived and survive persistence/import, so the host enforces + // the same containment as markdown-sourced requests. + it("opens a change-card file within the workspace using a relative path", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./src/a.ts", + values: { fromChangeCard: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "src/a.ts"), { + fromChangeCard: true, + skipDecode: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("rejects a change-card relative path that traverses outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./../../.env", + values: { fromChangeCard: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("./../../.env")) + }) + + it("rejects a change-card absolute path outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: OUTSIDE_ABS, + values: { fromChangeCard: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError(OUTSIDE_ABS)) + }) + + it("treats percent-encoded dots in a change-card path as literal names", async () => { + // Change-card rows are filesystem paths, not URL targets: "%2e%2e" is + // a literal directory name, stays inside the workspace, and nothing + // decodes it after containment (skipDecode), so it can never shift + // into a traversal. + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./%2e%2e/%2e%2e/.env", + values: { fromChangeCard: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "%2e%2e", "%2e%2e", ".env"), { + fromChangeCard: true, + skipDecode: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("treats a double-encoded change-card path as a literal name", async () => { + // Nothing decodes a change-card path after containment, so a + // double-encoded traversal stays a literal directory name: it cannot + // escape the workspace in a later decode step. + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./%252e%252e/%252e%252e/.env", + values: { fromChangeCard: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "%252e%252e", "%252e%252e", ".env"), { + fromChangeCard: true, + skipDecode: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("opens a change-card file whose name legitimately contains a percent sign", async () => { + // "100%.txt" is a literal filesystem name: the raw path is validated + // and opened without any decode. + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./100%.txt", + values: { fromChangeCard: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "100%.txt"), { + fromChangeCard: true, + skipDecode: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("opens a change-card file whose name contains a literal percent escape", async () => { + // A file literally named "a%20b.ts" must open as named: decoding it + // (the old boundary behavior) would open "a b.ts" instead - the wrong + // file when both exist, or a failure when only the literal exists. + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./src/a%20b.ts", + values: { fromChangeCard: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.join(MOCK_CWD, "src", "a%20b.ts"), { + fromChangeCard: true, + skipDecode: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("rejects a change-card path that escapes through a workspace symlink", async () => { + realWorld.symlinks.set(nodePath.join(MOCK_CWD, "link.txt"), nodePath.join(OUTSIDE_ROOT, "secret.txt")) + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "./link.txt", + values: { fromChangeCard: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(cannotAccessPathError("./link.txt")) + }) + // A dangling symlink inside the workspace — an existing entry whose target // is missing — must be rejected fail-closed: a creation flow (mkdir -p) // would follow it and could escape the workspace. diff --git a/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts new file mode 100644 index 0000000000..238e1daad5 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts @@ -0,0 +1,374 @@ +// npx vitest run src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts +import { describe, expect, it, vi, beforeEach } from "vitest" + +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { webviewMessageHandler } from "../webviewMessageHandler" +import { restoreLatestFile, rollbackFile, rollbackStep } from "../../checkpoints/rollback" +import type { Task } from "../../task/Task" +import type { ClineProvider } from "../ClineProvider" + +// The rollback cases only call these two provider methods, so the provider +// double below is cast once at this boundary; the spy is shared so results +// can be asserted after the handler runs. +vi.mock("../../checkpoints/rollback", () => ({ + rollbackFile: vi.fn(), + rollbackStep: vi.fn(), + restoreLatestFile: vi.fn(), +})) + +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: undefined, + }, +})) + +// The no-task failure posts localized copy; the extension i18n loader only +// populates resources outside tests, so the spec pins the English values the +// handler asks for (path is relative to this file: ../../../i18n = src/i18n). +vi.mock("../../../i18n", () => ({ + changeLanguage: vi.fn(), + t: (key: string) => { + const values: Record = { + "common:errors.message.no_active_task_to_roll_back": "No active task to roll back from", + "common:errors.message.no_active_task_to_restore": "No active task to restore from", + } + return values[key] ?? key + }, +})) + +// Structural mock: the handler only needs the task identity for these cases. +const mockTask = {} as Task +const postMessageToWebview = vi.fn(async (_message: ExtensionMessage) => undefined) + +function makeProvider(task: Task | undefined): ClineProvider { + const provider = { + getCurrentTask: () => task, + postMessageToWebview, + } + // Cast at the spec boundary: the rollback cases only read getCurrentTask() + // and observe postMessageToWebview calls on the structural double. + return provider as unknown as ClineProvider +} + +const provider = makeProvider(mockTask) + +describe("webviewMessageHandler - change card rollback", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("checkpointRollbackFile", () => { + it("restores the file and posts the success outcome back to the webview", async () => { + vi.mocked(rollbackFile).mockResolvedValueOnce({ filePath: "src/a.ts", success: true }) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + + expect(rollbackFile).toHaveBeenCalledWith(mockTask, "abc123", "src/a.ts") + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 1000, filePath: "src/a.ts", success: true }, + }) + }) + + it("posts the error outcome when the restore fails", async () => { + vi.mocked(rollbackFile).mockResolvedValueOnce({ + filePath: "src/a.ts", + success: false, + error: "checkpoint not found", + }) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + filePath: "src/a.ts", + success: false, + error: "checkpoint not found", + }, + }) + }) + + it("posts a correlated failure result when there is no current task", async () => { + const emptyProvider = makeProvider(undefined) + + await webviewMessageHandler(emptyProvider, { + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + + expect(rollbackFile).not.toHaveBeenCalled() + // The requesting card must clear its pending state, so the handler + // posts a correlated failure instead of nothing. + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + filePath: "src/a.ts", + success: false, + error: "No active task to roll back from", + }, + }) + }) + + it("posts a correlated failure when the rollback itself throws", async () => { + vi.mocked(rollbackFile).mockRejectedValueOnce(new Error("git restore failed")) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + + // The card must not stay pending: the handler turns the throw into a + // correlated failure result instead of dropping the message. + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + filePath: "src/a.ts", + success: false, + error: "Rollback failed: git restore failed", + }, + }) + }) + }) + + describe("checkpointRollbackStep", () => { + it("restores every step file and posts the aggregated outcome", async () => { + vi.mocked(rollbackStep).mockResolvedValueOnce({ + checkpointId: "abc123", + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ], + }) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, checkpointId: "abc123", filePaths: ["src/a.ts", "src/b.ts"] }, + }) + + expect(rollbackStep).toHaveBeenCalledWith(mockTask, ["src/a.ts", "src/b.ts"], "abc123") + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: true, + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ], + }, + }) + }) + + it("reports success false with the first failing file's error when a step file fails", async () => { + vi.mocked(rollbackStep).mockResolvedValueOnce({ + checkpointId: "abc123", + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: false, error: "boom" }, + ], + }) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, filePaths: ["src/a.ts", "src/b.ts"] }, + }) + + // Without an explicit step checkpoint id the journal lookup is used. + expect(rollbackStep).toHaveBeenCalledWith(mockTask, ["src/a.ts", "src/b.ts"], undefined) + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "boom", + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: false, error: "boom" }, + ], + }, + }) + }) + + it("posts a correlated failure result when there is no current task", async () => { + const emptyProvider = makeProvider(undefined) + + await webviewMessageHandler(emptyProvider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, filePaths: ["src/a.ts"] }, + }) + + expect(rollbackStep).not.toHaveBeenCalled() + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "No active task to roll back from", + }, + }) + }) + + it("posts a correlated failure when the step rollback itself throws", async () => { + vi.mocked(rollbackStep).mockRejectedValueOnce(new Error("journal unreadable")) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, filePaths: ["src/a.ts"] }, + }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "Rollback failed: journal unreadable", + }, + }) + }) + + it("ignores payloads that do not match the schema", async () => { + await webviewMessageHandler(provider, { + // Malformed on purpose (only the webview produces this message): the cast + // lets the spec reach the handler's safeParse rejection without `any`. + type: "checkpointRollbackFile", + payload: { cardTs: 1000 } as unknown as WebviewMessage["payload"], + }) + await webviewMessageHandler(provider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, filePaths: [] }, + }) + await webviewMessageHandler(provider, { + type: "checkpointRestoreLatestFile", + payload: { cardTs: 1000 } as unknown as WebviewMessage["payload"], + }) + + expect(rollbackFile).not.toHaveBeenCalled() + expect(rollbackStep).not.toHaveBeenCalled() + expect(restoreLatestFile).not.toHaveBeenCalled() + expect(postMessageToWebview).not.toHaveBeenCalled() + }) + }) + + describe("checkpointRestoreLatestFile", () => { + it("restores the file to its latest recorded version and posts the success outcome", async () => { + vi.mocked(restoreLatestFile).mockResolvedValueOnce({ filePath: "src/a.ts", success: true }) + + await webviewMessageHandler(provider, { + type: "checkpointRestoreLatestFile", + payload: { cardTs: 1000, filePath: "src/a.ts" }, + }) + + expect(restoreLatestFile).toHaveBeenCalledWith(mockTask, "src/a.ts") + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: true, + }, + }) + }) + + it("flags a no-op restore-latest so the card can report it", async () => { + vi.mocked(restoreLatestFile).mockResolvedValueOnce({ filePath: "src/a.ts", success: true, noOp: true }) + + await webviewMessageHandler(provider, { + type: "checkpointRestoreLatestFile", + payload: { cardTs: 1000, filePath: "src/a.ts" }, + }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: true, + noOp: true, + }, + }) + }) + + it("posts the error outcome when the restore fails", async () => { + vi.mocked(restoreLatestFile).mockResolvedValueOnce({ + filePath: "src/a.ts", + success: false, + error: "Checkpoints are not enabled for this task", + }) + + await webviewMessageHandler(provider, { + type: "checkpointRestoreLatestFile", + payload: { cardTs: 1000, filePath: "src/a.ts" }, + }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: false, + error: "Checkpoints are not enabled for this task", + }, + }) + }) + + it("posts a correlated failure result when there is no current task", async () => { + const emptyProvider = makeProvider(undefined) + + await webviewMessageHandler(emptyProvider, { + type: "checkpointRestoreLatestFile", + payload: { cardTs: 1000, filePath: "src/a.ts" }, + }) + + expect(restoreLatestFile).not.toHaveBeenCalled() + // The requesting card must clear its pending state, so the handler + // posts a correlated failure instead of nothing. + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: false, + error: "No active task to restore from", + }, + }) + }) + + it("posts a correlated failure when the restore itself throws", async () => { + vi.mocked(restoreLatestFile).mockRejectedValueOnce(new Error("git checkout failed")) + + await webviewMessageHandler(provider, { + type: "checkpointRestoreLatestFile", + payload: { cardTs: 1000, filePath: "src/a.ts" }, + }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: false, + error: "Restore failed: git checkout failed", + }, + }) + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5596b90e67..6bbf9db7c0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -19,6 +19,9 @@ import { TelemetryEventName, RooCodeSettings, ExperimentId, + checkpointRollbackFilePayloadSchema, + checkpointRollbackStepPayloadSchema, + checkpointRestoreLatestFilePayloadSchema, checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, getCompletionCheckpoint, @@ -1645,6 +1648,14 @@ export const webviewMessageHandler = async ( // slash-command settings) may legitimately open global config files // outside the workspace, so they keep the previous behavior. const fromMarkdown = message.values?.fromMarkdown === true + // Change-card file paths are model-derived and survive task + // persistence/import, so the host treats them as untrusted too + // (marker posted by ChangeCard.tsx): the same lexical + realpath + // containment runs. Card paths are plain filesystem paths rather + // than URL link targets, so they get a single best-effort decode - + // exactly the decode openFile itself applies after its check - + // instead of the markdown URL decode-to-fixed-point. + const fromChangeCard = message.values?.fromChangeCard === true const rejectOutsideWorkspace = () => { void vscode.window.showErrorMessage( t("common:errors.cannot_access_path", { @@ -1660,13 +1671,21 @@ export const webviewMessageHandler = async ( // containment as a literal and escape only after that later decode. if (fromMarkdown) { const decoded = decodeUntrustedPathToStable(rawPath) - // Stryker disable next-line ConditionalExpression,BlockStatement: hostile non-stabilizing encodings are unreachable from the webview (its posts are plain link targets); the bound is defensive if (decoded === null) { - // Stryker disable next-line CallExpression: hostile non-stabilizing encodings are pinned by the direct decodeUntrustedPathToStable bound test; the webview never posts such values rejectOutsideWorkspace() break } filePath = decoded + } else if (fromChangeCard) { + // Change-card rows post raw filesystem paths, not URL targets: + // validate the raw path and stop openFile from decoding it + // (skipDecode below), so a literal percent escape in a filename + // (`src/a%20b.ts`) opens the file it names. Decoding here would + // shift the path after containment and open the wrong file; a + // percent-encoded traversal (`%2e%2e`) stays a literal directory + // name, stays inside the workspace, and never shifts after + // validation because nothing decodes it afterwards. + filePath = rawPath } if (!path.isAbsolute(filePath)) { const cwd = getCurrentCwd() @@ -1679,22 +1698,43 @@ export const webviewMessageHandler = async ( filePath = path.resolve(cwd, filePath) } // Workspace-boundary validation (defense in depth): the webview already - // rejects traversal in markdown anchors, but refuse any markdown path - // that still resolves outside the workspace. - if (fromMarkdown && isPathOutsideWorkspace(filePath)) { + // rejects traversal in markdown anchors and change-card rows, but + // refuse any untrusted path that still resolves outside the workspace. + if ((fromMarkdown || fromChangeCard) && isPathOutsideWorkspace(filePath)) { rejectOutsideWorkspace() break } // Lexical containment cannot see symlinks: a link inside a workspace // folder may resolve to a target outside the workspace. Re-check the // real filesystem path (failing closed) before opening. - if (fromMarkdown && (await isRealPathOutsideWorkspace(filePath))) { + if ((fromMarkdown || fromChangeCard) && (await isRealPathOutsideWorkspace(filePath))) { rejectOutsideWorkspace() break } await openFile( filePath, - message.values as { create?: boolean; content?: string; line?: number; fromMarkdown?: boolean }, + // Change-card paths were validated raw above: openFile must not + // decode them afterwards. Other callers keep the historical + // best-effort decode (markdown reached a fixed point at the + // boundary, so its later decode is a no-op). + fromChangeCard && message.values + ? { + ...(message.values as { + create?: boolean + content?: string + line?: number + fromMarkdown?: boolean + fromChangeCard?: boolean + }), + skipDecode: true, + } + : (message.values as { + create?: boolean + content?: string + line?: number + fromMarkdown?: boolean + fromChangeCard?: boolean + }), ) break } @@ -1797,6 +1837,169 @@ export const webviewMessageHandler = async ( break } + case "checkpointRollbackFile": { + // B3b: restore one change-card file to its step checkpoint and report + // the outcome back to the requesting card (correlated by cardTs). + const result = checkpointRollbackFilePayloadSchema.safeParse(message.payload) + + if (result.success) { + const task = provider.getCurrentTask() + + if (task) { + // Lazy import: the rollback module pulls the checkpoint service and the + // editor integrations (DiffViewProvider) into the import graph. Loading it + // only when a rollback is requested keeps specs that mock `vscode` minimally + // from executing editor module-scope code at import time. + try { + const { rollbackFile } = await import("../checkpoints/rollback") + const outcome = await rollbackFile(task, result.data.checkpointId, result.data.filePath) + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + filePath: outcome.filePath, + success: outcome.success, + ...(outcome.error ? { error: outcome.error } : {}), + }, + }) + } catch (error) { + // Correlated failure: a throw between the request and the result post + // (import, journal read, git restore) would otherwise leave the + // requesting card pending forever. + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + filePath: result.data.filePath, + success: false, + error: `Rollback failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }) + } + } else { + // No active task: the rollback cannot run. Post the correlated + // failure so the requesting card can clear its pending state + // instead of waiting on a result that will never arrive. + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + filePath: result.data.filePath, + success: false, + error: t("common:errors.message.no_active_task_to_roll_back"), + }, + }) + } + } + + break + } + case "checkpointRollbackStep": { + // B3b: restore every file of a change-card step to the step checkpoint. + const result = checkpointRollbackStepPayloadSchema.safeParse(message.payload) + + if (result.success) { + const task = provider.getCurrentTask() + + if (task) { + // Lazy import (see the checkpointRollbackFile case above). + try { + const { rollbackStep } = await import("../checkpoints/rollback") + const outcome = await rollbackStep(task, result.data.filePaths, result.data.checkpointId) + const firstFailure = outcome.files.find((file) => !file.success) + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + success: outcome.files.every((file) => file.success), + ...(firstFailure ? { error: firstFailure.error } : {}), + files: outcome.files, + }, + }) + } catch (error) { + // Correlated failure (see the checkpointRollbackFile case). + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + success: false, + error: `Rollback failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }) + } + } else { + // No active task: post the correlated failure so the requesting + // card can clear its pending state. + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + success: false, + error: t("common:errors.message.no_active_task_to_roll_back"), + }, + }) + } + } + + break + } + case "checkpointRestoreLatestFile": { + // B3b: restore one change-card file to the latest recorded version of + // that file (the forward direction to a rollback) and report the + // outcome back to the requesting card (correlated by cardTs, kind + // "restore-latest"). + const result = checkpointRestoreLatestFilePayloadSchema.safeParse(message.payload) + + if (result.success) { + const task = provider.getCurrentTask() + + if (task) { + // Lazy import (see the checkpointRollbackFile case above). + try { + const { restoreLatestFile } = await import("../checkpoints/rollback") + const outcome = await restoreLatestFile(task, result.data.filePath) + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + kind: "restore-latest", + filePath: outcome.filePath, + success: outcome.success, + ...(outcome.noOp ? { noOp: true } : {}), + ...(outcome.error ? { error: outcome.error } : {}), + }, + }) + } catch (error) { + // Correlated failure (see the checkpointRollbackFile case). + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + kind: "restore-latest", + filePath: result.data.filePath, + success: false, + error: `Restore failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }) + } + } else { + // No active task: post the correlated failure so the requesting + // card can clear its pending state. + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + kind: "restore-latest", + filePath: result.data.filePath, + success: false, + error: t("common:errors.message.no_active_task_to_restore"), + }, + }) + } + } + + break + } case "completionCheckpointDiff": { const currentCline = provider.getCurrentTask() const checkpoint = currentCline ? resolveCompletionCheckpoint(currentCline) : undefined diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 579cd6b7d7..4fdd4903cc 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -104,6 +104,8 @@ }, "message": { "no_active_task_to_delete": "No hi ha cap tasca activa de la qual eliminar missatges", + "no_active_task_to_roll_back": "No hi ha cap tasca activa per desfer", + "no_active_task_to_restore": "No hi ha cap tasca activa per restaurar", "invalid_timestamp_for_deletion": "Marca de temps del missatge no vàlida per a l'eliminació", "cannot_delete_missing_timestamp": "No es pot eliminar el missatge: falta la marca de temps", "cannot_delete_invalid_timestamp": "No es pot eliminar el missatge: marca de temps no vàlida", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 3d8175a77e..3f3d9219d6 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Keine aktive Aufgabe, aus der Nachrichten gelöscht werden können", + "no_active_task_to_roll_back": "Keine aktive Aufgabe, von der aus zurückgerollt werden kann", + "no_active_task_to_restore": "Keine aktive Aufgabe, von der aus wiederhergestellt werden kann", "invalid_timestamp_for_deletion": "Ungültiger Nachrichten-Zeitstempel zum Löschen", "cannot_delete_missing_timestamp": "Nachricht kann nicht gelöscht werden: fehlender Zeitstempel", "cannot_delete_invalid_timestamp": "Nachricht kann nicht gelöscht werden: ungültiger Zeitstempel", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a7d616028f..70288346b3 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -102,6 +102,8 @@ }, "message": { "no_active_task_to_delete": "No active task to delete messages from", + "no_active_task_to_roll_back": "No active task to roll back from", + "no_active_task_to_restore": "No active task to restore from", "invalid_timestamp_for_deletion": "Invalid message timestamp for deletion", "cannot_delete_missing_timestamp": "Cannot delete message: missing timestamp", "cannot_delete_invalid_timestamp": "Cannot delete message: invalid timestamp", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 2eb6848f11..a57080b4c8 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "No hay tarea activa de la cual eliminar mensajes", + "no_active_task_to_roll_back": "No hay ninguna tarea activa desde la que deshacer los cambios", + "no_active_task_to_restore": "No hay ninguna tarea activa desde la que restaurar los cambios", "invalid_timestamp_for_deletion": "Marca de tiempo del mensaje no válida para eliminación", "cannot_delete_missing_timestamp": "No se puede eliminar el mensaje: falta marca de tiempo", "cannot_delete_invalid_timestamp": "No se puede eliminar el mensaje: marca de tiempo no válida", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index b6b65a88de..ad8c74ca6d 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Aucune tâche active pour supprimer des messages", + "no_active_task_to_roll_back": "Aucune tâche active pour annuler les modifications", + "no_active_task_to_restore": "Aucune tâche active pour restaurer les modifications", "invalid_timestamp_for_deletion": "Horodatage du message invalide pour la suppression", "cannot_delete_missing_timestamp": "Impossible de supprimer le message : horodatage manquant", "cannot_delete_invalid_timestamp": "Impossible de supprimer le message : horodatage invalide", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index f69b5adccf..7129a0f0fe 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "संदेशों को हटाने के लिए कोई सक्रिय कार्य नहीं", + "no_active_task_to_roll_back": "वापस करने के लिए कोई सक्रिय कार्य नहीं है", + "no_active_task_to_restore": "पुनर्स्थापित करने के लिए कोई सक्रिय कार्य नहीं है", "invalid_timestamp_for_deletion": "हटाने के लिए अमान्य संदेश टाइमस्टैम्प", "cannot_delete_missing_timestamp": "संदेश हटाया नहीं जा सकता: टाइमस्टैम्प गुम है", "cannot_delete_invalid_timestamp": "संदेश हटाया नहीं जा सकता: अमान्य टाइमस्टैम्प", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index e401612bf3..826bffd717 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Tidak ada tugas aktif untuk menghapus pesan", + "no_active_task_to_roll_back": "Tidak ada tugas aktif untuk di-rollback", + "no_active_task_to_restore": "Tidak ada tugas aktif untuk dipulihkan", "invalid_timestamp_for_deletion": "Timestamp pesan tidak valid untuk penghapusan", "cannot_delete_missing_timestamp": "Tidak dapat menghapus pesan: timestamp tidak ada", "cannot_delete_invalid_timestamp": "Tidak dapat menghapus pesan: timestamp tidak valid", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index aeb984940a..a187481263 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Nessuna attività attiva da cui eliminare messaggi", + "no_active_task_to_roll_back": "Nessun task attivo da cui annullare le modifiche", + "no_active_task_to_restore": "Nessun task attivo da cui ripristinare le modifiche", "invalid_timestamp_for_deletion": "Timestamp del messaggio non valido per l'eliminazione", "cannot_delete_missing_timestamp": "Impossibile eliminare il messaggio: timestamp mancante", "cannot_delete_invalid_timestamp": "Impossibile eliminare il messaggio: timestamp non valido", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a7ec0c4de3..16f007fe12 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "メッセージを削除するアクティブなタスクがありません", + "no_active_task_to_roll_back": "ロールバックできるアクティブなタスクがありません", + "no_active_task_to_restore": "復元できるアクティブなタスクがありません", "invalid_timestamp_for_deletion": "削除用のメッセージタイムスタンプが無効です", "cannot_delete_missing_timestamp": "メッセージを削除できません:タイムスタンプがありません", "cannot_delete_invalid_timestamp": "メッセージを削除できません:タイムスタンプが無効です", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 906652ac14..ad18c7cdb7 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "메시지를 삭제할 활성 작업이 없습니다", + "no_active_task_to_roll_back": "되돌릴 활성 작업이 없습니다", + "no_active_task_to_restore": "복원할 활성 작업이 없습니다", "invalid_timestamp_for_deletion": "삭제를 위한 메시지 타임스탬프가 유효하지 않습니다", "cannot_delete_missing_timestamp": "메시지를 삭제할 수 없습니다: 타임스탬프가 없습니다", "cannot_delete_invalid_timestamp": "메시지를 삭제할 수 없습니다: 타임스탬프가 유효하지 않습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 34cdbbbe89..7940b833c8 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Geen actieve taak om berichten uit te verwijderen", + "no_active_task_to_roll_back": "Geen actieve taak om terug te draaien", + "no_active_task_to_restore": "Geen actieve taak om te herstellen", "invalid_timestamp_for_deletion": "Ongeldig bericht tijdstempel voor verwijdering", "cannot_delete_missing_timestamp": "Kan bericht niet verwijderen: tijdstempel ontbreekt", "cannot_delete_invalid_timestamp": "Kan bericht niet verwijderen: ongeldig tijdstempel", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 2a743fc009..6743e9f6f4 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Brak aktywnego zadania do usunięcia wiadomości", + "no_active_task_to_roll_back": "Brak aktywnego zadania do cofnięcia", + "no_active_task_to_restore": "Brak aktywnego zadania do przywrócenia", "invalid_timestamp_for_deletion": "Nieprawidłowy znacznik czasu wiadomości do usunięcia", "cannot_delete_missing_timestamp": "Nie można usunąć wiadomości: brak znacznika czasu", "cannot_delete_invalid_timestamp": "Nie można usunąć wiadomości: nieprawidłowy znacznik czasu", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index eaf1cae06b..6635851a37 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -105,6 +105,8 @@ }, "message": { "no_active_task_to_delete": "Nenhuma tarefa ativa para excluir mensagens", + "no_active_task_to_roll_back": "Nenhuma tarefa ativa para desfazer", + "no_active_task_to_restore": "Nenhuma tarefa ativa para restaurar", "invalid_timestamp_for_deletion": "Timestamp da mensagem inválido para exclusão", "cannot_delete_missing_timestamp": "Não é possível excluir mensagem: timestamp ausente", "cannot_delete_invalid_timestamp": "Não é possível excluir mensagem: timestamp inválido", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6f48365ec1..48f666c20d 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Нет активной задачи для удаления сообщений", + "no_active_task_to_roll_back": "Нет активного задания для отката", + "no_active_task_to_restore": "Нет активного задания для восстановления", "invalid_timestamp_for_deletion": "Недействительная временная метка сообщения для удаления", "cannot_delete_missing_timestamp": "Невозможно удалить сообщение: отсутствует временная метка", "cannot_delete_invalid_timestamp": "Невозможно удалить сообщение: недействительная временная метка", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index b5c543c405..a39b8459ad 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Mesaj silinecek aktif görev yok", + "no_active_task_to_roll_back": "Geri alınacak etkin görev yok", + "no_active_task_to_restore": "Geri yüklenecek etkin görev yok", "invalid_timestamp_for_deletion": "Silme için geçersiz mesaj zaman damgası", "cannot_delete_missing_timestamp": "Mesaj silinemiyor: zaman damgası eksik", "cannot_delete_invalid_timestamp": "Mesaj silinemiyor: geçersiz zaman damgası", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index cb2ed8e9b8..31469059a5 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -101,6 +101,8 @@ }, "message": { "no_active_task_to_delete": "Không có nhiệm vụ hoạt động để xóa tin nhắn", + "no_active_task_to_roll_back": "Không có tác vụ hoạt động để hoàn tác", + "no_active_task_to_restore": "Không có tác vụ hoạt động để khôi phục", "invalid_timestamp_for_deletion": "Dấu thời gian tin nhắn không hợp lệ để xóa", "cannot_delete_missing_timestamp": "Không thể xóa tin nhắn: thiếu dấu thời gian", "cannot_delete_invalid_timestamp": "Không thể xóa tin nhắn: dấu thời gian không hợp lệ", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 08cfe736b5..160136dbc6 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -106,6 +106,8 @@ }, "message": { "no_active_task_to_delete": "没有可删除消息的活跃任务", + "no_active_task_to_roll_back": "没有可回滚的活动任务", + "no_active_task_to_restore": "没有可恢复的活动任务", "invalid_timestamp_for_deletion": "删除操作的消息时间戳无效", "cannot_delete_missing_timestamp": "无法删除消息:缺少时间戳", "cannot_delete_invalid_timestamp": "无法删除消息:时间戳无效", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 92fdbaeb96..a04cd4301d 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -100,6 +100,8 @@ }, "message": { "no_active_task_to_delete": "沒有可刪除訊息的活躍工作", + "no_active_task_to_roll_back": "沒有可還原的活動任務", + "no_active_task_to_restore": "沒有可復原的活動任務", "invalid_timestamp_for_deletion": "刪除操作的訊息時間戳無效", "cannot_delete_missing_timestamp": "無法刪除訊息:缺少時間戳", "cannot_delete_invalid_timestamp": "無法刪除訊息:時間戳無效", diff --git a/src/integrations/misc/__tests__/open-file.spec.ts b/src/integrations/misc/__tests__/open-file.spec.ts index 1cdd91d589..7f46e9bd3e 100644 --- a/src/integrations/misc/__tests__/open-file.spec.ts +++ b/src/integrations/misc/__tests__/open-file.spec.ts @@ -171,6 +171,30 @@ describe("openFile", () => { expect(vscode.workspace.openTextDocument).toHaveBeenCalled() expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() }) + + it("should not decode a validated path when skipDecode is set", async () => { + // Change-card paths are filesystem paths: a file literally named + // "a%20b.ts" must open as named, not decoded to "a b.ts". + const literalPath = "./src/a%20b.ts" + // Structural double: the mock Uri.file returns plain {fsPath} objects, + // matching the documented test-double style of this file. + const mockDocument = { uri: { fsPath: literalPath } } as unknown as vscode.TextDocument + + vi.mocked(vscode.workspace.fs.stat).mockResolvedValue({ + type: vscode.FileType.File, + ctime: 0, + mtime: 0, + size: 0, + }) + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(mockDocument) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as vscode.TextEditor) + + await openFile(literalPath, { skipDecode: true }) + + expect(vscode.Uri.file).toHaveBeenCalledWith(expect.stringContaining("a%20b.ts")) + expect(vscode.Uri.file).not.toHaveBeenCalledWith(expect.stringContaining("a b.ts")) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) }) describe("error handling", () => { diff --git a/src/integrations/misc/open-file.ts b/src/integrations/misc/open-file.ts index 630bba2901..f78329148b 100644 --- a/src/integrations/misc/open-file.ts +++ b/src/integrations/misc/open-file.ts @@ -8,6 +8,10 @@ interface OpenFileOptions { create?: boolean content?: string line?: number + // Validated change-card paths opt out of the decode below: they + // are filesystem paths, and decoding a validated path would shift it + // after containment (a literal "a%20b.ts" must open as named). + skipDecode?: boolean } export async function openFile(filePath: string, options: OpenFileOptions = {}) { @@ -15,12 +19,16 @@ export async function openFile(filePath: string, options: OpenFileOptions = {}) // Store the original path for error messages before any modifications const originalFilePathForError = filePath - // Try to decode the URI component, but if it fails, use the original path - try { - filePath = decodeURIComponent(filePath) - } catch (decodeError) { - // If decoding fails (e.g., invalid escape sequences), continue with the original path - console.warn(`Failed to decode file path: ${decodeError}. Using original path.`) + // Try to decode the URI component, but if it fails, use the original + // path. Validated change-card paths opt out (skipDecode): they were + // contained as literals and must open exactly as named. + if (!options.skipDecode) { + try { + filePath = decodeURIComponent(filePath) + } catch (decodeError) { + // If decoding fails (e.g., invalid escape sequences), continue with the original path + console.warn(`Failed to decode file path: ${decodeError}. Using original path.`) + } } const workspaceRoot = getWorkspacePath() diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index b420d40e35..a4d1902dcf 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -407,6 +407,230 @@ export abstract class ShadowCheckpointService extends EventEmitter { } } + /** + * Restore a single file to its state at `commitHash` without moving the + * branch or truncating the checkpoint list (unlike + * {@link restoreCheckpoint}). + * + * If the file did not exist at `commitHash`, it is removed from the + * working tree instead — rolling a file back to before it was created. + */ + // Real-path containment for restoreFile targets. The lexical prefix check + // cannot see through a symlinked ancestor: a link inside the workspace + // pointing outside it passes the prefix check while the real target + // resolves elsewhere. Validate containment on real paths for every + // target, not only existing ones: the checkout branch writes through a + // symlinked ancestor even when the target itself does not exist. Walk up + // to the deepest ancestor that has a directory entry (lstat, so a + // dangling symlink counts as present and is then rejected by realpath + // instead of being skipped by an exists()-style check, which would + // validate the root and let the checkout report a false success). + // Resolving both sides keeps a legitimate symlinked workspace root + // working. Called before the Git lookups AND again immediately before + // each filesystem mutation: a concurrent process can replace an existing + // ancestor with a symlink while the Git calls are awaited (TOCTOU), + // redirecting the delete or the checkout outside the workspace. + private async assertRestoreContainment(resolvedTarget: string, filePath: string): Promise { + const realWorkspaceRoot = await fs.realpath(this.workspaceDir) + const realRoot = realWorkspaceRoot.endsWith(path.sep) ? realWorkspaceRoot : realWorkspaceRoot + path.sep + let realCandidate = resolvedTarget + while (true) { + try { + await fs.lstat(realCandidate) + break + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== "ENOENT" && code !== "ENOTDIR") throw err + } + const parent = path.dirname(realCandidate) + if (parent === realCandidate) { + throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) + } + realCandidate = parent + } + let realChecked: string + try { + realChecked = await fs.realpath(realCandidate) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`restoreFile target resolves through a dangling link: ${filePath}`) + } + throw err + } + if (realChecked !== realWorkspaceRoot && !realChecked.startsWith(realRoot)) { + throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) + } + } + + public async restoreFile(commitHash: string, filePath: string): Promise { + try { + this.log(`[${this.constructor.name}#restoreFile] restoring ${filePath} from ${commitHash}`) + + if (!this.git) { + throw new Error("Shadow git repo not initialized") + } + + // Constrain the path to the workspace before either branch: `..` + // segments would otherwise normalize (path.join / git pathspec) to a + // location outside `this.workspaceDir`, and the delete branch could + // remove an unrelated file. `path.resolve` normalizes the segments; + // the trailing-separator prefix check is the containment guard. + const resolvedTarget = path.resolve(this.workspaceDir, filePath) + const workspaceRoot = this.workspaceDir.endsWith(path.sep) + ? this.workspaceDir + : this.workspaceDir + path.sep + if (resolvedTarget !== this.workspaceDir && !resolvedTarget.startsWith(workspaceRoot)) { + throw new Error(`restoreFile target is outside the workspace: ${filePath}`) + } + + // Real-path containment (symlinked ancestors, dangling links); see + // assertRestoreContainment. Re-checked below, immediately before + // each filesystem mutation. + await this.assertRestoreContainment(resolvedTarget, filePath) + + // Git pathspecs are always POSIX, but the path handed to git must be + // the same path containment just validated. Deriving it from the raw + // request with a blanket backslash conversion would reinterpret + // POSIX filename backslashes as separators: `link\secret.txt` (a + // legal POSIX filename) would reach git as `link/secret.txt` and + // could write through a symlinked directory the validated path never + // traversed. Derive the pathspec from the validated native path and + // convert only its real separators; on Windows this is exactly the + // native-to-POSIX normalization the journal and webview hand back. + const gitPath = path.relative(this.workspaceDir, resolvedTarget).split(path.sep).join("/") + + // restoreFile restores exactly one regular file: a directory pathspec + // would recursively check out the whole subtree from the checkpoint, + // so reject directory targets - including "." and "" resolving to the + // workspace root - before either branch runs. + try { + const targetStat = await fs.lstat(resolvedTarget) + if (targetStat.isDirectory()) { + throw new Error(`restoreFile target is a directory: ${filePath}`) + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err + } + + const start = Date.now() + const existed = await this.fileExistsInCommit(commitHash, gitPath) + + if (existed) { + // The checkpoint entry can be a tree or a symlink (whose object + // is also a blob); only regular-file modes may be restored through + // this single-file path. ls-tree reports the mode, unlike cat-file. + const entry = await this.git!.raw(["ls-tree", commitHash, "--", `:(literal)${gitPath}`]) + const mode = entry.trim() ? entry.split("\t")[0].split(" ")[0] : "" + if (mode !== "100644" && mode !== "100755") { + throw new Error(`restoreFile target is not a regular file in the checkpoint: ${filePath}`) + } + // TOCTOU guard: a concurrent process can replace an ancestor with + // a symlink while the Git lookups above are awaited, and the + // checkout would then write through the swapped link. Re-check + // containment immediately before the mutation. + await this.assertRestoreContainment(resolvedTarget, filePath) + // The checkout pathspec must be literal too: a filename containing + // glob characters ([a].ts, foo?bar.ts) would otherwise match - or + // miss - other files, restoring more (or the wrong file) than the + // single requested path. + await this.git.checkout([commitHash, "--", `:(literal)${gitPath}`]) + } else { + // Belt-and-suspenders: a path the shadow repository excludes was + // never tracked, so its absence from the commit is not evidence of + // a deletion - removing it here could rm a live user file (e.g. a + // log under an excluded directory) that no checkpoint ever + // contained. Journaling now skips excluded paths, but journals + // written before that fix can still route an excluded path here; + // fail loudly instead of deleting. + if (await this.isPathIgnored(filePath)) { + throw new Error(`restoreFile target is excluded from the shadow repository: ${filePath}`) + } + // TOCTOU guard: same race as above - without this re-check the + // delete could remove a file the swapped link now points at + // outside the workspace. + await this.assertRestoreContainment(resolvedTarget, filePath) + // Use the validated path, not a re-join of the raw request: the + // delete branch must remove exactly what containment approved. + await fs.rm(resolvedTarget, { force: true }) + } + + const duration = Date.now() - start + this.emit("restore", { type: "restore", commitHash, duration }) + this.log(`[${this.constructor.name}#restoreFile] restored ${filePath} in ${duration}ms`) + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)) + this.log(`[${this.constructor.name}#restoreFile] failed to restore file: ${error.message}`) + this.emit("error", { type: "error", error }) + throw error + } + } + + /** + * Whether `filePath` (workspace-relative or absolute) is excluded from the + * shadow repository by its local exclude rules (.git/info/exclude, which + * mirrors the workspace ignore configuration). Excluded paths are never + * tracked by the shadow repo, so they must not enter the change journal + * and must not be deleted by restoreFile's delete branch. + */ + public async isPathIgnored(filePath: string): Promise { + if (!this.git) { + return false + } + const resolved = path.resolve(this.workspaceDir, filePath) + const gitPath = path.relative(this.workspaceDir, resolved).split(path.sep).join("/") + if (!gitPath || gitPath.startsWith("..")) { + return false + } + try { + // --no-index evaluates the exclude rules alone: index or commit + // membership is irrelevant to "the shadow repo never tracks this". + // No -v: verbose mode prints a line for ANY matching rule, and a + // negation rule (e.g. "!.env.example" after ".env*") means the path + // is NOT ignored - under -v it would be misclassified as ignored. + // Without -v only genuinely ignored paths print a line. The verdict + // still comes from the output, not from the promise settling: + // simple-git's raw() resolves (rather than rejects) on a silent + // non-zero exit, so "not ignored" (exit 1, no output) arrives as an + // empty success here - the same defect documented in + // fileExistsInCommit. + const match = await this.git.raw(["check-ignore", "--no-index", "--", gitPath]) + return match.trim().length > 0 + } catch { + // A git fatal (corrupt repo) leaves the path unproven-ignored; + // callers keep their historical behavior for that case. + return false + } + } + + /** Whether `filePath` exists in the tree of `commitHash`. */ + private async fileExistsInCommit(commitHash: string, filePath: string): Promise { + // A failed lookup is not evidence the file is absent. If the commit object + // itself cannot be read (invalid hash, corrupt or missing shadow repo), + // falling through to the restoreFile delete branch would remove a live + // file. Verify the object first and fail the restore loudly instead. + // + // Verification must be evidence-based: simple-git's raw() only rejects + // when git writes a fatal to stderr, and `git cat-file -e ` + // fails *silently* (exit 1, no output) — a silent resolution would be + // read as "valid commit". `rev-parse --verify` emits the resolved id on + // success and a stderr fatal on every failure mode, so the reject is + // reliable. + try { + await this.git!.raw(["rev-parse", "--verify", `${commitHash}^{commit}`]) + } catch { + throw new Error(`Checkpoint unavailable: ${commitHash}`) + } + // Existence must distinguish "absent" from "unreadable": ls-tree exits 0 + // with empty output only when the path is genuinely absent from the + // tree, while a corrupt or missing tree object makes it fail - and that + // failure must propagate rather than be misread as absence, which would + // route restoreFile into the delete branch and rm a live file the + // checkpoint does contain. The :(literal) magic keeps paths containing + // glob characters exact. + const entry = await this.git!.raw(["ls-tree", commitHash, "--", `:(literal)${filePath}`]) + return entry.trim().length > 0 + } + public async getDiff({ from, to }: { from?: string; to?: string }): Promise { if (!this.git) { throw new Error("Shadow git repo not initialized") diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 76e98367a5..e1571253af 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -165,6 +165,452 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( }) }) + describe(`${klass.name}#restoreFile`, () => { + it("restores a modified file to a previous checkpoint without touching other files", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const newFile = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(newFile, "New file content") + const commit2 = await service.saveCheckpoint("Second checkpoint") + expect(commit2?.commit).toBeTruthy() + + // Drift both files, then roll back only test.txt to commit 1. + await fs.writeFile(testFile, "Changed after checkpoint") + await fs.writeFile(newFile, "Also changed") + + await service.restoreFile(commit1!.commit, "test.txt") + + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + // new.txt is not part of the restore and keeps its drifted content. + expect(await fs.readFile(newFile, "utf-8")).toBe("Also changed") + }) + + it("deletes a file that did not exist at the checkpoint", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const newFile = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(newFile, "Created after the checkpoint") + + await service.restoreFile(commit1!.commit, "new.txt") + + expect(await fileExistsAtPath(newFile)).toBe(false) + // The untouched file keeps its checkpoint-1 content. + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("rejects deleting an ignored live file that the checkpoint never tracked", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + // An ignored path (.log is excluded by the default shadow-repo + // rules) reaches restoreFile only from a journal written before the + // skip-when-journaling fix. Its absence from the commit is not + // evidence of a deletion: the delete branch must fail loudly + // instead of removing a live file the shadow repo never tracked. + const ignoredFile = path.join(service.workspaceDir, "live.log") + await fs.writeFile(ignoredFile, "live log content") + + await expect(service.restoreFile(commit1!.commit, "live.log")).rejects.toThrow( + /excluded from the shadow repository/, + ) + + expect(await fileExistsAtPath(ignoredFile)).toBe(true) + }) + + it("treats a negation rule as not-ignored in isPathIgnored", async () => { + // A negation rule re-includes a path matched by an earlier rule + // (".env*" then "!.env.example"). check-ignore -v prints the negation + // match and would misclassify the file as ignored; without -v only + // genuinely ignored paths print, so isPathIgnored must agree with + // what the shadow repo actually stages. + const excludesPath = path.join(service.checkpointsDir, ".git", "info", "exclude") + await fs.appendFile(excludesPath, "\n.env*\n!.env.example\n") + + expect(await service.isPathIgnored(".env")).toBe(true) + expect(await service.isPathIgnored(".env.example")).toBe(false) + }) + + it("accepts a native backslashed path on Windows (git pathspecs are POSIX)", async () => { + if (process.platform !== "win32") { + // Off-Windows the journal paths are already POSIX, so there is + // nothing to normalize; the native-separator handling is still + // exercised by every other restoreFile test (and the POSIX-only + // backslash case has its own test below). + return + } + + // A nested file, so the relative path actually contains a + // separator that Windows writes as a backslash. + const subFile = path.join(path.dirname(testFile), "subdir", "inner.txt") + await fs.mkdir(path.dirname(subFile), { recursive: true }) + await fs.writeFile(subFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.writeFile(subFile, "Drifted") + + // The change journal and the webview hand backslashed paths to the + // rollback service on Windows; restoreFile must normalize them for + // the git calls. Without the normalization, `cat-file -e` would + // never match and the delete branch would remove a file the + // checkpoint actually contains. + await service.restoreFile(commit1!.commit, "subdir" + path.sep + "inner.txt") + + expect(await fs.readFile(subFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("does not let a POSIX backslash path reach a symlinked directory through git", async () => { + // On POSIX a backslash is a legal filename character. Containment + // validates the literal `link\secret.txt` inside the workspace; a + // blanket backslash conversion for the git pathspec would hand git + // `link/secret.txt` - a path through a directory the validated + // path never traversed - and the checkout would act on it: git + // replaces the symlinked directory with a real one and restores a + // file the caller never requested (CWE-22 family: git acting on + // an unvalidated path; verified against real git on Linux). + if (process.platform === "win32") { + // Backslash is a separator on Windows; the scenario is POSIX-only. + return + } + const innerDir = path.join(service.workspaceDir, "link") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "secret.txt"), "inside") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + const outsideFile = path.join(outsideDir, "secret.txt") + await fs.writeFile(outsideFile, "outside") + + // Replace the committed directory with a symlink to the outside dir. + await fs.rm(innerDir, { recursive: true }) + await fs.symlink(outsideDir, innerDir, "dir") + + await service.restoreFile(commit1!.commit, "link\\secret.txt") + + // The literal name is not in the checkpoint, so the delete branch + // runs against the validated (literal) path: the symlink survives + // untouched (under the old pathspec it was replaced by a real + // directory holding a file nobody asked to restore) and the + // outside file behind it is untouched. + const linkStat = await fs.lstat(innerDir) + expect(linkStat.isSymbolicLink()).toBe(true) + expect(await fs.readFile(outsideFile, "utf-8")).toBe("outside") + }) + + it("rejects a restore target that escapes the workspace", async () => { + // `..` segments must be normalized and contained, so neither the + // checkout nor the delete branch can touch a file outside the + // workspace (CWE-22). + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await expect(service.restoreFile(commit1!.commit, path.join("..", "escape.txt"))).rejects.toThrow( + /outside the workspace/, + ) + }) + + it("rejects an unavailable checkpoint instead of deleting the live file", async () => { + // A corrupt journal entry can hand the rollback service a checkpoint + // id that does not resolve to a shadow-repo object. The lookup + // failure must not be read as "file absent at the checkpoint" — + // that would route the restore into the delete branch and remove a + // live file. The restore must fail loudly and leave the file intact. + await fs.writeFile(testFile, "Ahoy, world!") + await service.saveCheckpoint("First checkpoint") + + await expect( + service.restoreFile("0000000000000000000000000000000000000000", "test.txt"), + ).rejects.toThrow("Checkpoint unavailable") + + expect(await fileExistsAtPath(testFile)).toBe(true) + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("does not delete a live file when the checkpoint tree object is unreadable", async () => { + // The commit object can resolve while its tree is corrupt or missing + // (partial shadow-repo damage). An existence lookup that swallows the + // read failure would report "absent" and delete a live file the + // checkpoint does contain; the failure must propagate instead. + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const git = service["git"]! + const gitDir = (await git.raw(["rev-parse", "--absolute-git-dir"])).trim() + const treeOid = (await git.raw(["show", "-s", "--format=%T", commit1!.commit])).trim() + await fs.rm(path.join(gitDir, "objects", treeOid.slice(0, 2), treeOid.slice(2)), { force: true }) + + await expect(service.restoreFile(commit1!.commit, "test.txt")).rejects.toThrow() + + expect(await fileExistsAtPath(testFile)).toBe(true) + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("restores exactly the requested file when its name contains glob characters", async () => { + // A plain pathspec treats [a].ts as a character class matching + // a.ts, so checkout would restore the wrong file and leave the + // requested one modified. The restore contract is exactly one + // path: the checkout pathspec must be literal. + const workspaceDir = path.dirname(testFile) + const globFile = path.join(workspaceDir, "[a].ts") + const plainFile = path.join(workspaceDir, "a.ts") + await fs.writeFile(globFile, "glob content") + await fs.writeFile(plainFile, "plain content") + const commit = await service.saveCheckpoint("Glob-name checkpoint") + expect(commit?.commit).toBeTruthy() + + await fs.writeFile(globFile, "glob modified") + await fs.writeFile(plainFile, "plain modified") + + await service.restoreFile(commit!.commit, "[a].ts") + + expect(await fs.readFile(globFile, "utf-8")).toBe("glob content") + expect(await fs.readFile(plainFile, "utf-8")).toBe("plain modified") + }) + + it("rejects a target that escapes the workspace through a symlinked ancestor", async () => { + // A lexical prefix check passes for a path that goes through a + // symlink inside the workspace pointing outside it; the resolved + // (real) target must be contained as well, or the delete/checkout + // branch would mutate a file the task never owned (CWE-22). + if (process.platform === "win32") { + // Creating symlinks needs elevated privileges on Windows; the + // lexical-containment test above still covers the portable case. + return + } + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + const outsideFile = path.join(outsideDir, "sneaky.txt") + await fs.writeFile(outsideFile, "outside") + + // A directory link inside the workspace pointing at the outside dir: + // lexically `link/sneaky.txt` is inside the workspace. + await fs.symlink(outsideDir, path.join(service.workspaceDir, "link"), "dir") + + // "resolves outside the workspace" is the real-path (symlink) + // guard's message; the bare "outside the workspace" would also + // match the lexical guard's error. + await expect(service.restoreFile(commit1!.commit, path.join("link", "sneaky.txt"))).rejects.toThrow( + /resolves outside the workspace/, + ) + + // The outside file survives: the restore failed before any mutation. + expect(await fileExistsAtPath(outsideFile)).toBe(true) + expect(await fs.readFile(outsideFile, "utf-8")).toBe("outside") + }) + + it("rejects a nonexistent target under a symlinked ancestor before checkout", async () => { + // The checkout branch writes through symlinked ancestors even when + // the target file itself does not exist yet, so containment must be + // validated on the deepest existing ancestor for every target + // (CWE-22 defense-in-depth). "junction" resolves without elevation + // on Windows and behaves as a directory symlink on POSIX. + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "newfile.txt"), "v1") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + + // Replace the real dir with a link to the outside dir: the commit + // still carries sub/newfile.txt, but the working-tree location now + // escapes through the link, and the target does not exist. + await fs.rm(innerDir, { recursive: true, force: true }) + await fs.symlink(outsideDir, innerDir, "junction") + + await expect(service.restoreFile(commit1!.commit, path.join("sub", "newfile.txt"))).rejects.toThrow( + /resolves outside the workspace/, + ) + + // The checkout never wrote through the link. + expect(await fileExistsAtPath(path.join(outsideDir, "newfile.txt"))).toBe(false) + }) + + it("rejects a target under a dangling symlink ancestor instead of a false success", async () => { + // A dangling link is invisible to exists()-style checks (they follow + // the link), so an exists()-based containment walk would validate the + // workspace root and let the checkout branch report success without + // ever creating the file. The lstat walk must see the link entry and + // reject through realpath. "junction" needs no elevation on Windows + // and behaves as a directory symlink on POSIX. + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "newfile.txt"), "v1") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.rm(innerDir, { recursive: true, force: true }) + await fs.symlink(path.join(tmpDir, `gone-${Date.now()}`), innerDir, "junction") + + await expect(service.restoreFile(commit1!.commit, path.join("sub", "newfile.txt"))).rejects.toThrow( + /dangling link/, + ) + + // Nothing was created through the dangling link. + expect(await fileExistsAtPath(path.join(innerDir, "newfile.txt"))).toBe(false) + }) + + it("re-validates containment before the delete when an ancestor link is swapped mid-lookup", async () => { + // TOCTOU: containment passes while `sub` is a real directory, but + // a concurrent process replaces it with a link into an outside + // dir while the Git existence lookup is awaited. Without a + // re-check immediately before the mutation, fs.rm would delete + // the outside file the swapped link now points at. + await fs.writeFile(testFile, "Ahoy, world!") + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + // Untracked after the commit: the delete branch applies. + await fs.writeFile(path.join(innerDir, "victim.txt"), "v1") + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + const outsideFile = path.join(outsideDir, "victim.txt") + await fs.writeFile(outsideFile, "outside") + + // Structural view of the private lookup so the race can be injected + // mid-call without a double assertion (AGENTS.md). + const proto = Object.getPrototypeOf(service) as { + fileExistsInCommit: (...args: unknown[]) => Promise + } + const original = proto.fileExistsInCommit + const swapSpy = vi.spyOn(proto, "fileExistsInCommit").mockImplementation(async function ( + this: unknown, + ...args: unknown[] + ) { + // The concurrent ancestor replacement lands mid-lookup. + await fs.rm(innerDir, { recursive: true, force: true }) + await fs.symlink(outsideDir, innerDir, "junction") + return original.apply(this, args) + }) + + await expect(service.restoreFile(commit1!.commit, path.join("sub", "victim.txt"))).rejects.toThrow( + /resolves outside the workspace/, + ) + swapSpy.mockRestore() + + // The outside file survives: the delete never followed the link. + expect(await fileExistsAtPath(outsideFile)).toBe(true) + expect(await fs.readFile(outsideFile, "utf-8")).toBe("outside") + }) + + it("re-validates containment before the checkout when an ancestor link is swapped mid-lookup", async () => { + // Same race on the checkout branch: the swap lands while the + // lookups are awaited, so a checkout without a pre-mutation + // re-check would write the committed file through the link into + // the outside dir. + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "keep.txt"), "v1") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + + // Structural view of the private lookup so the race can be injected + // mid-call without a double assertion (AGENTS.md). + const proto = Object.getPrototypeOf(service) as { + fileExistsInCommit: (...args: unknown[]) => Promise + } + const original = proto.fileExistsInCommit + const swapSpy = vi.spyOn(proto, "fileExistsInCommit").mockImplementation(async function ( + this: unknown, + ...args: unknown[] + ) { + await fs.rm(innerDir, { recursive: true, force: true }) + await fs.symlink(outsideDir, innerDir, "junction") + return original.apply(this, args) + }) + + await expect(service.restoreFile(commit1!.commit, path.join("sub", "keep.txt"))).rejects.toThrow( + /resolves outside the workspace/, + ) + swapSpy.mockRestore() + + // The checkout never wrote through the swapped link. + expect(await fileExistsAtPath(path.join(outsideDir, "keep.txt"))).toBe(false) + }) + + it("rejects restoring the workspace root as a single file", async () => { + // "." passes the lexical and realpath guards (it IS the workspace) + // but as a pathspec it would recursively restore the entire tree + // from the checkpoint - a blast radius far beyond one file. + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await expect(service.restoreFile(commit1!.commit, ".")).rejects.toThrow( + /restoreFile target is a directory/, + ) + }) + + it("rejects a directory target that is a tree in the checkpoint", async () => { + // The on-disk directory check cannot catch a directory the task + // already deleted; only the checkpoint tree-entry mode check can + // stop git checkout from recursively restoring the subtree. + const innerDir = path.join(service.workspaceDir, "sub") + await fs.mkdir(innerDir) + await fs.writeFile(path.join(innerDir, "a.txt"), "a") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.rm(innerDir, { recursive: true, force: true }) + await expect(service.restoreFile(commit1!.commit, "sub")).rejects.toThrow( + /not a regular file in the checkpoint/, + ) + expect(await fileExistsAtPath(path.join(innerDir, "a.txt"))).toBe(false) + }) + + it("emits a restore event when a file is restored", async () => { + const restoreListener = vi.fn() + service.on("restore", restoreListener) + + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.writeFile(testFile, "Drifted") + await service.restoreFile(commit1!.commit, "test.txt") + + expect(restoreListener).toHaveBeenCalledWith( + expect.objectContaining({ type: "restore", commitHash: commit1!.commit }), + ) + }) + + it("throws and emits an error event when the shadow repo is not initialized", async () => { + // A service that never ran initShadowGit has no git handle; restoreFile + // must fail cleanly and surface the error event. + const raw = await klass.create({ + taskId, + shadowDir: path.join(tmpDir, `noinit-${Date.now()}`), + workspaceDir: path.join(tmpDir, `ws-noinit-${Date.now()}`), + log: () => {}, + }) + + const errorListener = vi.fn() + raw.on("error", errorListener) + + await expect(raw.restoreFile("sha-x", "test.txt")).rejects.toThrow("Shadow git repo not initialized") + expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({ type: "error" })) + }) + }) + describe(`${klass.name}#saveCheckpoint`, () => { it("creates a checkpoint if there are pending changes", async () => { await fs.writeFile(testFile, "Ahoy, world!") diff --git a/webview-ui/src/components/chat/ChangeCard.tsx b/webview-ui/src/components/chat/ChangeCard.tsx new file mode 100644 index 0000000000..d9a6755a7a --- /dev/null +++ b/webview-ui/src/components/chat/ChangeCard.tsx @@ -0,0 +1,452 @@ +import { useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" +import { Check, FileDiff, History, RotateCcw, X } from "lucide-react" +import { safeJsonParse } from "@roo/core" + +import { changeCardSchema, type ClineMessage, type ExtensionMessage } from "@roo-code/types" + +import { Button, StandardTooltip } from "@/components/ui" +import { vscode } from "@src/utils/vscode" +import { formatPathTooltip } from "@src/utils/formatPathTooltip" + +import CodeAccordion from "../common/CodeAccordion" + +type RollbackStatus = "idle" | "confirming" | "pending" | "success" | "error" + +type RollbackState = { + status: RollbackStatus + error?: string +} + +const IDLE: RollbackState = { status: "idle" } + +const successState: RollbackState = { status: "success" } + +/** + * Per-step change card (B3a payload, B3b UI): header with the file count, a + * per-file list with +/− diff badges, and per-file / per-step restore + * controls wired to the extension host through the + * `checkpointRollbackFile` / `checkpointRollbackStep` / + * `checkpointRestoreLatestFile` messages. A rollback restores a file to the + * state it had BEFORE the step (undoing the step's write); a restore-latest + * brings the file back to its most recent recorded version (the forward + * direction, available from every card of the task). Diffs come from the + * payload's per-file `diff` field: `full` cards expand by default, + * `summary` cards expand lazily on toggle, compact cards carry no diff. + */ +export const ChangeCard = ({ message }: { message: ClineMessage }) => { + const { t } = useTranslation() + + const card = useMemo(() => { + // Validate the shape, not just the JSON-ness: this text is persisted + // task history, so a truncated or pre-series record must not throw + // during render. Records that fail to parse (safeJsonParse returns + // `undefined` without a default) or fail shape validation fall through + // to the null path (an inert card row). + const parsed = safeJsonParse(message.text) + if (parsed === undefined) { + return null + } + const validated = changeCardSchema.safeParse(parsed) + return validated.success ? validated.data : null + }, [message.text]) + + // Files whose diff is currently expanded. "full" cards expand inline by + // default; "summary" cards keep the diff collapsed until toggled. + const [expandedFiles, setExpandedFiles] = useState>(() => { + if (!card || card.detail !== "full") { + return new Set() + } + return new Set(card.files.filter((file) => file.diff != null).map((file) => file.path)) + }) + + const [fileRollbacks, setFileRollbacks] = useState>({}) + const [fileRestores, setFileRestores] = useState>({}) + const [stepRollback, setStepRollback] = useState(IDLE) + + const checkpointId = card?.checkpointIds[0] + + // Correlate extension restore results with this card by message ts. + useEffect(() => { + const handler = (event: MessageEvent) => { + const data = event.data as ExtensionMessage | undefined + const result = data?.checkpointRollbackResult + if (data?.type !== "checkpointRollbackResult" || !result) { + return + } + if (result.cardTs !== message.ts) { + return + } + const filePath = result.filePath + if (filePath !== undefined) { + // Per-file results route to the control that requested them: + // restore-latest results update the restore control; everything + // else (including results posted before `kind` existed) the + // rollback control. + const state: RollbackState = result.success ? successState : { status: "error", error: result.error } + if (result.kind === "restore-latest") { + setFileRestores((prev) => ({ ...prev, [filePath]: state })) + } else { + setFileRollbacks((prev) => ({ ...prev, [filePath]: state })) + } + } + if (result.files) { + const fileUpdates: Record = {} + for (const file of result.files) { + fileUpdates[file.filePath] = file.success ? successState : { status: "error", error: file.error } + } + setFileRollbacks((prev) => ({ ...prev, ...fileUpdates })) + const firstFailure = result.files.find((file) => !file.success) + setStepRollback( + result.success ? successState : { status: "error", error: firstFailure?.error ?? result.error }, + ) + } else if (filePath === undefined) { + // Step-level result with no per-file payload (for example the + // missing-task response: success: false, no files). Without this the + // step button would stay in the in-progress state forever. + setStepRollback(result.success ? successState : { status: "error", error: result.error }) + } + } + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, [message.ts]) + + if (!card || !checkpointId) { + return null + } + + const toggleFile = (path: string) => { + setExpandedFiles((prev) => { + const next = new Set(prev) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next + }) + } + + const requestFileRollback = (path: string) => { + vscode.postMessage({ + type: "checkpointRollbackFile", + payload: { cardTs: message.ts, checkpointId, filePath: path }, + }) + setFileRollbacks((prev) => ({ ...prev, [path]: { status: "pending" } })) + } + + const requestFileRestoreLatest = (path: string) => { + vscode.postMessage({ + type: "checkpointRestoreLatestFile", + payload: { cardTs: message.ts, filePath: path }, + }) + setFileRestores((prev) => ({ ...prev, [path]: { status: "pending" } })) + } + + const requestStepRollback = () => { + vscode.postMessage({ + type: "checkpointRollbackStep", + payload: { cardTs: message.ts, checkpointId, filePaths: card.files.map((file) => file.path) }, + }) + setStepRollback({ status: "pending" }) + } + + // Open the changed file in the editor. The extension host resolves relative + // paths against the current cwd (webviewMessageHandler "openFile"), so + // normalize the "./" prefix the same way FileChangesPanel does. + const openFileInEditor = (path: string) => { + vscode.postMessage({ + type: "openFile", + text: path.startsWith("./") ? path : "./" + path, + // Card paths are model-derived and survive persistence/import: the + // host treats this marker as an untrusted source and enforces + // workspace containment before opening (webviewMessageHandler). + values: { fromChangeCard: true }, + }) + } + + const diffBadges = (additions: number, deletions: number) => + additions > 0 || deletions > 0 ? ( + + +{additions} + -{deletions} + + ) : null + + const fileRollbackControls = (path: string, index: number) => { + const state = fileRollbacks[path] ?? IDLE + const confirmTestId = `change-card-file-confirm-${index}` + const cancelTestId = `change-card-file-cancel-${index}` + + switch (state.status) { + case "confirming": + return ( + + + {t("chat:changeCard.rollbackFileWarning")} + + + + + ) + case "pending": + return ( + + + + ) + case "success": + return ( + + + {t("chat:changeCard.rolledBack")} + + ) + case "error": + return ( + + {/* Focusable status so the error detail is reachable by keyboard and + screen-reader users, not only via the hover tooltip. */} + + + {t("chat:changeCard.rollbackFailed")} + + + ) + default: + return ( + + + + ) + } + } + + // Per-file forward direction: restore the file to its most recent recorded + // version. Independent of the rollback control so a user can undo a + // rollback (or a manual edit) from any card of the task. + const fileRestoreLatestControls = (path: string, index: number) => { + const state = fileRestores[path] ?? IDLE + const confirmTestId = `change-card-file-restore-confirm-${index}` + const cancelTestId = `change-card-file-restore-cancel-${index}` + + switch (state.status) { + case "confirming": + return ( + + + {t("chat:changeCard.restoreLatestWarning")} + + + + + ) + case "pending": + return ( + + + + ) + case "success": + return ( + + + {t("chat:changeCard.restored")} + + ) + case "error": + return ( + + {/* Focusable status (see the file-rollback error span above). */} + + + {t("chat:changeCard.restoreFailed")} + + + ) + default: + return ( + + + + ) + } + } + + const stepRollbackControls = () => { + switch (stepRollback.status) { + case "confirming": + return ( + + + {t("chat:changeCard.rollbackWarning")} + + + + + ) + case "pending": + return ( + + + {t("chat:changeCard.rollingBack")} + + ) + case "success": + return ( + + + {t("chat:changeCard.stepRolledBack")} + + ) + case "error": + return ( + + {/* Focusable status (see the file-rollback error span above). */} + + + {t("chat:changeCard.rollbackFailed")} + + + ) + default: + return ( + + ) + } + } + + return ( +
+
+ + + {t("chat:changeCard.header", { count: card.totalFiles })} + + + {stepRollbackControls()} +
+
+ {card.files.map((file, index) => ( +
+
+ {file.diff != null ? ( + toggleFile(file.path)} + onJumpToFile={() => openFileInEditor(file.path)} + diffStats={{ added: file.additions, removed: file.deletions }} + /> + ) : ( +
+ + {formatPathTooltip(file.path)} + + + {diffBadges(file.additions, file.deletions)} + {/* Native button (not a span) so keyboard users can open the + file from a compact row: Enter/Space activate it for free. */} + +
+ )} +
+
+ {fileRollbackControls(file.path, index)} + {fileRestoreLatestControls(file.path, index)} +
+
+ ))} +
+
+ ) +} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 77ae4529c0..bb7282da23 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -39,6 +39,7 @@ import McpResourceRow from "../mcp/McpResourceRow" import { Mention } from "./Mention" import { CheckpointSaved } from "./checkpoints/CheckpointSaved" +import { ChangeCard } from "./ChangeCard" import { FollowUpSuggest } from "./FollowUpSuggest" import { BatchFilePermission } from "./BatchFilePermission" import { BatchDiffApproval } from "./BatchDiffApproval" @@ -1374,6 +1375,8 @@ export const ChatRowContent = ({ onJumpToPreviousCheckpoint={onJumpToPreviousCheckpoint} /> ) + case "change_card": + return case "condense_context": // In-progress state if (message.partial) { diff --git a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx new file mode 100644 index 0000000000..632ca77210 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx @@ -0,0 +1,575 @@ +// npx vitest run src/components/chat/__tests__/ChangeCard.spec.tsx + +import React from "react" +import { fireEvent, renderWithExtensionState, screen } from "@/utils/test-utils" +import type { ChangeCardData, ClineMessage } from "@roo-code/types" + +const mockPostMessage = vi.fn() + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (...args: unknown[]) => mockPostMessage(...args), + }, +})) + +// Mock i18n (same pattern as the other ChatRow specs) +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number; path?: string }) => { + const map: Record = { + "chat:changeCard.header": `${options?.count ?? 0} file(s) changed this step`, + "chat:changeCard.rollbackFile": "Rollback this file", + "chat:changeCard.rollbackFileWarning": "Restores this file to the content it had before this step.", + "chat:changeCard.restoreLatest": "Restore latest version", + "chat:changeCard.restoreLatestWarning": "Restores this file to the latest recorded version.", + "chat:changeCard.restored": "Restored latest version", + "chat:changeCard.restoreFailed": "Restore failed", + "chat:changeCard.rollbackStep": "Rollback step", + "chat:changeCard.rollbackWarning": "Restores the previous content of this step's files.", + "chat:changeCard.confirm": "Confirm", + "chat:changeCard.cancel": "Cancel", + "chat:changeCard.rollingBack": "Rolling back...", + "chat:changeCard.rolledBack": "Rolled back", + "chat:changeCard.stepRolledBack": "Step rolled back", + "chat:changeCard.rollbackFailed": "Rollback failed", + "chat:changeCard.openFile": "Open file: {{path}}", + } + // {{path}} interpolation: the compact row labels embed the file path + // (aria-label / title), mirroring the CodeAccordion label format. + const value = map[key] || key + return value.replace(/{{path}}/g, String(options?.path ?? "")) + }, + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock DiffView so the diff text is directly assertable (the real one runs a +// syntax highlighter, which is irrelevant to the lazy-expansion behavior). +vi.mock("@src/components/common/DiffView", () => ({ + default: ({ source }: { source: string }) =>
{source}
, +})) + +import { ChangeCard } from "../ChangeCard" +import { ChatRowContent } from "../ChatRow" + +function makeCardMessage(overrides: Partial = {}, ts = 1000): ClineMessage { + const card: ChangeCardData = { + checkpointIds: ["abc123"], + files: [ + { path: "src/a.ts", additions: 12, deletions: 3 }, + { path: "src/b.ts", additions: 1, deletions: 1 }, + ], + totalFiles: 2, + detail: "summary", + ...overrides, + } + return { + type: "say", + say: "change_card", + ts, + partial: false, + text: JSON.stringify(card), + } +} + +const DIFF_A = "@@ -1,1 +1,2 @@\n-old\n+new-a\n+extra\n" +const DIFF_B = "@@ -1,1 +1,1 @@\n-old\n+new-b\n" + +function fireRollbackResult(data: Record) { + fireEvent(window, new MessageEvent("message", { data })) +} + +describe("ChangeCard", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the header count and per-file list from a multi-file payload", () => { + renderWithExtensionState() + + expect(screen.getByTestId("change-card-header")).toHaveTextContent("2 file(s) changed this step") + expect(screen.getByText((text) => text.includes("src/a.ts"))).toBeInTheDocument() + expect(screen.getByText((text) => text.includes("src/b.ts"))).toBeInTheDocument() + expect(screen.getByText("+12")).toBeInTheDocument() + expect(screen.getByText("-3")).toBeInTheDocument() + expect(screen.getByText("+1")).toBeInTheDocument() + expect(screen.getByText("-1")).toBeInTheDocument() + }) + + it("keeps the diff hidden in summary cards until the file row is expanded", () => { + renderWithExtensionState( + , + ) + + // Collapsed by default: the diff text is not rendered. + expect(screen.queryByTestId("diff-view")).toBeNull() + expect(screen.queryByText((content) => content.includes("+new-a"))).toBeNull() + + // Expand the file row. + fireEvent.click(screen.getByText((text) => text.includes("src/a.ts"))) + + // The diff text comes from the payload and is rendered lazily on expand. + expect(screen.getByTestId("diff-view").textContent).toContain(DIFF_A.trim()) + + // Collapse again. + fireEvent.click(screen.getByText((text) => text.includes("src/a.ts"))) + expect(screen.queryByTestId("diff-view")).toBeNull() + }) + + it("renders the diff inline by default in full cards", () => { + renderWithExtensionState( + , + ) + + const [diffA, diffB] = screen.getAllByTestId("diff-view") + expect(diffA.textContent).toContain(DIFF_A.trim()) + expect(diffB.textContent).toContain(DIFF_B.trim()) + }) + + it("renders compact rows without a diff section when the payload carries no diffs", () => { + // Auto-approved steps are always emitted as summary cards without any + // per-file diff field; the card then renders file rows with stats only. + renderWithExtensionState() + + expect(screen.getByTestId("change-card-header")).toBeInTheDocument() + expect(screen.queryByTestId("diff-view")).toBeNull() + expect(screen.getByText((text) => text.includes("src/a.ts"))).toBeInTheDocument() + expect(screen.getByText((text) => text.includes("src/b.ts"))).toBeInTheDocument() + }) + + it("renders nothing for an unparseable card payload", () => { + const { container } = renderWithExtensionState( + , + ) + + expect(container.innerHTML).toBe("") + }) + + it("renders nothing for a schema-invalid card payload", () => { + // Syntactically valid JSON that fails changeCardSchema: the + // safeParse rejection path (not only the parse-failure path) must + // also yield an inert card row. + const { container } = renderWithExtensionState( + , + ) + + expect(container.innerHTML).toBe("") + }) + + it("rolls back one file through the checkpointRollbackFile message and shows pending + success", async () => { + renderWithExtensionState() + + // Open the confirm step for the first file. + fireEvent.click(screen.getByTestId("change-card-file-rollback-0")) + expect(screen.getByTestId("change-card-file-confirm-0")).toBeInTheDocument() + + // Confirm sends the webview->extension message and goes pending. + fireEvent.click(screen.getByText("Confirm")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + expect(screen.getByTestId("change-card-file-pending-0")).toBeInTheDocument() + + // The extension ack resolves the pending state. + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 1000, filePath: "src/a.ts", success: true }, + }) + await screen.findByTestId("change-card-file-success-0") + expect(screen.getByTestId("change-card-file-success-0")).toHaveTextContent("Rolled back") + }) + + it("shows the file rollback error state on a failed ack", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-file-rollback-0")) + fireEvent.click(screen.getByText("Confirm")) + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + filePath: "src/a.ts", + success: false, + error: "checkpoint not found", + }, + }) + + expect(await screen.findByTestId("change-card-file-error-0")).toHaveTextContent("Rollback failed") + }) + + it("rolls back the whole step through the checkpointRollbackStep message and shows pending + success", async () => { + renderWithExtensionState() + + // Open the confirm step for the step-level rollback. + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + expect(screen.getByTestId("change-card-step-confirm")).toBeInTheDocument() + expect(screen.getByText("Restores the previous content of this step's files.")).toBeInTheDocument() + + // Confirm sends the step message with the step's file list. + fireEvent.click(screen.getByText("Confirm")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "checkpointRollbackStep", + payload: { cardTs: 1000, checkpointId: "abc123", filePaths: ["src/a.ts", "src/b.ts"] }, + }) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + // The extension ack (per-step result carries the per-file outcomes). + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: true, + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ], + }, + }) + expect(await screen.findByTestId("change-card-step-success")).toHaveTextContent("Step rolled back") + // Per-file rows resolve to success as well. + expect(screen.getByTestId("change-card-file-success-0")).toBeInTheDocument() + expect(screen.getByTestId("change-card-file-success-1")).toBeInTheDocument() + }) + + it("shows the step rollback error state with the first failing file's error", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: false, error: "boom" }, + ], + }, + }) + + expect(await screen.findByTestId("change-card-step-error")).toHaveTextContent("Rollback failed") + expect(screen.getByTestId("change-card-file-error-1")).toBeInTheDocument() + expect(screen.getByTestId("change-card-file-success-0")).toBeInTheDocument() + + // The error detail is exposed to assistive technology: each failed + // control is a focusable status element (not just hover tooltip text). + const fileError = screen.getByTestId("change-card-file-error-1") + expect(fileError).toHaveAttribute("role", "status") + expect(fileError).toHaveAttribute("tabindex", "0") + expect(fileError).toHaveAttribute("aria-label", "boom") + expect(screen.getByTestId("change-card-step-error")).toHaveAttribute("role", "status") + expect(screen.getByTestId("change-card-step-error")).toHaveAttribute("tabindex", "0") + }) + + it("resolves the step state from a failure result that carries no files", async () => { + // The missing-task response is a step-level result with success: false + // and no per-file payload (no files, no filePath). Without handling the + // empty shape the step button would stay in the pending state forever. + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "Checkpoints are not enabled for this task", + }, + }) + + // The error detail rides in the tooltip content; the visible state is + // the rollback-failed label. The assertion that matters here is that the + // step left the pending state at all (previously it would stay pending). + const stepError = await screen.findByTestId("change-card-step-error") + expect(stepError).toHaveTextContent("Rollback failed") + // Focusable status with the actual error as its accessible name. + expect(stepError).toHaveAttribute("role", "status") + expect(stepError).toHaveAttribute("tabindex", "0") + expect(stepError).toHaveAttribute("aria-label", "Checkpoints are not enabled for this task") + }) + + it("resolves the step state from a success result that carries no files", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 1000, success: true }, + }) + + expect(await screen.findByTestId("change-card-step-success")).toBeInTheDocument() + }) + + it("ignores rollback results for other change cards", () => { + renderWithExtensionState() + + // Unrelated extension messages are dropped by the card's listener. + fireEvent(window, new MessageEvent("message", { data: { type: "state", text: "x" } })) + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + // A result for a different card ts must not resolve this card. + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 999, success: true, files: [] }, + }) + + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + }) + + it("cancels the file and step rollback confirmations without sending a message", () => { + renderWithExtensionState() + + // File-level cancel returns to idle without a rollback message. + fireEvent.click(screen.getByTestId("change-card-file-rollback-0")) + fireEvent.click(screen.getByTestId("change-card-file-cancel-0")) + expect(screen.getByTestId("change-card-file-rollback-0")).toBeInTheDocument() + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "checkpointRollbackFile" })) + + // Step-level cancel returns to idle as well. + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByTestId("change-card-step-cancel")) + expect(screen.getByTestId("change-card-step-rollback")).toBeInTheDocument() + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "checkpointRollbackStep" })) + }) + + it("posts an openFile message from both the diff-row jump icon and the no-diff-row button", () => { + renderWithExtensionState( + , + ) + + // Diff row: the CodeAccordion header jump icon (own aria-label). + fireEvent.click(screen.getByLabelText("Open file: src/a.ts")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openFile", + text: "./src/a.ts", + values: { fromChangeCard: true }, + }) + mockPostMessage.mockClear() + + // No-diff row: the open control on the plain path row. + fireEvent.click(screen.getByTestId("change-card-file-open-1")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openFile", + text: "./src/b.ts", + values: { fromChangeCard: true }, + }) + }) + + it("does not double-prefix paths that already carry the ./ marker", () => { + renderWithExtensionState( + , + ) + + fireEvent.click(screen.getByTestId("change-card-file-open-0")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openFile", + text: "./src/c.ts", + values: { fromChangeCard: true }, + }) + }) + + it("renders the no-diff row open control as a native button so keyboard users can activate it", () => { + renderWithExtensionState( + , + ) + + // A native