Files
steve 4d2f85d139 P4: audit battery — run.Audit Sink + Writer + queryable Memory store
First Tier-2 battery, plugging into run.Ports.Audit:
- storage.go/writer.go: skillaudit's Storage interface + per-run Writer moved
  clean (only utils->fmt); the Writer already matches run.RunRecorder's shape.
- sink.go: Sink adapts a Storage to run.Audit (StartRun -> a run row + a Writer
  wrapped as run.RunRecorder, converting run.RunStats on Close). NewSink(nil) is
  equivalent to no audit. Compile-time proofs: Sink is run.Audit, recorder is
  run.RunRecorder.
- memory.go: NewMemory() — a zero-dependency, queryable in-process Storage
  (retains runs + logs; all 17 read/filter/purge/walk methods) so a light host
  gets run history with no setup. Mort keeps its GORM Storage; contrib/store
  adds durable SQLite at P4.

End-to-end test: wire audit.NewSink(audit.NewMemory()) into the executor, run an
agent, and the run is recorded with terminal status/output and queryable by
caller. CI invariant verified: core imports ZERO from the audit battery (proper
battery direction; battery imports core, never the reverse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:12:19 -04:00

79 lines
2.3 KiB
Go

package audit_test
import (
"context"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
"gitea.stevedudenhoeffer.com/steve/executus/audit"
"gitea.stevedudenhoeffer.com/steve/executus/run"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// TestAuditBatteryEndToEnd wires the audit battery (Memory storage) into
// run.Ports.Audit, runs an agent, and verifies the run was recorded and is
// queryable — proving Sink/Writer/Memory satisfy the core seams end to end.
func TestAuditBatteryEndToEnd(t *testing.T) {
mem := audit.NewMemory()
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("the answer"))
m, err := fp.Model("m")
if err != nil {
t.Fatal(err)
}
ex := run.New(run.Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) {
return ctx, m, nil
},
Ports: run.Ports{Audit: audit.NewSink(mem)},
})
res := ex.Run(context.Background(),
run.RunnableAgent{ID: "agent-1", Name: "a", ModelTier: "m"},
tool.Invocation{RunID: "run-xyz", CallerID: "caller-1"},
"question")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
// The run was recorded with a terminal status + output.
got, err := mem.GetRun(context.Background(), "run-xyz")
if err != nil {
t.Fatalf("GetRun: %v", err)
}
if got.Status != "ok" {
t.Errorf("status = %q, want ok", got.Status)
}
if got.Output != "the answer" {
t.Errorf("output = %q, want %q", got.Output, "the answer")
}
if got.FinishedAt == nil {
t.Error("FinishedAt should be set after the run")
}
if got.SkillID != "agent-1" {
t.Errorf("SkillID = %q, want agent-1 (the subject id)", got.SkillID)
}
// And it is queryable by caller.
runs, err := mem.ListRunsByCaller(context.Background(), "caller-1", 10)
if err != nil {
t.Fatalf("ListRunsByCaller: %v", err)
}
if len(runs) != 1 || runs[0].ID != "run-xyz" {
t.Errorf("ListRunsByCaller = %+v, want [run-xyz]", runs)
}
}
// TestNilSinkRecordsNothing: NewSink(nil) is equivalent to no audit.
func TestNilSinkRecordsNothing(t *testing.T) {
s := audit.NewSink(nil)
if rec := s.StartRun(context.Background(), run.RunInfo{RunID: "r"}); rec != nil {
t.Error("NewSink(nil).StartRun should return a nil recorder")
}
}