This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/workflow_hooks.go
Jeffrey Smith 1b08769f09
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 1m56s
CI/CD / test-sqlite (pull_request) Successful in 2m47s
CI/CD / build-and-deploy (pull_request) Has been skipped
Feat v0.3.0 workflow schema redesign + stage CRUD modernization
Drop chat-era columns (persona_id, history_mode) from workflow_stages.
Rename transition_rules → stage_config. Add audience, stage_type,
starlark_hook, branch_rules columns. Update stage_mode CHECK to
(form, review, delegated, automated). All Go stores, handlers,
routing engine, Starlark module, and frontend editors updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:48:14 +00:00

172 lines
4.7 KiB
Go

package handlers
import (
"context"
"encoding/json"
"log"
"go.starlark.net/starlark"
"switchboard-core/sandbox"
"switchboard-core/store"
)
// ── on_advance Hook ─────────────────────────
//
// Fires synchronously after a stage transition succeeds.
// The hook can enrich/transform stage_data or reject the transition.
//
// Config in stage_config:
// {"on_advance": {"package_id": "...", "entry_point": "on_advance"}}
//
// Hook receives dict: {stage_data, previous_stage, current_stage, instance_id}
// Hook returns: {stage_data: {...}} (enriched), None (no change),
// or {error: "msg"} (reject — caller should handle rollback)
// OnAdvanceHookConfig is the on_advance section of stage_config.
type OnAdvanceHookConfig struct {
PackageID string `json:"package_id"`
EntryPoint string `json:"entry_point"`
}
// StageConfigOnAdvance extracts on_advance config from stage_config JSON.
type StageConfigOnAdvance 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,
previousStageConfig json.RawMessage,
instanceID string,
previousStage, currentStage int,
stageData json.RawMessage,
) *OnAdvanceResult {
if runner == nil || stores.Packages == nil {
return nil
}
var cfg StageConfigOnAdvance
if len(previousStageConfig) > 0 {
_ = json.Unmarshal(previousStageConfig, &cfg)
}
if cfg.OnAdvance == nil || cfg.OnAdvance.PackageID == "" || cfg.OnAdvance.EntryPoint == "" {
return nil
}
pkg, err := stores.Packages.Get(ctx, cfg.OnAdvance.PackageID)
if err != nil || pkg == nil {
log.Printf("[workflow-hooks] on_advance: package %s not found", cfg.OnAdvance.PackageID)
return nil
}
// Build context dict for the hook
ctxDict := starlark.NewDict(4)
_ = ctxDict.SetKey(starlark.String("instance_id"), starlark.String(instanceID))
_ = 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, cfg.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()
}
}