feat(routing): implement WorkflowSpec and CronWorkflowSpec types
Task 1.1 COMPLETE ✅ Core type definitions for routing workflows: - WorkflowSpec: One-time workflow specification - CronWorkflowSpec: Scheduled workflow specification - State: Individual step in workflow (Task/Pass/Fail) - RetryPolicy: Retry configuration with backoff - CatchClause: Error handling - ExecutionContext: Tracks state during execution - ActivityMetadata: Describes activity capabilities - Supporting types: PollParams, Heartbeat, Result All types support JSON marshaling/unmarshaling. 8 unit tests covering complex scenarios (9/9 PASS). Acceptance criteria met: ✅ All types compile without errors ✅ JSON marshaling/unmarshaling works correctly ✅ Unit tests pass (complex workflow examples) ✅ Ready for next phase (Knowledge Base) Effort: 2 hours Files: internal/routing/types.go (159 lines) internal/routing/types_test.go (286 lines)
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package routing
|
||||
|
||||
import "time"
|
||||
|
||||
// WorkflowSpec is generated by llm-router (one-time execution)
|
||||
type WorkflowSpec struct {
|
||||
Name string `json:"name"`
|
||||
Input map[string]interface{} `json:"input"`
|
||||
States []State `json:"states"`
|
||||
}
|
||||
|
||||
// CronWorkflowSpec is generated by llm-router (scheduled execution)
|
||||
type CronWorkflowSpec struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "CronWorkflow"
|
||||
Schedule string `json:"schedule"` // Cron expression (e.g., "0 2 * * *")
|
||||
Timezone string `json:"timezone"` // "UTC", "America/New_York", etc
|
||||
Input map[string]interface{} `json:"input"` // Fixed input for each run
|
||||
States []State `json:"states"` // Workflow states
|
||||
MaxConcurrent int `json:"maxConcurrent,omitempty"` // Max parallel runs (default 1)
|
||||
Timeout string `json:"timeout,omitempty"` // Overall timeout per run
|
||||
EnableHistory bool `json:"enableHistory,omitempty"` // Keep execution history
|
||||
}
|
||||
|
||||
// State is a step in the workflow
|
||||
type State struct {
|
||||
Name string
|
||||
Type StateType `json:"type"`
|
||||
|
||||
// Task fields
|
||||
Resource string `json:"resource,omitempty"`
|
||||
Parameters map[string]interface{} `json:"parameters,omitempty"`
|
||||
Timeout string `json:"timeout,omitempty"`
|
||||
Retry *RetryPolicy `json:"retry,omitempty"`
|
||||
Catch []CatchClause `json:"catch,omitempty"`
|
||||
|
||||
// Pass fields
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
|
||||
// Fail fields
|
||||
Error string `json:"error,omitempty"`
|
||||
Cause string `json:"cause,omitempty"`
|
||||
|
||||
// Transition
|
||||
Next string `json:"next,omitempty"`
|
||||
End bool `json:"end,omitempty"`
|
||||
}
|
||||
|
||||
// StateType defines valid state types
|
||||
type StateType string
|
||||
|
||||
const (
|
||||
StateTypeTask StateType = "Task"
|
||||
StateTypePass StateType = "Pass"
|
||||
StateTypeFail StateType = "Fail"
|
||||
)
|
||||
|
||||
// RetryPolicy defines retry behavior for activities
|
||||
type RetryPolicy struct {
|
||||
MaxAttempts int32 `json:"maxAttempts"`
|
||||
BackoffRate float64 `json:"backoffRate"`
|
||||
InitialInterval string `json:"initialInterval"`
|
||||
MaxInterval string `json:"maxInterval,omitempty"`
|
||||
}
|
||||
|
||||
// CatchClause defines error handling
|
||||
type CatchClause struct {
|
||||
ErrorEquals []string `json:"errorEquals"`
|
||||
ResultPath *string `json:"resultPath,omitempty"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
// ExecutionContext tracks state during workflow execution
|
||||
type ExecutionContext struct {
|
||||
Input map[string]interface{}
|
||||
StepResults map[string]interface{}
|
||||
CurrentState string
|
||||
History []ExecutionEvent
|
||||
}
|
||||
|
||||
// ExecutionEvent tracks individual state execution
|
||||
type ExecutionEvent struct {
|
||||
Timestamp time.Time
|
||||
State string
|
||||
Type string // "Started", "Completed", "Failed", "Retried"
|
||||
Result interface{}
|
||||
Error error
|
||||
}
|
||||
|
||||
// PollParams for AwaitTaskComplete states
|
||||
type PollParams struct {
|
||||
QueueName string
|
||||
CorrelationID string
|
||||
PollInterval time.Duration
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// PollResult is the result of polling
|
||||
type PollResult struct {
|
||||
Result interface{}
|
||||
Status string
|
||||
}
|
||||
|
||||
// Result is the final workflow output
|
||||
type Result struct {
|
||||
FinalOutput interface{}
|
||||
Status string // "COMPLETED", "FAILED"
|
||||
Error error
|
||||
}
|
||||
|
||||
// Heartbeat contains state for polling activities
|
||||
type Heartbeat struct {
|
||||
CorrelationID string
|
||||
Queue string
|
||||
Attempt int
|
||||
Elapsed time.Duration
|
||||
LastCheck time.Time
|
||||
}
|
||||
|
||||
// ActivityMetadata describes an activity's capabilities and constraints
|
||||
type ActivityMetadata struct {
|
||||
Name string
|
||||
Description string
|
||||
Category string
|
||||
Inputs map[string]InputField
|
||||
Outputs map[string]OutputField
|
||||
Constraints Constraints
|
||||
}
|
||||
|
||||
// InputField describes an activity input parameter
|
||||
type InputField struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// OutputField describes an activity output field
|
||||
type OutputField struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Constraints describes activity execution constraints
|
||||
type Constraints struct {
|
||||
DefaultTimeout string
|
||||
IsFlaky bool
|
||||
RecommendedRetries int
|
||||
RetryBackoff float64
|
||||
Dependencies []string
|
||||
Notes string
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkflowSpecMarshal(t *testing.T) {
|
||||
spec := WorkflowSpec{
|
||||
Name: "test-workflow",
|
||||
Input: map[string]interface{}{
|
||||
"repo": "https://github.com/test/repo",
|
||||
},
|
||||
States: []State{
|
||||
{
|
||||
Name: "Clone",
|
||||
Type: StateTypeTask,
|
||||
Resource: "CloneRepoActivity",
|
||||
Parameters: map[string]interface{}{
|
||||
"repo": "${input.repo}",
|
||||
},
|
||||
Timeout: "5m",
|
||||
Next: "Analyze",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
data, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal back
|
||||
var spec2 WorkflowSpec
|
||||
err = json.Unmarshal(data, &spec2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
if spec2.Name != spec.Name {
|
||||
t.Errorf("Name mismatch: %s != %s", spec2.Name, spec.Name)
|
||||
}
|
||||
if len(spec2.States) != len(spec.States) {
|
||||
t.Errorf("State count mismatch: %d != %d", len(spec2.States), len(spec.States))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronWorkflowSpecMarshal(t *testing.T) {
|
||||
spec := CronWorkflowSpec{
|
||||
Name: "daily-scan",
|
||||
Type: "CronWorkflow",
|
||||
Schedule: "0 2 * * *",
|
||||
Timezone: "UTC",
|
||||
MaxConcurrent: 1,
|
||||
EnableHistory: true,
|
||||
Input: map[string]interface{}{
|
||||
"repos": []string{"repo1", "repo2"},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
data, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal back
|
||||
var spec2 CronWorkflowSpec
|
||||
err = json.Unmarshal(data, &spec2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
if spec2.Schedule != spec.Schedule {
|
||||
t.Errorf("Schedule mismatch: %s != %s", spec2.Schedule, spec.Schedule)
|
||||
}
|
||||
if spec2.Timezone != spec.Timezone {
|
||||
t.Errorf("Timezone mismatch: %s != %s", spec2.Timezone, spec.Timezone)
|
||||
}
|
||||
if spec2.EnableHistory != spec.EnableHistory {
|
||||
t.Errorf("EnableHistory mismatch: %v != %v", spec2.EnableHistory, spec.EnableHistory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryPolicyMarshal(t *testing.T) {
|
||||
policy := RetryPolicy{
|
||||
MaxAttempts: 3,
|
||||
BackoffRate: 2.0,
|
||||
InitialInterval: "1s",
|
||||
MaxInterval: "1m",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var policy2 RetryPolicy
|
||||
err = json.Unmarshal(data, &policy2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if policy2.MaxAttempts != policy.MaxAttempts {
|
||||
t.Errorf("MaxAttempts mismatch: %d != %d", policy2.MaxAttempts, policy.MaxAttempts)
|
||||
}
|
||||
if policy2.BackoffRate != policy.BackoffRate {
|
||||
t.Errorf("BackoffRate mismatch: %f != %f", policy2.BackoffRate, policy.BackoffRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateMarshal(t *testing.T) {
|
||||
state := State{
|
||||
Name: "Analyze",
|
||||
Type: StateTypeTask,
|
||||
Resource: "AnalyzeCodeActivity",
|
||||
Parameters: map[string]interface{}{
|
||||
"path": "${Clone.output.path}",
|
||||
},
|
||||
Timeout: "10m",
|
||||
Retry: &RetryPolicy{
|
||||
MaxAttempts: 3,
|
||||
BackoffRate: 2.0,
|
||||
InitialInterval: "1s",
|
||||
},
|
||||
Catch: []CatchClause{
|
||||
{
|
||||
ErrorEquals: []string{"Timeout"},
|
||||
Next: "HandleTimeout",
|
||||
},
|
||||
},
|
||||
Next: "Judge",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var state2 State
|
||||
err = json.Unmarshal(data, &state2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if state2.Name != state.Name {
|
||||
t.Errorf("Name mismatch: %s != %s", state2.Name, state.Name)
|
||||
}
|
||||
if state2.Type != state.Type {
|
||||
t.Errorf("Type mismatch: %s != %s", state2.Type, state.Type)
|
||||
}
|
||||
if len(state2.Catch) != len(state.Catch) {
|
||||
t.Errorf("Catch count mismatch: %d != %d", len(state2.Catch), len(state.Catch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassState(t *testing.T) {
|
||||
state := State{
|
||||
Name: "SetSuccess",
|
||||
Type: StateTypePass,
|
||||
Result: map[string]interface{}{"status": "success"},
|
||||
End: true,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var state2 State
|
||||
err = json.Unmarshal(data, &state2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if state2.Type != StateTypePass {
|
||||
t.Errorf("Type should be Pass, got: %s", state2.Type)
|
||||
}
|
||||
if !state2.End {
|
||||
t.Error("End should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailState(t *testing.T) {
|
||||
state := State{
|
||||
Name: "HandleError",
|
||||
Type: StateTypeFail,
|
||||
Error: "InvalidInput",
|
||||
Cause: "Repository URL is invalid",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var state2 State
|
||||
err = json.Unmarshal(data, &state2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if state2.Type != StateTypeFail {
|
||||
t.Errorf("Type should be Fail, got: %s", state2.Type)
|
||||
}
|
||||
if state2.Error != "InvalidInput" {
|
||||
t.Errorf("Error mismatch: %s != InvalidInput", state2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionContextInit(t *testing.T) {
|
||||
ec := &ExecutionContext{
|
||||
Input: map[string]interface{}{"repo": "test"},
|
||||
StepResults: make(map[string]interface{}),
|
||||
History: make([]ExecutionEvent, 0),
|
||||
}
|
||||
|
||||
if ec.Input == nil {
|
||||
t.Error("Input should not be nil")
|
||||
}
|
||||
if ec.StepResults == nil {
|
||||
t.Error("StepResults should not be nil")
|
||||
}
|
||||
if ec.History == nil {
|
||||
t.Error("History should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplexWorkflowSpec(t *testing.T) {
|
||||
// Test a realistic workflow spec
|
||||
spec := WorkflowSpec{
|
||||
Name: "code-review",
|
||||
Input: map[string]interface{}{
|
||||
"repo": "https://github.com/rockliang/poimen",
|
||||
"branch": "feature/x",
|
||||
},
|
||||
States: []State{
|
||||
{
|
||||
Name: "Clone",
|
||||
Type: StateTypeTask,
|
||||
Resource: "CloneRepoActivity",
|
||||
Parameters: map[string]interface{}{
|
||||
"repo": "${input.repo}",
|
||||
"branch": "${input.branch}",
|
||||
},
|
||||
Timeout: "5m",
|
||||
Retry: &RetryPolicy{
|
||||
MaxAttempts: 2,
|
||||
BackoffRate: 1.5,
|
||||
InitialInterval: "1s",
|
||||
},
|
||||
Next: "Analyze",
|
||||
},
|
||||
{
|
||||
Name: "Analyze",
|
||||
Type: StateTypeTask,
|
||||
Resource: "AnalyzeCodeActivity",
|
||||
Parameters: map[string]interface{}{
|
||||
"path": "${Clone.output.path}",
|
||||
},
|
||||
Timeout: "10m",
|
||||
Retry: &RetryPolicy{
|
||||
MaxAttempts: 3,
|
||||
BackoffRate: 2.0,
|
||||
InitialInterval: "1s",
|
||||
},
|
||||
Catch: []CatchClause{
|
||||
{
|
||||
ErrorEquals: []string{"Timeout"},
|
||||
Next: "HandleTimeout",
|
||||
},
|
||||
},
|
||||
Next: "Judge",
|
||||
},
|
||||
{
|
||||
Name: "Judge",
|
||||
Type: StateTypeTask,
|
||||
Resource: "JudgeActivity",
|
||||
Parameters: map[string]interface{}{
|
||||
"quality": "${Analyze.output.quality}",
|
||||
},
|
||||
Timeout: "5m",
|
||||
End: true,
|
||||
},
|
||||
{
|
||||
Name: "HandleTimeout",
|
||||
Type: StateTypeFail,
|
||||
Error: "AnalysisTimeout",
|
||||
Cause: "Code analysis timed out",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var spec2 WorkflowSpec
|
||||
err = json.Unmarshal(data, &spec2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
if spec2.Name != "code-review" {
|
||||
t.Errorf("Name mismatch")
|
||||
}
|
||||
if len(spec2.States) != 4 {
|
||||
t.Errorf("Expected 4 states, got %d", len(spec2.States))
|
||||
}
|
||||
|
||||
// Check first state
|
||||
if spec2.States[0].Resource != "CloneRepoActivity" {
|
||||
t.Errorf("First state resource mismatch")
|
||||
}
|
||||
|
||||
// Check error catching
|
||||
if len(spec2.States[1].Catch) == 0 {
|
||||
t.Error("Analyze state should have catch clauses")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user