mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
- 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
178 lines
5.3 KiB
Go
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
|
|
}
|
|
}
|