Skip to content

Commit 2cda184

Browse files
authored
Merge pull request #2534 from dgageot/board/refactoring-boilerplate-in-pkg-hooks-con-00fdb9e5
refactor(hooks): drop runtime shadow types and tighten the executor
2 parents 437a06b + b10e00e commit 2cda184

7 files changed

Lines changed: 203 additions & 202 deletions

File tree

‎pkg/config/latest/types.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,6 +1727,15 @@ type HookDefinition struct {
17271727
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty"`
17281728
}
17291729

1730+
// GetTimeout returns the per-hook execution timeout, defaulting to 60
1731+
// seconds when [HookDefinition.Timeout] is zero or negative.
1732+
func (h *HookDefinition) GetTimeout() time.Duration {
1733+
if h.Timeout <= 0 {
1734+
return 60 * time.Second
1735+
}
1736+
return time.Duration(h.Timeout) * time.Second
1737+
}
1738+
17301739
// validate validates the HooksConfig
17311740
func (h *HooksConfig) validate() error {
17321741
// Validate PreToolUse matchers

‎pkg/hooks/config.go‎

Lines changed: 35 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,40 @@ import (
44
"github.com/docker/docker-agent/pkg/config/latest"
55
)
66

7-
// FromConfig converts a [latest.HooksConfig] into the runtime [Config].
8-
func FromConfig(cfg *latest.HooksConfig) *Config {
9-
if cfg == nil {
10-
return nil
11-
}
12-
return &Config{
13-
PreToolUse: convertMatchers(cfg.PreToolUse),
14-
PostToolUse: convertMatchers(cfg.PostToolUse),
15-
SessionStart: convertHooks(cfg.SessionStart),
16-
TurnStart: convertHooks(cfg.TurnStart),
17-
BeforeLLMCall: convertHooks(cfg.BeforeLLMCall),
18-
AfterLLMCall: convertHooks(cfg.AfterLLMCall),
19-
SessionEnd: convertHooks(cfg.SessionEnd),
20-
OnUserInput: convertHooks(cfg.OnUserInput),
21-
Stop: convertHooks(cfg.Stop),
22-
Notification: convertHooks(cfg.Notification),
23-
OnError: convertHooks(cfg.OnError),
24-
OnMaxIterations: convertHooks(cfg.OnMaxIterations),
25-
}
26-
}
7+
// The hooks package and the persisted [latest.HooksConfig] used to
8+
// declare two parallel struct hierarchies with identical fields, just
9+
// to attach a single helper method (GetTimeout) and a typed Type
10+
// string. The cost was a 12-events-listed-in-four-places translation
11+
// layer that grew every time a new event was added.
12+
//
13+
// Today they're the same types: aliases below give the runtime the
14+
// short names it always used, while the single source of truth (and
15+
// the YAML/JSON tags, the validate() method, and IsEmpty) lives next
16+
// to the schema in pkg/config/latest. Adding a new event is a one-line
17+
// change on [latest.HooksConfig] plus one line in compileEvents.
18+
type (
19+
// Config is the hooks configuration for an agent.
20+
Config = latest.HooksConfig
21+
// Hook is a single hook entry. The Type field is one of the
22+
// HookType* constants below; unrecognised values are rejected by
23+
// the executor at registry lookup.
24+
Hook = latest.HookDefinition
25+
// MatcherConfig pairs a tool-name regex with the hooks to run when
26+
// it matches (used by EventPreToolUse and EventPostToolUse).
27+
MatcherConfig = latest.HookMatcherConfig
28+
)
2729

28-
func convertMatchers(in []latest.HookMatcherConfig) []MatcherConfig {
29-
if len(in) == 0 {
30-
return nil
31-
}
32-
out := make([]MatcherConfig, len(in))
33-
for i, m := range in {
34-
out[i] = MatcherConfig{Matcher: m.Matcher, Hooks: convertHooks(m.Hooks)}
35-
}
36-
return out
37-
}
30+
// HookType values populate [Hook.Type]. It is an alias for string so
31+
// hooks authored in YAML round-trip through [latest.HookDefinition]
32+
// without any conversion; the executor validates the value at registry
33+
// lookup time.
34+
type HookType = string
3835

39-
func convertHooks(in []latest.HookDefinition) []Hook {
40-
if len(in) == 0 {
41-
return nil
42-
}
43-
out := make([]Hook, len(in))
44-
for i, h := range in {
45-
out[i] = Hook{
46-
Type: HookType(h.Type),
47-
Command: h.Command,
48-
Args: h.Args,
49-
Timeout: h.Timeout,
50-
}
51-
}
52-
return out
53-
}
36+
const (
37+
// HookTypeCommand runs a shell command.
38+
HookTypeCommand HookType = "command"
39+
// HookTypeBuiltin dispatches to a named in-process Go function
40+
// registered via [Registry.RegisterBuiltin]. The name is stored in
41+
// [Hook.Command].
42+
HookTypeBuiltin HookType = "builtin"
43+
)

‎pkg/hooks/dispatch_test.go‎

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,60 +6,61 @@ import (
66
"github.com/stretchr/testify/assert"
77
)
88

9-
// TestExecutorHasIsGeneric exercises the generic Has API across all
10-
// configured event kinds to ensure the eventTable is wired correctly. It
11-
// guards against regressions where adding a new event to the Config
12-
// struct silently fails to surface in Has/Dispatch.
9+
// trueHook is a minimal hook reused as the body of every per-event
10+
// fixture below — the hook's identity doesn't matter to Has, only its
11+
// presence does.
12+
var trueHook = []Hook{{Type: HookTypeCommand, Command: "true"}}
13+
14+
// matcherWildcard wraps trueHook in the structure tool-scoped events
15+
// (PreToolUse, PostToolUse) require.
16+
var matcherWildcard = []MatcherConfig{{Matcher: "*", Hooks: trueHook}}
17+
18+
// onlyHooks maps each known event to a Config that lights up exactly
19+
// that event. The cross-product test below iterates this map (once for
20+
// the empty check, once for the per-event check), so adding a new
21+
// event is a one-line update here — the same one-line update
22+
// compileEvents needs.
23+
var onlyHooks = map[EventType]*Config{
24+
EventPreToolUse: {PreToolUse: matcherWildcard},
25+
EventPostToolUse: {PostToolUse: matcherWildcard},
26+
EventSessionStart: {SessionStart: trueHook},
27+
EventTurnStart: {TurnStart: trueHook},
28+
EventBeforeLLMCall: {BeforeLLMCall: trueHook},
29+
EventAfterLLMCall: {AfterLLMCall: trueHook},
30+
EventSessionEnd: {SessionEnd: trueHook},
31+
EventOnUserInput: {OnUserInput: trueHook},
32+
EventStop: {Stop: trueHook},
33+
EventNotification: {Notification: trueHook},
34+
EventOnError: {OnError: trueHook},
35+
EventOnMaxIterations: {OnMaxIterations: trueHook},
36+
}
37+
38+
// TestExecutorHasIsGeneric exercises the generic Has API across every
39+
// event kind to ensure compileEvents is wired correctly. It guards
40+
// against regressions where adding a new event to the Config struct
41+
// silently fails to surface in Has/Dispatch.
1342
func TestExecutorHasIsGeneric(t *testing.T) {
1443
t.Parallel()
1544

1645
// Empty config: no event has hooks.
1746
empty := NewExecutor(nil, "/tmp", nil)
18-
for _, ev := range []EventType{
19-
EventPreToolUse, EventPostToolUse, EventSessionStart, EventTurnStart,
20-
EventBeforeLLMCall, EventAfterLLMCall,
21-
EventSessionEnd, EventOnUserInput, EventStop, EventNotification,
22-
EventOnError, EventOnMaxIterations,
23-
} {
47+
for ev := range onlyHooks {
2448
assert.Falsef(t, empty.Has(ev), "empty executor must not report Has(%s)", ev)
2549
}
2650

27-
// Each event populated in turn — the cross-product test ensures that
28-
// configuring event X never makes Has(Y) true for Y != X.
29-
cases := []struct {
30-
event EventType
31-
config *Config
32-
}{
33-
{EventPreToolUse, &Config{PreToolUse: []MatcherConfig{{Matcher: "*", Hooks: []Hook{{Type: HookTypeCommand, Command: "true"}}}}}},
34-
{EventPostToolUse, &Config{PostToolUse: []MatcherConfig{{Matcher: "*", Hooks: []Hook{{Type: HookTypeCommand, Command: "true"}}}}}},
35-
{EventSessionStart, &Config{SessionStart: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
36-
{EventTurnStart, &Config{TurnStart: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
37-
{EventBeforeLLMCall, &Config{BeforeLLMCall: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
38-
{EventAfterLLMCall, &Config{AfterLLMCall: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
39-
{EventSessionEnd, &Config{SessionEnd: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
40-
{EventOnUserInput, &Config{OnUserInput: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
41-
{EventStop, &Config{Stop: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
42-
{EventNotification, &Config{Notification: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
43-
{EventOnError, &Config{OnError: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
44-
{EventOnMaxIterations, &Config{OnMaxIterations: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
45-
}
46-
47-
for _, tc := range cases {
48-
exec := NewExecutor(tc.config, "/tmp", nil)
49-
for _, other := range []EventType{
50-
EventPreToolUse, EventPostToolUse, EventSessionStart, EventTurnStart,
51-
EventBeforeLLMCall, EventAfterLLMCall,
52-
EventSessionEnd, EventOnUserInput, EventStop, EventNotification,
53-
EventOnError, EventOnMaxIterations,
54-
} {
55-
if other == tc.event {
56-
assert.Truef(t, exec.Has(other), "configuring %s must light up Has(%s)", tc.event, other)
51+
// Cross-product: configuring event ev must light up Has(ev) and
52+
// only Has(ev).
53+
for ev, cfg := range onlyHooks {
54+
exec := NewExecutor(cfg, "/tmp", nil)
55+
for other := range onlyHooks {
56+
if other == ev {
57+
assert.Truef(t, exec.Has(other), "configuring %s must light up Has(%s)", ev, other)
5758
} else {
58-
assert.Falsef(t, exec.Has(other), "configuring %s must NOT light up Has(%s)", tc.event, other)
59+
assert.Falsef(t, exec.Has(other), "configuring %s must NOT light up Has(%s)", ev, other)
5960
}
6061
}
6162
}
6263

6364
// Unknown events fall through cleanly rather than panicking.
64-
assert.False(t, NewExecutor(nil, "/tmp", nil).Has(EventType("does-not-exist")))
65+
assert.False(t, empty.Has(EventType("does-not-exist")))
6566
}

0 commit comments

Comments
 (0)