feat(hooks/script): integrate script handler — FireResult + source-tier gate + migration

Wave 1 Phase 03. Wires the Phase 02 Goja handler into the dispatcher, widens
the DB schema for script + builtin sources, and refactors Dispatcher.Fire to
return a FireResult so builtin-source hooks can mutate event input.

Dispatcher:
- FireResult{Decision, UpdatedToolInput, UpdatedRawInput} replaces the plain
  Decision return. stdDispatcher.runSync keeps a local evMut copy so a
  builtin-source hook's updatedInput flows to downstream hooks in the chain
  and to the caller; non-builtin (source=ui) script mutations are dropped +
  WARN-logged (defense-in-depth; source tier enforced at the dispatcher, not
  only at the handler).
- applyBuiltinMutation + placeholder builtinAllowlistFor (rawInput, toolInput)
  — Phase 04 overrides the allowlist from builtins.yaml.
- noopDispatcher returns FireResult{DecisionAllow}.
- Script mutation tests: TestDispatcher_ScriptMutation_BuiltinSourceApplies,
  TestDispatcher_ScriptMutation_UISourceDenied.

Pipeline + callers (14 Fire sites refactored):
- FireHook wrapper returns FireResult; context_stage.go:52 applies
  UpdatedRawInput → state.Input.Message; tool_stage.go:52 applies
  UpdatedToolInput → tc.Arguments before ExecuteToolCall.
- delegate_bridge + delegate_tool keep the Decision branch; Updated* ignored.
- dispatcher_test / delegate_bridge_test / delegate_tool_hooks_test /
  integration test helpers updated for the new shape.

Validation:
- edition_gate allows HandlerScript on every edition (sandboxed, no shell
  escape surface).
- config.validateHandler rejects empty source, source > 32 KiB, goja compile
  error; validateTimeout rejects on_timeout=ask|defer (reserved).
- Six new config tests cover the script path.

Migrations:
- PG migration 000053 relaxes handler_type + source CHECKs and drops
  uq_hooks_{global,tenant,agent} — scripts routinely want many small hooks
  per event. RequiredSchemaVersion → 53.
- SQLite SchemaVersion → 21; patch 20 is a SELECT 1 placeholder because
  SQLite can't ALTER a CHECK — rebuildAgentHooksV21 runs outside the
  migration tx (parallel to backfillV16) to rename/recreate the table with
  widened CHECKs. schema.sql fresh-DB path matches.
- H9 fix: PGHookStore.Create + SqliteHookStore.Create honor caller-provided
  cfg.ID (uuid.Nil falls back to UUIDv7), unblocking Phase 04 idempotent
  UUIDv5 seed. TestCreateHonorsFixedID on both stores.

Config:
- config.HooksConfig{ScriptConcurrency, ScriptPerTenantConcurrency,
  ScriptCacheSize, BuiltinDisable}; buildHookHandlers wires the script
  handler with the caps from appCfg.Hooks.

Gates green: go build ./... + sqliteonly, go test -race -count=1 across
hooks/pipeline/sqlitestore.
This commit is contained in:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent a556161d0d
commit b7592bcacf
29 files changed
+663 -125

No files matched your search

+6 -15
View File
@@ -1570,9 +1570,12 @@ CREATE INDEX IF NOT EXISTS idx_vault_links_source
WHERE json_extract(metadata, '$.source') IS NOT NULL;
-- ============================================================
-- Table: agent_hooks (migration 000052)
-- Table: agent_hooks (migrations 000052 + 000053)
-- SQLite translation: JSONB→TEXT, TIMESTAMPTZ→TEXT, UUID→TEXT,
-- BYTEA→BLOB, DATE→TEXT (ISO8601), CHECK for enums.
-- v21 (migration 000053): handler_type adds 'script', source adds 'builtin',
-- old (event, handler_type) uniqueness indexes dropped — scripts routinely
-- want many small hooks per event.
-- ============================================================
CREATE TABLE IF NOT EXISTS agent_hooks (
@@ -1581,7 +1584,7 @@ CREATE TABLE IF NOT EXISTS agent_hooks (
agent_id TEXT REFERENCES agents(id) ON DELETE CASCADE,
scope TEXT NOT NULL CHECK (scope IN ('global', 'tenant', 'agent')),
event TEXT NOT NULL,
handler_type TEXT NOT NULL CHECK (handler_type IN ('command', 'http', 'prompt')),
handler_type TEXT NOT NULL CHECK (handler_type IN ('command', 'http', 'prompt', 'script')),
config TEXT NOT NULL DEFAULT '{}',
matcher TEXT,
if_expr TEXT,
@@ -1590,25 +1593,13 @@ CREATE TABLE IF NOT EXISTS agent_hooks (
priority INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'ui' CHECK (source IN ('ui', 'api', 'seed')),
source TEXT NOT NULL DEFAULT 'ui' CHECK (source IN ('ui', 'api', 'seed', 'builtin')),
metadata TEXT NOT NULL DEFAULT '{}',
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_global
ON agent_hooks (event, handler_type)
WHERE scope = 'global';
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_tenant
ON agent_hooks (tenant_id, event, handler_type)
WHERE scope = 'tenant';
CREATE UNIQUE INDEX IF NOT EXISTS uq_hooks_agent
ON agent_hooks (tenant_id, agent_id, event, handler_type)
WHERE scope = 'agent';
CREATE INDEX IF NOT EXISTS idx_hooks_lookup
ON agent_hooks (tenant_id, agent_id, event)
WHERE enabled = 1;