Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Audit/AuditArchiveTests.cs
T
RichardandClaude Opus 5.5 095722ae9c Audit und Laufbelege: Tageswerte je Agent und Einheit, Monatsarchiv statt Loeschen (#700)
Nach der Vorgabe vom 2026-09-22 (so wenig loeschen wie moeglich): Laeufe
aelter als hotDays (Vorgabe 180, 0 = alles bleibt) wandern aus AuditLog und
RunReceipts in Archive/audit-JJJJ-MM.db - kopiert und entfernt in einer
Transaktion, Zeilen-IDs bleiben, spaeter dazugekommene Spalten werden im
Archiv ergaenzt. Vorher wird jeder Tag in RunDaily verdichtet (Laeufe,
Fehlschlaege, Schrittgrenze, Tokens, Kosten, Werkzeugaufrufe, Fehler,
Verweigert, Freigaben) mit der Einheit des Agenten; die Tageswerte bleiben.
Nachschlagen eines Laufs findet ihn auch im Archiv. API /usage/days,
/usage/archive; Karte 'Tageswerte' in der Verbrauchsansicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:44:50 +02:00

125 lines
6.0 KiB
C#

using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Audit;
/// <summary>
/// #700: Audit and receipts are evidence — nothing is deleted. Old runs move from the working
/// database into one archive file per month and are still found; each day is condensed per
/// agent and unit first, and those values stay.
/// </summary>
public sealed class AuditArchiveTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "arc-" + Guid.NewGuid().ToString("N")[..8]);
private readonly SqliteStorage _storage;
private readonly SqliteAuditRepository _audit;
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 10, 2, 9, 0, 0, TimeSpan.Zero));
private readonly AuditArchiveSettings _settings = new() { HotDays = 30 };
private readonly AuditArchive _archive;
public AuditArchiveTests()
{
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_audit = new SqliteAuditRepository(_storage);
_archive = new AuditArchive(_storage, () => _settings, agent => agent == "desk" ? "sales" : null, NullLogger.Instance, _clock);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private async Task Run(string runId, string agent, DateTime at, string status = "Completed", decimal cost = 0.02m, params string[] callStatus)
{
foreach (var s in callStatus)
await _audit.AppendAsync(new AuditEntry
{
RunId = runId, AgentId = agent, Tool = "FileRW", Arguments = "{\"action\":\"read\"}",
Status = Enum.Parse<AuditStatus>(s), OccurredAt = at
}, default);
await _audit.RecordReceiptAsync(new RunReceipt
{
RunId = runId, AgentId = agent, Status = status, CostUsd = cost, CostIsKnown = true,
PromptTokens = 1000, CompletionTokens = 100, OccurredAt = at
}, default);
}
[Fact]
public async Task Each_past_day_is_condensed_per_agent_with_its_unit_and_today_waits()
{
var yesterday = new DateTime(2026, 10, 1, 12, 0, 0, DateTimeKind.Utc);
await Run("r1", "desk", yesterday, callStatus: ["Ok", "Error", "Staged"]);
await Run("r2", "desk", yesterday.AddHours(1), "LoopLimitExceeded", 0.05m, "Ok");
await Run("r3", "desk", yesterday.AddHours(2), "Failed");
await Run("r4", "desk", yesterday.AddHours(3), "BudgetExceeded", 0m);
await Run("r5", "other", yesterday.AddDays(-1), callStatus: ["Denied"]);
await Run("r6", "desk", new DateTime(2026, 10, 2, 8, 0, 0, DateTimeKind.Utc));
await _archive.CondenseAsync(default);
var days = await _archive.DaysAsync(new DateOnly(2026, 9, 1), new DateOnly(2026, 10, 2), null, null, default);
days.Select(d => (d.Day, d.AgentId)).ShouldBe([("2026-09-30", "other"), ("2026-10-01", "desk")]);
var desk = days.Single(d => d.AgentId == "desk");
(desk.UnitId, desk.Runs, desk.Failed, desk.LoopLimit).ShouldBe(("sales", 3, 1, 1), "a budget refusal is no run");
desk.CostUsd.ShouldBe(0.09m);
(desk.ToolCalls, desk.ToolErrors, desk.Staged).ShouldBe((3, 1, 1));
days.Single(d => d.AgentId == "other").Denied.ShouldBe(1);
(await _archive.DaysAsync(new DateOnly(2026, 9, 1), new DateOnly(2026, 10, 2), null, "sales", default)).Count.ShouldBe(1);
}
[Fact]
public async Task Old_runs_move_to_their_month_archive_are_still_found_and_nothing_is_lost()
{
await Run("aug", "desk", new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc), callStatus: ["Started", "Ok"]);
await Run("jul", "desk", new DateTime(2026, 7, 31, 10, 0, 0, DateTimeKind.Utc), callStatus: ["Ok"]);
await Run("now", "desk", new DateTime(2026, 9, 28, 10, 0, 0, DateTimeKind.Utc), callStatus: ["Ok"]);
var moved = await _archive.ArchiveAsync(default);
moved.ShouldBe(5, "three audit rows and two receipts");
_archive.Files().Select(f => f.Month).ShouldBe(["2026-08", "2026-07"]);
(await _audit.CountAsync(default)).ShouldBe(1, "only the recent run stays in the working table");
(await _audit.ListForRunAsync("aug", default)).Count.ShouldBe(2);
(await _audit.GetReceiptForRunAsync("jul", default))!.AgentId.ShouldBe("desk");
(await _audit.GetReceiptForRunAsync("now", default)).ShouldNotBeNull();
(await _archive.DaysAsync(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), null, null, default))
.Select(d => d.Day).ShouldBe(["2026-07-31", "2026-08-20"], "the day values stay after the move");
(await _archive.ArchiveAsync(default)).ShouldBe(0, "a second pass moves nothing twice");
}
[Fact]
public async Task A_column_added_later_is_added_to_an_existing_archive()
{
await Run("a1", "desk", new DateTime(2026, 8, 1, 10, 0, 0, DateTimeKind.Utc));
await _archive.ArchiveAsync(default);
await _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "ALTER TABLE RunReceipts ADD COLUMN LaterField TEXT NULL";
await cmd.ExecuteNonQueryAsync();
}, default);
await Run("a2", "desk", new DateTime(2026, 8, 2, 10, 0, 0, DateTimeKind.Utc));
(await _archive.ArchiveAsync(default)).ShouldBe(1);
(await _audit.GetReceiptForRunAsync("a2", default)).ShouldNotBeNull();
}
[Fact]
public async Task Zero_days_keeps_everything_in_the_working_database()
{
_settings.HotDays = 0;
await Run("old", "desk", new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc), callStatus: ["Ok"]);
(await _archive.ArchiveAsync(default)).ShouldBe(0);
_archive.Files().ShouldBeEmpty();
(await _audit.CountAsync(default)).ShouldBe(1);
}
}