Skip to content

Checkpoints System Suggestion #1286

Closed
Closed

Description

@eas4ai

This is from my own project but it works and might be useful. I didn't include my tool stuff or permissions but it should give you some ideas anyway

/**
 * Comprehensive Checkpointing System
 * Creates shadow git repository for safe experimentation and rollback
 * Implements nested git repository handling and worktree configuration
 */

import { execSync } from 'child_process';
import { readFile, writeFile, mkdir, access, rename } from 'fs/promises';
import { resolve, relative, join } from 'path';
import * as crypto from 'crypto';
import { Tool } from './types';
import type { ToolParameter, ToolCategory, ToolContext, ToolResult, ApprovalRequest } from './types';
import { Result, ok, err } from '../utils/result';
import * as path from 'path';
import { globby } from 'globby';
import * as os from 'os';
import { 
  PermissionManager, 
  OperationType, 
  RiskLevel, 
  createCheckpointPermissionRequest 
} from '../utils/permission-manager';

interface CheckpointParams extends Record<string, unknown> {
  action: 'create' | 'list' | 'restore' | 'diff' | 'delete' | 'status' | 'cleanup';
  name?: string;
  message?: string;
  checkpoint_id?: string;
  include_patterns?: string[];
  exclude_patterns?: string[];
  force?: boolean;
}

interface CheckpointRetentionConfig {
  maxAutoCheckpoints: number;      // Keep only last N auto-created checkpoints
  maxAgeDays: number;             // Delete auto-checkpoints older than X days
  maxTotalSizeMB: number;         // Cleanup when exceeding size limit
  preserveNamed: boolean;         // Always keep manually named checkpoints
}

interface Checkpoint {
  id: string;
  name: string;
  message: string;
  timestamp: string;
  commitHash: string;             // Git commit hash for this checkpoint
  changes: {
    additions: number;
    deletions: number;
    modifications: number;
  };
  hash: string;
  isAutoCreated?: boolean;        // Track auto vs manual checkpoints
  sizeMB?: number;               // Track checkpoint size
}

const GIT_DISABLED_SUFFIX = '_disabled';

export class CheckpointTool extends Tool<CheckpointParams, string> {
  readonly name = 'checkpoint';
  readonly description = 'Create, restore, and manage safe code checkpoints for experimentation and rollback';
  readonly category: ToolCategory = 'file';
  readonly parameters: ToolParameter[] = [
    {
      name: 'action',
      type: 'string',
      description: 'Action to perform: create, list, restore, diff, delete, or status',
      required: true,
      enum: ['create', 'list', 'restore', 'diff', 'delete', 'status', 'cleanup']
    },
    {
      name: 'name',
      type: 'string',
      description: 'Name for the checkpoint (required for create action)',
      required: false
    },
    {
      name: 'message',
      type: 'string',
      description: 'Optional message describing the checkpoint',
      required: false
    },
    {
      name: 'checkpoint_id',
      type: 'string',
      description: 'ID or name of checkpoint (required for restore, diff, delete actions)',
      required: false
    },
    {
      name: 'include_patterns',
      type: 'array',
      description: 'Glob patterns for files to include in checkpoint',
      required: false,
      items: { type: 'string' }
    },
    {
      name: 'exclude_patterns',
      type: 'array',
      description: 'Glob patterns for files to exclude from checkpoint',
      required: false,
      items: { type: 'string' }
    },
    {
      name: 'force',
      type: 'boolean',
      description: 'Force action without confirmation prompts',
      required: false,
      default: false
    }
  ];

  private shadowRepoPath: string = '';
  private workspaceRoot: string;
  private workspaceHash: string;
  private permissionManager = PermissionManager.getInstance();
  private retentionConfig: CheckpointRetentionConfig = {
    maxAutoCheckpoints: 10,      // Keep last 10 auto-checkpoints
    maxAgeDays: 7,              // Delete auto-checkpoints older than 7 days
    maxTotalSizeMB: 100,        // Cleanup when exceeding 100MB
    preserveNamed: true         // Always keep manually named checkpoints
  };

  constructor(workspaceRoot?: string) {
    super();
    this.workspaceRoot = path.resolve(workspaceRoot || process.cwd());
    this.workspaceHash = this.hashWorkingDir(this.workspaceRoot);
    this.shadowRepoPath = join(this.workspaceRoot, '.ai', 'checkpoints');
  }

  /**
   * Hash workspace directory to unique identifier
   */
  private hashWorkingDir(workingDir: string): string {
    if (!workingDir) {
      throw new Error('Working directory path cannot be empty');
    }
    let hash = 0;
    for (let i = 0; i < workingDir.length; i++) {
      hash = (hash * 31 + workingDir.charCodeAt(i)) >>> 0;
    }
    const bigHash = BigInt(hash);
    const numericHash = bigHash.toString().slice(0, 13);
    return numericHash;
  }

  /**
   * Validate workspace is not in protected directory
   */
  private async validateNotInProtectedDirectory(): Promise<void> {
    const homedir = os.homedir();
    const desktopPath = join(homedir, 'Desktop');
    const documentsPath = join(homedir, 'Documents');
    const downloadsPath = join(homedir, 'Downloads');
    
    switch (this.workspaceRoot) {
      case homedir:
        throw new Error('Cannot use checkpoints in home directory');
      case desktopPath:
        throw new Error('Cannot use checkpoints in Desktop directory');
      case documentsPath:
        throw new Error('Cannot use checkpoints in Documents directory');
      case downloadsPath:
        throw new Error('Cannot use checkpoints in Downloads directory');
    }

    // Check if directory exists and we have read permissions
    try {
      await access(this.workspaceRoot);
    } catch (error) {
      throw new Error(
        `Cannot access workspace directory. Please ensure you have permission to access your workspace. Error: ${error instanceof Error ? error.message : String(error)}`,
      );
    }
  }

  /**
   * Temporarily disable nested git repositories by renaming .git directories.
   * This prevents git from treating them as submodules during checkpoint operations.
   * This ensures that nested git repositories don't interfere with checkpoint operations
   * 
   * @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
   */
  private async renameNestedGitRepos(disable: boolean): Promise<void> {
    try {
      // Find all .git directories that are not at the root level
      const gitPaths = await globby('**/.git' + (disable ? '' : GIT_DISABLED_SUFFIX), {
        cwd: this.workspaceRoot,
        onlyDirectories: true,
        ignore: ['.git'], // Ignore root level .git
        dot: true,
        markDirectories: false,
        suppressErrors: true,
      });
      
      // For each nested .git directory, rename it based on operation
      for (const gitPath of gitPaths) {
        const fullPath = join(this.workspaceRoot, gitPath);
        let newPath: string;
        if (disable) {
          newPath = fullPath + GIT_DISABLED_SUFFIX;
        } else {
          newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) 
            ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) 
            : fullPath;
        }
        
        try {
          await rename(fullPath, newPath);
          console.log(`Checkpoint ${disable ? 'disabled' : 'enabled'} nested git repo ${gitPath}`);
        } catch (error) {
          console.error(`Failed to ${disable ? 'disable' : 'enable'} nested git repo ${gitPath}:`, error);
        }
      }
    } catch (error) {
      console.warn('Failed to manage nested git repositories:', error);
    }
  }

  /**
   * Add files to shadow git repository
   */
  private async addCheckpointFiles(): Promise<void> {
    try {
      // Add all files using git, ignoring permission errors
      execSync('git add . --ignore-errors', { 
        cwd: this.shadowRepoPath, 
        stdio: 'pipe' 
      });
    } catch (error) {
      console.warn('Some files could not be added to checkpoint:', error);
      // Continue anyway - git will add what it can
    }
  }

  /**
   * Validate parameters before execution
   */
  validate(params: CheckpointParams): Result<CheckpointParams> {
    try {
      // Validate required parameters for specific actions
      if (params.action === 'create' && !params.name) {
        return err(new Error('name parameter is required for create action'));
      }
      
      if (['restore', 'diff', 'delete'].includes(params.action) && !params.checkpoint_id) {
        return err(new Error('checkpoint_id parameter is required for restore, diff, and delete actions'));
      }

      return ok(params);
    } catch (error) {
      return err(error as Error);
    }
  }

  /**
   * Get approval request details
   */
  getApprovalRequest(params: CheckpointParams, _context: ToolContext): ApprovalRequest {
    const risk = params.action === 'restore' ? 'high' : 'medium';
    
    return {
      toolName: this.name,
      description: `Checkpoint ${params.action}${params.name ? ` "${params.name}"` : ''}${params.checkpoint_id ? ` (${params.checkpoint_id})` : ''}`,
      parameters: params,
      risk,
      reasoning: `Checkpoint operations can modify files and create/restore system state. Action: ${params.action}`,
      affectedPaths: params.action === 'restore' ? ['workspace files'] : ['.ai/checkpoints/']
    };
  }

  /**
   * Main execution method for checkpoint operations
   */
  async execute(params: CheckpointParams, _context: ToolContext): Promise<Result<ToolResult<string>>> {
    try {
      // Validate workspace before any operations
      await this.validateNotInProtectedDirectory();
      
      // Initialize shadow repository if needed
      await this.initializeShadowRepo();

      let output: string;

      switch (params.action) {
        case 'create':
          output = await this.createCheckpoint(params);
          break;
        case 'list':
          output = await this.listCheckpoints();
          break;
        case 'restore':
          output = await this.restoreCheckpoint(params);
          break;
        case 'diff':
          output = await this.showCheckpointDiff(params);
          break;
        case 'delete':
          output = await this.deleteCheckpoint(params);
          break;
        case 'status':
          output = await this.showStatus();
          break;
        case 'cleanup':
          output = await this.cleanupOldCheckpoints(params.force);
          break;
        default:
          throw new Error(`Unknown action: ${params.action}`);
      }

      return ok({
        success: true,
        data: output,
        output,
        metadata: {
          duration: 0, // Would need to measure actual execution time
          filesModified: params.action === 'restore' ? ['workspace files'] : undefined
        }
      });
    } catch (error) {
      return err(error as Error);
    }
  }

  /**
   * Initialize shadow repository for checkpoints
   */
  private async initializeShadowRepo(): Promise<void> {
    // Check if shadow repo directory exists
    let dirExists = false;
    try {
      await access(this.shadowRepoPath);
      dirExists = true;
    } catch {
      dirExists = false;
    }

    if (dirExists) {
      // Directory exists - check if it's a valid git repo
      try {
        execSync('git status', { cwd: this.shadowRepoPath, stdio: 'pipe' });
        
        // Verify worktree configuration
        try {
          const worktree = execSync('git config core.worktree', { 
            cwd: this.shadowRepoPath, 
            stdio: 'pipe',
            encoding: 'utf8'
          }).trim();
          
          if (worktree !== this.workspaceRoot) {
            throw new Error(`Checkpoints can only be used in the original workspace: ${worktree}`);
          }
        } catch {
          // If no worktree config, add it
          console.log('Adding worktree configuration to existing checkpoint repository...');
          execSync(`git config --local core.worktree "${this.workspaceRoot}"`, { cwd: this.shadowRepoPath, stdio: 'pipe' });
        }
        
        // Repository is valid, we're done
        return;
      } catch {
        // Directory exists but is not a valid git repo - try to repair
        console.warn('Warning: Shadow repo directory exists but git is corrupted. Attempting to repair...');
        try {
          // Check if it contains checkpoint data before destroying
          const checkpointsFile = join(this.shadowRepoPath, 'checkpoints.json');
          const backupData = await readFile(checkpointsFile, 'utf8');
          
          // Remove .git directory and reinitialize
          execSync('rm -rf .git', { cwd: this.shadowRepoPath, stdio: 'pipe' });
          await this.initializeNewShadowRepo();
          
          // Restore checkpoints.json
          await writeFile(checkpointsFile, backupData);
          
          // Add and commit existing data
          execSync('git add .', { cwd: this.shadowRepoPath, stdio: 'pipe' });
          execSync('git commit -m "Restored checkpoint repository"', { 
            cwd: this.shadowRepoPath, 
            stdio: 'pipe' 
          });
          console.log('Successfully repaired corrupted checkpoint repository');
          return;
        } catch {
          // No checkpoint data exists, safe to reinitialize
          console.log('Shadow repo directory exists but is not a git repo and contains no checkpoints. Initializing...');
        }
      }
    }

    // Directory doesn't exist OR exists but has no checkpoints - safe to create fresh
    await mkdir(this.shadowRepoPath, { recursive: true });
    await this.initializeNewShadowRepo();
  }

  /**
   * Initialize new shadow git repository
   */
  private async initializeNewShadowRepo(): Promise<void> {
    // Initialize git repository
    execSync('git init', { cwd: this.shadowRepoPath, stdio: 'pipe' });
    
    // Configure repo with git settings (using --local for safety)
    execSync(`git config --local core.worktree "${this.workspaceRoot}"`, { cwd: this.shadowRepoPath, stdio: 'pipe' });
    execSync('git config --local commit.gpgSign false', { cwd: this.shadowRepoPath, stdio: 'pipe' });
    execSync('git config --local user.name "AI Checkpoint"', { cwd: this.shadowRepoPath, stdio: 'pipe' });
    execSync('git config --local user.email "[email protected]"', { cwd: this.shadowRepoPath, stdio: 'pipe' });
    
    // Create initial files
    const gitignorePath = join(this.shadowRepoPath, '.gitignore');
    await writeFile(gitignorePath, '#Checkpoints Shadow Repository\n*.tmp\n*.log\n');
    
    const metadataPath = join(this.shadowRepoPath, 'workspace-metadata.json');
    const metadata = {
      workspaceRoot: resolve(this.workspaceRoot),
      workspaceHash: this.workspaceHash,
      createdAt: new Date().toISOString(),
      version: '2.0.0',
      description: 'Checkpoint system architecture'
    };
    await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
    
    // Initialize empty checkpoints file
    const checkpointsFile = join(this.shadowRepoPath, 'checkpoints.json');
    await writeFile(checkpointsFile, JSON.stringify([], null, 2));
    
    // Add files and create initial commit
    await this.addCheckpointFiles();
    execSync('git commit --allow-empty -m "initial commit"', { 
      cwd: this.shadowRepoPath, 
      stdio: 'pipe' 
    });
  }

  /**
   * Create a new checkpoint
   */
  private async createCheckpoint(params: CheckpointParams): Promise<string> {
    if (!params.name) {
      throw new Error('Checkpoint name is required');
    }

    const name = params.name;
    const message = params.message || `Checkpoint created at ${new Date().toISOString()}`;
    const checkpointId = this.generateCheckpointId(name);

    // Check if checkpoint already exists
    const checkpoints = await this.loadCheckpoints();
    if (checkpoints.some(cp => cp.name === name)) {
      if (!params.force) {
        throw new Error(`Checkpoint "${name}" already exists. Use force=true to overwrite.`);
      }
    }

    // Disable nested git repos before creating checkpoint
    await this.renameNestedGitRepos(true);
    
    try {
      // Add all files using git (respecting .gitignore)
      await this.addCheckpointFiles();
      
      // Create commit
      const commitMessage = `checkpoint-${this.workspaceHash}-${checkpointId}: ${name}`;
      const commitResult = execSync(`git commit --allow-empty --no-verify -m "${commitMessage}"`, { 
        cwd: this.shadowRepoPath, 
        stdio: 'pipe',
        encoding: 'utf8'
      });
      
      // Extract commit hash from output
      const hashMatch = commitResult.match(/\[[\w\s]+\s+([a-f0-9]+)\]/);
      const commitHash = hashMatch ? hashMatch[1] : '';
      
      // Get file statistics
      const stats = await this.getChangeStats();
      
      const checkpoint: Checkpoint = {
        id: checkpointId,
        name,
        message,
        timestamp: new Date().toISOString(),
        commitHash,
        changes: stats,
        hash: crypto.createHash('md5').update(`${name}-${Date.now()}`).digest('hex'),
        isAutoCreated: false
      };
      
      // Save checkpoint metadata
      const updatedCheckpoints = checkpoints.filter(cp => cp.name !== name);
      updatedCheckpoints.push(checkpoint);
      await this.saveCheckpoints(updatedCheckpoints);
      
      // Auto-cleanup old checkpoints if needed
      await this.autoCleanupIfNeeded();
      
      return this.formatCheckpointCreated(checkpoint);
    } finally {
      // Re-enable nested git repos
      await this.renameNestedGitRepos(false);
    }
  }

  /**
   * Create an auto-checkpoint (for file save triggers)
   */
  async createAutoCheckpoint(triggerFile?: string): Promise<string> {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const autoName = `auto-${timestamp}`;
    const message = triggerFile 
      ? `Auto-checkpoint after modifying ${triggerFile}`
      : 'Auto-checkpoint on file save';

    // Disable nested git repos before creating checkpoint
    await this.renameNestedGitRepos(true);
    
    try {
      const checkpointId = this.generateCheckpointId(autoName);
      
      // Add all files using git
      await this.addCheckpointFiles();
      
      // Create commit
      const commitMessage = `checkpoint-${this.workspaceHash}-${checkpointId}: ${autoName}`;
      const commitResult = execSync(`git commit --allow-empty --no-verify -m "${commitMessage}"`, { 
        cwd: this.shadowRepoPath, 
        stdio: 'pipe',
        encoding: 'utf8'
      });
      
      // Extract commit hash
      const hashMatch = commitResult.match(/\[[\w\s]+\s+([a-f0-9]+)\]/);
      const commitHash = hashMatch ? hashMatch[1] : '';
      
      const stats = await this.getChangeStats();
      
      const checkpoint: Checkpoint = {
        id: checkpointId,
        name: autoName,
        message,
        timestamp: new Date().toISOString(),
        commitHash,
        changes: stats,
        hash: crypto.createHash('md5').update(`${autoName}-${Date.now()}`).digest('hex'),
        isAutoCreated: true
      };
      
      const checkpoints = await this.loadCheckpoints();
      checkpoints.push(checkpoint);
      await this.saveCheckpoints(checkpoints);
      
      // Auto-cleanup after creating auto-checkpoint
      await this.autoCleanupIfNeeded();
      
      return `Auto-checkpoint created: ${autoName} (${commitHash.substring(0, 8)})`;
    } finally {
      // Re-enable nested git repos
      await this.renameNestedGitRepos(false);
    }
  }

  /**
   * Get change statistics from git diff analysis
   */
  private async getChangeStats(): Promise<{ additions: number; deletions: number; modifications: number }> {
    try {
      // Get detailed diff stats with line counts
      const diffStats = execSync('git diff --cached --numstat', { 
        cwd: this.shadowRepoPath, 
        encoding: 'utf8',
        stdio: 'pipe'
      });
      
      let modifications = 0;
      let additions = 0;

      // Parse numstat output: "additions\tdeletions\tfilename"
      const lines = diffStats.split('\n').filter(line => line.trim());
      
      for (const line of lines) {
        const parts = line.split('\t');
        if (parts.length >= 3) {
          const addedLines = parseInt(parts[0]) || 0;
          const deletedLines = parseInt(parts[1]) || 0;
          
          // Determine if this is a new file, modification, or deletion
          if (deletedLines === 0 && addedLines > 0) {
            // New file
            additions++;
          } else if (addedLines > 0 || deletedLines > 0) {
            // Modified file
            modifications++;
          }
        }
      }

      // Get list of new files (not tracked by git yet)
      try {
        const statusOutput = execSync('git status --porcelain', {
          cwd: this.shadowRepoPath,
          encoding: 'utf8',
          stdio: 'pipe'
        });

        const statusLines = statusOutput.split('\n').filter(line => line.trim());
        for (const line of statusLines) {
          const status = line.substring(0, 2);
          if (status.includes('A') && !status.includes('M')) {
            // File is staged as new (A) but not modified (M)
            additions++;
          }
        }
      } catch {
        // Ignore status errors - we'll use what we have from numstat
      }

      return {
        additions,
        deletions: 0, // We track file-level deletions, not individual files deleted
        modifications
      };
    } catch {
      return { additions: 0, deletions: 0, modifications: 0 };
    }
  }

  /**
   * Restore to a specific checkpoint using git reset
   */
  private async restoreCheckpoint(params: CheckpointParams): Promise<string> {
    if (!params.checkpoint_id) {
      throw new Error('checkpoint_id is required for restore action');
    }

    const checkpointId = params.checkpoint_id;
    const checkpoints = await this.loadCheckpoints();
    const checkpoint = checkpoints.find(cp => cp.id === checkpointId || cp.name === checkpointId);

    if (!checkpoint) {
      throw new Error(`Checkpoint "${checkpointId}" not found. Use action: "list" to see available checkpoints.`);
    }

    if (!checkpoint.commitHash) {
      throw new Error(`Checkpoint "${checkpoint.name}" has no commit hash. It may be from an older version.`);
    }

    // Show preview of changes before restore
    const diffResult = await this.generateRestoreDiff(checkpoint);
    
    if (!params.force) {
      // Request permission for high-risk restore operation
      const permissionRequest = createCheckpointPermissionRequest(
        OperationType.CHECKPOINT_RESTORE,
        checkpoint.name,
        `Restore checkpoint "${checkpoint.name}"`,
        RiskLevel.HIGH,
        `Restore Preview:\n\n${diffResult}\n\nWarning: This will overwrite current files using git reset.`
      );

      const permissionGrant = await this.permissionManager.requestPermission(permissionRequest);
      if (!permissionGrant.granted) {
        return permissionGrant.feedback || 'Checkpoint restore cancelled by user';
      }
    }

    // Disable nested git repos during restore
    await this.renameNestedGitRepos(true);
    
    try {
      // Reset to checkpoint commit (hard reset)
      execSync(`git reset --hard ${checkpoint.commitHash}`, { 
        cwd: this.shadowRepoPath, 
        stdio: 'pipe' 
      });
      
      return this.formatRestoreComplete(checkpoint);
    } finally {
      // Re-enable nested git repos
      await this.renameNestedGitRepos(false);
    }
  }

  /**
   * Generate diff using git diff
   */
  private async generateRestoreDiff(checkpoint: Checkpoint): Promise<string> {
    if (!checkpoint.commitHash) {
      return `Error: Cannot generate diff: checkpoint "${checkpoint.name}" has no commit hash.`;
    }

    // Disable nested git repos for diff operation
    await this.renameNestedGitRepos(true);
    
    try {
      // Stage current changes so they appear in diff
      await this.addCheckpointFiles();
      
      // Get diff between current state and checkpoint
      const diffOutput = execSync(`git diff ${checkpoint.commitHash}`, { 
        cwd: this.shadowRepoPath, 
        encoding: 'utf8',
        stdio: 'pipe'
      });
      
      if (!diffOutput.trim()) {
        return `No differences found between current state and checkpoint "${checkpoint.name}".`;
      }
      
      // Count changed files
      const changedFiles = (diffOutput.match(/^diff --git/gm) || []).length;
      
      return `Differences for checkpoint "${checkpoint.name}" (${changedFiles} file${changedFiles > 1 ? 's' : ''} changed):\n\n${diffOutput}`;
    } catch (error) {
      return `Failed to generate diff: ${error}`;
    } finally {
      // Re-enable nested git repos
      await this.renameNestedGitRepos(false);
    }
  }

  /**
   * List all checkpoints
   */
  private async listCheckpoints(): Promise<string> {
    const checkpoints = await this.loadCheckpoints();
    
    if (checkpoints.length === 0) {
      return `No checkpoints found.\n\nCreate your first checkpoint with:\n   action: "create"\n   name: "my-checkpoint"\n   message: "Description of changes"`;
    }

    const results: string[] = [];
    results.push(`Available Checkpoints (${checkpoints.length}):\n`);
    results.push(`Workspace: ${relative(process.cwd(), this.workspaceRoot)} (${this.workspaceHash})\n`);

    // Sort by timestamp (newest first)
    const sortedCheckpoints = checkpoints.sort((a, b) => 
      new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
    );

    for (const checkpoint of sortedCheckpoints) {
      const timeAgo = this.getTimeAgo(new Date(checkpoint.timestamp));
      const { additions, modifications } = checkpoint.changes;
      const autoMarker = checkpoint.isAutoCreated ? '[AUTO]' : '[MANUAL]';
      
      results.push(`\n${autoMarker} ${checkpoint.name} (${checkpoint.id})`);
      results.push(`   Message: ${checkpoint.message}`);
      results.push(`   Created: ${timeAgo}`);
      results.push(`   Changes: ${additions} new, ${modifications} modified`);
      if (checkpoint.commitHash) {
        results.push(`   Commit: ${checkpoint.commitHash.substring(0, 8)}`);
      }
    }

    results.push(`\nUse action: "restore" with checkpoint_id to restore a checkpoint.`);
    results.push(`Use action: "diff" with checkpoint_id to see changes.`);

    return results.join('\n');
  }

  /**
   * Show diff between current state and checkpoint
   */
  private async showCheckpointDiff(params: CheckpointParams): Promise<string> {
    if (!params.checkpoint_id) {
      throw new Error('checkpoint_id is required for diff action');
    }

    const checkpointId = params.checkpoint_id;
    const checkpoints = await this.loadCheckpoints();
    const checkpoint = checkpoints.find(cp => cp.id === checkpointId || cp.name === checkpointId);

    if (!checkpoint) {
      throw new Error(`Checkpoint "${checkpointId}" not found.`);
    }

    return await this.generateRestoreDiff(checkpoint);
  }

  /**
   * Delete a checkpoint
   */
  private async deleteCheckpoint(params: CheckpointParams): Promise<string> {
    if (!params.checkpoint_id) {
      throw new Error('checkpoint_id is required for delete action');
    }

    const checkpointId = params.checkpoint_id;
    const checkpoints = await this.loadCheckpoints();
    const checkpointIndex = checkpoints.findIndex(cp => cp.id === checkpointId || cp.name === checkpointId);

    if (checkpointIndex === -1) {
      throw new Error(`Checkpoint "${checkpointId}" not found.`);
    }

    const checkpoint = checkpoints[checkpointIndex];
    
    if (!params.force) {
      // Request permission for checkpoint deletion
      const permissionRequest = createCheckpointPermissionRequest(
        OperationType.CHECKPOINT_DELETE,
        checkpoint.name,
        `Delete checkpoint "${checkpoint.name}"`,
        RiskLevel.MEDIUM,
        `Delete checkpoint "${checkpoint.name}"?\n\nCheckpoint Info:\n   Message: ${checkpoint.message}\n   Created: ${this.getTimeAgo(new Date(checkpoint.timestamp))}\n   Commit: ${checkpoint.commitHash?.substring(0, 8) || 'no hash'}\n\nThis action cannot be undone.`
      );

      const permissionGrant = await this.permissionManager.requestPermission(permissionRequest);
      if (!permissionGrant.granted) {
        return permissionGrant.feedback || 'Checkpoint deletion cancelled by user';
      }
    }

    // Remove from metadata (git commits remain in history)
    checkpoints.splice(checkpointIndex, 1);
    await this.saveCheckpoints(checkpoints);

    return `Checkpoint "${checkpoint.name}" deleted successfully.\n\nRemaining checkpoints: ${checkpoints.length}`;
  }

  /**
   * Show status of checkpointing system
   */
  private async showStatus(): Promise<string> {
    const checkpoints = await this.loadCheckpoints();
    const autoCheckpoints = checkpoints.filter(cp => cp.isAutoCreated);
    const namedCheckpoints = checkpoints.filter(cp => !cp.isAutoCreated);
    
    const results: string[] = [];

    results.push('Checkpoint System Status\n');
    results.push('─'.repeat(40));
    
    results.push(`Shadow Repository: ${relative(this.workspaceRoot, this.shadowRepoPath)}`);
    results.push(`Workspace Hash: ${this.workspaceHash}`);
    results.push(`Total Checkpoints: ${checkpoints.length}`);
    results.push(`   Auto-created: ${autoCheckpoints.length}`);
    results.push(`   Named: ${namedCheckpoints.length}`);
    
    if (checkpoints.length > 0) {
      const latest = checkpoints.sort((a, b) => 
        new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
      )[0];
      
      results.push(`Latest: "${latest.name}" (${this.getTimeAgo(new Date(latest.timestamp))})`);
    }

    // Show retention policy
    results.push(`\nRetention Policy:`);
    results.push(`   • Keep last ${this.retentionConfig.maxAutoCheckpoints} auto-checkpoints`);
    results.push(`   • Delete auto-checkpoints older than ${this.retentionConfig.maxAgeDays} days`);
    results.push(`   • Cleanup when total size exceeds ${this.retentionConfig.maxTotalSizeMB}MB`);
    if (this.retentionConfig.preserveNamed) {
      results.push('   • Always preserve manually named checkpoints');
    }

    // Check workspace git status
    try {
      const gitStatus = execSync('git status --porcelain', { 
        cwd: this.workspaceRoot, 
        encoding: 'utf-8',
        stdio: 'pipe'
      });
      
      const hasChanges = gitStatus.trim().length > 0;
      results.push(`\nWorkspace Status: ${hasChanges ? 'Modified (has uncommitted changes)' : 'Clean'}`);
      
      if (hasChanges) {
        results.push('Consider creating a checkpoint before major changes.');
      }
    } catch {
      results.push('\nWorkspace Status: Not a git repository');
    }

    return results.join('\n');
  }

  /**
   * Clean up old checkpoints based on retention policy
   */
  private async cleanupOldCheckpoints(force = false): Promise<string> {
    const checkpoints = await this.loadCheckpoints();
    const toDelete: Checkpoint[] = [];
    const stats = {
      total: checkpoints.length,
      autoCheckpoints: 0,
      namedCheckpoints: 0,
      deletedByAge: 0,
      deletedByCount: 0,
      deletedBySize: 0
    };

    // Calculate current statistics
    for (const checkpoint of checkpoints) {
      if (checkpoint.isAutoCreated) {
        stats.autoCheckpoints++;
      } else {
        stats.namedCheckpoints++;
      }
    }

    // Sort checkpoints by age (newest first)
    const sortedCheckpoints = checkpoints.sort((a, b) => 
      new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
    );

    // 1. Delete auto-checkpoints older than maxAgeDays
    const cutoffDate = new Date();
    cutoffDate.setDate(cutoffDate.getDate() - this.retentionConfig.maxAgeDays);
    
    for (const checkpoint of sortedCheckpoints) {
      if (checkpoint.isAutoCreated && new Date(checkpoint.timestamp) < cutoffDate) {
        toDelete.push(checkpoint);
        stats.deletedByAge++;
      }
    }

    // 2. Keep only maxAutoCheckpoints most recent auto-checkpoints
    const autoCheckpoints = sortedCheckpoints
      .filter(cp => cp.isAutoCreated && !toDelete.includes(cp));
    
    if (autoCheckpoints.length > this.retentionConfig.maxAutoCheckpoints) {
      const excess = autoCheckpoints.slice(this.retentionConfig.maxAutoCheckpoints);
      toDelete.push(...excess);
      stats.deletedByCount += excess.length;
    }

    // 3. Size-based cleanup - enforce maxTotalSizeMB limit
    try {
      const getFolderSize = (await import('get-folder-size')).default;
      let currentSizeBytes = await getFolderSize.loose(this.shadowRepoPath);
      let currentSizeMB = currentSizeBytes / (1024 * 1024);
      
      if (currentSizeMB > this.retentionConfig.maxTotalSizeMB) {
        console.log(`Checkpoint size (${currentSizeMB.toFixed(1)}MB) exceeds limit (${this.retentionConfig.maxTotalSizeMB}MB), performing size-based cleanup`);
        
        // Delete oldest auto-checkpoints until we're under the size limit
        const remainingAutoCheckpoints = sortedCheckpoints
          .filter(cp => cp.isAutoCreated && !toDelete.includes(cp))
          .reverse(); // Oldest first for deletion
        
        for (const checkpoint of remainingAutoCheckpoints) {
          // Calculate individual checkpoint size estimate
          const checkpointSizeEstimate = await this.estimateCheckpointSize(checkpoint);
          
          toDelete.push(checkpoint);
          stats.deletedBySize++;
          
          // Update estimated size after marking for deletion
          currentSizeMB -= checkpointSizeEstimate;
          
          // Stop when we're projected to be under the limit
          if (currentSizeMB <= this.retentionConfig.maxTotalSizeMB) {
            break;
          }
        }
      }
    } catch (error) {
      console.warn('Failed to calculate checkpoint size for cleanup:', error);
    }

    if (toDelete.length === 0) {
      return this.formatCleanupResults(stats, []);
    }

    if (!force) {
      const summary = toDelete.map(cp => 
        `   • ${cp.name} (${this.getTimeAgo(new Date(cp.timestamp))})`
      ).join('\n');

      return `Cleanup Preview - ${toDelete.length} checkpoints to delete:\n\n${summary}\n\nUse force=true to proceed with cleanup.\n\nCurrent: ${stats.total} checkpoints\nAfter cleanup: ${stats.total - toDelete.length} checkpoints`;
    }

    // Perform cleanup by removing from metadata
    // Note: Git commits remain in history
    const remaining = checkpoints.filter(cp => !toDelete.includes(cp));
    await this.saveCheckpoints(remaining);

    const deleted = toDelete.map(cp => cp.name);
    return this.formatCleanupResults(stats, deleted);
  }

  /**
   * Estimate the size of a single checkpoint by analyzing its git objects
   */
  private async estimateCheckpointSize(checkpoint: Checkpoint): Promise<number> {
    try {
      if (!checkpoint.commitHash) {
        // For checkpoints without commit hash, use a conservative estimate
        return 1; // 1MB default estimate
      }

      // Get the size of objects for this specific commit
      const sizeOutput = execSync(
        `git cat-file -s ${checkpoint.commitHash} 2>/dev/null || echo "0"`,
        { 
          cwd: this.shadowRepoPath, 
          encoding: 'utf8',
          stdio: 'pipe'
        }
      ).trim();

      const commitObjectSize = parseInt(sizeOutput) || 0;

      // Get the number of files changed in this commit for better estimation
      const diffStats = execSync(
        `git diff-tree --numstat ${checkpoint.commitHash} 2>/dev/null | wc -l || echo "1"`,
        {
          cwd: this.shadowRepoPath,
          encoding: 'utf8',
          stdio: 'pipe'
        }
      ).trim();

      const filesChanged = parseInt(diffStats) || 1;
      
      // Estimate total checkpoint size:
      // - Base commit object size
      // - Plus estimated file content (files changed * average file size)
      // - Convert to MB
      const estimatedBytes = commitObjectSize + (filesChanged * 5000); // 5KB average per file
      const estimatedMB = estimatedBytes / (1024 * 1024);
      
      // Return at least 0.1MB per checkpoint to avoid zero estimates
      return Math.max(0.1, estimatedMB);
    } catch {
      // Conservative fallback estimate if git operations fail
      return 1; // 1MB
    }
  }

  /**
   * Auto-cleanup after checkpoint creation
   */
  private async autoCleanupIfNeeded(): Promise<void> {
    const checkpoints = await this.loadCheckpoints();
    
    // Quick check: do we have too many auto-checkpoints?
    const autoCheckpoints = checkpoints.filter(cp => cp.isAutoCreated);
    let needsCleanup = autoCheckpoints.length > this.retentionConfig.maxAutoCheckpoints;
    
    // Also check size if not already triggered by count
    if (!needsCleanup) {
      try {
        const getFolderSize = (await import('get-folder-size')).default;
        const currentSizeBytes = await getFolderSize.loose(this.shadowRepoPath);
        const currentSizeMB = currentSizeBytes / (1024 * 1024);
        needsCleanup = currentSizeMB > this.retentionConfig.maxTotalSizeMB;
        
        if (needsCleanup) {
          console.log(`Auto-cleanup triggered: checkpoint size (${currentSizeMB.toFixed(1)}MB) exceeds limit (${this.retentionConfig.maxTotalSizeMB}MB)`);
        }
      } catch (error) {
        console.warn('Failed to check checkpoint size for auto-cleanup:', error);
      }
    }
    
    if (needsCleanup) {
      try {
        await this.cleanupOldCheckpoints(true); // Force cleanup
      } catch (error) {
        console.warn('Auto-cleanup failed:', error);
      }
    }
  }

  /**
   * Format cleanup results
   */
  private formatCleanupResults(stats: any, deleted: string[]): string {
    const results: string[] = [];
    
    if (deleted.length > 0) {
      results.push(`Cleanup completed - ${deleted.length} checkpoints deleted from metadata`);
    } else {
      results.push('No cleanup needed');
    }
    
    results.push('');
    results.push('Cleanup Statistics:');
    results.push(`   Total checkpoints: ${stats.total}`);
    results.push(`   Auto-created: ${stats.autoCheckpoints}`);
    results.push(`   Named: ${stats.namedCheckpoints}`);
    
    if (deleted.length > 0) {
      results.push('');
      results.push('Deleted breakdown:');
      if (stats.deletedByAge > 0) results.push(`   By age (>${this.retentionConfig.maxAgeDays}d): ${stats.deletedByAge}`);
      if (stats.deletedByCount > 0) results.push(`   By count (>${this.retentionConfig.maxAutoCheckpoints}): ${stats.deletedByCount}`);
      if (stats.deletedBySize > 0) results.push(`   By size (>${this.retentionConfig.maxTotalSizeMB}MB): ${stats.deletedBySize}`);
      
      results.push('');
      results.push('Note: Git commits remain in repository history for safety');
    }
    
    return results.join('\n');
  }

  /**
   * Load checkpoints from metadata
   */
  private async loadCheckpoints(): Promise<Checkpoint[]> {
    const metadataPath = join(this.shadowRepoPath, 'checkpoints.json');
    
    try {
      const content = await readFile(metadataPath, 'utf-8');
      return JSON.parse(content);
    } catch {
      return [];
    }
  }

  /**
   * Save all checkpoints
   */
  private async saveCheckpoints(checkpoints: Checkpoint[]): Promise<void> {
    const metadataPath = join(this.shadowRepoPath, 'checkpoints.json');
    await writeFile(metadataPath, JSON.stringify(checkpoints, null, 2));
  }

  /**
   * Generate unique checkpoint ID
   */
  private generateCheckpointId(name: string): string {
    const timestamp = Date.now().toString();
    const hash = crypto.createHash('md5').update(`${name}-${timestamp}`).digest('hex');
    return `${name.replace(/[^a-zA-Z0-9-]/g, '-')}-${hash.substring(0, 8)}`;
  }

  /**
   * Format checkpoint creation success message
   */
  private formatCheckpointCreated(checkpoint: Checkpoint): string {
    const { additions, modifications } = checkpoint.changes;
    
    return [
      `Checkpoint "${checkpoint.name}" created successfully!`,
      '',
      `Checkpoint Details:`,
      `   ID: ${checkpoint.id}`,
      `   Message: ${checkpoint.message}`,
      `   Changes: ${additions} new, ${modifications} modified`,
      `   Created: ${new Date(checkpoint.timestamp).toLocaleString()}`,
      `   Commit: ${checkpoint.commitHash.substring(0, 8)}`,
      '',
      `Use action: "restore" with checkpoint_id: "${checkpoint.name}" to restore this state.`
    ].join('\n');
  }

  /**
   * Format restore completion message
   */
  private formatRestoreComplete(checkpoint: Checkpoint): string {
    return [
      `Restored to checkpoint "${checkpoint.name}"!`,
      '',
      `Restore Summary:`,
      `   Checkpoint from: ${new Date(checkpoint.timestamp).toLocaleString()}`,
      `   Message: ${checkpoint.message}`,
      `   Commit: ${checkpoint.commitHash.substring(0, 8)}`,
      '',
      `Your workspace has been restored using git reset --hard.`,
      `All files now match the checkpoint state exactly.`
    ].join('\n');
  }

  /**
   * Get human-readable time ago
   */
  private getTimeAgo(date: Date): string {
    const now = new Date();
    const diffMs = now.getTime() - date.getTime();
    const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
    const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
    const diffMinutes = Math.floor(diffMs / (1000 * 60));

    if (diffDays > 0) {
      return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
    } else if (diffHours > 0) {
      return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
    } else if (diffMinutes > 0) {
      return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
    } else {
      return 'just now';
    }
  }
}```

result.ts (from imports)
```typescript
/**
 * Result Type Utility
 * Provides type-safe error handling similar to Rust's Result type
 */

export type Result<T, E = Error> =
  | { ok: true; value: T; error?: never }
  | { ok: false; value?: never; error: E };

/**
 * Create a successful result
 */
export function ok<T>(value: T): Result<T> {
  return { ok: true, value };
}

/**
 * Create an error result
 */
export function err<E = Error>(error: E): Result<never, E> {
  return { ok: false, error };
}

// Type aliases for convenience
export type Ok<T> = { ok: true; value: T };
export type Err<E = Error> = { ok: false; error: E };

/**
 * Check if a result is successful
 */
export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
  return result.ok;
}

/**
 * Check if a result is an error
 */
export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
  return !result.ok;
}

/**
 * Transform a successful result value
 */
export function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  if (result.ok) {
    return { ok: true, value: fn(result.value) };
  }
  return { ok: false, error: result.error };
}

/**
 * Transform an error result
 */
export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
  if (!result.ok) {
    return { ok: false, error: fn(result.error) };
  }
  return { ok: true, value: result.value };
}

/**
 * Chain results together
 */
export function andThen<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>,
): Result<U, E> {
  if (result.ok) {
    return fn(result.value);
  }
  return { ok: false, error: result.error } as Result<U, E>;
}

/**
 * Unwrap a result value or throw an error
 */
export function unwrap<T, E>(result: Result<T, E>): T {
  if (result.ok) {
    return result.value;
  }
  throw result.error;
}

/**
 * Unwrap a result value or return a default value
 */
export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
  if (result.ok) {
    return result.value;
  }
  return defaultValue;
}

/**
 * Convert a promise that might throw into a Result
 */
export async function fromPromise<T>(promise: Promise<T>): Promise<Result<T, Error>> {
  try {
    const value = await promise;
    return ok(value);
  } catch (error) {
    return err(error instanceof Error ? error : new Error(String(error)));
  }
}

/**
 * Convert a function that might throw into a Result
 */
export function fromThrowable<T, Args extends any[]>(
  fn: (...args: Args) => T,
): (...args: Args) => Result<T, Error> {
  return (...args: Args) => {
    try {
      const value = fn(...args);
      return ok(value);
    } catch (error) {
      return err(error instanceof Error ? error : new Error(String(error)));
    }
  };
}

/**
 * Combine multiple results into a single result with an array of values
 */
export function combine<T, E>(results: Result<T, E>[]): Result<T[], E> {
  const values: T[] = [];

  for (const result of results) {
    if (!result.ok) {
      return { ok: false, error: result.error };
    }
    values.push(result.value);
  }

  return { ok: true, value: values };
}

/**
 * Custom error types for better error handling
 */
export class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly cause?: unknown
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export class ValidationError extends AppError {
  constructor(message: string, cause?: unknown) {
    super(message, 'VALIDATION_ERROR', cause);
    this.name = 'ValidationError';
  }
}

export class DatabaseError extends AppError {
  constructor(message: string, cause?: unknown) {
    super(message, 'DATABASE_ERROR', cause);
    this.name = 'DatabaseError';
  }
}

export class NetworkError extends AppError {
  constructor(message: string, cause?: unknown) {
    super(message, 'NETWORK_ERROR', cause);
    this.name = 'NetworkError';
  }
}

export class FileSystemError extends AppError {
  constructor(message: string, cause?: unknown) {
    super(message, 'FILESYSTEM_ERROR', cause);
    this.name = 'FileSystemError';
  }
}

export class ConfigurationError extends AppError {
  constructor(message: string, cause?: unknown) {
    super(message, 'CONFIGURATION_ERROR', cause);
    this.name = 'ConfigurationError';
  }
}

export class ToolExecutionError extends AppError {
  constructor(message: string, public readonly toolName: string, cause?: unknown) {
    super(message, 'TOOL_EXECUTION_ERROR', cause);
    this.name = 'ToolExecutionError';
  }
}

/**
 * Convert a standard Error to an AppError
 */
export function toAppError(error: unknown, defaultCode: string = 'UNKNOWN_ERROR'): AppError {
  if (error instanceof AppError) {
    return error;
  }
  
  if (error instanceof Error) {
    return new AppError(error.message, defaultCode, error);
  }
  
  return new AppError(String(error), defaultCode, error);
}

/**
 * Try-catch helper with Result type
 */
export function tryCatch<T>(
  fn: () => T,
  mapError?: (error: unknown) => Error
): Result<T, Error> {
  try {
    return ok(fn());
  } catch (error) {
    if (mapError) {
      return err(mapError(error));
    }
    return err(error instanceof Error ? error : new Error(String(error)));
  }
}

/**
 * Async try-catch helper with Result type
 */
export async function tryCatchAsync<T>(
  fn: () => Promise<T>,
  mapError?: (error: unknown) => Error
): Promise<Result<T, Error>> {
  try {
    const value = await fn();
    return ok(value);
  } catch (error) {
    if (mapError) {
      return err(mapError(error));
    }
    return err(error instanceof Error ? error : new Error(String(error)));
  }
}

Activity

  1. self-assigned this
    on Jul 25, 2025
  2. rekram1-node commented on Dec 28, 2025

    @rekram1-node
    Collaborator

    [automated] Closing due to 90+ days of inactivity. Feel free to reopen if you still need this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions