mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
- Added multi-execution view to display multiple execution statuses. - Improved error handling in agent triggering to surface failures. - Updated execution view to support live updates via SSE and added a log panel for node logs. - Enhanced pipeline canvas to display node statuses during execution. - Introduced new fields in the build node form for Docker configurations. - Updated API to support execution streaming and enhanced node catalog descriptions.
222 lines
5.6 KiB
Go
222 lines
5.6 KiB
Go
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/lyzrai/flow/pkg/durability"
|
|
"github.com/lyzrai/flow/pkg/engine"
|
|
"github.com/lyzrai/flow/pkg/models"
|
|
)
|
|
|
|
// walkDurable executes a workflow's DAG with each node wrapped in a NodeRunner
|
|
// (typically restate.Run for journaling). Sequential topological order — the
|
|
// reactive ready-queue with futures is a v0.2 thing.
|
|
func walkDurable(
|
|
ctx context.Context,
|
|
wf *models.WorkflowDefinition,
|
|
triggerData []models.Item,
|
|
lookup engine.ExecutorLookup,
|
|
runNode NodeRunner,
|
|
) (*models.ExecutionResult, error) {
|
|
dag, err := engine.BuildDAG(wf)
|
|
if err != nil {
|
|
wrapped := fmt.Errorf("failed to build DAG: %w", err)
|
|
return &models.ExecutionResult{Status: "failed", Errors: []string{wrapped.Error()}}, wrapped
|
|
}
|
|
|
|
order, err := dag.TopologicalSort()
|
|
if err != nil {
|
|
wrapped := fmt.Errorf("failed to sort DAG: %w", err)
|
|
return &models.ExecutionResult{Status: "failed", Errors: []string{wrapped.Error()}}, wrapped
|
|
}
|
|
|
|
slog.InfoContext(ctx, "workflow_started_durable",
|
|
slog.String("workflow", wf.Name),
|
|
slog.Int("node_count", len(order)),
|
|
)
|
|
|
|
execCtx := engine.NewExecutionContext(wf, dag)
|
|
execCtx.Lookup = lookup
|
|
|
|
emitter := engine.EmitterFromContext(ctx)
|
|
execID := durability.ExecutionIDFromContext(ctx)
|
|
|
|
emit := func(e engine.ExecutionEvent) {
|
|
emitter.Emit(ctx, execID, e)
|
|
}
|
|
|
|
var execErrors []string
|
|
|
|
for _, nodeName := range order {
|
|
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 orphan non-trigger nodes (no path from a trigger).
|
|
if node.Type != "flow-nodes-base.trigger" && len(dag.InEdges[nodeName]) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Skip nodes whose inputs were routed elsewhere (Switch/If false branch).
|
|
if len(dag.InEdges[nodeName]) > 0 && allInputsEmpty(inputs) {
|
|
continue
|
|
}
|
|
|
|
resolvedParams := engine.ResolveExpressions(node.Parameters, execCtx, nodeName)
|
|
node.Parameters = resolvedParams
|
|
|
|
executorFn, err := lookup(node.Type)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "node_skipped_unknown_type",
|
|
slog.String("node", nodeName),
|
|
slog.String("type", node.Type),
|
|
)
|
|
execErrors = append(execErrors, fmt.Sprintf("node %q: %v", nodeName, err))
|
|
continue
|
|
}
|
|
|
|
// Capture loop-locals for the closure.
|
|
nm, nd, in, ex := nodeName, node, inputs, executorFn
|
|
|
|
emit(engine.ExecutionEvent{
|
|
Type: engine.EventNodeStarted,
|
|
Node: nm,
|
|
NodeType: nd.Type,
|
|
Status: "running",
|
|
})
|
|
|
|
// Per-node logger that publishes EventNodeLog events on the same
|
|
// emitter the SSE bus subscribes to.
|
|
nodeLogger := &emitNodeLogger{
|
|
emitter: emitter,
|
|
ctx: ctx,
|
|
execID: execID,
|
|
node: nm,
|
|
ntype: nd.Type,
|
|
}
|
|
|
|
started := time.Now()
|
|
outputs, runErr := runNode(ctx, "node:"+nm, func(c context.Context) (map[int][]models.Item, error) {
|
|
c = engine.WithNodeLogger(c, nodeLogger)
|
|
return ex(c, nd, in, execCtx)
|
|
})
|
|
dur := time.Since(started).Milliseconds()
|
|
|
|
if runErr != nil {
|
|
emit(engine.ExecutionEvent{
|
|
Type: engine.EventNodeError,
|
|
Node: nm,
|
|
NodeType: nd.Type,
|
|
Status: "failed",
|
|
Error: runErr.Error(),
|
|
DurationMs: dur,
|
|
})
|
|
execErrors = append(execErrors, fmt.Sprintf("node %q: %v", nm, runErr))
|
|
continue
|
|
}
|
|
for outIdx, items := range outputs {
|
|
execCtx.SetOutput(nm, outIdx, items)
|
|
}
|
|
|
|
emit(engine.ExecutionEvent{
|
|
Type: engine.EventNodeCompleted,
|
|
Node: nm,
|
|
NodeType: nd.Type,
|
|
Status: "success",
|
|
Outputs: nodeOutputsPreview(outputs),
|
|
DurationMs: dur,
|
|
})
|
|
}
|
|
|
|
status := "success"
|
|
if len(execErrors) > 0 {
|
|
status = "partial_error"
|
|
}
|
|
|
|
emit(engine.ExecutionEvent{
|
|
Type: engine.EventDone,
|
|
Status: status,
|
|
})
|
|
|
|
return &models.ExecutionResult{
|
|
Status: status,
|
|
Outputs: terminalOutputs(execCtx, dag),
|
|
NodeOutputs: execCtx.AllOutputs(),
|
|
Errors: execErrors,
|
|
}, nil
|
|
}
|
|
|
|
func allInputsEmpty(inputs [][]models.Item) bool {
|
|
for _, group := range inputs {
|
|
if len(group) > 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// nodeOutputsPreview returns a small map suitable for embedding in an event.
|
|
// We don't ship full items over the SSE stream — they can be huge — just a
|
|
// per-output-index count + the first item's keys.
|
|
func nodeOutputsPreview(out map[int][]models.Item) map[string]any {
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
preview := map[string]any{}
|
|
for idx, items := range out {
|
|
entry := map[string]any{"count": len(items)}
|
|
if len(items) > 0 {
|
|
keys := make([]string, 0, len(items[0]))
|
|
for k := range items[0] {
|
|
keys = append(keys, k)
|
|
}
|
|
entry["sample_keys"] = keys
|
|
}
|
|
preview[fmt.Sprintf("%d", idx)] = entry
|
|
}
|
|
return preview
|
|
}
|
|
|
|
// emitNodeLogger is the per-node engine.NodeLogger implementation. Each
|
|
// Log call becomes an EventNodeLog event tagged with the node name + type.
|
|
type emitNodeLogger struct {
|
|
emitter engine.Emitter
|
|
ctx context.Context
|
|
execID string
|
|
node string
|
|
ntype string
|
|
}
|
|
|
|
func (l *emitNodeLogger) Log(line string) {
|
|
if l == nil || l.emitter == nil {
|
|
return
|
|
}
|
|
l.emitter.Emit(l.ctx, l.execID, engine.ExecutionEvent{
|
|
Type: engine.EventNodeLog,
|
|
Node: l.node,
|
|
NodeType: l.ntype,
|
|
Content: line,
|
|
})
|
|
}
|
|
|
|
func terminalOutputs(ctx *engine.ExecutionContext, dag *engine.DAG) map[string]map[int][]models.Item {
|
|
result := make(map[string]map[int][]models.Item)
|
|
all := ctx.AllOutputs()
|
|
for nodeName := range dag.Nodes {
|
|
if len(dag.Adjacency[nodeName]) == 0 {
|
|
if out, ok := all[nodeName]; ok {
|
|
result[nodeName] = out
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|