Skip to content

Commit 2b4a8ce

Browse files
committed
feat(checkpoints): per-file and per-step rollback service (B3c, #1375)
1 parent 78bd752 commit 2b4a8ce

4 files changed

Lines changed: 496 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import fs from "fs/promises"
2+
import os from "os"
3+
import path from "path"
4+
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
6+
7+
import type { Task } from "../../task/Task"
8+
import { getCheckpointService } from "../index"
9+
import { appendChange } from "../changeJournal"
10+
import { rollbackFile, rollbackStep } from "../rollback"
11+
12+
vi.mock("../index", () => ({
13+
getCheckpointService: vi.fn(),
14+
checkpointSave: vi.fn(),
15+
checkpointRestore: vi.fn(),
16+
checkpointDiff: vi.fn(),
17+
}))
18+
19+
const mockedGetCheckpointService = getCheckpointService as unknown as ReturnType<typeof vi.fn>
20+
21+
function makeTask(): Task {
22+
return {
23+
taskId: "task-rollback",
24+
providerRef: {
25+
deref: vi.fn().mockReturnValue({ context: { globalStorageUri: { fsPath: globalStorageDir } } }),
26+
},
27+
} as unknown as Task
28+
}
29+
30+
let globalStorageDir: string
31+
32+
beforeEach(async () => {
33+
globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3a-rollback-"))
34+
mockedGetCheckpointService.mockReset()
35+
})
36+
37+
afterEach(async () => {
38+
await fs.rm(globalStorageDir, { recursive: true, force: true })
39+
})
40+
41+
describe("rollbackFile (B3a)", () => {
42+
it("restores the file through the task's checkpoint service", async () => {
43+
const restoreFile = vi.fn().mockResolvedValue(undefined)
44+
mockedGetCheckpointService.mockResolvedValue({ restoreFile })
45+
46+
const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts")
47+
48+
expect(outcome).toEqual({ filePath: "src/a.ts", success: true })
49+
expect(restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts")
50+
})
51+
52+
it("fails cleanly when checkpoints are not enabled", async () => {
53+
mockedGetCheckpointService.mockResolvedValue(undefined)
54+
55+
const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts")
56+
57+
expect(outcome).toEqual({
58+
filePath: "src/a.ts",
59+
success: false,
60+
error: "Checkpoints are not enabled for this task",
61+
})
62+
})
63+
64+
it("reports the service error without throwing", async () => {
65+
mockedGetCheckpointService.mockResolvedValue({
66+
restoreFile: vi.fn().mockRejectedValue(new Error("pathspec did not match")),
67+
})
68+
69+
const outcome = await rollbackFile(makeTask(), "sha-bad", "src/a.ts")
70+
71+
expect(outcome.success).toBe(false)
72+
expect(outcome.error).toContain("pathspec did not match")
73+
})
74+
75+
it("stringifies non-Error rejections into the outcome", async () => {
76+
mockedGetCheckpointService.mockResolvedValue({
77+
restoreFile: vi.fn().mockRejectedValue("raw failure"),
78+
})
79+
80+
const outcome = await rollbackFile(makeTask(), "sha-bad", "src/a.ts")
81+
82+
expect(outcome).toEqual({ filePath: "src/a.ts", success: false, error: "raw failure" })
83+
})
84+
})
85+
86+
describe("rollbackStep (B3a)", () => {
87+
it("restores every step file from the step's checkpoint via the journal", async () => {
88+
await appendChange(globalStorageDir, "task-rollback", {
89+
path: "src/a.ts",
90+
operation: "create",
91+
checkpointId: "sha-step",
92+
})
93+
await appendChange(globalStorageDir, "task-rollback", {
94+
path: "src/b.ts",
95+
operation: "update",
96+
checkpointId: "sha-step",
97+
})
98+
99+
const restoreFile = vi.fn().mockResolvedValue(undefined)
100+
mockedGetCheckpointService.mockResolvedValue({ restoreFile })
101+
102+
const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-step")
103+
104+
expect(outcome.checkpointId).toBe("sha-step")
105+
expect(outcome.files).toEqual([
106+
{ filePath: "src/a.ts", success: true },
107+
{ filePath: "src/b.ts", success: true },
108+
])
109+
expect(restoreFile).toHaveBeenCalledTimes(2)
110+
expect(restoreFile).toHaveBeenNthCalledWith(1, "sha-step", "src/a.ts")
111+
expect(restoreFile).toHaveBeenNthCalledWith(2, "sha-step", "src/b.ts")
112+
})
113+
114+
it("rejects a file that is not part of the given step checkpoint", async () => {
115+
await appendChange(globalStorageDir, "task-rollback", {
116+
path: "src/a.ts",
117+
operation: "create",
118+
checkpointId: "sha-step",
119+
})
120+
121+
const restoreFile = vi.fn().mockResolvedValue(undefined)
122+
mockedGetCheckpointService.mockResolvedValue({ restoreFile })
123+
124+
const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/other.ts"], "sha-step")
125+
126+
expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true })
127+
expect(outcome.files[1].success).toBe(false)
128+
expect(outcome.files[1].error).toBe("File is not part of this step's checkpoint")
129+
expect(restoreFile).toHaveBeenCalledTimes(1)
130+
})
131+
132+
it("falls back to the latest journal entry per file without a step checkpoint id", async () => {
133+
await appendChange(globalStorageDir, "task-rollback", {
134+
path: "src/a.ts",
135+
operation: "create",
136+
checkpointId: "sha-1",
137+
})
138+
await appendChange(globalStorageDir, "task-rollback", {
139+
path: "src/a.ts",
140+
operation: "update",
141+
checkpointId: "sha-2",
142+
})
143+
144+
const restoreFile = vi.fn().mockResolvedValue(undefined)
145+
mockedGetCheckpointService.mockResolvedValue({ restoreFile })
146+
147+
const outcome = await rollbackStep(makeTask(), ["src/a.ts"])
148+
149+
expect(outcome.checkpointId).toBe("sha-2")
150+
expect(restoreFile).toHaveBeenCalledWith("sha-2", "src/a.ts")
151+
})
152+
153+
it("fails listed files without journal entries and keeps the checkpoint when resolvable", async () => {
154+
await appendChange(globalStorageDir, "task-rollback", {
155+
path: "src/a.ts",
156+
operation: "create",
157+
checkpointId: "sha-1",
158+
})
159+
160+
const restoreFile = vi.fn().mockResolvedValue(undefined)
161+
mockedGetCheckpointService.mockResolvedValue({ restoreFile })
162+
163+
const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/missing.ts"])
164+
165+
expect(outcome.checkpointId).toBe("sha-1")
166+
expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true })
167+
expect(outcome.files[1].success).toBe(false)
168+
expect(outcome.files[1].error).toBe("No change journal entry for this file")
169+
})
170+
171+
it("treats a missing global storage directory as an empty journal", async () => {
172+
// No context on the provider double → no journal location to read.
173+
const task = {
174+
taskId: "task-rollback",
175+
providerRef: { deref: vi.fn().mockReturnValue(undefined) },
176+
} as unknown as Task
177+
178+
const restoreFile = vi.fn().mockResolvedValue(undefined)
179+
mockedGetCheckpointService.mockResolvedValue({ restoreFile })
180+
181+
const outcome = await rollbackStep(task, ["src/a.ts"], "sha-step")
182+
183+
expect(outcome.checkpointId).toBe("sha-step")
184+
expect(outcome.files[0].success).toBe(false)
185+
expect(outcome.files[0].error).toBe("File is not part of this step's checkpoint")
186+
expect(restoreFile).not.toHaveBeenCalled()
187+
})
188+
it("fails every file when checkpoints are not enabled", async () => {
189+
mockedGetCheckpointService.mockResolvedValue(undefined)
190+
191+
const outcome = await rollbackStep(makeTask(), ["src/a.ts"], "sha-step")
192+
193+
expect(outcome.checkpointId).toBe("sha-step")
194+
expect(outcome.files).toEqual([
195+
{ filePath: "src/a.ts", success: false, error: "Checkpoints are not enabled for this task" },
196+
])
197+
})
198+
})

‎src/core/checkpoints/rollback.ts‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Per-file / per-step checkpoint rollback (B3a).
3+
*
4+
* Restores reuse the existing shadow-git service (`getCheckpointService` →
5+
* `RepoPerTaskCheckpointService.restoreFile`, the same instance whose
6+
* `restoreCheckpoint` the checkpoints UI uses) — nothing is forked.
7+
*
8+
* - `rollbackFile` restores one file to the content it had at an explicit
9+
* checkpoint commit (the "restore to any checkpoint" primitive).
10+
* - `rollbackStep` restores every file a step touched to that step's
11+
* checkpoint. The step's checkpoint is resolved from the B2 change
12+
* journal (`changes.jsonl`), whose entries key each written file by the
13+
* checkpoint commit it produced. When the caller already knows the step's
14+
* checkpoint id (the change-card payload carries it), pass it so the
15+
* journal is filtered to exactly that step's writes.
16+
*/
17+
import type { Task } from "../task/Task"
18+
19+
import { getCheckpointService } from "./index"
20+
import { loadChanges, type ChangeJournalEntry } from "./changeJournal"
21+
22+
export interface RollbackFileOutcome {
23+
filePath: string
24+
success: boolean
25+
error?: string
26+
}
27+
28+
export interface RollbackStepOutcome {
29+
/** The step checkpoint the files were restored from, when resolvable. */
30+
checkpointId?: string
31+
files: RollbackFileOutcome[]
32+
}
33+
34+
const NOT_ENABLED_ERROR = "Checkpoints are not enabled for this task"
35+
36+
/**
37+
* Restore a single file to its state at `checkpointId`.
38+
*
39+
* Only the named file's working-tree content is replaced; the shadow repo's
40+
* HEAD and the checkpoint list are untouched (unlike a full
41+
* `restoreCheckpoint`).
42+
*/
43+
export async function rollbackFile(task: Task, checkpointId: string, filePath: string): Promise<RollbackFileOutcome> {
44+
const service = await getCheckpointService(task)
45+
46+
if (!service) {
47+
return { filePath, success: false, error: NOT_ENABLED_ERROR }
48+
}
49+
50+
try {
51+
await service.restoreFile(checkpointId, filePath)
52+
return { filePath, success: true }
53+
} catch (error) {
54+
const message = error instanceof Error ? error.message : String(error)
55+
console.error(`[rollbackFile] failed to restore ${filePath} from checkpoint ${checkpointId}: ${message}`)
56+
return { filePath, success: false, error: message }
57+
}
58+
}
59+
60+
/**
61+
* Restore every file of a step to the step's checkpoint.
62+
*
63+
* `stepFiles` comes from the B2 journal entries for the step's checkpoint id
64+
* (which the change-card payload also carries). Each file is resolved to its
65+
* step checkpoint through the journal:
66+
* - with `stepCheckpointId`, only entries for that checkpoint are considered
67+
* (exactly the step's writes), and every listed file is restored from it;
68+
* - without it, the latest journal entry per file is used as a fallback.
69+
*/
70+
export async function rollbackStep(
71+
task: Task,
72+
stepFiles: string[],
73+
stepCheckpointId?: string,
74+
): Promise<RollbackStepOutcome> {
75+
const service = await getCheckpointService(task)
76+
77+
if (!service) {
78+
return {
79+
checkpointId: stepCheckpointId,
80+
files: stepFiles.map((filePath) => ({ filePath, success: false, error: NOT_ENABLED_ERROR })),
81+
}
82+
}
83+
84+
// loadChanges never throws: an absent or torn journal resolves to its
85+
// readable prefix (or []), so no error handling is needed here.
86+
const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath
87+
const entries = globalStorageDir ? await loadChanges(globalStorageDir, task.taskId) : []
88+
89+
// Latest journal entry per file (journal lines preserve write order).
90+
const latestByPath = new Map<string, ChangeJournalEntry>()
91+
for (const entry of entries) {
92+
latestByPath.set(entry.path, entry)
93+
}
94+
95+
const checkpointId =
96+
stepCheckpointId ?? stepFiles.map((filePath) => latestByPath.get(filePath)?.checkpointId).find(Boolean)
97+
98+
const files: RollbackFileOutcome[] = []
99+
for (const filePath of stepFiles) {
100+
const entry = stepCheckpointId
101+
? entries.find((e) => e.path === filePath && e.checkpointId === stepCheckpointId)
102+
: latestByPath.get(filePath)
103+
104+
if (!entry) {
105+
files.push({
106+
filePath,
107+
success: false,
108+
error: stepCheckpointId
109+
? "File is not part of this step's checkpoint"
110+
: "No change journal entry for this file",
111+
})
112+
continue
113+
}
114+
115+
files.push(await rollbackFile(task, entry.checkpointId, filePath))
116+
}
117+
118+
return { checkpointId, files }
119+
}

‎src/services/checkpoints/ShadowCheckpointService.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,73 @@ export abstract class ShadowCheckpointService extends EventEmitter {
407407
}
408408
}
409409

410+
/**
411+
* Restore a single file to its state at `commitHash` without moving the
412+
* branch or truncating the checkpoint list (unlike
413+
* {@link restoreCheckpoint}).
414+
*
415+
* If the file did not exist at `commitHash`, it is removed from the
416+
* working tree instead — rolling a file back to before it was created.
417+
*/
418+
public async restoreFile(commitHash: string, filePath: string): Promise<void> {
419+
try {
420+
this.log(`[${this.constructor.name}#restoreFile] restoring ${filePath} from ${commitHash}`)
421+
422+
if (!this.git) {
423+
throw new Error("Shadow git repo not initialized")
424+
}
425+
426+
// Git pathspecs are always POSIX: normalize a native (Windows
427+
// backslash) path before the git calls. Without this, `cat-file -e` on
428+
// a backslashed path never matches, the file is treated as absent at
429+
// the checkpoint, and the delete branch below would remove a file the
430+
// checkpoint actually contains. The local fs.rm join keeps the native
431+
// form, since the OS treats both separators interchangeably there.
432+
const gitPath = filePath.toPosix()
433+
434+
// Constrain the path to the workspace before either branch: `..`
435+
// segments would otherwise normalize (path.join / git pathspec) to a
436+
// location outside `this.workspaceDir`, and the delete branch could
437+
// remove an unrelated file. `path.resolve` normalizes the segments;
438+
// the trailing-separator prefix check is the containment guard.
439+
const resolvedTarget = path.resolve(this.workspaceDir, filePath)
440+
const workspaceRoot = this.workspaceDir.endsWith(path.sep)
441+
? this.workspaceDir
442+
: this.workspaceDir + path.sep
443+
if (resolvedTarget !== this.workspaceDir && !resolvedTarget.startsWith(workspaceRoot)) {
444+
throw new Error(`restoreFile target is outside the workspace: ${filePath}`)
445+
}
446+
447+
const start = Date.now()
448+
const existed = await this.fileExistsInCommit(commitHash, gitPath)
449+
450+
if (existed) {
451+
await this.git.checkout([commitHash, "--", gitPath])
452+
} else {
453+
await fs.rm(path.join(this.workspaceDir, filePath), { force: true })
454+
}
455+
456+
const duration = Date.now() - start
457+
this.emit("restore", { type: "restore", commitHash, duration })
458+
this.log(`[${this.constructor.name}#restoreFile] restored ${filePath} in ${duration}ms`)
459+
} catch (e) {
460+
const error = e instanceof Error ? e : new Error(String(e))
461+
this.log(`[${this.constructor.name}#restoreFile] failed to restore file: ${error.message}`)
462+
this.emit("error", { type: "error", error })
463+
throw error
464+
}
465+
}
466+
467+
/** Whether `filePath` exists in the tree of `commitHash`. */
468+
private async fileExistsInCommit(commitHash: string, filePath: string): Promise<boolean> {
469+
try {
470+
await this.git!.raw(["cat-file", "-e", `${commitHash}:${filePath}`])
471+
return true
472+
} catch {
473+
return false
474+
}
475+
}
476+
410477
public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
411478
if (!this.git) {
412479
throw new Error("Shadow git repo not initialized")

0 commit comments

Comments
 (0)