diff --git a/README.md b/README.md index 1d4a73d..db04781 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-14 19:16 (KST) +- 분석용 로그 저장 방식을 롤링 형태로 정리했습니다. `app`, `perf`, `audit`, `workflow` 로그는 이제 날짜별 파일을 유지하되 각 파일이 최대 1MB를 넘지 않도록 오래된 내용부터 밀어내며 새 로그를 이어 붙입니다. +- 보관 정책도 같이 정리했습니다. 공통 로그/성능 로그/감사 로그는 14일까지만 유지하고, 워크플로우 상세 로그는 기존 설정값을 따르되 최대 14일을 넘지 않게 제한합니다. +- 공통 유틸 [RollingTextLogStore.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/RollingTextLogStore.cs)와 회귀 테스트 [RollingTextLogStoreTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/RollingTextLogStoreTests.cs)도 추가했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_logroll\\ -p:IntermediateOutputPath=obj\\verify_logroll\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "RollingTextLogStoreTests" -p:OutputPath=bin\\verify_logroll_tests\\ -p:IntermediateOutputPath=obj\\verify_logroll_tests\\` 통과 3 +- 참고: 테스트 프로젝트의 기존 파일 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)` nullable 경고 1건은 유지됩니다. + - 업데이트: 2026-04-14 19:02 (KST) - 코워크/코드의 작업 폴더 선택 후 UI가 2~3초 멈추던 흐름을 점검해, 폴더 변경 직후 실행되던 스킬 소스 재탐색을 UI 스레드 밖으로 분리했습니다. 이제 작업 폴더 변경, 탭 전환, 대화 복원 시 필요한 스킬 재로드는 백그라운드에서 수행하고, 조건부 스킬 활성화만 UI에 다시 반영합니다. - 첨부 파일 추가/제거처럼 작업 폴더가 바뀌지 않는 경로는 기존 스킬 집합만 기준으로 조건부 스킬을 갱신하도록 분리해 불필요한 재탐색도 줄였습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index aadf08b..a55054f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -840,3 +840,12 @@ UI 디자인 대규모 리팩토링 등 위험 작업 전 기록한 안전 복 - 코워크/코드의 작업 폴더 선택 후 UI가 2~3초 멈추던 흐름을 점검해, 폴더 변경 직후 실행되던 스킬 소스 재탐색을 UI 스레드 밖으로 분리했습니다. 이제 작업 폴더 변경, 탭 전환, 대화 복원 시 필요한 스킬 재로드는 백그라운드에서 수행하고, 조건부 스킬 활성화만 UI에 다시 반영합니다. - 첨부 파일 추가/제거처럼 작업 폴더가 바뀌지 않는 경로는 기존 스킬 집합만 기준으로 조건부 스킬을 갱신하도록 분리해 불필요한 재탐색도 줄였습니다. - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_folderpick\\ -p:IntermediateOutputPath=obj\\verify_folderpick\\ 경고 0 / 오류 0 + +- 업데이트: 2026-04-14 19:16 (KST) +- 분석용 로그 저장 방식을 롤링 형태로 정리했습니다. app, perf, audit, workflow 로그는 날짜별 파일을 유지하되 각 파일이 최대 1MB를 넘지 않도록 오래된 내용부터 밀어내며 새 로그를 이어 붙입니다. +- 공통 유틸 RollingTextLogStore를 추가하고 LogService, AgentPerformanceLogService, AuditLogService, WorkflowLogService에 함께 적용했습니다. +- 공통 로그/성능 로그/감사 로그는 14일까지만 유지하고, 워크플로우 상세 로그는 기존 설정값을 따르되 최대 14일을 넘지 않도록 App 시작 시 상한을 적용했습니다. +- RollingTextLogStoreTests 3건을 추가해 파일 크기 상한 유지, 오래된 파일 삭제, 날짜 디렉터리 삭제 동작을 검증했습니다. +- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_logroll\\ -p:IntermediateOutputPath=obj\\verify_logroll\\ 경고 0 / 오류 0 +- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter RollingTextLogStoreTests -p:OutputPath=bin\\verify_logroll_tests\\ -p:IntermediateOutputPath=obj\\verify_logroll_tests\\ 통과 3 +- 참고: 테스트 프로젝트의 기존 nullable 경고 src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76) 1건은 유지됩니다. diff --git a/src/AxCopilot.Tests/Services/RollingTextLogStoreTests.cs b/src/AxCopilot.Tests/Services/RollingTextLogStoreTests.cs new file mode 100644 index 0000000..98cea2b --- /dev/null +++ b/src/AxCopilot.Tests/Services/RollingTextLogStoreTests.cs @@ -0,0 +1,96 @@ +using System.IO; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class RollingTextLogStoreTests +{ + [Fact] + public void AppendLine_ShouldKeepNewestContentWithinMaxBytes() + { + var root = CreateTempDirectory(); + try + { + var path = Path.Combine(root, "sample.log"); + for (var i = 0; i < 12; i++) + AxCopilot.Services.RollingTextLogStore.AppendLine(path, $"line-{i:00}-abcdefghijklmnopqrstuvwxyz", 120); + + var text = File.ReadAllText(path); + var bytes = new FileInfo(path).Length; + + bytes.Should().BeLessOrEqualTo(120); + text.Should().Contain("line-11"); + text.Should().NotContain("line-00"); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void PurgeOldFiles_ShouldDeleteFilesOutsideRetentionWindow() + { + var root = CreateTempDirectory(); + try + { + var oldFile = Path.Combine(root, "old.log"); + var newFile = Path.Combine(root, "new.log"); + File.WriteAllText(oldFile, "old"); + File.WriteAllText(newFile, "new"); + File.SetLastWriteTime(oldFile, DateTime.Now.AddDays(-20)); + File.SetLastWriteTime(newFile, DateTime.Now.AddDays(-1)); + + AxCopilot.Services.RollingTextLogStore.PurgeOldFiles(root, "*.log", 14); + + File.Exists(oldFile).Should().BeFalse(); + File.Exists(newFile).Should().BeTrue(); + } + finally + { + TryDelete(root); + } + } + + [Fact] + public void PurgeOldDirectoriesByDateName_ShouldDeleteOldDateDirectories() + { + var root = CreateTempDirectory(); + try + { + var oldDir = Path.Combine(root, DateTime.Now.AddDays(-16).ToString("yyyy-MM-dd")); + var newDir = Path.Combine(root, DateTime.Now.AddDays(-2).ToString("yyyy-MM-dd")); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(newDir); + + AxCopilot.Services.RollingTextLogStore.PurgeOldDirectoriesByDateName(root, 14); + + Directory.Exists(oldDir).Should().BeFalse(); + Directory.Exists(newDir).Should().BeTrue(); + } + finally + { + TryDelete(root); + } + } + + private static string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "AxCopilot.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + } + } +} diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index ecafa41..dfb489a 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -79,7 +79,7 @@ public partial class App : System.Windows.Application // ─── 워크플로우 상세 로그 초기화 ───────────────────────────────────── WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog; WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0 - ? settings.Settings.Llm.DetailedLogRetentionDays : 3; + ? Math.Min(settings.Settings.Llm.DetailedLogRetentionDays, 14) : 14; WorkflowLogService.IsRawLogEnabled = settings.Settings.Llm.EnableRawLlmLog; // ─── 대화 보관/디스크 정리 (제품화 하드닝) ─────────────────────────── diff --git a/src/AxCopilot/Services/AgentPerformanceLogService.cs b/src/AxCopilot/Services/AgentPerformanceLogService.cs index 19fffa2..f823a56 100644 --- a/src/AxCopilot/Services/AgentPerformanceLogService.cs +++ b/src/AxCopilot/Services/AgentPerformanceLogService.cs @@ -6,8 +6,11 @@ namespace AxCopilot.Services; public static class AgentPerformanceLogService { + private const int RetentionDays = 14; + private const long MaxLogFileBytes = 1L * 1024 * 1024; private static readonly string PerfDir; private static readonly object _lock = new(); + private static bool _purged; private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = false, @@ -48,8 +51,10 @@ public static class AgentPerformanceLogService lock (_lock) { - File.AppendAllText(filePath, json + Environment.NewLine, Encoding.UTF8); + RollingTextLogStore.AppendLine(filePath, json, MaxLogFileBytes, Encoding.UTF8); } + + if (!_purged) { _purged = true; PurgeOldLogs(); } } catch { @@ -66,6 +71,17 @@ public static class AgentPerformanceLogService } public static string GetPerformanceFolder() => PerfDir; + + public static void PurgeOldLogs() + { + try + { + RollingTextLogStore.PurgeOldFiles(PerfDir, "performance-*.json", RetentionDays); + } + catch + { + } + } } public sealed class AgentPerformanceEntry diff --git a/src/AxCopilot/Services/AuditLogService.cs b/src/AxCopilot/Services/AuditLogService.cs index 974a050..f675425 100644 --- a/src/AxCopilot/Services/AuditLogService.cs +++ b/src/AxCopilot/Services/AuditLogService.cs @@ -10,8 +10,11 @@ namespace AxCopilot.Services; /// public static class AuditLogService { + private const int RetentionDays = 14; + private const long MaxLogFileBytes = 1L * 1024 * 1024; private static readonly string AuditDir; private static readonly object _lock = new(); + private static bool _purged; private static readonly JsonSerializerOptions _jsonOpts = new() { WriteIndented = false, @@ -37,8 +40,10 @@ public static class AuditLogService lock (_lock) { - File.AppendAllText(filePath, json + "\n", Encoding.UTF8); + RollingTextLogStore.AppendLine(filePath, json, MaxLogFileBytes, Encoding.UTF8); } + + if (!_purged) { _purged = true; PurgeOldLogs(RetentionDays); } } catch { /* 감사 로그 실패는 무시 — 앱 동작에 영향 없음 */ } } @@ -123,16 +128,11 @@ public static class AuditLogService } /// 30일 이전 감사 로그를 삭제합니다. - public static void PurgeOldLogs(int retentionDays = 30) + public static void PurgeOldLogs(int retentionDays = RetentionDays) { try { - var cutoff = DateTime.Now.AddDays(-retentionDays); - foreach (var f in Directory.GetFiles(AuditDir, "*.json")) - { - if (File.GetLastWriteTime(f) < cutoff) - File.Delete(f); - } + RollingTextLogStore.PurgeOldFiles(AuditDir, "*.json", retentionDays); } catch { } } diff --git a/src/AxCopilot/Services/LogService.cs b/src/AxCopilot/Services/LogService.cs index db38a5b..77f7601 100644 --- a/src/AxCopilot/Services/LogService.cs +++ b/src/AxCopilot/Services/LogService.cs @@ -6,6 +6,8 @@ public enum LogLevel { Debug, Info, Warn, Error } public static class LogService { + private const int RetentionDays = 14; + private const long MaxLogFileBytes = 1L * 1024 * 1024; private static readonly string LogDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "logs"); @@ -14,8 +16,6 @@ public static class LogService Path.Combine(LogDir, $"app-{DateTime.Now:yyyy-MM-dd}.log"); private static readonly object _lock = new(); - - private const int RetentionDays = 14; // 14일 이상 지난 로그 파일 자동 삭제 private static bool _purged; public static LogLevel MinLevel { get; set; } = LogLevel.Info; @@ -33,7 +33,7 @@ public static class LogService Directory.CreateDirectory(LogDir); var line = $"[{DateTime.Now:HH:mm:ss.fff}] [{level,-5}] {msg}"; lock (_lock) - File.AppendAllText(LogFile, line + Environment.NewLine); + RollingTextLogStore.AppendLine(LogFile, line, MaxLogFileBytes); // 앱 세션 당 1회 오래된 로그 삭제 if (!_purged) { _purged = true; PurgeOldLogs(); } @@ -45,12 +45,7 @@ public static class LogService { try { - var cutoff = DateTime.Now.AddDays(-RetentionDays); - foreach (var file in Directory.GetFiles(LogDir, "app-*.log")) - { - if (File.GetCreationTime(file) < cutoff) - File.Delete(file); - } + RollingTextLogStore.PurgeOldFiles(LogDir, "app-*.log", RetentionDays); } catch { } } diff --git a/src/AxCopilot/Services/RollingTextLogStore.cs b/src/AxCopilot/Services/RollingTextLogStore.cs new file mode 100644 index 0000000..bff01fb --- /dev/null +++ b/src/AxCopilot/Services/RollingTextLogStore.cs @@ -0,0 +1,116 @@ +using System.IO; +using System.Text; + +namespace AxCopilot.Services; + +public static class RollingTextLogStore +{ + public static void AppendLine(string filePath, string line, long maxBytes, Encoding? encoding = null) + { + encoding ??= new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var appendedBytes = encoding.GetBytes(line + Environment.NewLine); + if (appendedBytes.LongLength > maxBytes) + appendedBytes = TrimToLastBytes(appendedBytes, maxBytes); + + byte[] retainedBytes = []; + if (File.Exists(filePath)) + { + var existingBytes = File.ReadAllBytes(filePath); + var retainBudget = Math.Max(0, maxBytes - appendedBytes.LongLength); + retainedBytes = SliceLatestWholeLines(existingBytes, retainBudget); + } + + using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read); + if (retainedBytes.Length > 0) + stream.Write(retainedBytes, 0, retainedBytes.Length); + stream.Write(appendedBytes, 0, appendedBytes.Length); + } + + public static void PurgeOldFiles(string directory, string searchPattern, int retentionDays) + { + if (retentionDays <= 0 || !Directory.Exists(directory)) + return; + + var cutoff = DateTime.Now.Date.AddDays(-retentionDays); + foreach (var file in Directory.GetFiles(directory, searchPattern)) + { + try + { + if (File.GetLastWriteTime(file) < cutoff) + File.Delete(file); + } + catch + { + } + } + } + + public static void PurgeOldDirectoriesByDateName(string rootDirectory, int retentionDays, string format = "yyyy-MM-dd") + { + if (retentionDays <= 0 || !Directory.Exists(rootDirectory)) + return; + + var cutoff = DateTime.Now.Date.AddDays(-retentionDays); + foreach (var directory in Directory.GetDirectories(rootDirectory)) + { + try + { + var name = Path.GetFileName(directory); + if (!DateTime.TryParseExact( + name, + format, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var dirDate)) + { + continue; + } + + if (dirDate < cutoff) + Directory.Delete(directory, recursive: true); + } + catch + { + } + } + } + + private static byte[] SliceLatestWholeLines(byte[] source, long maxBytes) + { + if (source.Length == 0 || maxBytes <= 0) + return []; + + if (source.LongLength <= maxBytes) + return source; + + var start = (int)Math.Max(0, source.LongLength - maxBytes); + while (start < source.Length && source[start] != (byte)'\n') + start++; + while (start < source.Length && (source[start] == (byte)'\n' || source[start] == (byte)'\r')) + start++; + + if (start >= source.Length) + return []; + + var length = source.Length - start; + var result = new byte[length]; + Buffer.BlockCopy(source, start, result, 0, length); + return result; + } + + private static byte[] TrimToLastBytes(byte[] source, long maxBytes) + { + if (source.LongLength <= maxBytes) + return source; + + var start = (int)(source.LongLength - maxBytes); + var length = (int)Math.Min(maxBytes, source.LongLength); + var result = new byte[length]; + Buffer.BlockCopy(source, start, result, 0, length); + return result; + } +} diff --git a/src/AxCopilot/Services/WorkflowLogService.cs b/src/AxCopilot/Services/WorkflowLogService.cs index 2962065..630e3ef 100644 --- a/src/AxCopilot/Services/WorkflowLogService.cs +++ b/src/AxCopilot/Services/WorkflowLogService.cs @@ -14,6 +14,7 @@ namespace AxCopilot.Services; /// public static class WorkflowLogService { + private const long MaxLogFileBytes = 1L * 1024 * 1024; private static readonly string WorkflowDir; private static readonly object _lock = new(); private static bool _purged; @@ -37,7 +38,7 @@ public static class WorkflowLogService public static bool IsEnabled { get; set; } /// 보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다. - public static int RetentionDays { get; set; } = 3; + public static int RetentionDays { get; set; } = 14; // ─── LlmService 등 하위 계층에서 사용할 현재 컨텍스트 ─── // AgentLoopService가 LLM 호출 직전에 설정하고, 완료 후 리셋합니다. @@ -86,7 +87,7 @@ public static class WorkflowLogService lock (_lock) { - File.AppendAllText(filePath, json + "\n", Encoding.UTF8); + RollingTextLogStore.AppendLine(filePath, json, MaxLogFileBytes, Encoding.UTF8); } if (!_purged) { _purged = true; PurgeOldLogs(); } @@ -251,20 +252,7 @@ public static class WorkflowLogService { try { - var cutoff = DateTime.Now.Date.AddDays(-RetentionDays); - foreach (var dir in Directory.GetDirectories(WorkflowDir)) - { - var dirName = Path.GetFileName(dir); - if (DateTime.TryParseExact(dirName, "yyyy-MM-dd", - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out var dirDate)) - { - if (dirDate < cutoff) - { - try { Directory.Delete(dir, recursive: true); } catch { } - } - } - } + RollingTextLogStore.PurgeOldDirectoriesByDateName(WorkflowDir, RetentionDays); } catch { } }