mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 18:19:19 +00:00
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.
119 lines
3.5 KiB
Go
119 lines
3.5 KiB
Go
package hooks
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
)
|
|
|
|
// --- fakes ---
|
|
|
|
// fakeBus records Subscribe calls and lets tests trigger handlers manually.
|
|
type fakeBus struct {
|
|
handlers map[eventbus.EventType][]eventbus.DomainEventHandler
|
|
}
|
|
|
|
func newFakeBus() *fakeBus {
|
|
return &fakeBus{handlers: make(map[eventbus.EventType][]eventbus.DomainEventHandler)}
|
|
}
|
|
|
|
func (b *fakeBus) Publish(_ eventbus.DomainEvent) {}
|
|
func (b *fakeBus) Subscribe(et eventbus.EventType, h eventbus.DomainEventHandler) func() {
|
|
b.handlers[et] = append(b.handlers[et], h)
|
|
return func() {}
|
|
}
|
|
func (b *fakeBus) Start(_ context.Context) {}
|
|
func (b *fakeBus) Drain(_ time.Duration) error { return nil }
|
|
|
|
func (b *fakeBus) trigger(ctx context.Context, et eventbus.EventType, ev eventbus.DomainEvent) {
|
|
for _, h := range b.handlers[et] {
|
|
_ = h(ctx, ev)
|
|
}
|
|
}
|
|
|
|
// fakeCapturingDispatcher records events passed to Fire.
|
|
type fakeCapturingDispatcher struct {
|
|
events []Event
|
|
}
|
|
|
|
func (f *fakeCapturingDispatcher) Fire(_ context.Context, ev Event) (FireResult, error) {
|
|
f.events = append(f.events, ev)
|
|
return FireResult{Decision: DecisionAllow}, nil
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestSubscribeDelegateEvents_CompletedFiresSubagentStop(t *testing.T) {
|
|
bus := newFakeBus()
|
|
disp := &fakeCapturingDispatcher{}
|
|
SubscribeDelegateEvents(bus, disp)
|
|
|
|
delegationID := uuid.NewString()
|
|
bus.trigger(context.Background(), eventbus.EventDelegateCompleted, eventbus.DomainEvent{
|
|
Type: eventbus.EventDelegateCompleted,
|
|
Payload: eventbus.DelegateCompletedPayload{DelegationID: delegationID},
|
|
Timestamp: time.Now(),
|
|
})
|
|
|
|
if len(disp.events) != 1 {
|
|
t.Fatalf("expected 1 Fire call; got %d", len(disp.events))
|
|
}
|
|
got := disp.events[0]
|
|
if got.HookEvent != EventSubagentStop {
|
|
t.Errorf("expected EventSubagentStop; got %q", got.HookEvent)
|
|
}
|
|
if got.EventID != delegationID {
|
|
t.Errorf("expected EventID=%q; got %q", delegationID, got.EventID)
|
|
}
|
|
}
|
|
|
|
func TestSubscribeDelegateEvents_FailedFiresSubagentStop(t *testing.T) {
|
|
bus := newFakeBus()
|
|
disp := &fakeCapturingDispatcher{}
|
|
SubscribeDelegateEvents(bus, disp)
|
|
|
|
delegationID := uuid.NewString()
|
|
bus.trigger(context.Background(), eventbus.EventDelegateFailed, eventbus.DomainEvent{
|
|
Type: eventbus.EventDelegateFailed,
|
|
Payload: eventbus.DelegateFailedPayload{DelegationID: delegationID, Error: "timeout"},
|
|
Timestamp: time.Now(),
|
|
})
|
|
|
|
if len(disp.events) != 1 {
|
|
t.Fatalf("expected 1 Fire call; got %d", len(disp.events))
|
|
}
|
|
got := disp.events[0]
|
|
if got.HookEvent != EventSubagentStop {
|
|
t.Errorf("expected EventSubagentStop; got %q", got.HookEvent)
|
|
}
|
|
if got.EventID != delegationID {
|
|
t.Errorf("expected EventID=%q; got %q", delegationID, got.EventID)
|
|
}
|
|
}
|
|
|
|
func TestSubscribeDelegateEvents_NilInputs_NoPanic(t *testing.T) {
|
|
// Should not panic with nil bus or nil dispatcher.
|
|
SubscribeDelegateEvents(nil, &fakeCapturingDispatcher{})
|
|
SubscribeDelegateEvents(newFakeBus(), nil)
|
|
}
|
|
|
|
func TestSubscribeDelegateEvents_UnknownPayload_NoFire(t *testing.T) {
|
|
bus := newFakeBus()
|
|
disp := &fakeCapturingDispatcher{}
|
|
SubscribeDelegateEvents(bus, disp)
|
|
|
|
// Trigger with unknown payload type — dispatcher should not be called.
|
|
bus.trigger(context.Background(), eventbus.EventDelegateCompleted, eventbus.DomainEvent{
|
|
Type: eventbus.EventDelegateCompleted,
|
|
Payload: "unexpected string payload",
|
|
})
|
|
|
|
if len(disp.events) != 0 {
|
|
t.Errorf("expected 0 Fire calls for unknown payload; got %d", len(disp.events))
|
|
}
|
|
}
|