mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-18 06:26:18 +00:00
- 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
196 lines
6.7 KiB
Go
196 lines
6.7 KiB
Go
package hooks
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
|
)
|
|
|
|
// Defaults and limits for HookConfig fields. Exported so the UI layer can
|
|
// surface the same values that Validate() enforces.
|
|
const (
|
|
// DefaultTimeoutMS applies when TimeoutMS is zero. Chosen at 5s to match
|
|
// the blocking-sync budget in the plan.
|
|
DefaultTimeoutMS = 5000
|
|
// MaxTimeoutMS caps the per-hook timeout. The 10s ceiling matches the
|
|
// chain wall-time budget; setting above this would let one hook starve
|
|
// the whole chain.
|
|
MaxTimeoutMS = 10_000
|
|
// DefaultSource is the fallback when Source is empty (M1: distinguishes
|
|
// UI-created hooks from agent-seeded ones).
|
|
DefaultSource = "ui"
|
|
)
|
|
|
|
// knownEvents enumerates every HookEvent value that Validate accepts.
|
|
// Extending the hook system requires adding the new event both here and in
|
|
// types.go's const block.
|
|
var knownEvents = map[HookEvent]struct{}{
|
|
EventSessionStart: {},
|
|
EventUserPromptSubmit: {},
|
|
EventPreToolUse: {},
|
|
EventPostToolUse: {},
|
|
EventStop: {},
|
|
EventSubagentStart: {},
|
|
EventSubagentStop: {},
|
|
}
|
|
|
|
// Validate checks a HookConfig for semantic correctness and fills in defaults.
|
|
// It MUST be called before Create/Update so that malformed configs never reach
|
|
// the DB or the dispatcher. Side effects (on success): TimeoutMS, OnTimeout,
|
|
// Version, and Source may be populated with their defaults.
|
|
//
|
|
// Validation order matters — cheap checks run first so we fail fast:
|
|
// 1. Event enum — zero-cost map lookup.
|
|
// 2. Scope / tenant / agent invariants — no I/O.
|
|
// 3. Edition gate — pure-function policy.
|
|
// 4. Matcher + CEL compile — most expensive; done last.
|
|
func (h *HookConfig) Validate(ed edition.Edition) error {
|
|
if _, ok := knownEvents[h.Event]; !ok {
|
|
return fmt.Errorf("hook: unknown event %q", h.Event)
|
|
}
|
|
|
|
if err := h.validateScope(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := h.validateHandler(ed); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := h.validateTimeout(); err != nil {
|
|
return err
|
|
}
|
|
|
|
h.applyDefaults()
|
|
|
|
// Matcher + CEL go last: parsing is the most expensive step and either
|
|
// value is optional (nil pattern / empty expr match all).
|
|
if _, err := CompileMatcher(h.Matcher); err != nil {
|
|
return fmt.Errorf("hook: matcher invalid: %w", err)
|
|
}
|
|
if _, err := CompileCELExpr(h.IfExpr); err != nil {
|
|
return fmt.Errorf("hook: if_expr invalid: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateScope enforces (scope, tenant_id, agent_id) invariants:
|
|
// - global → tenant_id MUST be the sentinel UUID.
|
|
// - tenant → tenant_id MUST be non-sentinel.
|
|
// - agent → tenant_id MUST be non-sentinel AND agent_id non-nil.
|
|
func (h *HookConfig) validateScope() error {
|
|
switch h.Scope {
|
|
case ScopeGlobal:
|
|
if h.TenantID != SentinelTenantID {
|
|
return fmt.Errorf("hook: global scope requires sentinel tenant_id, got %s", h.TenantID)
|
|
}
|
|
case ScopeTenant:
|
|
if h.TenantID == SentinelTenantID || h.TenantID == uuid.Nil {
|
|
return fmt.Errorf("hook: tenant scope requires a real tenant_id")
|
|
}
|
|
case ScopeAgent:
|
|
if h.TenantID == SentinelTenantID || h.TenantID == uuid.Nil {
|
|
return fmt.Errorf("hook: agent scope requires a real tenant_id")
|
|
}
|
|
if len(h.AgentIDs) == 0 {
|
|
if h.AgentID != nil && *h.AgentID != uuid.Nil {
|
|
h.AgentIDs = []uuid.UUID{*h.AgentID}
|
|
} else {
|
|
return fmt.Errorf("hook: agent scope requires at least one agent_id")
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("hook: unknown scope %q", h.Scope)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateHandler enforces per-handler rules + edition policy.
|
|
// Command on Standard (any non-global scope) is blocked. Prompt requires at
|
|
// least one filter (matcher OR if_expr) so we never fire an LLM per event.
|
|
func (h *HookConfig) validateHandler(ed edition.Edition) error {
|
|
// Edition gate — applies the same rule as the dispatcher fire-time gate.
|
|
if ok, reason := (HookEditionPolicy{}).Allow(h.HandlerType, h.Scope, ed); !ok {
|
|
return fmt.Errorf("hook: %s handler not allowed: %s", h.HandlerType, reason)
|
|
}
|
|
|
|
switch h.HandlerType {
|
|
case HandlerPrompt:
|
|
if h.Matcher == "" && h.IfExpr == "" {
|
|
return fmt.Errorf("hook: prompt handler requires a matcher or if_expr (runaway-cost guard)")
|
|
}
|
|
if tmpl, _ := h.Config["prompt_template"].(string); tmpl == "" {
|
|
return fmt.Errorf("hook: prompt handler requires non-empty prompt_template")
|
|
}
|
|
case HandlerScript:
|
|
source, _ := h.Config["source"].(string)
|
|
if source == "" {
|
|
return fmt.Errorf("hook: script handler requires non-empty config.source")
|
|
}
|
|
if len(source) > MaxScriptSourceBytes {
|
|
return fmt.Errorf("hook: script source exceeds %d bytes (got %d)", MaxScriptSourceBytes, len(source))
|
|
}
|
|
// Strict=true parallels the handler's compile; surfaces `Line N:M`
|
|
// errors that the UI editor can display inline.
|
|
if _, err := goja.Compile(h.ID.String(), source, true); err != nil {
|
|
return fmt.Errorf("hook: script compile error: %w", err)
|
|
}
|
|
case HandlerCommand, HandlerHTTP:
|
|
// No extra filter required — matcher/if_expr are optional.
|
|
default:
|
|
return fmt.Errorf("hook: unknown handler_type %q", h.HandlerType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MaxScriptSourceBytes mirrors handlers.MaxScriptSourceBytes (32 KiB). Declared
|
|
// here to avoid importing the handlers package (which imports goja) just for
|
|
// this constant — the size cap is part of the config contract and should be
|
|
// enforceable without the runtime dep elsewhere.
|
|
const MaxScriptSourceBytes = 32 * 1024
|
|
|
|
// validateTimeout bounds the per-hook timeout within [0, MaxTimeoutMS] and
|
|
// rejects on_timeout values reserved for future phases.
|
|
// Zero is normalized to DefaultTimeoutMS in applyDefaults.
|
|
func (h *HookConfig) validateTimeout() error {
|
|
if h.TimeoutMS < 0 {
|
|
return fmt.Errorf("hook: timeout_ms must be >= 0, got %d", h.TimeoutMS)
|
|
}
|
|
if h.TimeoutMS > MaxTimeoutMS {
|
|
return fmt.Errorf("hook: timeout_ms %d exceeds max %d", h.TimeoutMS, MaxTimeoutMS)
|
|
}
|
|
// ask/defer are reserved for Wave 3 human-approval + external-arbitration
|
|
// flows. Until the dispatcher wires a `request_user_input` path, accepting
|
|
// these as timeout-fallback would let a misconfigured hook hang blocking
|
|
// events indefinitely.
|
|
if h.OnTimeout == DecisionAsk || h.OnTimeout == DecisionDefer {
|
|
return fmt.Errorf("hook: on_timeout=%q not supported in Wave 1 (reserved for future)", h.OnTimeout)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applyDefaults fills optional fields with their plan-specified defaults.
|
|
// Called after validation so we never paper over a real error.
|
|
func (h *HookConfig) applyDefaults() {
|
|
if h.TimeoutMS == 0 {
|
|
h.TimeoutMS = DefaultTimeoutMS
|
|
}
|
|
if h.OnTimeout == "" {
|
|
if h.Event.IsBlocking() {
|
|
h.OnTimeout = DecisionBlock
|
|
} else {
|
|
h.OnTimeout = DecisionAllow
|
|
}
|
|
}
|
|
if h.Version == 0 {
|
|
h.Version = 1
|
|
}
|
|
if h.Source == "" {
|
|
h.Source = DefaultSource
|
|
}
|
|
}
|