Files
viettranx df82fafb28 feat(hooks): name field + beta explainer modal + UI polish
- PG migration 000054 + SQLite v21→v22: add nullable name column
- HookConfig.Name in Go struct, PG/SQLite scan/insert, WS handlers
- Builtin seed writes spec ID as hook name
- UI: name input in create/edit form, displayed in hook list row
- Beta card: "Learn more" button opens modal explaining hooks×skills×MCP
- Default handler_type changed from http to script
- Script editor section: max-h-[50vh] with scroll, border always visible
- i18n: all new keys in en/vi/zh
2026-04-16 14:17:48 +07:00

178 lines
5.3 KiB
Go

package builtin
import (
"context"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Seed UPSERTs every registered builtin into hookStore following the
// version-reconciliation rules in phase-04:
//
// - DB row missing → INSERT one row per event with source='builtin'.
// - DB version < embed version → UPDATE content (preserve enabled toggle).
// - DB version == embed version → no-op.
// - DB version > embed version → WARN + keep DB (no rollback).
//
// Runs AFTER migrations and BEFORE dispatcher handler registration. Uses
// store.RoleOwner + hooks.WithSeedBypass to traverse the tenant-scope guard
// and the builtin-readonly Update protection; both markers are process-local.
func Seed(ctx context.Context, hookStore hooks.HookStore, cfg config.HooksConfig) error {
regMu.RLock()
all := make([]Spec, len(specs))
copy(all, specs)
regMu.RUnlock()
disabled := make(map[string]struct{}, len(cfg.BuiltinDisable))
for _, d := range cfg.BuiltinDisable {
disabled[d] = struct{}{}
}
ctx = store.WithRole(ctx, store.RoleOwner)
ctx = hooks.WithSeedBypass(ctx)
for i := range all {
s := all[i]
_, forceDisabled := disabled[s.ID]
if err := seedOne(ctx, hookStore, s, forceDisabled); err != nil {
slog.Warn("hooks.builtin_seed_failed", "id", s.ID, "err", err)
}
}
return nil
}
// seedOne reconciles one spec (N events → N rows) against the store.
func seedOne(ctx context.Context, hs hooks.HookStore, s Spec, forceDisabled bool) error {
src, ok := source(s.SourceFile)
if !ok {
return fmt.Errorf("source not cached: %s", s.SourceFile)
}
cfgMap := map[string]any{"source": string(src)}
metadata := map[string]any{
"builtin": true,
"version": s.Version,
"description": s.Description,
}
for _, ev := range s.Events {
id := BuiltinEventID(s.ID, ev)
if err := reconcile(ctx, hs, id, s, ev, cfgMap, metadata, forceDisabled); err != nil {
return fmt.Errorf("%s/%s: %w", s.ID, ev, err)
}
}
return nil
}
// reconcile handles insert/update/noop for a single builtin-event row.
func reconcile(
ctx context.Context,
hs hooks.HookStore,
id uuid.UUID,
s Spec,
event string,
cfgMap, metadata map[string]any,
forceDisabled bool,
) error {
current, err := hs.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("get: %w", err)
}
if current == nil {
// Fresh insert: honor both the operator force-disable list AND the
// spec's default_disabled flag. Spec-default off keeps high-impact
// builtins (e.g. PII redactor) opt-in so users opt them in
// deliberately rather than having mutation enabled silently.
enabled := !forceDisabled && !s.DefaultDisabled
cfg := hooks.HookConfig{
ID: id,
TenantID: hooks.SentinelTenantID,
Name: s.ID,
Event: hooks.HookEvent(event),
HandlerType: hooks.HandlerScript,
Scope: hooks.Scope(s.Scope),
Config: cfgMap,
Matcher: s.Matcher,
IfExpr: s.IfExpr,
TimeoutMS: s.TimeoutMS,
OnTimeout: hooks.Decision(s.OnTimeout),
Priority: s.Priority,
Enabled: enabled,
Source: hooks.SourceBuiltin,
Metadata: metadata,
}
if _, err := hs.Create(ctx, cfg); err != nil {
return fmt.Errorf("create: %w", err)
}
slog.Info("hooks.builtin_seeded", "id", s.ID, "event", event, "version", s.Version)
return nil
}
dbVersion := metadataVersion(current.Metadata)
switch {
case s.Version > dbVersion:
patch := map[string]any{
"config": cfgMap,
"matcher": s.Matcher,
"if_expr": s.IfExpr,
"timeout_ms": s.TimeoutMS,
"on_timeout": string(hooks.Decision(s.OnTimeout)),
"priority": s.Priority,
"metadata": metadata,
}
// One-shot: when a version bump introduces default_disabled=true on
// a row that was previously enabled, flip it off too. Lets us roll
// out the "input mutation is opt-in" policy retroactively without
// stomping on a user who deliberately re-enables afterwards (the
// next boot finds dbVersion >= s.Version, skips this branch).
if s.DefaultDisabled && current.Enabled {
patch["enabled"] = false
slog.Info("hooks.builtin_default_off_applied",
"id", s.ID, "event", event,
"reason", "version bump introduced default_disabled=true")
}
if err := hs.Update(ctx, id, patch); err != nil {
return fmt.Errorf("update: %w", err)
}
slog.Info("hooks.builtin_updated", "id", s.ID, "event", event,
"from_version", dbVersion, "to_version", s.Version)
case s.Version < dbVersion:
slog.Warn("hooks.builtin_downgrade_detected",
"id", s.ID, "event", event,
"db_version", dbVersion, "embed_version", s.Version,
"note", "DB content kept; no rollback performed")
}
if forceDisabled && current.Enabled {
if err := hs.Update(ctx, id, map[string]any{"enabled": false}); err != nil {
return fmt.Errorf("force-disable: %w", err)
}
slog.Info("hooks.builtin_disabled_by_config", "id", s.ID, "event", event)
}
return nil
}
// metadataVersion reads metadata["version"] tolerating JSONB float64 decoding.
func metadataVersion(m map[string]any) int {
if m == nil {
return 0
}
switch v := m["version"].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
default:
return 0
}
}