Changeset 0.35.0 (#209)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
171
server/handlers/workflow_hooks.go
Normal file
171
server/handlers/workflow_hooks.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"chat-switchboard/sandbox"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── on_advance Hook (v0.35.0) ───────────────
|
||||
//
|
||||
// Fires synchronously after a stage transition succeeds.
|
||||
// The hook can enrich/transform stage_data or reject the transition.
|
||||
//
|
||||
// Config in transition_rules:
|
||||
// {"on_advance": {"package_id": "...", "entry_point": "on_advance"}}
|
||||
//
|
||||
// Hook receives dict: {stage_data, previous_stage, current_stage, channel_id}
|
||||
// Hook returns: {stage_data: {...}} (enriched), None (no change),
|
||||
// or {error: "msg"} (reject — caller should handle rollback)
|
||||
|
||||
// OnAdvanceHookConfig is the on_advance section of transition_rules.
|
||||
type OnAdvanceHookConfig struct {
|
||||
PackageID string `json:"package_id"`
|
||||
EntryPoint string `json:"entry_point"`
|
||||
}
|
||||
|
||||
// TransitionRulesOnAdvance extracts on_advance config from transition_rules JSON.
|
||||
type TransitionRulesOnAdvance struct {
|
||||
OnAdvance *OnAdvanceHookConfig `json:"on_advance,omitempty"`
|
||||
}
|
||||
|
||||
// OnAdvanceResult is the outcome of firing the on_advance hook.
|
||||
type OnAdvanceResult struct {
|
||||
EnrichedData json.RawMessage // updated stage_data (nil = no change)
|
||||
Error string // non-empty = hook rejected the transition
|
||||
}
|
||||
|
||||
// FireOnAdvanceHook fires the on_advance Starlark hook for a stage transition.
|
||||
// Returns nil if no hook is configured or the runner is unavailable.
|
||||
func FireOnAdvanceHook(
|
||||
ctx context.Context,
|
||||
stores store.Stores,
|
||||
runner *sandbox.Runner,
|
||||
previousStageRules json.RawMessage,
|
||||
channelID string,
|
||||
previousStage, currentStage int,
|
||||
stageData json.RawMessage,
|
||||
) *OnAdvanceResult {
|
||||
if runner == nil || stores.Packages == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rules TransitionRulesOnAdvance
|
||||
if len(previousStageRules) > 0 {
|
||||
_ = json.Unmarshal(previousStageRules, &rules)
|
||||
}
|
||||
if rules.OnAdvance == nil || rules.OnAdvance.PackageID == "" || rules.OnAdvance.EntryPoint == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkg, err := stores.Packages.Get(ctx, rules.OnAdvance.PackageID)
|
||||
if err != nil || pkg == nil {
|
||||
log.Printf("[workflow-hooks] on_advance: package %s not found", rules.OnAdvance.PackageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build context dict for the hook
|
||||
ctxDict := starlark.NewDict(4)
|
||||
_ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID))
|
||||
_ = ctxDict.SetKey(starlark.String("previous_stage"), starlark.MakeInt(previousStage))
|
||||
_ = ctxDict.SetKey(starlark.String("current_stage"), starlark.MakeInt(currentStage))
|
||||
|
||||
// Parse stage_data into Starlark dict
|
||||
var dataMap map[string]interface{}
|
||||
if json.Unmarshal(stageData, &dataMap) == nil {
|
||||
_ = ctxDict.SetKey(starlark.String("stage_data"), jsonToStarlark(dataMap))
|
||||
} else {
|
||||
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
||||
}
|
||||
|
||||
val, _, err := runner.CallEntryPoint(ctx, pkg, rules.OnAdvance.EntryPoint,
|
||||
starlark.Tuple{ctxDict}, nil, nil)
|
||||
if err != nil {
|
||||
log.Printf("[workflow-hooks] on_advance hook error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return parseOnAdvanceResult(val)
|
||||
}
|
||||
|
||||
// parseOnAdvanceResult extracts enriched data or error from the Starlark return value.
|
||||
func parseOnAdvanceResult(val starlark.Value) *OnAdvanceResult {
|
||||
if val == nil || val == starlark.None {
|
||||
return nil
|
||||
}
|
||||
|
||||
d, ok := val.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &OnAdvanceResult{}
|
||||
|
||||
// Check for error
|
||||
if errVal, found, _ := d.Get(starlark.String("error")); found {
|
||||
if s, ok := errVal.(starlark.String); ok {
|
||||
result.Error = string(s)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Check for enriched stage_data
|
||||
if sdVal, found, _ := d.Get(starlark.String("stage_data")); found {
|
||||
if sd, ok := sdVal.(*starlark.Dict); ok {
|
||||
goMap := starlarkDictToMap(sd)
|
||||
if data, err := json.Marshal(goMap); err == nil {
|
||||
result.EnrichedData = data
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// starlarkDictToMap converts a Starlark dict to a Go map.
|
||||
func starlarkDictToMap(d *starlark.Dict) map[string]any {
|
||||
result := make(map[string]any, d.Len())
|
||||
for _, item := range d.Items() {
|
||||
k, ok := item[0].(starlark.String)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result[string(k)] = starlarkToGo(item[1])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// starlarkToGo converts a Starlark value to a Go value.
|
||||
func starlarkToGo(v starlark.Value) any {
|
||||
switch val := v.(type) {
|
||||
case starlark.NoneType:
|
||||
return nil
|
||||
case starlark.Bool:
|
||||
return bool(val)
|
||||
case starlark.Int:
|
||||
if i, ok := val.Int64(); ok {
|
||||
return i
|
||||
}
|
||||
return val.String()
|
||||
case starlark.Float:
|
||||
return float64(val)
|
||||
case starlark.String:
|
||||
return string(val)
|
||||
case *starlark.List:
|
||||
result := make([]any, val.Len())
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
result[i] = starlarkToGo(val.Index(i))
|
||||
}
|
||||
return result
|
||||
case *starlark.Dict:
|
||||
return starlarkDictToMap(val)
|
||||
default:
|
||||
return v.String()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user