Skip to content

Commit f684922

Browse files
committed
kernel go framework stuff
1 parent 0df270d commit f684922

3 files changed

Lines changed: 392 additions & 0 deletions

File tree

appframework.go

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
package kernel
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"reflect"
8+
"sync"
9+
)
10+
11+
// KernelContext contains metadata that is propagated through a Go context.Context.
12+
// At the moment it only exposes the invocation ID of the running action, but this
13+
// struct can be extended in the future as we add more metadata.
14+
//
15+
// Use kernel.WithInvocationID to attach a KernelContext to an existing
16+
// context.Context and kernel.Context to retrieve it back.
17+
//
18+
// ctx := kernel.WithInvocationID(context.Background(), "inv-123")
19+
// kctx := kernel.Context(ctx) // => KernelContext{InvocationID: "inv-123"}
20+
//
21+
// End-users typically won't build the context themselves – the runtime that
22+
// executes actions will do it – but providing the helpers here allows for unit
23+
// testing and for advanced users to craft their own contexts when needed.
24+
//
25+
// NOTE: we intentionally keep the struct exported so that client code can
26+
// inspect / copy it if they need to.
27+
type KernelContext struct {
28+
InvocationID string
29+
}
30+
31+
type kernelCtxKey struct{}
32+
33+
// WithInvocationID returns a child context that contains a KernelContext with
34+
// the provided invocation id.
35+
func WithInvocationID(ctx context.Context, invocationID string) context.Context {
36+
return context.WithValue(ctx, kernelCtxKey{}, KernelContext{InvocationID: invocationID})
37+
}
38+
39+
// Context extracts the KernelContext stored inside the passed in ctx. If the
40+
// context was not carrying any KernelContext value, the zero value is
41+
// returned.
42+
func Context(ctx context.Context) KernelContext {
43+
if v, ok := ctx.Value(kernelCtxKey{}).(KernelContext); ok {
44+
return v
45+
}
46+
return KernelContext{}
47+
}
48+
49+
// -----------------------------------------------------------------------------
50+
// Global App Registry
51+
// -----------------------------------------------------------------------------
52+
53+
type appRegistry struct {
54+
mu sync.RWMutex
55+
apps map[string]*KernelApp
56+
}
57+
58+
func (r *appRegistry) registerApp(app *KernelApp) {
59+
r.mu.Lock()
60+
defer r.mu.Unlock()
61+
r.apps[app.Name] = app
62+
}
63+
64+
func (r *appRegistry) getApp(name string) (*KernelApp, bool) {
65+
r.mu.RLock()
66+
defer r.mu.RUnlock()
67+
app, ok := r.apps[name]
68+
return app, ok
69+
}
70+
71+
func (r *appRegistry) getApps() []*KernelApp {
72+
r.mu.RLock()
73+
defer r.mu.RUnlock()
74+
out := make([]*KernelApp, 0, len(r.apps))
75+
for _, app := range r.apps {
76+
out = append(out, app)
77+
}
78+
return out
79+
}
80+
81+
var globalRegistry = &appRegistry{apps: make(map[string]*KernelApp)}
82+
83+
// App returns a *KernelApp with the given name, creating it if necessary. The
84+
// returned app is automatically registered in the global registry so that the
85+
// runtime can discover it later.
86+
func App(name string) *KernelApp {
87+
if existing, ok := globalRegistry.getApp(name); ok {
88+
return existing
89+
}
90+
app := &KernelApp{
91+
Name: name,
92+
actions: make(map[string]KernelAction),
93+
}
94+
globalRegistry.registerApp(app)
95+
return app
96+
}
97+
98+
// Apps returns a slice with all the apps currently registered in the global registry.
99+
func Apps() []*KernelApp { return globalRegistry.getApps() }
100+
101+
// GetApp retrieves an app by name from the global registry. It returns nil if
102+
// the app could not be found.
103+
func GetApp(name string) *KernelApp {
104+
if app, ok := globalRegistry.getApp(name); ok {
105+
return app
106+
}
107+
return nil
108+
}
109+
110+
// Export returns a serialisable representation of the current registry. It is
111+
// primarily useful for debugging and testing.
112+
func Export() KernelJSON {
113+
apps := make([]KernelAppJSON, 0, len(globalRegistry.apps))
114+
for _, a := range Apps() {
115+
apps = append(apps, a.toJSON())
116+
}
117+
return KernelJSON{Apps: apps}
118+
}
119+
120+
// ExportJSON exports the registry to a JSON string. The JSON is indented with
121+
// two spaces to make it human-readable.
122+
func ExportJSON() string {
123+
raw, _ := json.MarshalIndent(Export(), "", " ")
124+
return string(raw)
125+
}
126+
127+
// -----------------------------------------------------------------------------
128+
// Kernel App & Actions
129+
// -----------------------------------------------------------------------------
130+
131+
// KernelAction wraps the user-provided handler together with some metadata that
132+
// the runtime may need when invoking the action.
133+
//
134+
// The internal exec function brings all handlers (with or without inputs /
135+
// outputs) to a single canonical signature so that the runtime can treat them
136+
// uniformly.
137+
type KernelAction struct {
138+
Name string
139+
140+
// handler is the original user-provided function so that we can surface it
141+
// back when needed (for example when generating reflection based stubs).
142+
handler any
143+
144+
// exec adapts every supported signature to func(ctx, input) (output, err).
145+
exec func(ctx context.Context, input any) (any, error)
146+
}
147+
148+
// Exec executes the adapted handler. It is **not** normally called by end-users
149+
// but is exported to simplify unit testing.
150+
func (a KernelAction) Exec(ctx context.Context, input any) (any, error) { // nolint: revive // false positive on stutter
151+
return a.exec(ctx, input)
152+
}
153+
154+
// KernelApp represents a collection of actions that belong together.
155+
//
156+
// Users obtain an instance by calling kernel.App("my-app").
157+
//
158+
// All methods on KernelApp are safe for concurrent use.
159+
type KernelApp struct {
160+
Name string
161+
162+
mu sync.RWMutex
163+
actions map[string]KernelAction
164+
}
165+
166+
// GetActions returns a copy of all registered actions.
167+
func (a *KernelApp) GetActions() []KernelAction {
168+
a.mu.RLock()
169+
defer a.mu.RUnlock()
170+
out := make([]KernelAction, 0, len(a.actions))
171+
for _, act := range a.actions {
172+
out = append(out, act)
173+
}
174+
return out
175+
}
176+
177+
// GetAction retrieves a single action by name. The second return value is
178+
// false when the action doesn't exist.
179+
func (a *KernelApp) GetAction(name string) (KernelAction, bool) {
180+
a.mu.RLock()
181+
defer a.mu.RUnlock()
182+
act, ok := a.actions[name]
183+
return act, ok
184+
}
185+
186+
// --- Action registration helpers ------------------------------------------------
187+
188+
// Action registers an action in the app. The handler must have one of the
189+
// following signatures:
190+
//
191+
// 1. func(context.Context) error
192+
// 2. func(context.Context) (Out, error)
193+
// 3. func(context.Context, In) error
194+
// 4. func(context.Context, In) (Out, error)
195+
//
196+
// "In" and "Out" can be any types (including struct{}).
197+
//
198+
// The method panics if the provided handler does not match any of the expected
199+
// signatures. We choose to panic instead of returning an error because action
200+
// registration happens at init-time and panicking provides immediate feedback
201+
// to the developer.
202+
func (a *KernelApp) Action(name string, handler any) {
203+
// Validate handler signature via reflection and create a wrapper that
204+
// normalises to func(ctx, input) (output, error)
205+
wrapper := buildActionWrapper(name, handler)
206+
a.addAction(name, handler, wrapper)
207+
}
208+
209+
// buildActionWrapper analyses the handler's type and returns a wrapper with the
210+
// canonical signature used internally by the runtime.
211+
func buildActionWrapper(name string, handler any) func(context.Context, any) (any, error) {
212+
hv := reflect.ValueOf(handler)
213+
ht := hv.Type()
214+
215+
if ht.Kind() != reflect.Func {
216+
panic(fmt.Sprintf("action %s: handler must be a function", name))
217+
}
218+
219+
// All allowed signatures start with a context.Context parameter.
220+
if ht.NumIn() == 0 {
221+
panic(fmt.Sprintf("action %s: handler must accept context.Context", name))
222+
}
223+
firstParam := ht.In(0)
224+
if !firstParam.Implements(reflect.TypeOf((*context.Context)(nil)).Elem()) {
225+
panic(fmt.Sprintf("action %s: first parameter must be context.Context", name))
226+
}
227+
228+
switch {
229+
// 1. func(ctx) error
230+
case ht.NumIn() == 1 && ht.NumOut() == 1 && ht.Out(0) == reflect.TypeOf((*error)(nil)).Elem():
231+
return func(ctx context.Context, _ any) (any, error) {
232+
out := hv.Call([]reflect.Value{reflect.ValueOf(ctx)})
233+
err, _ := out[0].Interface().(error)
234+
return nil, err
235+
}
236+
237+
// 2. func(ctx) (Out, error)
238+
case ht.NumIn() == 1 && ht.NumOut() == 2 && ht.Out(1) == reflect.TypeOf((*error)(nil)).Elem():
239+
return func(ctx context.Context, _ any) (any, error) {
240+
outs := hv.Call([]reflect.Value{reflect.ValueOf(ctx)})
241+
result := outs[0].Interface()
242+
err, _ := outs[1].Interface().(error)
243+
return result, err
244+
}
245+
246+
// 3. func(ctx, In) error
247+
case ht.NumIn() == 2 && ht.NumOut() == 1 && ht.Out(0) == reflect.TypeOf((*error)(nil)).Elem():
248+
inType := ht.In(1)
249+
return func(ctx context.Context, raw any) (any, error) {
250+
// Validate input type at runtime.
251+
if raw == nil {
252+
// Zero value of the expected type.
253+
raw = reflect.Zero(inType).Interface()
254+
}
255+
rv := reflect.ValueOf(raw)
256+
if !rv.IsValid() || !rv.Type().AssignableTo(inType) {
257+
return nil, fmt.Errorf("action %s: input type mismatch", name)
258+
}
259+
outs := hv.Call([]reflect.Value{reflect.ValueOf(ctx), rv})
260+
err, _ := outs[0].Interface().(error)
261+
return nil, err
262+
}
263+
264+
// 4. func(ctx, In) (Out, error)
265+
case ht.NumIn() == 2 && ht.NumOut() == 2 && ht.Out(1) == reflect.TypeOf((*error)(nil)).Elem():
266+
inType := ht.In(1)
267+
return func(ctx context.Context, raw any) (any, error) {
268+
if raw == nil {
269+
raw = reflect.Zero(inType).Interface()
270+
}
271+
rv := reflect.ValueOf(raw)
272+
if !rv.IsValid() || !rv.Type().AssignableTo(inType) {
273+
return nil, fmt.Errorf("action %s: input type mismatch", name)
274+
}
275+
outs := hv.Call([]reflect.Value{reflect.ValueOf(ctx), rv})
276+
result := outs[0].Interface()
277+
err, _ := outs[1].Interface().(error)
278+
return result, err
279+
}
280+
default:
281+
panic(fmt.Sprintf("action %s: handler has an unsupported signature", name))
282+
}
283+
}
284+
285+
// addAction centralises the logic of mutating the app.
286+
func (a *KernelApp) addAction(name string, handler any, exec func(context.Context, any) (any, error)) {
287+
a.mu.Lock()
288+
defer a.mu.Unlock()
289+
if _, exists := a.actions[name]; exists {
290+
panic(fmt.Sprintf("action with name %q already registered in app %q", name, a.Name))
291+
}
292+
a.actions[name] = KernelAction{Name: name, handler: handler, exec: exec}
293+
}
294+
295+
// --- JSON helpers (for tests / debug) -------------------------------------------
296+
297+
type KernelActionJSON struct {
298+
Name string `json:"name"`
299+
}
300+
301+
type KernelAppJSON struct {
302+
Name string `json:"name"`
303+
Actions []KernelActionJSON `json:"actions"`
304+
}
305+
306+
type KernelJSON struct {
307+
Apps []KernelAppJSON `json:"apps"`
308+
}
309+
310+
func (a *KernelApp) toJSON() KernelAppJSON {
311+
acts := make([]KernelActionJSON, 0, len(a.actions))
312+
for _, act := range a.GetActions() {
313+
acts = append(acts, KernelActionJSON{Name: act.Name})
314+
}
315+
return KernelAppJSON{Name: a.Name, Actions: acts}
316+
}

examples/basic/basic.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package basic
2+
3+
import (
4+
"context"
5+
6+
kernel "github.com/onkernel/kernel-go-sdk"
7+
)
8+
9+
// PageTitleInput represents the input expected by the get-page-title action.
10+
type PageTitleInput struct {
11+
URL string `json:"url"`
12+
}
13+
14+
// PageTitleOutput represents the output returned by the get-page-title action.
15+
type PageTitleOutput struct {
16+
Title string `json:"title"`
17+
}
18+
19+
var app = kernel.App("go-basic")
20+
21+
func init() {
22+
// Signature 1: ctx only => error
23+
app.Action("ping", func(ctx context.Context) error {
24+
// do nothing
25+
return nil
26+
})
27+
28+
// Signature 2: ctx only => (Out, err)
29+
app.Action("get-random-number", func(ctx context.Context) (int, error) {
30+
return 42, nil
31+
})
32+
33+
// Signature 3: ctx, In => error
34+
app.Action("print-url", func(ctx context.Context, input PageTitleInput) error {
35+
_ = input.URL // pretend to do something
36+
return nil
37+
})
38+
39+
// Signature 4: ctx, In => (Out, err)
40+
app.Action("get-page-title", func(ctx context.Context, input PageTitleInput) (PageTitleOutput, error) {
41+
// This is just a stub implementation used for demonstration purposes.
42+
return PageTitleOutput{Title: "Example Title for " + input.URL}, nil
43+
})
44+
}

examples/basic/basic_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package basic_test
2+
3+
import (
4+
"testing"
5+
6+
kernel "github.com/onkernel/kernel-go-sdk"
7+
_ "github.com/onkernel/kernel-go-sdk/examples/basic" // import for side-effects (init)
8+
)
9+
10+
func TestBasicAppRegistration(t *testing.T) {
11+
app := kernel.GetApp("go-basic")
12+
if app == nil {
13+
t.Fatalf("expected app to be registered, got nil")
14+
}
15+
16+
actions := app.GetActions()
17+
if len(actions) != 4 {
18+
t.Fatalf("expected 4 actions registered, got %d", len(actions))
19+
}
20+
21+
expected := map[string]bool{
22+
"ping": true,
23+
"get-random-number": true,
24+
"print-url": true,
25+
"get-page-title": true,
26+
}
27+
for _, act := range actions {
28+
if !expected[act.Name] {
29+
t.Fatalf("unexpected action name %s", act.Name)
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)