Files
executus/audit/sink.go
T
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

77 lines
2.6 KiB
Go

package audit
import (
"context"
"time"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/executus/run"
)
// Sink adapts an audit Storage to the run.Audit port: StartRun opens a run row
// and returns a per-run recorder (a Writer) that the executor feeds with steps,
// tool calls, and the terminal roll-up. This is what plugs the audit battery
// into run.Ports.Audit — mort backs it with its GORM Storage, a light host with
// Memory() (or omits it entirely).
type Sink struct{ storage Storage }
// NewSink wraps a Storage as a run.Audit. A nil Storage yields a Sink whose
// StartRun returns nil (the executor then records nothing) — so NewSink(nil) is
// equivalent to leaving run.Ports.Audit unset.
func NewSink(storage Storage) *Sink { return &Sink{storage: storage} }
// compile-time proof the adapter satisfies the core seams.
var (
_ run.Audit = (*Sink)(nil)
_ run.RunRecorder = (*recorder)(nil)
)
// StartRun records the run start and returns a recorder. Implements run.Audit.
func (s *Sink) StartRun(ctx context.Context, info run.RunInfo) run.RunRecorder {
if s == nil || s.storage == nil {
return nil
}
started := info.StartedAt
if started.IsZero() {
started = time.Now()
}
// Best-effort: a failed StartRun must not break the user-visible run.
_ = s.storage.StartRun(ctx, SkillRun{
ID: info.RunID,
SkillID: info.SubjectID,
CallerID: info.CallerID,
ChannelID: info.ChannelID,
ParentRunID: info.ParentRunID,
Inputs: info.Inputs,
StartedAt: started,
Status: "running",
})
return &recorder{w: NewWriter(s.storage, info.RunID)}
}
// recorder adapts a *Writer to run.RunRecorder, converting run.RunStats to the
// audit RunStats on Close (the two have identical fields).
type recorder struct{ w *Writer }
func (r *recorder) TokenStats() (in, out, thinking int64) { return r.w.TokenStats() }
func (r *recorder) ToolCallsCount() int { return r.w.ToolCallsCount() }
func (r *recorder) OnStep(iter int, resp *llm.Response) { r.w.OnStep(iter, resp) }
func (r *recorder) OnTool(call llm.ToolCall, result string) { r.w.OnTool(call, result) }
func (r *recorder) LogEvent(eventType string, payload map[string]any) {
r.w.LogEvent(eventType, payload)
}
func (r *recorder) LogError(msg string) { r.w.LogError(msg) }
func (r *recorder) Close(ctx context.Context, s run.RunStats) {
r.w.Close(ctx, RunStats{
Status: s.Status,
Output: s.Output,
Error: s.Error,
ToolCalls: s.ToolCalls,
RuntimeSeconds: s.RuntimeSeconds,
InputTokens: s.InputTokens,
OutputTokens: s.OutputTokens,
ThinkingTokens: s.ThinkingTokens,
})
}