package handlers import ( "context" "encoding/json" "log" "go.starlark.net/starlark" "switchboard-core/sandbox" "switchboard-core/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() } }