Files
viettranx 3fe5ae0b50 feat(hooks): introduce agent lifecycle hooks foundation with fail-closed blocking semantics
Phase 1 of agent-hooks-system establishes:
- Hook types, matchers, and CEL evaluation engine for event-driven extensibility
- Edition-gated command handlers (Lite disabled) with dedup_key-indexed audit log
- Dual-DB store layer (PostgreSQL + SQLite) with transaction boundary enforcement
- Blocking-event semantics: hook execution failures propagate to agent loop, graceful retry
- Tenant-isolated audit trail with per-hook execution context tracing
- Integration tests verifying store correctness and hook dispatch atomicity

Lays groundwork for post-v3.0 phase-02 (per-tenant onboarding hooks).
2026-04-15 14:25:59 +07:00

81 lines
2.2 KiB
Go

package hooks
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
// EmitHookSpan records a tracing span for a hook execution.
//
// The span name follows the plan's convention: "hook.<handlerType>.<event>"
// (e.g., "hook.command.pre_tool_use"). Duration is computed from startedAt
// to now; status is "completed" on success, "error" when errMsg is non-empty.
// The decision is persisted into Metadata as `{"decision":"allow|block|..."}`
// so dashboards can aggregate allow/block ratios per event.
//
// Fields lifted from ctx:
// - trace id (tracing.TraceIDFromContext)
// - parent span id (tracing.ParentSpanIDFromContext, omitted when nil)
// - team id (tracing.TraceTeamIDPtrFromContext)
// - tenant id (store.TenantIDFromContext)
//
// No-op when ctx has no collector attached — safe in tests and for tenants
// without tracing enabled.
func EmitHookSpan(
ctx context.Context,
event HookEvent,
ht HandlerType,
startedAt time.Time,
decision Decision,
errMsg string,
) {
collector := tracing.CollectorFromContext(ctx)
if collector == nil {
return
}
end := time.Now().UTC()
durationMS := max(int(end.Sub(startedAt)/time.Millisecond), 0)
status := store.SpanStatusCompleted
if errMsg != "" {
status = store.SpanStatusError
}
var metadata json.RawMessage
if decision != "" {
if b, err := json.Marshal(map[string]string{"decision": string(decision)}); err == nil {
metadata = b
}
}
span := store.SpanData{
TraceID: tracing.TraceIDFromContext(ctx),
SpanType: store.SpanTypeEvent,
Name: "hook." + string(ht) + "." + string(event),
StartTime: startedAt,
EndTime: &end,
DurationMS: durationMS,
Status: status,
Error: errMsg,
Metadata: metadata,
TeamID: tracing.TraceTeamIDPtrFromContext(ctx),
TenantID: store.TenantIDFromContext(ctx),
CreatedAt: end,
}
// Attach parent only when present — leaving it nil avoids bogus FK edges.
if parent := tracing.ParentSpanIDFromContext(ctx); parent != uuid.Nil {
p := parent
span.ParentSpanID = &p
}
collector.EmitSpan(span)
}