Files
kandev/apps/backend/internal/workflow/models/session_config.go
T
2026-08-02 22:32:56 +01:00

208 lines
6.8 KiB
Go

package models
import (
"fmt"
"strings"
)
// OnEnterConfigureSession applies a conditional configuration to the task's
// original session without selecting or creating another session.
const OnEnterConfigureSession OnEnterActionType = "configure_session"
// ConfigureSessionOperation describes the effect of a conditional session
// configuration rule.
type ConfigureSessionOperation string
const (
ConfigureSessionSet ConfigureSessionOperation = "set"
ConfigureSessionKeep ConfigureSessionOperation = "keep"
ConfigureSessionRestoreOriginal ConfigureSessionOperation = "restore_original"
)
// ConfigureSessionRule is the typed form of a configure_session action rule.
// The persisted workflow contract remains the existing action Config JSON map;
// this type is used after validation at runtime boundaries.
type ConfigureSessionRule struct {
AgentName string
Operation ConfigureSessionOperation
Model string
ConfigOptions map[string]string
}
// ParseConfigureSessionRules validates and decodes a configure_session action.
func ParseConfigureSessionRules(action OnEnterAction) ([]ConfigureSessionRule, error) {
if action.Type != OnEnterConfigureSession {
return nil, fmt.Errorf("action type %q is not configure_session", action.Type)
}
if action.Config == nil {
return nil, fmt.Errorf("configure_session requires a rules array")
}
rawRules, ok := action.Config["rules"]
if !ok {
return nil, fmt.Errorf("configure_session requires a rules array")
}
var items []interface{}
switch typed := rawRules.(type) {
case []interface{}:
items = typed
case []map[string]interface{}:
items = make([]interface{}, len(typed))
for i := range typed {
items[i] = typed[i]
}
case []ConfigureSessionRule:
items = make([]interface{}, len(typed))
for i := range typed {
items[i] = typed[i]
}
default:
return nil, fmt.Errorf("configure_session rules has unexpected type %T", rawRules)
}
if len(items) == 0 {
return nil, fmt.Errorf("configure_session requires at least one rule")
}
rules := make([]ConfigureSessionRule, 0, len(items))
seenFamilies := make(map[string]struct{}, len(items))
for index, item := range items {
rule, err := parseConfigureSessionRule(item)
if err != nil {
return nil, fmt.Errorf("configure_session rule %d: %w", index, err)
}
if _, exists := seenFamilies[rule.AgentName]; exists {
return nil, fmt.Errorf("configure_session has duplicate agent_name %q", rule.AgentName)
}
seenFamilies[rule.AgentName] = struct{}{}
rules = append(rules, rule)
}
return rules, nil
}
// ValidateWorkflowStep validates all on-enter invariants that must hold for
// both API writes and workflow imports.
func ValidateWorkflowStep(step *WorkflowStep) error {
if step == nil {
return fmt.Errorf("workflow step is required")
}
return ValidateStepEvents(step.Events, step.AgentProfileID != "")
}
// ValidateStepEvents validates on-enter action invariants for a step shape
// that does not carry a full WorkflowStep, such as a portable export.
func ValidateStepEvents(events StepEvents, hasAgentProfile bool) error {
configureCount := 0
for _, action := range events.OnEnter {
switch action.Type {
case OnEnterConfigureSession:
configureCount++
if hasAgentProfile {
return fmt.Errorf("configure_session cannot be combined with agent_profile_id")
}
if _, err := ParseConfigureSessionRules(action); err != nil {
return err
}
case OnEnterSetSessionMode:
if mode, _ := action.Config["mode"].(string); strings.TrimSpace(mode) == "" {
return fmt.Errorf("set_session_mode requires a non-empty string \"mode\" config")
}
}
}
if configureCount > 1 {
return fmt.Errorf("workflow step may contain at most one configure_session action")
}
return nil
}
func parseConfigureSessionRule(raw interface{}) (ConfigureSessionRule, error) {
if typed, ok := raw.(ConfigureSessionRule); ok {
return validateConfigureSessionRule(typed, typed.Model != "", typed.ConfigOptions != nil)
}
values, ok := raw.(map[string]interface{})
if !ok {
return ConfigureSessionRule{}, fmt.Errorf("must be an object")
}
agentName, ok := values["agent_name"].(string)
if !ok || strings.TrimSpace(agentName) == "" {
return ConfigureSessionRule{}, fmt.Errorf("agent_name must be a non-empty string")
}
operationValue, ok := values["operation"].(string)
if !ok || strings.TrimSpace(operationValue) == "" {
return ConfigureSessionRule{}, fmt.Errorf("operation must be a non-empty string")
}
rule := ConfigureSessionRule{
AgentName: strings.TrimSpace(agentName),
Operation: ConfigureSessionOperation(operationValue),
}
modelValue, hasModel := values["model"]
if hasModel {
model, ok := modelValue.(string)
if !ok {
return ConfigureSessionRule{}, fmt.Errorf("model must be a string")
}
rule.Model = model
}
optionsValue, hasOptions := values["config_options"]
if hasOptions {
options, err := parseConfigureSessionOptions(optionsValue)
if err != nil {
return ConfigureSessionRule{}, err
}
rule.ConfigOptions = options
}
return validateConfigureSessionRule(rule, hasModel, hasOptions)
}
func parseConfigureSessionOptions(raw interface{}) (map[string]string, error) {
options := make(map[string]string)
switch typed := raw.(type) {
case map[string]string:
for key, value := range typed {
options[key] = value
}
case map[string]interface{}:
for key, value := range typed {
stringValue, ok := value.(string)
if !ok {
return nil, fmt.Errorf("config_options[%q] must be a string", key)
}
options[key] = stringValue
}
default:
return nil, fmt.Errorf("config_options must be an object")
}
return options, nil
}
func validateConfigureSessionRule(rule ConfigureSessionRule, hasModel, hasOptions bool) (ConfigureSessionRule, error) {
rule.AgentName = strings.TrimSpace(rule.AgentName)
rule.Model = strings.TrimSpace(rule.Model)
if rule.AgentName == "" {
return ConfigureSessionRule{}, fmt.Errorf("agent_name must be a non-empty string")
}
if rule.ConfigOptions == nil {
rule.ConfigOptions = map[string]string{}
}
for key, value := range rule.ConfigOptions {
if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" {
return ConfigureSessionRule{}, fmt.Errorf("config_options keys and values must be non-empty strings")
}
}
switch rule.Operation {
case ConfigureSessionSet:
if strings.TrimSpace(rule.Model) == "" && len(rule.ConfigOptions) == 0 {
return ConfigureSessionRule{}, fmt.Errorf("set requires a non-empty model or config_options")
}
case ConfigureSessionKeep, ConfigureSessionRestoreOriginal:
if hasModel || hasOptions || strings.TrimSpace(rule.Model) != "" || len(rule.ConfigOptions) > 0 {
return ConfigureSessionRule{}, fmt.Errorf("%s does not accept model or config_options", rule.Operation)
}
default:
return ConfigureSessionRule{}, fmt.Errorf("operation %q must be set, keep, or restore_original", rule.Operation)
}
return rule, nil
}