Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ 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

/**
* GlobalSettings
*/
Expand Down Expand Up @@ -200,6 +207,12 @@ 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(),

ttsEnabled: z.boolean().optional(),
ttsSpeed: z.number().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ export type ExtensionState = Pick<

enableCheckpoints: boolean
checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15)
perWriteCheckpoints: boolean
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
Expand Down
138 changes: 138 additions & 0 deletions src/core/checkpoints/__tests__/changeJournal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import fs from "fs/promises"
import os from "os"
import path from "path"

import { afterEach, beforeEach, describe, expect, it } from "vitest"

import { appendChange, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal"

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> = {}): 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)
})
})

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("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("discards a torn final line and returns the complete entries", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Append a second line truncated mid-content, with no trailing newline.
await fs.appendFile(journalPath(tmpRoot, taskId), '{"path":"src/half.ts","operation":"upd')

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("ok")
})

it("skips a corrupt middle line and still loads the later valid entries", 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",
)

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("after")
})

it("does not throw when the entire journal is torn", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" }))

// Truncate to a single character — definitely invalid JSON.
await fs.writeFile(journalPath(tmpRoot, taskId), "{")

expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

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()
})
})
})
206 changes: 206 additions & 0 deletions src/core/checkpoints/__tests__/checkpointJournal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
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<unknown>
}

interface ProviderLike {
context: { globalStorageUri: { fsPath: string } }
log: (...args: unknown[]) => void
postMessageToWebview: (...args: unknown[]) => void
}

interface TaskLike {
taskId: string
enableCheckpoints: boolean
checkpointService: ServiceLike
checkpointServiceInitializing: boolean
providerRef: { deref: () => ProviderLike | undefined }
}

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(),
}
// 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 },
checkpointServiceInitializing: false,
providerRef: { deref: () => mockProvider },
}
})

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("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 and skips the journal on save failure", async () => {
saveCheckpointSpy.mockRejectedValueOnce(new Error("git exploded"))

await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined()
expect(mockTask.enableCheckpoints).toBe(false)
expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([])
})

it("does not write a journal entry when the checkpoint save is a no-op (empty commit)", async () => {
saveCheckpointSpy.mockResolvedValueOnce(undefined)

await checkpointSave(mockTask as Task, false, true, write)

expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([])
expect(mockTask.enableCheckpoints).toBe(true)
})

it("does not crash when the provider has no globalStorageDir", async () => {
mockTask.providerRef = { deref: () => undefined }

await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT })
expect(mockTask.enableCheckpoints).toBe(true)
expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([])
})

it("logs and continues when the journal cannot be written (checkpoints stay enabled)", async () => {
// Block the per-task checkpoint dir so the journal mkdir/append 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 journal failure is swallowed (logged, not rethrown), so the
// checkpoint result still resolves exactly as without journaling.
await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT })

expect(mockTask.enableCheckpoints).toBe(true)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("failed to append change journal entry"),
expect.anything(),
)
})
})
Loading
Loading