Skip to content
Merged
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
101 changes: 97 additions & 4 deletions src/checkpoints/checkpoint-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
* - Doesn't clutter the user's stash stack.
* - Captures both modifications AND creations (deleting files that
* were created after the checkpoint).
*
* Checkpoints are persisted to disk via CheckpointStore, allowing
* restoration across extension restarts.
*/
import * as vscode from "vscode";
import { resolveInWorkspace } from "../utils/workspace-path";
import { CheckpointStore } from "./checkpoint-store";

/**
* A single file snapshot within a checkpoint. `content === null` means
Expand All @@ -40,13 +44,53 @@ export class CheckpointManager {
private static readonly MAX_FILE_BYTES = 10 * 1024 * 1024; // 10MB per file

private checkpoints: Checkpoint[] = [];
private store: CheckpointStore | null;
private loadPromise: Promise<void> = Promise.resolve();

constructor(
private readonly workspaceRoot: string,
storagePath?: string,
) {
if (storagePath) {
this.store = new CheckpointStore(storagePath);
// Load checkpoints from disk on initialization
this.loadPromise = this.loadFromDisk().catch((err) => {
console.warn("Champ: failed to load checkpoints from disk:", err);
});
} else {
this.store = null;
}
}

constructor(private readonly workspaceRoot: string) {}
/**
* Wait for checkpoints to load from disk. Useful for testing and
* scenarios where checkpoint state must be ready before proceeding.
*/
async waitForLoad(): Promise<void> {
await this.loadPromise;
}

/**
* Load checkpoints from disk and populate the in-memory list.
* This is called during initialization to restore session state.
*/
private async loadFromDisk(): Promise<void> {
if (!this.store) return;
try {
const loaded = await this.store.loadAll();
// Sort by timestamp to maintain chronological order
loaded.sort((a, b) => a.timestamp - b.timestamp);
this.checkpoints = loaded;
} catch (err) {
console.warn("Champ: error loading checkpoints from disk:", err);
}
}

/**
* Create a checkpoint capturing the current state of the given files.
* Files that don't exist yet are recorded as "not existed" so a
* restore can delete them.
* restore can delete them. Checkpoints are persisted to disk if a
* storage path was configured.
*/
async create(label: string, filePaths: string[]): Promise<Checkpoint> {
const snapshots: FileSnapshot[] = [];
Expand Down Expand Up @@ -90,10 +134,27 @@ export class CheckpointManager {

// Prune oldest if we exceed the checkpoint count limit.
while (this.checkpoints.length >= CheckpointManager.MAX_CHECKPOINTS) {
this.checkpoints.shift();
const oldest = this.checkpoints.shift();
if (oldest && this.store) {
// Delete the oldest checkpoint from disk as well
await this.store.delete(oldest.id).catch((err) => {
console.warn(
"Champ: failed to delete old checkpoint from disk:",
err,
);
});
}
}

this.checkpoints.push(checkpoint);

// Persist to disk if storage is configured
if (this.store) {
await this.store.save(checkpoint).catch((err) => {
console.warn("Champ: failed to save checkpoint to disk:", err);
});
}

return checkpoint;
}

Expand All @@ -102,6 +163,8 @@ export class CheckpointManager {
* discarded; the requested checkpoint becomes the most recent one
* (inclusive on index, but we drop it and everything after so the
* restore "becomes" the new latest state).
*
* Deleted checkpoints are also removed from disk if storage is configured.
*/
async restore(checkpointId: string): Promise<void> {
const idx = this.checkpoints.findIndex((c) => c.id === checkpointId);
Expand Down Expand Up @@ -141,6 +204,19 @@ export class CheckpointManager {
}
}

// Delete discarded checkpoints from disk
const discarded = this.checkpoints.slice(idx + 1);
for (const cp of discarded) {
if (this.store) {
await this.store.delete(cp.id).catch((err) => {
console.warn(
"Champ: failed to delete discarded checkpoint from disk:",
err,
);
});
}
}

// Drop the restored checkpoint and everything after it.
this.checkpoints = this.checkpoints.slice(0, idx);
}
Expand All @@ -150,9 +226,26 @@ export class CheckpointManager {
return [...this.checkpoints];
}

/** Discard all checkpoints without restoring anything. */
/** Discard all checkpoints without restoring anything. Clears disk asynchronously in background. */
clear(): void {
const toDelete = [...this.checkpoints];
this.checkpoints = [];

// Delete checkpoints from disk asynchronously in the background
if (this.store && toDelete.length > 0) {
Promise.all(
toDelete.map((cp) =>
this.store!.delete(cp.id).catch((err) => {
console.warn(
"Champ: failed to delete checkpoint from disk during clear:",
err,
);
}),
),
).catch((err) => {
console.warn("Champ: error during checkpoint cleanup:", err);
});
}
}

private generateId(): string {
Expand Down
97 changes: 97 additions & 0 deletions src/checkpoints/checkpoint-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* CheckpointStore: filesystem persistence for checkpoints.
*
* Each checkpoint is stored as a JSON file under the storage root
* directory. Files are human-readable and can be tracked in version control.
*/
import * as fs from "fs";
import * as path from "path";
import type { Checkpoint } from "./checkpoint-manager";

export class CheckpointStore {
constructor(private readonly storageRoot: string) {}

/**
* Persist a checkpoint to <storageRoot>/<id>.json.
* Creates the storage directory if it doesn't exist.
*/
async save(checkpoint: Checkpoint): Promise<void> {
await fs.promises.mkdir(this.storageRoot, { recursive: true });
const filePath = this.checkpointPath(checkpoint.id);

// Convert Uint8Array to base64 strings for JSON serialization
const serialized = {
...checkpoint,
snapshots: checkpoint.snapshots.map(snap => ({
filePath: snap.filePath,
content: snap.content ? Buffer.from(snap.content).toString('base64') : null,
existed: snap.existed,
})),
};

const json = JSON.stringify(serialized, null, 2);
// Write to temp file then rename for atomic writes.
const tmpPath = `${filePath}.tmp`;
await fs.promises.writeFile(tmpPath, json, "utf-8");
await fs.promises.rename(tmpPath, filePath);
}

/**
* Load all checkpoints from disk. Corrupted files are skipped with
* a console warning rather than crashing.
*/
async loadAll(): Promise<Checkpoint[]> {
let entries: string[];
try {
entries = await fs.promises.readdir(this.storageRoot);
} catch {
return [];
}

const checkpoints: Checkpoint[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const filePath = path.join(this.storageRoot, entry);
try {
const data = await fs.promises.readFile(filePath, "utf-8");
const parsed = JSON.parse(data) as any;
if (parsed.id && parsed.label && Array.isArray(parsed.snapshots)) {
// Convert base64 strings back to Uint8Array
const checkpoint: Checkpoint = {
id: parsed.id,
label: parsed.label,
timestamp: parsed.timestamp,
snapshots: parsed.snapshots.map((snap: any) => ({
filePath: snap.filePath,
content: snap.content ? new Uint8Array(Buffer.from(snap.content, 'base64')) : null,
existed: snap.existed,
})),
};
checkpoints.push(checkpoint);
}
} catch (err) {
console.warn(
`Champ: skipping corrupted checkpoint file ${filePath}:`,
err instanceof Error ? err.message : err,
);
}
}
return checkpoints;
}

/**
* Delete a checkpoint file.
*/
async delete(id: string): Promise<void> {
const filePath = this.checkpointPath(id);
try {
await fs.promises.unlink(filePath);
} catch {
// File may already be deleted.
}
}

private checkpointPath(id: string): string {
return path.join(this.storageRoot, `${id}.json`);
}
}
5 changes: 4 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ export async function activate(
const globalMemoryBank = new GlobalMemoryBank();

const checkpointManager = workspaceRoot
? new CheckpointManager(workspaceRoot)
? new CheckpointManager(
workspaceRoot,
path.join(workspaceRoot, ".champ", "checkpoints"),
)
: null;

workflowStore = workspaceRoot ? new WorkflowStore(workspaceRoot) : undefined;
Expand Down
Loading
Loading