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>
This commit is contained in:
Richard
2026-10-02 11:44:50 +02:00
co-authored by Claude Opus 5.5
parent 0dd8a761b8
commit 095722ae9c
11 changed files with 761 additions and 13 deletions
@@ -83,22 +83,72 @@ public sealed class SqliteAuditRepository : IAuditRepository
public async Task<IReadOnlyList<AuditEntry>> ListForRunAsync(string runId, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog WHERE RunId = @runId ORDER BY Id";
cmd.Parameters.AddWithValue("@runId", runId);
return await ReadEntriesAsync(cmd, ct);
await using (var conn = await _storage.OpenConnectionAsync(ct))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog WHERE RunId = @runId ORDER BY Id";
cmd.Parameters.AddWithValue("@runId", runId);
var hot = await ReadEntriesAsync(cmd, ct);
if (hot.Count > 0)
return hot;
}
// #700: an older run moved to the archive — it is still evidence and still found.
foreach (var archive in ArchiveConnections())
await using (archive)
{
using var cmd = archive.CreateCommand();
cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog WHERE RunId = @runId ORDER BY Id";
cmd.Parameters.AddWithValue("@runId", runId);
var found = await ReadEntriesAsync(cmd, ct);
if (found.Count > 0)
return found;
}
return [];
}
public async Task<RunReceipt?> GetReceiptForRunAsync(string runId, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE RunId = @runId ORDER BY Id DESC LIMIT 1";
cmd.Parameters.AddWithValue("@runId", runId);
await using (var conn = await _storage.OpenConnectionAsync(ct))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE RunId = @runId ORDER BY Id DESC LIMIT 1";
cmd.Parameters.AddWithValue("@runId", runId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
return ReadReceipt(reader);
}
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct) ? ReadReceipt(reader) : null;
foreach (var archive in ArchiveConnections())
await using (archive)
{
using var cmd = archive.CreateCommand();
cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE RunId = @runId ORDER BY Id DESC LIMIT 1";
cmd.Parameters.AddWithValue("@runId", runId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
return ReadReceipt(reader);
}
return null;
}
/// <summary>
/// #700: The month archives, newest first, opened read-only one at a time. A lookup by run
/// is rare and asks for one run — opening a few small files for it is cheap.
/// </summary>
private IEnumerable<SqliteConnection> ArchiveConnections()
{
foreach (var file in AuditArchive.FilesIn(AuditArchive.ArchiveDirectoryOf(_storage)))
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(AuditArchive.ArchiveDirectoryOf(_storage), file.FileName),
Mode = SqliteOpenMode.ReadOnly,
Pooling = false
}.ToString());
connection.Open();
yield return connection;
}
}
public async Task<IReadOnlyList<RunReceipt>> ListReceiptsAsync(DateTime fromUtc, DateTime toUtc, int limit, CancellationToken ct)