feat(checkpoint): 添加 Checkpoint 可视化管理功能
Core 层增强:
- 添加 safety.ts: 7点安全检查机制
- 添加 session-tracker.ts: 会话级检查点跟踪
- 添加 lock.ts: 并发控制文件锁
- 添加 lfs.ts: Git LFS 大文件支持
- 添加 path-validator.ts: 路径验证
- 添加 commit-message.ts: 智能提交消息生成
- 增强 manager.ts: 支持三种恢复模式、unrevert 撤销回滚
Server 层:
- 添加 checkpoints.ts: 16个 REST API 端点
- GET/POST /checkpoints: 列表/创建检查点
- GET/DELETE /checkpoints/🆔 获取/删除检查点
- GET /checkpoints/:id/diff: 获取差异
- POST /checkpoints/:id/restore: 恢复到检查点
- POST /checkpoints/unrevert: 撤销回滚
- GET /checkpoints/:id/safety-check: 安全检查
UI 层:
- 添加 CheckpointPanel.tsx: 检查点列表面板
- 添加 CheckpointDiffViewer.tsx: 差异查看器
- 添加 RestoreDialog.tsx: 恢复确认对话框
- 添加 16 个 API 客户端函数
- 添加完整的 TypeScript 类型定义
Web/Desktop 集成:
- 添加 History 按钮到工具栏
- 集成 CheckpointPanel 组件
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 智能提交消息生成模块
|
||||
* 参考 Aider 的 AI 提交消息生成
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import type { CheckpointTrigger, FileChange } from './types.js';
|
||||
|
||||
/**
|
||||
* 工具调用信息
|
||||
*/
|
||||
interface ToolCallInfo {
|
||||
tool: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交消息生成器
|
||||
* 生成人类可读的检查点提交消息
|
||||
*/
|
||||
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<CheckpointTrigger, string> = {
|
||||
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<string, string>;
|
||||
} {
|
||||
const lines = message.split('\n');
|
||||
const firstLine = lines[0] || '';
|
||||
|
||||
// 解析第一行: prefix: description
|
||||
const [prefix, ...descParts] = firstLine.split(':');
|
||||
const description = descParts.join(':').trim();
|
||||
|
||||
// 解析元数据
|
||||
const metadata: Record<string, string> = {};
|
||||
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();
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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<LockOptions> = {
|
||||
stale: 30000, // 30 秒过期
|
||||
retries: 10,
|
||||
retryDelay: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查点锁管理器
|
||||
* 防止多个实例同时操作同一工作区的检查点
|
||||
*/
|
||||
export class CheckpointLock {
|
||||
private lockPath: string;
|
||||
private locked = false;
|
||||
private options: Required<LockOptions>;
|
||||
|
||||
constructor(shadowGitDir: string, options: LockOptions = {}) {
|
||||
this.lockPath = path.join(shadowGitDir, '.checkpoint.lock');
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取锁
|
||||
*/
|
||||
async acquire(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await fs.unlink(this.lockPath);
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查锁是否过期
|
||||
*/
|
||||
private async isLockStale(): Promise<boolean> {
|
||||
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<LockInfo | null> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 带锁执行操作
|
||||
*/
|
||||
async withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await this.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已锁定
|
||||
*/
|
||||
isLocked(): boolean {
|
||||
return this.locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查锁文件是否存在
|
||||
*/
|
||||
async lockExists(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.lockPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取锁持有者信息
|
||||
*/
|
||||
async getLockHolder(): Promise<LockInfo | null> {
|
||||
return this.readLockInfo();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建检查点锁实例
|
||||
*/
|
||||
export function createCheckpointLock(
|
||||
shadowGitDir: string,
|
||||
options?: LockOptions
|
||||
): CheckpointLock {
|
||||
return new CheckpointLock(shadowGitDir, options);
|
||||
}
|
||||
@@ -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<CheckpointEventListener> = 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<string, string[]> = 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<string, unknown> };
|
||||
messageId?: string;
|
||||
sessionId?: string;
|
||||
turnIndex?: number;
|
||||
}): Promise<CheckpointMetadata> {
|
||||
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<UnrevertResult> {
|
||||
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<SafetyCheckResult> {
|
||||
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<CheckpointMetadata> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<CheckpointMetadata[]> {
|
||||
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<CheckpointMetadata> {
|
||||
return this.createCheckpoint({
|
||||
trigger: options?.trigger || 'auto',
|
||||
description: options?.description || `Message checkpoint: ${messageId}`,
|
||||
messageId,
|
||||
sessionId: this.currentSessionId || undefined,
|
||||
turnIndex,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取与消息关联的检查点
|
||||
*/
|
||||
async getMessageCheckpoints(messageId: string): Promise<CheckpointMetadata[]> {
|
||||
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<RollbackResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 全局检查点管理器实例
|
||||
|
||||
@@ -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<boolean> {
|
||||
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();
|
||||
}
|
||||
@@ -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<SafetyCheckResult> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
@@ -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<string> {
|
||||
// 创建会话开始检查点
|
||||
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<void> {
|
||||
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<CheckpointMetadata[]> {
|
||||
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<RollbackResult> {
|
||||
if (!this.currentSession) {
|
||||
throw new Error('No active session');
|
||||
}
|
||||
|
||||
return this.checkpointManager.rollback({
|
||||
target: this.currentSession.startCheckpoint,
|
||||
skipSafetyCheck: true, // 会话级撤销跳过安全检查
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚到会话中的指定检查点
|
||||
*/
|
||||
async rollbackToCheckpoint(checkpointId: string): Promise<RollbackResult> {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 管理器实例
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
@@ -136,6 +139,9 @@ export function App() {
|
||||
{/* Agents 面板 */}
|
||||
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} />}
|
||||
|
||||
{/* Checkpoints 面板 */}
|
||||
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} />}
|
||||
|
||||
{/* Toast 通知 */}
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<ConnectionStatus />
|
||||
|
||||
{/* 工具栏按钮 */}
|
||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents) && (
|
||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints) && (
|
||||
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||
{/* Checkpoints 按钮 */}
|
||||
{onOpenCheckpoints && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={onOpenCheckpoints}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||
title="Checkpoints"
|
||||
>
|
||||
<History size={20} />
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* Agents 按钮 */}
|
||||
{onOpenAgents && (
|
||||
<motion.button
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { createBunWebSocket } from 'hono/bun';
|
||||
|
||||
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter } from './routes/index.js';
|
||||
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter } from './routes/index.js';
|
||||
import {
|
||||
handleWebSocket,
|
||||
handleWebSocketMessage,
|
||||
@@ -86,6 +86,7 @@ api.route('/commands', commandsRouter);
|
||||
api.route('/mcp', mcpRouter);
|
||||
api.route('/hooks', hooksRouter);
|
||||
api.route('/agents', agentsRouter);
|
||||
api.route('/checkpoints', checkpointsRouter);
|
||||
|
||||
// SSE 事件流
|
||||
api.get('/sessions/:id/events', handleSSE);
|
||||
|
||||
@@ -0,0 +1,902 @@
|
||||
/**
|
||||
* Checkpoints API Routes
|
||||
*
|
||||
* 提供 Checkpoint 管理的 REST API
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { getConfig } from './config.js';
|
||||
|
||||
// Core Checkpoint 模块类型
|
||||
interface CheckpointModule {
|
||||
getCheckpointManager: () => CheckpointManager;
|
||||
initCheckpointManager: (
|
||||
workDir: string,
|
||||
config?: Partial<CheckpointConfig>
|
||||
) => Promise<CheckpointManager>;
|
||||
RestoreMode: typeof RestoreMode;
|
||||
}
|
||||
|
||||
interface CheckpointManager {
|
||||
initialize(): Promise<void>;
|
||||
isEnabled(): boolean;
|
||||
getConfig(): CheckpointConfig;
|
||||
listCheckpoints(): Promise<CheckpointMetadata[]>;
|
||||
getCheckpoint(idOrHash: string): Promise<CheckpointMetadata | null>;
|
||||
getLatestCheckpoint(): Promise<CheckpointMetadata | null>;
|
||||
createCheckpoint(options: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
trigger?: CheckpointTrigger;
|
||||
}): Promise<CheckpointMetadata>;
|
||||
deleteCheckpoint(id: string): Promise<boolean>;
|
||||
getDiff(checkpointId: string): Promise<DiffInfo>;
|
||||
getFileDiff(checkpointId: string, filePath: string): Promise<FileDiff>;
|
||||
rollback(options: RollbackOptions): Promise<RollbackResult>;
|
||||
checkSafety(checkpointId: string): Promise<SafetyCheckResult>;
|
||||
unrevert(): Promise<UnrevertResult>;
|
||||
canUnrevert(): boolean;
|
||||
getLastRollback(): RollbackRecord | null;
|
||||
cleanup(): Promise<number>;
|
||||
getStats(): Promise<CheckpointStats>;
|
||||
getSessionCheckpoints(sessionId: string): Promise<CheckpointMetadata[]>;
|
||||
getMessageCheckpoints(messageId: string): Promise<CheckpointMetadata[]>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<CheckpointModule | null> {
|
||||
if (checkpointModule && managerInitialized) return checkpointModule;
|
||||
|
||||
try {
|
||||
const corePath = '@ai-assistant/core';
|
||||
const core = (await import(corePath)) as Record<string, unknown>;
|
||||
|
||||
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<CheckpointConfig>
|
||||
) => Promise<CheckpointManager>,
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
/** 对话轮次 */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<CheckpointDetail | null>(null);
|
||||
const [diff, setDiff] = useState<DiffInfo | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
const [fileDiff, setFileDiff] = useState<FileDiffDetail | null>(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 = () => (
|
||||
<div className="space-y-3 p-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-2 bg-gray-900/50 rounded">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-16 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染 diff 补丁
|
||||
const renderPatch = (patch: string) => {
|
||||
const lines = patch.split('\n');
|
||||
return (
|
||||
<pre className="text-xs font-mono overflow-x-auto">
|
||||
{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 (
|
||||
<div key={index} className={className}>
|
||||
{line || ' '}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
variants={modalOverlay}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/50 flex z-50',
|
||||
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalContent}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={(e) => 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 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-gray-700',
|
||||
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||
)}
|
||||
>
|
||||
{responsive && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Eye size={20} className="text-primary-400" />
|
||||
Checkpoint Diff
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{checkpoint ? (
|
||||
<>
|
||||
Comparing <code className="bg-gray-700 px-1 rounded">{checkpoint.commitHash.slice(0, 7)}</code> → Current
|
||||
</>
|
||||
) : (
|
||||
'Loading...'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh"
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{diff && (
|
||||
<div className="px-4 py-3 bg-gray-900/50 border-b border-gray-700">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-green-400">+{diff.totalInsertions}</span>
|
||||
<span className="text-red-400">-{diff.totalDeletions}</span>
|
||||
<span className="text-gray-400">across {diff.files.length} files</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="text-xs text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
{selectedFiles.size === diff.files.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : !diff || diff.files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<Eye size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-center">No changes detected</p>
|
||||
<p className="text-xs text-gray-600 mt-2 text-center">
|
||||
The workspace matches this checkpoint
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="divide-y divide-gray-700/50"
|
||||
>
|
||||
{diff.files.map((file) => {
|
||||
const isSelected = selectedFiles.has(file.path);
|
||||
const isExpanded = expandedFile === file.path;
|
||||
|
||||
return (
|
||||
<div key={file.path}>
|
||||
{/* File Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2',
|
||||
'hover:bg-gray-900/50 transition-colors cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggleFileSelection(file.path)}
|
||||
className={cn(
|
||||
'w-4 h-4 rounded border transition-colors flex items-center justify-center',
|
||||
isSelected
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-gray-600 hover:border-gray-500'
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check size={12} className="text-white" />}
|
||||
</button>
|
||||
|
||||
{/* Expand Icon */}
|
||||
<button
|
||||
onClick={() => handleViewFileDiff(file.path)}
|
||||
className="text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
|
||||
{/* Change Type */}
|
||||
<span
|
||||
className={cn(
|
||||
'w-5 h-5 rounded text-xs font-bold flex items-center justify-center',
|
||||
getChangeColor(file.type)
|
||||
)}
|
||||
>
|
||||
{getChangeLabel(file.type)}
|
||||
</span>
|
||||
|
||||
{/* File Path */}
|
||||
<span
|
||||
className="flex-1 text-sm font-mono truncate text-gray-300"
|
||||
onClick={() => handleViewFileDiff(file.path)}
|
||||
>
|
||||
{file.path}
|
||||
</span>
|
||||
|
||||
{/* Stats */}
|
||||
{(file.insertions !== undefined || file.deletions !== undefined) && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{file.insertions !== undefined && (
|
||||
<span className="text-green-400 mr-2">+{file.insertions}</span>
|
||||
)}
|
||||
{file.deletions !== undefined && (
|
||||
<span className="text-red-400">-{file.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Diff Content */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden bg-gray-900/30"
|
||||
>
|
||||
{loadingFileDiff ? (
|
||||
<div className="p-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : fileDiff?.patch ? (
|
||||
<div className="max-h-64 overflow-auto border-t border-gray-700/50">
|
||||
{renderPatch(fileDiff.patch)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
No diff content available
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{diff && diff.files.length > 0 && (onRestoreSelected || onRestoreAll) && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-t border-gray-700',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedFiles.size} of {diff.files.length} files selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onRestoreSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestoreSelected}
|
||||
disabled={selectedFiles.size === 0}
|
||||
className="text-green-400 border-green-400/50 hover:border-green-400"
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1" />
|
||||
Restore Selected ({selectedFiles.size})
|
||||
</Button>
|
||||
)}
|
||||
{onRestoreAll && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleRestoreAll}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1" />
|
||||
Restore All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -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<CheckpointListItem[]>([]);
|
||||
const [stats, setStats] = useState<CheckpointStats | null>(null);
|
||||
const [unrevertStatus, setUnrevertStatus] = useState<UnrevertStatus | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Today']));
|
||||
|
||||
// UI 状态
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(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 = () => (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg">
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
variants={modalOverlay}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/50 flex z-50',
|
||||
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalContent}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={(e) => 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 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-gray-700',
|
||||
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||
)}
|
||||
>
|
||||
{responsive && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<History size={20} className="text-primary-400" />
|
||||
Checkpoints
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats ? `${stats.count} checkpoints` : 'Loading...'}
|
||||
{stats?.oldestTimestamp && (
|
||||
<> · Oldest: {formatTime(stats.oldestTimestamp)}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
title="Create checkpoint"
|
||||
className={cn('text-green-400 hover:text-green-300', responsive && 'min-h-[44px]')}
|
||||
>
|
||||
{creating ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-green-500" />
|
||||
) : (
|
||||
<>
|
||||
<Plus size={16} className="mr-1" />
|
||||
Create
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh"
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unrevert Banner */}
|
||||
{unrevertStatus?.canUnrevert && unrevertStatus.lastRollback && (
|
||||
<div className="mx-4 mt-4 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-yellow-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm">
|
||||
Last rollback affected {unrevertStatus.lastRollback.restoredFiles.length} files
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUnrevert}
|
||||
disabled={actionLoading === 'unrevert'}
|
||||
className="text-yellow-400 hover:text-yellow-300"
|
||||
>
|
||||
{actionLoading === 'unrevert' ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-yellow-500" />
|
||||
) : (
|
||||
<>
|
||||
<Undo2 size={14} className="mr-1" />
|
||||
Unrevert
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkpoint List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : checkpoints.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<History size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-center">No checkpoints yet</p>
|
||||
<p className="text-xs text-gray-600 mt-2 text-center max-w-xs">
|
||||
Checkpoints are created automatically when files are modified
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
className="mt-4"
|
||||
disabled={creating}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
Create First Checkpoint
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.label);
|
||||
|
||||
return (
|
||||
<div key={group.label} className="space-y-1">
|
||||
{/* Group Header */}
|
||||
<button
|
||||
className="flex items-center gap-2 text-sm text-gray-400 hover:text-gray-300 transition-colors w-full"
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span className="font-medium">{group.label}</span>
|
||||
<span className="text-xs text-gray-500">({group.items.length})</span>
|
||||
</button>
|
||||
|
||||
{/* Group Items */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-1 overflow-hidden"
|
||||
>
|
||||
{group.items.map((cp) => {
|
||||
const triggerInfo = getTriggerInfo(cp.trigger);
|
||||
const isLoading = actionLoading === cp.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={cp.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="bg-gray-900/50 rounded-lg p-3 hover:bg-gray-900/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Trigger Icon */}
|
||||
<span className="text-lg" title={triggerInfo.label}>
|
||||
{triggerInfo.icon}
|
||||
</span>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-sm font-medium', triggerInfo.color)}>
|
||||
{triggerInfo.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatTime(cp.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{cp.description && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate">
|
||||
{cp.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText size={10} />
|
||||
{cp.filesChanged} files
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{formatFullTime(cp.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
|
||||
) : (
|
||||
<>
|
||||
{onViewDiff && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onViewDiff(cp.id)}
|
||||
title="View diff"
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{onRestore && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRestore(cp.id)}
|
||||
title="Restore"
|
||||
className="text-green-400 hover:text-green-300"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(cp.id)}
|
||||
title="Delete"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-t border-gray-700',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-gray-500">
|
||||
Auto-cleanup enabled (7 days / 100 max)
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCleanup}
|
||||
disabled={cleaningUp}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
>
|
||||
{cleaningUp ? (
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-t-2 border-b-2 border-gray-500 mr-1" />
|
||||
) : (
|
||||
<Trash2 size={12} className="mr-1" />
|
||||
)}
|
||||
Cleanup Now
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -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<CheckpointDetail | null>(null);
|
||||
const [safetyResult, setSafetyResult] = useState<SafetyCheckResult | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<RestoreResult | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [selectedMode, setSelectedMode] = useState<RestoreMode>('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 = () => (
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
variants={modalOverlay}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/50 flex z-50',
|
||||
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalContent}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={(e) => 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 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-gray-700',
|
||||
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||
)}
|
||||
>
|
||||
{responsive && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<RotateCcw size={20} className="text-primary-400" />
|
||||
Restore Checkpoint
|
||||
</h2>
|
||||
{checkpoint && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatTime(checkpoint.timestamp)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Safety Check Results */}
|
||||
{safetyResult && (
|
||||
<div className="space-y-2">
|
||||
{/* Errors */}
|
||||
{safetyResult.errors.length > 0 && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-400 mb-2">
|
||||
<AlertCircle size={16} />
|
||||
<span className="font-medium">Safety Errors</span>
|
||||
</div>
|
||||
<ul className="text-sm text-red-300 space-y-1">
|
||||
{safetyResult.errors.map((error, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-red-500">•</span>
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{safetyResult.warnings.length > 0 && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-yellow-400 mb-2">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="font-medium">Warnings</span>
|
||||
</div>
|
||||
<ul className="text-sm text-yellow-300 space-y-1">
|
||||
{safetyResult.warnings.map((warning, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-yellow-500">•</span>
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Safe */}
|
||||
{safetyResult.safe && safetyResult.errors.length === 0 && safetyResult.warnings.length === 0 && (
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle size={16} />
|
||||
<span className="font-medium">Safety check passed</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Mode Selection */}
|
||||
{!files && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300">Restore Mode</label>
|
||||
<div className="space-y-2">
|
||||
{RESTORE_MODES.map((mode) => (
|
||||
<label
|
||||
key={mode.value}
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-colors',
|
||||
selectedMode === mode.value
|
||||
? 'bg-primary-500/10 border border-primary-500/50'
|
||||
: 'bg-gray-900/50 border border-gray-700 hover:border-gray-600'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="restoreMode"
|
||||
value={mode.value}
|
||||
checked={selectedMode === mode.value}
|
||||
onChange={(e) => setSelectedMode(e.target.value as RestoreMode)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-200">{mode.label}</div>
|
||||
<div className="text-xs text-gray-500">{mode.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files to Restore */}
|
||||
{previewResult && previewResult.restoredFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
|
||||
<FileText size={14} />
|
||||
Files to restore ({previewResult.restoredFiles.length})
|
||||
</label>
|
||||
<div className="max-h-40 overflow-y-auto bg-gray-900/50 rounded-lg p-2 space-y-1">
|
||||
{previewResult.restoredFiles.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="text-xs font-mono text-gray-400 px-2 py-1 hover:bg-gray-800 rounded"
|
||||
>
|
||||
{file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Safety Check Option */}
|
||||
{safetyResult && !safetyResult.safe && (
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipSafetyCheck}
|
||||
onChange={(e) => setSkipSafetyCheck(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Skip safety check and proceed anyway
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-3 border-t border-gray-700',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleRestore}
|
||||
disabled={
|
||||
loading ||
|
||||
restoring ||
|
||||
(safetyResult && !safetyResult.safe && !skipSafetyCheck) ||
|
||||
!previewResult ||
|
||||
previewResult.restoredFiles.length === 0
|
||||
}
|
||||
>
|
||||
{restoring ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
Restoring...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw size={16} className="mr-2" />
|
||||
Restore Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
@@ -177,6 +180,9 @@ export function App() {
|
||||
{/* Agents 面板 */}
|
||||
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} responsive />}
|
||||
|
||||
{/* Checkpoints 面板 */}
|
||||
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} responsive />}
|
||||
|
||||
{/* 移动端底部文件按钮 */}
|
||||
<button
|
||||
onClick={() => setShowFileBrowser(true)}
|
||||
|
||||
@@ -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,
|
||||
@@ -26,6 +26,7 @@ interface ChatPageProps {
|
||||
onOpenMCP?: () => void;
|
||||
onOpenHooks?: () => void;
|
||||
onOpenAgents?: () => void;
|
||||
onOpenCheckpoints?: () => void;
|
||||
}
|
||||
|
||||
export function ChatPage({
|
||||
@@ -40,6 +41,7 @@ export function ChatPage({
|
||||
onOpenMCP,
|
||||
onOpenHooks,
|
||||
onOpenAgents,
|
||||
onOpenCheckpoints,
|
||||
}: ChatPageProps) {
|
||||
const {
|
||||
messages,
|
||||
@@ -134,8 +136,21 @@ export function ChatPage({
|
||||
<ConnectionStatus />
|
||||
|
||||
{/* 工具栏按钮 */}
|
||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents) && (
|
||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints) && (
|
||||
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||
{/* Checkpoints 按钮 */}
|
||||
{onOpenCheckpoints && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={onOpenCheckpoints}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||
title="Checkpoints"
|
||||
>
|
||||
<History size={20} />
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* Agents 按钮 */}
|
||||
{onOpenAgents && (
|
||||
<motion.button
|
||||
|
||||
Reference in New Issue
Block a user