using System.IO; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace AxCopilot.Services.Agent; /// /// 파일 시스템 체크포인트(스냅샷)를 생성/복원/삭제하는 도구. /// 작업 폴더의 텍스트 파일을 .ax/checkpoints/ 에 백업하여 undo/rollback을 지원합니다. /// public class CheckpointTool : IAgentTool { public string Name => "checkpoint"; public string Description => "Create, list, or restore file system checkpoints for undo/rollback. " + "Checkpoints capture text files in the working folder as snapshots. " + "- action=\"create\": Create a new checkpoint (name optional)\n" + "- action=\"list\": List all checkpoints\n" + "- action=\"restore\": Restore files from a checkpoint (id or name required, requires user approval)\n" + "- action=\"delete\": Delete a checkpoint (id required)"; public ToolParameterSchema Parameters => new() { Properties = new() { ["action"] = new() { Type = "string", Description = "create | list | restore | delete", Enum = ["create", "list", "restore", "delete"], }, ["name"] = new() { Type = "string", Description = "Checkpoint name (for create/restore)", }, ["id"] = new() { Type = "integer", Description = "Checkpoint ID (for restore/delete)", }, }, Required = ["action"], }; private const int MaxCheckpoints = 10; private const long MaxFileSize = 5 * 1024 * 1024; // 5MB private static readonly HashSet SkipDirs = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "node_modules", ".git", ".vs", ".ax", "packages", "Debug", "Release", "TestResults", ".idea", "__pycache__", }; private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) { ".cs", ".py", ".js", ".ts", ".java", ".cpp", ".c", ".h", ".hpp", ".xml", ".json", ".yaml", ".yml", ".md", ".txt", ".csv", ".html", ".htm", ".css", ".sql", ".sh", ".bat", ".ps1", ".config", ".ini", ".xaml", ".csproj", ".sln", ".props", ".targets", ".editorconfig", ".gitignore", ".tsx", ".jsx", ".vue", ".svelte", ".scss", ".less", ".toml", ".env", ".razor", ".proto", ".graphql", ".rs", ".go", ".rb", ".php", ".swift", }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) { if (!args.TryGetProperty("action", out var actionEl)) return ToolResult.Fail("action이 필요합니다."); var action = actionEl.GetString() ?? ""; if (string.IsNullOrEmpty(context.WorkFolder)) return ToolResult.Fail("작업 폴더가 설정되지 않았습니다."); var checkpointDir = Path.Combine(context.WorkFolder, ".ax", "checkpoints"); return action switch { "create" => await CreateCheckpoint(args, context, checkpointDir, ct), "list" => ListCheckpoints(checkpointDir), "restore" => await RestoreCheckpoint(args, context, checkpointDir, ct), "delete" => DeleteCheckpoint(args, checkpointDir), _ => ToolResult.Fail($"알 수 없는 액션: {action}. create | list | restore | delete 중 선택하세요."), }; } private async Task CreateCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct) { var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "unnamed" : "unnamed"; // 이름에서 파일 시스템 비안전 문자 제거 name = string.Join("_", name.Split(Path.GetInvalidFileNameChars())); var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var folderName = $"{timestamp}_{name}"; var cpDir = Path.Combine(checkpointDir, folderName); try { Directory.CreateDirectory(cpDir); // 추적 대상 텍스트 파일 수집 var files = CollectTextFiles(context.WorkFolder); if (files.Count == 0) return ToolResult.Fail("체크포인트할 텍스트 파일이 없습니다."); var manifest = new CheckpointManifest { Name = name, CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), WorkFolder = context.WorkFolder, Files = [], }; foreach (var file in files) { ct.ThrowIfCancellationRequested(); var relativePath = Path.GetRelativePath(context.WorkFolder, file); var destPath = Path.Combine(cpDir, relativePath); var destDir = Path.GetDirectoryName(destPath); if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir); await CopyFileAsync(file, destPath, ct); // SHA256 해시 계산 var hash = await ComputeHashAsync(file, ct); manifest.Files.Add(new FileEntry { Path = relativePath, Hash = hash }); } // manifest.json 저장 var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }); await TextFileCodec.WriteAllTextAsync( Path.Combine(cpDir, "manifest.json"), manifestJson, TextFileCodec.Utf8NoBom, ct); // 최대 개수 초과 시 가장 오래된 체크포인트 삭제 CleanupOldCheckpoints(checkpointDir); return ToolResult.Ok($"체크포인트 생성 완료: {folderName}\n파일 수: {manifest.Files.Count}"); } catch (Exception ex) { return ToolResult.Fail($"체크포인트 생성 오류: {ex.Message}"); } } private static ToolResult ListCheckpoints(string checkpointDir) { if (!Directory.Exists(checkpointDir)) return ToolResult.Ok("저장된 체크포인트가 없습니다."); var dirs = Directory.GetDirectories(checkpointDir) .OrderByDescending(d => d) .ToList(); if (dirs.Count == 0) return ToolResult.Ok("저장된 체크포인트가 없습니다."); var sb = new StringBuilder(); sb.AppendLine($"체크포인트 {dirs.Count}개:"); for (var i = 0; i < dirs.Count; i++) { var dirName = Path.GetFileName(dirs[i]); var manifestPath = Path.Combine(dirs[i], "manifest.json"); var fileCount = 0; var createdAt = ""; if (File.Exists(manifestPath)) { try { var json = TextFileCodec.ReadAllText(manifestPath).Text; var manifest = JsonSerializer.Deserialize(json); fileCount = manifest?.Files.Count ?? 0; createdAt = manifest?.CreatedAt ?? ""; } catch { /* manifest 읽기 실패 무시 */ } } sb.AppendLine($" [{i}] {dirName} — 파일 {fileCount}개{(string.IsNullOrEmpty(createdAt) ? "" : $", {createdAt}")}"); } return ToolResult.Ok(sb.ToString()); } private async Task RestoreCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct) { if (!Directory.Exists(checkpointDir)) return ToolResult.Fail("저장된 체크포인트가 없습니다."); var dirs = Directory.GetDirectories(checkpointDir) .OrderByDescending(d => d) .ToList(); if (dirs.Count == 0) return ToolResult.Fail("저장된 체크포인트가 없습니다."); // ID 또는 이름으로 체크포인트 찾기 string? targetDir = null; if (args.TryGetProperty("id", out var idEl)) { var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1; if (id >= 0 && id < dirs.Count) targetDir = dirs[id]; } if (targetDir == null && args.TryGetProperty("name", out var nameEl)) { var name = nameEl.GetString() ?? ""; targetDir = dirs.FirstOrDefault(d => Path.GetFileName(d).Contains(name, StringComparison.OrdinalIgnoreCase)); } if (targetDir == null) return ToolResult.Fail("체크포인트를 찾을 수 없습니다. id 또는 name을 확인하세요."); // 사용자 승인 요청 if (context.AskPermission != null) { var approved = await context.AskPermission("checkpoint_restore", $"체크포인트 복원: {Path.GetFileName(targetDir)}"); if (!approved) return ToolResult.Ok("사용자가 복원을 거부했습니다."); } try { var manifestPath = Path.Combine(targetDir, "manifest.json"); if (!File.Exists(manifestPath)) return ToolResult.Fail("체크포인트 매니페스트를 찾을 수 없습니다."); var manifestJson = (await TextFileCodec.ReadAllTextAsync(manifestPath, ct)).Text; var manifest = JsonSerializer.Deserialize(manifestJson); if (manifest == null) return ToolResult.Fail("매니페스트 파싱 오류"); var restoredCount = 0; foreach (var entry in manifest.Files) { ct.ThrowIfCancellationRequested(); var srcPath = Path.Combine(targetDir, entry.Path); var destPath = Path.Combine(context.WorkFolder, entry.Path); if (!File.Exists(srcPath)) continue; var destDir = Path.GetDirectoryName(destPath); if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir); await CopyFileAsync(srcPath, destPath, ct); restoredCount++; } return ToolResult.Ok($"체크포인트 복원 완료: {Path.GetFileName(targetDir)}\n복원 파일 수: {restoredCount}/{manifest.Files.Count}"); } catch (Exception ex) { return ToolResult.Fail($"체크포인트 복원 오류: {ex.Message}"); } } private static ToolResult DeleteCheckpoint(JsonElement args, string checkpointDir) { if (!Directory.Exists(checkpointDir)) return ToolResult.Fail("저장된 체크포인트가 없습니다."); var dirs = Directory.GetDirectories(checkpointDir) .OrderByDescending(d => d) .ToList(); if (!args.TryGetProperty("id", out var idEl)) return ToolResult.Fail("삭제할 체크포인트 id가 필요합니다."); var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1; if (id < 0 || id >= dirs.Count) return ToolResult.Fail($"잘못된 체크포인트 ID: {id}. 0~{dirs.Count - 1} 범위를 사용하세요."); try { var target = dirs[id]; var name = Path.GetFileName(target); Directory.Delete(target, recursive: true); return ToolResult.Ok($"체크포인트 삭제됨: {name}"); } catch (Exception ex) { return ToolResult.Fail($"체크포인트 삭제 오류: {ex.Message}"); } } #region Helpers private List CollectTextFiles(string workFolder) { var files = new List(); CollectFilesRecursive(workFolder, workFolder, files); return files; } private void CollectFilesRecursive(string dir, string rootDir, List files) { var dirName = Path.GetFileName(dir); if (dir != rootDir && SkipDirs.Contains(dirName)) return; try { foreach (var file in Directory.GetFiles(dir)) { var ext = Path.GetExtension(file); if (!TextExtensions.Contains(ext)) continue; var fi = new FileInfo(file); if (fi.Length > MaxFileSize) continue; files.Add(file); } foreach (var subDir in Directory.GetDirectories(dir)) CollectFilesRecursive(subDir, rootDir, files); } catch (UnauthorizedAccessException) { /* 접근 불가 디렉토리 무시 */ } } private static async Task CopyFileAsync(string src, string dest, CancellationToken ct) { var buffer = new byte[81920]; await using var srcStream = new FileStream(src, FileMode.Open, FileAccess.Read, FileShare.Read, buffer.Length, useAsync: true); await using var destStream = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, buffer.Length, useAsync: true); int bytesRead; while ((bytesRead = await srcStream.ReadAsync(buffer, ct)) > 0) await destStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct); } private static async Task ComputeHashAsync(string filePath, CancellationToken ct) { using var sha256 = SHA256.Create(); await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); var hash = await sha256.ComputeHashAsync(stream, ct); return Convert.ToHexString(hash).ToLowerInvariant(); } private static void CleanupOldCheckpoints(string checkpointDir) { if (!Directory.Exists(checkpointDir)) return; var dirs = Directory.GetDirectories(checkpointDir) .OrderBy(d => d) .ToList(); while (dirs.Count > MaxCheckpoints) { try { Directory.Delete(dirs[0], recursive: true); dirs.RemoveAt(0); } catch { break; } } } #endregion #region Models private class CheckpointManifest { public string Name { get; set; } = ""; public string CreatedAt { get; set; } = ""; public string WorkFolder { get; set; } = ""; public List Files { get; set; } = []; } private class FileEntry { public string Path { get; set; } = ""; public string Hash { get; set; } = ""; } #endregion }