diff --git a/packages/core/src/checkpoint/commit-message.ts b/packages/core/src/checkpoint/commit-message.ts new file mode 100644 index 0000000..752a59d --- /dev/null +++ b/packages/core/src/checkpoint/commit-message.ts @@ -0,0 +1,275 @@ +/** + * 智能提交消息生成模块 + * 参考 Aider 的 AI 提交消息生成 + */ + +import * as path from 'path'; +import type { CheckpointTrigger, FileChange } from './types.js'; + +/** + * 工具调用信息 + */ +interface ToolCallInfo { + tool: string; + params: Record; +} + +/** + * 提交消息生成器 + * 生成人类可读的检查点提交消息 + */ +export class CommitMessageGenerator { + /** + * 生成提交消息 + */ + generateMessage( + trigger: CheckpointTrigger, + toolCall?: ToolCallInfo, + filesChanged?: FileChange[] + ): string { + const prefix = this.getTriggerPrefix(trigger); + const description = this.getDescription(trigger, toolCall, filesChanged); + const body = this.generateBody(trigger, toolCall, filesChanged); + + if (body) { + return `${prefix}: ${description}\n\n${body}`; + } + + return `${prefix}: ${description}`; + } + + /** + * 获取触发类型的前缀 + */ + private getTriggerPrefix(trigger: CheckpointTrigger): string { + const prefixes: Record = { + auto: 'auto', + manual: 'checkpoint', + 'tool:write_file': 'write', + 'tool:edit_file': 'edit', + 'tool:delete_file': 'delete', + 'tool:move_file': 'move', + 'tool:copy_file': 'copy', + 'tool:bash': 'bash', + task_start: 'session-start', + task_complete: 'session-end', + pre_rollback: 'pre-rollback', + session_start: 'session-start', + session_end: 'session-end', + }; + + return prefixes[trigger] || 'checkpoint'; + } + + /** + * 生成描述 + */ + private getDescription( + trigger: CheckpointTrigger, + toolCall?: ToolCallInfo, + filesChanged?: FileChange[] + ): string { + // 从工具调用参数获取文件路径 + if (toolCall) { + const filePath = + (toolCall.params.file_path as string) || + (toolCall.params.path as string); + + if (filePath) { + return this.formatFilePath(filePath); + } + + // bash 命令 + if (toolCall.tool === 'bash') { + const command = String(toolCall.params.command || ''); + return this.formatCommand(command); + } + + // 移动/复制操作 + if (toolCall.params.source && toolCall.params.destination) { + const source = this.formatFilePath(String(toolCall.params.source)); + const dest = this.formatFilePath(String(toolCall.params.destination)); + return `${source} -> ${dest}`; + } + } + + // 从文件变更列表获取描述 + if (filesChanged && filesChanged.length > 0) { + if (filesChanged.length === 1) { + return this.formatFilePath(filesChanged[0].path); + } + return `${filesChanged.length} files`; + } + + // 根据触发类型生成描述 + switch (trigger) { + case 'task_start': + case 'session_start': + return 'begin session'; + case 'task_complete': + case 'session_end': + return 'end session'; + case 'pre_rollback': + return 'state before rollback'; + case 'manual': + return 'manual checkpoint'; + default: + return 'state snapshot'; + } + } + + /** + * 生成消息正文 + */ + private generateBody( + trigger: CheckpointTrigger, + toolCall?: ToolCallInfo, + filesChanged?: FileChange[] + ): string { + const lines: string[] = []; + + // 时间戳 + lines.push(`Time: ${new Date().toISOString()}`); + + // 触发类型 + lines.push(`Trigger: ${trigger}`); + + // 工具信息 + if (toolCall) { + lines.push(`Tool: ${toolCall.tool}`); + } + + // 文件变更统计 + if (filesChanged && filesChanged.length > 0) { + const stats = this.calculateStats(filesChanged); + lines.push(`Files: ${filesChanged.length}`); + if (stats.insertions > 0 || stats.deletions > 0) { + lines.push(`Changes: +${stats.insertions} -${stats.deletions}`); + } + } + + return lines.join('\n'); + } + + /** + * 格式化文件路径(只保留文件名和父目录) + */ + private formatFilePath(filePath: string): string { + const parts = filePath.split(path.sep); + + if (parts.length <= 2) { + return filePath; + } + + // 返回 父目录/文件名 格式 + return parts.slice(-2).join('/'); + } + + /** + * 格式化命令(截断过长的命令) + */ + private formatCommand(command: string): string { + const maxLength = 50; + const trimmed = command.trim(); + + if (trimmed.length <= maxLength) { + return `"${trimmed}"`; + } + + return `"${trimmed.slice(0, maxLength - 3)}..."`; + } + + /** + * 计算文件变更统计 + */ + private calculateStats(filesChanged: FileChange[]): { + insertions: number; + deletions: number; + added: number; + modified: number; + deleted: number; + renamed: number; + } { + let insertions = 0; + let deletions = 0; + let added = 0; + let modified = 0; + let deleted = 0; + let renamed = 0; + + for (const file of filesChanged) { + insertions += file.insertions || 0; + deletions += file.deletions || 0; + + switch (file.type) { + case 'added': + added++; + break; + case 'modified': + modified++; + break; + case 'deleted': + deleted++; + break; + case 'renamed': + renamed++; + break; + } + } + + return { insertions, deletions, added, modified, deleted, renamed }; + } + + /** + * 生成简短的提交消息(单行) + */ + generateShortMessage( + trigger: CheckpointTrigger, + toolCall?: ToolCallInfo + ): string { + const prefix = this.getTriggerPrefix(trigger); + const description = this.getDescription(trigger, toolCall); + return `${prefix}: ${description}`; + } + + /** + * 从提交消息解析元数据 + */ + parseMessage(message: string): { + prefix: string; + description: string; + metadata: Record; + } { + const lines = message.split('\n'); + const firstLine = lines[0] || ''; + + // 解析第一行: prefix: description + const [prefix, ...descParts] = firstLine.split(':'); + const description = descParts.join(':').trim(); + + // 解析元数据 + const metadata: Record = {}; + for (let i = 2; i < lines.length; i++) { + const line = lines[i]; + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + metadata[key] = value; + } + } + + return { + prefix: prefix.trim(), + description, + metadata, + }; + } +} + +/** + * 创建提交消息生成器实例 + */ +export function createCommitMessageGenerator(): CommitMessageGenerator { + return new CommitMessageGenerator(); +} diff --git a/packages/core/src/checkpoint/index.ts b/packages/core/src/checkpoint/index.ts index a8db08a..6947f7d 100644 --- a/packages/core/src/checkpoint/index.ts +++ b/packages/core/src/checkpoint/index.ts @@ -2,7 +2,16 @@ * 检查点系统模块 * * 提供工作区快照和回滚功能,使用 Shadow Git 架构 - * 参考 Cline 的实现 + * 参考 Cline 的实现,并增强了以下功能: + * - Unrevert 撤销回滚 + * - 7 点安全检查机制 + * - 三种恢复模式 + * - 消息级检查点关联 + * - 会话级跟踪 + * - 并发控制(文件锁) + * - LFS 大文件支持 + * - 工作区路径验证 + * - 智能提交消息生成 */ // 检查点管理器 @@ -16,20 +25,68 @@ export { // Shadow Git export { ShadowGit, createShadowGit, hashWorkingDir } from './shadow-git.js'; -// 类型 -export type { - CheckpointMetadata, - CheckpointConfig, - CheckpointTrigger, - FileChange, - FileChangeType, - DiffInfo, - FileDiff, - RollbackOptions, - RollbackResult, - CheckpointEvent, - CheckpointEventType, - CheckpointEventListener, -} from './types.js'; +// 安全检查器 +export { + CheckpointSafetyChecker, + createSafetyChecker, +} from './safety.js'; -export { DEFAULT_CHECKPOINT_CONFIG } from './types.js'; +// 会话跟踪器 +export { + SessionTracker, + createSessionTracker, +} from './session-tracker.js'; + +// 并发锁 +export { CheckpointLock } from './lock.js'; + +// LFS 支持 +export { + LFSPatternLoader, + createLFSPatternLoader, + isCommonLargeFile, + COMMON_LARGE_FILE_EXTENSIONS, +} from './lfs.js'; + +// 路径验证器 +export { + WorkspacePathValidator, + createPathValidator, +} from './path-validator.js'; + +// 提交消息生成器 +export { + CommitMessageGenerator, + createCommitMessageGenerator, +} from './commit-message.js'; + +// 类型 +export { + // 基础类型 + type CheckpointMetadata, + type CheckpointConfig, + type CheckpointTrigger, + type FileChange, + type FileChangeType, + type DiffInfo, + type FileDiff, + type RollbackOptions, + type RollbackResult, + type CheckpointEvent, + type CheckpointEventType, + type CheckpointEventListener, + // 恢复模式 + RestoreMode, + // Unrevert 相关 + type RollbackRecord, + type UnrevertResult, + // 安全检查 + type SafetyCheckResult, + // 会话跟踪 + type SessionState, + type SessionStats, + // 路径验证 + type PathValidationResult, + // 默认配置 + DEFAULT_CHECKPOINT_CONFIG, +} from './types.js'; diff --git a/packages/core/src/checkpoint/lfs.ts b/packages/core/src/checkpoint/lfs.ts new file mode 100644 index 0000000..f8be203 --- /dev/null +++ b/packages/core/src/checkpoint/lfs.ts @@ -0,0 +1,189 @@ +/** + * Git LFS 大文件支持模块 + * 参考 Cline 的 LFS 模式检测 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { minimatch } from 'minimatch'; + +/** + * LFS 模式加载器 + * 从 .gitattributes 文件中加载 LFS 模式 + */ +export class LFSPatternLoader { + private patterns: string[] = []; + private loaded = false; + + /** + * 从工作目录加载 LFS 模式 + */ + async loadPatterns(workDir: string): Promise { + const gitattributesPath = path.join(workDir, '.gitattributes'); + + try { + const content = await fs.readFile(gitattributesPath, 'utf-8'); + this.patterns = this.parseLfsPatterns(content); + this.loaded = true; + } catch { + // .gitattributes 不存在或无法读取 + this.patterns = []; + this.loaded = true; + } + } + + /** + * 解析 .gitattributes 内容,提取 LFS 模式 + */ + private parseLfsPatterns(content: string): string[] { + const patterns: string[] = []; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + + // 跳过空行和注释 + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + // 匹配 LFS 配置行: pattern filter=lfs diff=lfs merge=lfs -text + // 或简单形式: pattern filter=lfs + if (trimmed.includes('filter=lfs')) { + // 提取模式(第一个空白字符前的部分) + const match = trimmed.match(/^(\S+)/); + if (match) { + patterns.push(match[1]); + } + } + } + + return patterns; + } + + /** + * 检查文件是否为 LFS 管理的文件 + */ + isLfsFile(filePath: string): boolean { + if (!this.loaded) { + return false; + } + + // 规范化路径(使用正斜杠) + const normalizedPath = filePath.replace(/\\/g, '/'); + + return this.patterns.some((pattern) => + minimatch(normalizedPath, pattern, { matchBase: true }) + ); + } + + /** + * 获取所有 LFS 模式 + */ + getPatterns(): string[] { + return [...this.patterns]; + } + + /** + * 获取排除模式(用于 .gitignore) + */ + getExcludePatterns(): string[] { + return this.patterns.map((p) => { + // 确保模式以 / 开头(相对于仓库根目录) + if (!p.startsWith('/') && !p.startsWith('*')) { + return `/${p}`; + } + return p; + }); + } + + /** + * 检查是否已加载 + */ + isLoaded(): boolean { + return this.loaded; + } + + /** + * 重置(清除已加载的模式) + */ + reset(): void { + this.patterns = []; + this.loaded = false; + } + + /** + * 过滤文件列表,排除 LFS 文件 + */ + filterNonLfsFiles(files: string[]): string[] { + return files.filter((file) => !this.isLfsFile(file)); + } + + /** + * 获取 LFS 文件列表 + */ + filterLfsFiles(files: string[]): string[] { + return files.filter((file) => this.isLfsFile(file)); + } +} + +/** + * 常见的大文件扩展名(作为 LFS 候选) + */ +export const COMMON_LARGE_FILE_EXTENSIONS = [ + // 图片 + '.psd', + '.ai', + '.eps', + '.tiff', + '.raw', + '.cr2', + '.nef', + // 视频 + '.mp4', + '.mov', + '.avi', + '.mkv', + '.wmv', + '.flv', + // 音频 + '.mp3', + '.wav', + '.flac', + '.aac', + '.ogg', + // 压缩文件 + '.zip', + '.tar', + '.gz', + '.rar', + '.7z', + // 二进制 + '.exe', + '.dll', + '.so', + '.dylib', + // 数据文件 + '.db', + '.sqlite', + '.mdb', + // 其他 + '.pdf', + '.docx', + '.xlsx', + '.pptx', +]; + +/** + * 检查文件扩展名是否为常见大文件类型 + */ +export function isCommonLargeFile(filePath: string): boolean { + const ext = path.extname(filePath).toLowerCase(); + return COMMON_LARGE_FILE_EXTENSIONS.includes(ext); +} + +/** + * 创建 LFS 模式加载器实例 + */ +export function createLFSPatternLoader(): LFSPatternLoader { + return new LFSPatternLoader(); +} diff --git a/packages/core/src/checkpoint/lock.ts b/packages/core/src/checkpoint/lock.ts new file mode 100644 index 0000000..8768fc1 --- /dev/null +++ b/packages/core/src/checkpoint/lock.ts @@ -0,0 +1,229 @@ +/** + * 检查点并发控制模块 + * 参考 Cline 的 proper-lockfile 机制 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * 锁文件内容 + */ +interface LockInfo { + pid: number; + hostname: string; + timestamp: number; +} + +/** + * 锁配置 + */ +interface LockOptions { + /** 锁过期时间(毫秒) */ + stale?: number; + /** 重试次数 */ + retries?: number; + /** 重试间隔(毫秒) */ + retryDelay?: number; +} + +const DEFAULT_OPTIONS: Required = { + stale: 30000, // 30 秒过期 + retries: 10, + retryDelay: 100, +}; + +/** + * 检查点锁管理器 + * 防止多个实例同时操作同一工作区的检查点 + */ +export class CheckpointLock { + private lockPath: string; + private locked = false; + private options: Required; + + constructor(shadowGitDir: string, options: LockOptions = {}) { + this.lockPath = path.join(shadowGitDir, '.checkpoint.lock'); + this.options = { ...DEFAULT_OPTIONS, ...options }; + } + + /** + * 获取锁 + */ + async acquire(): Promise { + let lastError: Error | null = null; + + for (let i = 0; i < this.options.retries; i++) { + try { + await this.tryAcquire(); + this.locked = true; + return; + } catch (error) { + lastError = error as Error; + + // 检查是否为过期锁 + if (await this.isLockStale()) { + await this.forceRelease(); + continue; + } + + // 等待后重试 + await this.sleep(this.options.retryDelay); + } + } + + throw new Error( + `Failed to acquire checkpoint lock after ${this.options.retries} retries: ${lastError?.message}` + ); + } + + /** + * 尝试获取锁 + */ + private async tryAcquire(): Promise { + const lockInfo: LockInfo = { + pid: process.pid, + hostname: this.getHostname(), + timestamp: Date.now(), + }; + + // 确保目录存在 + const lockDir = path.dirname(this.lockPath); + await fs.mkdir(lockDir, { recursive: true }); + + // 使用 exclusive 标志创建锁文件 + const handle = await fs.open(this.lockPath, 'wx'); + try { + await handle.writeFile(JSON.stringify(lockInfo, null, 2)); + } finally { + await handle.close(); + } + } + + /** + * 释放锁 + */ + async release(): Promise { + if (!this.locked) { + return; + } + + try { + // 验证锁是否属于当前进程 + const lockInfo = await this.readLockInfo(); + if (lockInfo && lockInfo.pid === process.pid) { + await fs.unlink(this.lockPath); + } + } catch { + // 忽略错误 + } finally { + this.locked = false; + } + } + + /** + * 强制释放锁(用于清理过期锁) + */ + private async forceRelease(): Promise { + try { + await fs.unlink(this.lockPath); + } catch { + // 忽略错误 + } + } + + /** + * 检查锁是否过期 + */ + private async isLockStale(): Promise { + try { + const lockInfo = await this.readLockInfo(); + if (!lockInfo) { + return true; + } + + const age = Date.now() - lockInfo.timestamp; + return age > this.options.stale; + } catch { + return true; + } + } + + /** + * 读取锁信息 + */ + private async readLockInfo(): Promise { + try { + const content = await fs.readFile(this.lockPath, 'utf-8'); + return JSON.parse(content) as LockInfo; + } catch { + return null; + } + } + + /** + * 获取主机名 + */ + private getHostname(): string { + try { + return require('os').hostname(); + } catch { + return 'unknown'; + } + } + + /** + * 等待 + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * 带锁执行操作 + */ + async withLock(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + await this.release(); + } + } + + /** + * 检查是否已锁定 + */ + isLocked(): boolean { + return this.locked; + } + + /** + * 检查锁文件是否存在 + */ + async lockExists(): Promise { + try { + await fs.access(this.lockPath); + return true; + } catch { + return false; + } + } + + /** + * 获取锁持有者信息 + */ + async getLockHolder(): Promise { + return this.readLockInfo(); + } +} + +/** + * 创建检查点锁实例 + */ +export function createCheckpointLock( + shadowGitDir: string, + options?: LockOptions +): CheckpointLock { + return new CheckpointLock(shadowGitDir, options); +} diff --git a/packages/core/src/checkpoint/manager.ts b/packages/core/src/checkpoint/manager.ts index 250217a..140c605 100644 --- a/packages/core/src/checkpoint/manager.ts +++ b/packages/core/src/checkpoint/manager.ts @@ -3,22 +3,29 @@ * 管理检查点的创建、回滚、清理等操作 */ -import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { nanoid } from 'nanoid'; import { ShadowGit, createShadowGit } from './shadow-git.js'; -import type { - CheckpointMetadata, - CheckpointConfig, - CheckpointTrigger, - RollbackOptions, - RollbackResult, - DiffInfo, - FileDiff, - CheckpointEvent, - CheckpointEventListener, - DEFAULT_CHECKPOINT_CONFIG, +import { CheckpointLock } from './lock.js'; +import { CheckpointSafetyChecker } from './safety.js'; +import { WorkspacePathValidator } from './path-validator.js'; +import { CommitMessageGenerator } from './commit-message.js'; +import { LFSPatternLoader } from './lfs.js'; +import { + RestoreMode, + type CheckpointMetadata, + type CheckpointConfig, + type CheckpointTrigger, + type RollbackOptions, + type RollbackResult, + type DiffInfo, + type FileDiff, + type CheckpointEvent, + type CheckpointEventListener, + type RollbackRecord, + type UnrevertResult, + type SafetyCheckResult, } from './types.js'; /** @@ -38,6 +45,20 @@ export class CheckpointManager { private lastCheckpointTime = 0; private eventListeners: Set = new Set(); + // 新增:增强功能组件 + private lock: CheckpointLock; + private safetyChecker: CheckpointSafetyChecker; + private pathValidator: WorkspacePathValidator; + private commitMessageGenerator: CommitMessageGenerator; + private lfsLoader: LFSPatternLoader; + + // 新增:Unrevert 支持 + private lastRollback: RollbackRecord | null = null; + + // 新增:会话跟踪 + private currentSessionId: string | null = null; + private sessionCheckpoints: Map = new Map(); + // 防止重复创建检查点的最小间隔 (毫秒) private static readonly MIN_CHECKPOINT_INTERVAL = 1000; @@ -59,6 +80,13 @@ export class CheckpointManager { }; this.shadowGit = createShadowGit(this.workDir, this.config.storageDir); + + // 初始化增强组件 + this.lock = new CheckpointLock(this.shadowGit.getShadowGitDir()); + this.safetyChecker = new CheckpointSafetyChecker(this.workDir); + this.pathValidator = new WorkspacePathValidator(); + this.commitMessageGenerator = new CommitMessageGenerator(); + this.lfsLoader = new LFSPatternLoader(); } /** @@ -72,6 +100,15 @@ export class CheckpointManager { return; } + // 验证工作目录路径 + const pathValidation = this.pathValidator.validate(this.workDir); + if (!pathValidation.valid) { + throw new Error(`Invalid workspace path: ${pathValidation.reason}`); + } + + // 加载 LFS 模式 + await this.lfsLoader.loadPatterns(this.workDir); + // 初始化 Shadow Git await this.shadowGit.initialize(); @@ -195,6 +232,9 @@ export class CheckpointManager { description?: string; trigger?: CheckpointTrigger; toolCall?: { tool: string; params: Record }; + messageId?: string; + sessionId?: string; + turnIndex?: number; }): Promise { await this.initialize(); @@ -202,48 +242,75 @@ export class CheckpointManager { throw new Error('Checkpoint system is disabled'); } - const id = nanoid(10); - const timestamp = Date.now(); + // 使用锁保护检查点创建 + return this.lock.withLock(async () => { + const id = nanoid(10); + const timestamp = Date.now(); + const trigger = options.trigger || 'manual'; - // 创建元数据 - const metadata: CheckpointMetadata = { - id, - name: options.name, - description: options.description, - timestamp, - trigger: options.trigger || 'manual', - toolCall: options.toolCall, - commitHash: '', // 待填充 - filesChanged: 0, // 待填充 - }; + // 创建元数据 + const metadata: CheckpointMetadata = { + id, + name: options.name, + description: options.description, + timestamp, + trigger, + toolCall: options.toolCall, + commitHash: '', // 待填充 + filesChanged: 0, // 待填充 + // 新增:消息和会话关联 + messageId: options.messageId, + sessionId: options.sessionId || this.currentSessionId || undefined, + turnIndex: options.turnIndex, + }; - // 获取变更文件数 - try { - const diff = await this.shadowGit.getWorkingDirDiff(); - metadata.filesChanged = diff.files.length; - } catch { - // 忽略 - } + // 获取变更文件数 + let filesChanged: Array<{ path: string; type: string }> = []; + try { + const diff = await this.shadowGit.getWorkingDirDiff(); + metadata.filesChanged = diff.files.length; + filesChanged = diff.files; + } catch { + // 忽略 + } - // 创建 commit - const commitMessage = CHECKPOINT_PREFIX + JSON.stringify(metadata); - const commitHash = await this.shadowGit.createCommit(commitMessage); - metadata.commitHash = commitHash; + // 生成智能提交消息 + const humanReadableMessage = this.commitMessageGenerator.generateMessage( + trigger, + options.toolCall, + filesChanged as any + ); - // 更新索引 - this.checkpointsIndex.set(id, metadata); + // 创建 commit(使用 JSON 元数据作为 commit message,但包含可读描述) + const commitMessage = CHECKPOINT_PREFIX + JSON.stringify({ + ...metadata, + _readableMessage: humanReadableMessage, + }); + const commitHash = await this.shadowGit.createCommit(commitMessage); + metadata.commitHash = commitHash; - // 触发事件 - this.emitEvent({ - type: 'created', - checkpoint: metadata, - timestamp, + // 更新索引 + this.checkpointsIndex.set(id, metadata); + + // 记录到当前会话 + if (this.currentSessionId) { + const sessionCps = this.sessionCheckpoints.get(this.currentSessionId) || []; + sessionCps.push(id); + this.sessionCheckpoints.set(this.currentSessionId, sessionCps); + } + + // 触发事件 + this.emitEvent({ + type: 'created', + checkpoint: metadata, + timestamp, + }); + + // 异步清理 + this.cleanupAsync(); + + return metadata; }); - - // 异步清理 - this.cleanupAsync(); - - return metadata; } /** @@ -359,63 +426,253 @@ export class CheckpointManager { throw new Error(`Checkpoint not found: ${options.target}`); } - // 获取当前 HEAD 用于可能的撤销 - const previousCommit = await this.shadowGit.getHead(); + // 安全检查(除非明确跳过) + if (!options.skipSafetyCheck) { + const safetyResult = await this.safetyChecker.checkBeforeRollback(checkpoint, this); + if (!safetyResult.safe) { + const errorMsg = safetyResult.errors.join('; '); + throw new Error(`Safety check failed: ${errorMsg}`); + } + // 警告仍然记录,但不阻止操作 + if (safetyResult.warnings.length > 0) { + console.warn('Rollback warnings:', safetyResult.warnings.join('; ')); + } + } - // 预览模式 - if (options.dryRun) { - const diff = await this.getDiff(checkpoint.id); - return { + // 使用锁保护回滚操作 + return this.lock.withLock(async () => { + // 获取当前 HEAD 用于可能的撤销 + const previousCommit = await this.shadowGit.getHead(); + + // 预览模式 + if (options.dryRun) { + const diff = await this.getDiff(checkpoint.id); + return { + success: true, + restoredFiles: diff.files.map((f) => f.path), + errors: [], + previousCommit, + }; + } + + // 创建回滚前检查点(用于 unrevert) + let preRollbackCheckpoint: CheckpointMetadata | null = null; + try { + preRollbackCheckpoint = await this.createCheckpointInternal({ + trigger: 'pre_rollback', + description: `Before rollback to ${options.target}`, + }); + } catch { + // 忽略创建失败 + } + + const result: RollbackResult = { success: true, - restoredFiles: diff.files.map((f) => f.path), + restoredFiles: [], errors: [], previousCommit, }; - } - const result: RollbackResult = { - success: true, - restoredFiles: [], - errors: [], - previousCommit, - }; + try { + const mode = options.mode || RestoreMode.FULL; - try { - if (options.files && options.files.length > 0) { - // 选择性回滚 - await this.shadowGit.checkoutFiles(checkpoint.commitHash, options.files); - result.restoredFiles = options.files; - } else { - // 完整回滚 - await this.shadowGit.resetHard(checkpoint.commitHash); + if (options.files && options.files.length > 0) { + // 选择性回滚(指定文件) + await this.shadowGit.checkoutFiles(checkpoint.commitHash, options.files); + result.restoredFiles = options.files; + } else if (mode === RestoreMode.AI_CHANGES_ONLY) { + // 仅恢复 AI 修改的文件 + const aiFiles = await this.getAiModifiedFiles(checkpoint); + if (aiFiles.length > 0) { + await this.shadowGit.checkoutFiles(checkpoint.commitHash, aiFiles); + result.restoredFiles = aiFiles; + } + } else if (mode === RestoreMode.WORKSPACE_ONLY) { + // 仅恢复工作区变更(不包括 AI 修改) + const workspaceFiles = await this.getWorkspaceOnlyFiles(checkpoint); + if (workspaceFiles.length > 0) { + await this.shadowGit.checkoutFiles(checkpoint.commitHash, workspaceFiles); + result.restoredFiles = workspaceFiles; + } + } else { + // 完整回滚 + await this.shadowGit.resetHard(checkpoint.commitHash); - // 获取恢复的文件列表 - const diff = await this.shadowGit.getDiffSummary( - previousCommit, - checkpoint.commitHash - ); - result.restoredFiles = diff.files.map((f) => f.path); + // 获取恢复的文件列表 + const diff = await this.shadowGit.getDiffSummary( + previousCommit, + checkpoint.commitHash + ); + result.restoredFiles = diff.files.map((f) => f.path); + } + + // 记录回滚信息(用于 unrevert) + this.lastRollback = { + id: nanoid(10), + timestamp: Date.now(), + targetCheckpoint: checkpoint.id, + previousCommit: preRollbackCheckpoint?.commitHash || previousCommit, + restoredFiles: result.restoredFiles, + canUnrevert: true, + }; + + // 触发事件 + this.emitEvent({ + type: 'restored', + checkpoint, + timestamp: Date.now(), + details: { + files: result.restoredFiles, + previousCommit, + mode, + }, + }); + } catch (error) { + result.success = false; + result.errors.push({ + file: '*', + error: error instanceof Error ? error.message : String(error), + }); } - // 触发事件 - this.emitEvent({ - type: 'restored', - checkpoint, - timestamp: Date.now(), - details: { - files: result.restoredFiles, - previousCommit, - }, - }); - } catch (error) { - result.success = false; - result.errors.push({ - file: '*', - error: error instanceof Error ? error.message : String(error), - }); + return result; + }); + } + + /** + * 撤销最近一次回滚(Unrevert) + */ + async unrevert(): Promise { + await this.initialize(); + + if (!this.lastRollback || !this.lastRollback.canUnrevert) { + return { + success: false, + restoredCommit: '', + filesRestored: 0, + error: 'No rollback to unrevert', + }; } - return result; + return this.lock.withLock(async () => { + try { + // 恢复到回滚前的状态 + await this.shadowGit.resetHard(this.lastRollback!.previousCommit); + + const result: UnrevertResult = { + success: true, + restoredCommit: this.lastRollback!.previousCommit, + filesRestored: this.lastRollback!.restoredFiles.length, + }; + + // 清除 unrevert 记录 + this.lastRollback = null; + + return result; + } catch (error) { + return { + success: false, + restoredCommit: '', + filesRestored: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + } + + /** + * 检查是否可以执行 unrevert + */ + canUnrevert(): boolean { + return this.lastRollback !== null && this.lastRollback.canUnrevert; + } + + /** + * 获取最后一次回滚记录 + */ + getLastRollback(): RollbackRecord | null { + return this.lastRollback; + } + + /** + * 执行安全检查 + */ + async checkSafety(checkpointId: string): Promise { + await this.initialize(); + + const checkpoint = await this.getCheckpoint(checkpointId); + if (!checkpoint) { + return { + safe: false, + warnings: [], + errors: ['Checkpoint not found'], + }; + } + + return this.safetyChecker.checkBeforeRollback(checkpoint, this); + } + + /** + * 内部创建检查点(不使用锁,供 rollback 内部调用) + */ + private async createCheckpointInternal(options: { + trigger: CheckpointTrigger; + description?: string; + }): Promise { + const id = nanoid(10); + const timestamp = Date.now(); + + const metadata: CheckpointMetadata = { + id, + description: options.description, + timestamp, + trigger: options.trigger, + commitHash: '', + filesChanged: 0, + }; + + const commitMessage = CHECKPOINT_PREFIX + JSON.stringify(metadata); + const commitHash = await this.shadowGit.createCommit(commitMessage); + metadata.commitHash = commitHash; + + this.checkpointsIndex.set(id, metadata); + + return metadata; + } + + /** + * 获取 AI 修改的文件列表 + */ + private async getAiModifiedFiles(checkpoint: CheckpointMetadata): Promise { + const files: string[] = []; + const checkpoints = await this.listCheckpoints(); + + // 找到该检查点之后的所有检查点 + for (const cp of checkpoints) { + if (cp.timestamp > checkpoint.timestamp && cp.toolCall) { + const filePath = + (cp.toolCall.params.file_path as string) || + (cp.toolCall.params.path as string); + if (filePath && !files.includes(filePath)) { + files.push(filePath); + } + } + } + + return files; + } + + /** + * 获取仅工作区变更的文件(不包括 AI 修改) + */ + private async getWorkspaceOnlyFiles(checkpoint: CheckpointMetadata): Promise { + const diff = await this.getDiff(checkpoint.id); + const aiFiles = await this.getAiModifiedFiles(checkpoint); + + // 返回不在 AI 修改列表中的文件 + return diff.files + .map((f) => f.path) + .filter((path) => !aiFiles.includes(path)); } /** @@ -578,6 +835,151 @@ export class CheckpointManager { getConfig(): CheckpointConfig { return { ...this.config }; } + + // ==================== 会话管理方法 ==================== + + /** + * 开始新会话 + */ + async startSession(sessionId?: string): Promise { + await this.initialize(); + + const id = sessionId || nanoid(10); + this.currentSessionId = id; + this.sessionCheckpoints.set(id, []); + + // 创建会话开始检查点 + try { + await this.createCheckpoint({ + trigger: 'session_start', + description: `Session started: ${id}`, + sessionId: id, + }); + } catch { + // 忽略创建失败 + } + + return id; + } + + /** + * 结束当前会话 + */ + async endSession(): Promise { + if (!this.currentSessionId) return; + + // 创建会话结束检查点 + try { + await this.createCheckpoint({ + trigger: 'session_end', + description: `Session ended: ${this.currentSessionId}`, + sessionId: this.currentSessionId, + }); + } catch { + // 忽略创建失败 + } + + this.currentSessionId = null; + } + + /** + * 获取当前会话 ID + */ + getCurrentSessionId(): string | null { + return this.currentSessionId; + } + + /** + * 获取会话的所有检查点 + */ + async getSessionCheckpoints(sessionId: string): Promise { + await this.initialize(); + + const checkpointIds = this.sessionCheckpoints.get(sessionId); + if (!checkpointIds) return []; + + const checkpoints: CheckpointMetadata[] = []; + for (const id of checkpointIds) { + const checkpoint = this.checkpointsIndex.get(id); + if (checkpoint) { + checkpoints.push(checkpoint); + } + } + + return checkpoints.sort((a, b) => a.timestamp - b.timestamp); + } + + /** + * 创建与消息关联的检查点 + */ + async createMessageCheckpoint( + messageId: string, + turnIndex?: number, + options?: { + trigger?: CheckpointTrigger; + description?: string; + } + ): Promise { + return this.createCheckpoint({ + trigger: options?.trigger || 'auto', + description: options?.description || `Message checkpoint: ${messageId}`, + messageId, + sessionId: this.currentSessionId || undefined, + turnIndex, + }); + } + + /** + * 获取与消息关联的检查点 + */ + async getMessageCheckpoints(messageId: string): Promise { + await this.initialize(); + + const checkpoints: CheckpointMetadata[] = []; + for (const checkpoint of this.checkpointsIndex.values()) { + if (checkpoint.messageId === messageId) { + checkpoints.push(checkpoint); + } + } + + return checkpoints.sort((a, b) => a.timestamp - b.timestamp); + } + + /** + * 撤销整个会话的修改 + */ + async undoSession(sessionId: string): Promise { + const sessionCheckpoints = await this.getSessionCheckpoints(sessionId); + if (sessionCheckpoints.length === 0) { + throw new Error(`No checkpoints found for session: ${sessionId}`); + } + + // 找到会话开始的检查点 + const startCheckpoint = sessionCheckpoints.find( + (cp) => cp.trigger === 'session_start' + ); + + if (!startCheckpoint) { + // 如果没有明确的开始检查点,使用第一个检查点 + return this.rollback({ target: sessionCheckpoints[0].id }); + } + + return this.rollback({ target: startCheckpoint.id }); + } + + /** + * 获取 LFS 模式加载器 + */ + getLfsLoader(): LFSPatternLoader { + return this.lfsLoader; + } + + /** + * 检查文件是否由 LFS 管理 + */ + isLfsFile(filePath: string): boolean { + return this.lfsLoader.isLfsFile(filePath); + } } // 全局检查点管理器实例 diff --git a/packages/core/src/checkpoint/path-validator.ts b/packages/core/src/checkpoint/path-validator.ts new file mode 100644 index 0000000..c5ca443 --- /dev/null +++ b/packages/core/src/checkpoint/path-validator.ts @@ -0,0 +1,254 @@ +/** + * 工作区路径验证模块 + * 参考 Cline 的路径安全检查机制 + */ + +import * as os from 'os'; +import * as path from 'path'; +import type { PathValidationResult } from './types.js'; + +/** + * 工作区路径验证器 + * 阻止在敏感目录(home、desktop、documents 等)创建检查点 + */ +export class WorkspacePathValidator { + private blockedPaths: string[]; + private blockedPathsLower: string[]; + + constructor() { + const home = os.homedir(); + + this.blockedPaths = [ + // Home 目录 + home, + // 常见敏感目录 + path.join(home, 'Desktop'), + path.join(home, 'Documents'), + path.join(home, 'Downloads'), + path.join(home, 'Pictures'), + path.join(home, 'Music'), + path.join(home, 'Videos'), + // 系统根目录 + '/', + '/tmp', + '/var', + '/etc', + '/usr', + '/bin', + '/sbin', + '/opt', + ]; + + // Windows 特殊路径 + if (process.platform === 'win32') { + this.blockedPaths.push( + 'C:\\', + 'C:\\Windows', + 'C:\\Windows\\System32', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + 'C:\\Users', + 'C:\\ProgramData' + ); + } + + // macOS 特殊路径 + if (process.platform === 'darwin') { + this.blockedPaths.push( + '/Applications', + '/System', + '/Library', + path.join(home, 'Library') + ); + } + + // 创建小写版本用于不区分大小写的比较 + this.blockedPathsLower = this.blockedPaths.map((p) => p.toLowerCase()); + } + + /** + * 验证工作目录是否安全 + */ + validate(workDir: string): PathValidationResult { + const normalizedPath = path.resolve(workDir); + const normalizedLower = normalizedPath.toLowerCase(); + + // 1. 检查是否为阻止的路径 + for (let i = 0; i < this.blockedPaths.length; i++) { + const blocked = this.blockedPaths[i]; + const blockedLower = this.blockedPathsLower[i]; + + // 精确匹配 + if ( + normalizedPath === blocked || + normalizedLower === blockedLower + ) { + return { + valid: false, + reason: `Cannot create checkpoints in "${blocked}" - this is a protected directory`, + }; + } + } + + // 2. 检查是否为系统目录的直接子目录 + if (this.isDirectChildOfBlocked(normalizedPath)) { + return { + valid: false, + reason: `Cannot create checkpoints directly under system directories`, + }; + } + + // 3. 检查路径是否过短(可能是根目录或系统目录) + const pathDepth = this.getPathDepth(normalizedPath); + if (pathDepth < 2) { + return { + valid: false, + reason: 'Path is too shallow - please use a project subdirectory', + }; + } + + // 4. 检查是否包含危险字符 + if (this.containsDangerousChars(normalizedPath)) { + return { + valid: false, + reason: 'Path contains invalid characters', + }; + } + + return { valid: true }; + } + + /** + * 检查是否为阻止路径的直接子目录 + */ + private isDirectChildOfBlocked(targetPath: string): boolean { + const parent = path.dirname(targetPath); + const parentLower = parent.toLowerCase(); + + for (let i = 0; i < this.blockedPaths.length; i++) { + if ( + parent === this.blockedPaths[i] || + parentLower === this.blockedPathsLower[i] + ) { + // 特殊情况:允许 home 目录下的项目目录 + const home = os.homedir(); + if (parent === home || parentLower === home.toLowerCase()) { + // 但不允许一些特定的目录名 + const dirName = path.basename(targetPath).toLowerCase(); + const protectedNames = [ + 'desktop', + 'documents', + 'downloads', + 'pictures', + 'music', + 'videos', + 'library', + '.trash', + '.cache', + ]; + if (!protectedNames.includes(dirName)) { + return false; // 允许 home 下的其他目录 + } + } + return true; + } + } + + return false; + } + + /** + * 获取路径深度 + */ + private getPathDepth(targetPath: string): number { + const normalized = path.normalize(targetPath); + const parts = normalized.split(path.sep).filter((p) => p.length > 0); + + // Windows 盘符算一层 + if (process.platform === 'win32' && /^[A-Za-z]:$/.test(parts[0] || '')) { + return parts.length - 1; + } + + return parts.length; + } + + /** + * 检查是否包含危险字符 + */ + private containsDangerousChars(targetPath: string): boolean { + // 检查空字节和其他控制字符 + if (/[\x00-\x1f]/.test(targetPath)) { + return true; + } + + // 检查 .. 路径遍历 + if (targetPath.includes('..')) { + const resolved = path.resolve(targetPath); + if (resolved !== targetPath) { + return true; + } + } + + return false; + } + + /** + * 获取阻止的路径列表 + */ + getBlockedPaths(): string[] { + return [...this.blockedPaths]; + } + + /** + * 添加自定义阻止路径 + */ + addBlockedPath(blockedPath: string): void { + const normalized = path.resolve(blockedPath); + if (!this.blockedPaths.includes(normalized)) { + this.blockedPaths.push(normalized); + this.blockedPathsLower.push(normalized.toLowerCase()); + } + } + + /** + * 检查路径是否为有效的项目目录 + * 额外检查是否包含常见的项目标识文件 + */ + async isProjectDirectory(workDir: string): Promise { + const fs = await import('fs/promises'); + + const projectIndicators = [ + 'package.json', + 'Cargo.toml', + 'go.mod', + 'pom.xml', + 'build.gradle', + 'requirements.txt', + 'setup.py', + 'pyproject.toml', + 'Gemfile', + 'composer.json', + '.git', + 'Makefile', + 'CMakeLists.txt', + ]; + + for (const indicator of projectIndicators) { + try { + await fs.access(path.join(workDir, indicator)); + return true; + } catch { + // 继续检查下一个 + } + } + + return false; + } +} + +/** + * 创建路径验证器实例 + */ +export function createPathValidator(): WorkspacePathValidator { + return new WorkspacePathValidator(); +} diff --git a/packages/core/src/checkpoint/safety.ts b/packages/core/src/checkpoint/safety.ts new file mode 100644 index 0000000..e443d9d --- /dev/null +++ b/packages/core/src/checkpoint/safety.ts @@ -0,0 +1,214 @@ +/** + * 检查点安全检查模块 + * 参考 Aider 的 7 点安全检查机制 + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import type { CheckpointManager } from './manager.js'; +import type { SafetyCheckResult, CheckpointMetadata } from './types.js'; + +const execAsync = promisify(exec); + +/** + * 检查点安全检查器 + */ +export class CheckpointSafetyChecker { + private workDir: string; + + constructor(workDir: string) { + this.workDir = workDir; + } + + /** + * 在回滚前执行安全检查 + */ + async checkBeforeRollback( + checkpoint: CheckpointMetadata, + manager: CheckpointManager + ): Promise { + const result: SafetyCheckResult = { + safe: true, + warnings: [], + errors: [], + }; + + // 1. 检查检查点是否存在 + if (!checkpoint) { + result.safe = false; + result.errors.push('Checkpoint not found'); + return result; + } + + // 2. 检查工作区是否有未保存的变更 + const hasUnsaved = await this.hasUnsavedChanges(manager); + if (hasUnsaved) { + result.warnings.push('Working directory has unsaved changes that will be lost'); + } + + // 3. 检查主仓库状态 + const mainRepoStatus = await this.checkMainRepoStatus(); + if (mainRepoStatus.hasChanges) { + result.warnings.push('Main repository has uncommitted changes'); + } + + // 4. 检查是否会影响已推送的文件 + const pushedCheck = await this.checkPushedFiles(checkpoint); + if (pushedCheck.hasPushed) { + result.warnings.push( + `Some changes may have been pushed to remote. Files: ${pushedCheck.files.join(', ')}` + ); + } + + // 5. 检查时间跨度 + const age = Date.now() - checkpoint.timestamp; + const oneDay = 24 * 60 * 60 * 1000; + if (age > oneDay) { + const days = Math.floor(age / oneDay); + result.warnings.push(`Checkpoint is ${days} day(s) old`); + } + + // 6. 检查是否为 AI 创建的检查点 + const isAiCreated = this.isAiCreatedCheckpoint(checkpoint); + if (!isAiCreated) { + result.warnings.push('This checkpoint was not created by AI tools'); + } + + // 7. 检查影响的文件数量 + if (checkpoint.filesChanged > 50) { + result.warnings.push( + `This rollback will affect ${checkpoint.filesChanged} files` + ); + } + + return result; + } + + /** + * 检查工作区是否有未保存的变更 + */ + private async hasUnsavedChanges(manager: CheckpointManager): Promise { + try { + // 通过 manager 检查 + const checkpoints = await manager.listCheckpoints(); + if (checkpoints.length === 0) return false; + + const latest = checkpoints[0]; + const diff = await manager.getDiff(latest.id); + return diff.files.length > 0; + } catch { + return false; + } + } + + /** + * 检查主仓库状态 + */ + private async checkMainRepoStatus(): Promise<{ hasChanges: boolean; isGitRepo: boolean }> { + try { + const { stdout } = await execAsync('git status --porcelain', { + cwd: this.workDir, + }); + return { + hasChanges: stdout.trim().length > 0, + isGitRepo: true, + }; + } catch { + return { + hasChanges: false, + isGitRepo: false, + }; + } + } + + /** + * 检查是否有文件已被推送到远程 + */ + private async checkPushedFiles( + checkpoint: CheckpointMetadata + ): Promise<{ hasPushed: boolean; files: string[] }> { + try { + // 检查主仓库中自检查点时间后是否有推送 + const since = new Date(checkpoint.timestamp).toISOString(); + const { stdout } = await execAsync( + `git log --oneline --since="${since}" --name-only origin/HEAD..HEAD 2>/dev/null || true`, + { cwd: this.workDir } + ); + + if (!stdout.trim()) { + return { hasPushed: false, files: [] }; + } + + // 解析文件列表 + const lines = stdout.trim().split('\n'); + const files: string[] = []; + for (const line of lines) { + // 跳过 commit 行(包含空格) + if (!line.includes(' ') && line.length > 0) { + files.push(line); + } + } + + return { + hasPushed: files.length > 0, + files: [...new Set(files)], + }; + } catch { + return { hasPushed: false, files: [] }; + } + } + + /** + * 检查是否为 AI 创建的检查点 + */ + private isAiCreatedCheckpoint(checkpoint: CheckpointMetadata): boolean { + // AI 创建的检查点通常有 toolCall 或特定的 trigger + const aiTriggers = [ + 'tool:write_file', + 'tool:edit_file', + 'tool:delete_file', + 'tool:move_file', + 'tool:copy_file', + 'tool:bash', + 'task_start', + 'task_complete', + 'auto', + ]; + + return aiTriggers.includes(checkpoint.trigger) || !!checkpoint.toolCall; + } + + /** + * 格式化安全检查结果为用户友好的消息 + */ + formatResult(result: SafetyCheckResult): string { + const lines: string[] = []; + + if (result.errors.length > 0) { + lines.push('❌ Errors:'); + for (const error of result.errors) { + lines.push(` - ${error}`); + } + } + + if (result.warnings.length > 0) { + lines.push('⚠️ Warnings:'); + for (const warning of result.warnings) { + lines.push(` - ${warning}`); + } + } + + if (result.safe && result.warnings.length === 0) { + lines.push('✅ All safety checks passed'); + } + + return lines.join('\n'); + } +} + +/** + * 创建安全检查器实例 + */ +export function createSafetyChecker(workDir: string): CheckpointSafetyChecker { + return new CheckpointSafetyChecker(workDir); +} diff --git a/packages/core/src/checkpoint/session-tracker.ts b/packages/core/src/checkpoint/session-tracker.ts new file mode 100644 index 0000000..8a8eefe --- /dev/null +++ b/packages/core/src/checkpoint/session-tracker.ts @@ -0,0 +1,222 @@ +/** + * 会话级检查点跟踪 + * 参考 Aider 的 aider_commit_hashes 和 commit_before_message 机制 + */ + +import { nanoid } from 'nanoid'; +import type { CheckpointManager } from './manager.js'; +import type { + SessionState, + SessionStats, + CheckpointMetadata, + RollbackResult, +} from './types.js'; + +/** + * 会话跟踪器 + * 跟踪当前会话的所有检查点和文件修改 + */ +export class SessionTracker { + private currentSession: SessionState | null = null; + private checkpointManager: CheckpointManager; + + constructor(checkpointManager: CheckpointManager) { + this.checkpointManager = checkpointManager; + } + + /** + * 开始新会话 + */ + async startSession(): Promise { + // 创建会话开始检查点 + const startCp = await this.checkpointManager.createCheckpoint({ + trigger: 'session_start', + description: 'Session start', + }); + + const sessionId = nanoid(10); + + this.currentSession = { + id: sessionId, + startTime: Date.now(), + startCheckpoint: startCp.id, + checkpoints: [startCp.id], + modifiedFiles: [], + }; + + return sessionId; + } + + /** + * 结束当前会话 + */ + async endSession(): Promise { + if (!this.currentSession) return; + + // 创建会话结束检查点 + await this.checkpointManager.createCheckpoint({ + trigger: 'session_end', + description: 'Session end', + }); + + this.currentSession = null; + } + + /** + * 获取当前会话 ID + */ + getCurrentSessionId(): string | null { + return this.currentSession?.id ?? null; + } + + /** + * 检查是否有活跃会话 + */ + hasActiveSession(): boolean { + return this.currentSession !== null; + } + + /** + * 记录检查点到当前会话 + */ + recordCheckpoint(checkpointId: string): void { + if (this.currentSession) { + this.currentSession.checkpoints.push(checkpointId); + } + } + + /** + * 记录文件修改 + */ + recordFileChange(filePath: string): void { + if (this.currentSession) { + if (!this.currentSession.modifiedFiles.includes(filePath)) { + this.currentSession.modifiedFiles.push(filePath); + } + } + } + + /** + * 记录多个文件修改 + */ + recordFileChanges(filePaths: string[]): void { + for (const filePath of filePaths) { + this.recordFileChange(filePath); + } + } + + /** + * 获取会话中修改的所有文件 + */ + getModifiedFiles(): string[] { + return this.currentSession?.modifiedFiles ?? []; + } + + /** + * 获取会话中的所有检查点 + */ + async getSessionCheckpoints(): Promise { + if (!this.currentSession) { + return []; + } + + const checkpoints: CheckpointMetadata[] = []; + for (const id of this.currentSession.checkpoints) { + const cp = await this.checkpointManager.getCheckpoint(id); + if (cp) { + checkpoints.push(cp); + } + } + + return checkpoints.sort((a, b) => a.timestamp - b.timestamp); + } + + /** + * 撤销当前会话的所有修改 + */ + async undoSession(): Promise { + if (!this.currentSession) { + throw new Error('No active session'); + } + + return this.checkpointManager.rollback({ + target: this.currentSession.startCheckpoint, + skipSafetyCheck: true, // 会话级撤销跳过安全检查 + }); + } + + /** + * 回滚到会话中的指定检查点 + */ + async rollbackToCheckpoint(checkpointId: string): Promise { + if (!this.currentSession) { + throw new Error('No active session'); + } + + // 验证检查点属于当前会话 + if (!this.currentSession.checkpoints.includes(checkpointId)) { + throw new Error('Checkpoint does not belong to current session'); + } + + return this.checkpointManager.rollback({ + target: checkpointId, + }); + } + + /** + * 获取会话统计信息 + */ + getSessionStats(): SessionStats { + if (!this.currentSession) { + throw new Error('No active session'); + } + + return { + duration: Date.now() - this.currentSession.startTime, + checkpointCount: this.currentSession.checkpoints.length, + modifiedFilesCount: this.currentSession.modifiedFiles.length, + modifiedFiles: [...this.currentSession.modifiedFiles], + }; + } + + /** + * 获取当前会话状态 + */ + getSessionState(): SessionState | null { + if (!this.currentSession) { + return null; + } + + return { + ...this.currentSession, + modifiedFiles: [...this.currentSession.modifiedFiles], + checkpoints: [...this.currentSession.checkpoints], + }; + } + + /** + * 获取会话开始时的检查点 + */ + getStartCheckpoint(): string | null { + return this.currentSession?.startCheckpoint ?? null; + } + + /** + * 获取会话持续时间(毫秒) + */ + getSessionDuration(): number { + if (!this.currentSession) { + return 0; + } + return Date.now() - this.currentSession.startTime; + } +} + +/** + * 创建会话跟踪器实例 + */ +export function createSessionTracker( + checkpointManager: CheckpointManager +): SessionTracker { + return new SessionTracker(checkpointManager); +} diff --git a/packages/core/src/checkpoint/types.ts b/packages/core/src/checkpoint/types.ts index 4a8ac71..9996904 100644 --- a/packages/core/src/checkpoint/types.ts +++ b/packages/core/src/checkpoint/types.ts @@ -16,7 +16,10 @@ export type CheckpointTrigger = | 'tool:copy_file' // 复制文件前 | 'tool:bash' // bash 命令前 | 'task_start' // 任务开始 - | 'task_complete'; // 任务完成 + | 'task_complete' // 任务完成 + | 'pre_rollback' // 回滚前(用于 unrevert) + | 'session_start' // 会话开始 + | 'session_end'; // 会话结束 /** * 检查点元数据 @@ -41,6 +44,12 @@ export interface CheckpointMetadata { commitHash: string; /** 受影响的文件数 */ filesChanged: number; + /** 关联的消息 ID */ + messageId?: string; + /** 会话 ID */ + sessionId?: string; + /** 对话轮次 */ + turnIndex?: number; } /** @@ -140,6 +149,18 @@ export interface FileDiff { patch?: string; } +/** + * 恢复模式 + */ +export enum RestoreMode { + /** 仅恢复 AI 修改的文件 */ + AI_CHANGES_ONLY = 'ai_changes_only', + /** 仅恢复工作区变更 */ + WORKSPACE_ONLY = 'workspace_only', + /** 完整恢复(AI + 工作区) */ + FULL = 'full', +} + /** * 回滚选项 */ @@ -150,6 +171,10 @@ export interface RollbackOptions { files?: string[]; /** 预览模式 (不实际执行) */ dryRun?: boolean; + /** 恢复模式 */ + mode?: RestoreMode; + /** 跳过安全检查 */ + skipSafetyCheck?: boolean; } /** @@ -189,3 +214,87 @@ export interface CheckpointEvent { * 检查点事件监听器 */ export type CheckpointEventListener = (event: CheckpointEvent) => void; + +/** + * 回滚记录(用于 unrevert) + */ +export interface RollbackRecord { + /** 唯一标识 */ + id: string; + /** 时间戳 */ + timestamp: number; + /** 目标检查点 */ + targetCheckpoint: string; + /** 回滚前的 commit hash */ + previousCommit: string; + /** 恢复的文件列表 */ + restoredFiles: string[]; + /** 是否可撤销 */ + canUnrevert: boolean; +} + +/** + * 撤销回滚结果 + */ +export interface UnrevertResult { + /** 是否成功 */ + success: boolean; + /** 恢复的 commit */ + restoredCommit: string; + /** 恢复的文件数 */ + filesRestored: number; + /** 错误信息 */ + error?: string; +} + +/** + * 安全检查结果 + */ +export interface SafetyCheckResult { + /** 是否安全 */ + safe: boolean; + /** 警告列表 */ + warnings: string[]; + /** 错误列表 */ + errors: string[]; +} + +/** + * 会话状态 + */ +export interface SessionState { + /** 会话 ID */ + id: string; + /** 开始时间 */ + startTime: number; + /** 会话开始时的检查点 ID */ + startCheckpoint: string; + /** 会话中创建的检查点 ID 列表 */ + checkpoints: string[]; + /** 会话中修改的文件 */ + modifiedFiles: string[]; +} + +/** + * 会话统计 + */ +export interface SessionStats { + /** 持续时间(毫秒) */ + duration: number; + /** 检查点数量 */ + checkpointCount: number; + /** 修改的文件数 */ + modifiedFilesCount: number; + /** 修改的文件列表 */ + modifiedFiles: string[]; +} + +/** + * 路径验证结果 + */ +export interface PathValidationResult { + /** 是否有效 */ + valid: boolean; + /** 原因 */ + reason?: string; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 86fb4ea..8a64e78 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,53 @@ export type { CommandOperationResult, } from './commands/index.js'; +// Checkpoint +export { + CheckpointManager, + getCheckpointManager, + initCheckpointManager, + resetCheckpointManager, + ShadowGit, + createShadowGit, + hashWorkingDir, + CheckpointSafetyChecker, + createSafetyChecker, + SessionTracker, + createSessionTracker, + CheckpointLock, + LFSPatternLoader, + createLFSPatternLoader, + isCommonLargeFile, + COMMON_LARGE_FILE_EXTENSIONS, + WorkspacePathValidator, + createPathValidator, + CommitMessageGenerator, + createCommitMessageGenerator, + RestoreMode, + DEFAULT_CHECKPOINT_CONFIG, +} from './checkpoint/index.js'; + +export type { + CheckpointMetadata, + CheckpointConfig, + CheckpointTrigger, + FileChange, + FileChangeType, + DiffInfo, + FileDiff, + RollbackOptions, + RollbackResult, + CheckpointEvent, + CheckpointEventType, + CheckpointEventListener, + RollbackRecord, + UnrevertResult, + SafetyCheckResult, + SessionState, + SessionStats, + PathValidationResult, +} from './checkpoint/index.js'; + const program = new Command(); // MCP 管理器实例 diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index c068cd8..82cc4e7 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -11,6 +11,7 @@ import { MCPPanel, HooksPanel, AgentsPanel, + CheckpointPanel, Toaster, listSessions, createSession, @@ -27,6 +28,7 @@ export function App() { const [showMCP, setShowMCP] = useState(false); const [showHooks, setShowHooks] = useState(false); const [showAgents, setShowAgents] = useState(false); + const [showCheckpoints, setShowCheckpoints] = useState(false); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); // 初始化:加载或创建会话 @@ -101,6 +103,7 @@ export function App() { onOpenMCP={() => setShowMCP(true)} onOpenHooks={() => setShowHooks(true)} onOpenAgents={() => setShowAgents(true)} + onOpenCheckpoints={() => setShowCheckpoints(true)} /> ) : (
@@ -136,6 +139,9 @@ export function App() { {/* Agents 面板 */} {showAgents && setShowAgents(false)} />} + {/* Checkpoints 面板 */} + {showCheckpoints && setShowCheckpoints(false)} />} + {/* Toast 通知 */}
diff --git a/packages/desktop/src/pages/Chat.tsx b/packages/desktop/src/pages/Chat.tsx index f85b743..9eed7c0 100644 --- a/packages/desktop/src/pages/Chat.tsx +++ b/packages/desktop/src/pages/Chat.tsx @@ -3,7 +3,7 @@ */ import { useEffect, useRef } from 'react'; -import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot } from 'lucide-react'; +import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot, History } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useChat, @@ -24,6 +24,7 @@ interface ChatPageProps { onOpenMCP?: () => void; onOpenHooks?: () => void; onOpenAgents?: () => void; + onOpenCheckpoints?: () => void; } export function ChatPage({ @@ -36,6 +37,7 @@ export function ChatPage({ onOpenMCP, onOpenHooks, onOpenAgents, + onOpenCheckpoints, }: ChatPageProps) { const { messages, @@ -129,8 +131,21 @@ export function ChatPage({ {/* 工具栏按钮 */} - {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents) && ( + {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints) && (
+ {/* Checkpoints 按钮 */} + {onOpenCheckpoints && ( + + + + )} + {/* Agents 按钮 */} {onOpenAgents && ( CheckpointManager; + initCheckpointManager: ( + workDir: string, + config?: Partial + ) => Promise; + RestoreMode: typeof RestoreMode; +} + +interface CheckpointManager { + initialize(): Promise; + isEnabled(): boolean; + getConfig(): CheckpointConfig; + listCheckpoints(): Promise; + getCheckpoint(idOrHash: string): Promise; + getLatestCheckpoint(): Promise; + createCheckpoint(options: { + name?: string; + description?: string; + trigger?: CheckpointTrigger; + }): Promise; + deleteCheckpoint(id: string): Promise; + getDiff(checkpointId: string): Promise; + getFileDiff(checkpointId: string, filePath: string): Promise; + rollback(options: RollbackOptions): Promise; + checkSafety(checkpointId: string): Promise; + unrevert(): Promise; + canUnrevert(): boolean; + getLastRollback(): RollbackRecord | null; + cleanup(): Promise; + getStats(): Promise; + getSessionCheckpoints(sessionId: string): Promise; + getMessageCheckpoints(messageId: string): Promise; +} + +interface CheckpointConfig { + enabled: boolean; + autoCheckpoint: { + beforeWrite: boolean; + beforeEdit: boolean; + beforeDelete: boolean; + beforeMove: boolean; + beforeBash: boolean; + }; + maxCheckpoints: number; + maxAge: number; + storageDir: string; +} + +type CheckpointTrigger = + | 'auto' + | 'manual' + | 'tool:write_file' + | 'tool:edit_file' + | 'tool:delete_file' + | 'tool:move_file' + | 'tool:copy_file' + | 'tool:bash' + | 'task_start' + | 'task_complete' + | 'pre_rollback' + | 'session_start' + | 'session_end'; + +interface CheckpointMetadata { + id: string; + name?: string; + description?: string; + timestamp: number; + trigger: CheckpointTrigger; + toolCall?: { + tool: string; + params: Record; + }; + commitHash: string; + filesChanged: number; + messageId?: string; + sessionId?: string; + turnIndex?: number; +} + +type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed'; + +interface FileChange { + path: string; + type: FileChangeType; + oldPath?: string; + insertions?: number; + deletions?: number; +} + +interface DiffInfo { + from: string; + to: string; + files: FileChange[]; + totalInsertions: number; + totalDeletions: number; +} + +interface FileDiff { + path: string; + type: FileChangeType; + oldContent?: string; + newContent?: string; + patch?: string; +} + +enum RestoreMode { + AI_CHANGES_ONLY = 'ai_changes_only', + WORKSPACE_ONLY = 'workspace_only', + FULL = 'full', +} + +interface RollbackOptions { + target: string; + files?: string[]; + dryRun?: boolean; + mode?: RestoreMode; + skipSafetyCheck?: boolean; +} + +interface RollbackResult { + success: boolean; + restoredFiles: string[]; + errors: Array<{ file: string; error: string }>; + previousCommit?: string; +} + +interface SafetyCheckResult { + safe: boolean; + warnings: string[]; + errors: string[]; +} + +interface RollbackRecord { + id: string; + timestamp: number; + targetCheckpoint: string; + previousCommit: string; + restoredFiles: string[]; + canUnrevert: boolean; +} + +interface UnrevertResult { + success: boolean; + restoredCommit: string; + filesRestored: number; + error?: string; +} + +interface CheckpointStats { + count: number; + oldestTimestamp: number | null; + newestTimestamp: number | null; +} + +export const checkpointsRouter = new Hono(); + +// Core 模块缓存 +let checkpointModule: CheckpointModule | null = null; +let managerInitialized = false; + +/** + * 初始化 Checkpoint 模块 + */ +async function initCheckpointModule(): Promise { + if (checkpointModule && managerInitialized) return checkpointModule; + + try { + const corePath = '@ai-assistant/core'; + const core = (await import(corePath)) as Record; + + if ( + typeof core.getCheckpointManager !== 'function' || + typeof core.initCheckpointManager !== 'function' + ) { + console.warn('[Checkpoints] Core module missing Checkpoint exports'); + return null; + } + + checkpointModule = { + getCheckpointManager: core.getCheckpointManager as () => CheckpointManager, + initCheckpointManager: core.initCheckpointManager as ( + workDir: string, + config?: Partial + ) => Promise, + RestoreMode: core.RestoreMode as typeof RestoreMode, + }; + + // 初始化 Checkpoint Manager + const config = getConfig(); + await checkpointModule.initCheckpointManager(config.workdir); + managerInitialized = true; + + console.log('[Checkpoints] Checkpoint module initialized'); + return checkpointModule; + } catch (error) { + console.warn('[Checkpoints] Failed to load Checkpoint module:', error); + return null; + } +} + +/** + * GET /checkpoints - 获取所有检查点列表 + */ +checkpointsRouter.get('/', async (c) => { + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const checkpoints = await manager.listCheckpoints(); + + return c.json({ + success: true, + data: checkpoints.map((cp) => ({ + id: cp.id, + name: cp.name, + description: cp.description, + timestamp: cp.timestamp, + trigger: cp.trigger, + filesChanged: cp.filesChanged, + commitHash: cp.commitHash, + messageId: cp.messageId, + sessionId: cp.sessionId, + })), + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to list checkpoints', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/stats - 获取检查点统计信息 + */ +checkpointsRouter.get('/stats', async (c) => { + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const stats = await manager.getStats(); + + return c.json({ + success: true, + data: stats, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get stats', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/latest - 获取最新检查点 + */ +checkpointsRouter.get('/latest', async (c) => { + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const checkpoint = await manager.getLatestCheckpoint(); + + return c.json({ + success: true, + data: checkpoint, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get latest checkpoint', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/unrevert/status - 检查是否可撤销回滚 + */ +checkpointsRouter.get('/unrevert/status', async (c) => { + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const canUnrevert = manager.canUnrevert(); + const lastRollback = manager.getLastRollback(); + + return c.json({ + success: true, + data: { + canUnrevert, + lastRollback: lastRollback + ? { + id: lastRollback.id, + timestamp: lastRollback.timestamp, + targetCheckpoint: lastRollback.targetCheckpoint, + restoredFiles: lastRollback.restoredFiles, + } + : null, + }, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get unrevert status', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/:id - 获取单个检查点详情 + */ +checkpointsRouter.get('/:id', async (c) => { + const id = c.req.param('id'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const checkpoint = await manager.getCheckpoint(id); + + if (!checkpoint) { + return c.json( + { + success: false, + error: `Checkpoint not found: ${id}`, + }, + 404 + ); + } + + return c.json({ + success: true, + data: checkpoint, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get checkpoint', + }, + 500 + ); + } +}); + +/** + * POST /checkpoints - 创建手动检查点 + */ +checkpointsRouter.post('/', async (c) => { + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const body = await c.req.json<{ name?: string; description?: string }>(); + const manager = module.getCheckpointManager(); + + const checkpoint = await manager.createCheckpoint({ + name: body.name, + description: body.description, + trigger: 'manual', + }); + + return c.json({ + success: true, + data: checkpoint, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to create checkpoint', + }, + 500 + ); + } +}); + +/** + * DELETE /checkpoints/:id - 删除检查点 + */ +checkpointsRouter.delete('/:id', async (c) => { + const id = c.req.param('id'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const deleted = await manager.deleteCheckpoint(id); + + if (!deleted) { + return c.json( + { + success: false, + error: `Checkpoint not found: ${id}`, + }, + 404 + ); + } + + return c.json({ + success: true, + data: { deleted: true }, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete checkpoint', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/:id/diff - 获取检查点与当前工作区的差异 + */ +checkpointsRouter.get('/:id/diff', async (c) => { + const id = c.req.param('id'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const diff = await manager.getDiff(id); + + return c.json({ + success: true, + data: diff, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get diff', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/:id/file-diff - 获取单个文件的详细差异 + */ +checkpointsRouter.get('/:id/file-diff', async (c) => { + const id = c.req.param('id'); + const filePath = c.req.query('path'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + if (!filePath) { + return c.json( + { + success: false, + error: 'File path is required', + }, + 400 + ); + } + + try { + const manager = module.getCheckpointManager(); + const fileDiff = await manager.getFileDiff(id, filePath); + + return c.json({ + success: true, + data: fileDiff, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get file diff', + }, + 500 + ); + } +}); + +/** + * POST /checkpoints/:id/restore - 回滚到检查点 + */ +checkpointsRouter.post('/:id/restore', async (c) => { + const id = c.req.param('id'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const body = await c.req.json<{ + mode?: 'ai_changes_only' | 'workspace_only' | 'full'; + files?: string[]; + skipSafetyCheck?: boolean; + }>(); + + const manager = module.getCheckpointManager(); + + // 转换 mode 字符串为枚举值 + let mode: RestoreMode | undefined; + if (body.mode) { + switch (body.mode) { + case 'ai_changes_only': + mode = module.RestoreMode.AI_CHANGES_ONLY; + break; + case 'workspace_only': + mode = module.RestoreMode.WORKSPACE_ONLY; + break; + case 'full': + mode = module.RestoreMode.FULL; + break; + } + } + + const result = await manager.rollback({ + target: id, + mode, + files: body.files, + skipSafetyCheck: body.skipSafetyCheck, + }); + + return c.json({ + success: true, + data: result, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore checkpoint', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/:id/restore/preview - 预览回滚(dry run) + */ +checkpointsRouter.get('/:id/restore/preview', async (c) => { + const id = c.req.param('id'); + const modeParam = c.req.query('mode'); + const filesParam = c.req.query('files'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + + // 转换 mode 字符串为枚举值 + let mode: RestoreMode | undefined; + if (modeParam) { + switch (modeParam) { + case 'ai_changes_only': + mode = module.RestoreMode.AI_CHANGES_ONLY; + break; + case 'workspace_only': + mode = module.RestoreMode.WORKSPACE_ONLY; + break; + case 'full': + mode = module.RestoreMode.FULL; + break; + } + } + + const files = filesParam ? filesParam.split(',') : undefined; + + const result = await manager.rollback({ + target: id, + mode, + files, + dryRun: true, + }); + + return c.json({ + success: true, + data: result, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to preview restore', + }, + 500 + ); + } +}); + +/** + * POST /checkpoints/unrevert - 撤销最近一次回滚 + */ +checkpointsRouter.post('/unrevert', async (c) => { + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const result = await manager.unrevert(); + + if (!result.success) { + return c.json( + { + success: false, + error: result.error || 'Failed to unrevert', + }, + 400 + ); + } + + return c.json({ + success: true, + data: result, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to unrevert', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/:id/safety-check - 执行安全检查 + */ +checkpointsRouter.get('/:id/safety-check', async (c) => { + const id = c.req.param('id'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const result = await manager.checkSafety(id); + + return c.json({ + success: true, + data: result, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to check safety', + }, + 500 + ); + } +}); + +/** + * POST /checkpoints/cleanup - 清理过期检查点 + */ +checkpointsRouter.post('/cleanup', async (c) => { + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const deleted = await manager.cleanup(); + + return c.json({ + success: true, + data: { deleted }, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to cleanup', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/sessions/:sessionId - 获取会话的所有检查点 + */ +checkpointsRouter.get('/sessions/:sessionId', async (c) => { + const sessionId = c.req.param('sessionId'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const checkpoints = await manager.getSessionCheckpoints(sessionId); + + return c.json({ + success: true, + data: checkpoints, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get session checkpoints', + }, + 500 + ); + } +}); + +/** + * GET /checkpoints/messages/:messageId - 获取消息关联的检查点 + */ +checkpointsRouter.get('/messages/:messageId', async (c) => { + const messageId = c.req.param('messageId'); + const module = await initCheckpointModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Checkpoint module not available', + }, + 503 + ); + } + + try { + const manager = module.getCheckpointManager(); + const checkpoints = await manager.getMessageCheckpoints(messageId); + + return c.json({ + success: true, + data: checkpoints, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to get message checkpoints', + }, + 500 + ); + } +}); diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 1ae4619..f43cddb 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -12,3 +12,4 @@ export { commandsRouter } from './commands.js'; export { mcpRouter } from './mcp.js'; export { hooksRouter } from './hooks.js'; export { agentsRouter } from './agents.js'; +export { checkpointsRouter } from './checkpoints.js'; diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 8abd4ef..e4094b1 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -28,6 +28,16 @@ import type { AgentDetail, AgentInput, AgentDefaults, + CheckpointListItem, + CheckpointDetail, + CheckpointStats, + DiffInfo, + FileDiffDetail, + RestoreOptions, + RestoreResult, + SafetyCheckResult, + UnrevertStatus, + UnrevertResult, } from './types.js'; // Re-export types @@ -71,6 +81,21 @@ export type { AgentDetail, AgentInput, AgentDefaults, + // Checkpoint types + CheckpointTrigger, + FileChangeType, + CheckpointListItem, + CheckpointDetail, + FileChange, + DiffInfo, + FileDiffDetail, + RestoreMode, + RestoreOptions, + RestoreResult, + SafetyCheckResult, + CheckpointStats, + UnrevertStatus, + UnrevertResult, } from './types.js'; // API Configuration @@ -588,3 +613,194 @@ export async function updateAgentDefaults(defaults: AgentDefaults): Promise<{ }> { return request('PUT', '/agents/defaults', defaults); } + +// ============ Checkpoints API ============ + +/** + * 获取所有检查点列表 + */ +export async function listCheckpoints(): Promise<{ + success: boolean; + data: CheckpointListItem[]; + error?: string; +}> { + return request('GET', '/checkpoints'); +} + +/** + * 获取检查点统计信息 + */ +export async function getCheckpointStats(): Promise<{ + success: boolean; + data: CheckpointStats; + error?: string; +}> { + return request('GET', '/checkpoints/stats'); +} + +/** + * 获取最新检查点 + */ +export async function getLatestCheckpoint(): Promise<{ + success: boolean; + data: CheckpointDetail | null; + error?: string; +}> { + return request('GET', '/checkpoints/latest'); +} + +/** + * 获取单个检查点详情 + */ +export async function getCheckpoint(id: string): Promise<{ + success: boolean; + data?: CheckpointDetail; + error?: string; +}> { + return request('GET', `/checkpoints/${encodeURIComponent(id)}`); +} + +/** + * 创建手动检查点 + */ +export async function createCheckpoint(options?: { + name?: string; + description?: string; +}): Promise<{ + success: boolean; + data?: CheckpointDetail; + error?: string; +}> { + return request('POST', '/checkpoints', options || {}); +} + +/** + * 删除检查点 + */ +export async function deleteCheckpoint(id: string): Promise<{ + success: boolean; + data?: { deleted: boolean }; + error?: string; +}> { + return request('DELETE', `/checkpoints/${encodeURIComponent(id)}`); +} + +/** + * 获取检查点与当前工作区的差异 + */ +export async function getCheckpointDiff(id: string): Promise<{ + success: boolean; + data?: DiffInfo; + error?: string; +}> { + return request('GET', `/checkpoints/${encodeURIComponent(id)}/diff`); +} + +/** + * 获取单个文件的详细差异 + */ +export async function getFileDiff(checkpointId: string, filePath: string): Promise<{ + success: boolean; + data?: FileDiffDetail; + error?: string; +}> { + const params = new URLSearchParams({ path: filePath }); + return request('GET', `/checkpoints/${encodeURIComponent(checkpointId)}/file-diff?${params}`); +} + +/** + * 回滚到检查点 + */ +export async function restoreCheckpoint( + id: string, + options?: RestoreOptions +): Promise<{ + success: boolean; + data?: RestoreResult; + error?: string; +}> { + return request('POST', `/checkpoints/${encodeURIComponent(id)}/restore`, options || {}); +} + +/** + * 预览回滚(dry run) + */ +export async function previewRestore( + id: string, + options?: { mode?: RestoreOptions['mode']; files?: string[] } +): Promise<{ + success: boolean; + data?: RestoreResult; + error?: string; +}> { + const params = new URLSearchParams(); + if (options?.mode) params.set('mode', options.mode); + if (options?.files) params.set('files', options.files.join(',')); + return request('GET', `/checkpoints/${encodeURIComponent(id)}/restore/preview?${params}`); +} + +/** + * 撤销最近一次回滚 + */ +export async function unrevert(): Promise<{ + success: boolean; + data?: UnrevertResult; + error?: string; +}> { + return request('POST', '/checkpoints/unrevert'); +} + +/** + * 检查是否可撤销回滚 + */ +export async function getUnrevertStatus(): Promise<{ + success: boolean; + data?: UnrevertStatus; + error?: string; +}> { + return request('GET', '/checkpoints/unrevert/status'); +} + +/** + * 执行安全检查 + */ +export async function checkSafety(id: string): Promise<{ + success: boolean; + data?: SafetyCheckResult; + error?: string; +}> { + return request('GET', `/checkpoints/${encodeURIComponent(id)}/safety-check`); +} + +/** + * 清理过期检查点 + */ +export async function cleanupCheckpoints(): Promise<{ + success: boolean; + data?: { deleted: number }; + error?: string; +}> { + return request('POST', '/checkpoints/cleanup'); +} + +/** + * 获取会话的所有检查点 + */ +export async function getSessionCheckpoints(sessionId: string): Promise<{ + success: boolean; + data: CheckpointListItem[]; + error?: string; +}> { + return request('GET', `/checkpoints/sessions/${encodeURIComponent(sessionId)}`); +} + +/** + * 获取消息关联的检查点 + */ +export async function getMessageCheckpoints(messageId: string): Promise<{ + success: boolean; + data: CheckpointListItem[]; + error?: string; +}> { + return request('GET', `/checkpoints/messages/${encodeURIComponent(messageId)}`); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 14e3dda..6e678a8 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -424,3 +424,169 @@ export interface AgentDefaults { /** 权限配置 */ permission?: AgentPermission; } + +// ============ Checkpoint 相关 ============ + +/** 检查点触发类型 */ +export type CheckpointTrigger = + | 'auto' + | 'manual' + | 'tool:write_file' + | 'tool:edit_file' + | 'tool:delete_file' + | 'tool:move_file' + | 'tool:copy_file' + | 'tool:bash' + | 'task_start' + | 'task_complete' + | 'pre_rollback' + | 'session_start' + | 'session_end'; + +/** 文件变更类型 */ +export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed'; + +/** 检查点列表项 */ +export interface CheckpointListItem { + /** 唯一标识 */ + id: string; + /** 用户可读名称 */ + name?: string; + /** 描述信息 */ + description?: string; + /** 创建时间戳 */ + timestamp: number; + /** 触发类型 */ + trigger: CheckpointTrigger; + /** 受影响的文件数 */ + filesChanged: number; + /** Git commit hash */ + commitHash: string; + /** 关联的消息 ID */ + messageId?: string; + /** 会话 ID */ + sessionId?: string; +} + +/** 检查点详情 */ +export interface CheckpointDetail extends CheckpointListItem { + /** 关联的工具调用 */ + toolCall?: { + tool: string; + params: Record; + }; + /** 对话轮次 */ + turnIndex?: number; +} + +/** 文件变更信息 */ +export interface FileChange { + /** 文件路径 */ + path: string; + /** 变更类型 */ + type: FileChangeType; + /** 旧路径 (重命名时) */ + oldPath?: string; + /** 添加的行数 */ + insertions?: number; + /** 删除的行数 */ + deletions?: number; +} + +/** 差异信息 */ +export interface DiffInfo { + /** 源检查点/commit */ + from: string; + /** 目标检查点/commit */ + to: string; + /** 变更的文件列表 */ + files: FileChange[]; + /** 总添加行数 */ + totalInsertions: number; + /** 总删除行数 */ + totalDeletions: number; +} + +/** 文件差异详情 */ +export interface FileDiffDetail { + /** 文件路径 */ + path: string; + /** 变更类型 */ + type: FileChangeType; + /** 旧内容 */ + oldContent?: string; + /** 新内容 */ + newContent?: string; + /** 差异补丁 (unified diff 格式) */ + patch?: string; +} + +/** 恢复模式 */ +export type RestoreMode = 'ai_changes_only' | 'workspace_only' | 'full'; + +/** 恢复选项 */ +export interface RestoreOptions { + /** 恢复模式 */ + mode?: RestoreMode; + /** 只恢复指定文件 */ + files?: string[]; + /** 跳过安全检查 */ + skipSafetyCheck?: boolean; +} + +/** 恢复结果 */ +export interface RestoreResult { + /** 是否成功 */ + success: boolean; + /** 恢复的文件列表 */ + restoredFiles: string[]; + /** 错误列表 */ + errors: Array<{ file: string; error: string }>; + /** 回滚前的 commit hash */ + previousCommit?: string; +} + +/** 安全检查结果 */ +export interface SafetyCheckResult { + /** 是否安全 */ + safe: boolean; + /** 警告列表 */ + warnings: string[]; + /** 错误列表 */ + errors: string[]; +} + +/** 检查点统计信息 */ +export interface CheckpointStats { + /** 检查点数量 */ + count: number; + /** 最早时间戳 */ + oldestTimestamp: number | null; + /** 最新时间戳 */ + newestTimestamp: number | null; +} + +/** Unrevert 状态 */ +export interface UnrevertStatus { + /** 是否可撤销回滚 */ + canUnrevert: boolean; + /** 最后一次回滚记录 */ + lastRollback?: { + id: string; + timestamp: number; + targetCheckpoint: string; + restoredFiles: string[]; + }; +} + +/** Unrevert 结果 */ +export interface UnrevertResult { + /** 是否成功 */ + success: boolean; + /** 恢复的 commit */ + restoredCommit: string; + /** 恢复的文件数 */ + filesRestored: number; + /** 错误信息 */ + error?: string; +} diff --git a/packages/ui/src/components/CheckpointDiffViewer.tsx b/packages/ui/src/components/CheckpointDiffViewer.tsx new file mode 100644 index 0000000..afcc84e --- /dev/null +++ b/packages/ui/src/components/CheckpointDiffViewer.tsx @@ -0,0 +1,493 @@ +/** + * CheckpointDiffViewer Component + * + * 检查点差异查看器:显示检查点与当前工作区的差异 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + X, + RefreshCw, + ChevronDown, + ChevronRight, + Eye, + Check, + RotateCcw, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { cn } from '../utils/cn'; +import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; +import { Button } from '../primitives/Button'; +import { Skeleton } from './Skeleton'; +import { + getCheckpoint, + getCheckpointDiff, + getFileDiff, + type CheckpointDetail, + type DiffInfo, + type FileDiffDetail, + type FileChangeType, +} from '../api/client.js'; + +interface CheckpointDiffViewerProps { + /** 检查点 ID */ + checkpointId: string; + /** 关闭回调 */ + onClose: () => void; + /** 恢复选中文件 */ + onRestoreSelected?: (checkpointId: string, files: string[]) => void; + /** 恢复全部 */ + onRestoreAll?: (checkpointId: string) => void; + /** 是否启用响应式布局 */ + responsive?: boolean; +} + +// 文件变更类型标签 +function getChangeLabel(type: FileChangeType) { + switch (type) { + case 'added': + return 'A'; + case 'modified': + return 'M'; + case 'deleted': + return 'D'; + case 'renamed': + return 'R'; + default: + return '?'; + } +} + +// 文件变更类型颜色 +function getChangeColor(type: FileChangeType) { + switch (type) { + case 'added': + return 'text-green-400 bg-green-400/10'; + case 'modified': + return 'text-yellow-400 bg-yellow-400/10'; + case 'deleted': + return 'text-red-400 bg-red-400/10'; + case 'renamed': + return 'text-blue-400 bg-blue-400/10'; + default: + return 'text-gray-400 bg-gray-400/10'; + } +} + +export function CheckpointDiffViewer({ + checkpointId, + onClose, + onRestoreSelected, + onRestoreAll, + responsive = false, +}: CheckpointDiffViewerProps) { + // 数据状态 + const [checkpoint, setCheckpoint] = useState(null); + const [diff, setDiff] = useState(null); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [expandedFile, setExpandedFile] = useState(null); + const [fileDiff, setFileDiff] = useState(null); + + // UI 状态 + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [loadingFileDiff, setLoadingFileDiff] = useState(false); + + // 加载数据 + const loadData = useCallback(async () => { + try { + const [cpResult, diffResult] = await Promise.all([ + getCheckpoint(checkpointId), + getCheckpointDiff(checkpointId), + ]); + + if (cpResult.success && cpResult.data) { + setCheckpoint(cpResult.data); + } else { + toast.error(cpResult.error || 'Failed to load checkpoint'); + } + + if (diffResult.success && diffResult.data) { + setDiff(diffResult.data); + // 默认全选 + setSelectedFiles(new Set(diffResult.data.files.map((f) => f.path))); + } else { + toast.error(diffResult.error || 'Failed to load diff'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load data'); + } + }, [checkpointId]); + + // 初始加载 + useEffect(() => { + setLoading(true); + loadData().finally(() => setLoading(false)); + }, [loadData]); + + // 刷新 + const handleRefresh = async () => { + setRefreshing(true); + await loadData(); + setRefreshing(false); + toast.success('Diff refreshed'); + }; + + // 切换文件选择 + const toggleFileSelection = (path: string) => { + const newSelected = new Set(selectedFiles); + if (newSelected.has(path)) { + newSelected.delete(path); + } else { + newSelected.add(path); + } + setSelectedFiles(newSelected); + }; + + // 全选/取消全选 + const toggleSelectAll = () => { + if (diff) { + if (selectedFiles.size === diff.files.length) { + setSelectedFiles(new Set()); + } else { + setSelectedFiles(new Set(diff.files.map((f) => f.path))); + } + } + }; + + // 查看文件差异 + const handleViewFileDiff = async (path: string) => { + if (expandedFile === path) { + setExpandedFile(null); + setFileDiff(null); + return; + } + + setExpandedFile(path); + setLoadingFileDiff(true); + + try { + const result = await getFileDiff(checkpointId, path); + if (result.success && result.data) { + setFileDiff(result.data); + } else { + toast.error(result.error || 'Failed to load file diff'); + setFileDiff(null); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load file diff'); + setFileDiff(null); + } finally { + setLoadingFileDiff(false); + } + }; + + // 恢复选中文件 + const handleRestoreSelected = () => { + if (onRestoreSelected && selectedFiles.size > 0) { + onRestoreSelected(checkpointId, Array.from(selectedFiles)); + } + }; + + // 恢复全部 + const handleRestoreAll = () => { + if (onRestoreAll) { + onRestoreAll(checkpointId); + } + }; + + // Loading 骨架屏 + const LoadingSkeleton = () => ( +
+ +
+ {[1, 2, 3, 4].map((i) => ( +
+ + + +
+ ))} +
+
+ ); + + // 渲染 diff 补丁 + const renderPatch = (patch: string) => { + const lines = patch.split('\n'); + return ( +
+        {lines.map((line, index) => {
+          let className = 'px-2 py-0.5';
+          if (line.startsWith('+') && !line.startsWith('+++')) {
+            className += ' bg-green-500/10 text-green-400';
+          } else if (line.startsWith('-') && !line.startsWith('---')) {
+            className += ' bg-red-500/10 text-red-400';
+          } else if (line.startsWith('@@')) {
+            className += ' bg-blue-500/10 text-blue-400';
+          } else {
+            className += ' text-gray-400';
+          }
+          return (
+            
+ {line || ' '} +
+ ); + })} +
+ ); + }; + + return ( + + + e.stopPropagation()} + className={cn( + 'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col', + responsive + ? 'w-full md:w-full md:max-w-3xl md:mx-4 rounded-t-2xl md:rounded-lg' + : 'rounded-lg w-full max-w-3xl mx-4' + )} + > + {/* Header */} +
+ {responsive && ( +
+ )} +
+

+ + Checkpoint Diff +

+

+ {checkpoint ? ( + <> + Comparing {checkpoint.commitHash.slice(0, 7)} → Current + + ) : ( + 'Loading...' + )} +

+
+
+ + +
+
+ + {/* Summary */} + {diff && ( +
+
+
+ +{diff.totalInsertions} + -{diff.totalDeletions} + across {diff.files.length} files +
+ +
+
+ )} + + {/* File List */} +
+ {loading ? ( + + ) : !diff || diff.files.length === 0 ? ( +
+ +

No changes detected

+

+ The workspace matches this checkpoint +

+
+ ) : ( + + {diff.files.map((file) => { + const isSelected = selectedFiles.has(file.path); + const isExpanded = expandedFile === file.path; + + return ( +
+ {/* File Header */} +
+ {/* Checkbox */} + + + {/* Expand Icon */} + + + {/* Change Type */} + + {getChangeLabel(file.type)} + + + {/* File Path */} + handleViewFileDiff(file.path)} + > + {file.path} + + + {/* Stats */} + {(file.insertions !== undefined || file.deletions !== undefined) && ( + + {file.insertions !== undefined && ( + +{file.insertions} + )} + {file.deletions !== undefined && ( + -{file.deletions} + )} + + )} +
+ + {/* File Diff Content */} + + {isExpanded && ( + + {loadingFileDiff ? ( +
+ +
+ ) : fileDiff?.patch ? ( +
+ {renderPatch(fileDiff.patch)} +
+ ) : ( +
+ No diff content available +
+ )} +
+ )} +
+
+ ); + })} +
+ )} +
+ + {/* Footer Actions */} + {diff && diff.files.length > 0 && (onRestoreSelected || onRestoreAll) && ( +
+ + {selectedFiles.size} of {diff.files.length} files selected + +
+ {onRestoreSelected && ( + + )} + {onRestoreAll && ( + + )} +
+
+ )} + + + + ); +} diff --git a/packages/ui/src/components/CheckpointPanel.tsx b/packages/ui/src/components/CheckpointPanel.tsx new file mode 100644 index 0000000..abd9ff0 --- /dev/null +++ b/packages/ui/src/components/CheckpointPanel.tsx @@ -0,0 +1,609 @@ +/** + * CheckpointPanel Component + * + * 检查点管理面板:显示所有检查点、创建/删除、查看差异、恢复 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + X, + RefreshCw, + History, + Plus, + Trash2, + ChevronDown, + ChevronRight, + FileText, + Clock, + AlertTriangle, + RotateCcw, + Undo2, + Eye, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { cn } from '../utils/cn'; +import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; +import { Button } from '../primitives/Button'; +import { Skeleton } from './Skeleton'; +import { + listCheckpoints, + getCheckpointStats, + createCheckpoint, + deleteCheckpoint, + getUnrevertStatus, + unrevert, + cleanupCheckpoints, + type CheckpointListItem, + type CheckpointStats, + type CheckpointTrigger, + type UnrevertStatus, +} from '../api/client.js'; + +interface CheckpointPanelProps { + onClose: () => void; + /** 点击查看差异时触发 */ + onViewDiff?: (checkpointId: string) => void; + /** 点击恢复时触发 */ + onRestore?: (checkpointId: string) => void; + /** 是否启用响应式布局 */ + responsive?: boolean; +} + +// 触发类型图标和颜色 +function getTriggerInfo(trigger: CheckpointTrigger) { + switch (trigger) { + case 'tool:write_file': + return { icon: '🟢', label: 'Write File', color: 'text-green-400' }; + case 'tool:edit_file': + return { icon: '🟡', label: 'Edit File', color: 'text-yellow-400' }; + case 'tool:delete_file': + return { icon: '🔴', label: 'Delete File', color: 'text-red-400' }; + case 'tool:move_file': + case 'tool:copy_file': + return { icon: '🟠', label: 'Move/Copy', color: 'text-orange-400' }; + case 'tool:bash': + return { icon: '⚡', label: 'Bash', color: 'text-purple-400' }; + case 'manual': + return { icon: '🔵', label: 'Manual', color: 'text-blue-400' }; + case 'session_start': + return { icon: '▶️', label: 'Session Start', color: 'text-cyan-400' }; + case 'session_end': + return { icon: '⏹️', label: 'Session End', color: 'text-cyan-400' }; + case 'pre_rollback': + return { icon: '🔙', label: 'Pre-Rollback', color: 'text-gray-400' }; + case 'auto': + default: + return { icon: '⚪', label: 'Auto', color: 'text-gray-400' }; + } +} + +// 格式化时间 +function formatTime(timestamp: number) { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +// 格式化完整时间 +function formatFullTime(timestamp: number) { + return new Date(timestamp).toLocaleString(); +} + +// 按日期分组 +function groupByDate(checkpoints: CheckpointListItem[]) { + const groups: { label: string; items: CheckpointListItem[] }[] = []; + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + + let currentLabel = ''; + let currentItems: CheckpointListItem[] = []; + + for (const cp of checkpoints) { + const cpDate = new Date(cp.timestamp); + const cpDay = new Date(cpDate.getFullYear(), cpDate.getMonth(), cpDate.getDate()); + + let label: string; + if (cpDay.getTime() === today.getTime()) { + label = 'Today'; + } else if (cpDay.getTime() === yesterday.getTime()) { + label = 'Yesterday'; + } else { + label = cpDate.toLocaleDateString(); + } + + if (label !== currentLabel) { + if (currentItems.length > 0) { + groups.push({ label: currentLabel, items: currentItems }); + } + currentLabel = label; + currentItems = [cp]; + } else { + currentItems.push(cp); + } + } + + if (currentItems.length > 0) { + groups.push({ label: currentLabel, items: currentItems }); + } + + return groups; +} + +export function CheckpointPanel({ + onClose, + onViewDiff, + onRestore, + responsive = false, +}: CheckpointPanelProps) { + // 数据状态 + const [checkpoints, setCheckpoints] = useState([]); + const [stats, setStats] = useState(null); + const [unrevertStatus, setUnrevertStatus] = useState(null); + const [expandedGroups, setExpandedGroups] = useState>(new Set(['Today'])); + + // UI 状态 + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [actionLoading, setActionLoading] = useState(null); + const [creating, setCreating] = useState(false); + const [cleaningUp, setCleaningUp] = useState(false); + + // 加载数据 + const loadData = useCallback(async (showToast = false) => { + try { + const [cpResult, statsResult, unrevertResult] = await Promise.all([ + listCheckpoints(), + getCheckpointStats(), + getUnrevertStatus(), + ]); + + if (cpResult.success) { + setCheckpoints(cpResult.data); + } else { + toast.error(cpResult.error || 'Failed to load checkpoints'); + } + + if (statsResult.success) { + setStats(statsResult.data); + } + + if (unrevertResult.success) { + setUnrevertStatus(unrevertResult.data || null); + } + + if (showToast) { + toast.success('Checkpoints refreshed'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load checkpoints'); + } + }, []); + + // 初始加载 + useEffect(() => { + setLoading(true); + loadData().finally(() => setLoading(false)); + }, [loadData]); + + // 刷新 + const handleRefresh = async () => { + setRefreshing(true); + await loadData(true); + setRefreshing(false); + }; + + // 创建检查点 + const handleCreate = async () => { + setCreating(true); + try { + const result = await createCheckpoint({ + description: 'Manual checkpoint', + }); + if (result.success) { + toast.success('Checkpoint created'); + await loadData(); + } else { + toast.error(result.error || 'Failed to create checkpoint'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to create checkpoint'); + } finally { + setCreating(false); + } + }; + + // 删除检查点 + const handleDelete = async (id: string) => { + setActionLoading(id); + try { + const result = await deleteCheckpoint(id); + if (result.success) { + toast.success('Checkpoint deleted'); + await loadData(); + } else { + toast.error(result.error || 'Failed to delete checkpoint'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete checkpoint'); + } finally { + setActionLoading(null); + } + }; + + // 撤销回滚 + const handleUnrevert = async () => { + setActionLoading('unrevert'); + try { + const result = await unrevert(); + if (result.success) { + toast.success(`Unrevert successful: ${result.data?.filesRestored} files restored`); + await loadData(); + } else { + toast.error(result.error || 'Failed to unrevert'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to unrevert'); + } finally { + setActionLoading(null); + } + }; + + // 清理检查点 + const handleCleanup = async () => { + setCleaningUp(true); + try { + const result = await cleanupCheckpoints(); + if (result.success) { + toast.success(`Cleaned up ${result.data?.deleted || 0} checkpoints`); + await loadData(); + } else { + toast.error(result.error || 'Failed to cleanup'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to cleanup'); + } finally { + setCleaningUp(false); + } + }; + + // 切换分组展开 + const toggleGroup = (label: string) => { + const newExpanded = new Set(expandedGroups); + if (newExpanded.has(label)) { + newExpanded.delete(label); + } else { + newExpanded.add(label); + } + setExpandedGroups(newExpanded); + }; + + // 分组数据 + const groups = groupByDate(checkpoints); + + // Loading 骨架屏 + const LoadingSkeleton = () => ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); + + return ( + + + e.stopPropagation()} + className={cn( + 'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col', + responsive + ? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg' + : 'rounded-lg w-full max-w-2xl mx-4' + )} + > + {/* Header */} +
+ {responsive && ( +
+ )} +
+

+ + Checkpoints +

+

+ {stats ? `${stats.count} checkpoints` : 'Loading...'} + {stats?.oldestTimestamp && ( + <> · Oldest: {formatTime(stats.oldestTimestamp)} + )} +

+
+
+ + + +
+
+ + {/* Unrevert Banner */} + {unrevertStatus?.canUnrevert && unrevertStatus.lastRollback && ( +
+
+
+ + + Last rollback affected {unrevertStatus.lastRollback.restoredFiles.length} files + +
+ +
+
+ )} + + {/* Checkpoint List */} +
+ {loading ? ( + + ) : checkpoints.length === 0 ? ( +
+ +

No checkpoints yet

+

+ Checkpoints are created automatically when files are modified +

+ +
+ ) : ( + + {groups.map((group) => { + const isExpanded = expandedGroups.has(group.label); + + return ( +
+ {/* Group Header */} + + + {/* Group Items */} + + {isExpanded && ( + + {group.items.map((cp) => { + const triggerInfo = getTriggerInfo(cp.trigger); + const isLoading = actionLoading === cp.id; + + return ( + +
+ {/* Trigger Icon */} + + {triggerInfo.icon} + + + {/* Info */} +
+
+ + {triggerInfo.label} + + + {formatTime(cp.timestamp)} + +
+ {cp.description && ( +

+ {cp.description} +

+ )} +
+ + + {cp.filesChanged} files + + + + {formatFullTime(cp.timestamp)} + +
+
+ + {/* Actions */} +
+ {isLoading ? ( +
+ ) : ( + <> + {onViewDiff && ( + + )} + {onRestore && ( + + )} + + + )} +
+
+ + ); + })} + + )} + +
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+ + Auto-cleanup enabled (7 days / 100 max) + + +
+
+ + + ); +} diff --git a/packages/ui/src/components/RestoreDialog.tsx b/packages/ui/src/components/RestoreDialog.tsx new file mode 100644 index 0000000..3a434a0 --- /dev/null +++ b/packages/ui/src/components/RestoreDialog.tsx @@ -0,0 +1,391 @@ +/** + * RestoreDialog Component + * + * 检查点恢复确认对话框:显示安全检查、选择恢复模式、确认操作 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + X, + AlertTriangle, + AlertCircle, + CheckCircle, + RotateCcw, + FileText, + Loader2, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { cn } from '../utils/cn'; +import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; +import { Button } from '../primitives/Button'; +import { Skeleton } from './Skeleton'; +import { + getCheckpoint, + checkSafety, + previewRestore, + restoreCheckpoint, + type CheckpointDetail, + type SafetyCheckResult, + type RestoreResult, + type RestoreMode, +} from '../api/client.js'; + +interface RestoreDialogProps { + /** 检查点 ID */ + checkpointId: string; + /** 要恢复的文件列表(可选,为空则恢复全部) */ + files?: string[]; + /** 关闭回调 */ + onClose: () => void; + /** 恢复成功回调 */ + onRestored?: (result: RestoreResult) => void; + /** 是否启用响应式布局 */ + responsive?: boolean; +} + +// 恢复模式选项 +const RESTORE_MODES: { value: RestoreMode; label: string; description: string }[] = [ + { + value: 'ai_changes_only', + label: 'AI Changes Only', + description: 'Only restore files that were modified by AI', + }, + { + value: 'workspace_only', + label: 'Workspace Only', + description: 'Only restore workspace changes (not AI modifications)', + }, + { + value: 'full', + label: 'Full Restore', + description: 'Restore all files to checkpoint state', + }, +]; + +export function RestoreDialog({ + checkpointId, + files, + onClose, + onRestored, + responsive = false, +}: RestoreDialogProps) { + // 数据状态 + const [checkpoint, setCheckpoint] = useState(null); + const [safetyResult, setSafetyResult] = useState(null); + const [previewResult, setPreviewResult] = useState(null); + + // 表单状态 + const [selectedMode, setSelectedMode] = useState('full'); + const [skipSafetyCheck, setSkipSafetyCheck] = useState(false); + + // UI 状态 + const [loading, setLoading] = useState(true); + const [restoring, setRestoring] = useState(false); + + // 加载数据 + const loadData = useCallback(async () => { + try { + const [cpResult, safetyRes, previewRes] = await Promise.all([ + getCheckpoint(checkpointId), + checkSafety(checkpointId), + previewRestore(checkpointId, { mode: selectedMode, files }), + ]); + + if (cpResult.success && cpResult.data) { + setCheckpoint(cpResult.data); + } else { + toast.error(cpResult.error || 'Failed to load checkpoint'); + } + + if (safetyRes.success && safetyRes.data) { + setSafetyResult(safetyRes.data); + } + + if (previewRes.success && previewRes.data) { + setPreviewResult(previewRes.data); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load data'); + } + }, [checkpointId, selectedMode, files]); + + // 初始加载 + useEffect(() => { + setLoading(true); + loadData().finally(() => setLoading(false)); + }, [loadData]); + + // 模式变化时重新预览 + useEffect(() => { + if (!loading) { + previewRestore(checkpointId, { mode: selectedMode, files }) + .then((result) => { + if (result.success && result.data) { + setPreviewResult(result.data); + } + }) + .catch(() => {}); + } + }, [checkpointId, selectedMode, files, loading]); + + // 执行恢复 + const handleRestore = async () => { + setRestoring(true); + try { + const result = await restoreCheckpoint(checkpointId, { + mode: selectedMode, + files, + skipSafetyCheck, + }); + + if (result.success && result.data) { + toast.success(`Restored ${result.data.restoredFiles.length} files`); + onRestored?.(result.data); + onClose(); + } else { + toast.error(result.error || 'Failed to restore checkpoint'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to restore'); + } finally { + setRestoring(false); + } + }; + + // 格式化时间 + const formatTime = (timestamp: number) => new Date(timestamp).toLocaleString(); + + // Loading 骨架屏 + const LoadingSkeleton = () => ( +
+ + + +
+ ); + + return ( + + + e.stopPropagation()} + className={cn( + 'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col', + responsive + ? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg' + : 'rounded-lg w-full max-w-lg mx-4' + )} + > + {/* Header */} +
+ {responsive && ( +
+ )} +
+

+ + Restore Checkpoint +

+ {checkpoint && ( +

+ {formatTime(checkpoint.timestamp)} +

+ )} +
+ +
+ + {/* Content */} +
+ {loading ? ( + + ) : ( + <> + {/* Safety Check Results */} + {safetyResult && ( +
+ {/* Errors */} + {safetyResult.errors.length > 0 && ( +
+
+ + Safety Errors +
+
    + {safetyResult.errors.map((error, i) => ( +
  • + + {error} +
  • + ))} +
+
+ )} + + {/* Warnings */} + {safetyResult.warnings.length > 0 && ( +
+
+ + Warnings +
+
    + {safetyResult.warnings.map((warning, i) => ( +
  • + + {warning} +
  • + ))} +
+
+ )} + + {/* Safe */} + {safetyResult.safe && safetyResult.errors.length === 0 && safetyResult.warnings.length === 0 && ( +
+
+ + Safety check passed +
+
+ )} +
+ )} + + {/* Restore Mode Selection */} + {!files && ( +
+ +
+ {RESTORE_MODES.map((mode) => ( + + ))} +
+
+ )} + + {/* Files to Restore */} + {previewResult && previewResult.restoredFiles.length > 0 && ( +
+ +
+ {previewResult.restoredFiles.map((file) => ( +
+ {file} +
+ ))} +
+
+ )} + + {/* Skip Safety Check Option */} + {safetyResult && !safetyResult.safe && ( + + )} + + )} +
+ + {/* Footer */} +
+ + +
+ + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e9eac6f..0ac3933 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -63,6 +63,23 @@ export { listPresetAgents, getAgentDefaults, updateAgentDefaults, + // Checkpoints API + listCheckpoints, + getCheckpointStats, + getLatestCheckpoint, + getCheckpoint, + createCheckpoint, + deleteCheckpoint, + getCheckpointDiff, + getFileDiff, + restoreCheckpoint, + previewRestore, + unrevert, + getUnrevertStatus, + checkSafety, + cleanupCheckpoints, + getSessionCheckpoints, + getMessageCheckpoints, } from './api/client.js'; // Types @@ -109,6 +126,21 @@ export type { AgentDetail, AgentInput, AgentDefaults, + // Checkpoint types + CheckpointTrigger, + FileChangeType, + CheckpointListItem, + CheckpointDetail, + FileChange, + DiffInfo, + FileDiffDetail, + RestoreMode, + RestoreOptions, + RestoreResult, + SafetyCheckResult, + CheckpointStats, + UnrevertStatus, + UnrevertResult, } from './api/client.js'; // Primitives (shadcn/ui style) @@ -130,6 +162,9 @@ export { HookEditor } from './components/HookEditor.js'; export { AgentsPanel } from './components/AgentsPanel.js'; export { AgentEditor } from './components/AgentEditor.js'; export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js'; +export { CheckpointPanel } from './components/CheckpointPanel.js'; +export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js'; +export { RestoreDialog } from './components/RestoreDialog.js'; export { Sidebar } from './components/Sidebar.js'; export { FileBrowser } from './components/FileBrowser.js'; export { ConfigPanel } from './components/ConfigPanel.js'; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index d07b1c7..0e9d371 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -13,6 +13,7 @@ import { MCPPanel, HooksPanel, AgentsPanel, + CheckpointPanel, Toaster, listSessions, createSession, @@ -29,6 +30,7 @@ export function App() { const [showMCP, setShowMCP] = useState(false); const [showHooks, setShowHooks] = useState(false); const [showAgents, setShowAgents] = useState(false); + const [showCheckpoints, setShowCheckpoints] = useState(false); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); // 初始化:加载或创建会话 @@ -117,6 +119,7 @@ export function App() { onOpenMCP={() => setShowMCP(true)} onOpenHooks={() => setShowHooks(true)} onOpenAgents={() => setShowAgents(true)} + onOpenCheckpoints={() => setShowCheckpoints(true)} /> ) : (
@@ -177,6 +180,9 @@ export function App() { {/* Agents 面板 */} {showAgents && setShowAgents(false)} responsive />} + {/* Checkpoints 面板 */} + {showCheckpoints && setShowCheckpoints(false)} responsive />} + {/* 移动端底部文件按钮 */}