- llm_router.go: Extract getStringFromMap, firstNonEmpty, paramResolver - buildCronSpec: 12 → 4 complexity - buildParameters: 9 → 5 complexity - routing_workflow.go: Extract stateMachine, stateResult types - RoutingWorkflow: 11 → 6 complexity - Separate executeTask/executePass/executeFail - notification.go: Extract checker interface pattern - DeploymentPreCheckActivity: 10 → 5 complexity - goCheckers() returns language-specific checkers - Added 7 new test cases for helper functions - Coverage: internal/routing 63.6% → 66.0%
This commit is contained in:
+76
-31
@@ -203,6 +203,70 @@ type DeploymentPreCheckOutput struct {
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// checker defines a deployment check
|
||||
type checker struct {
|
||||
name string
|
||||
types []string // which checkTypes trigger this checker
|
||||
isWarning bool // if true, failure goes to warnings not failures
|
||||
run func(ctx context.Context, path string) (string, error)
|
||||
}
|
||||
|
||||
// shouldRun checks if this checker should run for given checkType
|
||||
func (c *checker) shouldRun(checkType string) bool {
|
||||
for _, t := range c.types {
|
||||
if t == checkType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// goCheckers returns checkers for Go projects
|
||||
func goCheckers() []*checker {
|
||||
return []*checker{
|
||||
{
|
||||
name: "build",
|
||||
types: []string{"all", "build"},
|
||||
run: func(ctx context.Context, path string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "go", "build", "./...")
|
||||
cmd.Dir = path
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
types: []string{"all", "test"},
|
||||
run: func(ctx context.Context, path string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-short", "./...")
|
||||
cmd.Dir = path
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lint",
|
||||
types: []string{"all", "lint"},
|
||||
isWarning: true,
|
||||
run: func(ctx context.Context, path string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "go", "vet", "./...")
|
||||
cmd.Dir = path
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// detectProjectCheckers returns checkers based on project type
|
||||
func detectProjectCheckers(path string) []*checker {
|
||||
if _, err := os.Stat(fmt.Sprintf("%s/go.mod", path)); err == nil {
|
||||
return goCheckers()
|
||||
}
|
||||
// Add more project types here (Node, Python, etc.)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeploymentPreCheckActivity validates deployment readiness
|
||||
func DeploymentPreCheckActivity(ctx context.Context, in DeploymentPreCheckInput) (DeploymentPreCheckOutput, error) {
|
||||
logger := newActivityLogger(ctx)
|
||||
@@ -226,38 +290,19 @@ func DeploymentPreCheckActivity(ctx context.Context, in DeploymentPreCheckInput)
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// Check for Go project
|
||||
isGo := false
|
||||
if _, err := os.Stat(fmt.Sprintf("%s/go.mod", in.Path)); err == nil {
|
||||
isGo = true
|
||||
}
|
||||
|
||||
if isGo && (checkType == "all" || checkType == "build") {
|
||||
// Try go build
|
||||
cmd := exec.CommandContext(ctx, "go", "build", "./...")
|
||||
cmd.Dir = in.Path
|
||||
if buildOut, err := cmd.CombinedOutput(); err != nil {
|
||||
output.Passed = false
|
||||
output.Failures = append(output.Failures, fmt.Sprintf("build failed: %s", string(buildOut)))
|
||||
// Run applicable checkers
|
||||
for _, c := range detectProjectCheckers(in.Path) {
|
||||
if !c.shouldRun(checkType) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if isGo && (checkType == "all" || checkType == "test") {
|
||||
// Try go test
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-short", "./...")
|
||||
cmd.Dir = in.Path
|
||||
if testOut, err := cmd.CombinedOutput(); err != nil {
|
||||
output.Passed = false
|
||||
output.Failures = append(output.Failures, fmt.Sprintf("tests failed: %s", string(testOut)))
|
||||
}
|
||||
}
|
||||
|
||||
if isGo && (checkType == "all" || checkType == "lint") {
|
||||
// Try go vet
|
||||
cmd := exec.CommandContext(ctx, "go", "vet", "./...")
|
||||
cmd.Dir = in.Path
|
||||
if vetOut, err := cmd.CombinedOutput(); err != nil {
|
||||
output.Warnings = append(output.Warnings, fmt.Sprintf("vet issues: %s", string(vetOut)))
|
||||
if out, err := c.run(ctx, in.Path); err != nil {
|
||||
msg := fmt.Sprintf("%s failed: %s", c.name, out)
|
||||
if c.isWarning {
|
||||
output.Warnings = append(output.Warnings, msg)
|
||||
} else {
|
||||
output.Passed = false
|
||||
output.Failures = append(output.Failures, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+111
-64
@@ -324,44 +324,52 @@ func (r *LLMRouter) buildSpec(intent *Intent, input LLMRouterInput) (*WorkflowSp
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getStringFromMap safely extracts a string from a map
|
||||
func getStringFromMap(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty string from the list
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildCronSpec creates CronWorkflowSpec from intent
|
||||
func (r *LLMRouter) buildCronSpec(intent *Intent, input LLMRouterInput) (*CronWorkflowSpec, error) {
|
||||
// First build regular spec
|
||||
spec, err := r.buildSpec(intent, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get schedule - check both intent and parameters (LLM sometimes puts it in parameters)
|
||||
schedule := intent.CronSchedule
|
||||
if schedule == "" {
|
||||
if sched, ok := intent.Parameters["cronSchedule"].(string); ok {
|
||||
schedule = sched
|
||||
}
|
||||
}
|
||||
if schedule == "" {
|
||||
if sched, ok := spec.Input["cronSchedule"].(string); ok {
|
||||
schedule = sched
|
||||
delete(spec.Input, "cronSchedule") // Remove from input
|
||||
}
|
||||
}
|
||||
// Extract schedule from multiple sources
|
||||
schedule := firstNonEmpty(
|
||||
intent.CronSchedule,
|
||||
getStringFromMap(intent.Parameters, "cronSchedule"),
|
||||
getStringFromMap(spec.Input, "cronSchedule"),
|
||||
)
|
||||
|
||||
// Get timezone
|
||||
timezone := intent.CronTimezone
|
||||
if timezone == "" {
|
||||
if tz, ok := intent.Parameters["cronTimezone"].(string); ok {
|
||||
timezone = tz
|
||||
}
|
||||
}
|
||||
if timezone == "" {
|
||||
if tz, ok := spec.Input["cronTimezone"].(string); ok {
|
||||
timezone = tz
|
||||
delete(spec.Input, "cronTimezone") // Remove from input
|
||||
}
|
||||
}
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
// Extract timezone from multiple sources, default to UTC
|
||||
timezone := firstNonEmpty(
|
||||
intent.CronTimezone,
|
||||
getStringFromMap(intent.Parameters, "cronTimezone"),
|
||||
getStringFromMap(spec.Input, "cronTimezone"),
|
||||
"UTC",
|
||||
)
|
||||
|
||||
// Clean cron fields from input
|
||||
delete(spec.Input, "cronSchedule")
|
||||
delete(spec.Input, "cronTimezone")
|
||||
|
||||
return &CronWorkflowSpec{
|
||||
Name: spec.Name,
|
||||
@@ -376,44 +384,83 @@ func (r *LLMRouter) buildCronSpec(intent *Intent, input LLMRouterInput) (*CronWo
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildParameters creates parameter map for activity
|
||||
func (r *LLMRouter) buildParameters(act *ActivityMetadata, intent *Intent, stateIndex int) map[string]interface{} {
|
||||
params := make(map[string]interface{})
|
||||
// isCommonInputField checks if field name is a common workflow input
|
||||
func isCommonInputField(name string) bool {
|
||||
switch name {
|
||||
case "repo", "path", "branch":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for inputName, inputDef := range act.Inputs {
|
||||
// Check if parameter was extracted from intent
|
||||
if val, ok := intent.Parameters[inputName]; ok {
|
||||
params[inputName] = val
|
||||
continue
|
||||
}
|
||||
// paramResolver resolves activity parameters from multiple sources
|
||||
type paramResolver struct {
|
||||
intent *Intent
|
||||
kb *KnowledgeBase
|
||||
prevState string
|
||||
}
|
||||
|
||||
// Check for JSONPath reference from previous state
|
||||
if stateIndex > 0 {
|
||||
prevAct := intent.Activities[stateIndex-1]
|
||||
prevActDef := r.knowledgeBase.GetActivity(prevAct)
|
||||
|
||||
// Look for matching output from previous activity
|
||||
for outName := range prevActDef.Outputs {
|
||||
if outName == inputName || strings.EqualFold(outName, inputName) {
|
||||
params[inputName] = fmt.Sprintf("${%s.output.%s}", prevAct, outName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use default if available
|
||||
if params[inputName] == nil && inputDef.Default != nil {
|
||||
params[inputName] = inputDef.Default
|
||||
}
|
||||
|
||||
// Use input reference for common fields
|
||||
if params[inputName] == nil {
|
||||
if inputName == "repo" || inputName == "path" || inputName == "branch" {
|
||||
params[inputName] = fmt.Sprintf("${input.%s}", inputName)
|
||||
}
|
||||
}
|
||||
// resolve finds parameter value from intent, previous output, default, or input ref
|
||||
func (r *paramResolver) resolve(inputName string, inputDef InputField) interface{} {
|
||||
// 1. From intent parameters
|
||||
if val, ok := r.intent.Parameters[inputName]; ok {
|
||||
return val
|
||||
}
|
||||
|
||||
// 2. From previous state output
|
||||
if val := r.fromPrevOutput(inputName); val != nil {
|
||||
return val
|
||||
}
|
||||
|
||||
// 3. Default value
|
||||
if inputDef.Default != nil {
|
||||
return inputDef.Default
|
||||
}
|
||||
|
||||
// 4. Input reference for common fields
|
||||
if isCommonInputField(inputName) {
|
||||
return fmt.Sprintf("${input.%s}", inputName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fromPrevOutput checks if previous activity has matching output
|
||||
func (r *paramResolver) fromPrevOutput(inputName string) interface{} {
|
||||
if r.prevState == "" {
|
||||
return nil
|
||||
}
|
||||
prevActDef := r.kb.GetActivity(r.prevState)
|
||||
if prevActDef == nil {
|
||||
return nil
|
||||
}
|
||||
for outName := range prevActDef.Outputs {
|
||||
if outName == inputName || strings.EqualFold(outName, inputName) {
|
||||
return fmt.Sprintf("${%s.output.%s}", r.prevState, outName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildParameters creates parameter map for activity
|
||||
func (r *LLMRouter) buildParameters(act *ActivityMetadata, intent *Intent, stateIndex int) map[string]interface{} {
|
||||
var prevState string
|
||||
if stateIndex > 0 {
|
||||
prevState = intent.Activities[stateIndex-1]
|
||||
}
|
||||
|
||||
resolver := ¶mResolver{
|
||||
intent: intent,
|
||||
kb: r.knowledgeBase,
|
||||
prevState: prevState,
|
||||
}
|
||||
|
||||
params := make(map[string]interface{})
|
||||
for name, def := range act.Inputs {
|
||||
if val := resolver.resolve(name, def); val != nil {
|
||||
params[name] = val
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,193 @@ func TestBuildRetryPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStringFromMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m map[string]interface{}
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{"nil map", nil, "key", ""},
|
||||
{"missing key", map[string]interface{}{"a": "b"}, "key", ""},
|
||||
{"found string", map[string]interface{}{"key": "value"}, "key", "value"},
|
||||
{"non-string value", map[string]interface{}{"key": 123}, "key", ""},
|
||||
{"empty string", map[string]interface{}{"key": ""}, "key", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getStringFromMap(tt.m, tt.key)
|
||||
if got != tt.expected {
|
||||
t.Errorf("getStringFromMap() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNonEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []string
|
||||
expected string
|
||||
}{
|
||||
{"all empty", []string{"", "", ""}, ""},
|
||||
{"first non-empty", []string{"first", "second"}, "first"},
|
||||
{"second non-empty", []string{"", "second", "third"}, "second"},
|
||||
{"last non-empty", []string{"", "", "last"}, "last"},
|
||||
{"no values", []string{}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := firstNonEmpty(tt.values...)
|
||||
if got != tt.expected {
|
||||
t.Errorf("firstNonEmpty() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommonInputField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected bool
|
||||
}{
|
||||
{"repo", true},
|
||||
{"path", true},
|
||||
{"branch", true},
|
||||
{"unknown", false},
|
||||
{"Repository", false}, // case-sensitive
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isCommonInputField(tt.name)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isCommonInputField(%q) = %v, want %v", tt.name, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCronSpecScheduleSources(t *testing.T) {
|
||||
kb, err := LoadKnowledgeBaseFromDefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load knowledge base: %v", err)
|
||||
}
|
||||
|
||||
router := &LLMRouter{knowledgeBase: kb}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
intentSchedule string
|
||||
intentTimezone string
|
||||
paramSchedule string
|
||||
paramTimezone string
|
||||
wantSchedule string
|
||||
wantTimezone string
|
||||
}{
|
||||
{
|
||||
name: "from intent",
|
||||
intentSchedule: "0 2 * * *",
|
||||
intentTimezone: "PST",
|
||||
wantSchedule: "0 2 * * *",
|
||||
wantTimezone: "PST",
|
||||
},
|
||||
{
|
||||
name: "from params",
|
||||
paramSchedule: "0 3 * * *",
|
||||
paramTimezone: "EST",
|
||||
wantSchedule: "0 3 * * *",
|
||||
wantTimezone: "EST",
|
||||
},
|
||||
{
|
||||
name: "default UTC",
|
||||
wantSchedule: "",
|
||||
wantTimezone: "UTC",
|
||||
},
|
||||
{
|
||||
name: "intent priority",
|
||||
intentSchedule: "0 1 * * *",
|
||||
intentTimezone: "UTC",
|
||||
paramSchedule: "0 2 * * *",
|
||||
paramTimezone: "PST",
|
||||
wantSchedule: "0 1 * * *",
|
||||
wantTimezone: "UTC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
intent := &Intent{
|
||||
Activities: []string{"CloneRepoActivity"},
|
||||
Parameters: map[string]interface{}{},
|
||||
IsCron: true,
|
||||
CronSchedule: tt.intentSchedule,
|
||||
CronTimezone: tt.intentTimezone,
|
||||
WorkflowName: "test",
|
||||
}
|
||||
if tt.paramSchedule != "" {
|
||||
intent.Parameters["cronSchedule"] = tt.paramSchedule
|
||||
}
|
||||
if tt.paramTimezone != "" {
|
||||
intent.Parameters["cronTimezone"] = tt.paramTimezone
|
||||
}
|
||||
|
||||
input := LLMRouterInput{Message: "test"}
|
||||
spec, err := router.buildCronSpec(intent, input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCronSpec failed: %v", err)
|
||||
}
|
||||
|
||||
if spec.Schedule != tt.wantSchedule {
|
||||
t.Errorf("schedule = %q, want %q", spec.Schedule, tt.wantSchedule)
|
||||
}
|
||||
if spec.Timezone != tt.wantTimezone {
|
||||
t.Errorf("timezone = %q, want %q", spec.Timezone, tt.wantTimezone)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamResolverFromPrevOutput(t *testing.T) {
|
||||
kb, err := LoadKnowledgeBaseFromDefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load knowledge base: %v", err)
|
||||
}
|
||||
|
||||
resolver := ¶mResolver{
|
||||
intent: &Intent{
|
||||
Activities: []string{"CloneRepoActivity", "AnalyzeCodeActivity"},
|
||||
Parameters: map[string]interface{}{},
|
||||
},
|
||||
kb: kb,
|
||||
prevState: "CloneRepoActivity",
|
||||
}
|
||||
|
||||
// Should find matching output from CloneRepoActivity
|
||||
got := resolver.fromPrevOutput("path")
|
||||
if got == nil {
|
||||
t.Error("expected to find path from prev output")
|
||||
}
|
||||
if got != "${CloneRepoActivity.output.path}" {
|
||||
t.Errorf("got %v, want ${CloneRepoActivity.output.path}", got)
|
||||
}
|
||||
|
||||
// Should not find non-existent output
|
||||
got = resolver.fromPrevOutput("nonexistent")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for nonexistent, got %v", got)
|
||||
}
|
||||
|
||||
// Empty prevState should return nil
|
||||
resolver.prevState = ""
|
||||
got = resolver.fromPrevOutput("path")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for empty prevState, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentJSONMarshal(t *testing.T) {
|
||||
intent := &Intent{
|
||||
Activities: []string{"CloneRepoActivity"},
|
||||
|
||||
+2
-2
@@ -9,6 +9,6 @@ metadata:
|
||||
app.kubernetes.io/name: poimen
|
||||
app.kubernetes.io/component: orchestrator
|
||||
data:
|
||||
GIT_COMMIT: "303e78f7" # Updated automatically by CI/CD
|
||||
GIT_COMMIT: "e82600c1" # Updated automatically by CI/CD
|
||||
GIT_BRANCH: "main"
|
||||
DEPLOYMENT_DATE: "2026-09-02"
|
||||
DEPLOYMENT_DATE: "2026-09-03"
|
||||
|
||||
@@ -13,8 +13,8 @@ spec:
|
||||
labels:
|
||||
app: poimen-worker
|
||||
annotations:
|
||||
git-commit: "303e78f7" # ✅ Updated on each push, triggers rolling restart
|
||||
deployment-date: "2026-09-02"
|
||||
git-commit: "e82600c1" # ✅ Updated on each push, triggers rolling restart
|
||||
deployment-date: "2026-09-03"
|
||||
spec:
|
||||
containers:
|
||||
- name: worker
|
||||
|
||||
@@ -23,98 +23,137 @@ type RoutingWorkflowOutput struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// stateResult holds the result of executing a state
|
||||
type stateResult struct {
|
||||
result interface{}
|
||||
nextState string
|
||||
isDone bool
|
||||
err error
|
||||
}
|
||||
|
||||
// stateMachine manages workflow state execution
|
||||
type stateMachine struct {
|
||||
spec *routing.WorkflowSpec
|
||||
stateIndex map[string]*routing.State
|
||||
execCtx *routing.ExecutionContext
|
||||
output *RoutingWorkflowOutput
|
||||
current string
|
||||
}
|
||||
|
||||
// newStateMachine creates a state machine from spec
|
||||
func newStateMachine(spec *routing.WorkflowSpec) *stateMachine {
|
||||
idx := make(map[string]*routing.State)
|
||||
for i := range spec.States {
|
||||
idx[spec.States[i].Name] = &spec.States[i]
|
||||
}
|
||||
return &stateMachine{
|
||||
spec: spec,
|
||||
stateIndex: idx,
|
||||
execCtx: &routing.ExecutionContext{
|
||||
Input: spec.Input,
|
||||
StepResults: make(map[string]interface{}),
|
||||
},
|
||||
output: &RoutingWorkflowOutput{
|
||||
Status: "FAILED",
|
||||
StepResults: make(map[string]interface{}),
|
||||
},
|
||||
current: spec.States[0].Name,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *stateMachine) currentState() *routing.State {
|
||||
return m.stateIndex[m.current]
|
||||
}
|
||||
|
||||
func (m *stateMachine) recordResult(name string, result interface{}) {
|
||||
wrapped := map[string]interface{}{"output": result}
|
||||
m.execCtx.StepResults[name] = wrapped
|
||||
m.output.StepResults[name] = result
|
||||
}
|
||||
|
||||
func (m *stateMachine) complete(result interface{}) RoutingWorkflowOutput {
|
||||
m.output.Status = "COMPLETED"
|
||||
m.output.FinalOutput = result
|
||||
return *m.output
|
||||
}
|
||||
|
||||
func (m *stateMachine) fail(errMsg string) RoutingWorkflowOutput {
|
||||
m.output.Error = errMsg
|
||||
return *m.output
|
||||
}
|
||||
|
||||
// executeTask runs a Task state
|
||||
func executeTask(ctx workflow.Context, state *routing.State, execCtx *routing.ExecutionContext, logger log.Logger) stateResult {
|
||||
result, nextState, err := executeTaskState(ctx, state, execCtx, logger)
|
||||
if err != nil {
|
||||
if nextState != "" {
|
||||
return stateResult{nextState: nextState} // Caught, continue to error handler
|
||||
}
|
||||
return stateResult{err: err}
|
||||
}
|
||||
return stateResult{result: result, nextState: state.Next, isDone: state.End}
|
||||
}
|
||||
|
||||
// executePass runs a Pass state
|
||||
func executePass(state *routing.State) stateResult {
|
||||
return stateResult{result: state.Result, nextState: state.Next, isDone: state.End}
|
||||
}
|
||||
|
||||
// executeFail runs a Fail state
|
||||
func executeFail(state *routing.State) stateResult {
|
||||
return stateResult{err: fmt.Errorf("%s: %s", state.Error, state.Cause)}
|
||||
}
|
||||
|
||||
// RoutingWorkflow executes any WorkflowSpec generated by llm-router
|
||||
func RoutingWorkflow(ctx workflow.Context, input RoutingWorkflowInput) (RoutingWorkflowOutput, error) {
|
||||
logger := workflow.GetLogger(ctx)
|
||||
|
||||
output := RoutingWorkflowOutput{
|
||||
Status: "FAILED",
|
||||
StepResults: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
if input.Spec == nil || len(input.Spec.States) == 0 {
|
||||
output.Error = "empty workflow spec"
|
||||
return output, nil
|
||||
return RoutingWorkflowOutput{Status: "FAILED", Error: "empty workflow spec", StepResults: map[string]interface{}{}}, nil
|
||||
}
|
||||
|
||||
logger.Info("RoutingWorkflow started", "name", input.Spec.Name, "stateCount", len(input.Spec.States))
|
||||
|
||||
// Build execution context
|
||||
execCtx := &routing.ExecutionContext{
|
||||
Input: input.Spec.Input,
|
||||
StepResults: make(map[string]interface{}),
|
||||
}
|
||||
m := newStateMachine(input.Spec)
|
||||
|
||||
// Build state index for fast lookup
|
||||
stateIndex := make(map[string]*routing.State)
|
||||
for i := range input.Spec.States {
|
||||
stateIndex[input.Spec.States[i].Name] = &input.Spec.States[i]
|
||||
}
|
||||
|
||||
// Find first state (first in array)
|
||||
currentStateName := input.Spec.States[0].Name
|
||||
|
||||
// State machine loop
|
||||
for {
|
||||
state, ok := stateIndex[currentStateName]
|
||||
if !ok {
|
||||
output.Error = fmt.Sprintf("state not found: %s", currentStateName)
|
||||
return output, nil
|
||||
state := m.currentState()
|
||||
if state == nil {
|
||||
return m.fail(fmt.Sprintf("state not found: %s", m.current)), nil
|
||||
}
|
||||
|
||||
logger.Info("executing state", "state", currentStateName, "type", state.Type)
|
||||
logger.Info("executing state", "state", m.current, "type", state.Type)
|
||||
|
||||
var res stateResult
|
||||
switch state.Type {
|
||||
case routing.StateTypeTask:
|
||||
result, nextState, err := executeTaskState(ctx, state, execCtx, logger)
|
||||
if err != nil {
|
||||
// Check for catch clause
|
||||
if nextState != "" {
|
||||
currentStateName = nextState
|
||||
continue
|
||||
}
|
||||
output.Error = fmt.Sprintf("state %s failed: %v", currentStateName, err)
|
||||
return output, nil
|
||||
}
|
||||
// Wrap result in output key for JSONPath compatibility (e.g., ${Clone.output.path})
|
||||
wrappedResult := map[string]interface{}{"output": result}
|
||||
execCtx.StepResults[state.Name] = wrappedResult
|
||||
output.StepResults[state.Name] = result // Keep original for output
|
||||
|
||||
if state.End {
|
||||
output.Status = "COMPLETED"
|
||||
output.FinalOutput = result
|
||||
logger.Info("RoutingWorkflow completed", "name", input.Spec.Name)
|
||||
return output, nil
|
||||
}
|
||||
currentStateName = state.Next
|
||||
|
||||
res = executeTask(ctx, state, m.execCtx, logger)
|
||||
case routing.StateTypePass:
|
||||
execCtx.StepResults[state.Name] = state.Result
|
||||
output.StepResults[state.Name] = state.Result
|
||||
|
||||
if state.End {
|
||||
output.Status = "COMPLETED"
|
||||
output.FinalOutput = state.Result
|
||||
return output, nil
|
||||
}
|
||||
currentStateName = state.Next
|
||||
|
||||
res = executePass(state)
|
||||
case routing.StateTypeFail:
|
||||
output.Error = fmt.Sprintf("%s: %s", state.Error, state.Cause)
|
||||
logger.Error("RoutingWorkflow failed at Fail state", "state", currentStateName, "error", state.Error)
|
||||
return output, nil
|
||||
|
||||
res = executeFail(state)
|
||||
default:
|
||||
output.Error = fmt.Sprintf("unknown state type: %s", state.Type)
|
||||
return output, nil
|
||||
return m.fail(fmt.Sprintf("unknown state type: %s", state.Type)), nil
|
||||
}
|
||||
|
||||
// Safety check
|
||||
if currentStateName == "" {
|
||||
output.Error = "no next state and not end"
|
||||
return output, nil
|
||||
if res.err != nil {
|
||||
logger.Error("state failed", "state", m.current, "error", res.err)
|
||||
return m.fail(fmt.Sprintf("state %s failed: %v", m.current, res.err)), nil
|
||||
}
|
||||
|
||||
if res.result != nil {
|
||||
m.recordResult(state.Name, res.result)
|
||||
}
|
||||
|
||||
if res.isDone {
|
||||
logger.Info("RoutingWorkflow completed", "name", input.Spec.Name)
|
||||
return m.complete(res.result), nil
|
||||
}
|
||||
|
||||
if res.nextState == "" {
|
||||
return m.fail("no next state and not end"), nil
|
||||
}
|
||||
m.current = res.nextState
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user