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 管理器实例
|
||||
|
||||
Reference in New Issue
Block a user