Files
viettranx ee328d4266 refactor: consolidate hooks table migration and drop deprecated agent_id column
- Consolidated PG migration 054: agent_hooks rename + junction table + deprecated column drop
- Updated SQLite schema v19 with final consolidated migration
- Removed obsolete schema rebuild files (v21, v23)
- Updated Go store layer (pg/hooks.go, sqlitestore/hooks.go)
- Updated integration tests to use new table names (hooks, hook_agents)
- Updated TypeScript protocol, UI components, and i18n strings
- Updated gateway methods to reflect schema changes

Tables: agent_hooks → hooks, agent_hook_agents → hook_agents
2026-04-16 14:17:48 +07:00

168 lines
5.2 KiB
Go

//go:build integration
package integration
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
hookhandlers "github.com/nextlevelbuilder/goclaw/internal/hooks/handlers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
// TestHooksTracing_EmitHookSpan verifies EmitHookSpan writes a span row with
// the canonical name format "hook.<handlerType>.<event>" when a collector
// is attached to ctx. No collector → no-op (already covered in unit tests).
func TestHooksTracing_EmitHookSpan(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ts := pg.NewPGTracingStore(db)
c := tracing.NewCollector(ts)
c.Start()
// No defer Stop — tests call Stop explicitly to flush before polling.
// Seed a parent trace so tenant_id scoping doesn't orphan the span.
traceID := uuid.New()
if err := c.CreateTrace(tenantCtx(tenantID), &store.TraceData{
ID: traceID,
Status: store.SpanStatusCompleted,
StartTime: time.Now().Add(-5 * time.Second),
}); err != nil {
t.Fatalf("CreateTrace: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM spans WHERE trace_id = $1", traceID)
db.Exec("DELETE FROM traces WHERE id = $1", traceID)
})
ctx := tracing.WithTraceID(tracing.WithCollector(tenantCtx(tenantID), c), traceID)
started := time.Now().Add(-200 * time.Millisecond)
hooks.EmitHookSpan(ctx, hooks.EventPreToolUse, hooks.HandlerHTTP, started, hooks.DecisionAllow, "")
// Stop collector to flush the buffered span; Stop() drains the chan.
c.Stop()
var name string
var durationMS int
if err := db.QueryRow(
`SELECT name, duration_ms FROM spans WHERE trace_id = $1 LIMIT 1`,
traceID,
).Scan(&name, &durationMS); err != nil {
t.Fatalf("read span: %v", err)
}
if name != "hook.http.pre_tool_use" {
t.Errorf("span name=%q, want hook.http.pre_tool_use", name)
}
if durationMS <= 0 {
t.Errorf("span duration=%d, want positive", durationMS)
}
}
// TestHooksTracing_DispatcherEmitsSpan runs a full dispatcher Fire with a
// collector wired, then asserts a hook-span row appears. Proves tracing +
// dispatcher integration end-to-end.
func TestHooksTracing_DispatcherEmitsSpan(t *testing.T) {
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
allowLoopbackForTest(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ts := pg.NewPGTracingStore(db)
c := tracing.NewCollector(ts)
c.Start()
// No defer Stop — tests call Stop explicitly to flush before polling.
traceID := uuid.New()
if err := c.CreateTrace(tenantCtx(tenantID), &store.TraceData{
ID: traceID, Status: store.SpanStatusCompleted,
StartTime: time.Now().Add(-5 * time.Second),
}); err != nil {
t.Fatalf("CreateTrace: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM spans WHERE trace_id = $1", traceID)
db.Exec("DELETE FROM traces WHERE id = $1", traceID)
})
hs := pg.NewPGHookStore(db)
cfg := hooks.HookConfig{
TenantID: tenantID, AgentID: &agentID,
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
HandlerType: hooks.HandlerHTTP,
Config: map[string]any{"url": srv.URL},
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
Enabled: true, Version: 1, Source: "api",
Metadata: map[string]any{},
}
hookID, err := hs.Create(tenantCtx(tenantID), cfg)
if err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
db.Exec("DELETE FROM hooks WHERE id = $1", hookID)
})
// Wrap ctx with tracing collector + trace ID — dispatcher's emit helper
// is invoked from handlers wired via the standard dispatcher.
ctx := tracing.WithTraceID(tracing.WithCollector(tenantCtx(tenantID), c), traceID)
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
hooks.HandlerHTTP: tracedHandler{inner: &hookhandlers.HTTPHandler{Client: srv.Client()}},
})
if _, err := d.Fire(ctx, hooks.Event{
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
HookEvent: hooks.EventPreToolUse,
}); err != nil {
t.Fatalf("Fire: %v", err)
}
// Stop collector to flush the buffered span.
c.Stop()
var rowsCount int
if err := db.QueryRow(
`SELECT COUNT(*) FROM spans WHERE trace_id = $1 AND name = $2`,
traceID, "hook.http.pre_tool_use",
).Scan(&rowsCount); err != nil {
t.Fatalf("count spans: %v", err)
}
if rowsCount < 1 {
t.Errorf("expected >=1 hook span in DB, got %d", rowsCount)
}
}
// tracedHandler wraps an existing hooks.Handler and emits a tracing span
// around each call. Production dispatcher does NOT auto-emit spans —
// integration with tracing goes through this wrapper pattern (see plan).
type tracedHandler struct {
inner hooks.Handler
}
func (w tracedHandler) Execute(ctx_ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) (hooks.Decision, error) {
start := time.Now()
dec, err := w.inner.Execute(ctx_ctx, cfg, ev)
errMsg := ""
if err != nil {
errMsg = err.Error()
}
hooks.EmitHookSpan(ctx_ctx, ev.HookEvent, cfg.HandlerType, start, dec, errMsg)
return dec, err
}