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:
2025-12-12 22:52:27 +08:00
parent a225e66ad7
commit cb554c65b4
23 changed files with 4970 additions and 116 deletions
+2 -1
View File
@@ -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);
+902
View File
@@ -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
);
}
});
+1
View File
@@ -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';