feat(executor): enhance workflow execution with branch decision tracking and condition evaluations
This commit is contained in:
+1
-1
Submodule docs updated: 47d22d9d57...da1f6d7c39
@@ -69,11 +69,12 @@ func NewExecutor() *Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type runState struct {
|
type runState struct {
|
||||||
input Input
|
input Input
|
||||||
nodesByID map[string]dsl.Node
|
nodesByID map[string]dsl.Node
|
||||||
outgoing map[string][]dsl.Edge
|
outgoing map[string][]dsl.Edge
|
||||||
vars map[string]map[string]any
|
vars map[string]map[string]any
|
||||||
result Result
|
branchDecisions map[string]branchDecision
|
||||||
|
result Result
|
||||||
}
|
}
|
||||||
|
|
||||||
type workflowCheckPoint struct {
|
type workflowCheckPoint struct {
|
||||||
@@ -82,6 +83,24 @@ type workflowCheckPoint struct {
|
|||||||
Vars map[string]map[string]any `json:"vars"`
|
Vars map[string]map[string]any `json:"vars"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type branchDecision struct {
|
||||||
|
SelectedEdgeID string `json:"selectedEdgeId,omitempty"`
|
||||||
|
SelectedTargetNodeID string `json:"selectedTargetNodeId,omitempty"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Evaluations []conditionEvaluation `json:"evaluations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type conditionEvaluation struct {
|
||||||
|
EdgeID string `json:"edgeId"`
|
||||||
|
TargetNodeID string `json:"targetNodeId"`
|
||||||
|
SourceNodeID string `json:"sourceNodeId,omitempty"`
|
||||||
|
SourceField string `json:"sourceField,omitempty"`
|
||||||
|
Operator string `json:"operator,omitempty"`
|
||||||
|
LeftValue any `json:"leftValue,omitempty"`
|
||||||
|
RightValue any `json:"rightValue,omitempty"`
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Executor) Execute(ctx context.Context, input Input) (*Result, error) {
|
func (e *Executor) Execute(ctx context.Context, input Input) (*Result, error) {
|
||||||
state := newRunState(input)
|
state := newRunState(input)
|
||||||
currentID := strings.TrimSpace(input.Definition.EntryNodeID)
|
currentID := strings.TrimSpace(input.Definition.EntryNodeID)
|
||||||
@@ -159,25 +178,34 @@ func (e *Executor) executeFrom(ctx context.Context, state *runState, currentID s
|
|||||||
state.result.Status = "error"
|
state.result.Status = "error"
|
||||||
return &state.result, err
|
return &state.result, err
|
||||||
}
|
}
|
||||||
trace.OutputPreview = workflowPreviewJSON(state.vars[node.ID])
|
|
||||||
trace.DurationMS = int(time.Since(startedAt).Milliseconds())
|
trace.DurationMS = int(time.Since(startedAt).Milliseconds())
|
||||||
if state.result.Interrupted {
|
if state.result.Interrupted {
|
||||||
|
trace.OutputPreview = workflowPreviewJSON(state.nodeOutputPreview(node.ID))
|
||||||
trace.Status = "interrupted"
|
trace.Status = "interrupted"
|
||||||
state.result.NodeTraces = append(state.result.NodeTraces, trace)
|
state.result.NodeTraces = append(state.result.NodeTraces, trace)
|
||||||
state.result.Status = "interrupted"
|
state.result.Status = "interrupted"
|
||||||
return &state.result, nil
|
return &state.result, nil
|
||||||
}
|
}
|
||||||
trace.Status = "completed"
|
|
||||||
state.result.NodeTraces = append(state.result.NodeTraces, trace)
|
|
||||||
if node.Type == workflowregistry.NodeTypeEnd {
|
if node.Type == workflowregistry.NodeTypeEnd {
|
||||||
|
trace.OutputPreview = workflowPreviewJSON(state.nodeOutputPreview(node.ID))
|
||||||
|
trace.Status = "completed"
|
||||||
|
state.result.NodeTraces = append(state.result.NodeTraces, trace)
|
||||||
state.result.Status = "completed"
|
state.result.Status = "completed"
|
||||||
return &state.result, nil
|
return &state.result, nil
|
||||||
}
|
}
|
||||||
nextID, ok, err := state.nextNodeID(node.ID)
|
nextID, ok, err := state.nextNodeID(node.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
trace.OutputPreview = workflowPreviewJSON(state.nodeOutputPreview(node.ID))
|
||||||
|
trace.Status = "failed"
|
||||||
|
trace.ErrorMessage = err.Error()
|
||||||
|
trace.DurationMS = int(time.Since(startedAt).Milliseconds())
|
||||||
|
state.result.NodeTraces = append(state.result.NodeTraces, trace)
|
||||||
state.result.Status = "error"
|
state.result.Status = "error"
|
||||||
return &state.result, err
|
return &state.result, err
|
||||||
}
|
}
|
||||||
|
trace.OutputPreview = workflowPreviewJSON(state.nodeOutputPreview(node.ID))
|
||||||
|
trace.Status = "completed"
|
||||||
|
state.result.NodeTraces = append(state.result.NodeTraces, trace)
|
||||||
if !ok {
|
if !ok {
|
||||||
state.result.Status = "completed"
|
state.result.Status = "completed"
|
||||||
return &state.result, nil
|
return &state.result, nil
|
||||||
@@ -191,10 +219,11 @@ func (e *Executor) executeFrom(ctx context.Context, state *runState, currentID s
|
|||||||
|
|
||||||
func newRunState(input Input) *runState {
|
func newRunState(input Input) *runState {
|
||||||
state := &runState{
|
state := &runState{
|
||||||
input: input,
|
input: input,
|
||||||
nodesByID: make(map[string]dsl.Node, len(input.Definition.Nodes)),
|
nodesByID: make(map[string]dsl.Node, len(input.Definition.Nodes)),
|
||||||
outgoing: make(map[string][]dsl.Edge),
|
outgoing: make(map[string][]dsl.Edge),
|
||||||
vars: make(map[string]map[string]any),
|
vars: make(map[string]map[string]any),
|
||||||
|
branchDecisions: make(map[string]branchDecision),
|
||||||
result: Result{
|
result: Result{
|
||||||
Status: "started",
|
Status: "started",
|
||||||
NodePath: make([]string, 0),
|
NodePath: make([]string, 0),
|
||||||
@@ -532,61 +561,99 @@ func (s *runState) nextNodeID(sourceNodeID string) (string, bool, error) {
|
|||||||
if len(edges) == 0 {
|
if len(edges) == 0 {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
evaluations := make([]conditionEvaluation, 0)
|
||||||
for _, edge := range edges {
|
for _, edge := range edges {
|
||||||
if edge.Condition == nil {
|
if edge.Condition == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matched, err := s.evaluateCondition(edge.Condition)
|
matched, evaluation, err := s.evaluateCondition(edge)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
|
evaluations = append(evaluations, evaluation)
|
||||||
if matched {
|
if matched {
|
||||||
|
s.branchDecisions[sourceNodeID] = branchDecision{
|
||||||
|
SelectedEdgeID: strings.TrimSpace(edge.ID),
|
||||||
|
SelectedTargetNodeID: strings.TrimSpace(edge.Target),
|
||||||
|
Reason: "conditional edge matched",
|
||||||
|
Evaluations: evaluations,
|
||||||
|
}
|
||||||
return strings.TrimSpace(edge.Target), true, nil
|
return strings.TrimSpace(edge.Target), true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, edge := range edges {
|
for _, edge := range edges {
|
||||||
if edge.Condition == nil {
|
if edge.Condition == nil {
|
||||||
|
if len(evaluations) > 0 {
|
||||||
|
s.branchDecisions[sourceNodeID] = branchDecision{
|
||||||
|
SelectedEdgeID: strings.TrimSpace(edge.ID),
|
||||||
|
SelectedTargetNodeID: strings.TrimSpace(edge.Target),
|
||||||
|
Reason: "no conditional edge matched; selected default edge",
|
||||||
|
Evaluations: evaluations,
|
||||||
|
}
|
||||||
|
}
|
||||||
return strings.TrimSpace(edge.Target), true, nil
|
return strings.TrimSpace(edge.Target), true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(evaluations) > 0 {
|
||||||
|
s.branchDecisions[sourceNodeID] = branchDecision{
|
||||||
|
Reason: "no conditional edge matched and no default edge exists",
|
||||||
|
Evaluations: evaluations,
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *runState) evaluateCondition(condition *dsl.Condition) (bool, error) {
|
func (s *runState) evaluateCondition(edge dsl.Edge) (bool, conditionEvaluation, error) {
|
||||||
|
condition := edge.Condition
|
||||||
|
evaluation := conditionEvaluation{
|
||||||
|
EdgeID: strings.TrimSpace(edge.ID),
|
||||||
|
TargetNodeID: strings.TrimSpace(edge.Target),
|
||||||
|
}
|
||||||
if condition == nil {
|
if condition == nil {
|
||||||
return true, nil
|
evaluation.Matched = true
|
||||||
|
return true, evaluation, nil
|
||||||
}
|
}
|
||||||
left := s.resolveSelector(condition.Left)
|
left := s.resolveSelector(condition.Left)
|
||||||
operator := strings.TrimSpace(condition.Operator)
|
operator := strings.TrimSpace(condition.Operator)
|
||||||
if operator == "" && strings.TrimSpace(condition.Expression) != "" {
|
if condition.Left != nil {
|
||||||
return false, fmt.Errorf("free-form workflow condition expressions are not supported")
|
evaluation.SourceNodeID = strings.TrimSpace(condition.Left.NodeID)
|
||||||
|
evaluation.SourceField = strings.TrimSpace(condition.Left.Field)
|
||||||
}
|
}
|
||||||
|
evaluation.Operator = operator
|
||||||
|
evaluation.LeftValue = left
|
||||||
|
evaluation.RightValue = condition.Right
|
||||||
|
if operator == "" && strings.TrimSpace(condition.Expression) != "" {
|
||||||
|
return false, evaluation, fmt.Errorf("free-form workflow condition expressions are not supported")
|
||||||
|
}
|
||||||
|
var matched bool
|
||||||
switch operator {
|
switch operator {
|
||||||
case "eq", "equals":
|
case "eq", "equals":
|
||||||
return compareString(left, condition.Right) == 0, nil
|
matched = compareString(left, condition.Right) == 0
|
||||||
case "neq", "not_equals":
|
case "neq", "not_equals":
|
||||||
return compareString(left, condition.Right) != 0, nil
|
matched = compareString(left, condition.Right) != 0
|
||||||
case "contains":
|
case "contains":
|
||||||
return strings.Contains(toString(left), toString(condition.Right)), nil
|
matched = strings.Contains(toString(left), toString(condition.Right))
|
||||||
case "exists":
|
case "exists":
|
||||||
return exists(left), nil
|
matched = exists(left)
|
||||||
case "not_exists":
|
case "not_exists":
|
||||||
return !exists(left), nil
|
matched = !exists(left)
|
||||||
case "truthy", "is_true":
|
case "truthy", "is_true":
|
||||||
return truthy(left), nil
|
matched = truthy(left)
|
||||||
case "falsy", "is_false":
|
case "falsy", "is_false":
|
||||||
return !truthy(left), nil
|
matched = !truthy(left)
|
||||||
case "gt":
|
case "gt":
|
||||||
return compareNumber(left, condition.Right) > 0, nil
|
matched = compareNumber(left, condition.Right) > 0
|
||||||
case "gte":
|
case "gte":
|
||||||
return compareNumber(left, condition.Right) >= 0, nil
|
matched = compareNumber(left, condition.Right) >= 0
|
||||||
case "lt":
|
case "lt":
|
||||||
return compareNumber(left, condition.Right) < 0, nil
|
matched = compareNumber(left, condition.Right) < 0
|
||||||
case "lte":
|
case "lte":
|
||||||
return compareNumber(left, condition.Right) <= 0, nil
|
matched = compareNumber(left, condition.Right) <= 0
|
||||||
default:
|
default:
|
||||||
return false, fmt.Errorf("unsupported workflow condition operator: %s", operator)
|
return false, evaluation, fmt.Errorf("unsupported workflow condition operator: %s", operator)
|
||||||
}
|
}
|
||||||
|
evaluation.Matched = matched
|
||||||
|
return matched, evaluation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *runState) setNodeVars(nodeID string, values map[string]any) {
|
func (s *runState) setNodeVars(nodeID string, values map[string]any) {
|
||||||
@@ -620,6 +687,16 @@ func (s *runState) nodeInputPreview(node dsl.Node) map[string]any {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *runState) nodeOutputPreview(nodeID string) map[string]any {
|
||||||
|
ret := map[string]any{
|
||||||
|
"outputs": s.vars[nodeID],
|
||||||
|
}
|
||||||
|
if decision, ok := s.branchDecisions[nodeID]; ok {
|
||||||
|
ret["branchDecision"] = decision
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
func workflowPreviewJSON(value any) string {
|
func workflowPreviewJSON(value any) string {
|
||||||
raw, err := json.Marshal(value)
|
raw, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -35,6 +35,62 @@ func TestExecutorRoutesByConditionEdge(t *testing.T) {
|
|||||||
assertPath(t, result.NodePath, []string{"start_1", "condition_1", "vip_reply", "send_vip", "end_1"})
|
assertPath(t, result.NodePath, []string{"start_1", "condition_1", "vip_reply", "send_vip", "end_1"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecutorConditionNodeTraceExplainsMatchedEdge(t *testing.T) {
|
||||||
|
result, err := NewExecutor().Execute(context.Background(), Input{
|
||||||
|
Definition: conditionalReplyDefinition(),
|
||||||
|
UserMessage: models.Message{
|
||||||
|
Content: "vip",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute workflow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trace := findNodeTrace(result.NodeTraces, "condition_1")
|
||||||
|
if trace == nil {
|
||||||
|
t.Fatalf("expected condition node trace, got %#v", result.NodeTraces)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"selectedEdgeId":"edge_condition_vip"`,
|
||||||
|
`"selectedTargetNodeId":"vip_reply"`,
|
||||||
|
`"operator":"eq"`,
|
||||||
|
`"leftValue":"vip"`,
|
||||||
|
`"matched":true`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(trace.OutputPreview, want) {
|
||||||
|
t.Fatalf("expected condition trace output to contain %s, got %s", want, trace.OutputPreview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorConditionNodeTraceExplainsDefaultEdge(t *testing.T) {
|
||||||
|
result, err := NewExecutor().Execute(context.Background(), Input{
|
||||||
|
Definition: conditionalReplyDefinition(),
|
||||||
|
UserMessage: models.Message{
|
||||||
|
Content: "normal",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute workflow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trace := findNodeTrace(result.NodeTraces, "condition_1")
|
||||||
|
if trace == nil {
|
||||||
|
t.Fatalf("expected condition node trace, got %#v", result.NodeTraces)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"selectedEdgeId":"edge_condition_default"`,
|
||||||
|
`"selectedTargetNodeId":"normal_reply"`,
|
||||||
|
`"reason":"no conditional edge matched; selected default edge"`,
|
||||||
|
`"leftValue":"normal"`,
|
||||||
|
`"matched":false`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(trace.OutputPreview, want) {
|
||||||
|
t.Fatalf("expected condition trace output to contain %s, got %s", want, trace.OutputPreview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecutorUsesDefaultEdgeWhenConditionDoesNotMatch(t *testing.T) {
|
func TestExecutorUsesDefaultEdgeWhenConditionDoesNotMatch(t *testing.T) {
|
||||||
executor := NewExecutor()
|
executor := NewExecutor()
|
||||||
result, err := executor.Execute(context.Background(), Input{
|
result, err := executor.Execute(context.Background(), Input{
|
||||||
@@ -279,6 +335,15 @@ func TestExecutorResumeCreatesTicketAfterHumanConfirmation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace {
|
||||||
|
for i := range items {
|
||||||
|
if items[i].NodeID == nodeID {
|
||||||
|
return &items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func conditionalReplyDefinition() dsl.Definition {
|
func conditionalReplyDefinition() dsl.Definition {
|
||||||
return dsl.Definition{
|
return dsl.Definition{
|
||||||
SchemaVersion: 1,
|
SchemaVersion: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user