- 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"`
|
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
|
// DeploymentPreCheckActivity validates deployment readiness
|
||||||
func DeploymentPreCheckActivity(ctx context.Context, in DeploymentPreCheckInput) (DeploymentPreCheckOutput, error) {
|
func DeploymentPreCheckActivity(ctx context.Context, in DeploymentPreCheckInput) (DeploymentPreCheckOutput, error) {
|
||||||
logger := newActivityLogger(ctx)
|
logger := newActivityLogger(ctx)
|
||||||
@@ -226,38 +290,19 @@ func DeploymentPreCheckActivity(ctx context.Context, in DeploymentPreCheckInput)
|
|||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Go project
|
// Run applicable checkers
|
||||||
isGo := false
|
for _, c := range detectProjectCheckers(in.Path) {
|
||||||
if _, err := os.Stat(fmt.Sprintf("%s/go.mod", in.Path)); err == nil {
|
if !c.shouldRun(checkType) {
|
||||||
isGo = true
|
continue
|
||||||
}
|
|
||||||
|
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
}
|
if out, err := c.run(ctx, in.Path); err != nil {
|
||||||
|
msg := fmt.Sprintf("%s failed: %s", c.name, out)
|
||||||
if isGo && (checkType == "all" || checkType == "test") {
|
if c.isWarning {
|
||||||
// Try go test
|
output.Warnings = append(output.Warnings, msg)
|
||||||
cmd := exec.CommandContext(ctx, "go", "test", "-short", "./...")
|
} else {
|
||||||
cmd.Dir = in.Path
|
output.Passed = false
|
||||||
if testOut, err := cmd.CombinedOutput(); err != nil {
|
output.Failures = append(output.Failures, msg)
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+111
-64
@@ -324,44 +324,52 @@ func (r *LLMRouter) buildSpec(intent *Intent, input LLMRouterInput) (*WorkflowSp
|
|||||||
}, nil
|
}, 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
|
// buildCronSpec creates CronWorkflowSpec from intent
|
||||||
func (r *LLMRouter) buildCronSpec(intent *Intent, input LLMRouterInput) (*CronWorkflowSpec, error) {
|
func (r *LLMRouter) buildCronSpec(intent *Intent, input LLMRouterInput) (*CronWorkflowSpec, error) {
|
||||||
// First build regular spec
|
|
||||||
spec, err := r.buildSpec(intent, input)
|
spec, err := r.buildSpec(intent, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get schedule - check both intent and parameters (LLM sometimes puts it in parameters)
|
// Extract schedule from multiple sources
|
||||||
schedule := intent.CronSchedule
|
schedule := firstNonEmpty(
|
||||||
if schedule == "" {
|
intent.CronSchedule,
|
||||||
if sched, ok := intent.Parameters["cronSchedule"].(string); ok {
|
getStringFromMap(intent.Parameters, "cronSchedule"),
|
||||||
schedule = sched
|
getStringFromMap(spec.Input, "cronSchedule"),
|
||||||
}
|
)
|
||||||
}
|
|
||||||
if schedule == "" {
|
|
||||||
if sched, ok := spec.Input["cronSchedule"].(string); ok {
|
|
||||||
schedule = sched
|
|
||||||
delete(spec.Input, "cronSchedule") // Remove from input
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get timezone
|
// Extract timezone from multiple sources, default to UTC
|
||||||
timezone := intent.CronTimezone
|
timezone := firstNonEmpty(
|
||||||
if timezone == "" {
|
intent.CronTimezone,
|
||||||
if tz, ok := intent.Parameters["cronTimezone"].(string); ok {
|
getStringFromMap(intent.Parameters, "cronTimezone"),
|
||||||
timezone = tz
|
getStringFromMap(spec.Input, "cronTimezone"),
|
||||||
}
|
"UTC",
|
||||||
}
|
)
|
||||||
if timezone == "" {
|
|
||||||
if tz, ok := spec.Input["cronTimezone"].(string); ok {
|
// Clean cron fields from input
|
||||||
timezone = tz
|
delete(spec.Input, "cronSchedule")
|
||||||
delete(spec.Input, "cronTimezone") // Remove from input
|
delete(spec.Input, "cronTimezone")
|
||||||
}
|
|
||||||
}
|
|
||||||
if timezone == "" {
|
|
||||||
timezone = "UTC"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CronWorkflowSpec{
|
return &CronWorkflowSpec{
|
||||||
Name: spec.Name,
|
Name: spec.Name,
|
||||||
@@ -376,44 +384,83 @@ func (r *LLMRouter) buildCronSpec(intent *Intent, input LLMRouterInput) (*CronWo
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildParameters creates parameter map for activity
|
// isCommonInputField checks if field name is a common workflow input
|
||||||
func (r *LLMRouter) buildParameters(act *ActivityMetadata, intent *Intent, stateIndex int) map[string]interface{} {
|
func isCommonInputField(name string) bool {
|
||||||
params := make(map[string]interface{})
|
switch name {
|
||||||
|
case "repo", "path", "branch":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
for inputName, inputDef := range act.Inputs {
|
// paramResolver resolves activity parameters from multiple sources
|
||||||
// Check if parameter was extracted from intent
|
type paramResolver struct {
|
||||||
if val, ok := intent.Parameters[inputName]; ok {
|
intent *Intent
|
||||||
params[inputName] = val
|
kb *KnowledgeBase
|
||||||
continue
|
prevState string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for JSONPath reference from previous state
|
// resolve finds parameter value from intent, previous output, default, or input ref
|
||||||
if stateIndex > 0 {
|
func (r *paramResolver) resolve(inputName string, inputDef InputField) interface{} {
|
||||||
prevAct := intent.Activities[stateIndex-1]
|
// 1. From intent parameters
|
||||||
prevActDef := r.knowledgeBase.GetActivity(prevAct)
|
if val, ok := r.intent.Parameters[inputName]; ok {
|
||||||
|
return val
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
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) {
|
func TestIntentJSONMarshal(t *testing.T) {
|
||||||
intent := &Intent{
|
intent := &Intent{
|
||||||
Activities: []string{"CloneRepoActivity"},
|
Activities: []string{"CloneRepoActivity"},
|
||||||
|
|||||||
+2
-2
@@ -9,6 +9,6 @@ metadata:
|
|||||||
app.kubernetes.io/name: poimen
|
app.kubernetes.io/name: poimen
|
||||||
app.kubernetes.io/component: orchestrator
|
app.kubernetes.io/component: orchestrator
|
||||||
data:
|
data:
|
||||||
GIT_COMMIT: "303e78f7" # Updated automatically by CI/CD
|
GIT_COMMIT: "e82600c1" # Updated automatically by CI/CD
|
||||||
GIT_BRANCH: "main"
|
GIT_BRANCH: "main"
|
||||||
DEPLOYMENT_DATE: "2026-09-02"
|
DEPLOYMENT_DATE: "2026-09-03"
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: poimen-worker
|
app: poimen-worker
|
||||||
annotations:
|
annotations:
|
||||||
git-commit: "303e78f7" # ✅ Updated on each push, triggers rolling restart
|
git-commit: "e82600c1" # ✅ Updated on each push, triggers rolling restart
|
||||||
deployment-date: "2026-09-02"
|
deployment-date: "2026-09-03"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: worker
|
- name: worker
|
||||||
|
|||||||
@@ -23,98 +23,137 @@ type RoutingWorkflowOutput struct {
|
|||||||
Error string `json:"error,omitempty"`
|
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
|
// RoutingWorkflow executes any WorkflowSpec generated by llm-router
|
||||||
func RoutingWorkflow(ctx workflow.Context, input RoutingWorkflowInput) (RoutingWorkflowOutput, error) {
|
func RoutingWorkflow(ctx workflow.Context, input RoutingWorkflowInput) (RoutingWorkflowOutput, error) {
|
||||||
logger := workflow.GetLogger(ctx)
|
logger := workflow.GetLogger(ctx)
|
||||||
|
|
||||||
output := RoutingWorkflowOutput{
|
|
||||||
Status: "FAILED",
|
|
||||||
StepResults: make(map[string]interface{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.Spec == nil || len(input.Spec.States) == 0 {
|
if input.Spec == nil || len(input.Spec.States) == 0 {
|
||||||
output.Error = "empty workflow spec"
|
return RoutingWorkflowOutput{Status: "FAILED", Error: "empty workflow spec", StepResults: map[string]interface{}{}}, nil
|
||||||
return output, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("RoutingWorkflow started", "name", input.Spec.Name, "stateCount", len(input.Spec.States))
|
logger.Info("RoutingWorkflow started", "name", input.Spec.Name, "stateCount", len(input.Spec.States))
|
||||||
|
|
||||||
// Build execution context
|
m := newStateMachine(input.Spec)
|
||||||
execCtx := &routing.ExecutionContext{
|
|
||||||
Input: input.Spec.Input,
|
|
||||||
StepResults: make(map[string]interface{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
for {
|
||||||
state, ok := stateIndex[currentStateName]
|
state := m.currentState()
|
||||||
if !ok {
|
if state == nil {
|
||||||
output.Error = fmt.Sprintf("state not found: %s", currentStateName)
|
return m.fail(fmt.Sprintf("state not found: %s", m.current)), nil
|
||||||
return output, 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 {
|
switch state.Type {
|
||||||
case routing.StateTypeTask:
|
case routing.StateTypeTask:
|
||||||
result, nextState, err := executeTaskState(ctx, state, execCtx, logger)
|
res = executeTask(ctx, state, m.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
|
|
||||||
|
|
||||||
case routing.StateTypePass:
|
case routing.StateTypePass:
|
||||||
execCtx.StepResults[state.Name] = state.Result
|
res = executePass(state)
|
||||||
output.StepResults[state.Name] = state.Result
|
|
||||||
|
|
||||||
if state.End {
|
|
||||||
output.Status = "COMPLETED"
|
|
||||||
output.FinalOutput = state.Result
|
|
||||||
return output, nil
|
|
||||||
}
|
|
||||||
currentStateName = state.Next
|
|
||||||
|
|
||||||
case routing.StateTypeFail:
|
case routing.StateTypeFail:
|
||||||
output.Error = fmt.Sprintf("%s: %s", state.Error, state.Cause)
|
res = executeFail(state)
|
||||||
logger.Error("RoutingWorkflow failed at Fail state", "state", currentStateName, "error", state.Error)
|
|
||||||
return output, nil
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
output.Error = fmt.Sprintf("unknown state type: %s", state.Type)
|
return m.fail(fmt.Sprintf("unknown state type: %s", state.Type)), nil
|
||||||
return output, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety check
|
if res.err != nil {
|
||||||
if currentStateName == "" {
|
logger.Error("state failed", "state", m.current, "error", res.err)
|
||||||
output.Error = "no next state and not end"
|
return m.fail(fmt.Sprintf("state %s failed: %v", m.current, res.err)), nil
|
||||||
return output, 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