Skip to content

Commit 79a8a01

Browse files
authored
Merge pull request #499 from Cai-Tang-www/feat/issue-488-hook-core-origin-main
feat(runtime/hooks): 实现 #488 的 P0 Hook Core 基础设施
2 parents 8a551f5 + e11b3c6 commit 79a8a01

11 files changed

Lines changed: 1887 additions & 0 deletions

File tree

internal/runtime/hooks/context.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package hooks
2+
3+
import "reflect"
4+
5+
// HookContext 是 hook 执行时可见的通用上下文快照。
6+
type HookContext struct {
7+
RunID string
8+
SessionID string
9+
Metadata map[string]any
10+
}
11+
12+
// Clone 返回 HookContext 的安全副本,避免元数据被跨 hook 共享修改。
13+
func (c HookContext) Clone() HookContext {
14+
if len(c.Metadata) == 0 {
15+
return c
16+
}
17+
cloned := c
18+
cloned.Metadata = make(map[string]any, len(c.Metadata))
19+
for key, value := range c.Metadata {
20+
cloned.Metadata[key] = cloneMetadataValue(value)
21+
}
22+
return cloned
23+
}
24+
25+
func cloneMetadataValue(value any) any {
26+
if value == nil {
27+
return nil
28+
}
29+
original := reflect.ValueOf(value)
30+
cloned := deepCloneValue(original)
31+
return cloned.Interface()
32+
}
33+
34+
func deepCloneValue(value reflect.Value) reflect.Value {
35+
if !value.IsValid() {
36+
return value
37+
}
38+
39+
switch value.Kind() {
40+
case reflect.Pointer:
41+
if value.IsNil() {
42+
return reflect.Zero(value.Type())
43+
}
44+
pointer := reflect.New(value.Type().Elem())
45+
pointer.Elem().Set(deepCloneValue(value.Elem()))
46+
return pointer
47+
case reflect.Interface:
48+
if value.IsNil() {
49+
return reflect.Zero(value.Type())
50+
}
51+
elem := deepCloneValue(value.Elem())
52+
out := reflect.New(value.Type()).Elem()
53+
out.Set(elem)
54+
return out
55+
case reflect.Map:
56+
if value.IsNil() {
57+
return reflect.Zero(value.Type())
58+
}
59+
clonedMap := reflect.MakeMapWithSize(value.Type(), value.Len())
60+
iter := value.MapRange()
61+
for iter.Next() {
62+
key := deepCloneValue(iter.Key())
63+
val := deepCloneValue(iter.Value())
64+
clonedMap.SetMapIndex(key, val)
65+
}
66+
return clonedMap
67+
case reflect.Slice:
68+
if value.IsNil() {
69+
return reflect.Zero(value.Type())
70+
}
71+
clonedSlice := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
72+
for i := 0; i < value.Len(); i++ {
73+
clonedSlice.Index(i).Set(deepCloneValue(value.Index(i)))
74+
}
75+
return clonedSlice
76+
case reflect.Array:
77+
clonedArray := reflect.New(value.Type()).Elem()
78+
for i := 0; i < value.Len(); i++ {
79+
clonedArray.Index(i).Set(deepCloneValue(value.Index(i)))
80+
}
81+
return clonedArray
82+
case reflect.Struct:
83+
clonedStruct := reflect.New(value.Type()).Elem()
84+
clonedStruct.Set(value)
85+
for i := 0; i < value.NumField(); i++ {
86+
target := clonedStruct.Field(i)
87+
if !target.CanSet() {
88+
continue
89+
}
90+
target.Set(deepCloneValue(value.Field(i)))
91+
}
92+
return clonedStruct
93+
default:
94+
return value
95+
}
96+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package hooks
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestHookContextCloneDeepCopyMetadata(t *testing.T) {
10+
t.Parallel()
11+
12+
original := HookContext{
13+
RunID: "run-1",
14+
SessionID: "session-1",
15+
Metadata: map[string]any{
16+
"slice": []any{"a", map[string]any{"k": "v"}},
17+
"map": map[string]any{"nested": []string{"x", "y"}},
18+
},
19+
}
20+
21+
cloned := original.Clone()
22+
metadataSlice, ok := cloned.Metadata["slice"].([]any)
23+
if !ok {
24+
t.Fatalf("slice metadata type = %T, want []any", cloned.Metadata["slice"])
25+
}
26+
nestedMap, ok := metadataSlice[1].(map[string]any)
27+
if !ok {
28+
t.Fatalf("nested map type = %T, want map[string]any", metadataSlice[1])
29+
}
30+
nestedMap["k"] = "changed"
31+
32+
clonedMap, ok := cloned.Metadata["map"].(map[string]any)
33+
if !ok {
34+
t.Fatalf("map metadata type = %T, want map[string]any", cloned.Metadata["map"])
35+
}
36+
nestedSlice, ok := clonedMap["nested"].([]string)
37+
if !ok {
38+
t.Fatalf("nested slice type = %T, want []string", clonedMap["nested"])
39+
}
40+
nestedSlice[0] = "changed"
41+
42+
originalSlice := original.Metadata["slice"].([]any)
43+
originalNestedMap := originalSlice[1].(map[string]any)
44+
if got := originalNestedMap["k"]; got != "v" {
45+
t.Fatalf("original nested map value = %v, want v", got)
46+
}
47+
originalMap := original.Metadata["map"].(map[string]any)
48+
originalNestedSlice := originalMap["nested"].([]string)
49+
if got := originalNestedSlice[0]; got != "x" {
50+
t.Fatalf("original nested slice value = %q, want x", got)
51+
}
52+
}
53+
54+
func TestHookContextCloneDeepCopyStructFields(t *testing.T) {
55+
t.Parallel()
56+
57+
type nested struct {
58+
Flag bool
59+
}
60+
type metaPayload struct {
61+
Attrs map[string]string
62+
Items []int
63+
Ref *nested
64+
}
65+
66+
original := HookContext{
67+
Metadata: map[string]any{
68+
"struct": metaPayload{
69+
Attrs: map[string]string{"k": "v"},
70+
Items: []int{1, 2, 3},
71+
Ref: &nested{Flag: true},
72+
},
73+
},
74+
}
75+
76+
cloned := original.Clone()
77+
payload, ok := cloned.Metadata["struct"].(metaPayload)
78+
if !ok {
79+
t.Fatalf("struct metadata type = %T, want metaPayload", cloned.Metadata["struct"])
80+
}
81+
82+
payload.Attrs["k"] = "changed"
83+
payload.Items[0] = 99
84+
payload.Ref.Flag = false
85+
cloned.Metadata["struct"] = payload
86+
87+
originPayload := original.Metadata["struct"].(metaPayload)
88+
if got := originPayload.Attrs["k"]; got != "v" {
89+
t.Fatalf("original Attrs[k] = %q, want v", got)
90+
}
91+
if got := originPayload.Items[0]; got != 1 {
92+
t.Fatalf("original Items[0] = %d, want 1", got)
93+
}
94+
if got := originPayload.Ref.Flag; got != true {
95+
t.Fatalf("original Ref.Flag = %v, want true", got)
96+
}
97+
}
98+
99+
func TestHookContextCloneNoMetadata(t *testing.T) {
100+
t.Parallel()
101+
102+
original := HookContext{RunID: "run-1", SessionID: "session-1"}
103+
cloned := original.Clone()
104+
if cloned.RunID != original.RunID || cloned.SessionID != original.SessionID {
105+
t.Fatalf("Clone() basic fields mismatch: got %+v, want %+v", cloned, original)
106+
}
107+
if cloned.Metadata != nil {
108+
t.Fatalf("Clone().Metadata = %#v, want nil", cloned.Metadata)
109+
}
110+
}
111+
112+
func TestCloneMetadataValueNil(t *testing.T) {
113+
t.Parallel()
114+
115+
if got := cloneMetadataValue(nil); got != nil {
116+
t.Fatalf("cloneMetadataValue(nil) = %#v, want nil", got)
117+
}
118+
}
119+
120+
func TestDeepCloneValueEdgeCases(t *testing.T) {
121+
t.Parallel()
122+
123+
invalid := deepCloneValue(reflect.Value{})
124+
if invalid.IsValid() {
125+
t.Fatalf("deepCloneValue(invalid).IsValid() = true, want false")
126+
}
127+
128+
var nilPtr *int
129+
clonedNilPtr := deepCloneValue(reflect.ValueOf(nilPtr))
130+
if !clonedNilPtr.IsNil() {
131+
t.Fatalf("cloned nil pointer should be nil")
132+
}
133+
134+
var nilMap map[string]int
135+
clonedNilMap := deepCloneValue(reflect.ValueOf(nilMap))
136+
if !clonedNilMap.IsNil() {
137+
t.Fatalf("cloned nil map should be nil")
138+
}
139+
140+
var nilSlice []int
141+
clonedNilSlice := deepCloneValue(reflect.ValueOf(nilSlice))
142+
if !clonedNilSlice.IsNil() {
143+
t.Fatalf("cloned nil slice should be nil")
144+
}
145+
146+
// 走到 interface nil 分支。
147+
nilIfaceValue := reflect.New(reflect.TypeOf((*any)(nil)).Elem()).Elem()
148+
clonedNilIface := deepCloneValue(nilIfaceValue)
149+
if !clonedNilIface.IsNil() {
150+
t.Fatalf("cloned nil interface should be nil")
151+
}
152+
153+
arr := [2]map[string]int{
154+
{"a": 1},
155+
{"b": 2},
156+
}
157+
clonedArr := deepCloneValue(reflect.ValueOf(arr)).Interface().([2]map[string]int)
158+
clonedArr[0]["a"] = 99
159+
if arr[0]["a"] != 1 {
160+
t.Fatalf("array nested map shared, got %d, want 1", arr[0]["a"])
161+
}
162+
163+
// time.Time 的内部字段不可 set,可覆盖 struct 分支中的 CanSet=false 路径。
164+
valueWithTime := struct {
165+
When time.Time
166+
}{
167+
When: time.Now(),
168+
}
169+
clonedWithTime := deepCloneValue(reflect.ValueOf(valueWithTime)).Interface().(struct {
170+
When time.Time
171+
})
172+
if !clonedWithTime.When.Equal(valueWithTime.When) {
173+
t.Fatalf("cloned struct with time mismatch")
174+
}
175+
}

internal/runtime/hooks/errors.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package hooks
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
var (
9+
// ErrHookAlreadyExists 表示注册了重复 hook ID。
10+
ErrHookAlreadyExists = errors.New("hook already exists")
11+
// ErrHookNotFound 表示待删除 hook 不存在。
12+
ErrHookNotFound = errors.New("hook not found")
13+
// ErrInvalidHookSpec 表示 HookSpec 未通过校验。
14+
ErrInvalidHookSpec = errors.New("invalid hook spec")
15+
)
16+
17+
// wrapInvalidSpec 将参数化错误统一包装为 ErrInvalidHookSpec。
18+
func wrapInvalidSpec(format string, args ...any) error {
19+
return fmt.Errorf("%w: %s", ErrInvalidHookSpec, fmt.Sprintf(format, args...))
20+
}

internal/runtime/hooks/events.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package hooks
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// HookEventType 标识 hook 事件类型。
9+
type HookEventType string
10+
11+
const (
12+
// HookEventStarted 表示 hook 执行开始事件。
13+
HookEventStarted HookEventType = "hook_started"
14+
// HookEventFinished 表示 hook 执行结束事件(pass/block)。
15+
HookEventFinished HookEventType = "hook_finished"
16+
// HookEventFailed 表示 hook 执行失败事件。
17+
HookEventFailed HookEventType = "hook_failed"
18+
)
19+
20+
// HookEvent 描述 hook 执行过程中的结构化事件。
21+
type HookEvent struct {
22+
Type HookEventType
23+
HookID string
24+
Point HookPoint
25+
Scope HookScope
26+
Kind HookKind
27+
Mode HookMode
28+
Status HookResultStatus
29+
StartedAt time.Time
30+
DurationMS int64
31+
Error string
32+
}
33+
34+
// EventEmitter 抽象 hook 事件发射器,避免依赖 runtime.Service。
35+
type EventEmitter interface {
36+
EmitHookEvent(ctx context.Context, event HookEvent) error
37+
}

0 commit comments

Comments
 (0)