4d2f85d139
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>
77 lines
2.6 KiB
Go
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,
|
|
})
|
|
}
|