From 201d5b600d3038d8c85814e3ad0daefcfbe5ccf1 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 11:53:02 +0800 Subject: [PATCH 01/27] feat(core): publish loop containers and validate their scope and configuration --- internal/core/workflow_collaboration.go | 13 +- internal/core/workflow_graph.go | 61 ++- internal/core/workflow_loop_graph.go | 493 ++++++++++++++++++++++ internal/core/workflow_loop_graph_test.go | 387 +++++++++++++++++ internal/core/workflow_scheduler.go | 4 +- 5 files changed, 944 insertions(+), 14 deletions(-) create mode 100644 internal/core/workflow_loop_graph.go create mode 100644 internal/core/workflow_loop_graph_test.go diff --git a/internal/core/workflow_collaboration.go b/internal/core/workflow_collaboration.go index 38f382d0c..e7d870ce6 100644 --- a/internal/core/workflow_collaboration.go +++ b/internal/core/workflow_collaboration.go @@ -821,9 +821,14 @@ func foldLegacyVersionAnswer(graph, values Object) { } } -// workflowStartNode returns the `data` of the graph's first Start node. The graph is a document the API -// never interprets, so its shape is read defensively: anything unexpected contributes nothing rather -// than failing the request. The first Start node wins, matching the editor, which allows at most one. +// workflowStartNode returns the `data` of the graph's first outer Start node. The graph is a document +// the API never interprets, so its shape is read defensively: anything unexpected contributes nothing +// rather than failing the request. The first outer Start node wins, matching the editor, which allows +// at most one. +// +// A loop body carries its own Start node, and that node is not the run's entry point: its variables +// never enter the @ form and its prompt is never the kickoff. Skipping container members here is what +// keeps the launcher fields reading only the document's outer start. // // Reading the prompt and the variables through this one lookup is what keeps them from ever coming // from two different nodes in a graph that somehow carries more than one. @@ -835,7 +840,7 @@ func workflowStartNode(graph Object) Object { continue } data, ok := node["data"].(map[string]any) - if !ok || data["kind"] != "start" { + if !ok || data["kind"] != "start" || Object(data).S("containerId") != "" { continue } return Object(data) diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 7a7f2e5b8..53567f5b6 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -20,14 +20,19 @@ import ( // the whole point of the draft/published distinction, and moving an install or completeness check // into the save path is the mistake this file exists to prevent. -// workflowExecutableNodeKinds is the subset of the editor's node kinds Cloud executes in its first -// phase (#67 结论 4): start, agent, condition and output. Everything else stays editable and -// saveable, but publishing refuses it rather than freezing a graph whose nodes would be dropped. +// workflowExecutableNodeKinds is the subset of the editor's node kinds Cloud executes. It started as +// the root decision's phase-1 set (#67 结论 4: start, agent, condition, output) and grows one decision +// at a time; `loop` and `loopExit` joined it with +// specs decisions/cloud/workflow/20261025-loop-and-loop-exit-execution.md. Everything else stays +// editable and saveable, but publishing refuses it rather than freezing a graph whose nodes would be +// dropped. var workflowExecutableNodeKinds = map[string]bool{ "start": true, "agent": true, "condition": true, "output": true, + "loop": true, + "loopExit": true, } // workflowAgentSchemaVersion is the only `agentConfig.schemaVersion` Cloud executes. Version 4 @@ -277,9 +282,14 @@ func validateWorkflowGraph(graph Object) []workflowGraphProblem { problems = append(problems, workflowGraphProblem{Code: "edges_not_a_list"}) } globals := workflowGlobalVariables(graph) + edges, _ := graph["edges"].([]any) problems = append(problems, validateWorkflowGlobals(graph)...) problems = append(problems, validateWorkflowNodes(nodes, globals)...) problems = append(problems, validateWorkflowEdges(graph, nodes)...) + // Container structure is checked after the edges exist and before the cycle walk: a body whose + // membership or boundaries are wrong is described by its own codes, and the cycle walk reads the + // same union of scopes either way. + problems = append(problems, validateWorkflowContainers(nodes, edges)...) problems = append(problems, validateWorkflowAcyclic(graph)...) problems = append(problems, validateWorkflowSlots(graph, nodes)...) problems = append(problems, validateWorkflowReservedFields(nodes, globals)...) @@ -311,7 +321,9 @@ func validateWorkflowReservedFields(nodes []any, globals []Object) []workflowGra } o := Object(node) data := o.O("data") - if data.S("kind") != "start" { + // A loop body's Start is not the form's Start: its variables never enter the `@` form, so a + // reserved name there cannot collide with the platform's field. + if data.S("kind") != "start" || data.S("containerId") != "" { continue } declared, _ := data["inputVariables"].([]any) @@ -370,13 +382,21 @@ func validateWorkflowAcyclic(graph Object) []workflowGraphProblem { return []workflowGraphProblem{{Code: "graph_cycle", NodeID: source}} } -// validateWorkflowNodes checks node identity, kind, agent configuration and the start-node count. The -// editor allows at most one Start node, and a run needs exactly one entry point, so zero and two are -// both refused: a graph with none is unfinished, and one with two has no defined place to begin. +// validateWorkflowNodes checks node identity, kind, per-kind configuration and the start-node count. +// The editor allows at most one Start node in the outer graph, and a run needs exactly one entry +// point, so zero and two are both refused: a graph with none is unfinished, and one with two has no +// defined place to begin. A loop body's own start is a second `start` in the document; it is counted +// and checked by validateWorkflowContainers, not here, and it never becomes the run's entry point. +// +// Selectors are validated against the roots visible in the node's scope, not the flat declaration +// set: a body node may read its loop's carried variables, and an outer node may read the loop's +// published outputs. See workflowVisibleRoots. func validateWorkflowNodes(nodes []any, globals []Object) []workflowGraphProblem { problems := []workflowGraphProblem{} seen := map[string]bool{} declaredRoots := workflowDeclaredRoots(nodes, globals) + containers := workflowNodeContainers(nodes) + loops := workflowLoopRootsByName(nodes) starts := 0 for _, raw := range nodes { node, ok := raw.(map[string]any) @@ -396,13 +416,16 @@ func validateWorkflowNodes(nodes []any, globals []Object) []workflowGraphProblem } seen[id] = true kind := o.O("data").S("kind") + visibleRoots := workflowVisibleRoots(declaredRoots, loops, containers[id]) switch { case kind == "": problems = append(problems, workflowGraphProblem{Code: "node_kind_missing", NodeID: id}) case !workflowExecutableNodeKinds[kind]: problems = append(problems, workflowGraphProblem{Code: "node_kind_unsupported", NodeID: id, Kind: kind}) case kind == "start": - starts++ + if containers[id] == "" { + starts++ + } case kind == "agent": // Only an agent node is asked about its configuration, and only once its kind is known to // be one Cloud executes: a `tool` node carrying `skills` is already refused for the kind, @@ -410,7 +433,12 @@ func validateWorkflowNodes(nodes []any, globals []Object) []workflowGraphProblem // delete anyway. problems = append(problems, validateAgentConfig(id, o.O("data"))...) case kind == "condition": - problems = append(problems, validateConditionConfig(id, o.O("data"), declaredRoots)...) + problems = append(problems, validateConditionConfig(id, o.O("data"), visibleRoots)...) + case kind == "loop" && containers[id] == "": + // The loop's own initial values resolve in the outer scope, while feedback, until and + // outputs resolve against a round pool; validateLoopConfig derives both from the flat + // declarations instead of the node's container visibility. + problems = append(problems, validateLoopConfig(id, o.O("data"), declaredRoots, loops)...) } } if starts != 1 { @@ -609,6 +637,21 @@ func workflowDeclaredRoots(nodes []any, globals []Object) map[string]map[string] } case "output": set["output"] = true + case "loop": + // A loop exposes two root groups under the same writer: its carried variables (visible + // only inside the body) and its named outputs (visible only outside). They share a name + // space here because the visibility filter separates them by scope; the runtime never + // writes both under one key. + for _, variable := range workflowObjectEntries(data.O("loopConfig")["variables"]) { + if name := variable.S("name"); name != "" { + set[name] = true + } + } + for _, output := range workflowObjectEntries(data.O("loopConfig")["outputs"]) { + if name := output.S("name"); name != "" { + set[name] = true + } + } } roots[id] = set } diff --git a/internal/core/workflow_loop_graph.go b/internal/core/workflow_loop_graph.go new file mode 100644 index 000000000..0f5474917 --- /dev/null +++ b/internal/core/workflow_loop_graph.go @@ -0,0 +1,493 @@ +package core + +import ( + "math" + "strings" +) + +// Loop containers are the first composite nodes Cloud executes +// (specs decisions/cloud/workflow/20261025-loop-and-loop-exit-execution.md). This file owns the +// structural rules that make a loop a loop: its body is a separate DAG whose membership is declared by +// `data.containerId`, only the body may read the loop's carried variables, and only the outer graph may +// read its published outputs. The publish gate and the scheduler read these rules, so a graph Cloud +// publishes is a graph Cloud can run. +// +// A loop's body start is a second `start` node in the document. Every reader that used to assume one +// flat graph (the run's launcher fields, the reserved-field walk, the start-node count) therefore has +// to look at the outer scope only; the helpers below are the one place that distinction is made. + +const ( + // workflowLoopMinIterations and workflowLoopMaxIterations mirror desktop's 1..=100 ceiling + // (desktop `loop_config.rs`), so a loop Cloud accepts can produce the same round budget. + workflowLoopMinIterations = 1 + workflowLoopMaxIterations = 100 +) + +// workflowLoopRoots separates the two root groups a loop publishes, because their visibility differs. +// A carried variable is visible only inside the loop's body; a named output only outside it. Keeping +// them apart is what lets the gate refuse `[loop, draft]` from the outer graph and `[loop, result]` +// from the body without knowing what the runtime did. +type workflowLoopRoots struct { + carried map[string]bool + outputs map[string]bool +} + +// workflowNodeContainers maps every node id to the id of the container it belongs to, or "" for the +// outer graph. A `containerId` naming a node that does not exist is still returned: the structure +// validator reports it rather than silently reading the node as outer. +func workflowNodeContainers(nodes []any) map[string]string { + containers := make(map[string]string, len(nodes)) + for _, raw := range nodes { + node, ok := asWorkflowObject(raw) + if !ok { + continue + } + if id := node.S("id"); id != "" { + containers[id] = node.O("data").S("containerId") + } + } + return containers +} + +// workflowLoopRootsByName reads each loop's declared carried and output names. It reads names only, +// so a malformed config still declares nothing here while validateLoopConfig reports the shape error; +// the two functions must not both report the same fault. +func workflowLoopRootsByName(nodes []any) map[string]workflowLoopRoots { + out := map[string]workflowLoopRoots{} + for _, raw := range nodes { + node, ok := asWorkflowObject(raw) + if !ok { + continue + } + data := node.O("data") + if data.S("kind") != "loop" { + continue + } + id := node.S("id") + if id == "" { + continue + } + config := data.O("loopConfig") + roots := workflowLoopRoots{carried: map[string]bool{}, outputs: map[string]bool{}} + for _, variable := range workflowObjectEntries(config["variables"]) { + if name := variable.S("name"); name != "" { + roots.carried[name] = true + } + } + for _, output := range workflowObjectEntries(config["outputs"]) { + if name := output.S("name"); name != "" { + roots.outputs[name] = true + } + } + out[id] = roots + } + return out +} + +// workflowVisibleRoots returns the declared roots visible to a node in containerID, copying the base +// map so the caller's declarations are never mutated. A loop's carried roots are removed outside its +// body, and its named outputs are removed inside it: those are the two roots the other scope cannot +// read. Selectors are validated against this projection rather than the flat declaration set, so an +// outer selector on a carried variable is refused the same way desktop's Undeclared pool answers. +func workflowVisibleRoots( + declared map[string]map[string]bool, + loops map[string]workflowLoopRoots, + containerID string, +) map[string]map[string]bool { + out := make(map[string]map[string]bool, len(declared)) + for writer, roots := range declared { + set := make(map[string]bool, len(roots)) + for root := range roots { + set[root] = true + } + out[writer] = set + } + for loopID, groups := range loops { + set, ok := out[loopID] + if !ok { + continue + } + if loopID == containerID { + for name := range groups.outputs { + delete(set, name) + } + continue + } + for name := range groups.carried { + delete(set, name) + } + } + return out +} + +// workflowLoopBodyContainers returns the ids whose body belongs to a loop node, in document order. +func workflowLoopBodyContainers(nodes []any) []string { + out := []string{} + for _, raw := range nodes { + node, ok := asWorkflowObject(raw) + if !ok { + continue + } + data := node.O("data") + if data.S("kind") == "loop" && node.S("id") != "" && data.S("containerId") == "" { + out = append(out, node.S("id")) + } + } + return out +} + +// validateWorkflowContainers checks the structure of every loop container: membership, the one body +// start, nested composites and outputs, cross-scope edges and loopExit terminality. It reports one +// problem per violated rule and node, in document order, so a client renders a stable list. +func validateWorkflowContainers(nodes, edges []any) []workflowGraphProblem { + problems := []workflowGraphProblem{} + type nodeInfo struct { + kind string + container string + } + info := map[string]nodeInfo{} + order := []string{} + for _, raw := range nodes { + node, ok := asWorkflowObject(raw) + if !ok { + continue + } + id := node.S("id") + if id == "" { + continue + } + data := node.O("data") + info[id] = nodeInfo{kind: data.S("kind"), container: data.S("containerId")} + order = append(order, id) + } + // Membership rules: a body node names a loop that exists, and the kinds with their own scope or + // terminal semantics cannot be nested in v1. A loopExit outside any body has no loop to exit. + for _, id := range order { + node := info[id] + if node.kind == "loopExit" && node.container == "" { + problems = append(problems, workflowGraphProblem{Code: "loop_exit_outside_body", NodeID: id}) + } + if node.container == "" { + continue + } + owner, known := info[node.container] + if !known || owner.kind != "loop" { + problems = append(problems, workflowGraphProblem{Code: "loop_member_container_mismatch", NodeID: id}) + continue + } + switch node.kind { + case "loop", "iteration", "subflow": + problems = append(problems, workflowGraphProblem{Code: "loop_nested_composite_unsupported", NodeID: id, Kind: node.kind}) + case "output": + problems = append(problems, workflowGraphProblem{Code: "loop_body_output_unsupported", NodeID: id}) + } + } + // Edge rules: an edge may not cross a container boundary. Both endpoints are known here because + // validateWorkflowEdges reports unknown endpoints separately; skipping them keeps one mistake + // from being reported as a container error too. + crossContainer := map[string]bool{} + for _, raw := range edges { + edge, ok := asWorkflowObject(raw) + if !ok { + continue + } + source, target := edge.S("source"), edge.S("target") + sourceNode, sourceOK := info[source] + targetNode, targetOK := info[target] + if !sourceOK || !targetOK { + continue + } + if sourceNode.container != targetNode.container { + problems = append(problems, workflowGraphProblem{Code: "loop_cross_container_edge", NodeID: source}) + crossContainer[source] = true + crossContainer[target] = true + } + } + // loopExit is a terminal marker: it has no outgoing edge in any container. + for _, raw := range edges { + edge, ok := asWorkflowObject(raw) + if !ok { + continue + } + if info[edge.S("source")].kind == "loopExit" { + problems = append(problems, workflowGraphProblem{Code: "loop_exit_has_outgoing_edge", NodeID: edge.S("source")}) + } + } + // Per-loop body rules: exactly one start, and every member reachable from it. Reachability is + // skipped when the start count is wrong because there is no defined place to begin. + for _, loopID := range workflowLoopBodyContainers(nodes) { + members := []string{} + starts := []string{} + for _, id := range order { + if info[id].container != loopID { + continue + } + members = append(members, id) + if info[id].kind == "start" { + starts = append(starts, id) + } + } + if len(starts) != 1 { + problems = append(problems, workflowGraphProblem{Code: "loop_body_start_count", NodeID: loopID}) + continue + } + reachable := map[string]bool{starts[0]: true} + queue := []string{starts[0]} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, raw := range edges { + edge, ok := asWorkflowObject(raw) + if !ok || edge.S("source") != current { + continue + } + next := edge.S("target") + if info[next].container != loopID || reachable[next] { + continue + } + reachable[next] = true + queue = append(queue, next) + } + } + for _, member := range members { + // A nested composite or an Output member is already refused for what it is; reporting it + // as unreachable too would ask the author to fix a node they have to move or delete. + switch info[member].kind { + case "loop", "iteration", "subflow", "output": + continue + } + if !reachable[member] && !crossContainer[member] { + problems = append(problems, workflowGraphProblem{Code: "loop_body_unreachable", NodeID: member}) + } + } + } + return problems +} + +// validateLoopConfig checks `data.loopConfig` against the shape the scheduler executes. `declared` is +// the flat declaration set and `loops` the visibility groups: the initial value is resolved in the +// outer scope at entry, while feedback, until and outputs are resolved against the round pool, which +// adds the carried roots and drops the loop's own outputs. +func validateLoopConfig( + nodeID string, + data Object, + declared map[string]map[string]bool, + loops map[string]workflowLoopRoots, +) []workflowGraphProblem { + problems := []workflowGraphProblem{} + raw, present := data["loopConfig"] + if !present || raw == nil { + return append(problems, workflowGraphProblem{Code: "loop_config_missing", NodeID: nodeID, Field: "loopConfig"}) + } + config, ok := asWorkflowObject(raw) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_config_missing", NodeID: nodeID, Field: "loopConfig"}) + } + outer := workflowVisibleRoots(declared, loops, "") + body := workflowVisibleRoots(declared, loops, nodeID) + + problems = append(problems, validateLoopMaxIterations(nodeID, config)...) + problems = append(problems, validateLoopVariables(nodeID, config, outer)...) + problems = append(problems, validateLoopUntil(nodeID, config, body)...) + problems = append(problems, validateLoopOutputs(nodeID, config, body)...) + return problems +} + +// validateLoopMaxIterations checks the round budget and the exhaustion policy. Both decide whether a +// run stops with a failure or publishes its last round, so a value the scheduler would have to guess +// at is refused rather than defaulted. +func validateLoopMaxIterations(nodeID string, config Object) []workflowGraphProblem { + problems := []workflowGraphProblem{} + maxIterations, numeric := workflowNumberOf(config["maxIterations"]) + if !numeric || maxIterations != math.Trunc(maxIterations) || + maxIterations < workflowLoopMinIterations || maxIterations > workflowLoopMaxIterations { + problems = append(problems, workflowGraphProblem{Code: "loop_max_iterations_invalid", NodeID: nodeID, Field: "loopConfig.maxIterations"}) + } + if value, present := config["maxIterationsBehavior"]; present && value != nil { + behavior, text := value.(string) + if !text || (behavior != "fail" && behavior != "succeed") { + problems = append(problems, workflowGraphProblem{Code: "loop_max_iterations_behavior_invalid", NodeID: nodeID, Field: "loopConfig.maxIterationsBehavior"}) + } + } + return problems +} + +// validateLoopVariables checks the carried variables: names that address a selector segment, a +// supported type, an initial value that type accepts, and a feedback selector the round pool can +// resolve. Initial is resolved in the outer scope, feedback in the body. +func validateLoopVariables(nodeID string, config Object, outer map[string]map[string]bool) []workflowGraphProblem { + problems := []workflowGraphProblem{} + raw, present := config["variables"] + if !present || raw == nil { + return append(problems, workflowGraphProblem{Code: "loop_variables_not_a_list", NodeID: nodeID, Field: "loopConfig.variables"}) + } + variables, ok := raw.([]any) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_variables_not_a_list", NodeID: nodeID, Field: "loopConfig.variables"}) + } + names := map[string]bool{} + for _, item := range variables { + variable, ok := asWorkflowObject(item) + if !ok { + problems = append(problems, workflowGraphProblem{Code: "loop_variable_invalid", NodeID: nodeID, Field: "loopConfig.variables"}) + continue + } + name := variable.S("name") + if !validWorkflowLoopName(name) { + problems = append(problems, workflowGraphProblem{Code: "loop_variable_invalid", NodeID: nodeID, Field: "loopConfig.variables.name"}) + } else if names[name] { + problems = append(problems, workflowGraphProblem{Code: "loop_variable_duplicate", NodeID: nodeID, Field: "loopConfig.variables.name"}) + } + names[name] = true + valueType := variable.S("valueType") + if !workflowSupportedVariableType(valueType) { + problems = append(problems, workflowGraphProblem{Code: "loop_variable_type_unsupported", NodeID: nodeID, Field: "loopConfig.variables.valueType"}) + continue + } + problems = append(problems, validateLoopInitial(nodeID, variable, valueType, outer)...) + problems = append(problems, validateLoopSelector(nodeID, variable["feedback"], "loopConfig.variables.feedback", "loop_feedback_invalid", outer)...) + } + return problems +} + +// validateLoopInitial checks one carried variable's first-round value. A constant is checked against +// the declared type (the scheduler normalizes it the same way at entry); a variable reference is +// resolved once in the outer scope, so it may name an ancestor's root but not a carried one. +func validateLoopInitial(nodeID string, variable Object, valueType string, outer map[string]map[string]bool) []workflowGraphProblem { + problems := []workflowGraphProblem{} + initial, ok := asWorkflowObject(variable["initial"]) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_initial_invalid", NodeID: nodeID, Field: "loopConfig.variables.initial"}) + } + switch initial.S("kind") { + case "constant": + value, present := initial["value"] + if !present { + problems = append(problems, workflowGraphProblem{Code: "loop_initial_invalid", NodeID: nodeID, Field: "loopConfig.variables.initial.value"}) + break + } + if _, ok := normalizeWorkflowValue(value, valueType); !ok { + problems = append(problems, workflowGraphProblem{Code: "loop_initial_invalid", NodeID: nodeID, Field: "loopConfig.variables.initial.value"}) + } + case "variable": + problems = append(problems, validateLoopSelector(nodeID, initial["selector"], "loopConfig.variables.initial.selector", "loop_initial_invalid", outer)...) + default: + problems = append(problems, workflowGraphProblem{Code: "loop_initial_invalid", NodeID: nodeID, Field: "loopConfig.variables.initial.kind"}) + } + return problems +} + +// validateLoopUntil checks the round-end condition. The comparison rules are the condition node's own +// validator, so an operator or selector this gate admits is one the evaluator can apply; only the +// envelope (logic and list shape) has a loop-specific code. +func validateLoopUntil(nodeID string, config Object, body map[string]map[string]bool) []workflowGraphProblem { + problems := []workflowGraphProblem{} + until, ok := asWorkflowObject(config["until"]) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_until_invalid", NodeID: nodeID, Field: "loopConfig.until"}) + } + switch until.S("logic") { + case "and", "or": + default: + problems = append(problems, workflowGraphProblem{Code: "loop_until_invalid", NodeID: nodeID, Field: "loopConfig.until.logic"}) + } + switch conditions := until["conditions"].(type) { + case nil: + problems = append(problems, workflowGraphProblem{Code: "loop_until_invalid", NodeID: nodeID, Field: "loopConfig.until.conditions"}) + case []any: + for _, rule := range conditions { + problems = append(problems, validateConditionRule(nodeID, rule, body)...) + } + default: + problems = append(problems, workflowGraphProblem{Code: "loop_until_invalid", NodeID: nodeID, Field: "loopConfig.until.conditions"}) + } + return problems +} + +// validateLoopOutputs checks the names a loop publishes. Each selector is resolved against the final +// round's pool, so it may name a body node or a carried root but not the loop's own outputs. +func validateLoopOutputs(nodeID string, config Object, body map[string]map[string]bool) []workflowGraphProblem { + problems := []workflowGraphProblem{} + raw, present := config["outputs"] + if !present || raw == nil { + return append(problems, workflowGraphProblem{Code: "loop_outputs_not_a_list", NodeID: nodeID, Field: "loopConfig.outputs"}) + } + outputs, ok := raw.([]any) + if !ok { + return append(problems, workflowGraphProblem{Code: "loop_outputs_not_a_list", NodeID: nodeID, Field: "loopConfig.outputs"}) + } + names := map[string]bool{} + for _, item := range outputs { + output, ok := asWorkflowObject(item) + if !ok { + problems = append(problems, workflowGraphProblem{Code: "loop_output_invalid", NodeID: nodeID, Field: "loopConfig.outputs"}) + continue + } + name := output.S("name") + if !validWorkflowLoopName(name) { + problems = append(problems, workflowGraphProblem{Code: "loop_output_invalid", NodeID: nodeID, Field: "loopConfig.outputs.name"}) + } else if names[name] { + problems = append(problems, workflowGraphProblem{Code: "loop_output_duplicate", NodeID: nodeID, Field: "loopConfig.outputs.name"}) + } + names[name] = true + problems = append(problems, validateLoopSelector(nodeID, output["variableSelector"], "loopConfig.outputs.variableSelector", "loop_output_invalid", body)...) + } + return problems +} + +// validateLoopSelector checks one selector against a visibility projection. A malformed selector gets +// the caller's shape code; a well-formed one that names an undeclared writer or root gets the shared +// undeclared code, so the author learns the same thing whichever field carried the reference. +func validateLoopSelector( + nodeID string, + value any, + field string, + shapeCode string, + visible map[string]map[string]bool, +) []workflowGraphProblem { + parts, ok := workflowSelectorParts(value) + if !ok { + return []workflowGraphProblem{{Code: shapeCode, NodeID: nodeID, Field: field}} + } + if !visible[parts[0]][parts[1]] { + return []workflowGraphProblem{{Code: "loop_selector_undeclared", NodeID: nodeID, Field: field}} + } + return nil +} + +// workflowSelectorParts reads a Dify-style selector as its string segments, desktop's +// `VariableSelector::try_from_parts`: at least a node and a root, every segment non-empty. +func workflowSelectorParts(value any) ([]string, bool) { + raw, ok := value.([]any) + if !ok || len(raw) < 2 { + return nil, false + } + parts := make([]string, 0, len(raw)) + for _, item := range raw { + text, ok := item.(string) + if !ok || text == "" { + return nil, false + } + parts = append(parts, text) + } + return parts, true +} + +// validWorkflowLoopName reports whether a carried-variable or output name can address a selector +// segment: non-blank, no surrounding whitespace and no dot (desktop's loop `validate_name`). +func validWorkflowLoopName(name string) bool { + return name != "" && strings.TrimSpace(name) == name && !strings.Contains(name, ".") +} + +// workflowObjectEntries reads a JSON list of objects, dropping entries that are not objects. It is for +// readers that only collect names, where a malformed entry is reported by the shape validator. +func workflowObjectEntries(raw any) []Object { + list, _ := raw.([]any) + out := make([]Object, 0, len(list)) + for _, item := range list { + if entry, ok := asWorkflowObject(item); ok { + out = append(out, entry) + } + } + return out +} diff --git a/internal/core/workflow_loop_graph_test.go b/internal/core/workflow_loop_graph_test.go new file mode 100644 index 000000000..06944564d --- /dev/null +++ b/internal/core/workflow_loop_graph_test.go @@ -0,0 +1,387 @@ +package core + +import ( + "reflect" + "testing" +) + +// The loop container is the first graph scope Cloud publishes and executes. These tests are the +// publish gate's side of the while-loop decision: a well-formed loop passes, and every structural or +// configuration fault the scheduler would otherwise have to guess at is refused with a code, a node +// and a field. + +// loopTestGraph builds the smallest executable loop: an outer start → loop → output, and a body +// start → agent inside the loop. The loop's config is the argument so a test can replace or damage it. +func loopTestGraph(loopConfig Object) Object { + loopData := Object{"kind": "loop", "title": "循环 1"} + if loopConfig != nil { + loopData["loopConfig"] = loopConfig + } + return Object{ + "nodes": []any{ + Object{"id": "start-1", "type": "workflow", "data": Object{"kind": "start", "title": "开始 1", "inputVariables": []any{}}}, + Object{"id": "loop-1", "type": "workflow", "data": loopData}, + Object{"id": "entry", "type": "workflow", "data": Object{"kind": "start", "title": "入口", "containerId": "loop-1", "inputVariables": []any{}}}, + Object{"id": "worker", "type": "workflow", "data": Object{"kind": "agent", "title": "写手", "containerId": "loop-1", "agentConfig": Object{"schemaVersion": 3, "prompt": "write"}}}, + Object{"id": "out", "type": "workflow", "data": Object{"kind": "output", "title": "输出 1"}}, + }, + "edges": []any{ + Object{"source": "start-1", "target": "loop-1"}, + Object{"source": "entry", "target": "worker"}, + Object{"source": "loop-1", "target": "out"}, + }, + } +} + +// validLoopConfig is the configuration the scheduler executes: one carried string, an until that +// reads the body agent, and one published output. +func validLoopConfig() Object { + return Object{ + "maxIterations": 3, + "variables": []any{ + Object{ + "name": "draft", + "valueType": "string", + "initial": Object{"kind": "constant", "value": ""}, + "feedback": []any{"worker", "output"}, + }, + }, + "until": Object{ + "logic": "and", + "conditions": []any{ + Object{"variableSelector": []any{"worker", "output"}, "operator": "equals", "value": "done"}, + }, + }, + "outputs": []any{ + Object{"name": "result", "variableSelector": []any{"worker", "output"}}, + }, + } +} + +// loopProblemStrings projects problems as stable `code@node#field` strings so a case can state the +// complete expected list without depending on the problem struct's unused fields. +func loopProblemStrings(t *testing.T, graph Object) []string { + t.Helper() + out := []string{} + for _, problem := range validateWorkflowGraph(decodeGraph(t, graph)) { + entry := problem.Code + if problem.NodeID != "" { + entry += "@" + problem.NodeID + } + if problem.Field != "" { + entry += "#" + problem.Field + } + out = append(out, entry) + } + return out +} + +// TestWorkflowPublishAcceptsAWellFormedLoop is the positive control: without it every refusal below +// could pass on a graph that never validates. +func TestWorkflowPublishAcceptsAWellFormedLoop(t *testing.T) { + if got := loopProblemStrings(t, loopTestGraph(validLoopConfig())); len(got) != 0 { + t.Fatalf("a well-formed loop must publish, got problems %v", got) + } +} + +// TestWorkflowPublishRefusesInvalidLoopContainers is the structural matrix: membership, body start, +// nested composites, body output, cross-scope edges, loopExit terminality and reachability. +func TestWorkflowPublishRefusesInvalidLoopContainers(t *testing.T) { + cases := []struct { + name string + mutate func(graph Object) + want []string + }{ + { + name: "body without a start", + mutate: func(graph Object) { + graph["nodes"] = removeNode(graph["nodes"], "entry") + graph["edges"] = removeEdge(graph["edges"], "entry", "worker") + }, + want: []string{"loop_body_start_count@loop-1"}, + }, + { + name: "body with two starts", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "entry-2", "data": Object{"kind": "start", "containerId": "loop-1"}}) + }, + want: []string{"loop_body_start_count@loop-1"}, + }, + { + name: "nested loop inside a body", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "loop-2", "data": Object{"kind": "loop", "containerId": "loop-1"}}) + }, + want: []string{"loop_nested_composite_unsupported@loop-2"}, + }, + { + name: "output inside a body", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "out-2", "data": Object{"kind": "output", "containerId": "loop-1"}}) + graph["edges"] = appendEdge(graph["edges"], "worker", "out-2") + }, + want: []string{"loop_body_output_unsupported@out-2"}, + }, + { + name: "edge crossing the container boundary", + mutate: func(graph Object) { + graph["edges"] = appendEdge(graph["edges"], "loop-1", "worker") + }, + want: []string{"loop_cross_container_edge@loop-1"}, + }, + { + name: "loopExit outside a body", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "exit-1", "data": Object{"kind": "loopExit"}}) + }, + want: []string{"loop_exit_outside_body@exit-1"}, + }, + { + name: "loopExit with an outgoing edge", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "exit-1", "data": Object{"kind": "loopExit", "containerId": "loop-1"}}) + graph["edges"] = appendEdge(graph["edges"], "entry", "exit-1") + graph["edges"] = appendEdge(graph["edges"], "exit-1", "worker") + }, + want: []string{"loop_exit_has_outgoing_edge@exit-1"}, + }, + { + name: "unreachable body member", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "stray", "data": Object{"kind": "agent", "containerId": "loop-1", "agentConfig": Object{"schemaVersion": 3, "prompt": "x"}}}) + }, + want: []string{"loop_body_unreachable@stray"}, + }, + { + name: "member naming a container that is not a loop", + mutate: func(graph Object) { + graph["nodes"] = appendNode(graph["nodes"], Object{"id": "lost", "data": Object{"kind": "agent", "containerId": "out", "agentConfig": Object{"schemaVersion": 3, "prompt": "x"}}}) + }, + want: []string{"loop_member_container_mismatch@lost"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + graph := loopTestGraph(validLoopConfig()) + tc.mutate(graph) + if got := loopProblemStrings(t, graph); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("problems = %v, want %v", got, tc.want) + } + }) + } +} + +// TestWorkflowPublishRefusesInvalidLoopConfig is the configuration matrix: the round budget, the +// exhaustion policy, carried names and types, the initial value, feedback, until and outputs. +func TestWorkflowPublishRefusesInvalidLoopConfig(t *testing.T) { + cases := []struct { + name string + mutate func(config Object) + want []string + }{ + { + name: "missing loopConfig", + mutate: nil, + want: []string{"loop_config_missing@loop-1#loopConfig"}, + }, + { + name: "non-integer maxIterations", + mutate: func(config Object) { config["maxIterations"] = 2.5 }, + want: []string{"loop_max_iterations_invalid@loop-1#loopConfig.maxIterations"}, + }, + { + name: "maxIterations above the ceiling", + mutate: func(config Object) { config["maxIterations"] = 101 }, + want: []string{"loop_max_iterations_invalid@loop-1#loopConfig.maxIterations"}, + }, + { + name: "unknown exhaustion policy", + mutate: func(config Object) { config["maxIterationsBehavior"] = "ignore" }, + want: []string{"loop_max_iterations_behavior_invalid@loop-1#loopConfig.maxIterationsBehavior"}, + }, + { + name: "variable name with a dot", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["name"] = "draft.value" + }, + want: []string{"loop_variable_invalid@loop-1#loopConfig.variables.name"}, + }, + { + name: "duplicate variable names", + mutate: func(config Object) { + config["variables"] = append(config["variables"].([]any), Object{"name": "draft", "valueType": "string", "initial": Object{"kind": "constant", "value": ""}, "feedback": []any{"worker", "output"}}) + }, + want: []string{"loop_variable_duplicate@loop-1#loopConfig.variables.name"}, + }, + { + name: "unsupported variable type", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["valueType"] = "date" + }, + want: []string{"loop_variable_type_unsupported@loop-1#loopConfig.variables.valueType"}, + }, + { + name: "constant that does not match its type", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["initial"] = Object{"kind": "constant", "value": 3} + }, + want: []string{"loop_initial_invalid@loop-1#loopConfig.variables.initial.value"}, + }, + { + name: "initial selector on an undeclared root", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["initial"] = Object{"kind": "variable", "selector": []any{"missing", "value"}} + }, + want: []string{"loop_selector_undeclared@loop-1#loopConfig.variables.initial.selector"}, + }, + { + name: "feedback on an undeclared root", + mutate: func(config Object) { + config["variables"].([]any)[0].(Object)["feedback"] = []any{"missing", "value"} + }, + want: []string{"loop_selector_undeclared@loop-1#loopConfig.variables.feedback"}, + }, + { + name: "until logic that is neither and nor or", + mutate: func(config Object) { + config["until"].(Object)["logic"] = "xor" + }, + want: []string{"loop_until_invalid@loop-1#loopConfig.until.logic"}, + }, + { + name: "until comparison with an unknown operator", + mutate: func(config Object) { + config["until"].(Object)["conditions"].([]any)[0].(Object)["operator"] = "bogus" + }, + want: []string{"condition_operator_unsupported@loop-1#cases.conditions.operator"}, + }, + { + name: "duplicate output names", + mutate: func(config Object) { + config["outputs"] = append(config["outputs"].([]any), Object{"name": "result", "variableSelector": []any{"worker", "output"}}) + }, + want: []string{"loop_output_duplicate@loop-1#loopConfig.outputs.name"}, + }, + { + name: "output selector on the loop's own output", + mutate: func(config Object) { + config["outputs"].([]any)[0].(Object)["variableSelector"] = []any{"loop-1", "result"} + }, + want: []string{"loop_selector_undeclared@loop-1#loopConfig.outputs.variableSelector"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var config Object + if tc.mutate != nil { + config = validLoopConfig() + tc.mutate(config) + } + if got := loopProblemStrings(t, loopTestGraph(config)); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("problems = %v, want %v", got, tc.want) + } + }) + } +} + +// TestWorkflowPublishResolvesLoopRootsPerScope is the visibility rule: a carried variable is readable +// inside the body and refused outside it, and a published output is the reverse. +func TestWorkflowPublishResolvesLoopRootsPerScope(t *testing.T) { + t.Run("body reads a carried value", func(t *testing.T) { + graph := loopTestGraph(validLoopConfig()) + graph["nodes"] = appendNode(graph["nodes"], Object{ + "id": "check", + "data": Object{"kind": "condition", "containerId": "loop-1", "cases": []any{Object{"id": "c1", "conditions": []any{Object{"variableSelector": []any{"loop-1", "draft"}, "operator": "not_empty"}}}}}, + }) + graph["edges"] = appendEdge(graph["edges"], "worker", "check") + if got := loopProblemStrings(t, graph); len(got) != 0 { + t.Fatalf("a body node may read its loop's carried variable, got %v", got) + } + }) + t.Run("outer graph cannot read a carried value", func(t *testing.T) { + graph := loopTestGraph(validLoopConfig()) + graph["nodes"] = appendNode(graph["nodes"], Object{ + "id": "check", + "data": Object{"kind": "condition", "cases": []any{Object{"id": "c1", "conditions": []any{Object{"variableSelector": []any{"loop-1", "draft"}, "operator": "not_empty"}}}}}, + }) + graph["edges"] = appendEdge(graph["edges"], "loop-1", "check") + want := []string{"condition_selector_undeclared@check#cases.conditions.variableSelector"} + if got := loopProblemStrings(t, graph); !reflect.DeepEqual(got, want) { + t.Fatalf("problems = %v, want %v", got, want) + } + }) + t.Run("outer graph reads the loop's published output", func(t *testing.T) { + graph := loopTestGraph(validLoopConfig()) + graph["nodes"] = appendNode(graph["nodes"], Object{ + "id": "check", + "data": Object{"kind": "condition", "cases": []any{Object{"id": "c1", "conditions": []any{Object{"variableSelector": []any{"loop-1", "result"}, "operator": "not_empty"}}}}}, + }) + graph["edges"] = appendEdge(graph["edges"], "loop-1", "check") + if got := loopProblemStrings(t, graph); len(got) != 0 { + t.Fatalf("the outer graph may read a loop's published output, got %v", got) + } + }) +} + +// TestWorkflowStartNodeIgnoresABodyStart keeps the launcher fields reading the outer graph: a body +// start appears first in the document here, which is exactly the order that would have been read as +// the run's entry point before containers existed. +func TestWorkflowStartNodeIgnoresABodyStart(t *testing.T) { + graph := Object{ + "nodes": []any{ + Object{"id": "entry", "data": Object{"kind": "start", "containerId": "loop-1", "input": "body prompt", "inputVariables": []any{Object{"name": "inner"}}}}, + Object{"id": "start-1", "data": Object{"kind": "start", "input": "run prompt", "inputVariables": []any{Object{"name": "outer"}}}}, + }, + } + decoded := decodeGraph(t, graph) + if got := workflowStartPrompt(decoded); got != "run prompt" { + t.Fatalf("start prompt = %q, want the outer start's prompt", got) + } + variables := workflowStartVariables(decoded) + if len(variables) != 1 || variables[0].S("name") != "outer" { + t.Fatalf("start variables = %v, want only the outer declaration", variables) + } + if got := readWorkflowGraph(decoded).Start; got != "start-1" { + t.Fatalf("run entry point = %q, want the outer start-1", got) + } +} + +// appendNode and appendEdge keep the structural cases readable without rebuilding the graph. +func appendNode(nodes any, node Object) []any { + list, _ := nodes.([]any) + return append(list, node) +} + +// appendEdge appends one edge, preserving the document's list type. +func appendEdge(edges any, source, target string) []any { + list, _ := edges.([]any) + return append(list, Object{"source": source, "target": target}) +} + +// removeNode drops one node by id from a document list. +func removeNode(nodes any, id string) []any { + list, _ := nodes.([]any) + out := make([]any, 0, len(list)) + for _, item := range list { + node, ok := asWorkflowObject(item) + if ok && node.S("id") == id { + continue + } + out = append(out, item) + } + return out +} + +// removeEdge drops the edge with the given endpoints. +func removeEdge(edges any, source, target string) []any { + list, _ := edges.([]any) + out := make([]any, 0, len(list)) + for _, item := range list { + edge, ok := asWorkflowObject(item) + if ok && edge.S("source") == source && edge.S("target") == target { + continue + } + out = append(out, item) + } + return out +} diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 714ec48a5..04f3309fa 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -157,7 +157,9 @@ func readWorkflowGraph(graph Object) workflowGraphView { view := workflowNodeView{ID: id, Kind: o.O("data").S("kind"), Data: o.O("data")} out.Nodes = append(out.Nodes, view) out.ByNode[id] = view - if view.Kind == "start" && out.Start == "" { + // A loop body carries its own start. The run's entry point is the outer start only; picking a + // body start here would make the projection root inside the container and never run the loop. + if view.Kind == "start" && view.Data.S("containerId") == "" && out.Start == "" { out.Start = id } } From 11b300a2a73310eaeb83b303335cee29a568acd5 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 12:11:08 +0800 Subject: [PATCH 02/27] feat(core): execute loop rounds and loopExit in the scheduler --- internal/core/workflow_loop_run.go | 640 ++++++++++++++++++ .../core/workflow_loop_scheduler_db_test.go | 199 ++++++ internal/core/workflow_scheduler.go | 52 +- internal/core/workflow_scheduler_db_test.go | 2 +- 4 files changed, 888 insertions(+), 5 deletions(-) create mode 100644 internal/core/workflow_loop_run.go create mode 100644 internal/core/workflow_loop_scheduler_db_test.go diff --git a/internal/core/workflow_loop_run.go b/internal/core/workflow_loop_run.go new file mode 100644 index 000000000..9d2b8aa37 --- /dev/null +++ b/internal/core/workflow_loop_run.go @@ -0,0 +1,640 @@ +package core + +import ( + "fmt" +) + +// The loop round engine. A loop is a container node whose body is a separate DAG (specs +// decisions/cloud/workflow/20261025-loop-and-loop-exit-execution.md). The active round's member +// states live in the run's flat node_states map, which is what lets the existing session, retry, +// branch and viewer paths keep working untouched: a body agent is an ordinary agent node while it +// runs. Completed rounds are archived on the loop node's own state. +// +// The engine is a pure advance function over committed state: it evaluates carried values, drives the +// body projection, folds a completed round into feedback, until and outputs, and either starts the +// next round or settles the loop. Nothing is remembered between passes. + +// workflowLoopRunState is the durable round progress of one loop node. +type workflowLoopRunState struct { + Round int64 + MaxIterations int64 + // Carried is the value set the active round entered with, keyed by the declared variable name. + Carried map[string]any + // Completed archives every finished round: {index, carried, states}. The final round is archived + // too; its member states also stay in the flat map so the run viewer can show them. + Completed []any + // Reason is the successful completion reason: condition_satisfied, max_iterations or loop_exit. + Reason string +} + +// object renders the loop progress in the shape the run viewer reads. +func (s *workflowLoopRunState) object() Object { + carried := Object{} + for name, value := range s.Carried { + carried[name] = value + } + completed := s.Completed + if completed == nil { + completed = []any{} + } + out := Object{ + "round": s.Round, + "maxIterations": s.MaxIterations, + "carried": carried, + "completed": completed, + } + if s.Reason != "" { + out["reason"] = s.Reason + } + return out +} + +// workflowLoopRunStateFromJSON reads committed loop progress back. A missing round reads as round 1 so +// a truncated document resumes at the first round instead of inventing a later one. +func workflowLoopRunStateFromJSON(raw Object) *workflowLoopRunState { + state := &workflowLoopRunState{ + Round: raw.N("round"), + MaxIterations: raw.N("maxIterations"), + Carried: map[string]any{}, + Reason: raw.S("reason"), + } + if state.Round < 1 { + state.Round = 1 + } + if carried, ok := asWorkflowObject(raw["carried"]); ok { + for name, value := range carried { + state.Carried[name] = value + } + } + if completed, ok := raw["completed"].([]any); ok { + state.Completed = completed + } + return state +} + +// workflowLoopVariableConfig is one carried variable after parsing: its first-round initializer and +// the selector that supplies the next round's value. +type workflowLoopVariableConfig struct { + Name string + ValueType string + InitialKind string + InitialValue any + InitialSelector workflowSelector + Feedback workflowSelector +} + +// workflowLoopOutput is one named result the loop publishes when it succeeds. +type workflowLoopOutput struct { + Name string + Selector workflowSelector +} + +// workflowLoopConfig is the executable shape of `data.loopConfig`. It is parsed defensively: a +// snapshot that predates the publish gate must fail the node with an explanation, never panic or +// silently run with defaults. +type workflowLoopConfig struct { + MaxIterations int64 + Behavior string + Variables []workflowLoopVariableConfig + UntilLogic string + UntilRules []any + Outputs []workflowLoopOutput +} + +// parseWorkflowLoopConfig compiles the wire config the publish gate accepted. Every field the engine +// needs is required here as well, because a frozen snapshot may predate the gate. +func parseWorkflowLoopConfig(data Object) (workflowLoopConfig, error) { + raw, present := data["loopConfig"] + if !present || raw == nil { + return workflowLoopConfig{}, fmt.Errorf("loop node has no loopConfig") + } + config, ok := asWorkflowObject(raw) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loopConfig must be an object") + } + maxIterations, numeric := workflowNumberOf(config["maxIterations"]) + if !numeric || maxIterations < workflowLoopMinIterations || maxIterations > workflowLoopMaxIterations { + return workflowLoopConfig{}, fmt.Errorf("maxIterations must be between %d and %d", workflowLoopMinIterations, workflowLoopMaxIterations) + } + parsed := workflowLoopConfig{MaxIterations: int64(maxIterations), Behavior: "fail"} + switch behavior := config.S("maxIterationsBehavior"); behavior { + case "": + case "fail", "succeed": + parsed.Behavior = behavior + default: + return workflowLoopConfig{}, fmt.Errorf("unknown maxIterationsBehavior %q", behavior) + } + variables, ok := config["variables"].([]any) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop variables must be a list") + } + for _, entry := range variables { + variable, err := parseWorkflowLoopVariable(entry) + if err != nil { + return workflowLoopConfig{}, err + } + parsed.Variables = append(parsed.Variables, variable) + } + until, ok := asWorkflowObject(config["until"]) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop until must be an object") + } + switch until.S("logic") { + case "and", "or": + parsed.UntilLogic = until.S("logic") + default: + return workflowLoopConfig{}, fmt.Errorf("loop until logic must be and or or") + } + rules, ok := until["conditions"].([]any) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop until conditions must be a list") + } + parsed.UntilRules = rules + outputs, ok := config["outputs"].([]any) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop outputs must be a list") + } + for _, entry := range outputs { + output, ok := asWorkflowObject(entry) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop output must be an object") + } + selector, ok := parseWorkflowSelector(output["variableSelector"]) + if !ok { + return workflowLoopConfig{}, fmt.Errorf("loop output %q has an invalid selector", output.S("name")) + } + parsed.Outputs = append(parsed.Outputs, workflowLoopOutput{Name: output.S("name"), Selector: selector}) + } + return parsed, nil +} + +// parseWorkflowLoopVariable compiles one carried variable. +func parseWorkflowLoopVariable(raw any) (workflowLoopVariableConfig, error) { + entry, ok := asWorkflowObject(raw) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable must be an object") + } + variable := workflowLoopVariableConfig{Name: entry.S("name"), ValueType: entry.S("valueType")} + if variable.Name == "" || variable.ValueType == "" { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable needs a name and a value type") + } + initial, ok := asWorkflowObject(entry["initial"]) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has no initial value", variable.Name) + } + switch variable.InitialKind = initial.S("kind"); variable.InitialKind { + case "constant": + value, present := initial["value"] + if !present { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has no initial constant", variable.Name) + } + normalized, ok := normalizeWorkflowValue(value, variable.ValueType) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q initial value does not match %s", variable.Name, variable.ValueType) + } + variable.InitialValue = normalized + case "variable": + selector, ok := parseWorkflowSelector(initial["selector"]) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has an invalid initial selector", variable.Name) + } + variable.InitialSelector = selector + default: + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has an unknown initial kind", variable.Name) + } + feedback, ok := parseWorkflowSelector(entry["feedback"]) + if !ok { + return workflowLoopVariableConfig{}, fmt.Errorf("loop variable %q has an invalid feedback selector", variable.Name) + } + variable.Feedback = feedback + return variable, nil +} + +// workflowOuterGraph is the graph the run's wave executes: every loop body is driven by its own +// container engine, so members are not nodes of the outer ready set. +func workflowOuterGraph(graph workflowGraphView) workflowGraphView { + out := workflowGraphView{ByNode: map[string]workflowNodeView{}, Start: graph.Start} + for _, node := range graph.Nodes { + if node.Data.S("containerId") != "" { + continue + } + out.Nodes = append(out.Nodes, node) + out.ByNode[node.ID] = node + } + for _, edge := range graph.Edges { + if _, ok := out.ByNode[edge.S("source")]; !ok { + continue + } + if _, ok := out.ByNode[edge.S("target")]; !ok { + continue + } + out.Edges = append(out.Edges, edge) + } + return out +} + +// workflowBodyGraph is one loop's body as a standalone graph: members, internal edges and the body +// start. An empty Start means the document has no defined place to begin and the loop cannot run. +func workflowBodyGraph(graph workflowGraphView, loopID string) workflowGraphView { + out := workflowGraphView{ByNode: map[string]workflowNodeView{}} + for _, node := range graph.Nodes { + if node.Data.S("containerId") != loopID { + continue + } + out.Nodes = append(out.Nodes, node) + out.ByNode[node.ID] = node + if node.Kind == "start" && out.Start == "" { + out.Start = node.ID + } + } + for _, edge := range graph.Edges { + if _, ok := out.ByNode[edge.S("source")]; !ok { + continue + } + if _, ok := out.ByNode[edge.S("target")]; !ok { + continue + } + out.Edges = append(out.Edges, edge) + } + return out +} + +// runningWorkflowLoop finds a loop whose state says it is mid-flight. A committed running loop is not +// "ready" in the projection, so the wave has to drive it explicitly before looking for new work. +func runningWorkflowLoop(graph workflowGraphView, states map[string]workflowNodeState) (workflowNodeView, bool) { + for _, node := range graph.Nodes { + if node.Kind == "loop" && states[node.ID].Status == "running" && states[node.ID].Loop != nil { + return node, true + } + } + return workflowNodeView{}, false +} + +// advanceWorkflowLoop drives one loop as far as committed state allows. It returns true when the pass +// may continue the outer wave (progress was made or the loop is terminal), and false when the pass +// must return — a body agent session was started, or the loop is waiting for one. +func advanceWorkflowLoop( + t *transaction, + run Object, + node workflowNodeView, + version int64, + states map[string]workflowNodeState, + ambient map[string]workflowNodeState, + graph workflowGraphView, +) bool { + config, err := parseWorkflowLoopConfig(node.Data) + if err != nil { + failWorkflowLoop(states, node, "loop_invalid_config", err.Error()) + return true + } + state, committed := states[node.ID] + if !committed { + carried, err := initializeWorkflowLoopCarried(config, graph, node.ID, states, ambient) + if err != nil { + failWorkflowLoop(states, node, "loop_initial_invalid", err.Error()) + return true + } + state = workflowNodeState{ + Status: "running", + DisplayName: workflowNodeDisplayName(node), + StartedAt: nowRFC3339(), + Loop: &workflowLoopRunState{ + Round: 1, + MaxIterations: config.MaxIterations, + Carried: carried, + Completed: []any{}, + }, + } + } else if state.Loop == nil || state.Status != "running" { + return true + } + states[node.ID] = state + body := workflowBodyGraph(graph, node.ID) + if body.Start == "" { + failWorkflowLoop(states, node, "loop_body_start_count", "loop body has no start node") + return true + } + projection := projectWorkflowNodes(body, states) + if projection.hasFailure() { + reason, detail := workflowBodyFailure(body, states) + failWorkflowLoop(states, node, reason, detail) + return true + } + if ready, ok := projection.readyNode(); ok { + if ready.Kind == "agent" { + startWorkflowAgentSession(t, run, ready, version, states) + return false + } + readStates := workflowLoopReadStates(graph, node.ID, states, ambient) + states[ready.ID] = executeWorkflowNode(ready, run.O("input"), readStates, ambient) + return true + } + if projection.hasPending() { + return false + } + if workflowBodyInFlight(body, states) { + // A committed running or retrying member owns the serial slot; the pass settles it through the + // session or retry path before the round can be judged complete. + return false + } + // The body is drained. A succeeded loopExit short-circuits the round: no feedback is computed and + // the published outputs come from the round the exit produced. + roundStates := workflowLoopReadStates(graph, node.ID, states, ambient) + if workflowLoopExitSucceeded(body, states) { + outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_exit_output_unset", err.Error()) + return true + } + finishWorkflowLoop(states, node, state, body, "loop_exit", outputs) + return true + } + feedback, err := resolveWorkflowLoopFeedback(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_value_unset", err.Error()) + return true + } + satisfied, err := workflowLoopUntilSatisfied(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_value_unset", err.Error()) + return true + } + if satisfied { + outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_value_unset", err.Error()) + return true + } + finishWorkflowLoop(states, node, state, body, "condition_satisfied", outputs) + return true + } + if state.Loop.Round >= config.MaxIterations { + if config.Behavior == "succeed" { + outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + if err != nil { + failWorkflowLoop(states, node, "loop_value_unset", err.Error()) + return true + } + finishWorkflowLoop(states, node, state, body, "max_iterations", outputs) + return true + } + // The round that exhausted the budget is a finished round: archive it before failing so the + // run's evidence contains the values the condition was judged against. + archiveWorkflowLoopRound(state.Loop, body, states, true) + failWorkflowLoop(states, node, "loop_max_iterations_reached", + fmt.Sprintf("loop did not terminate within %d rounds", config.MaxIterations)) + return true + } + archiveWorkflowLoopRound(state.Loop, body, states, false) + state.Loop.Round++ + state.Loop.Carried = feedback + states[node.ID] = state + return true +} + +// initializeWorkflowLoopCarried evaluates every carried variable once, before the first round: a +// constant is normalized against its declared type and a selector is read from the loop's outer +// scope. A missing or mistyped value fails the loop instead of starting it with a hole. +func initializeWorkflowLoopCarried( + config workflowLoopConfig, + graph workflowGraphView, + loopID string, + states map[string]workflowNodeState, + ambient map[string]workflowNodeState, +) (map[string]any, error) { + outer := workflowLoopOuterStates(graph, loopID, states, ambient) + carried := map[string]any{} + for _, variable := range config.Variables { + value := variable.InitialValue + if variable.InitialKind == "variable" { + resolved, present, err := resolveWorkflowSelector(variable.InitialSelector, outer) + if err != nil || !present { + return nil, fmt.Errorf("initial value of %s is not available", variable.Name) + } + value, _ = normalizeWorkflowValue(resolved, variable.ValueType) + } + carried[variable.Name] = value + } + return carried, nil +} + +// resolveWorkflowLoopFeedback reads every feedback selector from the completed round in one snapshot +// and normalizes it against the declared type. All variables are read before any is assigned, so two +// variables that swap values receive each other's value. +func resolveWorkflowLoopFeedback(config workflowLoopConfig, roundStates map[string]workflowNodeState) (map[string]any, error) { + carried := map[string]any{} + for _, variable := range config.Variables { + value, present, err := resolveWorkflowSelector(variable.Feedback, roundStates) + if err != nil || !present { + return nil, fmt.Errorf("feedback selector %s has no value", variable.Feedback.qualified()) + } + normalized, ok := normalizeWorkflowValue(value, variable.ValueType) + if !ok { + return nil, fmt.Errorf("feedback selector %s does not match %s", variable.Feedback.qualified(), variable.ValueType) + } + carried[variable.Name] = normalized + } + return carried, nil +} + +// workflowLoopUntilSatisfied evaluates the round-end condition over the completed round. An until with +// no rules never matches, which is desktop's reading: only a case with conditions can select. +func workflowLoopUntilSatisfied(config workflowLoopConfig, roundStates map[string]workflowNodeState) (bool, error) { + until := workflowNodeView{ + ID: "until", + Kind: "condition", + Data: Object{"cases": []any{Object{"id": "until", "logic": config.UntilLogic, "conditions": config.UntilRules}}}, + } + branch, err := evaluateWorkflowCondition(until, roundStates) + if err != nil { + return false, err + } + return branch != workflowBranchElse, nil +} + +// resolveWorkflowLoopOutputs reads every named output from the round that produced it. +func resolveWorkflowLoopOutputs(config workflowLoopConfig, roundStates map[string]workflowNodeState) (Object, error) { + outputs := Object{} + for _, output := range config.Outputs { + value, present, err := resolveWorkflowSelector(output.Selector, roundStates) + if err != nil || !present { + return nil, fmt.Errorf("loop output %s has no value", output.Name) + } + outputs[output.Name] = value + } + return outputs, nil +} + +// workflowLoopReadStates is the round's variable pool as committed states: ambient variables, the +// loop's outer ancestors, the active round's member states, and the carried values under the loop's +// own node id. It is what feedback, until, outputs and body conditions read. +func workflowLoopReadStates( + graph workflowGraphView, + loopID string, + states map[string]workflowNodeState, + ambient map[string]workflowNodeState, +) map[string]workflowNodeState { + round := workflowLoopOuterStates(graph, loopID, states, ambient) + for _, node := range graph.Nodes { + if node.Data.S("containerId") != loopID { + continue + } + if state, ok := states[node.ID]; ok { + round[node.ID] = state + } + } + loop := states[loopID].Loop + if loop != nil { + carried := Object{} + for name, value := range loop.Carried { + carried[name] = value + } + round[loopID] = workflowNodeState{Status: "succeeded", Output: carried} + } + return round +} + +// workflowLoopOuterStates is the pool visible before any round: ambient variables overlaid with the +// loop's transitive predecessors. Body members are never ancestors of their own loop. +func workflowLoopOuterStates( + graph workflowGraphView, + loopID string, + states map[string]workflowNodeState, + ambient map[string]workflowNodeState, +) map[string]workflowNodeState { + outer := map[string]workflowNodeState{} + for id, state := range ambient { + outer[id] = state + } + for id := range workflowAncestorIDs(graph, loopID) { + if state, ok := states[id]; ok { + outer[id] = state + } + } + return outer +} + +// workflowAncestorIDs walks the reverse edges of one node and returns its transitive predecessors. +func workflowAncestorIDs(graph workflowGraphView, nodeID string) map[string]bool { + ancestors := map[string]bool{} + queue := []string{nodeID} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, edge := range graph.Edges { + if edge.S("target") != current { + continue + } + source := edge.S("source") + if source == nodeID || ancestors[source] { + continue + } + ancestors[source] = true + queue = append(queue, source) + } + } + return ancestors +} + +// workflowBodyFailure names the first failed body member in document order with its committed reason. +func workflowBodyFailure(body workflowGraphView, states map[string]workflowNodeState) (string, string) { + for _, member := range body.Nodes { + state := states[member.ID] + if state.Status != "failed" { + continue + } + reason := state.Output.S("reason") + if reason == "" { + reason = "node failed" + } + return reason, state.Error + } + return "node failed", "" +} + +// workflowBodyInFlight reports whether the active round still owns a member the pass must wait for: a +// running body agent session or a retrying one. The round cannot be judged complete until it settles. +func workflowBodyInFlight(body workflowGraphView, states map[string]workflowNodeState) bool { + for _, member := range body.Nodes { + switch states[member.ID].Status { + case "running", "retrying": + return true + } + } + return false +} + +// workflowLoopExitSucceeded reports whether the round selected and completed a loopExit member. +func workflowLoopExitSucceeded(body workflowGraphView, states map[string]workflowNodeState) bool { + for _, member := range body.Nodes { + if member.Kind == "loopExit" && states[member.ID].Status == "succeeded" { + return true + } + } + return false +} + +// failWorkflowLoop commits one loop failure: the node's stable reason, a readable detail, and the +// round archive as it stood. The run settles failed from this committed state, never from the pass. +func failWorkflowLoop(states map[string]workflowNodeState, node workflowNodeView, reason, detail string) { + state := states[node.ID] + if state.DisplayName == "" { + state.DisplayName = workflowNodeDisplayName(node) + } + if state.StartedAt == "" { + state.StartedAt = nowRFC3339() + } + state.Status = "failed" + state.FinishedAt = nowRFC3339() + state.Error = detail + state.Output = Object{"reason": reason} + states[node.ID] = state +} + +// finishWorkflowLoop archives the final round, keeps its member states visible, and publishes the +// loop's outputs. The completion reason is recorded on the loop state so the viewer can distinguish +// "the condition held" from "the budget ran out". +func finishWorkflowLoop( + states map[string]workflowNodeState, + node workflowNodeView, + state workflowNodeState, + body workflowGraphView, + reason string, + outputs Object, +) { + if state.Loop != nil { + archiveWorkflowLoopRound(state.Loop, body, states, true) + state.Loop.Reason = reason + } + state.Status = "succeeded" + state.FinishedAt = nowRFC3339() + state.Error = "" + state.Output = outputs + states[node.ID] = state +} + +// archiveWorkflowLoopRound records the active round's member states as one durable entry. When the +// loop continues, the members are removed from the flat map so the next round starts from nothing; +// when the loop finishes they stay, because they are the round the viewer should see. +func archiveWorkflowLoopRound(loop *workflowLoopRunState, body workflowGraphView, states map[string]workflowNodeState, keep bool) { + archived := Object{} + for _, member := range body.Nodes { + state, ok := states[member.ID] + if !ok { + continue + } + archived[member.ID] = state.object() + if !keep { + delete(states, member.ID) + } + } + carried := Object{} + for name, value := range loop.Carried { + carried[name] = value + } + loop.Completed = append(loop.Completed, Object{ + "index": loop.Round, + "carried": carried, + "states": archived, + }) +} diff --git a/internal/core/workflow_loop_scheduler_db_test.go b/internal/core/workflow_loop_scheduler_db_test.go new file mode 100644 index 000000000..ae8a6a1b5 --- /dev/null +++ b/internal/core/workflow_loop_scheduler_db_test.go @@ -0,0 +1,199 @@ +package core + +import ( + "context" + "testing" +) + +// These are the loop round engine's end-to-end scheduler tests over PostgreSQL. They use no agent +// node, so every round is decided inline and the assertions are about the round machine itself: +// simultaneous feedback, until, exhaustion and loopExit. + +// loopSceneGraph builds start → loop → output with a body of the given members and edges. The loop's +// config is supplied by the caller so each test states the contract it exercises. +func loopSceneGraph(loopConfig Object, bodyNodes []Object, bodyEdges [][3]string) Object { + loop := wfNode("l", "loop", "循环 1") + loop["data"].(Object)["loopConfig"] = loopConfig + nodes := []Object{wfNode("s", "start", "开始 1"), loop, wfNode("o", "output", "输出 1")} + nodes = append(nodes, bodyNodes...) + edges := [][3]string{{"s", "l", ""}, {"l", "o", ""}} + edges = append(edges, bodyEdges...) + return wfGraph(nodes, edges) +} + +// loopBodyStart is the one start node a loop body requires. +func loopBodyStart(id string) Object { + node := wfNode(id, "start", "入口") + node["data"].(Object)["containerId"] = "l" + return node +} + +// loopVar builds one carried variable with a constant initial and a feedback selector. +func loopVar(name, valueType string, initial any, feedback ...string) Object { + selector := make([]any, 0, len(feedback)) + for _, part := range feedback { + selector = append(selector, part) + } + return Object{ + "name": name, + "valueType": valueType, + "initial": Object{"kind": "constant", "value": initial}, + "feedback": selector, + } +} + +// loopUntilEquals builds the round-end condition `selector equals value`. +func loopUntilEquals(selector []any, value any) Object { + return Object{ + "logic": "and", + "conditions": []any{ + Object{"variableSelector": selector, "operator": "equals", "value": value}, + }, + } +} + +// readLoopState projects the committed loop progress for assertions. +func readLoopState(t *testing.T, states Object) (round int64, reason string, completed int) { + t.Helper() + node, ok := asWorkflowObject(states["l"]) + if !ok { + t.Fatalf("loop node has no committed state") + } + loop, ok := asWorkflowObject(node["loop"]) + if !ok { + t.Fatalf("loop node has no committed loop state: %v", node) + } + finished, _ := loop["completed"].([]any) + return loop.N("round"), loop.S("reason"), len(finished) +} + +// TestWorkflowAdvanceLoopFinishesWhenUntilHolds drives two rounds with a carried pair that swaps +// values each round: the first round does not satisfy until, the second does. It pins that feedback +// is read from one completed snapshot (a swap must exchange values, not collapse both onto one) and +// that the loop publishes the final carried value. +func TestWorkflowAdvanceLoopFinishesWhenUntilHolds(t *testing.T) { + store := dispatcherDB(t) + config := Object{ + "maxIterations": 3, + "variables": []any{ + loopVar("a", "string", "a", "l", "b"), + loopVar("b", "string", "b", "l", "a"), + }, + "until": loopUntilEquals([]any{"l", "a"}, "b"), + "outputs": []any{Object{"name": "result", "variableSelector": []any{"l", "a"}}}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry")}, + [][3]string{}, + )) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + round, reason, completed := readLoopState(t, states) + if round != 2 || reason != "condition_satisfied" || completed != 2 { + t.Fatalf("loop round=%d reason=%q completed=%d; want 2 condition_satisfied 2", round, reason, completed) + } + node, _ := asWorkflowObject(states["l"]) + output, _ := asWorkflowObject(node["output"]) + if got := output["result"]; got != "b" { + t.Fatalf("loop output result = %v; want the swapped carried value b", got) + } + if got := wfNodeStatus(t, states, "o"); got != "succeeded" { + t.Fatalf("output node status = %q; want succeeded", got) + } +} + +// TestWorkflowAdvanceLoopFailsOnExhaustion pins the default exhaustion policy: a loop that never +// satisfies until fails the run with a stable reason instead of publishing its last round. +func TestWorkflowAdvanceLoopFailsOnExhaustion(t *testing.T) { + store := dispatcherDB(t) + config := Object{ + "maxIterations": 2, + "variables": []any{ + loopVar("a", "string", "a", "l", "b"), + loopVar("b", "string", "b", "l", "a"), + }, + "until": loopUntilEquals([]any{"l", "a"}, "never"), + "outputs": []any{}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry")}, + [][3]string{}, + )) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "loop_max_iterations_reached" { + t.Fatalf("run settled %q error %q; want failed loop_max_iterations_reached", status, runErr) + } + round, _, completed := readLoopState(t, states) + if round != 2 || completed != 2 { + t.Fatalf("loop round=%d completed=%d; want 2 2", round, completed) + } +} + +// TestWorkflowAdvanceLoopFailsWhenFeedbackIsUnset pins the atomic feedback rule: a selector that names +// an assigned-but-absent root fails the loop instead of starting a round with a hole. +func TestWorkflowAdvanceLoopFailsWhenFeedbackIsUnset(t *testing.T) { + store := dispatcherDB(t) + config := Object{ + "maxIterations": 2, + "variables": []any{ + loopVar("a", "string", "a", "s", "missing"), + }, + "until": loopUntilEquals([]any{"l", "a"}, "never"), + "outputs": []any{}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry")}, + [][3]string{}, + )) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, _, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "loop_value_unset" { + t.Fatalf("run settled %q error %q; want failed loop_value_unset", status, runErr) + } +} + +// TestWorkflowAdvanceLoopExitsEarly pins loopExit: the marker ends the round before feedback, and the +// loop publishes the outputs resolved from the round that produced it. +func TestWorkflowAdvanceLoopExitsEarly(t *testing.T) { + store := dispatcherDB(t) + exit := wfNode("exit", "loopExit", "早退") + exit["data"].(Object)["containerId"] = "l" + config := Object{ + "maxIterations": 5, + "variables": []any{ + loopVar("draft", "string", "x", "l", "draft"), + }, + "until": loopUntilEquals([]any{"l", "draft"}, "never"), + "outputs": []any{Object{"name": "result", "variableSelector": []any{"l", "draft"}}}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry"), exit}, + [][3]string{{"entry", "exit", ""}}, + )) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + round, reason, completed := readLoopState(t, states) + if round != 1 || reason != "loop_exit" || completed != 1 { + t.Fatalf("loop round=%d reason=%q completed=%d; want 1 loop_exit 1", round, reason, completed) + } + node, _ := asWorkflowObject(states["l"]) + output, _ := asWorkflowObject(node["output"]) + if got := output["result"]; got != "x" { + t.Fatalf("loop output result = %v; want the carried value x", got) + } +} diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 04f3309fa..b70b1c0a4 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -258,6 +258,10 @@ type workflowNodeState struct { // replaces a failure, it does not erase it. Error string Output Object + // Loop is the round progress of a loop node and nil for every other kind. The active round's + // member states stay in the run's flat node_states map (so the session, retry and viewer paths see + // one shape); completed rounds are archived here for audit and recovery. + Loop *workflowLoopRunState } // attemptNumber is the state's 1-based attempt, reading an absent attempt as the first one. Only @@ -287,6 +291,9 @@ func (s *workflowNodeState) object() Object { if s.Output != nil { out["output"] = s.Output } + if s.Loop != nil { + out["loop"] = s.Loop.object() + } return out } @@ -313,6 +320,9 @@ func workflowStatesFromJSON(raw Object) map[string]workflowNodeState { if output, ok := asWorkflowObject(node["output"]); ok { state.Output = output } + if loop, ok := asWorkflowObject(node["loop"]); ok { + state.Loop = workflowLoopRunStateFromJSON(loop) + } out[id] = state } return out @@ -372,6 +382,18 @@ func (p *workflowProjection) readyNode() (workflowNodeView, bool) { return workflowNodeView{}, false } +// hasPending reports whether the projection left a node unresolved because an upstream edge is still +// pending. A scope whose nodes are all resolved and none ready has drained: for a loop body that is +// the moment the round is judged complete. +func (p *workflowProjection) hasPending() bool { + for _, node := range p.graph.Nodes { + if _, ok := p.projected[node.ID]; !ok { + return true + } + } + return false +} + // hasFailure reports whether any committed node failed. func (p *workflowProjection) hasFailure() bool { for _, state := range p.committed { @@ -593,8 +615,9 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura // would make the pass's final settle a silent no-op and leave the run in `running`. version = workflowRunVersion(t, runID, version) } + outer := workflowOuterGraph(graph) for { - projection := projectWorkflowNodes(graph, states) + projection := projectWorkflowNodes(outer, states) // A failed node decides the run. Dispatching another ready node in the same wave would do // real work for a run that is already terminal — for an agent that means provisioning a // Workspace and waiting on a session — only to settle failed after the session ends. Stopping @@ -603,6 +626,14 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura if projection.hasFailure() { break } + if loopNode, running := runningWorkflowLoop(outer, states); running { + // A running loop is not "ready" in the projection (its state is committed), so the wave + // drives it explicitly. A started body session ends the pass; other progress continues. + if !advanceWorkflowLoop(t, run, loopNode, version, states, ambient, graph) { + return + } + continue + } node, ready := projection.readyNode() if !ready { break @@ -615,6 +646,15 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura startWorkflowAgentSession(t, run, node, version, states) return } + if node.Kind == "loop" { + // Drive the container one step. A loop starts its body agent through the same session + // path, so a started session ends the pass; otherwise the loop's state is updated in + // this pass and the outer wave continues. + if !advanceWorkflowLoop(t, run, node, version, states, ambient, graph) { + return + } + continue + } states[node.ID] = executeWorkflowNode(node, run.O("input"), states, ambient) } // The wave stopped with no executable node left. Persist the projection's inactive nodes — @@ -622,9 +662,9 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura // tells the viewer the full story (a missing state renders as idle, which would understate a // branch the run already resolved). Then settle: failed when any committed node failed, // succeeded otherwise; the failure check reads committed state, never the pass's own reasoning. - states = workflowInactiveStates(graph, states) - if projectWorkflowNodes(graph, states).hasFailure() { - finishWorkflowRun(t, runID, version, "failed", workflowRunFailureReason(graph, states), states) + states = workflowInactiveStates(outer, states) + if projectWorkflowNodes(outer, states).hasFailure() { + finishWorkflowRun(t, runID, version, "failed", workflowRunFailureReason(outer, states), states) return } if workflowAnyCancelled(states) { @@ -734,6 +774,10 @@ func executeWorkflowNode(node workflowNodeView, input Object, states, ambient ma state.Output = Object{"selectedBranch": branch} case "output": state.Output = Object{} + case "loopExit": + // The early-exit marker carries no configuration and produces nothing; the container engine + // reads its success to skip feedback and publish the round's outputs. + state.Output = Object{} default: // Unreachable: the ready pass only yields executable kinds, and the agent case returned // earlier. A future kind that reaches here must not be recorded as success. diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 0cddb1f61..fc024d7b0 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -494,7 +494,7 @@ func TestWorkflowAdvanceIgnoresARepublishedVersion(t *testing.T) { func TestWorkflowAdvanceFailsAnUnsupportedNodeKind(t *testing.T) { store := dispatcherDB(t) runID := seedWorkflowRunScene(t, store, wfGraph( - []Object{wfNode("s", "start", ""), wfNode("l", "loop", "iterate"), wfNode("o", "output", "")}, + []Object{wfNode("s", "start", ""), wfNode("l", "iteration", "iterate"), wfNode("o", "output", "")}, [][3]string{{"s", "l", ""}, {"l", "o", ""}}, )) From ca12ae73ffcaf947fd3bc0a9793c7d19bdc195b9 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 12:40:22 +0800 Subject: [PATCH 03/27] feat(workflows): add a Loop from the palette as one atomic starter group --- .../editor/use-workflow-draft.test.ts | 22 +++++++++++++++++++ .../workflows/editor/use-workflow-draft.ts | 11 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.test.ts b/frontend/src/features/workflows/editor/use-workflow-draft.test.ts index 527cb97c5..7d35f6961 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.test.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.test.ts @@ -83,6 +83,28 @@ function requireNode(nodes: WorkflowCanvasNode[], id: string): WorkflowCanvasNod } describe('useWorkflowDraft', () => { + it('adds a Loop as one atomic group with its starter body', () => { + // A bare Loop saves but never publishes: the body needs a Start. The palette entry therefore + // emits the container, its Start, its starter Agent and the internal edge together. + const { result } = setup(fixtureWithGraph([outputNode()])) + + act(() => { + result.current.addNode('loop', { x: 0, y: 0 }) + }) + + expect( + result.current.nodes.map((node) => [node.id, node.data.kind, node.data.containerId]), + ).toEqual([ + ['output-1', 'output', undefined], + ['loop-1', 'loop', undefined], + ['loop-1-start', 'start', 'loop-1'], + ['loop-1-agent', 'agent', 'loop-1'], + ]) + expect(result.current.edges.map((edge) => [edge.source, edge.target])).toEqual([ + ['loop-1-start', 'loop-1-agent'], + ]) + }) + it('adds a member through the entry seam, widening the frame and wiring the entry edge', () => { const { result } = setup(draftFixture()) diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.ts b/frontend/src/features/workflows/editor/use-workflow-draft.ts index 86e63313c..9a157f5a3 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.ts @@ -25,6 +25,7 @@ import { type WorkflowIterationInsertion, } from '@/features/workflows/runtime/iteration-graph' import { + createWorkflowLoopGroup, createWorkflowNode, nextWorkflowNodeSequence, type WorkflowNodeTranslator, @@ -300,6 +301,16 @@ function appendNode( kind, current.nodes.map((node) => node.id), ) + if (kind === 'loop') { + // A Loop without a body is a saveable but unrunnable container; the factory emits the body + // start, a starter agent and the round contract together so the palette entry is executable. + const group = createWorkflowLoopGroup({ kind, sequence, position, translate }) + return { + ...current, + nodes: [...current.nodes, ...group.nodes], + edges: [...current.edges, ...group.edges], + } + } const node = createWorkflowNode({ kind, sequence, position, translate }) return { ...current, nodes: [...current.nodes, node] } } From 5aefaa3d4f56d4abdb8b32a9cd78dcff0340578c Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 12:40:22 +0800 Subject: [PATCH 04/27] feat(workflows): edit Loop rounds and import the loopExit marker --- .../workflows/editor/node-metadata.ts | 6 + .../workflows/editor/workflow-inspector.tsx | 4 + .../editor/workflow-kind-fields.test.tsx | 11 + .../workflows/editor/workflow-kind-fields.tsx | 237 +++++++++++------- .../workflows/editor/workflow-node-card.tsx | 16 +- .../features/workflows/loop-translations.ts | 48 ++++ .../workflows/runtime/node-catalog.test.ts | 11 +- .../workflows/runtime/node-catalog.ts | 8 +- .../workflows/runtime/node-factory.ts | 3 +- .../src/features/workflows/runtime/types.ts | 3 + .../src/features/workflows/translations.ts | 29 +-- frontend/src/i18n/i18n.test.ts | 1 + 12 files changed, 248 insertions(+), 129 deletions(-) create mode 100644 frontend/src/features/workflows/loop-translations.ts diff --git a/frontend/src/features/workflows/editor/node-metadata.ts b/frontend/src/features/workflows/editor/node-metadata.ts index 7ddfa5e74..2744e853e 100644 --- a/frontend/src/features/workflows/editor/node-metadata.ts +++ b/frontend/src/features/workflows/editor/node-metadata.ts @@ -2,6 +2,7 @@ import { ArrowRight, Boxes, Braces, + CornerDownRight, GitBranch, GitMerge, Layers, @@ -50,6 +51,11 @@ const NODE_METADATA: Record = { icon: Repeat, tone: 'bg-indigo-500/12 text-indigo-700 dark:text-indigo-400', }, + loopExit: { + kind: 'loopExit', + icon: CornerDownRight, + tone: 'bg-indigo-500/12 text-indigo-700 dark:text-indigo-400', + }, iteration: { kind: 'iteration', icon: Layers, diff --git a/frontend/src/features/workflows/editor/workflow-inspector.tsx b/frontend/src/features/workflows/editor/workflow-inspector.tsx index 73652726c..9ae9d7a7e 100644 --- a/frontend/src/features/workflows/editor/workflow-inspector.tsx +++ b/frontend/src/features/workflows/editor/workflow-inspector.tsx @@ -22,6 +22,7 @@ import { HumanNodeFields, JunctionNodeFields, LoopNodeFields, + LoopExitNodeFields, SubflowNodeFields, ToolNodeFields, type WorkflowKindFieldsProps, @@ -189,6 +190,9 @@ function NodeKindFields({ /> ) } + if (data.kind === 'loopExit') { + return + } if (data.kind === 'subflow') { return } diff --git a/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx index 2a051546e..e5017a7c9 100644 --- a/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx @@ -13,6 +13,7 @@ import { ConditionNodeFields } from '@/features/workflows/editor/workflow-condit import { HumanNodeFields, JunctionNodeFields, + LoopExitNodeFields, LoopNodeFields, SubflowNodeFields, ToolNodeFields, @@ -333,6 +334,7 @@ describe('LoopNodeFields', () => { />, ) + expect(screen.getByLabelText('轮次耗尽时')).toHaveTextContent('失败') fireEvent.change(screen.getByLabelText('最大轮次'), { target: { value: '55' } }) expect(data.loopConfig?.maxIterations).toBe(55) @@ -349,11 +351,20 @@ describe('LoopNodeFields', () => { renderWithProviders() expect(screen.getByLabelText('最大轮次')).toBeDisabled() + expect(screen.getByLabelText('轮次耗尽时')).toBeDisabled() expect(screen.getByLabelText('初始值')).toBeDisabled() expect(screen.getByText('此旧循环节点缺少可执行配置,请删除后重新添加。')).toBeInTheDocument() }) }) +describe('LoopExitNodeFields', () => { + it('explains that the marker has no configuration of its own', () => { + renderWithProviders() + + expect(screen.getByText(/循环体内的终端节点/)).toBeInTheDocument() + }) +}) + describe('IterationNodeFields', () => { const ITERATION_CONFIG: WorkflowIterationConfig = { iteratorSelector: ['iteration-1', 'output'], diff --git a/frontend/src/features/workflows/editor/workflow-kind-fields.tsx b/frontend/src/features/workflows/editor/workflow-kind-fields.tsx index 7a2813940..d4059d7c9 100644 --- a/frontend/src/features/workflows/editor/workflow-kind-fields.tsx +++ b/frontend/src/features/workflows/editor/workflow-kind-fields.tsx @@ -13,6 +13,7 @@ import { Textarea } from '@/components/ui/textarea' import { createDefaultWorkflowCapabilities, workflowChoiceLabel, + type WorkflowChoice, } from '@/features/workflows/runtime/capabilities' import type { WorkflowGlobalVariable, @@ -206,6 +207,41 @@ function ToolParametersField({ ) } +/** A select whose menu text and closed-state label both come from one choice list. */ +function LocalizedChoiceSelect({ + id, + label, + value, + options, + disabled, + onChange, +}: { + id: string + label: string + value: string + options: readonly WorkflowChoice[] + disabled?: boolean + onChange: (value: string | null) => void +}) { + const translate = useWorkflowTranslator() + return ( + + + + ) +} + /** Edits the merge behavior of a Junction node: wait and failure strategies. */ export function JunctionNodeFields({ data, @@ -214,33 +250,17 @@ export function JunctionNodeFields({ const { t } = useTranslation() return ( <> - - - + value={data.waitStrategy ?? 'all'} + options={WORKFLOW_JUNCTION_WAIT_CHOICES} + onChange={(strategy) => { + if (strategy === 'all' || strategy === 'any' || strategy === 'count') { + onChange({ ...data, waitStrategy: strategy }) + } + }} + /> {data.waitStrategy === 'count' && ( )} - - - + value={data.failureStrategy ?? 'fail'} + options={WORKFLOW_JUNCTION_FAILURE_CHOICES} + onChange={(strategy) => { + if (strategy === 'fail' || strategy === 'continue') { + onChange({ ...data, failureStrategy: strategy }) + } + }} + /> ) } @@ -337,20 +341,38 @@ export function HumanNodeFields({ ) } -/** Edits a Loop container: iteration ceiling, carried initial value, and a behavior note. */ +/** Exhaustion policies of a Loop, keyed to the graph's enum names. */ +const WORKFLOW_LOOP_BEHAVIOR_CHOICES = [ + { value: 'fail', labelKey: 'workflows.loop.behavior.fail' }, + { value: 'succeed', labelKey: 'workflows.loop.behavior.succeed' }, +] as const satisfies readonly WorkflowChoice[] + +/** Edits a Loop container: budget, carried initial value, and the behavior note. */ export function LoopNodeFields({ data, onChange, }: Pick) { const { t } = useTranslation() const loopConfig = data.loopConfig - const carriedVariable = loopConfig?.variables[0] - // A constant may have been imported as a number, boolean, or object, so the - // shared JSON value renderer turns it back into editable text safely. - const initialValue = - carriedVariable?.initial.kind === 'constant' - ? comparisonValueToText(carriedVariable.initial.value) - : '' + return ( + <> + + +

+ {t( + loopConfig === undefined + ? 'workflows.loop.legacyUnsupported' + : 'workflows.loop.defaultBehavior', + )} +

+ + ) +} + +/** Edits the round ceiling and what an exhausted budget does. */ +function LoopBudgetFields({ data, onChange }: Pick) { + const { t } = useTranslation() + const loopConfig = data.loopConfig const updateMaxIterations = (value: number): void => { if (loopConfig === undefined) { return @@ -363,22 +385,13 @@ export function LoopNodeFields({ }, }) } - const updateInitialValue = (value: string): void => { - if (loopConfig === undefined || carriedVariable === undefined) { + const updateMaxIterationsBehavior = (value: string | null): void => { + if (loopConfig === undefined || (value !== 'fail' && value !== 'succeed')) { return } onChange({ ...data, - loopConfig: { - ...loopConfig, - variables: [ - { - ...carriedVariable, - initial: { kind: 'constant', value }, - }, - ...loopConfig.variables.slice(1), - ], - }, + loopConfig: { ...loopConfig, maxIterationsBehavior: value }, }) } return ( @@ -402,28 +415,72 @@ export function LoopNodeFields({ }} /> - - updateInitialValue(event.target.value)} - /> - -

- {t( - loopConfig === undefined - ? 'workflows.loop.legacyUnsupported' - : 'workflows.loop.defaultBehavior', - )} -

+ ) } +/** Edits the first carried variable's first-round constant value. */ +function LoopCarriedFields({ data, onChange }: Pick) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + const carriedVariable = loopConfig?.variables[0] + // A constant may have been imported as a number, boolean, or object, so the + // shared JSON value renderer turns it back into editable text safely. + const initialValue = + carriedVariable?.initial.kind === 'constant' + ? comparisonValueToText(carriedVariable.initial.value) + : '' + const updateInitialValue = (value: string): void => { + if (loopConfig === undefined || carriedVariable === undefined) { + return + } + onChange({ + ...data, + loopConfig: { + ...loopConfig, + variables: [ + { + ...carriedVariable, + initial: { kind: 'constant', value }, + }, + ...loopConfig.variables.slice(1), + ], + }, + }) + } + return ( + + updateInitialValue(event.target.value)} + /> + + ) +} + +/** Explains the loopExit marker: it ends the round and carries no configuration of its own. */ +export function LoopExitNodeFields() { + const { t } = useTranslation() + return ( +

+ {t('workflows.node.loopExit.description')} +

+ ) +} + /** Notes that a Subflow node is a placeholder until the V2 execution engine lands. */ export function SubflowNodeFields() { const { t } = useTranslation() diff --git a/frontend/src/features/workflows/editor/workflow-node-card.tsx b/frontend/src/features/workflows/editor/workflow-node-card.tsx index dd86301ad..60b1fbe24 100644 --- a/frontend/src/features/workflows/editor/workflow-node-card.tsx +++ b/frontend/src/features/workflows/editor/workflow-node-card.tsx @@ -86,13 +86,15 @@ export const WorkflowNodeCard = memo(function WorkflowNodeCard({ } /> - + {data.kind !== 'loopExit' && ( + + )} ) }) diff --git a/frontend/src/features/workflows/loop-translations.ts b/frontend/src/features/workflows/loop-translations.ts new file mode 100644 index 000000000..1111dbec4 --- /dev/null +++ b/frontend/src/features/workflows/loop-translations.ts @@ -0,0 +1,48 @@ +/** + * Messages for the Loop container and its early-exit marker. + * + * They live beside the feature's main bundle for the same reason the history and launch-field + * bundles do: the group is self-contained — the container's palette identity, the starter body the + * factory builds, the inspector fields, and the `loopExit` marker — and keeping it here stops the + * main bundle outgrowing the line budget that keeps its keys scannable. `translations.ts` spreads + * both locales in, so the feature still ships one bundle. + */ +export const loopTranslations = { + 'zh-CN': { + 'workflows.node.loop.label': '循环', + 'workflows.node.loop.description': '重复执行直到满足条件', + 'workflows.node.loopExit.label': '早退', + 'workflows.node.loopExit.description': '循环体内的终端节点:被选中时提前结束循环', + 'workflows.loop.roundStart': '轮次开始', + 'workflows.loop.loopAgent': '循环 Agent', + 'workflows.loop.promptTemplate': '改进当前值并输出新值:{{#NODE_ID.value#}}', + 'workflows.loop.field.maxIterations': '最大轮次', + 'workflows.loop.field.maxIterationsBehavior': '轮次耗尽时', + 'workflows.loop.behavior.fail': '失败', + 'workflows.loop.behavior.succeed': '导出最后一轮并成功', + 'workflows.loop.field.initialValue': '初始值', + 'workflows.loop.defaultBehavior': + '每轮将 Agent 输出反馈为下一轮的 value;输出非空时结束,并导出为 result。', + 'workflows.loop.legacyUnsupported': '此旧循环节点缺少可执行配置,请删除后重新添加。', + }, + 'en-US': { + 'workflows.node.loop.label': 'Loop', + 'workflows.node.loop.description': 'Repeat until the exit condition is met', + 'workflows.node.loopExit.label': 'Early exit', + 'workflows.node.loopExit.description': + 'Terminal node inside a Loop body that ends the loop when selected', + 'workflows.loop.roundStart': 'Round start', + 'workflows.loop.loopAgent': 'Loop Agent', + 'workflows.loop.promptTemplate': + 'Improve the current value and output the new value: {{#NODE_ID.value#}}', + 'workflows.loop.field.maxIterations': 'Maximum rounds', + 'workflows.loop.field.maxIterationsBehavior': 'When rounds run out', + 'workflows.loop.behavior.fail': 'Fail', + 'workflows.loop.behavior.succeed': 'Publish the final round', + 'workflows.loop.field.initialValue': 'Initial value', + 'workflows.loop.defaultBehavior': + 'Each Agent output feeds the next round as value; a non-empty output stops the Loop and is exported as result.', + 'workflows.loop.legacyUnsupported': + 'This legacy Loop lacks executable configuration. Delete it and add a new Loop.', + }, +} diff --git a/frontend/src/features/workflows/runtime/node-catalog.test.ts b/frontend/src/features/workflows/runtime/node-catalog.test.ts index 27b0d0ac6..be3eaaa98 100644 --- a/frontend/src/features/workflows/runtime/node-catalog.test.ts +++ b/frontend/src/features/workflows/runtime/node-catalog.test.ts @@ -29,6 +29,10 @@ describe('workflow node catalog', () => { expect(supportsWorkflowNodeScope('start', 'iteration')).toBe(false) }) + it('offers only the early-exit marker inside a loop body', () => { + expect(workflowPaletteNodeTypes('loop').map((entry) => entry.kind)).toEqual(['loopExit']) + }) + it('allows only the nodes a region can run in the iteration scope', () => { expect(workflowPaletteNodeTypes('iteration').map((entry) => entry.kind)).toEqual([ 'agent', @@ -37,8 +41,9 @@ describe('workflow node catalog', () => { }) it('lists the workflow palette in menu order', () => { - expect(workflowPaletteNodeTypes('workflow').map((entry) => entry.kind)).toEqual([ - ...WORKFLOW_NODE_PALETTE, - ]) + // The palette also carries the loop-only marker, which the workflow scope filters out. + expect(workflowPaletteNodeTypes('workflow').map((entry) => entry.kind)).toEqual( + WORKFLOW_NODE_PALETTE.filter((kind) => supportsWorkflowNodeScope(kind, 'workflow')), + ) }) }) diff --git a/frontend/src/features/workflows/runtime/node-catalog.ts b/frontend/src/features/workflows/runtime/node-catalog.ts index 1e858da1f..e43685e24 100644 --- a/frontend/src/features/workflows/runtime/node-catalog.ts +++ b/frontend/src/features/workflows/runtime/node-catalog.ts @@ -1,7 +1,7 @@ import type { WorkflowNodeKind } from '@/features/workflows/runtime/types' /** Editor scopes in which a node kind can be authored. */ -export type WorkflowNodeScope = 'workflow' | 'iteration' +export type WorkflowNodeScope = 'workflow' | 'iteration' | 'loop' /** Inspector sections a node kind exposes. */ export type WorkflowConfigField = @@ -16,6 +16,7 @@ export type WorkflowConfigField = | 'exitCondition' | 'maxIterations' | 'loopInitialValue' + | 'loopBehavior' | 'iteration' /** @@ -43,6 +44,8 @@ export const WORKFLOW_NODE_PALETTE: readonly WorkflowNodeKind[] = [ 'loop', 'iteration', 'output', + // Offered only by a loop body's insert menu: the marker is meaningless outside a container. + 'loopExit', ] /** Builds one definition, deriving the translation keys from the kind. */ @@ -73,7 +76,8 @@ const NODE_TYPE_DEFINITIONS: Record { 'junction', 'human', 'loop', + 'loopExit', 'iteration', 'subflow', 'output', From aa6ddc14c25271497b9ed6bf5ca5b9a020124141 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 12:46:39 +0800 Subject: [PATCH 05/27] test(core): pin the palette's loop starter group against the publish gate --- internal/core/workflow_loop_graph_test.go | 51 +++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/internal/core/workflow_loop_graph_test.go b/internal/core/workflow_loop_graph_test.go index 06944564d..9fe68df35 100644 --- a/internal/core/workflow_loop_graph_test.go +++ b/internal/core/workflow_loop_graph_test.go @@ -84,6 +84,57 @@ func TestWorkflowPublishAcceptsAWellFormedLoop(t *testing.T) { } } +// TestWorkflowPublishAcceptsTheEditorStarterGroup pins the contract between the palette and the gate: +// the exact shape `createWorkflowLoopGroup` emits — including the default agent contract the editor +// starts from — must publish without a single change. A drift here would make every Loop added from +// the palette unpublishable while the editor still looked fine. +func TestWorkflowPublishAcceptsTheEditorStarterGroup(t *testing.T) { + agent := Object{ + "id": "loop-1-agent", + "data": Object{ + "kind": "agent", + "title": "循环 Agent 1", + "containerId": "loop-1", + "agentConfig": Object{ + "schemaVersion": 3, + "executor": Object{"agentCli": "claude-code", "modelId": "claude-sonnet-4-5"}, + "roleId": "member", + "skills": []any{}, + "mcps": []any{}, + "prompt": "改进当前值并输出新值:{{#loop-1.value#}}", + }, + }, + } + graph := Object{ + "nodes": []any{ + Object{"id": "start-1", "data": Object{"kind": "start", "title": "开始", "inputVariables": []any{}}}, + Object{"id": "loop-1", "data": Object{"kind": "loop", "title": "循环 1", "loopConfig": Object{ + "maxIterations": 3, + "variables": []any{Object{ + "name": "value", "valueType": "string", + "initial": Object{"kind": "constant", "value": ""}, + "feedback": []any{"loop-1-agent", "output"}, + }}, + "until": Object{"logic": "and", "conditions": []any{ + Object{"variableSelector": []any{"loop-1-agent", "output"}, "operator": "not_empty"}, + }}, + "outputs": []any{Object{"name": "result", "variableSelector": []any{"loop-1-agent", "output"}}}, + }}}, + Object{"id": "loop-1-start", "data": Object{"kind": "start", "title": "轮次开始", "containerId": "loop-1", "inputVariables": []any{}}}, + agent, + Object{"id": "output-1", "data": Object{"kind": "output", "title": "输出"}}, + }, + "edges": []any{ + Object{"source": "start-1", "target": "loop-1"}, + Object{"source": "loop-1", "target": "output-1"}, + Object{"source": "loop-1-start", "target": "loop-1-agent"}, + }, + } + if got := loopProblemStrings(t, graph); len(got) != 0 { + t.Fatalf("the palette's starter group must publish, got problems %v", got) + } +} + // TestWorkflowPublishRefusesInvalidLoopContainers is the structural matrix: membership, body start, // nested composites, body output, cross-scope edges, loopExit terminality and reachability. func TestWorkflowPublishRefusesInvalidLoopContainers(t *testing.T) { From d2370ea284ddbd1561b429d0755deb085b31f4d6 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 12:46:40 +0800 Subject: [PATCH 06/27] fix(core): cancel a running loop when its run is cancelled --- internal/core/workflow_loop_run.go | 14 +++++++ .../core/workflow_loop_scheduler_db_test.go | 42 +++++++++++++++++++ internal/core/workflow_run_session.go | 3 ++ 3 files changed, 59 insertions(+) diff --git a/internal/core/workflow_loop_run.go b/internal/core/workflow_loop_run.go index 9d2b8aa37..6e20c36a4 100644 --- a/internal/core/workflow_loop_run.go +++ b/internal/core/workflow_loop_run.go @@ -574,6 +574,20 @@ func workflowLoopExitSucceeded(body workflowGraphView, states map[string]workflo return false } +// cancelWorkflowLoops marks every in-flight loop cancelled when the run itself is being cancelled. +// A cancelled run is terminal, so a loop left `running` would misreport a container that will never +// advance; the loop state keeps its rounds as the audit trail of what did run. +func cancelWorkflowLoops(states map[string]workflowNodeState) { + for id, state := range states { + if state.Status != "running" || state.Loop == nil { + continue + } + state.Status = "cancelled" + state.FinishedAt = nowRFC3339() + states[id] = state + } +} + // failWorkflowLoop commits one loop failure: the node's stable reason, a readable detail, and the // round archive as it stood. The run settles failed from this committed state, never from the pass. func failWorkflowLoop(states map[string]workflowNodeState, node workflowNodeView, reason, detail string) { diff --git a/internal/core/workflow_loop_scheduler_db_test.go b/internal/core/workflow_loop_scheduler_db_test.go index ae8a6a1b5..114fce163 100644 --- a/internal/core/workflow_loop_scheduler_db_test.go +++ b/internal/core/workflow_loop_scheduler_db_test.go @@ -162,6 +162,48 @@ func TestWorkflowAdvanceLoopFailsWhenFeedbackIsUnset(t *testing.T) { } } +// TestWorkflowAdvanceCancelsARunningLoop pins the cancellation reaction: a run cancelled while its +// loop is mid-flight settles `cancelled`, and the loop node is cancelled with it instead of staying +// `running` in a terminal run. +func TestWorkflowAdvanceCancelsARunningLoop(t *testing.T) { + store := dispatcherDB(t) + config := Object{ + "maxIterations": 3, + "variables": []any{}, + "until": Object{"logic": "and", "conditions": []any{}}, + "outputs": []any{}, + } + runID := seedWorkflowRunScene(t, store, loopSceneGraph(config, + []Object{loopBodyStart("entry")}, + [][3]string{}, + )) + commitWorkflowRunMidpoint(t, store, runID, Object{ + "s": Object{"status": "succeeded", "output": Object{"input": ""}}, + "l": Object{"status": "running", "startedAt": nowRFC3339(), "loop": Object{ + "round": 1, + "maxIterations": 3, + "carried": Object{}, + "completed": []any{}, + }}, + }) + if _, err := store.Pool.Exec(`UPDATE workflow_runs SET cancel_requested_at=now() WHERE id=$1`, runID); err != nil { + t.Fatalf("request cancellation: %v", err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "cancelled" || runErr != "" { + t.Fatalf("run settled %q error %q; want cancelled", status, runErr) + } + if got := wfNodeStatus(t, states, "l"); got != "cancelled" { + t.Fatalf("loop node status = %q; want cancelled", got) + } + if _, ran := states["o"]; ran { + t.Fatalf("a node downstream of the cancelled loop committed: %v", states) + } +} + // TestWorkflowAdvanceLoopExitsEarly pins loopExit: the marker ends the round before feedback, and the // loop publishes the outputs resolved from the round that produced it. func TestWorkflowAdvanceLoopExitsEarly(t *testing.T) { diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index 830ffb2bc..348b2890a 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -300,6 +300,9 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no // Declaring delete on a busy Project returns busy — a later pass re-declares, so a terminal run // is never left with a live Workspace forever. func finishWorkflowRun(t *transaction, runID string, version int64, status, runErr string, states map[string]workflowNodeState) { + if status == "cancelled" { + cancelWorkflowLoops(states) + } if settleWorkflowRunStates(t, runID, version, status, runErr, states) { finishWorkflowRunCleanup(t, runID) } From 2da491988dea133d1c74c8ce4fe55535b4e2bee0 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 13:13:24 +0800 Subject: [PATCH 07/27] refactor(workflows): share one variable type menu between globals and loops --- .../workflow-global-variables-dialog.tsx | 24 +++++-------------- .../editor/workflow-variable-select.tsx | 22 ++++++++++++++++- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/frontend/src/features/workflows/editor/workflow-global-variables-dialog.tsx b/frontend/src/features/workflows/editor/workflow-global-variables-dialog.tsx index 5db95ffd8..5610b5347 100644 --- a/frontend/src/features/workflows/editor/workflow-global-variables-dialog.tsx +++ b/frontend/src/features/workflows/editor/workflow-global-variables-dialog.tsx @@ -11,19 +11,13 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { Select, SelectTrigger, SelectValue } from '@/components/ui/select' import { normalizeWorkflowGlobalVariables } from '@/features/workflows/runtime/variable-catalog' -import { - WORKFLOW_VARIABLE_VALUE_TYPES, - type WorkflowGlobalVariable, - type WorkflowVariableValueType, +import type { + WorkflowGlobalVariable, + WorkflowVariableValueType, } from '@/features/workflows/runtime/types' +import { WorkflowVariableTypeMenu } from '@/features/workflows/editor/workflow-variable-select' import { formatWorkflowVariableValue, isWorkflowVariableValueType, @@ -256,13 +250,7 @@ function GlobalVariableRow({ - - {WORKFLOW_VARIABLE_VALUE_TYPES.map((valueType) => ( - - {valueType} - - ))} - + + {WORKFLOW_VARIABLE_VALUE_TYPES.map((valueType) => ( + + {valueType} + + ))} + + ) +} + /** Renders a comparison value back into its editable text form. */ export function comparisonValueToText(value: unknown): string { if (value === undefined || value === null) { From 54f5c087e77bbff72297b5b69308b5e990fb431d Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 13:13:25 +0800 Subject: [PATCH 08/27] feat(workflows): edit Loop carried variables and named outputs --- .../editor/workflow-kind-fields.test.tsx | 54 +++- .../workflows/editor/workflow-kind-fields.tsx | 285 +++++++++++++++--- .../features/workflows/loop-translations.ts | 32 ++ 3 files changed, 329 insertions(+), 42 deletions(-) diff --git a/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx index e5017a7c9..3e1b9c80f 100644 --- a/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx @@ -325,7 +325,8 @@ describe('LoopNodeFields', () => { outputs: [], } - it('clamps the iteration ceiling and writes the carried initial value', () => { + /** Renders the panel with the shared config; `rerenderPanel` rebinds it to the captured node. */ + function setUpLoopPanel() { const { data, onChange } = captureOnChange() const view = renderWithProviders( { onChange={onChange} />, ) + const rerenderPanel = (): void => { + view.rerender() + } + return { data, rerenderPanel } + } + + it('clamps the round ceiling and writes a variable first-round value', () => { + const { data, rerenderPanel } = setUpLoopPanel() expect(screen.getByLabelText('轮次耗尽时')).toHaveTextContent('失败') fireEvent.change(screen.getByLabelText('最大轮次'), { target: { value: '55' } }) expect(data.loopConfig?.maxIterations).toBe(55) - view.rerender() - fireEvent.change(screen.getByLabelText('初始值'), { target: { value: 'ready' } }) - expect(data.loopConfig?.variables[0]?.initial).toEqual({ - kind: 'constant', - value: 'ready', + rerenderPanel() + fireEvent.change(screen.getByLabelText('初值 1'), { target: { value: 'ready' } }) + expect(data.loopConfig?.variables[0]?.initial).toEqual({ kind: 'constant', value: 'ready' }) + }) + + it('adds a carried variable and records its feedback selector', () => { + const { data, rerenderPanel } = setUpLoopPanel() + fireEvent.click(screen.getByRole('button', { name: '添加变量' })) + expect(data.loopConfig?.variables).toHaveLength(2) + + rerenderPanel() + fireEvent.change(screen.getByLabelText('变量名 2'), { target: { value: 'draft' } }) + rerenderPanel() + fireEvent.change(screen.getByLabelText('反馈 2'), { target: { value: 'writer.output' } }) + expect(data.loopConfig?.variables[1]).toMatchObject({ + name: 'draft', + feedback: ['writer', 'output'], + }) + + fireEvent.click(screen.getByRole('button', { name: '删除变量 1' })) + expect(data.loopConfig?.variables).toHaveLength(1) + expect(data.loopConfig?.variables[0]?.name).toBe('draft') + }) + + it('adds a named output with its selector', () => { + const { data, rerenderPanel } = setUpLoopPanel() + fireEvent.click(screen.getByRole('button', { name: '添加输出' })) + expect(data.loopConfig?.outputs).toHaveLength(1) + + rerenderPanel() + fireEvent.change(screen.getByLabelText('输出名 1'), { target: { value: 'result' } }) + rerenderPanel() + fireEvent.change(screen.getByLabelText('取值 1'), { target: { value: 'writer.output' } }) + expect(data.loopConfig?.outputs[0]).toEqual({ + name: 'result', + variableSelector: ['writer', 'output'], }) }) @@ -352,7 +392,7 @@ describe('LoopNodeFields', () => { expect(screen.getByLabelText('最大轮次')).toBeDisabled() expect(screen.getByLabelText('轮次耗尽时')).toBeDisabled() - expect(screen.getByLabelText('初始值')).toBeDisabled() + expect(screen.queryByLabelText('变量名 1')).not.toBeInTheDocument() expect(screen.getByText('此旧循环节点缺少可执行配置,请删除后重新添加。')).toBeInTheDocument() }) }) diff --git a/frontend/src/features/workflows/editor/workflow-kind-fields.tsx b/frontend/src/features/workflows/editor/workflow-kind-fields.tsx index d4059d7c9..b57bd9868 100644 --- a/frontend/src/features/workflows/editor/workflow-kind-fields.tsx +++ b/frontend/src/features/workflows/editor/workflow-kind-fields.tsx @@ -17,7 +17,9 @@ import { } from '@/features/workflows/runtime/capabilities' import type { WorkflowGlobalVariable, + WorkflowLoopVariable, WorkflowNodeData, + WorkflowOutputBinding, WorkflowToolParameter, } from '@/features/workflows/runtime/types' import type { TranslationKey } from '@/i18n/i18n-instance' @@ -27,7 +29,11 @@ import type { WorkflowCanvasNode } from '@/features/workflows/editor/canvas-type import type { WorkflowDecoratedVariable } from '@/features/workflows/runtime/variable-catalog' import { LocalizedSelectValue, + WorkflowVariableTypeMenu, comparisonValueToText, + parseComparisonValue, + selectorToText, + textToSelector, } from '@/features/workflows/editor/workflow-variable-select' /** @@ -357,7 +363,8 @@ export function LoopNodeFields({ return ( <> - + +

{t( loopConfig === undefined @@ -427,46 +434,254 @@ function LoopBudgetFields({ data, onChange }: Pick) { +/** Parses a typed constant: text stays text for string types, otherwise it is JSON-read. */ +function loopConstantValue(text: string, valueType: string): unknown { + return valueType === 'string' || valueType === 'secret' ? text : parseComparisonValue(text) +} + +/** Builds the empty carried variable a new row starts from. */ +function blankLoopVariable(): WorkflowLoopVariable { + return { name: '', valueType: 'string', initial: { kind: 'constant', value: '' }, feedback: [] } +} + +/** Edits every carried variable: its name, type, first-round value and feedback selector. */ +function LoopVariablesFields({ + data, + onChange, +}: Pick) { const { t } = useTranslation() const loopConfig = data.loopConfig - const carriedVariable = loopConfig?.variables[0] - // A constant may have been imported as a number, boolean, or object, so the - // shared JSON value renderer turns it back into editable text safely. - const initialValue = - carriedVariable?.initial.kind === 'constant' - ? comparisonValueToText(carriedVariable.initial.value) - : '' - const updateInitialValue = (value: string): void => { - if (loopConfig === undefined || carriedVariable === undefined) { - return - } - onChange({ - ...data, - loopConfig: { - ...loopConfig, - variables: [ - { - ...carriedVariable, - initial: { kind: 'constant', value }, - }, - ...loopConfig.variables.slice(1), - ], - }, - }) + if (loopConfig === undefined) { + return null + } + const update = (variables: WorkflowLoopVariable[]): void => { + onChange({ ...data, loopConfig: { ...loopConfig, variables } }) } return ( - + +

+ {/* oxlint-disable react/no-array-index-key -- a carried variable has no stable id; its name is the editable identity, so a content key collides on a blank new row. */} + {loopConfig.variables.map((variable, index) => ( + + update(loopConfig.variables.map((current, i) => (i === index ? next : current))) + } + onRemove={() => update(loopConfig.variables.filter((_, i) => i !== index))} + /> + ))} + {/* oxlint-enable react/no-array-index-key */} +
+ + + ) +} + +/** One carried variable's fields; the index only labels the controls for assistive technology. */ +function LoopVariableRow({ + index, + variable, + onChange, + onRemove, +}: { + index: number + variable: WorkflowLoopVariable + onChange: (variable: WorkflowLoopVariable) => void + onRemove: () => void +}) { + const { t } = useTranslation() + const label = (key: TranslationKey): string => `${t(key)} ${index + 1}` + return ( +
+
+ onChange({ ...variable, name: event.target.value })} + /> + +
+
+ + +
+ updateInitialValue(event.target.value)} + aria-label={label('workflows.loop.field.variableFeedback')} + placeholder={t('workflows.loop.selectorPlaceholder')} + value={selectorToText(variable.feedback)} + onChange={(event) => + onChange({ ...variable, feedback: textToSelector(event.target.value) }) + } /> +
+ ) +} + +/** + * The first-round value: a typed constant, or a reference resolved once before the loop starts. + * Switching kinds replaces the value, because a constant and a selector cannot be converted into + * each other without guessing what the author meant. + */ +function LoopInitialValueInput({ + index, + variable, + onChange, +}: { + index: number + variable: WorkflowLoopVariable + onChange: (variable: WorkflowLoopVariable) => void +}) { + const { t } = useTranslation() + const label = `${t('workflows.loop.field.variableInitial')} ${index + 1}` + if (variable.initial.kind === 'constant') { + return ( + + onChange({ + ...variable, + initial: { + kind: 'constant', + value: loopConstantValue(event.target.value, variable.valueType), + }, + }) + } + /> + ) + } + return ( + + onChange({ + ...variable, + initial: { kind: 'variable', selector: textToSelector(event.target.value) }, + }) + } + /> + ) +} + +/** Edits the named results the loop publishes when it succeeds. */ +function LoopOutputsFields({ data, onChange }: Pick) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + if (loopConfig === undefined) { + return null + } + const update = (outputs: WorkflowOutputBinding[]): void => { + onChange({ ...data, loopConfig: { ...loopConfig, outputs } }) + } + return ( + +
+ {/* oxlint-disable react/no-array-index-key -- a named output has no stable id; its name is the editable identity, so a content key collides on a blank new row. */} + {loopConfig.outputs.map((output, index) => ( + // oxlint-disable-next-line react/no-array-index-key -- a named output has no stable id; its name is the editable identity, so a content key collides on a blank new row. +
+ + update( + loopConfig.outputs.map((current, i) => + i === index ? { ...current, name: event.target.value } : current, + ), + ) + } + /> + + update( + loopConfig.outputs.map((current, i) => + i === index + ? { ...current, variableSelector: textToSelector(event.target.value) } + : current, + ), + ) + } + /> + +
+ ))} +
+
) } diff --git a/frontend/src/features/workflows/loop-translations.ts b/frontend/src/features/workflows/loop-translations.ts index 1111dbec4..721b5db66 100644 --- a/frontend/src/features/workflows/loop-translations.ts +++ b/frontend/src/features/workflows/loop-translations.ts @@ -21,6 +21,22 @@ export const loopTranslations = { 'workflows.loop.behavior.fail': '失败', 'workflows.loop.behavior.succeed': '导出最后一轮并成功', 'workflows.loop.field.initialValue': '初始值', + 'workflows.loop.field.variables': '携带变量', + 'workflows.loop.field.variableName': '变量名', + 'workflows.loop.field.variableType': '类型', + 'workflows.loop.field.variableInitialKind': '初值来源', + 'workflows.loop.initialKind.constant': '常量', + 'workflows.loop.initialKind.variable': '变量', + 'workflows.loop.field.variableInitial': '初值', + 'workflows.loop.field.variableFeedback': '反馈', + 'workflows.loop.addVariable': '添加变量', + 'workflows.loop.removeVariable': '删除变量', + 'workflows.loop.field.outputs': '输出', + 'workflows.loop.field.outputName': '输出名', + 'workflows.loop.field.outputSelector': '取值', + 'workflows.loop.addOutput': '添加输出', + 'workflows.loop.removeOutput': '删除输出', + 'workflows.loop.selectorPlaceholder': '节点.变量,例如 writer.output', 'workflows.loop.defaultBehavior': '每轮将 Agent 输出反馈为下一轮的 value;输出非空时结束,并导出为 result。', 'workflows.loop.legacyUnsupported': '此旧循环节点缺少可执行配置,请删除后重新添加。', @@ -40,6 +56,22 @@ export const loopTranslations = { 'workflows.loop.behavior.fail': 'Fail', 'workflows.loop.behavior.succeed': 'Publish the final round', 'workflows.loop.field.initialValue': 'Initial value', + 'workflows.loop.field.variables': 'Carried variables', + 'workflows.loop.field.variableName': 'Variable name', + 'workflows.loop.field.variableType': 'Type', + 'workflows.loop.field.variableInitialKind': 'First value from', + 'workflows.loop.initialKind.constant': 'Constant', + 'workflows.loop.initialKind.variable': 'Variable', + 'workflows.loop.field.variableInitial': 'First value', + 'workflows.loop.field.variableFeedback': 'Feedback', + 'workflows.loop.addVariable': 'Add variable', + 'workflows.loop.removeVariable': 'Remove variable', + 'workflows.loop.field.outputs': 'Outputs', + 'workflows.loop.field.outputName': 'Output name', + 'workflows.loop.field.outputSelector': 'Value', + 'workflows.loop.addOutput': 'Add output', + 'workflows.loop.removeOutput': 'Remove output', + 'workflows.loop.selectorPlaceholder': 'node.variable, for example writer.output', 'workflows.loop.defaultBehavior': 'Each Agent output feeds the next round as value; a non-empty output stops the Loop and is exported as result.', 'workflows.loop.legacyUnsupported': From 2cc9af50ce9d800451ff74c311c2ed56beef048b Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 13:25:36 +0800 Subject: [PATCH 09/27] feat(workflows): edit a Loop's exit condition in the inspector --- .../editor/workflow-choice-select.tsx | 70 +++ .../workflows/editor/workflow-inspector.tsx | 2 +- .../editor/workflow-kind-fields.test.tsx | 21 +- .../workflows/editor/workflow-kind-fields.tsx | 391 +------------- .../workflows/editor/workflow-loop-fields.tsx | 510 ++++++++++++++++++ .../features/workflows/loop-translations.ts | 6 + 6 files changed, 611 insertions(+), 389 deletions(-) create mode 100644 frontend/src/features/workflows/editor/workflow-choice-select.tsx create mode 100644 frontend/src/features/workflows/editor/workflow-loop-fields.tsx diff --git a/frontend/src/features/workflows/editor/workflow-choice-select.tsx b/frontend/src/features/workflows/editor/workflow-choice-select.tsx new file mode 100644 index 000000000..a28c12220 --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-choice-select.tsx @@ -0,0 +1,70 @@ +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' +import { workflowChoiceLabel, type WorkflowChoice } from '@/features/workflows/runtime/capabilities' +import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' +import { LocalizedSelectValue } from '@/features/workflows/editor/workflow-variable-select' +import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' + +/** A select whose menu text and closed-state label both come from one choice list. */ +export function WorkflowChoiceSelect({ + id, + value, + options, + disabled, + ariaLabel, + className, + onChange, +}: { + id: string + value: string + options: readonly WorkflowChoice[] + disabled?: boolean + ariaLabel?: string + className?: string + onChange: (value: string | null) => void +}) { + const translate = useWorkflowTranslator() + return ( + + ) +} + +/** A {@link WorkflowChoiceSelect} wrapped in the inspector's label block. */ +export function LocalizedChoiceSelect({ + id, + label, + value, + options, + disabled, + onChange, +}: { + id: string + label: string + value: string + options: readonly WorkflowChoice[] + disabled?: boolean + onChange: (value: string | null) => void +}) { + return ( + + + + ) +} diff --git a/frontend/src/features/workflows/editor/workflow-inspector.tsx b/frontend/src/features/workflows/editor/workflow-inspector.tsx index 9ae9d7a7e..104fdfb93 100644 --- a/frontend/src/features/workflows/editor/workflow-inspector.tsx +++ b/frontend/src/features/workflows/editor/workflow-inspector.tsx @@ -21,12 +21,12 @@ import { ConditionNodeFields } from '@/features/workflows/editor/workflow-condit import { HumanNodeFields, JunctionNodeFields, - LoopNodeFields, LoopExitNodeFields, SubflowNodeFields, ToolNodeFields, type WorkflowKindFieldsProps, } from '@/features/workflows/editor/workflow-kind-fields' +import { LoopNodeFields } from '@/features/workflows/editor/workflow-loop-fields' import { IterationNodeFields } from '@/features/workflows/editor/workflow-iteration-fields' import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' import { workflowNodeMetadata } from '@/features/workflows/editor/node-metadata' diff --git a/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx index 3e1b9c80f..52e3a3850 100644 --- a/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-kind-fields.test.tsx @@ -14,10 +14,10 @@ import { HumanNodeFields, JunctionNodeFields, LoopExitNodeFields, - LoopNodeFields, SubflowNodeFields, ToolNodeFields, } from '@/features/workflows/editor/workflow-kind-fields' +import { LoopNodeFields } from '@/features/workflows/editor/workflow-loop-fields' import { IterationNodeFields } from '@/features/workflows/editor/workflow-iteration-fields' import type { WorkflowCanvasNode } from '@/features/workflows/editor/canvas-types' import { renderWithProviders } from '@/test/render' @@ -386,6 +386,25 @@ describe('LoopNodeFields', () => { }) }) + it('adds a round-end rule and records its selector, operator and value', () => { + const { data, rerenderPanel } = setUpLoopPanel() + fireEvent.click(screen.getByRole('button', { name: '添加条件' })) + expect(data.loopConfig?.until.conditions).toHaveLength(1) + + rerenderPanel() + fireEvent.change(screen.getByLabelText('变量 1'), { target: { value: 'writer.output' } }) + rerenderPanel() + fireEvent.change(screen.getByLabelText('值 1'), { target: { value: 'done' } }) + expect(data.loopConfig?.until.conditions[0]).toEqual({ + variableSelector: ['writer', 'output'], + operator: 'equals', + value: 'done', + }) + + fireEvent.click(screen.getByRole('button', { name: '删除条件 1' })) + expect(data.loopConfig?.until.conditions).toHaveLength(0) + }) + it('hides the config inputs for a legacy loop without executable config', () => { const onChange = vi.fn<(data: WorkflowNodeData) => void>() renderWithProviders() diff --git a/frontend/src/features/workflows/editor/workflow-kind-fields.tsx b/frontend/src/features/workflows/editor/workflow-kind-fields.tsx index b57bd9868..dc2fd9e8d 100644 --- a/frontend/src/features/workflows/editor/workflow-kind-fields.tsx +++ b/frontend/src/features/workflows/editor/workflow-kind-fields.tsx @@ -13,28 +13,19 @@ import { Textarea } from '@/components/ui/textarea' import { createDefaultWorkflowCapabilities, workflowChoiceLabel, - type WorkflowChoice, } from '@/features/workflows/runtime/capabilities' import type { WorkflowGlobalVariable, - WorkflowLoopVariable, WorkflowNodeData, - WorkflowOutputBinding, WorkflowToolParameter, } from '@/features/workflows/runtime/types' -import type { TranslationKey } from '@/i18n/i18n-instance' import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' -import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' import type { WorkflowCanvasNode } from '@/features/workflows/editor/canvas-types' import type { WorkflowDecoratedVariable } from '@/features/workflows/runtime/variable-catalog' -import { - LocalizedSelectValue, - WorkflowVariableTypeMenu, - comparisonValueToText, - parseComparisonValue, - selectorToText, - textToSelector, -} from '@/features/workflows/editor/workflow-variable-select' +import type { TranslationKey } from '@/i18n/i18n-instance' +import { LocalizedChoiceSelect } from '@/features/workflows/editor/workflow-choice-select' +import { LocalizedSelectValue } from '@/features/workflows/editor/workflow-variable-select' +import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' /** * Everything the kind-specific panels may read beyond the selected node itself. @@ -213,41 +204,6 @@ function ToolParametersField({ ) } -/** A select whose menu text and closed-state label both come from one choice list. */ -function LocalizedChoiceSelect({ - id, - label, - value, - options, - disabled, - onChange, -}: { - id: string - label: string - value: string - options: readonly WorkflowChoice[] - disabled?: boolean - onChange: (value: string | null) => void -}) { - const translate = useWorkflowTranslator() - return ( - - - - ) -} - /** Edits the merge behavior of a Junction node: wait and failure strategies. */ export function JunctionNodeFields({ data, @@ -347,345 +303,6 @@ export function HumanNodeFields({ ) } -/** Exhaustion policies of a Loop, keyed to the graph's enum names. */ -const WORKFLOW_LOOP_BEHAVIOR_CHOICES = [ - { value: 'fail', labelKey: 'workflows.loop.behavior.fail' }, - { value: 'succeed', labelKey: 'workflows.loop.behavior.succeed' }, -] as const satisfies readonly WorkflowChoice[] - -/** Edits a Loop container: budget, carried initial value, and the behavior note. */ -export function LoopNodeFields({ - data, - onChange, -}: Pick) { - const { t } = useTranslation() - const loopConfig = data.loopConfig - return ( - <> - - - -

- {t( - loopConfig === undefined - ? 'workflows.loop.legacyUnsupported' - : 'workflows.loop.defaultBehavior', - )} -

- - ) -} - -/** Edits the round ceiling and what an exhausted budget does. */ -function LoopBudgetFields({ data, onChange }: Pick) { - const { t } = useTranslation() - const loopConfig = data.loopConfig - const updateMaxIterations = (value: number): void => { - if (loopConfig === undefined) { - return - } - onChange({ - ...data, - loopConfig: { - ...loopConfig, - maxIterations: Math.min(100, Math.max(1, Math.trunc(value))), - }, - }) - } - const updateMaxIterationsBehavior = (value: string | null): void => { - if (loopConfig === undefined || (value !== 'fail' && value !== 'succeed')) { - return - } - onChange({ - ...data, - loopConfig: { ...loopConfig, maxIterationsBehavior: value }, - }) - } - return ( - <> - - { - const parsed = Number(event.target.value) - if (Number.isFinite(parsed)) { - updateMaxIterations(parsed) - } - }} - /> - - - - ) -} - -/** Parses a typed constant: text stays text for string types, otherwise it is JSON-read. */ -function loopConstantValue(text: string, valueType: string): unknown { - return valueType === 'string' || valueType === 'secret' ? text : parseComparisonValue(text) -} - -/** Builds the empty carried variable a new row starts from. */ -function blankLoopVariable(): WorkflowLoopVariable { - return { name: '', valueType: 'string', initial: { kind: 'constant', value: '' }, feedback: [] } -} - -/** Edits every carried variable: its name, type, first-round value and feedback selector. */ -function LoopVariablesFields({ - data, - onChange, -}: Pick) { - const { t } = useTranslation() - const loopConfig = data.loopConfig - if (loopConfig === undefined) { - return null - } - const update = (variables: WorkflowLoopVariable[]): void => { - onChange({ ...data, loopConfig: { ...loopConfig, variables } }) - } - return ( - -
- {/* oxlint-disable react/no-array-index-key -- a carried variable has no stable id; its name is the editable identity, so a content key collides on a blank new row. */} - {loopConfig.variables.map((variable, index) => ( - - update(loopConfig.variables.map((current, i) => (i === index ? next : current))) - } - onRemove={() => update(loopConfig.variables.filter((_, i) => i !== index))} - /> - ))} - {/* oxlint-enable react/no-array-index-key */} -
- -
- ) -} - -/** One carried variable's fields; the index only labels the controls for assistive technology. */ -function LoopVariableRow({ - index, - variable, - onChange, - onRemove, -}: { - index: number - variable: WorkflowLoopVariable - onChange: (variable: WorkflowLoopVariable) => void - onRemove: () => void -}) { - const { t } = useTranslation() - const label = (key: TranslationKey): string => `${t(key)} ${index + 1}` - return ( -
-
- onChange({ ...variable, name: event.target.value })} - /> - -
-
- - -
- - - onChange({ ...variable, feedback: textToSelector(event.target.value) }) - } - /> -
- ) -} - -/** - * The first-round value: a typed constant, or a reference resolved once before the loop starts. - * Switching kinds replaces the value, because a constant and a selector cannot be converted into - * each other without guessing what the author meant. - */ -function LoopInitialValueInput({ - index, - variable, - onChange, -}: { - index: number - variable: WorkflowLoopVariable - onChange: (variable: WorkflowLoopVariable) => void -}) { - const { t } = useTranslation() - const label = `${t('workflows.loop.field.variableInitial')} ${index + 1}` - if (variable.initial.kind === 'constant') { - return ( - - onChange({ - ...variable, - initial: { - kind: 'constant', - value: loopConstantValue(event.target.value, variable.valueType), - }, - }) - } - /> - ) - } - return ( - - onChange({ - ...variable, - initial: { kind: 'variable', selector: textToSelector(event.target.value) }, - }) - } - /> - ) -} - -/** Edits the named results the loop publishes when it succeeds. */ -function LoopOutputsFields({ data, onChange }: Pick) { - const { t } = useTranslation() - const loopConfig = data.loopConfig - if (loopConfig === undefined) { - return null - } - const update = (outputs: WorkflowOutputBinding[]): void => { - onChange({ ...data, loopConfig: { ...loopConfig, outputs } }) - } - return ( - -
- {/* oxlint-disable react/no-array-index-key -- a named output has no stable id; its name is the editable identity, so a content key collides on a blank new row. */} - {loopConfig.outputs.map((output, index) => ( - // oxlint-disable-next-line react/no-array-index-key -- a named output has no stable id; its name is the editable identity, so a content key collides on a blank new row. -
- - update( - loopConfig.outputs.map((current, i) => - i === index ? { ...current, name: event.target.value } : current, - ), - ) - } - /> - - update( - loopConfig.outputs.map((current, i) => - i === index - ? { ...current, variableSelector: textToSelector(event.target.value) } - : current, - ), - ) - } - /> - -
- ))} -
- -
- ) -} - /** Explains the loopExit marker: it ends the round and carries no configuration of its own. */ export function LoopExitNodeFields() { const { t } = useTranslation() diff --git a/frontend/src/features/workflows/editor/workflow-loop-fields.tsx b/frontend/src/features/workflows/editor/workflow-loop-fields.tsx new file mode 100644 index 000000000..077003bbe --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-loop-fields.tsx @@ -0,0 +1,510 @@ +import { useTranslation } from 'react-i18next' +import { Plus, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + createDefaultWorkflowCapabilities, + type WorkflowChoice, +} from '@/features/workflows/runtime/capabilities' +import type { + WorkflowConditionComparison, + WorkflowLoopConfig, + WorkflowLoopVariable, + WorkflowNodeData, + WorkflowOutputBinding, +} from '@/features/workflows/runtime/types' +import type { TranslationKey } from '@/i18n/i18n-instance' +import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' +import { + LocalizedChoiceSelect, + WorkflowChoiceSelect, +} from '@/features/workflows/editor/workflow-choice-select' +import { + WorkflowVariableTypeMenu, + comparisonValueToText, + parseComparisonValue, + selectorToText, + textToSelector, +} from '@/features/workflows/editor/workflow-variable-select' + +/** Props every Loop panel component takes: the node data and the writer for the whole node. */ +interface LoopFieldsProps { + data: WorkflowNodeData + onChange: (data: WorkflowNodeData) => void +} +/** Exhaustion policies of a Loop, keyed to the graph's enum names. */ +const WORKFLOW_LOOP_BEHAVIOR_CHOICES = [ + { value: 'fail', labelKey: 'workflows.loop.behavior.fail' }, + { value: 'succeed', labelKey: 'workflows.loop.behavior.succeed' }, +] as const satisfies readonly WorkflowChoice[] + +/** Edits a Loop container: budget, carried initial value, and the behavior note. */ +export function LoopNodeFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + return ( + <> + + + + +

+ {t( + loopConfig === undefined + ? 'workflows.loop.legacyUnsupported' + : 'workflows.loop.defaultBehavior', + )} +

+ + ) +} + +/** Edits the round ceiling and what an exhausted budget does. */ +function LoopBudgetFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + const updateMaxIterations = (value: number): void => { + if (loopConfig === undefined) { + return + } + onChange({ + ...data, + loopConfig: { + ...loopConfig, + maxIterations: Math.min(100, Math.max(1, Math.trunc(value))), + }, + }) + } + const updateMaxIterationsBehavior = (value: string | null): void => { + if (loopConfig === undefined || (value !== 'fail' && value !== 'succeed')) { + return + } + onChange({ + ...data, + loopConfig: { ...loopConfig, maxIterationsBehavior: value }, + }) + } + return ( + <> + + { + const parsed = Number(event.target.value) + if (Number.isFinite(parsed)) { + updateMaxIterations(parsed) + } + }} + /> + + + + ) +} + +/** Parses a typed constant: text stays text for string types, otherwise it is JSON-read. */ +function loopConstantValue(text: string, valueType: string): unknown { + return valueType === 'string' || valueType === 'secret' ? text : parseComparisonValue(text) +} + +/** Builds the empty carried variable a new row starts from. */ +function blankLoopVariable(): WorkflowLoopVariable { + return { name: '', valueType: 'string', initial: { kind: 'constant', value: '' }, feedback: [] } +} + +/** Edits every carried variable: its name, type, first-round value and feedback selector. */ +function LoopVariablesFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + if (loopConfig === undefined) { + return null + } + const update = (variables: WorkflowLoopVariable[]): void => { + onChange({ ...data, loopConfig: { ...loopConfig, variables } }) + } + return ( + +
+ {/* oxlint-disable react/no-array-index-key -- a carried variable has no stable id; its name is the editable identity, so a content key collides on a blank new row. */} + {loopConfig.variables.map((variable, index) => ( + + update(loopConfig.variables.map((current, i) => (i === index ? next : current))) + } + onRemove={() => update(loopConfig.variables.filter((_, i) => i !== index))} + /> + ))} + {/* oxlint-enable react/no-array-index-key */} +
+ +
+ ) +} + +/** One carried variable's fields; the index only labels the controls for assistive technology. */ +function LoopVariableRow({ + index, + variable, + onChange, + onRemove, +}: { + index: number + variable: WorkflowLoopVariable + onChange: (variable: WorkflowLoopVariable) => void + onRemove: () => void +}) { + const { t } = useTranslation() + const label = (key: TranslationKey): string => `${t(key)} ${index + 1}` + return ( +
+
+ onChange({ ...variable, name: event.target.value })} + /> + +
+
+ + +
+ + + onChange({ ...variable, feedback: textToSelector(event.target.value) }) + } + /> +
+ ) +} + +/** + * The first-round value: a typed constant, or a reference resolved once before the loop starts. + * Switching kinds replaces the value, because a constant and a selector cannot be converted into + * each other without guessing what the author meant. + */ +function LoopInitialValueInput({ + index, + variable, + onChange, +}: { + index: number + variable: WorkflowLoopVariable + onChange: (variable: WorkflowLoopVariable) => void +}) { + const { t } = useTranslation() + const label = `${t('workflows.loop.field.variableInitial')} ${index + 1}` + if (variable.initial.kind === 'constant') { + return ( + + onChange({ + ...variable, + initial: { + kind: 'constant', + value: loopConstantValue(event.target.value, variable.valueType), + }, + }) + } + /> + ) + } + return ( + + onChange({ + ...variable, + initial: { kind: 'variable', selector: textToSelector(event.target.value) }, + }) + } + /> + ) +} + +/** How a Loop's round-end rules combine. */ +const LOOP_UNTIL_LOGIC_CHOICES = [ + { value: 'and', labelKey: 'workflows.condition.logicAnd' }, + { value: 'or', labelKey: 'workflows.condition.logicOr' }, +] as const satisfies readonly WorkflowChoice[] + +/** Operators that answer a question about the value, so they take no expected value. */ +const LOOP_OPERATORS_WITHOUT_VALUE = new Set(['exists', 'not_exists', 'empty', 'not_empty']) + +/** Edits the round-end condition: how rules combine and the rules themselves. */ +function LoopUntilFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + if (loopConfig === undefined) { + return null + } + const until = loopConfig.until + const update = (next: WorkflowLoopConfig['until']): void => { + onChange({ ...data, loopConfig: { ...loopConfig, until: next } }) + } + return ( + +
+ { + if (logic === 'and' || logic === 'or') { + update({ ...until, logic }) + } + }} + /> + {/* oxlint-disable react/no-array-index-key -- a rule has no stable id; its selector is the editable identity, so a content key collides on a blank new rule. */} + {until.conditions.map((condition, index) => ( + + update({ + ...until, + conditions: until.conditions.map((current, i) => (i === index ? next : current)), + }) + } + onRemove={() => + update({ + ...until, + conditions: until.conditions.filter((_, i) => i !== index), + }) + } + /> + ))} + {/* oxlint-enable react/no-array-index-key */} +
+ +
+ ) +} + +/** One round-end comparison: a variable, an operator and, for value operators, the expected value. */ +function LoopUntilRow({ + index, + condition, + onChange, + onRemove, +}: { + index: number + condition: WorkflowConditionComparison + onChange: (condition: WorkflowConditionComparison) => void + onRemove: () => void +}) { + const { t } = useTranslation() + const capabilities = createDefaultWorkflowCapabilities() + return ( +
+
+ IF {index + 1} + +
+ + onChange({ ...condition, variableSelector: textToSelector(event.target.value) }) + } + /> + { + if (operator !== null) { + onChange({ ...condition, operator }) + } + }} + /> + {LOOP_OPERATORS_WITHOUT_VALUE.has(condition.operator) ? null : ( + + onChange({ ...condition, value: parseComparisonValue(event.target.value) }) + } + /> + )} +
+ ) +} + +/** Edits the named results the loop publishes when it succeeds. */ +function LoopOutputsFields({ data, onChange }: LoopFieldsProps) { + const { t } = useTranslation() + const loopConfig = data.loopConfig + if (loopConfig === undefined) { + return null + } + const update = (outputs: WorkflowOutputBinding[]): void => { + onChange({ ...data, loopConfig: { ...loopConfig, outputs } }) + } + return ( + +
+ {/* oxlint-disable react/no-array-index-key -- a named output has no stable id; its name is the editable identity, so a content key collides on a blank new row. */} + {loopConfig.outputs.map((output, index) => ( + // oxlint-disable-next-line react/no-array-index-key -- a named output has no stable id; its name is the editable identity, so a content key collides on a blank new row. +
+ + update( + loopConfig.outputs.map((current, i) => + i === index ? { ...current, name: event.target.value } : current, + ), + ) + } + /> + + update( + loopConfig.outputs.map((current, i) => + i === index + ? { ...current, variableSelector: textToSelector(event.target.value) } + : current, + ), + ) + } + /> + +
+ ))} +
+ +
+ ) +} diff --git a/frontend/src/features/workflows/loop-translations.ts b/frontend/src/features/workflows/loop-translations.ts index 721b5db66..eec6add2e 100644 --- a/frontend/src/features/workflows/loop-translations.ts +++ b/frontend/src/features/workflows/loop-translations.ts @@ -31,6 +31,9 @@ export const loopTranslations = { 'workflows.loop.field.variableFeedback': '反馈', 'workflows.loop.addVariable': '添加变量', 'workflows.loop.removeVariable': '删除变量', + 'workflows.loop.field.until': '结束条件', + 'workflows.loop.addUntilRule': '添加条件', + 'workflows.loop.removeUntilRule': '删除条件', 'workflows.loop.field.outputs': '输出', 'workflows.loop.field.outputName': '输出名', 'workflows.loop.field.outputSelector': '取值', @@ -66,6 +69,9 @@ export const loopTranslations = { 'workflows.loop.field.variableFeedback': 'Feedback', 'workflows.loop.addVariable': 'Add variable', 'workflows.loop.removeVariable': 'Remove variable', + 'workflows.loop.field.until': 'Exit condition', + 'workflows.loop.addUntilRule': 'Add condition', + 'workflows.loop.removeUntilRule': 'Remove condition', 'workflows.loop.field.outputs': 'Outputs', 'workflows.loop.field.outputName': 'Output name', 'workflows.loop.field.outputSelector': 'Value', From ba54322489707a807b94cf2deb3cedca17a40e87 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 13:33:18 +0800 Subject: [PATCH 10/27] feat(core): publish and execute the variable aggregator node --- internal/core/workflow_aggregator_graph.go | 113 ++++++++++++++++ .../core/workflow_aggregator_graph_test.go | 83 ++++++++++++ .../workflow_aggregator_scheduler_db_test.go | 122 ++++++++++++++++++ internal/core/workflow_graph.go | 19 ++- internal/core/workflow_scheduler.go | 17 +++ 5 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 internal/core/workflow_aggregator_graph.go create mode 100644 internal/core/workflow_aggregator_graph_test.go create mode 100644 internal/core/workflow_aggregator_scheduler_db_test.go diff --git a/internal/core/workflow_aggregator_graph.go b/internal/core/workflow_aggregator_graph.go new file mode 100644 index 000000000..98f474805 --- /dev/null +++ b/internal/core/workflow_aggregator_graph.go @@ -0,0 +1,113 @@ +package core + +// The variable aggregator: an ordered pass-through that collapses mutually exclusive branch outputs +// into one variable (desktop `workflow_run/engine/aggregator.rs`, V1 contract). Selectors are an +// ordered non-empty list of root selectors; the node publishes the first **assigned** candidate as +// `{aggregator}.output`, and zero assigned candidates is a stable failure. +// +// "Assigned" is key existence in the committed pool, never truthiness: `null`, `false`, `0`, `""`, an +// empty list and an empty object all count, exactly as they do on desktop. The node never reads branch +// decisions; which branch ran is expressed by the committed states the selector resolves against. + +// aggregatorSelector is one parsed candidate: a node and a root, with no nested path. Nested paths are +// refused at publish because a root selector is the only shape the aggregator can statically type. +type aggregatorSelector struct { + nodeID string + root string +} + +// validateAggregatorConfig checks `data.aggregatorConfig` against the executable contract. Every +// problem names the node and the field, so the editor can point at the list rather than at the graph. +func validateAggregatorConfig(nodeID string, data Object, declaredRoots map[string]map[string]bool) []workflowGraphProblem { + raw, present := data["aggregatorConfig"] + if !present || raw == nil { + return []workflowGraphProblem{{Code: "aggregator_config_missing", NodeID: nodeID, Field: "aggregatorConfig"}} + } + config, ok := asWorkflowObject(raw) + if !ok { + return []workflowGraphProblem{{Code: "aggregator_config_missing", NodeID: nodeID, Field: "aggregatorConfig"}} + } + rawSelectors, present := config["variables"] + if !present || rawSelectors == nil { + return []workflowGraphProblem{{Code: "aggregator_variables_not_a_list", NodeID: nodeID, Field: "aggregatorConfig.variables"}} + } + selectors, ok := rawSelectors.([]any) + if !ok { + return []workflowGraphProblem{{Code: "aggregator_variables_not_a_list", NodeID: nodeID, Field: "aggregatorConfig.variables"}} + } + if len(selectors) == 0 { + return []workflowGraphProblem{{Code: "aggregator_variables_empty", NodeID: nodeID, Field: "aggregatorConfig.variables"}} + } + problems := []workflowGraphProblem{} + seen := map[string]bool{} + for _, item := range selectors { + selector, ok := parseWorkflowSelector(item) + if !ok || len(selector.nested) != 0 { + problems = append(problems, workflowGraphProblem{Code: "aggregator_selector_unsupported", NodeID: nodeID, Field: "aggregatorConfig.variables"}) + continue + } + qualified := selector.qualified() + if seen[qualified] { + problems = append(problems, workflowGraphProblem{Code: "aggregator_selector_duplicate", NodeID: nodeID, Field: "aggregatorConfig.variables"}) + continue + } + seen[qualified] = true + if !declaredRoots[selector.nodeID][selector.root] { + problems = append(problems, workflowGraphProblem{Code: "aggregator_selector_undeclared", NodeID: nodeID, Field: "aggregatorConfig.variables"}) + } + } + return problems +} + +// parseAggregatorSelectors compiles the wire list the way the scheduler executes it. It runs on frozen +// snapshots, so it reports a document the gate would have refused instead of assuming the shape. +func parseAggregatorSelectors(data Object) ([]aggregatorSelector, error) { + config, ok := asWorkflowObject(data["aggregatorConfig"]) + if !ok { + return nil, errString("aggregator node has no config") + } + raw, ok := config["variables"].([]any) + if !ok { + return nil, errString("aggregator variables must be a list") + } + selectors := make([]aggregatorSelector, 0, len(raw)) + for _, item := range raw { + selector, ok := parseWorkflowSelector(item) + if !ok || len(selector.nested) != 0 { + return nil, errString("aggregator selector must be a node id and a root") + } + selectors = append(selectors, aggregatorSelector{nodeID: selector.nodeID, root: selector.root}) + } + if len(selectors) == 0 { + return nil, errString("aggregator must declare at least one selector") + } + return selectors, nil +} + +// errString is a tiny error type so the parser stays dependency-free and its messages reach the node +// state unchanged. +type errString string + +func (e errString) Error() string { return string(e) } + +// evaluateWorkflowAggregator resolves the declared candidates in order and returns the first +// assigned value. Presence is key existence in the committed pool, so null, false, 0, "" and empty +// containers all count as assigned — desktop's V1 contract, not a truthiness test. Unassigned +// candidates are skipped; an empty result is reported as not assigned so the caller can fail with +// the stable no-match reason. +func evaluateWorkflowAggregator(node workflowNodeView, states map[string]workflowNodeState) (any, bool, error) { + selectors, err := parseAggregatorSelectors(node.Data) + if err != nil { + return nil, false, err + } + for _, candidate := range selectors { + value, present, err := resolveWorkflowSelector(workflowSelector{nodeID: candidate.nodeID, root: candidate.root}, states) + if err != nil { + return nil, false, err + } + if present { + return value, true, nil + } + } + return nil, false, nil +} diff --git a/internal/core/workflow_aggregator_graph_test.go b/internal/core/workflow_aggregator_graph_test.go new file mode 100644 index 000000000..0b167d0ea --- /dev/null +++ b/internal/core/workflow_aggregator_graph_test.go @@ -0,0 +1,83 @@ +package core + +import ( + "reflect" + "testing" +) + +// The aggregator's publish gate: an ordered list of root selectors, each naming a declared producer, +// with no nested path and no duplicate. The scheduler reads the same list, so a refused document is +// exactly one the run could not execute. + +// aggregatorTestGraph is start feeding two agents that both merge into one aggregator and then an +// output — the branch fan-in the node exists for. +func aggregatorTestGraph(config Object) Object { + agent := func(id string) Object { + return Object{"id": id, "data": Object{"kind": "agent", "title": id, "agentConfig": Object{"schemaVersion": 3, "prompt": "x"}}} + } + return Object{ + "nodes": []any{ + Object{"id": "s", "data": Object{"kind": "start", "title": "开始", "inputVariables": []any{}}}, + agent("a1"), + agent("a2"), + Object{"id": "agg", "data": Object{"kind": "aggregator", "title": "合并", "aggregatorConfig": config}}, + Object{"id": "o", "data": Object{"kind": "output"}}, + }, + "edges": []any{ + Object{"source": "s", "target": "a1"}, + Object{"source": "s", "target": "a2"}, + Object{"source": "a1", "target": "agg"}, + Object{"source": "a2", "target": "agg"}, + Object{"source": "agg", "target": "o"}, + }, + } +} + +// aggregatorProblems projects problems as `code@node#field` strings for exact comparison. +func aggregatorProblems(t *testing.T, graph Object) []string { + t.Helper() + out := []string{} + for _, problem := range validateWorkflowGraph(decodeGraph(t, graph)) { + entry := problem.Code + if problem.NodeID != "" { + entry += "@" + problem.NodeID + } + if problem.Field != "" { + entry += "#" + problem.Field + } + out = append(out, entry) + } + return out +} + +// TestWorkflowPublishValidatesAggregatorConfig is the configuration matrix. +func TestWorkflowPublishValidatesAggregatorConfig(t *testing.T) { + valid := Object{"variables": []any{[]any{"a1", "output"}, []any{"a2", "output"}}} + if got := aggregatorProblems(t, aggregatorTestGraph(valid)); len(got) != 0 { + t.Fatalf("a well-formed aggregator must publish, got %v", got) + } + cases := []struct { + name string + config any + want []string + }{ + {name: "missing config", config: nil, want: []string{"aggregator_config_missing@agg#aggregatorConfig"}}, + {name: "variables not a list", config: Object{"variables": "a1.output"}, want: []string{"aggregator_variables_not_a_list@agg#aggregatorConfig.variables"}}, + {name: "empty selector list", config: Object{"variables": []any{}}, want: []string{"aggregator_variables_empty@agg#aggregatorConfig.variables"}}, + {name: "one-segment selector", config: Object{"variables": []any{[]any{"a1"}}}, want: []string{"aggregator_selector_unsupported@agg#aggregatorConfig.variables"}}, + {name: "nested selector", config: Object{"variables": []any{[]any{"a1", "output", "text"}}}, want: []string{"aggregator_selector_unsupported@agg#aggregatorConfig.variables"}}, + {name: "duplicate selector", config: Object{"variables": []any{[]any{"a1", "output"}, []any{"a1", "output"}}}, want: []string{"aggregator_selector_duplicate@agg#aggregatorConfig.variables"}}, + {name: "undeclared producer", config: Object{"variables": []any{[]any{"missing", "output"}}}, want: []string{"aggregator_selector_undeclared@agg#aggregatorConfig.variables"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + graph := aggregatorTestGraph(nil) + if tc.config != nil { + graph = aggregatorTestGraph(tc.config.(Object)) + } + if got := aggregatorProblems(t, graph); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("problems = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/core/workflow_aggregator_scheduler_db_test.go b/internal/core/workflow_aggregator_scheduler_db_test.go new file mode 100644 index 000000000..79761cba2 --- /dev/null +++ b/internal/core/workflow_aggregator_scheduler_db_test.go @@ -0,0 +1,122 @@ +package core + +import ( + "context" + "testing" +) + +// The aggregator's scheduler behaviour over PostgreSQL: the first assigned root is passed through as +// `{aggregator}.output`, presence is key existence (a null counts), and an empty candidate set is the +// stable no-match failure. + +// aggregatorRunGraph is start → aggregator → output with two declared start variables, so a run can +// assign either, both or neither candidate. +func aggregatorRunGraph(selectors ...[]any) Object { + start := wfNode("s", "start", "开始") + start["data"].(Object)["inputVariables"] = []any{ + Object{"name": "a", "valueType": "string"}, + Object{"name": "b", "valueType": "string"}, + } + agg := wfNode("agg", "aggregator", "合并") + choices := make([]any, 0, len(selectors)) + for _, selector := range selectors { + choices = append(choices, selector) + } + agg["data"].(Object)["aggregatorConfig"] = Object{"variables": choices} + return wfGraph( + []Object{start, agg, wfNode("o", "output", "输出")}, + [][3]string{{"s", "agg", ""}, {"agg", "o", ""}}, + ) +} + +// TestWorkflowAdvanceAggregatorPassesFirstAssignedCandidate pins the order contract: the first +// assigned selector wins even when a later candidate is also assigned. +func TestWorkflowAdvanceAggregatorPassesFirstAssignedCandidate(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunSceneWithInput(t, store, + aggregatorRunGraph([]any{"s", "b"}, []any{"s", "a"}), + Object{"a": "from-a", "b": "from-b"}, + ) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + agg, ok := asWorkflowObject(states["agg"]) + if !ok { + t.Fatalf("aggregator has no committed state: %v", states) + } + if got := agg.O("output")["output"]; got != "from-b" { + t.Fatalf("aggregator output = %v; want the first declared candidate from-b", got) + } + if got := wfNodeStatus(t, states, "o"); got != "succeeded" { + t.Fatalf("downstream output status = %q; want succeeded", got) + } +} + +// TestWorkflowAdvanceAggregatorSkipsAnUnassignedCandidate pins the assignment rule: a declared but +// unproduced root is skipped, not treated as a null value. +func TestWorkflowAdvanceAggregatorSkipsAnUnassignedCandidate(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunSceneWithInput(t, store, + aggregatorRunGraph([]any{"s", "a"}, []any{"s", "b"}), + Object{"b": "only-b"}, + ) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + agg, _ := asWorkflowObject(states["agg"]) + if got := agg.O("output")["output"]; got != "only-b" { + t.Fatalf("aggregator output = %v; want the only assigned candidate only-b", got) + } +} + +// TestWorkflowAdvanceAggregatorCountsANullAsAssigned pins the falsy rule: an explicit null is assigned +// (key existence), so the aggregator passes it through instead of failing no-match. +func TestWorkflowAdvanceAggregatorCountsANullAsAssigned(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunSceneWithInput(t, store, + aggregatorRunGraph([]any{"s", "a"}, []any{"s", "b"}), + Object{"a": nil}, + ) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded for an assigned null", status, runErr) + } + agg, _ := asWorkflowObject(states["agg"]) + if value, present := agg.O("output")["output"]; !present || value != nil { + t.Fatalf("aggregator output = %v (present %v); want an assigned null", value, present) + } +} + +// TestWorkflowAdvanceAggregatorFailsWithoutAssignedCandidate pins the no-match failure: no candidate +// produced anything fails the node and the run with the stable reason. +func TestWorkflowAdvanceAggregatorFailsWithoutAssignedCandidate(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunScene(t, store, + aggregatorRunGraph([]any{"s", "a"}, []any{"s", "b"}), + ) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "aggregator_no_match" { + t.Fatalf("run settled %q error %q; want failed/aggregator_no_match", status, runErr) + } + agg, _ := asWorkflowObject(states["agg"]) + if agg.S("status") != "failed" || agg.O("output").S("reason") != "aggregator_no_match" { + t.Fatalf("aggregator state = %v; want failed with its reason", states["agg"]) + } + if _, ran := states["o"]; ran { + t.Fatalf("a node downstream of the failed aggregator committed: %v", states) + } +} diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 7a7f2e5b8..5f9360023 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -21,13 +21,15 @@ import ( // into the save path is the mistake this file exists to prevent. // workflowExecutableNodeKinds is the subset of the editor's node kinds Cloud executes in its first -// phase (#67 结论 4): start, agent, condition and output. Everything else stays editable and -// saveable, but publishing refuses it rather than freezing a graph whose nodes would be dropped. +// phase (#67 结论 4): start, agent, condition and output; `aggregator` joined it with the +// variable-aggregator slice. Everything else stays editable and saveable, but publishing refuses it +// rather than freezing a graph whose nodes would be dropped. var workflowExecutableNodeKinds = map[string]bool{ - "start": true, - "agent": true, - "condition": true, - "output": true, + "start": true, + "agent": true, + "condition": true, + "aggregator": true, + "output": true, } // workflowAgentSchemaVersion is the only `agentConfig.schemaVersion` Cloud executes. Version 4 @@ -411,6 +413,8 @@ func validateWorkflowNodes(nodes []any, globals []Object) []workflowGraphProblem problems = append(problems, validateAgentConfig(id, o.O("data"))...) case kind == "condition": problems = append(problems, validateConditionConfig(id, o.O("data"), declaredRoots)...) + case kind == "aggregator": + problems = append(problems, validateAggregatorConfig(id, o.O("data"), declaredRoots)...) } } if starts != 1 { @@ -609,6 +613,9 @@ func workflowDeclaredRoots(nodes []any, globals []Object) map[string]map[string] } case "output": set["output"] = true + case "aggregator": + // The aggregator publishes exactly one root: the value of the first assigned candidate. + set["output"] = true } roots[id] = set } diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 714ec48a5..d817a33ea 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -732,6 +732,23 @@ func executeWorkflowNode(node workflowNodeView, input Object, states, ambient ma state.Output = Object{"selectedBranch": branch} case "output": state.Output = Object{} + case "aggregator": + value, assigned, err := evaluateWorkflowAggregator(node, workflowConditionStates(states, ambient)) + if err != nil { + state.Status = "failed" + state.Error = err.Error() + state.Output = Object{"reason": "aggregator_invalid_config"} + return state + } + if !assigned { + // Every candidate was declared but none was produced: the stable desktop failure, not an + // empty output. Publishing nothing would make a branch merge silently disappear. + state.Status = "failed" + state.Error = "variable aggregator found no assigned candidate" + state.Output = Object{"reason": "aggregator_no_match"} + return state + } + state.Output = Object{"output": value} default: // Unreachable: the ready pass only yields executable kinds, and the agent case returned // earlier. A future kind that reaches here must not be recorded as success. From 5f8325e6c11d30c9f57f7e78a58fa93d8796d9e8 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 13:47:30 +0800 Subject: [PATCH 11/27] feat(workflows): author the variable aggregator node --- .../workflows/aggregator-translations.ts | 27 +++++++ .../workflows/editor/node-metadata.ts | 6 ++ .../workflow-aggregator-fields.test.tsx | 47 +++++++++++ .../editor/workflow-aggregator-fields.tsx | 81 +++++++++++++++++++ .../workflows/editor/workflow-inspector.tsx | 4 + .../features/workflows/node-translations.ts | 55 +++++++++++++ .../workflows/runtime/node-catalog.test.ts | 1 + .../workflows/runtime/node-catalog.ts | 3 + .../workflows/runtime/node-factory.test.ts | 1 + .../workflows/runtime/node-factory.ts | 4 + .../src/features/workflows/runtime/types.ts | 8 ++ .../src/features/workflows/translations.ts | 47 ++--------- frontend/src/i18n/i18n.test.ts | 1 + 13 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 frontend/src/features/workflows/aggregator-translations.ts create mode 100644 frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx create mode 100644 frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx create mode 100644 frontend/src/features/workflows/node-translations.ts diff --git a/frontend/src/features/workflows/aggregator-translations.ts b/frontend/src/features/workflows/aggregator-translations.ts new file mode 100644 index 000000000..13d6d01cd --- /dev/null +++ b/frontend/src/features/workflows/aggregator-translations.ts @@ -0,0 +1,27 @@ +/** + * Messages for the variable aggregator node. + * + * They live beside the feature's main bundle for the same reason the history and launch-field + * bundles do: the group is self-contained — the node's palette identity and the ordered candidate + * list — and the main bundle is held to a line budget. `translations.ts` spreads both locales in. + */ +export const aggregatorTranslations = { + 'zh-CN': { + 'workflows.node.aggregator.label': '变量聚合', + 'workflows.node.aggregator.description': '按顺序取第一个已赋值的候选变量', + 'workflows.aggregator.field.variables': '候选变量', + 'workflows.aggregator.field.candidate': '候选', + 'workflows.aggregator.addCandidate': '添加候选', + 'workflows.aggregator.removeCandidate': '删除候选', + 'workflows.aggregator.selectorPlaceholder': '节点.变量,例如 writer.output', + }, + 'en-US': { + 'workflows.node.aggregator.label': 'Variable aggregator', + 'workflows.node.aggregator.description': 'Pass through the first assigned candidate, in order', + 'workflows.aggregator.field.variables': 'Candidates', + 'workflows.aggregator.field.candidate': 'Candidate', + 'workflows.aggregator.addCandidate': 'Add candidate', + 'workflows.aggregator.removeCandidate': 'Remove candidate', + 'workflows.aggregator.selectorPlaceholder': 'node.variable, for example writer.output', + }, +} diff --git a/frontend/src/features/workflows/editor/node-metadata.ts b/frontend/src/features/workflows/editor/node-metadata.ts index 7ddfa5e74..2e0607e39 100644 --- a/frontend/src/features/workflows/editor/node-metadata.ts +++ b/frontend/src/features/workflows/editor/node-metadata.ts @@ -5,6 +5,7 @@ import { GitBranch, GitMerge, Layers, + Merge, Play, Repeat, UserCheck, @@ -38,6 +39,11 @@ const NODE_METADATA: Record = { icon: GitBranch, tone: 'bg-amber-500/12 text-amber-700 dark:text-amber-400', }, + aggregator: { + kind: 'aggregator', + icon: Merge, + tone: 'bg-orange-500/12 text-orange-700 dark:text-orange-400', + }, tool: { kind: 'tool', icon: Braces, tone: 'bg-cyan-500/12 text-cyan-700 dark:text-cyan-400' }, junction: { kind: 'junction', diff --git a/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx new file mode 100644 index 000000000..b3cb6caa6 --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { WorkflowNodeData } from '@/features/workflows/runtime/types' +import { AggregatorNodeFields } from '@/features/workflows/editor/workflow-aggregator-fields' +import { renderWithProviders } from '@/test/render' + +/** The node the panel edits; candidates are the only thing it reads. */ +function aggregatorData(variables: string[][]): WorkflowNodeData { + return { + kind: 'aggregator', + title: '变量聚合', + description: '', + aggregatorConfig: { variables }, + } +} + +describe('AggregatorNodeFields', () => { + it('adds a candidate and writes its root selector', () => { + let data = aggregatorData([]) + const onChange = vi.fn<(next: WorkflowNodeData) => void>((next) => { + data = next + }) + const view = renderWithProviders() + + fireEvent.click(screen.getByRole('button', { name: '添加候选' })) + expect(data.aggregatorConfig?.variables).toEqual([[]]) + + view.rerender() + fireEvent.change(screen.getByLabelText('候选 1'), { target: { value: 'writer.output' } }) + expect(data.aggregatorConfig?.variables).toEqual([['writer', 'output']]) + }) + + it('keeps the declared order when a candidate is removed', () => { + let data = aggregatorData([ + ['a', 'output'], + ['b', 'output'], + ]) + const onChange = vi.fn<(next: WorkflowNodeData) => void>((next) => { + data = next + }) + renderWithProviders() + + fireEvent.click(screen.getByRole('button', { name: '删除候选 1' })) + + expect(data.aggregatorConfig?.variables).toEqual([['b', 'output']]) + }) +}) diff --git a/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx b/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx new file mode 100644 index 000000000..eabe553bc --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from 'react-i18next' +import { Plus, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' +import { + selectorToText, + textToSelector, +} from '@/features/workflows/editor/workflow-variable-select' +import type { WorkflowNodeData } from '@/features/workflows/runtime/types' + +/** Props every Aggregator panel component takes: the node data and the writer for the whole node. */ +export interface AggregatorFieldsProps { + data: WorkflowNodeData + onChange: (data: WorkflowNodeData) => void +} + +/** + * Edits the aggregator's ordered candidates. + * + * Order is the contract: the first assigned candidate is the one the node publishes, so the panel + * never reorders the list on its own and exposes the position as a plain number. A selector is + * `node.root` text until the shared picker replaces it; the publish gate refuses an undeclared or + * nested selector, so a typo is caught before the graph runs. + */ +export function AggregatorNodeFields({ data, onChange }: AggregatorFieldsProps) { + const { t } = useTranslation() + const config = data.aggregatorConfig + if (config === undefined) { + return null + } + const update = (variables: string[][]): void => { + onChange({ ...data, aggregatorConfig: { variables } }) + } + return ( + +
+ {/* oxlint-disable react/no-array-index-key -- a candidate has no stable identity: its selector is the editable value, so a content key collides on two blank rows. */} + {config.variables.map((selector, index) => ( +
+ {index + 1} + + update( + config.variables.map((current, i) => + i === index ? textToSelector(event.target.value) : current, + ), + ) + } + /> + +
+ ))} + {/* oxlint-enable react/no-array-index-key */} +
+ +
+ ) +} diff --git a/frontend/src/features/workflows/editor/workflow-inspector.tsx b/frontend/src/features/workflows/editor/workflow-inspector.tsx index 73652726c..1202677cf 100644 --- a/frontend/src/features/workflows/editor/workflow-inspector.tsx +++ b/frontend/src/features/workflows/editor/workflow-inspector.tsx @@ -26,6 +26,7 @@ import { ToolNodeFields, type WorkflowKindFieldsProps, } from '@/features/workflows/editor/workflow-kind-fields' +import { AggregatorNodeFields } from '@/features/workflows/editor/workflow-aggregator-fields' import { IterationNodeFields } from '@/features/workflows/editor/workflow-iteration-fields' import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' import { workflowNodeMetadata } from '@/features/workflows/editor/node-metadata' @@ -165,6 +166,9 @@ function NodeKindFields({ if (data.kind === 'condition') { return } + if (data.kind === 'aggregator') { + return + } if (data.kind === 'tool') { return } diff --git a/frontend/src/features/workflows/node-translations.ts b/frontend/src/features/workflows/node-translations.ts new file mode 100644 index 000000000..2ee76faac --- /dev/null +++ b/frontend/src/features/workflows/node-translations.ts @@ -0,0 +1,55 @@ +/** + * Palette identity of every workflow node kind. + * + * The node catalog stores these keys rather than resolved text, so a language switch never renames a + * node the author already wrote. They live beside the feature's main bundle because the block is + * self-contained and the main bundle is held to a line budget; `translations.ts` spreads both + * locales in. + */ +export const nodeTranslations = { + 'zh-CN': { + // Node catalog: palette labels and descriptions for every executable kind. + 'workflows.node.start.label': '开始', + 'workflows.node.start.description': '定义工作流输入', + 'workflows.node.agent.label': 'Agent', + 'workflows.node.agent.description': '交给模型自主执行', + 'workflows.node.condition.label': '条件分支', + 'workflows.node.condition.description': '根据规则选择路径', + 'workflows.node.tool.label': '工具', + 'workflows.node.tool.description': '调用终端或插件', + 'workflows.node.junction.label': '汇合', + 'workflows.node.junction.description': '等待多个执行分支完成', + 'workflows.node.human.label': '人工确认', + 'workflows.node.human.description': '等待人工决策后继续', + 'workflows.node.loop.label': '循环', + 'workflows.node.loop.description': '重复执行直到满足条件', + 'workflows.node.iteration.label': '迭代', + 'workflows.node.iteration.description': '对数组逐项执行区域内节点', + 'workflows.node.subflow.label': '子流程', + 'workflows.node.subflow.description': '封装复杂业务步骤', + 'workflows.node.output.label': '输出', + 'workflows.node.output.description': '返回最终结果', + }, + 'en-US': { + 'workflows.node.start.label': 'Start', + 'workflows.node.start.description': 'Define workflow inputs', + 'workflows.node.agent.label': 'Agent', + 'workflows.node.agent.description': 'Delegate autonomous work to a model', + 'workflows.node.condition.label': 'Condition', + 'workflows.node.condition.description': 'Route execution based on rules', + 'workflows.node.tool.label': 'Tool', + 'workflows.node.tool.description': 'Call a terminal or plugin', + 'workflows.node.junction.label': 'Merge', + 'workflows.node.junction.description': 'Wait for multiple branches to complete', + 'workflows.node.human.label': 'Human confirmation', + 'workflows.node.human.description': 'Pause for a human decision', + 'workflows.node.loop.label': 'Loop', + 'workflows.node.loop.description': 'Repeat until the exit condition is met', + 'workflows.node.iteration.label': 'Iteration', + 'workflows.node.iteration.description': 'Run the region once per array element', + 'workflows.node.subflow.label': 'Subflow', + 'workflows.node.subflow.description': 'Encapsulate a complex business step', + 'workflows.node.output.label': 'Output', + 'workflows.node.output.description': 'Return the final result', + }, +} diff --git a/frontend/src/features/workflows/runtime/node-catalog.test.ts b/frontend/src/features/workflows/runtime/node-catalog.test.ts index 27b0d0ac6..9cf9fc583 100644 --- a/frontend/src/features/workflows/runtime/node-catalog.test.ts +++ b/frontend/src/features/workflows/runtime/node-catalog.test.ts @@ -33,6 +33,7 @@ describe('workflow node catalog', () => { expect(workflowPaletteNodeTypes('iteration').map((entry) => entry.kind)).toEqual([ 'agent', 'condition', + 'aggregator', ]) }) diff --git a/frontend/src/features/workflows/runtime/node-catalog.ts b/frontend/src/features/workflows/runtime/node-catalog.ts index 1e858da1f..307ab5854 100644 --- a/frontend/src/features/workflows/runtime/node-catalog.ts +++ b/frontend/src/features/workflows/runtime/node-catalog.ts @@ -9,6 +9,7 @@ export type WorkflowConfigField = | 'initialPrompt' | 'tool' | 'condition' + | 'aggregator' | 'approvalPrompt' | 'waitStrategy' | 'failureStrategy' @@ -40,6 +41,7 @@ export const WORKFLOW_NODE_PALETTE: readonly WorkflowNodeKind[] = [ 'start', 'agent', 'condition', + 'aggregator', 'loop', 'iteration', 'output', @@ -70,6 +72,7 @@ const NODE_TYPE_DEFINITIONS: Record { 'start', 'agent', 'condition', + 'aggregator', 'loop', 'iteration', 'output', diff --git a/frontend/src/features/workflows/runtime/node-factory.ts b/frontend/src/features/workflows/runtime/node-factory.ts index a99e75f10..68578474e 100644 --- a/frontend/src/features/workflows/runtime/node-factory.ts +++ b/frontend/src/features/workflows/runtime/node-factory.ts @@ -40,6 +40,7 @@ export const AUTHORABLE_NODE_KINDS: readonly WorkflowNodeKind[] = [ 'start', 'agent', 'condition', + 'aggregator', 'loop', 'iteration', 'output', @@ -100,6 +101,9 @@ function seedNodeData( // The Loop's own `loopConfig` is supplied by the atomic group factory, which creates // its starter children at the same time. return {} + case 'aggregator': + // An empty list is a legal draft but not publishable: the author picks candidates next. + return { aggregatorConfig: { variables: [] } } case 'condition': return { condition: translate('workflows.node.condition.label'), diff --git a/frontend/src/features/workflows/runtime/types.ts b/frontend/src/features/workflows/runtime/types.ts index 253036cfd..d4d1bfa85 100644 --- a/frontend/src/features/workflows/runtime/types.ts +++ b/frontend/src/features/workflows/runtime/types.ts @@ -8,6 +8,7 @@ export const WORKFLOW_NODE_KINDS = [ 'start', 'agent', 'condition', + 'aggregator', 'tool', 'junction', 'human', @@ -173,6 +174,11 @@ export interface WorkflowConditionCase { conditions: WorkflowConditionComparison[] } +/** Ordered root selectors of an Aggregator node; the first assigned candidate is passed through. */ +export interface WorkflowAggregatorConfig { + variables: string[][] +} + /** One named result an Output node exposes, resolved from the run variable pool at completion. */ export interface WorkflowOutputBinding { name: string @@ -267,6 +273,8 @@ export interface WorkflowNodeData extends Record { condition?: string agentConfig?: WorkflowAgentConfig conditionBranches?: WorkflowConditionBranch[] + /** Aggregator node: ordered root selectors (backend data.aggregatorConfig). */ + aggregatorConfig?: WorkflowAggregatorConfig /** Legacy executable cases retained so existing saved graphs remain readable. */ conditionCases?: WorkflowConditionCase[] /** Executable cases for Condition nodes, matching the backend `data.cases` wire format. */ diff --git a/frontend/src/features/workflows/translations.ts b/frontend/src/features/workflows/translations.ts index 67fed10f7..700588b73 100644 --- a/frontend/src/features/workflows/translations.ts +++ b/frontend/src/features/workflows/translations.ts @@ -1,6 +1,8 @@ import { publishErrorTranslations } from '@/features/workflows/publish-error-translations' import { historyTranslations } from '@/features/workflows/history-translations' import { launchFieldTranslations } from '@/features/workflows/launch-field-translations' +import { aggregatorTranslations } from '@/features/workflows/aggregator-translations' +import { nodeTranslations } from '@/features/workflows/node-translations' import type { TranslationBundle } from '@/i18n/resource-bundle' /** @@ -24,27 +26,8 @@ export const workflowTranslations = { ...historyTranslations['zh-CN'], ...publishErrorTranslations['zh-CN'], ...launchFieldTranslations['zh-CN'], - // Node catalog: palette labels and descriptions for every executable kind. - 'workflows.node.start.label': '开始', - 'workflows.node.start.description': '定义工作流输入', - 'workflows.node.agent.label': 'Agent', - 'workflows.node.agent.description': '交给模型自主执行', - 'workflows.node.condition.label': '条件分支', - 'workflows.node.condition.description': '根据规则选择路径', - 'workflows.node.tool.label': '工具', - 'workflows.node.tool.description': '调用终端或插件', - 'workflows.node.junction.label': '汇合', - 'workflows.node.junction.description': '等待多个执行分支完成', - 'workflows.node.human.label': '人工确认', - 'workflows.node.human.description': '等待人工决策后继续', - 'workflows.node.loop.label': '循环', - 'workflows.node.loop.description': '重复执行直到满足条件', - 'workflows.node.iteration.label': '迭代', - 'workflows.node.iteration.description': '对数组逐项执行区域内节点', - 'workflows.node.subflow.label': '子流程', - 'workflows.node.subflow.description': '封装复杂业务步骤', - 'workflows.node.output.label': '输出', - 'workflows.node.output.description': '返回最终结果', + ...aggregatorTranslations['zh-CN'], + ...nodeTranslations['zh-CN'], // Loop container: the starter group a factory builds atomically. 'workflows.loop.roundStart': '轮次开始', @@ -449,26 +432,8 @@ export const workflowTranslations = { ...historyTranslations['en-US'], ...publishErrorTranslations['en-US'], ...launchFieldTranslations['en-US'], - 'workflows.node.start.label': 'Start', - 'workflows.node.start.description': 'Define workflow inputs', - 'workflows.node.agent.label': 'Agent', - 'workflows.node.agent.description': 'Delegate autonomous work to a model', - 'workflows.node.condition.label': 'Condition', - 'workflows.node.condition.description': 'Route execution based on rules', - 'workflows.node.tool.label': 'Tool', - 'workflows.node.tool.description': 'Call a terminal or plugin', - 'workflows.node.junction.label': 'Merge', - 'workflows.node.junction.description': 'Wait for multiple branches to complete', - 'workflows.node.human.label': 'Human confirmation', - 'workflows.node.human.description': 'Pause for a human decision', - 'workflows.node.loop.label': 'Loop', - 'workflows.node.loop.description': 'Repeat until the exit condition is met', - 'workflows.node.iteration.label': 'Iteration', - 'workflows.node.iteration.description': 'Run the region once per array element', - 'workflows.node.subflow.label': 'Subflow', - 'workflows.node.subflow.description': 'Encapsulate a complex business step', - 'workflows.node.output.label': 'Output', - 'workflows.node.output.description': 'Return the final result', + ...aggregatorTranslations['en-US'], + ...nodeTranslations['en-US'], // Loop container: the starter group a factory builds atomically. 'workflows.loop.roundStart': 'Round start', diff --git a/frontend/src/i18n/i18n.test.ts b/frontend/src/i18n/i18n.test.ts index 61ab7dc41..94b8447a7 100644 --- a/frontend/src/i18n/i18n.test.ts +++ b/frontend/src/i18n/i18n.test.ts @@ -79,6 +79,7 @@ describe('the shipped resources', () => { 'start', 'agent', 'condition', + 'aggregator', 'tool', 'junction', 'human', From 950805c1a42b8d137b681819d9b54b772d5e7dfd Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 14:12:24 +0800 Subject: [PATCH 12/27] feat(workflows): offer every condition operator the engine evaluates --- .../workflows/operator-translations.ts | 41 +++++++++++++++++++ .../workflows/runtime/capabilities.ts | 6 +++ .../workflows/runtime/condition-cases.test.ts | 6 +++ .../workflows/runtime/condition-cases.ts | 6 +++ .../src/features/workflows/translations.ts | 20 ++------- 5 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 frontend/src/features/workflows/operator-translations.ts diff --git a/frontend/src/features/workflows/operator-translations.ts b/frontend/src/features/workflows/operator-translations.ts new file mode 100644 index 000000000..848eea878 --- /dev/null +++ b/frontend/src/features/workflows/operator-translations.ts @@ -0,0 +1,41 @@ +/** + * Comparison operator labels for Condition nodes and Loop exit rules. + * + * The list is the consumer's vocabulary: the wire spellings are stable identifiers, so they live + * next to the capability table and stay out of the feature's main bundle, which is held to a line + * budget. `translations.ts` spreads both locales in. + */ +export const operatorTranslations = { + 'zh-CN': { + 'workflows.operator.equals': '等于', + 'workflows.operator.not-equals': '不等于', + 'workflows.operator.contains': '包含', + 'workflows.operator.not-contains': '不包含', + 'workflows.operator.starts-with': '开头是', + 'workflows.operator.ends-with': '结尾是', + 'workflows.operator.greater-than': '大于', + 'workflows.operator.greater-than-or-equal': '大于或等于', + 'workflows.operator.less-than': '小于', + 'workflows.operator.less-than-or-equal': '小于或等于', + 'workflows.operator.empty': '为空', + 'workflows.operator.not-empty': '不为空', + 'workflows.operator.exists': '已赋值', + 'workflows.operator.not-exists': '未赋值', + }, + 'en-US': { + 'workflows.operator.equals': 'equals', + 'workflows.operator.not-equals': 'does not equal', + 'workflows.operator.contains': 'contains', + 'workflows.operator.not-contains': 'does not contain', + 'workflows.operator.starts-with': 'starts with', + 'workflows.operator.ends-with': 'ends with', + 'workflows.operator.greater-than': 'is greater than', + 'workflows.operator.greater-than-or-equal': 'is greater than or equal to', + 'workflows.operator.less-than': 'is less than', + 'workflows.operator.less-than-or-equal': 'is less than or equal to', + 'workflows.operator.empty': 'is empty', + 'workflows.operator.not-empty': 'is not empty', + 'workflows.operator.exists': 'is assigned', + 'workflows.operator.not-exists': 'is not assigned', + }, +} diff --git a/frontend/src/features/workflows/runtime/capabilities.ts b/frontend/src/features/workflows/runtime/capabilities.ts index 76ad98bd2..b4529c7c2 100644 --- a/frontend/src/features/workflows/runtime/capabilities.ts +++ b/frontend/src/features/workflows/runtime/capabilities.ts @@ -142,10 +142,16 @@ export const DEMO_CONDITION_OPERATORS: WorkflowChoice[] = [ { value: 'not_equals', labelKey: 'workflows.operator.not-equals' }, { value: 'contains', labelKey: 'workflows.operator.contains' }, { value: 'not_contains', labelKey: 'workflows.operator.not-contains' }, + { value: 'starts_with', labelKey: 'workflows.operator.starts-with' }, + { value: 'ends_with', labelKey: 'workflows.operator.ends-with' }, { value: 'greater_than', labelKey: 'workflows.operator.greater-than' }, + { value: 'greater_than_or_equal', labelKey: 'workflows.operator.greater-than-or-equal' }, { value: 'less_than', labelKey: 'workflows.operator.less-than' }, + { value: 'less_than_or_equal', labelKey: 'workflows.operator.less-than-or-equal' }, { value: 'empty', labelKey: 'workflows.operator.empty' }, { value: 'not_empty', labelKey: 'workflows.operator.not-empty' }, + { value: 'exists', labelKey: 'workflows.operator.exists' }, + { value: 'not_exists', labelKey: 'workflows.operator.not-exists' }, ] /** diff --git a/frontend/src/features/workflows/runtime/condition-cases.test.ts b/frontend/src/features/workflows/runtime/condition-cases.test.ts index 42e3bb2d2..9299bbdb4 100644 --- a/frontend/src/features/workflows/runtime/condition-cases.test.ts +++ b/frontend/src/features/workflows/runtime/condition-cases.test.ts @@ -177,10 +177,16 @@ describe('condition operator catalog', () => { 'not_equals', 'contains', 'not_contains', + 'starts_with', + 'ends_with', 'greater_than', + 'greater_than_or_equal', 'less_than', + 'less_than_or_equal', 'empty', 'not_empty', + 'exists', + 'not_exists', ]) }) }) diff --git a/frontend/src/features/workflows/runtime/condition-cases.ts b/frontend/src/features/workflows/runtime/condition-cases.ts index 8d0425d6a..06df62bd3 100644 --- a/frontend/src/features/workflows/runtime/condition-cases.ts +++ b/frontend/src/features/workflows/runtime/condition-cases.ts @@ -12,10 +12,16 @@ export const WORKFLOW_CONDITION_OPERATORS: readonly string[] = [ 'not_equals', 'contains', 'not_contains', + 'starts_with', + 'ends_with', 'greater_than', + 'greater_than_or_equal', 'less_than', + 'less_than_or_equal', 'empty', 'not_empty', + 'exists', + 'not_exists', ] /** Operators whose meaning does not require a literal comparison value. */ diff --git a/frontend/src/features/workflows/translations.ts b/frontend/src/features/workflows/translations.ts index 15f47a10d..a4f2a4bff 100644 --- a/frontend/src/features/workflows/translations.ts +++ b/frontend/src/features/workflows/translations.ts @@ -4,6 +4,7 @@ import { launchFieldTranslations } from '@/features/workflows/launch-field-trans import { aggregatorTranslations } from '@/features/workflows/aggregator-translations' import { loopTranslations } from '@/features/workflows/loop-translations' import { nodeTranslations } from '@/features/workflows/node-translations' +import { operatorTranslations } from '@/features/workflows/operator-translations' import type { TranslationBundle } from '@/i18n/resource-bundle' /** @@ -30,6 +31,7 @@ export const workflowTranslations = { ...aggregatorTranslations['zh-CN'], ...loopTranslations['zh-CN'], ...nodeTranslations['zh-CN'], + ...operatorTranslations['zh-CN'], // Palette. 'workflows.palette.title': '节点', @@ -126,14 +128,6 @@ export const workflowTranslations = { 'workflows.role.documentation-agent': '文档', // Condition operators, referenced by the capability catalog. - 'workflows.operator.equals': '等于', - 'workflows.operator.not-equals': '不等于', - 'workflows.operator.contains': '包含', - 'workflows.operator.not-contains': '不包含', - 'workflows.operator.greater-than': '大于', - 'workflows.operator.less-than': '小于', - 'workflows.operator.empty': '为空', - 'workflows.operator.not-empty': '不为空', // Tool operations, referenced by the capability catalog. 'workflows.operation.run-command': '执行命令', @@ -427,6 +421,7 @@ export const workflowTranslations = { ...aggregatorTranslations['en-US'], ...loopTranslations['en-US'], ...nodeTranslations['en-US'], + ...operatorTranslations['en-US'], 'workflows.palette.title': 'Nodes', 'workflows.palette.dragHint': 'Drag onto the canvas or click to add at the center', @@ -521,15 +516,6 @@ export const workflowTranslations = { 'workflows.role.debugger': 'Debugger', 'workflows.role.documentation-agent': 'Documentation agent', - 'workflows.operator.equals': 'equals', - 'workflows.operator.not-equals': 'does not equal', - 'workflows.operator.contains': 'contains', - 'workflows.operator.not-contains': 'does not contain', - 'workflows.operator.greater-than': 'is greater than', - 'workflows.operator.less-than': 'is less than', - 'workflows.operator.empty': 'is empty', - 'workflows.operator.not-empty': 'is not empty', - 'workflows.operation.run-command': 'Run command', 'workflows.operation.read-file': 'Read file', 'workflows.operation.write-file': 'Write file', From 060df285113efa71f6d80b7457cd172cdbef046c Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 14:25:16 +0800 Subject: [PATCH 13/27] feat(workflows): reorder aggregator candidates in the inspector --- .../workflows/aggregator-translations.ts | 4 ++ .../workflow-aggregator-fields.test.tsx | 53 +++++++++++++------ .../editor/workflow-aggregator-fields.tsx | 33 +++++++++++- 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/frontend/src/features/workflows/aggregator-translations.ts b/frontend/src/features/workflows/aggregator-translations.ts index 13d6d01cd..ec37c553e 100644 --- a/frontend/src/features/workflows/aggregator-translations.ts +++ b/frontend/src/features/workflows/aggregator-translations.ts @@ -13,6 +13,8 @@ export const aggregatorTranslations = { 'workflows.aggregator.field.candidate': '候选', 'workflows.aggregator.addCandidate': '添加候选', 'workflows.aggregator.removeCandidate': '删除候选', + 'workflows.aggregator.moveCandidateUp': '上移候选', + 'workflows.aggregator.moveCandidateDown': '下移候选', 'workflows.aggregator.selectorPlaceholder': '节点.变量,例如 writer.output', }, 'en-US': { @@ -22,6 +24,8 @@ export const aggregatorTranslations = { 'workflows.aggregator.field.candidate': 'Candidate', 'workflows.aggregator.addCandidate': 'Add candidate', 'workflows.aggregator.removeCandidate': 'Remove candidate', + 'workflows.aggregator.moveCandidateUp': 'Move candidate up', + 'workflows.aggregator.moveCandidateDown': 'Move candidate down', 'workflows.aggregator.selectorPlaceholder': 'node.variable, for example writer.output', }, } diff --git a/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx index b3cb6caa6..192d261ba 100644 --- a/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { WorkflowNodeData } from '@/features/workflows/runtime/types' import { AggregatorNodeFields } from '@/features/workflows/editor/workflow-aggregator-fields' import { renderWithProviders } from '@/test/render' @@ -14,34 +14,57 @@ function aggregatorData(variables: string[][]): WorkflowNodeData { } } +/** + * Renders the panel and tracks the latest node it published. + * + * `published.data` is what a real editor would persist, so each assertion reads the shape the save + * path would write instead of a captured mock call. + */ +function renderPanel(variables: string[][]) { + const published = { data: aggregatorData(variables) } + const onChange = (next: WorkflowNodeData): void => { + published.data = next + } + const view = renderWithProviders( + , + ) + return { published, onChange, view } +} + describe('AggregatorNodeFields', () => { it('adds a candidate and writes its root selector', () => { - let data = aggregatorData([]) - const onChange = vi.fn<(next: WorkflowNodeData) => void>((next) => { - data = next - }) - const view = renderWithProviders() + const { published, onChange, view } = renderPanel([]) fireEvent.click(screen.getByRole('button', { name: '添加候选' })) - expect(data.aggregatorConfig?.variables).toEqual([[]]) + expect(published.data.aggregatorConfig?.variables).toEqual([[]]) - view.rerender() + view.rerender() fireEvent.change(screen.getByLabelText('候选 1'), { target: { value: 'writer.output' } }) - expect(data.aggregatorConfig?.variables).toEqual([['writer', 'output']]) + expect(published.data.aggregatorConfig?.variables).toEqual([['writer', 'output']]) + }) + + it('moves a candidate up to change the published priority', () => { + const { published } = renderPanel([ + ['a', 'output'], + ['b', 'output'], + ]) + + fireEvent.click(screen.getByRole('button', { name: '上移候选 2' })) + + expect(published.data.aggregatorConfig?.variables).toEqual([ + ['b', 'output'], + ['a', 'output'], + ]) }) it('keeps the declared order when a candidate is removed', () => { - let data = aggregatorData([ + const { published } = renderPanel([ ['a', 'output'], ['b', 'output'], ]) - const onChange = vi.fn<(next: WorkflowNodeData) => void>((next) => { - data = next - }) - renderWithProviders() fireEvent.click(screen.getByRole('button', { name: '删除候选 1' })) - expect(data.aggregatorConfig?.variables).toEqual([['b', 'output']]) + expect(published.data.aggregatorConfig?.variables).toEqual([['b', 'output']]) }) }) diff --git a/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx b/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx index eabe553bc..867ca949b 100644 --- a/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx +++ b/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Plus, Trash2 } from 'lucide-react' +import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' @@ -32,6 +32,17 @@ export function AggregatorNodeFields({ data, onChange }: AggregatorFieldsProps) const update = (variables: string[][]): void => { onChange({ ...data, aggregatorConfig: { variables } }) } + // Order is the published priority, so moving a candidate is a first-class edit rather than a + // re-sort the panel could infer. + const move = (from: number, to: number): void => { + const next = [...config.variables] + const moved = next.splice(from, 1)[0] + if (moved === undefined) { + return + } + next.splice(to, 0, moved) + update(next) + } return ( + + + )}

{t( loopConfig === undefined diff --git a/frontend/src/features/workflows/loop-translations.ts b/frontend/src/features/workflows/loop-translations.ts index eec6add2e..c7aff4e76 100644 --- a/frontend/src/features/workflows/loop-translations.ts +++ b/frontend/src/features/workflows/loop-translations.ts @@ -33,6 +33,7 @@ export const loopTranslations = { 'workflows.loop.removeVariable': '删除变量', 'workflows.loop.field.until': '结束条件', 'workflows.loop.addUntilRule': '添加条件', + 'workflows.loop.addExitNode': '添加早退节点', 'workflows.loop.removeUntilRule': '删除条件', 'workflows.loop.field.outputs': '输出', 'workflows.loop.field.outputName': '输出名', @@ -71,6 +72,7 @@ export const loopTranslations = { 'workflows.loop.removeVariable': 'Remove variable', 'workflows.loop.field.until': 'Exit condition', 'workflows.loop.addUntilRule': 'Add condition', + 'workflows.loop.addExitNode': 'Add early-exit node', 'workflows.loop.removeUntilRule': 'Remove condition', 'workflows.loop.field.outputs': 'Outputs', 'workflows.loop.field.outputName': 'Output name', From 32a5998e04da501e34a44fc14e8e377cb3ed6fe9 Mon Sep 17 00:00:00 2001 From: wanglongan587 Date: Sun, 25 Oct 2026 15:53:02 +0800 Subject: [PATCH 21/27] feat(workflows): pick aggregator candidates from the variable catalog --- .../workflow-aggregator-fields.test.tsx | 28 ++++++++-- .../editor/workflow-aggregator-fields.tsx | 28 +++++----- .../workflows/editor/workflow-inspector.tsx | 2 +- .../editor/workflow-iteration-fields.tsx | 51 ++----------------- .../editor/workflow-variable-select.tsx | 48 +++++++++++++++++ 5 files changed, 88 insertions(+), 69 deletions(-) diff --git a/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx index 192d261ba..7cf13b63c 100644 --- a/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-aggregator-fields.test.tsx @@ -1,9 +1,23 @@ import { fireEvent, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vitest' import type { WorkflowNodeData } from '@/features/workflows/runtime/types' +import type { WorkflowDecoratedVariable } from '@/features/workflows/runtime/variable-catalog' import { AggregatorNodeFields } from '@/features/workflows/editor/workflow-aggregator-fields' import { renderWithProviders } from '@/test/render' +/** The only upstream root the picker offers: the workflow's Start input. */ +const CATALOG: readonly WorkflowDecoratedVariable[] = [ + { + selector: ['start-1', 'input'], + sourceNodeId: 'start-1', + variableName: 'input', + valueType: 'string', + scope: 'node', + sourceNodeTitle: '开始 1', + }, +] + /** The node the panel edits; candidates are the only thing it reads. */ function aggregatorData(variables: string[][]): WorkflowNodeData { return { @@ -26,21 +40,25 @@ function renderPanel(variables: string[][]) { published.data = next } const view = renderWithProviders( - , + , ) return { published, onChange, view } } describe('AggregatorNodeFields', () => { - it('adds a candidate and writes its root selector', () => { + it('adds a candidate and picks its root from the catalog', async () => { + const user = userEvent.setup() const { published, onChange, view } = renderPanel([]) fireEvent.click(screen.getByRole('button', { name: '添加候选' })) expect(published.data.aggregatorConfig?.variables).toEqual([[]]) - view.rerender() - fireEvent.change(screen.getByLabelText('候选 1'), { target: { value: 'writer.output' } }) - expect(published.data.aggregatorConfig?.variables).toEqual([['writer', 'output']]) + view.rerender( + , + ) + await user.click(screen.getByLabelText('候选 1')) + await user.click(await screen.findByRole('option', { name: /input/ })) + expect(published.data.aggregatorConfig?.variables).toEqual([['start-1', 'input']]) }) it('moves a candidate up to change the published priority', () => { diff --git a/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx b/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx index 867ca949b..ac0c60518 100644 --- a/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx +++ b/frontend/src/features/workflows/editor/workflow-aggregator-fields.tsx @@ -1,18 +1,17 @@ import { useTranslation } from 'react-i18next' import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { InspectorField } from '@/features/workflows/editor/workflow-inspector-fields' -import { - selectorToText, - textToSelector, -} from '@/features/workflows/editor/workflow-variable-select' +import { WorkflowCatalogSelector } from '@/features/workflows/editor/workflow-variable-select' import type { WorkflowNodeData } from '@/features/workflows/runtime/types' +import type { WorkflowDecoratedVariable } from '@/features/workflows/runtime/variable-catalog' /** Props every Aggregator panel component takes: the node data and the writer for the whole node. */ export interface AggregatorFieldsProps { data: WorkflowNodeData onChange: (data: WorkflowNodeData) => void + /** Every root reachable from this node, offered as candidates. */ + catalog: readonly WorkflowDecoratedVariable[] } /** @@ -23,7 +22,7 @@ export interface AggregatorFieldsProps { * `node.root` text until the shared picker replaces it; the publish gate refuses an undeclared or * nested selector, so a typo is caught before the graph runs. */ -export function AggregatorNodeFields({ data, onChange }: AggregatorFieldsProps) { +export function AggregatorNodeFields({ data, onChange, catalog }: AggregatorFieldsProps) { const { t } = useTranslation() const config = data.aggregatorConfig if (config === undefined) { @@ -53,16 +52,15 @@ export function AggregatorNodeFields({ data, onChange }: AggregatorFieldsProps) {config.variables.map((selector, index) => (

{index + 1} - - update( - config.variables.map((current, i) => - i === index ? textToSelector(event.target.value) : current, - ), - ) + choices={catalog} + catalog={catalog} + ariaLabel={`${t('workflows.aggregator.field.candidate')} ${index + 1}`} + onPick={(picked) => + update(config.variables.map((current, i) => (i === index ? picked : current))) } />
- - + - onChange({ ...variable, feedback: textToSelector(event.target.value) }) - } + choices={choices} + catalog={choices} + ariaLabel={label('workflows.loop.field.variableFeedback')} + onPick={(picked) => onChange({ ...variable, feedback: picked })} /> ) @@ -311,10 +324,12 @@ function LoopVariableRow({ function LoopInitialValueInput({ index, variable, + choices, onChange, }: { index: number variable: WorkflowLoopVariable + choices: readonly WorkflowDecoratedVariable[] onChange: (variable: WorkflowLoopVariable) => void }) { const { t } = useTranslation() @@ -337,15 +352,15 @@ function LoopInitialValueInput({ ) } return ( - - onChange({ - ...variable, - initial: { kind: 'variable', selector: textToSelector(event.target.value) }, - }) + choices={choices} + catalog={choices} + ariaLabel={label} + onPick={(picked) => + onChange({ ...variable, initial: { kind: 'variable', selector: picked } }) } /> ) @@ -361,7 +376,13 @@ const LOOP_UNTIL_LOGIC_CHOICES = [ const LOOP_OPERATORS_WITHOUT_VALUE = new Set(['exists', 'not_exists', 'empty', 'not_empty']) /** Edits the round-end condition: how rules combine and the rules themselves. */ -function LoopUntilFields({ data, onChange }: Pick) { +function LoopUntilFields({ + data, + onChange, + choices, +}: Pick & { + choices: readonly WorkflowDecoratedVariable[] +}) { const { t } = useTranslation() const loopConfig = data.loopConfig if (loopConfig === undefined) { @@ -392,6 +413,7 @@ function LoopUntilFields({ data, onChange }: Pick update({ ...until, @@ -430,11 +452,13 @@ function LoopUntilFields({ data, onChange }: Pick void onRemove: () => void }) { @@ -454,13 +478,14 @@ function LoopUntilRow({ - - onChange({ ...condition, variableSelector: textToSelector(event.target.value) }) - } + choices={choices} + catalog={choices} + ariaLabel={t('workflows.condition.field.variable', { index: index + 1 })} + onPick={(picked) => onChange({ ...condition, variableSelector: picked })} /> Date: Sun, 25 Oct 2026 22:20:38 +0800 Subject: [PATCH 24/27] feat(workflows): author distinct condition branch connections --- docs/workflows/workflows.en.md | 7 ++ docs/workflows/workflows.md | 6 ++ .../features/workflows/editor/README.en.md | 17 ++++ .../src/features/workflows/editor/README.md | 16 ++++ .../editor/use-workflow-draft.test.ts | 79 +++++++++++++++++++ .../workflows/editor/use-workflow-draft.ts | 40 +++++++++- .../workflows/editor/workflow-node-card.tsx | 11 +-- .../workflow-node-source-handles.test.tsx | 62 +++++++++++++++ .../editor/workflow-node-source-handles.tsx | 70 ++++++++++++++++ .../features/workflows/node-translations.ts | 2 + .../features/workflows/runtime/README.en.md | 19 +++++ .../src/features/workflows/runtime/README.md | 17 ++++ .../runtime/connection-validation.test.ts | 26 ++++++ .../runtime/connection-validation.ts | 30 +++++-- 14 files changed, 384 insertions(+), 18 deletions(-) create mode 100644 frontend/src/features/workflows/editor/README.en.md create mode 100644 frontend/src/features/workflows/editor/README.md create mode 100644 frontend/src/features/workflows/editor/workflow-node-source-handles.test.tsx create mode 100644 frontend/src/features/workflows/editor/workflow-node-source-handles.tsx create mode 100644 frontend/src/features/workflows/runtime/README.en.md create mode 100644 frontend/src/features/workflows/runtime/README.md diff --git a/docs/workflows/workflows.en.md b/docs/workflows/workflows.en.md index 5aa4b7ab1..4372f4ebc 100644 --- a/docs/workflows/workflows.en.md +++ b/docs/workflows/workflows.en.md @@ -64,6 +64,13 @@ deliverable declaration `agentConfig.completion` (any non-null value) and any `a `3` — including `4`, the legacy `1`/`2`, and non-numbers: Cloud executes only version `3`, absent or `null` keeps the old behavior, and the refusal names the node and field instead of silently dropping the declaration. +## Condition branches + +Condition cards expose a port for each case and the implicit ELSE branch. Connections persist the +case id in `sourceHandle`. Different branches may join the same node; duplicate endpoint-and-port +pairs are rejected. Deleting a case removes its connections, and undo restores both. Legacy +unlabeled condition connections retain ELSE semantics; case ordering never renumbers existing edges. + ## Executor slots Agent nodes do not hard-code an Agent CLI or model ID in the graph; they reference an **executor slot**: diff --git a/docs/workflows/workflows.md b/docs/workflows/workflows.md index 65138dc45..298a2e87c 100644 --- a/docs/workflows/workflows.md +++ b/docs/workflows/workflows.md @@ -44,6 +44,12 @@ 同样在发布时拒绝的还有交互式节点的产出物声明 `agentConfig.completion`(任何非 null 值),以及 `agentConfig.schemaVersion` 不是 `3` 的值 (`4`、旧的 `1`/`2`、非数字):Cloud 只执行版本 `3`,缺省或 `null` 保持旧行为,其余非 `3` 的值都拒绝,拒绝时点名节点与字段,不静默剥离声明。 +## 条件分支 + +Condition 卡片为每个 case 和隐式 ELSE 显示独立端口,连线持久化对应 case id 的 `sourceHandle`。 +不同分支可以汇入同一节点;同一对端点和端口的重复连线被拒绝。删除分支会删除该分支连线,撤销恢复二者。 +旧图未标端口的 condition 连线继续表示 ELSE;新增或调整分支不会把既有连线按分支顺序重新编号。 + ## 执行器槽位 Agent 节点不在图里写死 Agent CLI 或模型 ID,而是引用一个**执行器槽位**: diff --git a/frontend/src/features/workflows/editor/README.en.md b/frontend/src/features/workflows/editor/README.en.md new file mode 100644 index 000000000..c0a099013 --- /dev/null +++ b/frontend/src/features/workflows/editor/README.en.md @@ -0,0 +1,17 @@ +# Workflow editor + +Owns the canvas, inspectors and editable draft. Runtime owns policy and variable catalogs; +workflow boundary adapters own HTTP. + +| Files | Responsibility | +|---|---| +| `workflow-node-card.tsx`, `workflow-node-source-handles.tsx` | Cards and IF/ELIF/ELSE ports registered by case id | +| `use-workflow-draft.ts` | Sole draft writer, edge identity, removed-case cleanup, saves and history restoration | +| `workflow-inspector.tsx`, `workflow-*-fields.tsx` | Compose per-kind inspectors | +| `workflow-variable-select.tsx` | Display/select consumer-visible variables without rewriting persisted selectors | +| `workflow-canvas.tsx`, `iteration-*.tsx` | React Flow composition and iteration-region interactions | +| Other `workflow-*` files | Editor page, history, import, publication and launch actions | + +Depends on runtime, UI components, i18n and workflow API adapters; workflow pages consume this module. +Case ids and connection ports persist together; different ports may join one target. Tests exercise real React Flow ports, controlled inspectors and draft save/reload behavior; network +tests replace the shared test boundary. diff --git a/frontend/src/features/workflows/editor/README.md b/frontend/src/features/workflows/editor/README.md new file mode 100644 index 000000000..7b5b473b7 --- /dev/null +++ b/frontend/src/features/workflows/editor/README.md @@ -0,0 +1,16 @@ +# 工作流编辑器 + +负责画布、检查器与草稿编辑;运行策略和变量目录由 runtime 提供,HTTP 由工作流边界适配器负责。 + +| 文件 | 职责 | +|---|---| +| `workflow-node-card.tsx`、`workflow-node-source-handles.tsx` | 卡片和按 case id 注册的 IF/ELIF/ELSE 端口 | +| `use-workflow-draft.ts` | 草稿唯一写入者、连线身份、分支删除清理、保存与历史恢复 | +| `workflow-inspector.tsx`、`workflow-*-fields.tsx` | 按节点类型组合检查器 | +| `workflow-variable-select.tsx` | 显示和选择消费者可见的变量,不改变持久化 selector | +| `workflow-canvas.tsx`、`iteration-*.tsx` | React Flow 装配与迭代区域交互 | +| 其余 `workflow-*` 文件 | 编辑页、历史及导入/发布/启动等编辑动作 | + +依赖 runtime、UI 组件、i18n 与工作流 API 适配器;工作流页面消费本模块。分支 id 与连线端口共同持久化, +不同端口可以汇入同一节点。 +测试通过真实 React Flow 端口、受控检查器及草稿保存重载验证行为,网络测试使用共享测试边界。 diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.test.ts b/frontend/src/features/workflows/editor/use-workflow-draft.test.ts index c3533f96b..6a3796f56 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.test.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.test.ts @@ -83,6 +83,85 @@ function requireNode(nodes: WorkflowCanvasNode[], id: string): WorkflowCanvasNod } describe('useWorkflowDraft', () => { + it('persists distinct IF, ELIF and ELSE connections to one target across reloads', () => { + const condition: WorkflowCanvasNode = { + id: 'condition-1', + type: 'workflow', + position: { x: 0, y: 0 }, + data: { + kind: 'condition', + title: '条件', + description: '', + cases: [ + { id: 'case-1', conditions: [] }, + { id: 'case-2', conditions: [] }, + ], + }, + } + const { result } = setup(fixtureWithGraph([condition, outputNode()])) + for (const sourceHandle of ['case-1', 'case-2', 'else', 'case-1']) { + act(() => + result.current.connect({ + source: 'condition-1', + target: 'output-1', + sourceHandle, + targetHandle: null, + }), + ) + } + const before = result.current.edges + expect(before.map((edge) => edge.sourceHandle)).toEqual(['case-1', 'case-2', 'else']) + expect(new Set(before.map((edge) => edge.id)).size).toBe(3) + const saved = workflowFixture({ graph: result.current.snapshot({ x: 0, y: 0, zoom: 1 }) }) + const reloaded = setup(saved) + expect(reloaded.result.current.edges).toEqual(before) + const history = result.current.captureContent() + act(() => + result.current.updateNodeData('condition-1', { + ...condition.data, + cases: [{ id: 'case-1', conditions: [] }], + }), + ) + expect(result.current.edges.map((edge) => edge.sourceHandle)).toEqual(['case-1', 'else']) + act(() => result.current.restoreContent(history)) + expect(result.current.captureContent()).toEqual(history) + }) + + it('treats a legacy unlabeled Condition connection as ELSE when reconnecting', () => { + const condition: WorkflowCanvasNode = { + id: 'condition-1', + type: 'workflow', + position: { x: 0, y: 0 }, + data: { kind: 'condition', title: '条件', description: '' }, + } + const { result } = setup( + workflowFixture({ + graph: { + nodes: [condition, outputNode()], + edges: [{ id: 'old', source: condition.id, target: 'output-1' }], + }, + }), + ) + act(() => + result.current.connect({ + source: condition.id, + target: 'output-1', + sourceHandle: 'else', + targetHandle: null, + }), + ) + expect(result.current.edges).toHaveLength(1) + act(() => + result.current.connect({ + source: condition.id, + target: 'output-1', + sourceHandle: 'case-1', + targetHandle: null, + }), + ) + expect(result.current.edges.map((edge) => edge.sourceHandle)).toEqual(['else', 'case-1']) + }) + it('adds a Loop as one atomic group with its starter body', () => { // A bare Loop saves but never publishes: the body needs a Start. The palette entry therefore // emits the container, its Start, its starter Agent and the internal edge together. diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.ts b/frontend/src/features/workflows/editor/use-workflow-draft.ts index 201db7b98..7239f8162 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.ts @@ -8,6 +8,7 @@ import { type XYPosition, } from '@xyflow/react' import type { Workflow as CloudWorkflow } from '@/api/generated.schemas' +import { resolveConditionCases } from '@/features/workflows/runtime/condition-cases' import { parseWorkflowGraphValue, serializeWorkflowGraphValue, @@ -347,15 +348,33 @@ function appendLoopExitNode( /** Adds an edge, unless the gesture would create a self-loop or a duplicate. */ function appendConnection(current: DraftState, connection: Connection): DraftState { - if (!isValidWorkflowConnection(connection, current.edges)) { + const sourceNode = current.nodes.find((node) => node.id === connection.source) + const sourceHandle = + connection.sourceHandle ?? (sourceNode?.data.kind === 'condition' ? 'else' : null) + const candidate = { ...connection, sourceHandle } + // An older unlabeled Condition edge means ELSE. Canonicalize that source's existing edges so + // reconnecting its explicit ELSE port cannot create a second edge for the same branch. + const edges = current.edges.map((edge) => + sourceNode?.data.kind === 'condition' && edge.source === connection.source + ? { ...edge, sourceHandle: edge.sourceHandle ?? 'else' } + : edge, + ) + if (!isValidWorkflowConnection(candidate, edges)) { return current } - const { source, target } = connection + const { source, target, targetHandle } = candidate return { ...current, edges: [ - ...current.edges, - { id: workflowEdgeId(source, target), source, target, type: WORKFLOW_FLOW_EDGE_TYPE }, + ...edges, + { + id: workflowEdgeId(source, target, candidate), + source, + target, + ...(sourceHandle === null ? {} : { sourceHandle }), + ...(targetHandle === null ? {} : { targetHandle }), + type: WORKFLOW_FLOW_EDGE_TYPE, + }, ], } } @@ -399,9 +418,22 @@ function toggleCollapsed(current: DraftState, iterationId: string): DraftState { /** Replaces one node's data with an edited copy. */ function setNodeData(current: DraftState, nodeId: string, data: WorkflowNodeData): DraftState { + const previous = current.nodes.find((node) => node.id === nodeId) + const removedBranches = new Set() + if (previous?.data.kind === 'condition' && data.kind === 'condition') { + const remaining = new Set(resolveConditionCases(data).map((branch) => branch.id)) + for (const branch of resolveConditionCases(previous.data)) { + if (!remaining.has(branch.id)) { + removedBranches.add(branch.id) + } + } + } return { ...current, nodes: current.nodes.map((node) => (node.id === nodeId ? { ...node, data } : node)), + edges: current.edges.filter( + (edge) => edge.source !== nodeId || !removedBranches.has(edge.sourceHandle ?? 'else'), + ), } } diff --git a/frontend/src/features/workflows/editor/workflow-node-card.tsx b/frontend/src/features/workflows/editor/workflow-node-card.tsx index 60b1fbe24..645146728 100644 --- a/frontend/src/features/workflows/editor/workflow-node-card.tsx +++ b/frontend/src/features/workflows/editor/workflow-node-card.tsx @@ -11,6 +11,7 @@ import { import { NodeParameterSummary } from '@/features/workflows/editor/node-parameter-summary' import { IterationNodeFrame } from '@/features/workflows/editor/iteration-node-frame' import { NodeIdentity } from '@/features/workflows/editor/node-identity' +import { WorkflowNodeSourceHandles } from '@/features/workflows/editor/workflow-node-source-handles' import { useWorkflowTranslator } from '@/features/workflows/editor/use-workflow-translator' import { workflowNodeMetadata } from '@/features/workflows/editor/node-metadata' import type { WorkflowCanvasNode } from '@/features/workflows/editor/canvas-types' @@ -86,15 +87,7 @@ export const WorkflowNodeCard = memo(function WorkflowNodeCard({ } /> - {data.kind !== 'loopExit' && ( - - )} + ) }) diff --git a/frontend/src/features/workflows/editor/workflow-node-source-handles.test.tsx b/frontend/src/features/workflows/editor/workflow-node-source-handles.test.tsx new file mode 100644 index 000000000..b319309a0 --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-node-source-handles.test.tsx @@ -0,0 +1,62 @@ +import { screen } from '@testing-library/react' +import { ReactFlow } from '@xyflow/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { WorkflowNodeCard } from '@/features/workflows/editor/workflow-node-card' +import type { WorkflowCanvasNode } from '@/features/workflows/editor/canvas-types' +import { renderWithProviders } from '@/test/render' +import { stubViewportGeometry } from '@/test/viewport' + +const NODE_TYPES = { workflow: WorkflowNodeCard } + +/** The real React Flow canvas registers each rendered port under this authored node. */ +function canvas(nodes: WorkflowCanvasNode[]) { + return +} + +/** A node whose case ids intentionally differ from their displayed ordinal labels. */ +function condition(ids: string[]): WorkflowCanvasNode { + return { + id: 'condition-1', + type: 'workflow', + position: { x: 0, y: 0 }, + data: { + kind: 'condition', + title: '条件 1', + description: '', + cases: ids.map((id) => ({ id, conditions: [] })), + }, + } +} + +beforeEach(() => stubViewportGeometry()) + +describe('Condition canvas ports', () => { + it('renders the actual case ids and implicit ELSE, refreshing ports after a case edit', async () => { + const view = renderWithProviders(canvas([condition(['approved', 'retry'])])) + const first = await screen.findByLabelText('从条件 1的 IF 分支连接') + expect(first).toHaveAttribute('data-handleid', 'approved') + expect(screen.getByLabelText('从条件 1的 ELIF 1 分支连接')).toHaveAttribute( + 'data-handleid', + 'retry', + ) + expect(screen.getByLabelText('从条件 1的 ELSE 分支连接')).toHaveAttribute( + 'data-handleid', + 'else', + ) + view.rerender(canvas([condition(['approved', 'fallback', 'stop'])])) + expect(await screen.findByLabelText('从条件 1的 ELIF 2 分支连接')).toHaveAttribute( + 'data-handleid', + 'stop', + ) + expect(document.querySelector('[data-handleid="retry"]')).toBeNull() + }) + + it('keeps ELSE available when a stored Condition has no cases', async () => { + renderWithProviders(canvas([condition([])])) + expect(await screen.findByLabelText('从条件 1的 ELSE 分支连接')).toHaveAttribute( + 'data-handleid', + 'else', + ) + expect(screen.queryByText('IF')).toBeNull() + }) +}) diff --git a/frontend/src/features/workflows/editor/workflow-node-source-handles.tsx b/frontend/src/features/workflows/editor/workflow-node-source-handles.tsx new file mode 100644 index 000000000..caf6fcd0a --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-node-source-handles.tsx @@ -0,0 +1,70 @@ +import { useLayoutEffect, useRef } from 'react' +import { Handle, Position, useUpdateNodeInternals } from '@xyflow/react' +import { useTranslation } from 'react-i18next' +import { resolveConditionCases } from '@/features/workflows/runtime/condition-cases' +import { WORKFLOW_NODE_ANCHOR_Y } from '@/features/workflows/runtime/node-geometry' +import type { WorkflowNodeData } from '@/features/workflows/runtime/types' + +/** + * Condition ports retain their case ids through connection gestures. The implicit ELSE has its + * own stable port; other executable nodes keep their ordinary source and loopExit has none. + */ +export function WorkflowNodeSourceHandles({ + nodeId, + data, +}: { + nodeId: string + data: WorkflowNodeData +}) { + const { t } = useTranslation() + const updateNodeInternals = useUpdateNodeInternals() + const branches = resolveConditionCases(data).map((branch, index) => ({ + id: branch.id, + label: index === 0 ? 'IF' : `ELIF ${index}`, + })) + const portIdentity = `${nodeId}:${data.kind === 'condition' ? JSON.stringify(branches) : data.kind}` + const measuredIdentity = useRef(undefined) + useLayoutEffect(() => { + // React Flow measures handles separately from node data; case edits must invalidate that + // registry after the new port DOM exists or connections retain the removed branch positions. + if (measuredIdentity.current !== portIdentity) { + measuredIdentity.current = portIdentity + updateNodeInternals(nodeId) + } + }, [nodeId, portIdentity, updateNodeInternals]) + + if (data.kind === 'loopExit') { + return null + } + if (data.kind !== 'condition') { + return ( + + ) + } + return ( +
+ {[...branches, { id: 'else', label: 'ELSE' }].map((branch) => ( +
+ {branch.label} + +
+ ))} +
+ ) +} diff --git a/frontend/src/features/workflows/node-translations.ts b/frontend/src/features/workflows/node-translations.ts index 2ee76faac..b3c47cd2d 100644 --- a/frontend/src/features/workflows/node-translations.ts +++ b/frontend/src/features/workflows/node-translations.ts @@ -8,6 +8,7 @@ */ export const nodeTranslations = { 'zh-CN': { + 'workflows.condition.connectFromBranch': '从{{name}}的 {{branch}} 分支连接', // Node catalog: palette labels and descriptions for every executable kind. 'workflows.node.start.label': '开始', 'workflows.node.start.description': '定义工作流输入', @@ -31,6 +32,7 @@ export const nodeTranslations = { 'workflows.node.output.description': '返回最终结果', }, 'en-US': { + 'workflows.condition.connectFromBranch': 'Connect from {{branch}} of {{name}}', 'workflows.node.start.label': 'Start', 'workflows.node.start.description': 'Define workflow inputs', 'workflows.node.agent.label': 'Agent', diff --git a/frontend/src/features/workflows/runtime/README.en.md b/frontend/src/features/workflows/runtime/README.en.md new file mode 100644 index 000000000..60dde6c7d --- /dev/null +++ b/frontend/src/features/workflows/runtime/README.en.md @@ -0,0 +1,19 @@ +# Workflow runtime definitions + +Owns framework-free graph definitions, variable catalogs, geometry and pure editing policy. +It neither sends HTTP requests nor stores server state. + +| Files | Responsibility | +|---|---| +| `types.ts`, `definition.ts`, `graph-codec.ts`, `graph-import.ts` | Contracts, structural validation and field-preserving codecs | +| `connection-validation.ts` | Endpoint/port deduplication and stable edge ids; preserve ordinary unhandled edge ids | +| `condition-cases.ts` | Compatible branch configuration and canonical wire operators | +| `node-catalog.ts`, `node-factory.ts`, `capabilities.ts` | Authorable nodes, defaults and capability catalogs | +| `variable-*.ts`, `global-variables.ts` | Consumer-visible variables and type derivation | +| `layout.ts`, `node-geometry.ts`, `container-layout.ts`, `iteration-*.ts` | Layout, region membership and pure graph transforms | +| Other formatting, identity and schema files | Pure run-display derivations | + +Depends on basic utilities and static contracts; consumed by editors, run pages and plugin imports. +Distinct branch edges between the same nodes must not overwrite each other. Null and omitted ports +are equivalent; the node-aware draft layer normalizes legacy Condition ELSE connections. Pure tests +cover boundaries and codec tests verify persisted ports and Output bindings across reloads. diff --git a/frontend/src/features/workflows/runtime/README.md b/frontend/src/features/workflows/runtime/README.md new file mode 100644 index 000000000..08ceadf93 --- /dev/null +++ b/frontend/src/features/workflows/runtime/README.md @@ -0,0 +1,17 @@ +# 工作流运行定义 + +维护不依赖 React 的图定义、变量目录、几何与纯编辑规则;不发 HTTP 请求、不持有服务端状态。 + +| 文件 | 职责 | +|---|---| +| `types.ts`、`definition.ts`、`graph-codec.ts`、`graph-import.ts` | 图契约、结构检查与保留文档字段的编解码 | +| `connection-validation.ts` | 端点/端口级去重与稳定连线 id;普通无端口边保留原 id 形状 | +| `condition-cases.ts` | 兼容分支配置与规范 wire 算子 | +| `node-catalog.ts`、`node-factory.ts`、`capabilities.ts` | 可创作节点、初值和能力目录 | +| `variable-*.ts`、`global-variables.ts` | 消费者可见变量和类型推导 | +| `layout.ts`、`node-geometry.ts`、`container-layout.ts`、`iteration-*.ts` | 布局、区域成员与纯图变换 | +| 其他格式化、身份与 schema 文件 | 运行展示的纯派生规则 | + +依赖基础工具和静态契约;编辑器、运行页面和插件导入消费本模块。相同节点对的不同分支连线不能互相覆盖, +空端口与缺省端口等价;condition 的历史 ELSE 归一由知道节点类型的草稿层完成。 +纯函数测试覆盖边界,编解码测试核对保存重载后端口和 Output 绑定仍存在。 diff --git a/frontend/src/features/workflows/runtime/connection-validation.test.ts b/frontend/src/features/workflows/runtime/connection-validation.test.ts index 8708a0299..b1a760d3a 100644 --- a/frontend/src/features/workflows/runtime/connection-validation.test.ts +++ b/frontend/src/features/workflows/runtime/connection-validation.test.ts @@ -37,6 +37,22 @@ describe('isValidWorkflowConnection', () => { expect(isValidWorkflowConnection({ source: 'a', target: 'b' }, edges)).toBe(true) }) + + it('allows separate condition branches to join the same target, but rejects the same port twice', () => { + const edges = [{ source: 'condition', target: 'join', sourceHandle: 'case-1' }] + expect( + isValidWorkflowConnection( + { source: 'condition', target: 'join', sourceHandle: 'else' }, + edges, + ), + ).toBe(true) + expect( + isValidWorkflowConnection( + { source: 'condition', target: 'join', sourceHandle: 'case-1' }, + edges, + ), + ).toBe(false) + }) }) describe('workflowEdgeId', () => { @@ -47,4 +63,14 @@ describe('workflowEdgeId', () => { it('distinguishes the two directions between the same pair', () => { expect(workflowEdgeId('a', 'b')).not.toBe(workflowEdgeId('b', 'a')) }) + + it('gives parallel branch connections distinct ids that survive serialization', () => { + const first = workflowEdgeId('condition', 'join', { sourceHandle: 'case-1' }) + const otherwise = workflowEdgeId('condition', 'join', { sourceHandle: 'else' }) + expect(first).not.toBe(otherwise) + expect(first).toBe(workflowEdgeId('condition', 'join', { sourceHandle: 'case-1' })) + expect(workflowEdgeId('condition', 'join', { sourceHandle: 'a/b' })).not.toBe( + workflowEdgeId('condition', 'join', { sourceHandle: 'a%2Fb' }), + ) + }) }) diff --git a/frontend/src/features/workflows/runtime/connection-validation.ts b/frontend/src/features/workflows/runtime/connection-validation.ts index 341c262b8..3e467f590 100644 --- a/frontend/src/features/workflows/runtime/connection-validation.ts +++ b/frontend/src/features/workflows/runtime/connection-validation.ts @@ -1,7 +1,13 @@ import type { WorkflowLayoutEdge } from '@/features/workflows/runtime/layout' +/** Port identities distinguish parallel branch edges between the same nodes. */ +interface WorkflowConnectionHandles { + sourceHandle?: string | null + targetHandle?: string | null +} + /** The endpoint pair a candidate connection would create. */ -export interface WorkflowConnectionCandidate { +export interface WorkflowConnectionCandidate extends WorkflowConnectionHandles { source: string | null target: string | null } @@ -22,13 +28,19 @@ export interface WorkflowConnectionCandidate { */ export function isValidWorkflowConnection( candidate: WorkflowConnectionCandidate, - edges: readonly WorkflowLayoutEdge[], + edges: readonly (WorkflowLayoutEdge & WorkflowConnectionHandles)[], ): boolean { const { source, target } = candidate if (source === null || target === null || source === target) { return false } - return !edges.some((edge) => edge.source === source && edge.target === target) + return !edges.some( + (edge) => + edge.source === source && + edge.target === target && + (edge.sourceHandle ?? null) === (candidate.sourceHandle ?? null) && + (edge.targetHandle ?? null) === (candidate.targetHandle ?? null), + ) } /** @@ -42,6 +54,14 @@ export function isValidWorkflowConnection( * @param target - Target node id. * @returns The edge id. */ -export function workflowEdgeId(source: string, target: string): string { - return `e-${source}-${target}` +export function workflowEdgeId( + source: string, + target: string, + handles: WorkflowConnectionHandles = {}, +): string { + const base = `e-${source}-${target}` + if (handles.sourceHandle == null && handles.targetHandle == null) { + return base + } + return `${base}-${encodeURIComponent(JSON.stringify([handles.sourceHandle ?? null, handles.targetHandle ?? null]))}` } From b181f3e61ef7b8bd4d1f60495bfe3109c6af6367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:23:26 +0800 Subject: [PATCH 25/27] feat(workflows): edit and execute named output bindings --- docs/workflows/workflows.en.md | 9 +- docs/workflows/workflows.md | 7 +- .../features/workflows/editor/README.en.md | 6 +- .../src/features/workflows/editor/README.md | 4 +- .../workflows/editor/workflow-inspector.tsx | 5 +- .../editor/workflow-output-fields.test.tsx | 100 ++++++++++++++++++ .../editor/workflow-output-fields.tsx | 77 ++++++++++++++ .../features/workflows/node-translations.ts | 18 ++++ internal/core/workflow_graph.go | 2 + internal/core/workflow_output.go | 88 +++++++++++++++ .../core/workflow_output_scheduler_db_test.go | 41 +++++++ internal/core/workflow_output_test.go | 83 +++++++++++++++ internal/core/workflow_scheduler.go | 13 ++- 13 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 frontend/src/features/workflows/editor/workflow-output-fields.test.tsx create mode 100644 frontend/src/features/workflows/editor/workflow-output-fields.tsx create mode 100644 internal/core/workflow_output.go create mode 100644 internal/core/workflow_output_scheduler_db_test.go create mode 100644 internal/core/workflow_output_test.go diff --git a/docs/workflows/workflows.en.md b/docs/workflows/workflows.en.md index 4372f4ebc..955cab32d 100644 --- a/docs/workflows/workflows.en.md +++ b/docs/workflows/workflows.en.md @@ -64,13 +64,20 @@ deliverable declaration `agentConfig.completion` (any non-null value) and any `a `3` — including `4`, the legacy `1`/`2`, and non-numbers: Cloud executes only version `3`, absent or `null` keeps the old behavior, and the refusal names the node and field instead of silently dropping the declaration. -## Condition branches +## Condition branches and named outputs Condition cards expose a port for each case and the implicit ELSE branch. Connections persist the case id in `sourceHandle`. Different branches may join the same node; duplicate endpoint-and-port pairs are rejected. Deleting a case removes its connections, and undo restores both. Legacy unlabeled condition connections retain ELSE semantics; case ordering never renumbers existing edges. +The Output inspector adds, names and removes `data.outputs` bindings, with sources selected from +the consumer's visible variable catalog. Execution resolves all bindings against one committed +snapshot and stores the named result on the output node. No bindings retain an empty object. +Explicit null, false, zero and empty strings remain assigned values. An unassigned binding or an +invalid path fails the node without publishing partial results. Publication rejects malformed +bindings, empty or duplicate names, invalid selectors and undeclared roots, with node/field locations. + ## Executor slots Agent nodes do not hard-code an Agent CLI or model ID in the graph; they reference an **executor slot**: diff --git a/docs/workflows/workflows.md b/docs/workflows/workflows.md index 298a2e87c..17cb7aae2 100644 --- a/docs/workflows/workflows.md +++ b/docs/workflows/workflows.md @@ -44,12 +44,17 @@ 同样在发布时拒绝的还有交互式节点的产出物声明 `agentConfig.completion`(任何非 null 值),以及 `agentConfig.schemaVersion` 不是 `3` 的值 (`4`、旧的 `1`/`2`、非数字):Cloud 只执行版本 `3`,缺省或 `null` 保持旧行为,其余非 `3` 的值都拒绝,拒绝时点名节点与字段,不静默剥离声明。 -## 条件分支 +## 条件分支与命名输出 Condition 卡片为每个 case 和隐式 ELSE 显示独立端口,连线持久化对应 case id 的 `sourceHandle`。 不同分支可以汇入同一节点;同一对端点和端口的重复连线被拒绝。删除分支会删除该分支连线,撤销恢复二者。 旧图未标端口的 condition 连线继续表示 ELSE;新增或调整分支不会把既有连线按分支顺序重新编号。 +Output 检查器可添加、命名和删除 `data.outputs` 绑定,从该节点可见的变量目录选择来源。 +运行时在同一个已提交快照上解析所有绑定,结果按声明的名称写入 output 节点状态;没有绑定时保持空对象。 +显式 null、false、0 和空字符串均为有效产物。任何绑定未赋值或路径无效都会使节点失败,不返回部分成功结果。 +发布检查拒绝非法绑定结构、空名称、重名、无效选择器和未声明的变量根,并定位到节点和字段。 + ## 执行器槽位 Agent 节点不在图里写死 Agent CLI 或模型 ID,而是引用一个**执行器槽位**: diff --git a/frontend/src/features/workflows/editor/README.en.md b/frontend/src/features/workflows/editor/README.en.md index c0a099013..9d50c1cdc 100644 --- a/frontend/src/features/workflows/editor/README.en.md +++ b/frontend/src/features/workflows/editor/README.en.md @@ -7,11 +7,13 @@ workflow boundary adapters own HTTP. |---|---| | `workflow-node-card.tsx`, `workflow-node-source-handles.tsx` | Cards and IF/ELIF/ELSE ports registered by case id | | `use-workflow-draft.ts` | Sole draft writer, edge identity, removed-case cleanup, saves and history restoration | -| `workflow-inspector.tsx`, `workflow-*-fields.tsx` | Compose per-kind inspectors | +| `workflow-inspector.tsx`, `workflow-*-fields.tsx` | Compose per-kind inspectors; Output edits named result bindings | | `workflow-variable-select.tsx` | Display/select consumer-visible variables without rewriting persisted selectors | | `workflow-canvas.tsx`, `iteration-*.tsx` | React Flow composition and iteration-region interactions | | Other `workflow-*` files | Editor page, history, import, publication and launch actions | Depends on runtime, UI components, i18n and workflow API adapters; workflow pages consume this module. -Case ids and connection ports persist together; different ports may join one target. Tests exercise real React Flow ports, controlled inspectors and draft save/reload behavior; network +Case ids and connection ports persist together; different ports may join one target. Output retains +authored bindings and incomplete draft rows, leaving publication validation to reject invalid data. +Tests exercise real React Flow ports, controlled inspectors and draft save/reload behavior; network tests replace the shared test boundary. diff --git a/frontend/src/features/workflows/editor/README.md b/frontend/src/features/workflows/editor/README.md index 7b5b473b7..9bf60ee56 100644 --- a/frontend/src/features/workflows/editor/README.md +++ b/frontend/src/features/workflows/editor/README.md @@ -6,11 +6,11 @@ |---|---| | `workflow-node-card.tsx`、`workflow-node-source-handles.tsx` | 卡片和按 case id 注册的 IF/ELIF/ELSE 端口 | | `use-workflow-draft.ts` | 草稿唯一写入者、连线身份、分支删除清理、保存与历史恢复 | -| `workflow-inspector.tsx`、`workflow-*-fields.tsx` | 按节点类型组合检查器 | +| `workflow-inspector.tsx`、`workflow-*-fields.tsx` | 按节点类型组合检查器,Output 编辑命名结果绑定 | | `workflow-variable-select.tsx` | 显示和选择消费者可见的变量,不改变持久化 selector | | `workflow-canvas.tsx`、`iteration-*.tsx` | React Flow 装配与迭代区域交互 | | 其余 `workflow-*` 文件 | 编辑页、历史及导入/发布/启动等编辑动作 | 依赖 runtime、UI 组件、i18n 与工作流 API 适配器;工作流页面消费本模块。分支 id 与连线端口共同持久化, -不同端口可以汇入同一节点。 +不同端口可以汇入同一节点。Output 保存原始命名绑定,未完成的行保留为草稿,发布校验负责拒绝。 测试通过真实 React Flow 端口、受控检查器及草稿保存重载验证行为,网络测试使用共享测试边界。 diff --git a/frontend/src/features/workflows/editor/workflow-inspector.tsx b/frontend/src/features/workflows/editor/workflow-inspector.tsx index 8a753f6ca..b740644bd 100644 --- a/frontend/src/features/workflows/editor/workflow-inspector.tsx +++ b/frontend/src/features/workflows/editor/workflow-inspector.tsx @@ -18,6 +18,7 @@ import { UnconfigurableNotice, } from '@/features/workflows/editor/workflow-inspector-fields' import { ConditionNodeFields } from '@/features/workflows/editor/workflow-condition-fields' +import { OutputNodeFields } from '@/features/workflows/editor/workflow-output-fields' import { HumanNodeFields, JunctionNodeFields, @@ -223,9 +224,7 @@ function NodeKindFields({ return } if (data.kind === 'output') { - // An Output node is fully described by its inbound edges until named result - // bindings arrive with the structured-output editor. - return null + return } return } diff --git a/frontend/src/features/workflows/editor/workflow-output-fields.test.tsx b/frontend/src/features/workflows/editor/workflow-output-fields.test.tsx new file mode 100644 index 000000000..3fd1fd66c --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-output-fields.test.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { fireEvent, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { OutputNodeFields } from '@/features/workflows/editor/workflow-output-fields' +import { useWorkflowDraft } from '@/features/workflows/editor/use-workflow-draft' +import type { WorkflowNodeData } from '@/features/workflows/runtime/types' +import type { WorkflowDecoratedVariable } from '@/features/workflows/runtime/variable-catalog' +import { workflowFixture } from '@/test/cloud-handlers' +import { renderWithProviders } from '@/test/render' + +const CATALOG: readonly WorkflowDecoratedVariable[] = [ + { + selector: ['start', 'input'], + sourceNodeId: 'start', + variableName: 'input', + valueType: 'string', + scope: 'node', + sourceNodeTitle: '开始', + }, +] +const EMPTY: WorkflowNodeData = { kind: 'output', title: '输出', description: '' } + +/** Exercises the real draft save/reload boundary after every controlled panel edit. */ +function Editor({ onSave }: { onSave: (graph: Record) => void }) { + const draft = useWorkflowDraft( + workflowFixture({ + graph: { + nodes: [ + { + id: 'out', + type: 'workflow', + position: { x: 0, y: 0 }, + data: EMPTY, + }, + ], + edges: [], + }, + }), + (key) => key, + ) + const data = draft.nodes[0]?.data ?? EMPTY + return ( + <> + draft.updateNodeData('out', next)} + catalog={CATALOG} + /> + + + ) +} + +describe('Output bindings', () => { + it('persists a named catalog selection through save/reload and removes the exact row', async () => { + const user = userEvent.setup() + const saved: Record[] = [] + renderWithProviders( saved.push(graph)} />) + await user.click(screen.getByRole('button', { name: '添加输出' })) + fireEvent.change(screen.getByLabelText('输出名称 1'), { target: { value: 'summary' } }) + await user.click(screen.getByLabelText('输出来源 1')) + await user.click(await screen.findByRole('option', { name: /input/ })) + await user.click(screen.getByRole('button', { name: '保存并重载' })) + expect(saved[0]).toMatchObject({ + nodes: [{ data: { outputs: [{ name: 'summary', variableSelector: ['start', 'input'] }] } }], + }) + expect(screen.getByLabelText('输出名称 1')).toHaveValue('summary') + expect(screen.getByLabelText('输出来源 1')).toHaveTextContent('input') + await user.click(screen.getByRole('button', { name: '添加输出' })) + fireEvent.change(screen.getByLabelText('输出名称 2'), { target: { value: 'second' } }) + await user.click(screen.getByRole('button', { name: '删除输出 1' })) + expect(screen.getByLabelText('输出名称 1')).toHaveValue('second') + await user.click(screen.getByRole('button', { name: '删除输出 1' })) + await user.click(screen.getByRole('button', { name: '保存并重载' })) + expect(saved[1]).toMatchObject({ nodes: [{ data: { outputs: [] } }] }) + }) + + it('keeps imported nested selectors editable without silently replacing them', () => { + function Imported() { + const [data, setData] = useState({ + ...EMPTY, + outputs: [{ name: 'count', variableSelector: ['writer', 'structured_output', 'count'] }], + }) + return + } + renderWithProviders() + expect(screen.getByLabelText('输出来源 1')).toHaveTextContent('writer.structured_output.count') + fireEvent.change(screen.getByLabelText('输出名称 1'), { target: { value: 'total' } }) + expect(screen.getByLabelText('输出来源 1')).toHaveTextContent('writer.structured_output.count') + }) +}) diff --git a/frontend/src/features/workflows/editor/workflow-output-fields.tsx b/frontend/src/features/workflows/editor/workflow-output-fields.tsx new file mode 100644 index 000000000..2ef85fbb0 --- /dev/null +++ b/frontend/src/features/workflows/editor/workflow-output-fields.tsx @@ -0,0 +1,77 @@ +import { useTranslation } from 'react-i18next' +import { Plus, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { WorkflowCatalogSelector } from '@/features/workflows/editor/workflow-variable-select' +import type { WorkflowNodeData, WorkflowOutputBinding } from '@/features/workflows/runtime/types' +import type { WorkflowDecoratedVariable } from '@/features/workflows/runtime/variable-catalog' + +/** Edits the persisted named outputs; unset rows remain drafts until publication validates them. */ +export function OutputNodeFields({ + data, + onChange, + catalog, +}: { + data: WorkflowNodeData + onChange: (data: WorkflowNodeData) => void + catalog: readonly WorkflowDecoratedVariable[] +}) { + const { t } = useTranslation() + const outputs = data.outputs ?? [] + const update = (next: WorkflowOutputBinding[]): void => onChange({ ...data, outputs: next }) + return ( +
+

+ {t('workflows.output.description')} +

+ {outputs.map((binding, index) => ( + // oxlint-disable-next-line react/no-array-index-key -- names are editable and new rows have no persisted id; ordinal keys preserve focus while typing. +
+
+ + update( + outputs.map((row, i) => + i === index ? { ...row, name: event.target.value } : row, + ), + ) + } + /> + +
+ + update(outputs.map((row, i) => (i === index ? { ...row, variableSelector } : row))) + } + /> +
+ ))} + +
+ ) +} diff --git a/frontend/src/features/workflows/node-translations.ts b/frontend/src/features/workflows/node-translations.ts index b3c47cd2d..a18d4cef0 100644 --- a/frontend/src/features/workflows/node-translations.ts +++ b/frontend/src/features/workflows/node-translations.ts @@ -9,6 +9,15 @@ export const nodeTranslations = { 'zh-CN': { 'workflows.condition.connectFromBranch': '从{{name}}的 {{branch}} 分支连接', + 'workflows.output.bindings': '输出绑定', + 'workflows.output.description': + '为最终结果命名,并选择要返回的上游变量。未添加绑定时返回空结果。', + 'workflows.output.name': '输出名称', + 'workflows.output.namePlaceholder': '例如 summary', + 'workflows.output.source': '输出来源', + 'workflows.output.sourcePlaceholder': '选择变量', + 'workflows.output.add': '添加输出', + 'workflows.output.remove': '删除输出', // Node catalog: palette labels and descriptions for every executable kind. 'workflows.node.start.label': '开始', 'workflows.node.start.description': '定义工作流输入', @@ -33,6 +42,15 @@ export const nodeTranslations = { }, 'en-US': { 'workflows.condition.connectFromBranch': 'Connect from {{branch}} of {{name}}', + 'workflows.output.bindings': 'Output bindings', + 'workflows.output.description': + 'Name each result and choose the upstream variable to return. No bindings produce an empty result.', + 'workflows.output.name': 'Output name', + 'workflows.output.namePlaceholder': 'For example, summary', + 'workflows.output.source': 'Output source', + 'workflows.output.sourcePlaceholder': 'Choose a variable', + 'workflows.output.add': 'Add output', + 'workflows.output.remove': 'Remove output', 'workflows.node.start.label': 'Start', 'workflows.node.start.description': 'Define workflow inputs', 'workflows.node.agent.label': 'Agent', diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 114b74257..e8d03a76e 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -448,6 +448,8 @@ func validateWorkflowNodes(nodes []any, globals []Object) []workflowGraphProblem problems = append(problems, validateAgentConfig(id, o.O("data"))...) case kind == "condition": problems = append(problems, validateConditionConfig(id, o.O("data"), visibleRoots)...) + case kind == "output": + problems = append(problems, validateWorkflowOutputConfig(id, o.O("data"), visibleRoots)...) case kind == "aggregator": problems = append(problems, validateAggregatorConfig(id, o.O("data"), visibleRoots)...) case kind == "loop" && containers[id] == "": diff --git a/internal/core/workflow_output.go b/internal/core/workflow_output.go new file mode 100644 index 000000000..805a4c969 --- /dev/null +++ b/internal/core/workflow_output.go @@ -0,0 +1,88 @@ +package core + +import ( + "fmt" + "strings" +) + +// workflowOutputBinding names one final result without importing unrelated predecessor values. +type workflowOutputBinding struct { + Name string + Selector workflowSelector +} + +// readWorkflowOutputBindings is shared by publication and execution so malformed authored rows +// cannot be silently skipped into a successful, partial result. An absent list retains empty output. +func readWorkflowOutputBindings(nodeID string, data Object) ([]workflowOutputBinding, []workflowGraphProblem) { + value, present := data["outputs"] + if !present || value == nil { + return nil, nil + } + rows, ok := value.([]any) + if !ok { + return nil, []workflowGraphProblem{{Code: "output_bindings_not_a_list", NodeID: nodeID, Field: "outputs"}} + } + var bindings []workflowOutputBinding + var problems []workflowGraphProblem + seen := map[string]bool{} + for _, row := range rows { + binding, object := asWorkflowObject(row) + name := binding.S("name") + if !object || strings.TrimSpace(name) == "" { + problems = append(problems, workflowGraphProblem{Code: "output_binding_invalid", NodeID: nodeID, Field: "outputs.name"}) + continue + } + if seen[name] { + problems = append(problems, workflowGraphProblem{Code: "output_name_duplicate", NodeID: nodeID, Field: "outputs.name"}) + } + seen[name] = true + selector, valid := parseWorkflowSelector(binding["variableSelector"]) + if !valid { + problems = append(problems, workflowGraphProblem{Code: "output_selector_unsupported", NodeID: nodeID, Field: "outputs.variableSelector"}) + continue + } + bindings = append(bindings, workflowOutputBinding{Name: name, Selector: selector}) + } + return bindings, problems +} + +// validateWorkflowOutputConfig checks roots against the consumer's scope, including frozen globals. +func validateWorkflowOutputConfig(nodeID string, data Object, roots map[string]map[string]bool) []workflowGraphProblem { + bindings, problems := readWorkflowOutputBindings(nodeID, data) + for _, binding := range bindings { + if !roots[binding.Selector.nodeID][binding.Selector.root] { + problems = append(problems, workflowGraphProblem{Code: "output_selector_undeclared", NodeID: nodeID, Field: "outputs.variableSelector"}) + } + } + return problems +} + +// workflowOutputFailure separates the public reason from the actionable, bounded diagnostic. +type workflowOutputFailure struct { + Reason string + Message string +} + +// Error reports the binding that failed without including any variable values. +func (failure *workflowOutputFailure) Error() string { return failure.Message } + +// evaluateWorkflowOutput resolves all bindings against one committed snapshot. Missing or invalid +// paths fail the whole node; explicit null, false, zero and empty values remain assigned results. +func evaluateWorkflowOutput(node workflowNodeView, states map[string]workflowNodeState) (Object, *workflowOutputFailure) { + bindings, problems := readWorkflowOutputBindings(node.ID, node.Data) + if len(problems) > 0 { + return nil, &workflowOutputFailure{Reason: "output_invalid_config", Message: "output bindings are invalid"} + } + result := Object{} + for _, binding := range bindings { + value, present, err := resolveWorkflowSelector(binding.Selector, states) + if err != nil { + return nil, &workflowOutputFailure{Reason: "output_invalid_path", Message: fmt.Sprintf("result %s references invalid path %s", binding.Name, binding.Selector.qualified())} + } + if !present { + return nil, &workflowOutputFailure{Reason: "output_value_unset", Message: fmt.Sprintf("result %s references unassigned variable %s", binding.Name, binding.Selector.qualified())} + } + result[binding.Name] = value + } + return result, nil +} diff --git a/internal/core/workflow_output_scheduler_db_test.go b/internal/core/workflow_output_scheduler_db_test.go new file mode 100644 index 000000000..c4199303f --- /dev/null +++ b/internal/core/workflow_output_scheduler_db_test.go @@ -0,0 +1,41 @@ +package core + +import ( + "context" + "reflect" + "testing" +) + +// Both branches join the same terminal through distinct handles. The scheduler must activate only +// the selected edge, resolve the binding and persist the same named result the run view returns. +func TestWorkflowAdvanceConditionBranchesJoinNamedOutput(t *testing.T) { + for _, test := range []struct{ kickoff, branch string }{{"yes", "approved"}, {"no", "else"}} { + t.Run(test.branch, func(t *testing.T) { + store := dispatcherDB(t) + condition := wfNode("c", "condition", "route") + condition["data"].(Object)["cases"] = []any{Object{ + "id": "approved", "conditions": []any{Object{ + "variableSelector": []any{"s", "input"}, "operator": "equals", "value": "yes", + }}, + }} + output := wfNode("out", "output", "result") + output["data"].(Object)["outputs"] = []any{Object{"name": "answer", "variableSelector": []any{"s", "input"}}} + graph := wfGraph([]Object{wfNode("s", "start", ""), condition, output}, + [][3]string{{"s", "c", ""}, {"c", "out", "approved"}, {"c", "out", "else"}}) + runID := seedWorkflowRunSceneWithInput(t, store, graph, Object{"input": test.kickoff}) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" || states.O("c").O("output").S("selectedBranch") != test.branch { + t.Fatalf("run = %s/%s; states = %#v", status, runErr, states) + } + if got := states.O("out").O("output"); !reflect.DeepEqual(got, Object{"answer": test.kickoff}) { + t.Fatalf("named output = %#v", got) + } + if workflowIssueOutputSummary(graph, states, "") == "" { + t.Fatal("named output was lost from the issue result projection") + } + }) + } +} diff --git a/internal/core/workflow_output_test.go b/internal/core/workflow_output_test.go new file mode 100644 index 000000000..a0566f9dd --- /dev/null +++ b/internal/core/workflow_output_test.go @@ -0,0 +1,83 @@ +package core + +import ( + "reflect" + "testing" +) + +func TestWorkflowOutputResolvesNamedBindingsAtomically(t *testing.T) { + states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{ + "input": "hello", "zero": float64(0), "null": nil, "false": false, + "empty": "", "object": Object{"count": float64(2)}, + }}} + bindings := []any{} + for _, root := range []string{"input", "zero", "null", "false", "empty"} { + bindings = append(bindings, Object{"name": root, "variableSelector": []any{"s", root}}) + } + bindings = append(bindings, Object{"name": "nested", "variableSelector": []any{"s", "object", "count"}}) + node := workflowNodeView{ID: "out", Kind: "output", Data: Object{"outputs": bindings}} + got := executeWorkflowNode(node, nil, states, nil) + want := Object{"input": "hello", "zero": float64(0), "null": nil, "false": false, "empty": "", "nested": float64(2)} + if got.Status != "succeeded" || !reflect.DeepEqual(got.Output, want) { + t.Fatalf("output = %#v; want succeeded with %#v", got, want) + } + bindings = append(bindings, Object{"name": "missing", "variableSelector": []any{"unselected", "output"}}) + node.Data["outputs"] = bindings + got = executeWorkflowNode(node, nil, states, nil) + if got.Status != "failed" || !reflect.DeepEqual(got.Output, Object{"reason": "output_value_unset"}) { + t.Fatalf("missing binding leaked a partial successful output: %#v", got) + } +} + +func TestWorkflowOutputRejectsMalformedBindingsAndInvalidPaths(t *testing.T) { + for _, test := range []struct { + name, reason string + bindings any + }{ + {"wrong list", "output_invalid_config", "not a list"}, + {"empty name", "output_invalid_config", []any{Object{"name": "", "variableSelector": []any{"s", "input"}}}}, + {"invalid path", "output_invalid_path", []any{Object{"name": "value", "variableSelector": []any{"s", "input", "missing"}}}}, + } { + t.Run(test.name, func(t *testing.T) { + got := executeWorkflowNode(workflowNodeView{ID: "out", Kind: "output", Data: Object{"outputs": test.bindings}}, nil, + map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{"input": "text"}}}, nil) + if got.Status != "failed" || got.Output.S("reason") != test.reason { + t.Fatalf("state = %#v; want failed/%s", got, test.reason) + } + }) + } + got := executeWorkflowNode(workflowNodeView{ID: "out", Kind: "output", Data: Object{}}, nil, nil, nil) + if got.Status != "succeeded" || !reflect.DeepEqual(got.Output, Object{}) { + t.Fatalf("legacy output without bindings = %#v", got) + } +} + +func TestWorkflowPublishValidatesOutputBindings(t *testing.T) { + valid := Object{"name": "summary", "variableSelector": []any{"s", "input"}} + for _, test := range []struct { + name, code string + bindings any + }{ + {"valid", "", []any{valid}}, + {"empty", "", []any{}}, + {"wrong list", "output_bindings_not_a_list", true}, + {"wrong row", "output_binding_invalid", []any{"bad"}}, + {"duplicate", "output_name_duplicate", []any{valid, valid}}, + {"invalid selector", "output_selector_unsupported", []any{Object{"name": "result", "variableSelector": []any{"s"}}}}, + {"undeclared", "output_selector_undeclared", []any{Object{"name": "result", "variableSelector": []any{"unknown", "output"}}}}, + } { + t.Run(test.name, func(t *testing.T) { + node := wfNode("out", "output", "") + node["data"].(Object)["outputs"] = test.bindings + graph := wfGraph([]Object{wfNode("s", "start", ""), node}, [][3]string{{"s", "out", ""}}) + problems := validateWorkflowGraph(graph) + if test.code == "" { + if len(problems) != 0 { + t.Fatalf("valid bindings rejected: %#v", problems) + } + } else if len(problems) != 1 || problems[0].Code != test.code || problems[0].NodeID != "out" { + t.Fatalf("problems = %#v; want one %s at out", problems, test.code) + } + }) + } +} diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index a84f28c0d..25d224aaa 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -776,8 +776,8 @@ func workflowRunVersion(t *transaction, runID string, fallback int64) int64 { // the variable roots desktop's Start node exposes; condition evaluates its cases against prior node // outputs overlaid with the run's ambient variables (sys.* and frozen user globals) and commits the // selected branch (first match wins, else when nothing matched, and a failure when a rule cannot be -// evaluated); output is a terminal marker whose produce stays in the node state for the run viewer -// (D7: 成果交付延后). +// evaluated); output resolves explicit named bindings into its committed result. No bindings retain +// an empty result; an unassigned binding fails instead of silently emitting a partial object. func executeWorkflowNode(node workflowNodeView, input Object, states, ambient map[string]workflowNodeState) workflowNodeState { now := nowRFC3339() state := workflowNodeState{ @@ -802,7 +802,14 @@ func executeWorkflowNode(node workflowNodeView, input Object, states, ambient ma } state.Output = Object{"selectedBranch": branch} case "output": - state.Output = Object{} + output, failure := evaluateWorkflowOutput(node, workflowConditionStates(states, ambient)) + if failure != nil { + state.Status = "failed" + state.Error = failure.Error() + state.Output = Object{"reason": failure.Reason} + return state + } + state.Output = output case "aggregator": value, assigned, err := evaluateWorkflowAggregator(node, workflowConditionStates(states, ambient)) if err != nil { From 0f002f05382a8c51488e8d249f597e5e09155e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:26:53 +0800 Subject: [PATCH 26/27] fix(workflows): retain legacy empty branch port semantics --- .../editor/use-workflow-draft.test.ts | 71 ++++++++++--------- .../workflows/editor/use-workflow-draft.ts | 4 +- .../runtime/connection-validation.test.ts | 10 +++ .../runtime/connection-validation.ts | 13 ++-- 4 files changed, 57 insertions(+), 41 deletions(-) diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.test.ts b/frontend/src/features/workflows/editor/use-workflow-draft.test.ts index 6a3796f56..a16a52f69 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.test.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.test.ts @@ -127,40 +127,43 @@ describe('useWorkflowDraft', () => { expect(result.current.captureContent()).toEqual(history) }) - it('treats a legacy unlabeled Condition connection as ELSE when reconnecting', () => { - const condition: WorkflowCanvasNode = { - id: 'condition-1', - type: 'workflow', - position: { x: 0, y: 0 }, - data: { kind: 'condition', title: '条件', description: '' }, - } - const { result } = setup( - workflowFixture({ - graph: { - nodes: [condition, outputNode()], - edges: [{ id: 'old', source: condition.id, target: 'output-1' }], - }, - }), - ) - act(() => - result.current.connect({ - source: condition.id, - target: 'output-1', - sourceHandle: 'else', - targetHandle: null, - }), - ) - expect(result.current.edges).toHaveLength(1) - act(() => - result.current.connect({ - source: condition.id, - target: 'output-1', - sourceHandle: 'case-1', - targetHandle: null, - }), - ) - expect(result.current.edges.map((edge) => edge.sourceHandle)).toEqual(['else', 'case-1']) - }) + it.each([undefined, ''])( + 'treats a legacy Condition port %s as ELSE when reconnecting', + (sourceHandle) => { + const condition: WorkflowCanvasNode = { + id: 'condition-1', + type: 'workflow', + position: { x: 0, y: 0 }, + data: { kind: 'condition', title: '条件', description: '' }, + } + const { result } = setup( + workflowFixture({ + graph: { + nodes: [condition, outputNode()], + edges: [{ id: 'old', source: condition.id, target: 'output-1', sourceHandle }], + }, + }), + ) + act(() => + result.current.connect({ + source: condition.id, + target: 'output-1', + sourceHandle: 'else', + targetHandle: null, + }), + ) + expect(result.current.edges).toHaveLength(1) + act(() => + result.current.connect({ + source: condition.id, + target: 'output-1', + sourceHandle: 'case-1', + targetHandle: null, + }), + ) + expect(result.current.edges.map((edge) => edge.sourceHandle)).toEqual(['else', 'case-1']) + }, + ) it('adds a Loop as one atomic group with its starter body', () => { // A bare Loop saves but never publishes: the body needs a Start. The palette entry therefore diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.ts b/frontend/src/features/workflows/editor/use-workflow-draft.ts index 7239f8162..5b52f1d15 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.ts @@ -350,13 +350,13 @@ function appendLoopExitNode( function appendConnection(current: DraftState, connection: Connection): DraftState { const sourceNode = current.nodes.find((node) => node.id === connection.source) const sourceHandle = - connection.sourceHandle ?? (sourceNode?.data.kind === 'condition' ? 'else' : null) + connection.sourceHandle || (sourceNode?.data.kind === 'condition' ? 'else' : null) const candidate = { ...connection, sourceHandle } // An older unlabeled Condition edge means ELSE. Canonicalize that source's existing edges so // reconnecting its explicit ELSE port cannot create a second edge for the same branch. const edges = current.edges.map((edge) => sourceNode?.data.kind === 'condition' && edge.source === connection.source - ? { ...edge, sourceHandle: edge.sourceHandle ?? 'else' } + ? { ...edge, sourceHandle: edge.sourceHandle || 'else' } : edge, ) if (!isValidWorkflowConnection(candidate, edges)) { diff --git a/frontend/src/features/workflows/runtime/connection-validation.test.ts b/frontend/src/features/workflows/runtime/connection-validation.test.ts index b1a760d3a..b086116d8 100644 --- a/frontend/src/features/workflows/runtime/connection-validation.test.ts +++ b/frontend/src/features/workflows/runtime/connection-validation.test.ts @@ -56,6 +56,16 @@ describe('isValidWorkflowConnection', () => { }) describe('workflowEdgeId', () => { + it('normalizes an empty legacy port to the omitted port identity', () => { + expect(workflowEdgeId('a', 'b', { sourceHandle: '', targetHandle: '' })).toBe( + workflowEdgeId('a', 'b'), + ) + expect( + isValidWorkflowConnection({ source: 'a', target: 'b' }, [ + { source: 'a', target: 'b', sourceHandle: '', targetHandle: '' }, + ]), + ).toBe(false) + }) it('derives the id from the endpoints, so a reload cannot duplicate it', () => { expect(workflowEdgeId('agent-1', 'output-1')).toBe('e-agent-1-output-1') }) diff --git a/frontend/src/features/workflows/runtime/connection-validation.ts b/frontend/src/features/workflows/runtime/connection-validation.ts index 3e467f590..fcf1dc29f 100644 --- a/frontend/src/features/workflows/runtime/connection-validation.ts +++ b/frontend/src/features/workflows/runtime/connection-validation.ts @@ -38,15 +38,16 @@ export function isValidWorkflowConnection( (edge) => edge.source === source && edge.target === target && - (edge.sourceHandle ?? null) === (candidate.sourceHandle ?? null) && - (edge.targetHandle ?? null) === (candidate.targetHandle ?? null), + (edge.sourceHandle || null) === (candidate.sourceHandle || null) && + (edge.targetHandle || null) === (candidate.targetHandle || null), ) } /** * Builds the id of the edge between two nodes. * - * Deriving it from the endpoints rather than from a counter keeps the id stable + * Including the port pair keeps sibling branches distinct. Deriving the id from these identities + * rather than from a counter keeps it stable * across a reload, so an edge is never duplicated by re-adding the same * connection. * @@ -60,8 +61,10 @@ export function workflowEdgeId( handles: WorkflowConnectionHandles = {}, ): string { const base = `e-${source}-${target}` - if (handles.sourceHandle == null && handles.targetHandle == null) { + const sourceHandle = handles.sourceHandle || null + const targetHandle = handles.targetHandle || null + if (sourceHandle === null && targetHandle === null) { return base } - return `${base}-${encodeURIComponent(JSON.stringify([handles.sourceHandle ?? null, handles.targetHandle ?? null]))}` + return `${base}-${encodeURIComponent(JSON.stringify([sourceHandle, targetHandle]))}` } From 64158659f4250d0261318c64db6dd7793b61f63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:26:54 +0800 Subject: [PATCH 27/27] fix(workflows): persist iteration progress across scheduler passes --- internal/core/workflow_aggregator_graph.go | 2 +- .../workflow_aggregator_scheduler_db_test.go | 2 +- internal/core/workflow_iteration_run.go | 13 +++--- .../workflow_iteration_scheduler_db_test.go | 5 +-- .../core/workflow_iteration_state_test.go | 41 +++++++++++++++++ internal/core/workflow_loop_run.go | 45 ++++++++++--------- internal/core/workflow_retry.go | 3 +- internal/core/workflow_run_session.go | 7 ++- internal/core/workflow_scheduler.go | 19 +++++--- 9 files changed, 96 insertions(+), 41 deletions(-) create mode 100644 internal/core/workflow_iteration_state_test.go diff --git a/internal/core/workflow_aggregator_graph.go b/internal/core/workflow_aggregator_graph.go index 98f474805..f34853d51 100644 --- a/internal/core/workflow_aggregator_graph.go +++ b/internal/core/workflow_aggregator_graph.go @@ -95,7 +95,7 @@ func (e errString) Error() string { return string(e) } // containers all count as assigned — desktop's V1 contract, not a truthiness test. Unassigned // candidates are skipped; an empty result is reported as not assigned so the caller can fail with // the stable no-match reason. -func evaluateWorkflowAggregator(node workflowNodeView, states map[string]workflowNodeState) (any, bool, error) { +func evaluateWorkflowAggregator(node workflowNodeView, states map[string]workflowNodeState) (value any, assigned bool, err error) { selectors, err := parseAggregatorSelectors(node.Data) if err != nil { return nil, false, err diff --git a/internal/core/workflow_aggregator_scheduler_db_test.go b/internal/core/workflow_aggregator_scheduler_db_test.go index 79761cba2..dd9d9e806 100644 --- a/internal/core/workflow_aggregator_scheduler_db_test.go +++ b/internal/core/workflow_aggregator_scheduler_db_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -// The aggregator's scheduler behaviour over PostgreSQL: the first assigned root is passed through as +// The aggregator's scheduler behavior over PostgreSQL: the first assigned root is passed through as // `{aggregator}.output`, presence is key existence (a null counts), and an empty candidate set is the // stable no-match failure. diff --git a/internal/core/workflow_iteration_run.go b/internal/core/workflow_iteration_run.go index c6e71ec77..07510d003 100644 --- a/internal/core/workflow_iteration_run.go +++ b/internal/core/workflow_iteration_run.go @@ -167,7 +167,7 @@ func advanceWorkflowIteration( } states[node.ID] = state if len(items) == 0 { - finishWorkflowIteration(states, node, state) + finishWorkflowIteration(states, node, &state) return true } return true @@ -177,7 +177,7 @@ func advanceWorkflowIteration( } iteration := state.Iteration if iteration.Round >= len(iteration.Items) { - finishWorkflowIteration(states, node, state) + finishWorkflowIteration(states, node, &state) return true } item := iteration.Items[iteration.Round] @@ -250,7 +250,7 @@ func settleWorkflowIterationRound( iteration.Round++ states[node.ID] = state if iteration.Round >= len(iteration.Items) { - finishWorkflowIteration(states, node, state) + finishWorkflowIteration(states, node, &state) } } @@ -269,7 +269,7 @@ func archiveWorkflowIterationRound(iteration *workflowIterationRunState, body wo } // finishWorkflowIteration publishes the exposed variables and settles the node succeeded. -func finishWorkflowIteration(states map[string]workflowNodeState, node workflowNodeView, state workflowNodeState) { +func finishWorkflowIteration(states map[string]workflowNodeState, node workflowNodeView, state *workflowNodeState) { iteration := state.Iteration state.Status = "succeeded" state.FinishedAt = nowRFC3339() @@ -278,7 +278,7 @@ func finishWorkflowIteration(states map[string]workflowNodeState, node workflowN "entries": iteration.Ledger, "failed_count": iteration.Failed, } - states[node.ID] = state + states[node.ID] = *state } // failWorkflowIteration settles the node and the run as failed with a stable reason. @@ -294,7 +294,8 @@ func failWorkflowIteration(states map[string]workflowNodeState, node workflowNod // cancelWorkflowIterations marks every running iteration cancelled when the run is cancelled. func cancelWorkflowIterations(states map[string]workflowNodeState) { - for id, state := range states { + for id := range states { + state := states[id] if state.Status != "running" || state.Iteration == nil { continue } diff --git a/internal/core/workflow_iteration_scheduler_db_test.go b/internal/core/workflow_iteration_scheduler_db_test.go index 371a006ba..5e8c144a4 100644 --- a/internal/core/workflow_iteration_scheduler_db_test.go +++ b/internal/core/workflow_iteration_scheduler_db_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -// Iteration's scheduler behaviour over PostgreSQL: one round per item in order, the ledger projection, +// Iteration's scheduler behavior over PostgreSQL: one round per item in order, the ledger projection, // the fixed exposed trio, both error strategies, the empty array and the over-limit refusal. // iterationRunGraph builds start → iteration → output. The region holds one aggregator member, so the @@ -63,9 +63,6 @@ func TestWorkflowAdvanceIterationCollectsEveryRound(t *testing.T) { if first.S("item") != "a" || first.S("status") != "succeeded" { t.Fatalf("first entry = %v; want item a succeeded", entries[0]) } - if output.S("failed_count") != "" { - // failed_count is a number, not a string; the helper above only type-checks strings. - } if failed, ok := workflowNumberOf(output["failed_count"]); !ok || failed != 0 { t.Fatalf("failed_count = %v; want 0", output["failed_count"]) } diff --git a/internal/core/workflow_iteration_state_test.go b/internal/core/workflow_iteration_state_test.go new file mode 100644 index 000000000..5fb881f92 --- /dev/null +++ b/internal/core/workflow_iteration_state_test.go @@ -0,0 +1,41 @@ +package core + +import ( + "encoding/json" + "reflect" + "testing" +) + +// A running iteration may yield while an agent executes. The same wire codec used by the CAS write +// must preserve its settled ledger and active round before a different scheduler pass resumes it. +func TestWorkflowIterationProgressSurvivesStateJSON(t *testing.T) { + progress := &workflowIterationRunState{ + Round: 1, Items: []any{"a", "b"}, Strategy: "continue", Failed: 0, + Ledger: []any{Object{"item": "a", "status": "succeeded", "output": "first", "error": nil}}, + Output: []any{"first"}, + Completed: []any{Object{"index": float64(0), "states": Object{ + "worker": Object{"status": "succeeded", "output": Object{"output": "first"}}, + }}}, + } + state := workflowNodeState{Status: "running", DisplayName: "foreach", Iteration: progress} + encoded, err := json.Marshal(Object{"iter": state.object()}) + if err != nil { + t.Fatal(err) + } + var raw Object + if err := json.Unmarshal(encoded, &raw); err != nil { + t.Fatal(err) + } + restored := workflowStatesFromJSON(raw) + got := restored["iter"] + if got.Iteration == nil || got.Iteration.Round != 1 || got.Iteration.Strategy != "continue" || + !reflect.DeepEqual(got.Iteration.Output, []any{"first"}) || len(got.Iteration.Completed) != 1 || len(got.Iteration.Ledger) != 1 { + t.Fatalf("persisted iteration progress was lost: %#v", got.Iteration) + } + beforeCancel := got.Iteration + cancelWorkflowIterations(restored) + got = restored["iter"] + if got.Status != "cancelled" || got.FinishedAt == "" || !reflect.DeepEqual(got.Iteration, beforeCancel) { + t.Fatalf("cancellation lost the committed ledger: %#v", got) + } +} diff --git a/internal/core/workflow_loop_run.go b/internal/core/workflow_loop_run.go index f2b5002f5..0e47b4ba0 100644 --- a/internal/core/workflow_loop_run.go +++ b/internal/core/workflow_loop_run.go @@ -289,7 +289,7 @@ func advanceWorkflowLoop( } state, committed := states[node.ID] if !committed { - carried, err := initializeWorkflowLoopCarried(config, graph, node.ID, states, ambient) + carried, err := initializeWorkflowLoopCarried(&config, graph, node.ID, states, ambient) if err != nil { failWorkflowLoop(states, node, "loop_initial_invalid", err.Error()) return true @@ -341,41 +341,41 @@ func advanceWorkflowLoop( // the published outputs come from the round the exit produced. roundStates := workflowLoopReadStates(graph, node.ID, states, ambient) if workflowLoopExitSucceeded(body, states) { - outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + outputs, err := resolveWorkflowLoopOutputs(&config, roundStates) if err != nil { failWorkflowLoop(states, node, "loop_exit_output_unset", err.Error()) return true } - finishWorkflowLoop(states, node, state, body, "loop_exit", outputs) + finishWorkflowLoop(states, node, &state, body, "loop_exit", outputs) return true } - feedback, err := resolveWorkflowLoopFeedback(config, roundStates) + feedback, err := resolveWorkflowLoopFeedback(&config, roundStates) if err != nil { failWorkflowLoop(states, node, "loop_value_unset", err.Error()) return true } - satisfied, err := workflowLoopUntilSatisfied(config, roundStates) + satisfied, err := workflowLoopUntilSatisfied(&config, roundStates) if err != nil { failWorkflowLoop(states, node, "loop_value_unset", err.Error()) return true } if satisfied { - outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + outputs, err := resolveWorkflowLoopOutputs(&config, roundStates) if err != nil { failWorkflowLoop(states, node, "loop_value_unset", err.Error()) return true } - finishWorkflowLoop(states, node, state, body, "condition_satisfied", outputs) + finishWorkflowLoop(states, node, &state, body, "condition_satisfied", outputs) return true } if state.Loop.Round >= config.MaxIterations { if config.Behavior == "succeed" { - outputs, err := resolveWorkflowLoopOutputs(config, roundStates) + outputs, err := resolveWorkflowLoopOutputs(&config, roundStates) if err != nil { failWorkflowLoop(states, node, "loop_value_unset", err.Error()) return true } - finishWorkflowLoop(states, node, state, body, "max_iterations", outputs) + finishWorkflowLoop(states, node, &state, body, "max_iterations", outputs) return true } // The round that exhausted the budget is a finished round: archive it before failing so the @@ -396,7 +396,7 @@ func advanceWorkflowLoop( // constant is normalized against its declared type and a selector is read from the loop's outer // scope. A missing or mistyped value fails the loop instead of starting it with a hole. func initializeWorkflowLoopCarried( - config workflowLoopConfig, + config *workflowLoopConfig, graph workflowGraphView, loopID string, states map[string]workflowNodeState, @@ -404,7 +404,8 @@ func initializeWorkflowLoopCarried( ) (map[string]any, error) { outer := workflowLoopOuterStates(graph, loopID, states, ambient) carried := map[string]any{} - for _, variable := range config.Variables { + for i := range config.Variables { + variable := &config.Variables[i] value := variable.InitialValue if variable.InitialKind == "variable" { resolved, present, err := resolveWorkflowSelector(variable.InitialSelector, outer) @@ -421,9 +422,10 @@ func initializeWorkflowLoopCarried( // resolveWorkflowLoopFeedback reads every feedback selector from the completed round in one snapshot // and normalizes it against the declared type. All variables are read before any is assigned, so two // variables that swap values receive each other's value. -func resolveWorkflowLoopFeedback(config workflowLoopConfig, roundStates map[string]workflowNodeState) (map[string]any, error) { +func resolveWorkflowLoopFeedback(config *workflowLoopConfig, roundStates map[string]workflowNodeState) (map[string]any, error) { carried := map[string]any{} - for _, variable := range config.Variables { + for i := range config.Variables { + variable := &config.Variables[i] value, present, err := resolveWorkflowSelector(variable.Feedback, roundStates) if err != nil || !present { return nil, fmt.Errorf("feedback selector %s has no value", variable.Feedback.qualified()) @@ -439,7 +441,7 @@ func resolveWorkflowLoopFeedback(config workflowLoopConfig, roundStates map[stri // workflowLoopUntilSatisfied evaluates the round-end condition over the completed round. An until with // no rules never matches, which is desktop's reading: only a case with conditions can select. -func workflowLoopUntilSatisfied(config workflowLoopConfig, roundStates map[string]workflowNodeState) (bool, error) { +func workflowLoopUntilSatisfied(config *workflowLoopConfig, roundStates map[string]workflowNodeState) (bool, error) { until := workflowNodeView{ ID: "until", Kind: "condition", @@ -453,7 +455,7 @@ func workflowLoopUntilSatisfied(config workflowLoopConfig, roundStates map[strin } // resolveWorkflowLoopOutputs reads every named output from the round that produced it. -func resolveWorkflowLoopOutputs(config workflowLoopConfig, roundStates map[string]workflowNodeState) (Object, error) { +func resolveWorkflowLoopOutputs(config *workflowLoopConfig, roundStates map[string]workflowNodeState) (Object, error) { outputs := Object{} for _, output := range config.Outputs { value, present, err := resolveWorkflowSelector(output.Selector, roundStates) @@ -503,8 +505,8 @@ func workflowLoopOuterStates( ambient map[string]workflowNodeState, ) map[string]workflowNodeState { outer := map[string]workflowNodeState{} - for id, state := range ambient { - outer[id] = state + for id := range ambient { + outer[id] = ambient[id] } for id := range workflowAncestorIDs(graph, loopID) { if state, ok := states[id]; ok { @@ -537,7 +539,7 @@ func workflowAncestorIDs(graph workflowGraphView, nodeID string) map[string]bool } // workflowBodyFailure names the first failed body member in document order with its committed reason. -func workflowBodyFailure(body workflowGraphView, states map[string]workflowNodeState) (string, string) { +func workflowBodyFailure(body workflowGraphView, states map[string]workflowNodeState) (reason, detail string) { for _, member := range body.Nodes { state := states[member.ID] if state.Status != "failed" { @@ -578,7 +580,8 @@ func workflowLoopExitSucceeded(body workflowGraphView, states map[string]workflo // A cancelled run is terminal, so a loop left `running` would misreport a container that will never // advance; the loop state keeps its rounds as the audit trail of what did run. func cancelWorkflowLoops(states map[string]workflowNodeState) { - for id, state := range states { + for id := range states { + state := states[id] if state.Status != "running" || state.Loop == nil { continue } @@ -611,7 +614,7 @@ func failWorkflowLoop(states map[string]workflowNodeState, node workflowNodeView func finishWorkflowLoop( states map[string]workflowNodeState, node workflowNodeView, - state workflowNodeState, + state *workflowNodeState, body workflowGraphView, reason string, outputs Object, @@ -624,7 +627,7 @@ func finishWorkflowLoop( state.FinishedAt = nowRFC3339() state.Error = "" state.Output = outputs - states[node.ID] = state + states[node.ID] = *state } // archiveWorkflowLoopRound records the active round's member states as one durable entry. When the diff --git a/internal/core/workflow_retry.go b/internal/core/workflow_retry.go index 368986e75..fd739f54a 100644 --- a/internal/core/workflow_retry.go +++ b/internal/core/workflow_retry.go @@ -140,7 +140,8 @@ func workflowRetryDecision(policy workflowRetryPolicy, failedAttempt int64, reas // most one: a waiting attempt keeps the run's serial agent slot (D3), so no other agent node of the // same run can be waiting at the same time. func workflowRetryingAgentNode(states map[string]workflowNodeState) (string, bool) { - for id, state := range states { + for id := range states { + state := states[id] if state.Status == "retrying" { return id, true } diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index 348b2890a..01069738e 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -59,7 +59,8 @@ func settleWorkflowSessionEnded(t *transaction, runID, executionID string, ended // workflowRunningAgentNode returns the id of the node committed `running`, if any. func workflowRunningAgentNode(states map[string]workflowNodeState) (string, bool) { - for id, state := range states { + for id := range states { + state := states[id] if state.Status == "running" { return id, true } @@ -302,6 +303,7 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no func finishWorkflowRun(t *transaction, runID string, version int64, status, runErr string, states map[string]workflowNodeState) { if status == "cancelled" { cancelWorkflowLoops(states) + cancelWorkflowIterations(states) } if settleWorkflowRunStates(t, runID, version, status, runErr, states) { finishWorkflowRunCleanup(t, runID) @@ -490,7 +492,8 @@ func workflowPinnedPluginGap(t *transaction, run, w Object) string { // workflowAnyCancelled reports whether any committed node was cancelled. func workflowAnyCancelled(states map[string]workflowNodeState) bool { - for _, state := range states { + for id := range states { + state := states[id] if state.Status == "cancelled" { return true } diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 25d224aaa..5ddce8daf 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -299,6 +299,9 @@ func (s *workflowNodeState) object() Object { if s.Loop != nil { out["loop"] = s.Loop.object() } + if s.Iteration != nil { + out["iteration"] = s.Iteration.object() + } return out } @@ -328,6 +331,9 @@ func workflowStatesFromJSON(raw Object) map[string]workflowNodeState { if loop, ok := asWorkflowObject(node["loop"]); ok { state.Loop = workflowLoopRunStateFromJSON(loop) } + if iteration, ok := asWorkflowObject(node["iteration"]); ok { + state.Iteration = workflowIterationRunStateFromJSON(iteration) + } out[id] = state } return out @@ -352,7 +358,8 @@ func projectWorkflowNodes(graph workflowGraphView, committed map[string]workflow committed: committed, projected: map[string]string{}, } - for id, state := range committed { + for id := range committed { + state := committed[id] p.projected[id] = state.Status } for { @@ -744,7 +751,8 @@ func settleWorkflowRunStates(t *transaction, runID string, version int64, status version=version+1, updated_at=now() WHERE id=$1 AND version=$4 AND deleted_at IS NULL`, runID, status, runErr, version, terminal) } else { - for id, state := range states { + for id := range states { + state := states[id] encoded[id] = state.object() } applied = t.execRows(`UPDATE workflow_runs SET node_states=$2, status=$3, error=$4, @@ -898,10 +906,11 @@ func workflowConditionStates(states, ambient map[string]workflowNodeState) map[s return states } out := make(map[string]workflowNodeState, len(states)+len(ambient)) - for id, state := range ambient { - out[id] = state + for id := range ambient { + out[id] = ambient[id] } - for id, state := range states { + for id := range states { + state := states[id] if existing, ok := out[id]; ok { existing.Output = mergeWorkflowOutput(existing.Output, state.Output) out[id] = existing