Files
langship.sh/pkg/engine/runner.go
T

152 lines
4.0 KiB
Go

package engine
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/lyzrai/flow/pkg/models"
)
// terminalError is unwrapped by the runner to halt execution.
// Executors and durability layers can wrap their errors with TerminalError
// to signal "do not retry, do not forward inputs, stop the workflow".
type terminalError struct{ Err error }
func (e *terminalError) Error() string { return e.Err.Error() }
func (e *terminalError) Unwrap() error { return e.Err }
// TerminalError marks an error as non-retryable.
func TerminalError(err error) error {
if err == nil {
return nil
}
return &terminalError{Err: err}
}
// IsTerminalError returns true if err (or any wrapped error) is terminal.
func IsTerminalError(err error) bool {
var te *terminalError
return errors.As(err, &te)
}
// RunWorkflow builds the DAG and executes nodes in topological order.
func RunWorkflow(ctx context.Context, wf *models.WorkflowDefinition, triggerData []models.Item, lookup ExecutorLookup) (*models.ExecutionResult, error) {
dag, err := BuildDAG(wf)
if err != nil {
return nil, fmt.Errorf("failed to build DAG: %w", err)
}
order, err := dag.TopologicalSort()
if err != nil {
return nil, fmt.Errorf("failed to sort DAG: %w", err)
}
slog.InfoContext(ctx, "workflow_started",
slog.String("workflow", wf.Name),
slog.Int("node_count", len(order)),
)
execCtx := NewExecutionContext(wf, dag)
execCtx.Lookup = lookup
var execErrors []string
for _, nodeName := range order {
if execCtx.IsDelegated(nodeName) {
continue
}
node := dag.Nodes[nodeName]
var inputs [][]models.Item
if node.Type == "flow-nodes-base.trigger" && triggerData != nil {
inputs = [][]models.Item{triggerData}
} else {
inputs = execCtx.GatherInputs(nodeName)
}
// Skip non-trigger root nodes (orphaned — no path from trigger).
if node.Type != "flow-nodes-base.trigger" && len(dag.InEdges[nodeName]) == 0 {
slog.InfoContext(ctx, "node_skipped_no_trigger_path",
slog.String("node", nodeName),
slog.String("type", node.Type),
)
continue
}
// Skip nodes that have upstream connections but received no items.
if len(dag.InEdges[nodeName]) > 0 && allInputsEmpty(inputs) {
slog.InfoContext(ctx, "node_skipped_no_input",
slog.String("node", nodeName),
slog.String("type", node.Type),
)
continue
}
resolvedParams := ResolveExpressions(node.Parameters, execCtx, nodeName)
node.Parameters = resolvedParams
executorFn, err := lookup(node.Type)
if err != nil {
slog.ErrorContext(ctx, "node_skipped",
slog.String("node", nodeName),
slog.String("type", node.Type),
slog.String("error", err.Error()),
)
execErrors = append(execErrors, fmt.Sprintf("node %q: %v", nodeName, err))
forwardInputs(execCtx, nodeName, inputs)
continue
}
start := time.Now()
outputs, err := executeWithRetry(ctx, executorFn, node, inputs, execCtx)
elapsed := time.Since(start)
if err != nil {
slog.ErrorContext(ctx, "node_failed",
slog.String("node", nodeName),
slog.String("type", node.Type),
slog.String("error", err.Error()),
slog.Float64("duration_ms", float64(elapsed.Microseconds())/1000.0),
)
if IsTerminalError(err) {
return nil, fmt.Errorf("node %q: %w", nodeName, err)
}
execErrors = append(execErrors, fmt.Sprintf("node %q execution failed: %v", nodeName, err))
forwardInputs(execCtx, nodeName, inputs)
continue
}
slog.InfoContext(ctx, "node_executed",
slog.String("node", nodeName),
slog.String("type", node.Type),
slog.Float64("duration_ms", float64(elapsed.Microseconds())/1000.0),
)
for outIdx, items := range outputs {
execCtx.SetOutput(nodeName, outIdx, items)
}
}
status := "success"
if len(execErrors) > 0 {
status = "partial_error"
}
slog.InfoContext(ctx, "workflow_completed",
slog.String("workflow", wf.Name),
slog.String("status", status),
slog.Int("errors", len(execErrors)),
)
return &models.ExecutionResult{
Status: status,
Outputs: getTerminalOutputs(execCtx, dag),
NodeOutputs: execCtx.AllOutputs(),
Errors: execErrors,
}, nil
}