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 { }
}