diff --git a/backend/src/__tests__/checkpoint-store.test.ts b/backend/src/__tests__/checkpoint-store.test.ts new file mode 100644 index 0000000..65cdd02 --- /dev/null +++ b/backend/src/__tests__/checkpoint-store.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir, tmpdir } from 'os'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { CheckpointStore } from '../checkpoint-store.js'; + +const execAsync = promisify(exec); + +describe('CheckpointStore', () => { + let testDir: string; + let storeDir: string; + let store: CheckpointStore; + let isGitAvailable = true; + + beforeEach(async () => { + const uniqueId = Date.now() + '-' + Math.random().toString(36).substring(7); + testDir = join(tmpdir(), '.claudia-checkpoint-test-' + uniqueId); + storeDir = join(tmpdir(), '.claudia-checkpoint-store-' + uniqueId); + mkdirSync(testDir, { recursive: true }); + mkdirSync(storeDir, { recursive: true }); + + try { + await execAsync('git --version'); + } catch { + isGitAvailable = false; + } + + if (isGitAvailable) { + await execAsync('git init', { cwd: testDir }); + await execAsync('git config user.email "test@test.com"', { cwd: testDir }); + await execAsync('git config user.name "Test"', { cwd: testDir }); + writeFileSync(join(testDir, 'file.txt'), 'initial content'); + await execAsync('git add . && git commit -m "initial"', { cwd: testDir }); + } + + // Use a separate store directory so checkpoints.json doesn't end up in the git repo + store = new CheckpointStore(storeDir); + }); + + afterEach(() => { + try { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + if (existsSync(storeDir)) { + rmSync(storeDir, { recursive: true, force: true }); + } + } catch { + // Ignore cleanup errors + } + }); + + describe('createCheckpoint', () => { + it('should create a checkpoint with auto-generated name', async () => { + if (!isGitAvailable) return; + + const checkpoint = await store.createCheckpoint('task-1', testDir); + + expect(checkpoint.id).toBeDefined(); + expect(checkpoint.taskId).toBe('task-1'); + expect(checkpoint.workspaceId).toBe(testDir); + expect(checkpoint.name).toContain('Checkpoint'); + expect(checkpoint.timestamp).toBeDefined(); + expect(checkpoint.gitRef).toBeDefined(); + expect(checkpoint.gitBranch).toBeDefined(); + }); + + it('should create a checkpoint with custom name and description', async () => { + if (!isGitAvailable) return; + + const checkpoint = await store.createCheckpoint( + 'task-1', + testDir, + 'Before refactor', + 'State before the big refactoring pass', + ); + + expect(checkpoint.name).toBe('Before refactor'); + expect(checkpoint.description).toBe('State before the big refactoring pass'); + }); + + it('should capture git ref and branch', async () => { + if (!isGitAvailable) return; + + const { stdout: headRef } = await execAsync('git rev-parse HEAD', { cwd: testDir }); + const checkpoint = await store.createCheckpoint('task-1', testDir); + + expect(checkpoint.gitRef).toBe(headRef.trim()); + expect(['main', 'master']).toContain(checkpoint.gitBranch); + }); + + it('should capture uncommitted diff', async () => { + if (!isGitAvailable) return; + + writeFileSync(join(testDir, 'file.txt'), 'modified content'); + const checkpoint = await store.createCheckpoint('task-1', testDir); + + expect(checkpoint.gitDiff).toBeDefined(); + expect(checkpoint.gitDiff).toContain('modified content'); + expect(checkpoint.metadata?.filesModified).toBe(1); + }); + + it('should record 0 filesModified when no uncommitted changes', async () => { + if (!isGitAvailable) return; + + const checkpoint = await store.createCheckpoint('task-1', testDir); + expect(checkpoint.metadata?.filesModified).toBe(0); + expect(checkpoint.gitDiff).toBeUndefined(); + }); + + it('should handle non-git directories gracefully', async () => { + // Create a dir outside any git repo + const nonGitDir = join(tmpdir(), '.claudia-non-git-' + Date.now()); + mkdirSync(nonGitDir, { recursive: true }); + + try { + const checkpoint = await store.createCheckpoint('task-1', nonGitDir); + + expect(checkpoint.id).toBeDefined(); + expect(checkpoint.gitRef).toBeUndefined(); + expect(checkpoint.gitBranch).toBeUndefined(); + expect(checkpoint.gitDiff).toBeUndefined(); + } finally { + rmSync(nonGitDir, { recursive: true, force: true }); + } + }); + }); + + describe('listCheckpoints', () => { + it('should return empty array for unknown task', () => { + const checkpoints = store.listCheckpoints('nonexistent'); + expect(checkpoints).toEqual([]); + }); + + it('should list checkpoints for a specific task in descending order', async () => { + if (!isGitAvailable) return; + + await store.createCheckpoint('task-1', testDir, 'First'); + await new Promise((r) => setTimeout(r, 10)); + await store.createCheckpoint('task-1', testDir, 'Second'); + + const checkpoints = store.listCheckpoints('task-1'); + expect(checkpoints).toHaveLength(2); + expect(checkpoints[0].name).toBe('Second'); + expect(checkpoints[1].name).toBe('First'); + }); + + it('should not include checkpoints from other tasks', async () => { + if (!isGitAvailable) return; + + await store.createCheckpoint('task-1', testDir, 'Task 1 checkpoint'); + await store.createCheckpoint('task-2', testDir, 'Task 2 checkpoint'); + + const task1Checkpoints = store.listCheckpoints('task-1'); + expect(task1Checkpoints).toHaveLength(1); + expect(task1Checkpoints[0].name).toBe('Task 1 checkpoint'); + }); + }); + + describe('listCheckpointsByWorkspace', () => { + it('should list all checkpoints for a workspace across tasks', async () => { + if (!isGitAvailable) return; + + await store.createCheckpoint('task-1', testDir, 'From task 1'); + await store.createCheckpoint('task-2', testDir, 'From task 2'); + + const checkpoints = store.listCheckpointsByWorkspace(testDir); + expect(checkpoints).toHaveLength(2); + }); + + it('should return empty array for unknown workspace', () => { + const checkpoints = store.listCheckpointsByWorkspace('/nonexistent'); + expect(checkpoints).toEqual([]); + }); + }); + + describe('getCheckpoint', () => { + it('should return a specific checkpoint by id', async () => { + if (!isGitAvailable) return; + + const created = await store.createCheckpoint('task-1', testDir, 'Test'); + const fetched = store.getCheckpoint(created.id); + + expect(fetched).toBeDefined(); + expect(fetched?.id).toBe(created.id); + expect(fetched?.name).toBe('Test'); + }); + + it('should return undefined for unknown id', () => { + expect(store.getCheckpoint('nonexistent')).toBeUndefined(); + }); + }); + + describe('deleteCheckpoint', () => { + it('should delete a checkpoint and return true', async () => { + if (!isGitAvailable) return; + + const checkpoint = await store.createCheckpoint('task-1', testDir, 'To delete'); + const result = store.deleteCheckpoint(checkpoint.id); + + expect(result).toBe(true); + expect(store.getCheckpoint(checkpoint.id)).toBeUndefined(); + expect(store.listCheckpoints('task-1')).toHaveLength(0); + }); + + it('should return false for nonexistent checkpoint', () => { + expect(store.deleteCheckpoint('nonexistent')).toBe(false); + }); + }); + + describe('deleteCheckpointsForTask', () => { + it('should delete all checkpoints for a task', async () => { + if (!isGitAvailable) return; + + await store.createCheckpoint('task-1', testDir, 'First'); + await store.createCheckpoint('task-1', testDir, 'Second'); + await store.createCheckpoint('task-2', testDir, 'Other task'); + + const removed = store.deleteCheckpointsForTask('task-1'); + + expect(removed).toBe(2); + expect(store.listCheckpoints('task-1')).toHaveLength(0); + expect(store.listCheckpoints('task-2')).toHaveLength(1); + }); + + it('should return 0 if task has no checkpoints', () => { + expect(store.deleteCheckpointsForTask('nonexistent')).toBe(0); + }); + }); + + describe('restoreCheckpoint', () => { + it('should fail for nonexistent checkpoint', async () => { + const result = await store.restoreCheckpoint('nonexistent'); + expect(result.success).toBe(false); + expect(result.error).toBe('Checkpoint not found'); + }); + + it('should fail if checkpoint has no git reference', async () => { + // Create a non-git workspace checkpoint + const nonGitDir = join(tmpdir(), '.claudia-non-git-restore-' + Date.now()); + mkdirSync(nonGitDir, { recursive: true }); + + try { + const checkpoint = await store.createCheckpoint('task-1', nonGitDir, 'No git'); + const result = await store.restoreCheckpoint(checkpoint.id); + + // Either "not a git repo" or "no git reference" depending on order of checks + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + } finally { + rmSync(nonGitDir, { recursive: true, force: true }); + } + }); + + it('should restore to a previous commit state', async () => { + if (!isGitAvailable) return; + + const checkpoint = await store.createCheckpoint('task-1', testDir, 'Initial'); + + // Make a new commit + writeFileSync(join(testDir, 'new-file.txt'), 'new content'); + await execAsync('git add . && git commit -m "second commit"', { cwd: testDir }); + + const result = await store.restoreCheckpoint(checkpoint.id); + expect(result.success).toBe(true); + + expect(existsSync(join(testDir, 'new-file.txt'))).toBe(false); + }); + }); + + describe('forkFromCheckpoint', () => { + it('should fail for nonexistent checkpoint', async () => { + const result = await store.forkFromCheckpoint('nonexistent'); + expect(result.success).toBe(false); + expect(result.error).toBe('Checkpoint not found'); + }); + + it('should create a new branch from checkpoint', async () => { + if (!isGitAvailable) return; + + const checkpoint = await store.createCheckpoint('task-1', testDir, 'Fork point'); + + // Make another commit on current branch + writeFileSync(join(testDir, 'extra.txt'), 'extra'); + await execAsync('git add . && git commit -m "extra"', { cwd: testDir }); + + const result = await store.forkFromCheckpoint(checkpoint.id, 'my-fork-branch'); + expect(result.success).toBe(true); + expect(result.branch).toBe('my-fork-branch'); + + // Verify we're on the new branch + const { stdout } = await execAsync('git branch --show-current', { cwd: testDir }); + expect(stdout.trim()).toBe('my-fork-branch'); + + // The extra file should not exist on this branch + expect(existsSync(join(testDir, 'extra.txt'))).toBe(false); + }); + + it('should auto-generate branch name when not provided', async () => { + if (!isGitAvailable) return; + + const checkpoint = await store.createCheckpoint('task-1', testDir, 'Auto fork'); + const result = await store.forkFromCheckpoint(checkpoint.id); + + expect(result.success).toBe(true); + expect(result.branch).toContain('checkpoint-fork-'); + }); + }); + + describe('persistence', () => { + it('should persist checkpoints across store instances', async () => { + if (!isGitAvailable) return; + + await store.createCheckpoint('task-1', testDir, 'Persisted'); + + // Create a new store instance pointing to the same file + const newStore = new CheckpointStore(storeDir); + const checkpoints = newStore.listCheckpoints('task-1'); + + expect(checkpoints).toHaveLength(1); + expect(checkpoints[0].name).toBe('Persisted'); + }); + }); +}); diff --git a/backend/src/checkpoint-store.ts b/backend/src/checkpoint-store.ts new file mode 100644 index 0000000..dbad3f5 --- /dev/null +++ b/backend/src/checkpoint-store.ts @@ -0,0 +1,550 @@ +/** + * Checkpoint Store - Manages checkpoint/timeline snapshots for tasks. + * Captures git state (commit SHA, branch, uncommitted diff) and allows + * restoring or forking from a previous checkpoint. + */ +import { existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { randomUUID } from 'crypto'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { Checkpoint } from '@claudia/shared'; +import { loadVersioned, saveVersioned } from './utils/schema-version.js'; +import { isGitRepo, getHeadCommit, getCurrentBranch } from './git-utils.js'; + +const execAsync = promisify(exec); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** Schema version for checkpoints.json */ +const CHECKPOINT_SCHEMA_VERSION = 1; + +interface CheckpointData { + checkpoints: Checkpoint[]; +} + +function getDefaultData(): CheckpointData { + return { checkpoints: [] }; +} + +export class CheckpointStore { + private data: CheckpointData; + private filePath: string; + + constructor(basePath?: string) { + this.filePath = basePath + ? join(basePath, 'checkpoints.json') + : join(__dirname, '..', 'checkpoints.json'); + + // Ensure directory exists + const dir = dirname(this.filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + this.data = this.loadData(); + console.log( + `[CheckpointStore] Loaded ${this.data.checkpoints.length} checkpoints from ${this.filePath}`, + ); + } + + private loadData(): CheckpointData { + try { + return loadVersioned(this.filePath, { + currentVersion: CHECKPOINT_SCHEMA_VERSION, + defaultData: getDefaultData(), + legacyLoader: (raw) => (raw as CheckpointData) ?? getDefaultData(), + }); + } catch (error) { + console.error('[CheckpointStore] Error loading data:', error); + return getDefaultData(); + } + } + + private save(): void { + try { + saveVersioned(this.filePath, this.data, CHECKPOINT_SCHEMA_VERSION); + } catch (error) { + console.error('[CheckpointStore] Error saving data:', error); + throw error; + } + } + + /** + * Create a checkpoint for a task by capturing the current git state. + */ + async createCheckpoint( + taskId: string, + workspaceId: string, + name?: string, + description?: string, + ): Promise { + const cwd = workspaceId; // workspaceId IS the directory path + + let gitRef: string | undefined; + let gitBranch: string | undefined; + let gitDiff: string | undefined; + let filesModified = 0; + + const isRepo = await isGitRepo(cwd); + if (isRepo) { + gitRef = (await getHeadCommit(cwd)) || undefined; + gitBranch = (await getCurrentBranch(cwd)) || undefined; + + // Capture uncommitted diff (both staged and unstaged) + try { + const { stdout: diffOut } = await execAsync('git diff HEAD', { + cwd, + maxBuffer: 1024 * 1024, + }); + if (diffOut.trim()) { + gitDiff = diffOut; + } + } catch { + // No diff or error - that's fine + } + + // Count modified files + try { + const { stdout: statusOut } = await execAsync('git status --porcelain', { cwd }); + filesModified = statusOut + .trim() + .split('\n') + .filter((l) => l.length > 0).length; + } catch { + // Ignore + } + } + + const autoName = + name || + `Checkpoint ${new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`; + + const checkpoint: Checkpoint = { + id: randomUUID(), + taskId, + workspaceId, + name: autoName, + description, + timestamp: new Date().toISOString(), + gitRef, + gitBranch, + gitDiff, + metadata: { + filesModified, + isCurrent: false, + }, + }; + + this.data.checkpoints.push(checkpoint); + this.save(); + + console.log( + `[CheckpointStore] Created checkpoint "${checkpoint.name}" for task ${taskId} (git: ${gitRef?.substring(0, 7) || 'n/a'}, branch: ${gitBranch || 'n/a'})`, + ); + return checkpoint; + } + + /** + * List all checkpoints for a task, ordered by timestamp descending. + */ + listCheckpoints(taskId: string): Checkpoint[] { + return this.data.checkpoints + .filter((c) => c.taskId === taskId) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + } + + /** + * List all checkpoints for a workspace. + */ + listCheckpointsByWorkspace(workspaceId: string): Checkpoint[] { + return this.data.checkpoints + .filter((c) => c.workspaceId === workspaceId) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + } + + /** + * Get a single checkpoint by ID. + */ + getCheckpoint(checkpointId: string): Checkpoint | undefined { + return this.data.checkpoints.find((c) => c.id === checkpointId); + } + + /** + * Restore git state to a checkpoint. + * Stashes current changes, checks out the checkpoint's commit, and applies the saved diff. + */ + async restoreCheckpoint(checkpointId: string): Promise<{ success: boolean; error?: string }> { + const checkpoint = this.getCheckpoint(checkpointId); + if (!checkpoint) { + return { success: false, error: 'Checkpoint not found' }; + } + + const cwd = checkpoint.workspaceId; + const isRepo = await isGitRepo(cwd); + if (!isRepo) { + return { success: false, error: 'Workspace is not a git repository' }; + } + + if (!checkpoint.gitRef) { + return { success: false, error: 'Checkpoint has no git reference to restore' }; + } + + try { + // Step 1: Stash current changes (if any) + console.log( + `[CheckpointStore] Restoring checkpoint "${checkpoint.name}" - stashing current changes...`, + ); + try { + await execAsync('git stash push -m "claudia-checkpoint-restore-autostash"', { cwd }); + } catch { + // Stash may fail if nothing to stash - that's OK + } + + // Step 2: Checkout the checkpoint's commit + console.log(`[CheckpointStore] Checking out ${checkpoint.gitRef.substring(0, 7)}...`); + if (checkpoint.gitBranch) { + // Try to checkout the branch first (cleaner state) + try { + await execAsync(`git checkout ${checkpoint.gitBranch}`, { cwd }); + } catch { + // Branch might not exist anymore, checkout commit directly + await execAsync(`git checkout ${checkpoint.gitRef}`, { cwd }); + } + } + + // Reset to the exact commit + await execAsync(`git reset --hard ${checkpoint.gitRef}`, { cwd }); + + // Step 3: Apply saved diff (if any) + if (checkpoint.gitDiff) { + console.log(`[CheckpointStore] Applying saved diff...`); + try { + const { exec: execCb } = await import('child_process'); + await new Promise((resolve, reject) => { + const proc = execCb('git apply --allow-empty -', { cwd }, (err) => { + if (err) reject(err); + else resolve(); + }); + proc.stdin?.write(checkpoint.gitDiff); + proc.stdin?.end(); + }); + } catch (applyErr) { + console.warn( + '[CheckpointStore] Could not apply saved diff (may have conflicts):', + applyErr, + ); + // Not a fatal error - the commit restore still succeeded + } + } + + console.log(`[CheckpointStore] Successfully restored to checkpoint "${checkpoint.name}"`); + return { success: true }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error('[CheckpointStore] Restore failed:', msg); + return { success: false, error: msg }; + } + } + + /** + * Delete a checkpoint. + */ + deleteCheckpoint(checkpointId: string): boolean { + const idx = this.data.checkpoints.findIndex((c) => c.id === checkpointId); + if (idx === -1) return false; + + const removed = this.data.checkpoints.splice(idx, 1)[0]; + this.save(); + console.log(`[CheckpointStore] Deleted checkpoint "${removed.name}" (${checkpointId})`); + return true; + } + + /** + * Fork from a checkpoint: creates a new git branch from the checkpoint's commit + * and applies the saved diff. Returns the new branch name. + */ + async forkFromCheckpoint( + checkpointId: string, + newBranchName?: string, + ): Promise<{ success: boolean; branch?: string; error?: string }> { + const checkpoint = this.getCheckpoint(checkpointId); + if (!checkpoint) { + return { success: false, error: 'Checkpoint not found' }; + } + + const cwd = checkpoint.workspaceId; + const isRepo = await isGitRepo(cwd); + if (!isRepo) { + return { success: false, error: 'Workspace is not a git repository' }; + } + + if (!checkpoint.gitRef) { + return { success: false, error: 'Checkpoint has no git reference to fork from' }; + } + + const branch = newBranchName || `checkpoint-fork-${checkpoint.id.substring(0, 8)}`; + + try { + // Step 1: Create and checkout new branch from the checkpoint's commit + console.log( + `[CheckpointStore] Forking from checkpoint "${checkpoint.name}" to branch "${branch}"...`, + ); + await execAsync(`git checkout -b ${branch} ${checkpoint.gitRef}`, { cwd }); + + // Step 2: Apply saved diff (if any) + if (checkpoint.gitDiff) { + console.log(`[CheckpointStore] Applying saved diff to fork...`); + try { + const { exec: execCb } = await import('child_process'); + await new Promise((resolve, reject) => { + const proc = execCb('git apply --allow-empty -', { cwd }, (err) => { + if (err) reject(err); + else resolve(); + }); + proc.stdin?.write(checkpoint.gitDiff); + proc.stdin?.end(); + }); + } catch (applyErr) { + console.warn('[CheckpointStore] Could not apply diff to fork:', applyErr); + } + } + + console.log(`[CheckpointStore] Successfully forked to branch "${branch}"`); + return { success: true, branch }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error('[CheckpointStore] Fork failed:', msg); + return { success: false, error: msg }; + } + } + + /** + * Delete all checkpoints for a task. + */ + deleteCheckpointsForTask(taskId: string): number { + const before = this.data.checkpoints.length; + this.data.checkpoints = this.data.checkpoints.filter((c) => c.taskId !== taskId); + const removed = before - this.data.checkpoints.length; + if (removed > 0) { + this.save(); + console.log(`[CheckpointStore] Deleted ${removed} checkpoints for task ${taskId}`); + } + return removed; + } + + /** + * Parse a unified diff to extract the file paths it touches. + */ + private parseDiffFiles(diff: string): string[] { + const files: string[] = []; + for (const line of diff.split('\n')) { + // Match "diff --git a/path b/path" or "+++ b/path" lines + const gitDiffMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/); + if (gitDiffMatch) { + files.push(gitDiffMatch[2]); + } + } + return [...new Set(files)]; + } + + /** + * Extract the hunks for specific files from a unified diff. + */ + private extractDiffForFiles(fullDiff: string, files: string[]): string { + const fileSet = new Set(files); + const lines = fullDiff.split('\n'); + const result: string[] = []; + let include = false; + + for (const line of lines) { + const match = line.match(/^diff --git a\/(.+) b\/(.+)$/); + if (match) { + include = fileSet.has(match[2]); + } + if (include) { + result.push(line); + } + } + return result.join('\n'); + } + + /** + * Selective restore: only revert files that were part of this checkpoint's diff. + * Detects conflicts (files modified by other tasks since checkpoint) and returns them separately. + */ + async restoreCheckpointSelective(checkpointId: string): Promise<{ + success: boolean; + restoredFiles: string[]; + conflictingFiles: string[]; + error?: string; + }> { + const checkpoint = this.getCheckpoint(checkpointId); + if (!checkpoint) { + return { + success: false, + restoredFiles: [], + conflictingFiles: [], + error: 'Checkpoint not found', + }; + } + + const cwd = checkpoint.workspaceId; + const isRepo = await isGitRepo(cwd); + if (!isRepo) { + return { + success: false, + restoredFiles: [], + conflictingFiles: [], + error: 'Not a git repository', + }; + } + + if (!checkpoint.gitRef) { + return { + success: false, + restoredFiles: [], + conflictingFiles: [], + error: 'Checkpoint has no git reference', + }; + } + + // If no diff was saved, there's nothing task-specific to restore + if (!checkpoint.gitDiff) { + return { success: true, restoredFiles: [], conflictingFiles: [] }; + } + + const checkpointFiles = this.parseDiffFiles(checkpoint.gitDiff); + if (checkpointFiles.length === 0) { + return { success: true, restoredFiles: [], conflictingFiles: [] }; + } + + // Get current diff for these files to detect conflicts + const conflictingFiles: string[] = []; + const safeFiles: string[] = []; + + try { + const { stdout: currentDiff } = await execAsync('git diff HEAD', { + cwd, + maxBuffer: 1024 * 1024, + }); + const currentlyModifiedFiles = this.parseDiffFiles(currentDiff); + + for (const file of checkpointFiles) { + // A file is "conflicting" if it's currently modified AND the current diff + // for that file differs from what's in the checkpoint diff + if (currentlyModifiedFiles.includes(file)) { + const currentFileHunks = this.extractDiffForFiles(currentDiff, [file]); + const checkpointFileHunks = this.extractDiffForFiles(checkpoint.gitDiff!, [file]); + if (currentFileHunks !== checkpointFileHunks) { + conflictingFiles.push(file); + continue; + } + } + safeFiles.push(file); + } + + // Restore safe files: reset to committed state then apply checkpoint diff + const restoredFiles: string[] = []; + for (const file of safeFiles) { + try { + await execAsync(`git checkout ${checkpoint.gitRef} -- "${file}"`, { cwd }); + restoredFiles.push(file); + } catch (e) { + console.warn(`[CheckpointStore] Could not checkout file "${file}":`, e); + } + } + + // Apply the checkpoint's diff for just these files + if (restoredFiles.length > 0 && checkpoint.gitDiff) { + const partialDiff = this.extractDiffForFiles(checkpoint.gitDiff, restoredFiles); + if (partialDiff.trim()) { + try { + const { exec: execCb } = await import('child_process'); + await new Promise((resolve, reject) => { + const proc = execCb('git apply --allow-empty -', { cwd }, (err) => { + if (err) reject(err); + else resolve(); + }); + proc.stdin?.write(partialDiff); + proc.stdin?.end(); + }); + } catch { + console.warn('[CheckpointStore] Partial diff apply had issues (non-fatal)'); + } + } + } + + console.log( + `[CheckpointStore] Selective restore: ${restoredFiles.length} restored, ${conflictingFiles.length} conflicts`, + ); + return { success: true, restoredFiles, conflictingFiles }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error('[CheckpointStore] Selective restore failed:', msg); + return { success: false, restoredFiles: [], conflictingFiles: [], error: msg }; + } + } + + /** + * Force-restore specific files from a checkpoint, ignoring conflicts. + */ + async forceRestoreFiles( + checkpointId: string, + files: string[], + ): Promise<{ + success: boolean; + restoredFiles: string[]; + error?: string; + }> { + const checkpoint = this.getCheckpoint(checkpointId); + if (!checkpoint) { + return { success: false, restoredFiles: [], error: 'Checkpoint not found' }; + } + + const cwd = checkpoint.workspaceId; + if (!checkpoint.gitRef) { + return { success: false, restoredFiles: [], error: 'Checkpoint has no git reference' }; + } + + const restoredFiles: string[] = []; + try { + for (const file of files) { + try { + await execAsync(`git checkout ${checkpoint.gitRef} -- "${file}"`, { cwd }); + restoredFiles.push(file); + } catch (e) { + console.warn(`[CheckpointStore] Force restore failed for "${file}":`, e); + } + } + + // Apply checkpoint diff for these files + if (restoredFiles.length > 0 && checkpoint.gitDiff) { + const partialDiff = this.extractDiffForFiles(checkpoint.gitDiff, restoredFiles); + if (partialDiff.trim()) { + try { + const { exec: execCb } = await import('child_process'); + await new Promise((resolve, reject) => { + const proc = execCb('git apply --allow-empty -', { cwd }, (err) => { + if (err) reject(err); + else resolve(); + }); + proc.stdin?.write(partialDiff); + proc.stdin?.end(); + }); + } catch { + console.warn('[CheckpointStore] Force restore diff apply had issues'); + } + } + } + + console.log(`[CheckpointStore] Force-restored ${restoredFiles.length} files`); + return { success: true, restoredFiles }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { success: false, restoredFiles: [], error: msg }; + } + } +} diff --git a/backend/src/server.ts b/backend/src/server.ts index fcc006f..0b5e040 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -16,8 +16,9 @@ import { ConfigStore } from './config-store.js'; import { SupervisorChat } from './supervisor-chat.js'; import { getConversationHistory, getWorkspaceSessions } from './conversation-parser.js'; import { setUserId } from './usage-reporter.js'; -import { Task, Workspace, WorkspaceReference, WSMessage, WSMessageType, WSErrorPayload, ChatMessage, SuggestedAction, WaitingInputType, ScheduledTask, PORTS, TaskTokenUsage, UsageDashboardData } from '@claudia/shared'; +import { Task, Workspace, WorkspaceReference, WSMessage, WSMessageType, WSErrorPayload, ChatMessage, SuggestedAction, WaitingInputType, ScheduledTask, Checkpoint, PORTS, TaskTokenUsage, UsageDashboardData } from '@claudia/shared'; import { CronScheduler, validateCronExpression, describeCronExpression } from './cron-scheduler.js'; +import { CheckpointStore } from './checkpoint-store.js'; import { validateConfigUpdate, validateWorkspacePath } from './validation.js'; import { isGitRepo, getDefaultBranch, getCurrentBranch, checkoutBranch, getPrForBranch } from './git-utils.js'; import { WorktreeManager } from './worktree-manager.js'; @@ -102,6 +103,13 @@ const VALID_WS_MESSAGE_TYPES = new Set([ 'worktree:remove', 'worktree:prune', 'workspace:autoWorktree', + 'checkpoint:create', + 'checkpoint:list', + 'checkpoint:restore', + 'checkpoint:restore-selective', + 'checkpoint:restore-force', + 'checkpoint:delete', + 'checkpoint:fork', ]); // WebSocket message validation @@ -510,6 +518,9 @@ export async function createApp(basePath?: string) { ); cronScheduler.start(); + // CheckpointStore for per-task git snapshots / restore points + const checkpointStore = new CheckpointStore(basePath); + // Wire up tunnel events for broadcasting tunnelManager.on('tunnel:ready', (data: { url: string; token: string }) => { logger.info('Tunnel ready, broadcasting status', { url: data.url }); @@ -1408,6 +1419,23 @@ export async function createApp(basePath?: string) { if (internalTask) { internalTask.lastRefKey = validRefs.map(r => r.id).sort().join(','); } + // Create initial checkpoint capturing repo state before any agent action. + // Best-effort: failures don't block task creation. + checkpointStore + .createCheckpoint(newTask.id, validatedPath, 'Initial state') + .then((cp) => { + logger.info('Initial checkpoint created for new task', { + taskId: newTask.id, + checkpointId: cp.id, + }); + broadcast({ type: 'checkpoint:created', payload: cp }); + }) + .catch((err) => { + logger.warn('Failed to create initial checkpoint', { + taskId: newTask.id, + error: String(err), + }); + }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); logger.error('Failed to create task', { error: errorMessage }); @@ -2576,6 +2604,138 @@ export async function createApp(basePath?: string) { broadcast({ type: 'workspace:updated' as WSMessageType, payload: { workspace: updatedWs } }); break; } + + // ===== Checkpoints / Timeline ===== + + case 'checkpoint:create': { + const { + taskId: cpTaskId, + workspaceId: cpWsId, + name: cpName, + description: cpDesc, + } = payload as { + taskId?: string; + workspaceId?: string; + name?: string; + description?: string; + }; + if (!cpTaskId || !cpWsId) { + sendWSError(ws, 'checkpoint:create requires taskId and workspaceId', message.type, 'MISSING_PARAMS'); + break; + } + try { + const checkpoint = await checkpointStore.createCheckpoint(cpTaskId, cpWsId, cpName, cpDesc); + ws.send(JSON.stringify({ type: 'checkpoint:created', payload: checkpoint })); + } catch (err) { + sendWSError(ws, `Failed to create checkpoint: ${err}`, message.type, 'CHECKPOINT_ERROR'); + } + break; + } + + case 'checkpoint:list': { + const { taskId: cpListTaskId, workspaceId: cpListWsId } = payload as { + taskId?: string; + workspaceId?: string; + }; + let checkpoints: Checkpoint[] = []; + if (cpListTaskId) { + checkpoints = checkpointStore.listCheckpoints(cpListTaskId); + } else if (cpListWsId) { + checkpoints = checkpointStore.listCheckpointsByWorkspace(cpListWsId); + } + ws.send(JSON.stringify({ type: 'checkpoint:list', payload: { checkpoints } })); + break; + } + + case 'checkpoint:restore': { + const { checkpointId: restoreId } = payload as { checkpointId?: string }; + if (!restoreId) { + sendWSError(ws, 'checkpoint:restore requires checkpointId', message.type, 'MISSING_PARAMS'); + break; + } + const result = await checkpointStore.restoreCheckpoint(restoreId); + if (result.success) { + ws.send(JSON.stringify({ type: 'checkpoint:restored', payload: { checkpointId: restoreId } })); + } else { + ws.send(JSON.stringify({ type: 'checkpoint:error', payload: { error: result.error, checkpointId: restoreId } })); + } + break; + } + + case 'checkpoint:restore-selective': { + const { checkpointId: selRestoreId } = payload as { checkpointId?: string }; + if (!selRestoreId) { + sendWSError(ws, 'checkpoint:restore-selective requires checkpointId', message.type, 'MISSING_PARAMS'); + break; + } + const selResult = await checkpointStore.restoreCheckpointSelective(selRestoreId); + ws.send(JSON.stringify({ + type: 'checkpoint:restore-selective-result', + payload: { + checkpointId: selRestoreId, + success: selResult.success, + restoredFiles: selResult.restoredFiles, + conflictingFiles: selResult.conflictingFiles, + error: selResult.error, + }, + })); + break; + } + + case 'checkpoint:restore-force': { + const { checkpointId: forceId, files: forceFiles } = payload as { + checkpointId?: string; + files?: string[]; + }; + if (!forceId || !forceFiles || forceFiles.length === 0) { + sendWSError(ws, 'checkpoint:restore-force requires checkpointId and files[]', message.type, 'MISSING_PARAMS'); + break; + } + const forceResult = await checkpointStore.forceRestoreFiles(forceId, forceFiles); + ws.send(JSON.stringify({ + type: 'checkpoint:restore-force-result', + payload: { + checkpointId: forceId, + success: forceResult.success, + restoredFiles: forceResult.restoredFiles, + error: forceResult.error, + }, + })); + break; + } + + case 'checkpoint:delete': { + const { checkpointId: deleteId } = payload as { checkpointId?: string }; + if (!deleteId) { + sendWSError(ws, 'checkpoint:delete requires checkpointId', message.type, 'MISSING_PARAMS'); + break; + } + const deleted = checkpointStore.deleteCheckpoint(deleteId); + if (deleted) { + ws.send(JSON.stringify({ type: 'checkpoint:deleted', payload: { checkpointId: deleteId } })); + } else { + sendWSError(ws, 'Checkpoint not found', message.type, 'CHECKPOINT_NOT_FOUND'); + } + break; + } + + case 'checkpoint:fork': { + const { checkpointId: forkId, branchName } = payload as { + checkpointId?: string; + branchName?: string; + }; + if (!forkId) { + sendWSError(ws, 'checkpoint:fork requires checkpointId', message.type, 'MISSING_PARAMS'); + break; + } + const forkResult = await checkpointStore.forkFromCheckpoint(forkId, branchName); + if (forkResult.success) { + ws.send(JSON.stringify({ type: 'checkpoint:forked', payload: { checkpointId: forkId, branch: forkResult.branch } })); + } else { + ws.send(JSON.stringify({ type: 'checkpoint:error', payload: { error: forkResult.error, checkpointId: forkId } })); + } + break; + } } } catch (err) { logger.error('Error handling message', { diff --git a/frontend/src/components/CheckpointTimeline.css b/frontend/src/components/CheckpointTimeline.css new file mode 100644 index 0000000..33945b6 --- /dev/null +++ b/frontend/src/components/CheckpointTimeline.css @@ -0,0 +1,323 @@ +.cp-timeline { + border: 1px solid var(--border-color, #2a2a2a); + border-radius: 8px; + overflow: hidden; + background: var(--bg-tertiary, #141414); +} + +.cp-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + cursor: pointer; + user-select: none; + font-size: 13px; +} + +.cp-header:hover { + background: var(--bg-hover, #1a1a1a); +} + +.cp-toggle { + display: flex; + align-items: center; + color: var(--text-secondary, #888); +} + +.cp-title { + font-weight: 600; + color: var(--text-primary, #eee); +} + +.cp-count { + font-size: 11px; + background: var(--bg-primary, #0d0d0d); + color: var(--text-secondary, #888); + padding: 1px 6px; + border-radius: 10px; + font-family: 'SF Mono', monospace; +} + +.cp-create-btn { + margin-left: auto; + background: none; + border: 1px solid var(--border-color, #333); + border-radius: 4px; + padding: 3px 5px; + cursor: pointer; + color: var(--text-secondary, #888); + display: flex; + align-items: center; +} + +.cp-create-btn:hover { + color: var(--accent-color, #4a9eff); + border-color: var(--accent-color, #4a9eff); +} + +.cp-body { + border-top: 1px solid var(--border-color, #2a2a2a); + padding: 8px 10px; +} + +.cp-create-form { + display: flex; + gap: 6px; + margin-bottom: 10px; +} + +.cp-create-form input { + flex: 1; + background: var(--bg-primary, #0d0d0d); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + padding: 5px 8px; + color: var(--text-primary, #eee); + font-size: 12px; + outline: none; +} + +.cp-create-form input:focus { + border-color: var(--accent-color, #4a9eff); +} + +.cp-save-btn { + background: var(--accent-color, #4a9eff); + border: none; + border-radius: 4px; + padding: 5px 10px; + color: #fff; + font-size: 12px; + cursor: pointer; + font-weight: 500; +} + +.cp-save-btn:hover { + opacity: 0.9; +} + +.cp-empty { + color: var(--text-secondary, #666); + font-size: 12px; + text-align: center; + padding: 12px 0; +} + +.cp-list { + display: flex; + flex-direction: column; + position: relative; + padding-left: 12px; +} + +.cp-list::before { + content: ''; + position: absolute; + left: 5px; + top: 8px; + bottom: 8px; + width: 1px; + background: var(--border-color, #333); +} + +.cp-node { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 6px 0; + position: relative; +} + +.cp-node-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--border-color, #555); + flex-shrink: 0; + margin-top: 4px; + position: relative; + z-index: 1; + margin-left: -9px; +} + +.cp-node-latest .cp-node-dot { + background: var(--accent-color, #4a9eff); + box-shadow: 0 0 6px rgba(74, 158, 255, 0.4); +} + +.cp-node-content { + flex: 1; + min-width: 0; +} + +.cp-node-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #eee); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cp-node-meta { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; + font-size: 11px; + color: var(--text-secondary, #888); +} + +.cp-node-meta svg { + flex-shrink: 0; +} + +.cp-node-sha { + font-family: 'SF Mono', monospace; + font-size: 10px; + background: var(--bg-primary, #0d0d0d); + padding: 0 4px; + border-radius: 3px; +} + +.cp-node-desc { + font-size: 11px; + color: var(--text-secondary, #888); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cp-node-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity 0.15s; +} + +.cp-node:hover .cp-node-actions { + opacity: 1; +} + +.cp-node-actions button { + background: none; + border: none; + color: var(--text-secondary, #888); + cursor: pointer; + padding: 3px; + border-radius: 3px; + display: flex; + align-items: center; +} + +.cp-node-actions button:hover { + color: var(--text-primary, #fff); + background: var(--bg-hover, #222); +} + +.cp-delete-btn:hover { + color: #e55 !important; +} + +/* Conflict Resolution Dialog */ +.cp-conflict-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.cp-conflict-dialog { + background: var(--bg-secondary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 10px; + padding: 16px 20px; + width: min(400px, 90vw); + max-height: 60vh; + overflow-y: auto; +} + +.cp-conflict-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: #f0a030; + margin-bottom: 8px; +} + +.cp-conflict-desc { + font-size: 12px; + color: var(--text-secondary, #aaa); + margin: 0 0 12px; + line-height: 1.4; +} + +.cp-conflict-files { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} + +.cp-conflict-file { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-primary, #eee); + font-family: 'SF Mono', monospace; + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; +} + +.cp-conflict-file:hover { + background: var(--bg-hover, #222); +} + +.cp-conflict-file input[type="checkbox"] { + accent-color: var(--accent-color, #4a9eff); +} + +.cp-conflict-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.cp-conflict-skip { + background: none; + border: 1px solid var(--border-color, #444); + border-radius: 6px; + padding: 6px 14px; + color: var(--text-secondary, #aaa); + font-size: 12px; + cursor: pointer; +} + +.cp-conflict-skip:hover { + border-color: var(--text-secondary, #888); + color: var(--text-primary, #eee); +} + +.cp-conflict-force { + background: #c04020; + border: none; + border-radius: 6px; + padding: 6px 14px; + color: #fff; + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.cp-conflict-force:hover { + background: #d04828; +} diff --git a/frontend/src/components/CheckpointTimeline.tsx b/frontend/src/components/CheckpointTimeline.tsx new file mode 100644 index 0000000..c0bc58d --- /dev/null +++ b/frontend/src/components/CheckpointTimeline.tsx @@ -0,0 +1,300 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + GitBranch, + Clock, + RotateCcw, + GitFork, + Trash2, + Plus, + ChevronDown, + ChevronRight, + AlertTriangle, +} from 'lucide-react'; +import { Checkpoint } from '@claudia/shared'; +import './CheckpointTimeline.css'; + +interface CheckpointTimelineProps { + taskId: string; + workspaceId: string; + wsRef: React.RefObject; +} + +interface ConflictState { + checkpointId: string; + checkpointName: string; + restoredFiles: string[]; + conflictingFiles: string[]; + selectedFiles: Set; +} + +export function CheckpointTimeline({ taskId, workspaceId, wsRef }: CheckpointTimelineProps) { + const [checkpoints, setCheckpoints] = useState([]); + const [expanded, setExpanded] = useState(false); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [conflict, setConflict] = useState(null); + const [restoring, setRestoring] = useState(false); + + const sendWS = useCallback( + (type: string, payload: Record) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + wsRef.current.send(JSON.stringify({ type, payload })); + }, + [wsRef], + ); + + const fetchCheckpoints = useCallback(() => { + sendWS('checkpoint:list', { taskId }); + }, [sendWS, taskId]); + + useEffect(() => { + fetchCheckpoints(); + }, [fetchCheckpoints]); + + useEffect(() => { + const ws = wsRef.current; + if (!ws) return; + + const handler = (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + switch (msg.type) { + case 'checkpoint:list': + setCheckpoints(msg.payload.checkpoints || []); + break; + case 'checkpoint:created': + if (msg.payload.taskId === taskId) { + setCheckpoints((prev) => [msg.payload, ...prev]); + } + setCreating(false); + setNewName(''); + break; + case 'checkpoint:deleted': + setCheckpoints((prev) => prev.filter((c) => c.id !== msg.payload.checkpointId)); + break; + case 'checkpoint:restored': + case 'checkpoint:forked': + fetchCheckpoints(); + break; + case 'checkpoint:restore-selective-result': { + setRestoring(false); + const { success, restoredFiles, conflictingFiles, checkpointId, error } = msg.payload; + if (!success) { + alert(`Restore failed: ${error}`); + break; + } + if (conflictingFiles && conflictingFiles.length > 0) { + const cp = checkpoints.find((c) => c.id === checkpointId); + setConflict({ + checkpointId, + checkpointName: cp?.name || 'checkpoint', + restoredFiles: restoredFiles || [], + conflictingFiles, + selectedFiles: new Set(conflictingFiles), + }); + } else if (restoredFiles && restoredFiles.length > 0) { + fetchCheckpoints(); + } + break; + } + case 'checkpoint:restore-force-result': { + setConflict(null); + if (!msg.payload.success) { + alert(`Force restore failed: ${msg.payload.error}`); + } + fetchCheckpoints(); + break; + } + } + } catch { + /* ignore */ + } + }; + + ws.addEventListener('message', handler); + return () => ws.removeEventListener('message', handler); + }, [wsRef, fetchCheckpoints, taskId, checkpoints]); + + const handleCreate = () => { + sendWS('checkpoint:create', { taskId, workspaceId, name: newName || undefined }); + }; + + const handleRestore = (checkpointId: string, name: string) => { + if (!confirm(`Restore "${name}"? Only files changed by this task will be reverted.`)) return; + setRestoring(true); + sendWS('checkpoint:restore-selective', { checkpointId }); + }; + + const handleForceRestore = () => { + if (!conflict) return; + const files = Array.from(conflict.selectedFiles); + if (files.length === 0) { + setConflict(null); + return; + } + sendWS('checkpoint:restore-force', { checkpointId: conflict.checkpointId, files }); + }; + + const toggleConflictFile = (file: string) => { + if (!conflict) return; + const next = new Set(conflict.selectedFiles); + if (next.has(file)) next.delete(file); + else next.add(file); + setConflict({ ...conflict, selectedFiles: next }); + }; + + const handleFork = (checkpointId: string) => { + const branch = prompt('Branch name (leave empty for auto-generated):'); + if (branch === null) return; + sendWS('checkpoint:fork', { checkpointId, branchName: branch || undefined }); + }; + + const handleDelete = (checkpointId: string, name: string) => { + if (!confirm(`Delete checkpoint "${name}"?`)) return; + sendWS('checkpoint:delete', { checkpointId }); + }; + + const formatTime = (timestamp: string) => { + const d = new Date(timestamp); + const now = Date.now(); + const diff = now - d.getTime(); + if (diff < 60000) return 'just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }; + + return ( +
+
setExpanded(!expanded)}> + + {expanded ? : } + + + Checkpoints + {checkpoints.length} + +
+ + {expanded && ( +
+ {creating && ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreate(); + if (e.key === 'Escape') setCreating(false); + }} + autoFocus + /> + +
+ )} + + {checkpoints.length === 0 && !creating && ( +
No checkpoints yet. Create one to save your progress.
+ )} + +
+ {checkpoints.map((cp, idx) => ( +
+
+
+
{cp.name}
+
+ + {formatTime(cp.timestamp)} + {cp.gitBranch && ( + <> + + {cp.gitBranch} + + )} + {cp.gitRef && {cp.gitRef.slice(0, 7)}} +
+ {cp.description &&
{cp.description}
} +
+
+ + + +
+
+ ))} +
+
+ )} + + {conflict && ( +
setConflict(null)}> +
e.stopPropagation()}> +
+ + Conflicting Files +
+

+ {conflict.restoredFiles.length > 0 && ( + {conflict.restoredFiles.length} file(s) restored successfully. + )} + The following files were modified by another task. Select which to force-restore: +

+
+ {conflict.conflictingFiles.map((file) => ( + + ))} +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx index 2c35db4..593e070 100644 --- a/frontend/src/components/TerminalView.tsx +++ b/frontend/src/components/TerminalView.tsx @@ -7,6 +7,7 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'; import { Task, Workspace } from '@claudia/shared'; import { Copy, Check, Play, BookOpen, ArrowDown } from 'lucide-react'; import { TaskInputBar } from './TaskInputBar'; +import { CheckpointTimeline } from './CheckpointTimeline'; import { TaskTokenStats } from './TaskTokenStats'; import { useEffectiveTheme } from '../hooks/useTheme'; import { DARK_TERMINAL_THEME, LIGHT_TERMINAL_THEME } from '../types/theme'; @@ -814,6 +815,9 @@ export function TerminalView({ task, wsRef, workspace, isMobile }: TerminalViewP
+ {workspace && ( + + )} ); diff --git a/shared/src/index.ts b/shared/src/index.ts index f4c3cff..8e54ba8 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -139,6 +139,23 @@ export interface TaskSummary { timestamp: Date; } +// Checkpoint/Timeline types +export interface Checkpoint { + id: string; // Unique identifier + taskId: string; // Which task this checkpoint belongs to + workspaceId: string; // Which workspace + name: string; // User-provided or auto-generated name + description?: string; // Optional description + timestamp: string; // ISO timestamp when created + gitRef?: string; // Git commit SHA at checkpoint time + gitBranch?: string; // Current branch at checkpoint time + gitDiff?: string; // Uncommitted changes (unified diff) at checkpoint time + metadata?: { + filesModified?: number; + isCurrent?: boolean; + }; +} + // Scheduled task (cron) for recurring/one-shot prompts export interface ScheduledTask { id: string; // 8-char unique ID @@ -217,6 +234,13 @@ export type WSMessageType = | 'cron:fired' | 'cron:ran' | 'cron:updated' + // Checkpoints / Timeline + | 'checkpoint:created' + | 'checkpoint:list' + | 'checkpoint:restored' + | 'checkpoint:deleted' + | 'checkpoint:forked' + | 'checkpoint:error' // Token usage | 'task:tokenUsage' // Server status