Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs
T
RichardandClaude Opus 5 b568a45de3 Wiederaufnahme nach Absturz, Nachschau und Auswertung (#553-#556)
#553 FileRW schreibt atomar. Die Vorkehrung lag seit #241 im selben
Repository und wurde ueberall benutzt - Einstellungen, Sicherungen,
Chat-Zustand, Identity.md - nur der Weg, ueber den Agenten ihre
Arbeitsergebnisse ablegen, ging daran vorbei. Damit stand das Verhaeltnis
auf dem Kopf: geschuetzt war das Eigene, ungeschuetzt das Ergebnis.
append bleibt bewusst nicht atomar; das ginge nur, indem die ganze Datei
neu geschrieben wird, und bei einer grossen Protokolldatei waere das der
falsche Handel.

#554 Wiederaufnahme. Neuer Eintrag AuditStatus.Started, geschrieben VOR
der Wirkung und als einziger nicht "best effort" - auf ihn stuetzt sich
alles Weitere, also laeuft der Aufruf gar nicht erst, wenn er nicht
durchkommt. Neue Kennzeichnung IsRepeatable, getrennt von IsParallelSafe:
Das eine heisst "stoert keinen gleichzeitigen Aufruf", das andere
"richtet zweimal keinen Schaden an". CrashRecovery sichtet beim Start,
was offen blieb, und stuft ein - fuehrt aber nichts aus. Es sichtet und
berichtet; entscheiden soll der Aufrufer.

Dabei kippte eine alte Regel: Bisher wurde ein Abbruch bewusst NICHT
protokolliert. Das war richtig, solange es keinen Eintrag vor dem Aufruf
gab. Jetzt saehe sonst jeder Benutzerabbruch beim naechsten Start aus wie
ein Absturz - bis niemand mehr hinsieht. Deshalb AuditStatus.Cancelled.

#555 Optionale Zustandspruefung IToolStateProbe, umgesetzt in FileRW.
Unknown ist eine vollwertige Antwort, kein Fehlschlag: Ob ein SMTP-Server
angenommen hat, laesst sich nicht feststellen, und ein Werkzeug, das hier
raet, waere schlimmer als eines, das schweigt. Partial gilt ausdruecklich
nicht als geklaert - einen halb angehaengten Text sollte niemand
automatisch geraderuecken.

#556 Auswertung. ListForAgentAsync und SummarizeToolUsageAsync. Die Dauer
wird nur ueber abgeschlossene Aufrufe gemittelt; zaehlten die
Started-Eintraege mit ihrer Dauer 0 mit, saehe jedes Werkzeug doppelt so
schnell aus, wie es ist.

Nebenbei: SqliteStorage kann jetzt Spalten nachruesten. CREATE TABLE IF
NOT EXISTS allein genuegte nicht - bei einer bestehenden Datenbank tut die
Anweisung nichts, und erst das naechste INSERT waere gescheitert, im
Betrieb beim Anwender.

Vier Audit-Tests angepasst: Ein ausgefuehrter Aufruf erzeugt jetzt zwei
Eintraege statt einem. Ein Aufruf, der gar nicht erst laeuft - verweigert,
unbekannt, vorgelegt - hat weiterhin genau einen; der Beginn wird erst
nach Rechtepruefung und Staging geschrieben.

23 neue Tests, 912 gesamt, keine Regression. Build ohne Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:48:18 +02:00

95 lines
4.1 KiB
C#

using System.Collections.Concurrent;
using ClawdDotNet.Core.Audit;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// In-Memory-Attrappe des Audit-Logs für Engine-Tests — hält Einträge und Belege in
/// Listen, damit ein Test prüfen kann, was die Engine stempelt, ohne eine Datei.
/// </summary>
internal sealed class InMemoryAuditRepository : IAuditRepository
{
public ConcurrentQueue<AuditEntry> Entries { get; } = new();
public ConcurrentQueue<RunReceipt> Receipts { get; } = new();
public Task AppendAsync(AuditEntry entry, CancellationToken ct)
{
Entries.Enqueue(entry);
return Task.CompletedTask;
}
public Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct)
{
Receipts.Enqueue(receipt);
return Task.CompletedTask;
}
public Task<IReadOnlyList<AuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AuditEntry>>(Entries.Reverse().Take(limit).ToList());
public Task<IReadOnlyList<AuditEntry>> ListForRunAsync(string runId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AuditEntry>>(Entries.Where(e => e.RunId == runId).ToList());
public Task<RunReceipt?> GetReceiptForRunAsync(string runId, CancellationToken ct)
=> Task.FromResult(Receipts.LastOrDefault(r => r.RunId == runId));
public Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList());
public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
/// <summary>Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId.</summary>
public Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct)
{
var alle = Entries.ToList();
var abgeschlossen = alle
.Where(e => e.Status != AuditStatus.Started && e.CallId.Length > 0)
.Select(e => e.CallId)
.ToHashSet();
var offen = alle
.Where(e => e.Status == AuditStatus.Started
&& e.CallId.Length > 0
&& !abgeschlossen.Contains(e.CallId))
.ToList();
return Task.FromResult<IReadOnlyList<AuditEntry>>(offen);
}
public Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
string agentId, DateTime? seit, int limit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AuditEntry>>(
Entries.Where(e => e.AgentId == agentId)
.Where(e => seit is null || e.OccurredAt >= seit)
.Reverse()
.Take(limit)
.ToList());
public Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
string? agentId, DateTime? seit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<ToolUsageStat>>(
Entries.Where(e => agentId is null || e.AgentId == agentId)
.Where(e => seit is null || e.OccurredAt >= seit)
.GroupBy(e => (e.AgentId, e.Tool))
.Select(g =>
{
var abgeschlossen = g.Where(e => e.Status != AuditStatus.Started).ToList();
var begonnen = g.Count(e => e.Status == AuditStatus.Started);
return new ToolUsageStat
{
AgentId = g.Key.AgentId,
Tool = g.Key.Tool,
Calls = abgeschlossen.Count,
Failed = abgeschlossen.Count(e => e.Status == AuditStatus.Error),
Denied = abgeschlossen.Count(e => e.Status == AuditStatus.Denied),
Unfinished = Math.Max(0, begonnen - abgeschlossen.Count),
TotalDurationMs = abgeschlossen.Sum(e => e.DurationMs),
MaxDurationMs = g.Max(e => e.DurationMs)
};
})
.OrderByDescending(s => s.Calls)
.ToList());
}