Feat v0.3.0 workflow schema (#14)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #14.
This commit is contained in:
@@ -11,26 +11,26 @@ import (
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// ── on_advance Hook (v0.35.0) ───────────────
|
||||
// ── on_advance Hook ─────────────────────────
|
||||
//
|
||||
// Fires synchronously after a stage transition succeeds.
|
||||
// The hook can enrich/transform stage_data or reject the transition.
|
||||
//
|
||||
// Config in transition_rules:
|
||||
// Config in stage_config:
|
||||
// {"on_advance": {"package_id": "...", "entry_point": "on_advance"}}
|
||||
//
|
||||
// Hook receives dict: {stage_data, previous_stage, current_stage, channel_id}
|
||||
// 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 transition_rules.
|
||||
// OnAdvanceHookConfig is the on_advance section of stage_config.
|
||||
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 {
|
||||
// StageConfigOnAdvance extracts on_advance config from stage_config JSON.
|
||||
type StageConfigOnAdvance struct {
|
||||
OnAdvance *OnAdvanceHookConfig `json:"on_advance,omitempty"`
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ func FireOnAdvanceHook(
|
||||
ctx context.Context,
|
||||
stores store.Stores,
|
||||
runner *sandbox.Runner,
|
||||
previousStageRules json.RawMessage,
|
||||
channelID string,
|
||||
previousStageConfig json.RawMessage,
|
||||
instanceID string,
|
||||
previousStage, currentStage int,
|
||||
stageData json.RawMessage,
|
||||
) *OnAdvanceResult {
|
||||
@@ -55,23 +55,23 @@ func FireOnAdvanceHook(
|
||||
return nil
|
||||
}
|
||||
|
||||
var rules TransitionRulesOnAdvance
|
||||
if len(previousStageRules) > 0 {
|
||||
_ = json.Unmarshal(previousStageRules, &rules)
|
||||
var cfg StageConfigOnAdvance
|
||||
if len(previousStageConfig) > 0 {
|
||||
_ = json.Unmarshal(previousStageConfig, &cfg)
|
||||
}
|
||||
if rules.OnAdvance == nil || rules.OnAdvance.PackageID == "" || rules.OnAdvance.EntryPoint == "" {
|
||||
if cfg.OnAdvance == nil || cfg.OnAdvance.PackageID == "" || cfg.OnAdvance.EntryPoint == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkg, err := stores.Packages.Get(ctx, rules.OnAdvance.PackageID)
|
||||
pkg, err := stores.Packages.Get(ctx, cfg.OnAdvance.PackageID)
|
||||
if err != nil || pkg == nil {
|
||||
log.Printf("[workflow-hooks] on_advance: package %s not found", rules.OnAdvance.PackageID)
|
||||
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("channel_id"), starlark.String(channelID))
|
||||
_ = 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))
|
||||
|
||||
@@ -83,7 +83,7 @@ func FireOnAdvanceHook(
|
||||
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
||||
}
|
||||
|
||||
val, _, err := runner.CallEntryPoint(ctx, pkg, rules.OnAdvance.EntryPoint,
|
||||
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)
|
||||
|
||||
@@ -54,28 +54,35 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
|
||||
"name": s.Name,
|
||||
"ordinal": s.Ordinal,
|
||||
"stage_mode": s.StageMode,
|
||||
"history_mode": s.HistoryMode,
|
||||
"audience": s.Audience,
|
||||
"stage_type": s.StageType,
|
||||
"auto_transition": s.AutoTransition,
|
||||
}
|
||||
if s.PersonaID != nil {
|
||||
sd["persona_id"] = *s.PersonaID
|
||||
}
|
||||
if s.AssignmentTeamID != nil {
|
||||
sd["assignment_team_id"] = *s.AssignmentTeamID
|
||||
}
|
||||
if s.SurfacePkgID != nil {
|
||||
sd["surface_pkg_id"] = *s.SurfacePkgID
|
||||
}
|
||||
if s.StarlarkHook != nil {
|
||||
sd["starlark_hook"] = *s.StarlarkHook
|
||||
}
|
||||
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
|
||||
var ft any
|
||||
if json.Unmarshal(s.FormTemplate, &ft) == nil {
|
||||
sd["form_template"] = ft
|
||||
}
|
||||
}
|
||||
if len(s.TransitionRules) > 0 && string(s.TransitionRules) != "{}" {
|
||||
var tr any
|
||||
if json.Unmarshal(s.TransitionRules, &tr) == nil {
|
||||
sd["transition_rules"] = tr
|
||||
if len(s.StageConfig) > 0 && string(s.StageConfig) != "{}" {
|
||||
var sc any
|
||||
if json.Unmarshal(s.StageConfig, &sc) == nil {
|
||||
sd["stage_config"] = sc
|
||||
}
|
||||
}
|
||||
if len(s.BranchRules) > 0 && string(s.BranchRules) != "[]" {
|
||||
var br any
|
||||
if json.Unmarshal(s.BranchRules, &br) == nil {
|
||||
sd["branch_rules"] = br
|
||||
}
|
||||
}
|
||||
stageDefs = append(stageDefs, sd)
|
||||
@@ -208,27 +215,34 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
||||
// Create stages from definition
|
||||
for _, s := range wfDef.Stages {
|
||||
st := &models.WorkflowStage{
|
||||
WorkflowID: workflowID,
|
||||
Ordinal: s.Ordinal,
|
||||
Name: s.Name,
|
||||
StageMode: s.StageMode,
|
||||
HistoryMode: s.HistoryMode,
|
||||
AutoTransition: s.AutoTransition,
|
||||
PersonaID: s.PersonaID,
|
||||
WorkflowID: workflowID,
|
||||
Ordinal: s.Ordinal,
|
||||
Name: s.Name,
|
||||
StageMode: s.StageMode,
|
||||
Audience: s.Audience,
|
||||
StageType: s.StageType,
|
||||
AutoTransition: s.AutoTransition,
|
||||
AssignmentTeamID: s.AssignmentTeamID,
|
||||
SurfacePkgID: s.SurfacePkgID,
|
||||
SurfacePkgID: s.SurfacePkgID,
|
||||
StarlarkHook: s.StarlarkHook,
|
||||
}
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeCustom
|
||||
st.StageMode = models.StageModeDelegated
|
||||
}
|
||||
if st.HistoryMode == "" {
|
||||
st.HistoryMode = "full"
|
||||
if st.Audience == "" {
|
||||
st.Audience = models.AudienceTeam
|
||||
}
|
||||
if st.StageType == "" {
|
||||
st.StageType = models.StageTypeSimple
|
||||
}
|
||||
if s.FormTemplate != nil {
|
||||
st.FormTemplate, _ = json.Marshal(s.FormTemplate)
|
||||
}
|
||||
if s.TransitionRules != nil {
|
||||
st.TransitionRules, _ = json.Marshal(s.TransitionRules)
|
||||
if s.StageConfig != nil {
|
||||
st.StageConfig, _ = json.Marshal(s.StageConfig)
|
||||
}
|
||||
if s.BranchRules != nil {
|
||||
st.BranchRules, _ = json.Marshal(s.BranchRules)
|
||||
}
|
||||
if err := stores.Workflows.CreateStage(reqCtx, st); err != nil {
|
||||
log.Printf("[workflow-pkg] failed to create stage %q: %v", s.Name, err)
|
||||
@@ -245,14 +259,16 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
||||
|
||||
// workflowPkgStage is the stage definition within a workflow package manifest.
|
||||
type workflowPkgStage struct {
|
||||
Name string `json:"name"`
|
||||
Ordinal int `json:"ordinal"`
|
||||
StageMode string `json:"stage_mode"`
|
||||
HistoryMode string `json:"history_mode"`
|
||||
AutoTransition bool `json:"auto_transition"`
|
||||
PersonaID *string `json:"persona_id,omitempty"`
|
||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||
FormTemplate any `json:"form_template,omitempty"`
|
||||
TransitionRules any `json:"transition_rules,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Ordinal int `json:"ordinal"`
|
||||
StageMode string `json:"stage_mode"`
|
||||
Audience string `json:"audience"`
|
||||
StageType string `json:"stage_type"`
|
||||
AutoTransition bool `json:"auto_transition"`
|
||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||
StarlarkHook *string `json:"starlark_hook,omitempty"`
|
||||
FormTemplate any `json:"form_template,omitempty"`
|
||||
StageConfig any `json:"stage_config,omitempty"`
|
||||
BranchRules any `json:"branch_rules,omitempty"`
|
||||
}
|
||||
|
||||
@@ -187,18 +187,33 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
if st.HistoryMode == "" {
|
||||
st.HistoryMode = "full"
|
||||
}
|
||||
if st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
||||
return
|
||||
}
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeCustom
|
||||
st.StageMode = models.StageModeDelegated
|
||||
}
|
||||
if !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"})
|
||||
return
|
||||
}
|
||||
if st.Audience == "" {
|
||||
st.Audience = models.AudienceTeam
|
||||
}
|
||||
if !models.ValidAudiences[st.Audience] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
|
||||
return
|
||||
}
|
||||
if st.StageType == "" {
|
||||
st.StageType = models.StageTypeSimple
|
||||
}
|
||||
if !models.ValidStageTypes[st.StageType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
||||
return
|
||||
}
|
||||
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
||||
return
|
||||
}
|
||||
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
||||
return
|
||||
}
|
||||
if st.Ordinal == 0 {
|
||||
@@ -222,12 +237,24 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
||||
}
|
||||
st.ID = c.Param("sid")
|
||||
st.WorkflowID = c.Param("id")
|
||||
if st.HistoryMode != "" && st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
||||
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"})
|
||||
return
|
||||
}
|
||||
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"})
|
||||
if st.Audience != "" && !models.ValidAudiences[st.Audience] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
|
||||
return
|
||||
}
|
||||
if st.StageType != "" && !models.ValidStageTypes[st.StageType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
||||
return
|
||||
}
|
||||
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
||||
return
|
||||
}
|
||||
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
||||
return
|
||||
}
|
||||
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user