From 606949e44cc4d2ceb2dcdcc860fd42fa42710e94 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 13:09:42 +0800 Subject: [PATCH 01/11] feat(core): evaluate conditions with desktop's semantics The publish gate could refuse condition documents Cloud evaluated differently from desktop, but refusing was the ceiling: array/object `contains`, non-string `starts_with`/`ends_with`, expected-value templates and the selector shape were unavailable rather than aligned. This closes circus#210's four differences against desktop's engine/condition.rs, which is the reference implementation. - contains is substring for strings and element equality for arrays; other types are a type mismatch. starts_with/ends_with require strings on both sides. Numeric ordering requires two numbers. Absence is answered only by exists/not_exists/empty/not_empty; every comparator, not_contains included, fails on an unset variable instead of silently taking else, and a malformed case/rule/operator fails the node and the run. - Expected values resolve Dify-style `{{#node.var#}}` templates: an exclusive placeholder keeps the bound JSON type, a mixed template renders a string, and an unset or malformed selector fails rather than comparing placeholder text. - The Start node exposes desktop's variable roots: `input` is the kickoff prompt and every run-form variable is its own root. Cloud previously nested the whole run input under `input`, so the same selector read different values on the two sides. - The publish gate drops condition_value_template_unsupported; the other refusals remain. Tests mirror the named desktop cases for each difference and cover the start node's roots and the failed-node path. The condition template renderer moved to a sibling file to keep both files under the repository's size guidance. Refs ora-space/circus#215 Co-Authored-By: Claude Code --- internal/core/workflow_condition.go | 553 ++++++++++++------ .../core/workflow_condition_parity_test.go | 181 ++++++ internal/core/workflow_condition_template.go | 97 +++ internal/core/workflow_graph.go | 19 +- internal/core/workflow_graph_test.go | 21 +- internal/core/workflow_scheduler.go | 45 +- internal/core/workflow_scheduler_db_test.go | 22 +- internal/core/workflow_scheduler_test.go | 213 ++++--- 8 files changed, 882 insertions(+), 269 deletions(-) create mode 100644 internal/core/workflow_condition_parity_test.go create mode 100644 internal/core/workflow_condition_template.go diff --git a/internal/core/workflow_condition.go b/internal/core/workflow_condition.go index 3e3021298..bf914279e 100644 --- a/internal/core/workflow_condition.go +++ b/internal/core/workflow_condition.go @@ -1,23 +1,145 @@ package core -import "strings" +import ( + "encoding/json" + "fmt" + "math" + "strings" +) // Workflow Condition node semantics, ported from desktop's engine/condition.rs so a branch decision // means the same thing on both sides (specs#99 D1 条件④: the scheduler boundary mirrors desktop's // ports, and desktop is the reference implementation). // -// The variable pool is the run's committed node outputs: a rule's variableSelector [nodeId, ...path] -// resolves by reading prior node states — selector[0] names the node, selector[1:] walks into its -// output object. A variable a prior node has not produced (unknown node, absent path, or a node that -// never ran) is absent, which the operators handle deliberately: exists / not_exists / empty / -// not_empty classify absence, and every other operator compares nothing — a comparator on an absent -// variable does not match. None of this is a run failure: an unevaluable condition has an else -// branch to fall back to, and a failed run should name a real execution failure, not a document the -// author must fix anyway. +// The variable pool is the run's committed node outputs: a rule's variableSelector is +// [nodeId, root, ...nested]. nodeId names a prior node, root is one variable that node exposes +// (start: `input` plus each declared input variable; agent: `output`/`structured_output`; condition: +// `selected_branch_id`; output: `output`), and nested walks object fields past the root. A variable a +// prior node has not produced (unknown node, unassigned root, or a node that never ran) is absent. +// +// Absence is not a universal no-match: desktop's existence and emptiness operators classify it, and +// every other operator fails the node and the run rather than silently falling through to else. The +// same applies to a value whose type the operator cannot compare. That failure is the point of the +// parity work (circus#210): a condition the author could not have meant must be visible, not routed. +// +// The expected value may also be a Dify-style template. `{{#node.var#}}` alone keeps the bound JSON +// type (so numeric and boolean comparisons stay typed); a template mixed with literal text renders +// as a string. Both forms resolve every placeholder eagerly, so an unset variable fails the node +// before a branch is chosen. + +// workflowConditionError is one runtime condition failure. Code is the stable identifier callers and +// tests key on; the message names the selector and operator the way desktop's ConditionError does so +// the run viewer points at the value that needs fixing. +type workflowConditionError struct { + Code string + Detail string +} + +func (e *workflowConditionError) Error() string { return e.Detail } + +func workflowConditionFailure(code, format string, args ...any) *workflowConditionError { + return &workflowConditionError{Code: code, Detail: fmt.Sprintf(format, args...)} +} + +// workflowSelector is a resolved variableSelector. Root is the variable name inside the node's +// output, not part of an opaque path, which is what makes Cloud's selector the same shape desktop's +// variable pool uses. +type workflowSelector struct { + nodeID string + root string + nested []string +} + +func (s workflowSelector) qualified() string { + parts := make([]string, 0, 2+len(s.nested)) + parts = append(parts, s.nodeID, s.root) + parts = append(parts, s.nested...) + return strings.Join(parts, ".") +} + +// parseWorkflowSelector reads a wire variableSelector. The editor sends JSON arrays, so []any is the +// production shape; []string is accepted too because hand-built documents and tests spell selectors +// naturally that way. A selector needs both a node and a root: one segment would name a whole node +// output, which desktop refuses. +func parseWorkflowSelector(raw any) (workflowSelector, bool) { + var parts []string + switch value := raw.(type) { + case []any: + parts = make([]string, len(value)) + for i, element := range value { + text, ok := element.(string) + if !ok { + return workflowSelector{}, false + } + parts[i] = text + } + case []string: + parts = value + default: + return workflowSelector{}, false + } + return buildWorkflowSelector(parts) +} + +// buildWorkflowSelector validates selector parts from either a wire array or a template placeholder. +func buildWorkflowSelector(parts []string) (workflowSelector, bool) { + if len(parts) < 2 { + return workflowSelector{}, false + } + for _, part := range parts { + if part == "" { + return workflowSelector{}, false + } + } + return workflowSelector{nodeID: parts[0], root: parts[1], nested: parts[2:]}, true +} + +// resolveWorkflowSelector reads one selector out of committed node states. The boolean reports +// whether the variable is assigned; absence is an ordinary state the operators classify. A nested +// path that cannot be walked is an error, matching desktop's variable pool: once the root exists, a +// missing or non-object field means the selector names something the author did not produce. +func resolveWorkflowSelector(selector workflowSelector, states map[string]workflowNodeState) (value any, present bool, err error) { + state, ok := states[selector.nodeID] + if !ok || state.Status != "succeeded" { + return nil, false, nil + } + output, ok := asWorkflowObject(state.Output) + if !ok { + return nil, false, nil + } + value, present = output[selector.root] + if !present { + return nil, false, nil + } + for _, segment := range selector.nested { + object, ok := asWorkflowObject(value) + if !ok { + return nil, false, workflowConditionFailure("condition_invalid_path", + "condition variable %s is not an object, so its path cannot be resolved", selector.qualified()) + } + value, present = object[segment] + if !present { + return nil, false, workflowConditionFailure("condition_invalid_path", + "condition variable %s has no object path segment %s", selector.qualified(), segment) + } + } + return value, true, nil +} + +// workflowConditionOperand is one rule's fully resolved inputs: the variable, the expected value and +// the names both are reported with. hasExpected distinguishes a missing `value` from an explicit +// null, which desktop compares as a real JSON value. +type workflowConditionOperand struct { + actual any + present bool + expected any + hasExpected bool + selector string + operator string +} -// workflowConditionOperator evaluates one comparison rule. absent reports whether the selector -// resolved to nothing; resolved is the variable when it is present. -type workflowConditionOperator = func(resolved any, absent bool, expected any) bool +// workflowConditionOperator evaluates one comparison rule; an error fails the node and the run. +type workflowConditionOperator func(*workflowConditionOperand) (bool, error) // workflowConditionOperators is desktop's full wire operator set (condition.rs // ComparisonOperator::parse). `is`/`is_not` are the same comparisons as `equals`/`not_equals`. @@ -33,240 +155,333 @@ var workflowConditionOperators = map[string]workflowConditionOperator{ "is_not": workflowCompareNotEquals, "contains": workflowCompareContains, "not_contains": workflowCompareNotContains, - "starts_with": workflowCompareTextOrder("starts"), - "ends_with": workflowCompareTextOrder("ends"), + "starts_with": workflowCompareTextOrder(true), + "ends_with": workflowCompareTextOrder(false), "empty": workflowCompareEmpty, - "not_empty": workflowCompareNegate(workflowCompareEmpty), + "not_empty": workflowCompareNotEmpty, "exists": workflowCompareExists, - "not_exists": workflowCompareNegate(workflowCompareExists), + "not_exists": workflowCompareNotExists, "greater_than": workflowCompareNumeric("greater"), "greater_than_or_equal": workflowCompareNumeric("greater_eq"), "less_than": workflowCompareNumeric("less"), "less_than_or_equal": workflowCompareNumeric("less_eq"), } -func workflowCompareEquals(resolved any, absent bool, expected any) bool { - if absent { - return false +// operands requires an assigned variable and an expected value. Existence and emptiness are the +// only operators that read an absent variable; every other one fails instead of treating absence as +// false, so an unset variable can never silently choose the else branch. +func (o *workflowConditionOperand) operands() (actual, expected any, err error) { + if !o.present { + return nil, nil, workflowConditionFailure("condition_variable_unset", + "condition variable %s has not been assigned yet", o.selector) } - return workflowJSONEqual(resolved, expected) + if !o.hasExpected { + return nil, nil, workflowCompareTypeMismatch(o, "a comparison value") + } + return o.actual, o.expected, nil } -func workflowCompareNotEquals(resolved any, absent bool, expected any) bool { - if absent { - return false +func workflowCompareTypeMismatch(o *workflowConditionOperand, expectedLabel string) error { + return workflowConditionFailure("condition_type_mismatch", + "operator %s cannot compare variable %s of type %s against %s", + o.operator, o.selector, workflowTypeLabel(o.actual), expectedLabel) +} + +func workflowCompareEquals(o *workflowConditionOperand) (bool, error) { + actual, expected, err := o.operands() + if err != nil { + return false, err } - return !workflowJSONEqual(resolved, expected) + return workflowJSONEqual(actual, expected), nil } -func workflowCompareContains(resolved any, absent bool, expected any) bool { - if absent { - return false +func workflowCompareNotEquals(o *workflowConditionOperand) (bool, error) { + matched, err := workflowCompareEquals(o) + if err != nil { + return false, err } - return strings.Contains(workflowTextOf(resolved), workflowTextOf(expected)) + return !matched, nil } -// workflowCompareNotContains is contains' negation with one deliberate exception: absence. Every -// comparator in this file treats an absent variable as a no-match (see the file comment), so -// not_contains has to answer false there too. Negating the inner verdict instead would answer -// true, which puts not_contains and not_equals on opposite branches of the same absent variable — -// not_contains would select its case while not_equals fell through to else. The negation belongs to -// the comparison, not to the classification of absence. -func workflowCompareNotContains(resolved any, absent bool, expected any) bool { - if absent { - return false +func workflowCompareContains(o *workflowConditionOperand) (bool, error) { + actual, expected, err := o.operands() + if err != nil { + return false, err + } + switch value := actual.(type) { + case string: + needle, ok := expected.(string) + if !ok { + return false, workflowCompareTypeMismatch(o, workflowTypeLabel(expected)) + } + return strings.Contains(value, needle), nil + case []any: + for _, item := range value { + if workflowJSONEqual(item, expected) { + return true, nil + } + } + return false, nil + default: + // desktop's contains is defined for strings (substring) and arrays (element equality) only; + // an object or scalar has no containment relation to guess at. + return false, workflowCompareTypeMismatch(o, workflowTypeLabel(expected)) } - return !workflowCompareContains(resolved, absent, expected) } -// workflowCompareTextOrder serves both text-position operators: "starts" is prefix, "ends" is -// suffix; any other direction is unreachable. -func workflowCompareTextOrder(direction string) workflowConditionOperator { - return func(resolved any, absent bool, expected any) bool { - if absent { - return false +func workflowCompareNotContains(o *workflowConditionOperand) (bool, error) { + matched, err := workflowCompareContains(o) + if err != nil { + return false, err + } + return !matched, nil +} + +// workflowCompareTextOrder serves both text-position operators: starts is prefix, ends is suffix. +// desktop requires both sides to be strings; a number or object is a type error, not text. +func workflowCompareTextOrder(starts bool) workflowConditionOperator { + return func(o *workflowConditionOperand) (bool, error) { + actual, expected, err := o.operands() + if err != nil { + return false, err } - resolvedText, expectedText := workflowTextOf(resolved), workflowTextOf(expected) - switch direction { - case "starts": - return strings.HasPrefix(resolvedText, expectedText) - default: - return strings.HasSuffix(resolvedText, expectedText) + actualText, okActual := actual.(string) + expectedText, okExpected := expected.(string) + if !okActual || !okExpected { + return false, workflowCompareTypeMismatch(o, workflowTypeLabel(expected)) } + if starts { + return strings.HasPrefix(actualText, expectedText), nil + } + return strings.HasSuffix(actualText, expectedText), nil } } -func workflowCompareEmpty(resolved any, absent bool, expected any) bool { - if absent { - return true // absence is the emptiest a variable can be +func workflowCompareEmpty(o *workflowConditionOperand) (bool, error) { + return workflowValueEmpty(o.actual, o.present), nil +} + +func workflowCompareNotEmpty(o *workflowConditionOperand) (bool, error) { + return !workflowValueEmpty(o.actual, o.present), nil +} + +func workflowCompareExists(o *workflowConditionOperand) (bool, error) { + return o.present, nil +} + +func workflowCompareNotExists(o *workflowConditionOperand) (bool, error) { + return !o.present, nil +} + +// workflowValueEmpty is desktop's is_empty: unassigned, null, and an empty string/array/object are +// empty; booleans and numbers are not. +func workflowValueEmpty(value any, present bool) bool { + if !present { + return true } - switch value := resolved.(type) { + switch typed := value.(type) { case nil: return true case string: - return value == "" + return typed == "" case []any: - return len(value) == 0 + return len(typed) == 0 default: // The object case goes through asWorkflowObject rather than a `case map[string]any` arm: // Object is a named type, so an Object boxed in `any` — which is exactly what a start node's // output is, since it wraps the run input — never matches that arm and would fall through to // "not empty". Reading a nested object out of a node output hits the same trap one level in. - obj, ok := asWorkflowObject(value) + object, ok := asWorkflowObject(typed) if !ok { return false } - return len(obj) == 0 + return len(object) == 0 } } -func workflowCompareExists(resolved any, absent bool, expected any) bool { - return !absent +// workflowCompareNumeric serves the four ordering operators. Both sides must be JSON numbers; +// text against a number is a type error, exactly as desktop's compare_numbers answers None. +func workflowCompareNumeric(direction string) workflowConditionOperator { + return func(o *workflowConditionOperand) (bool, error) { + actual, expected, err := o.operands() + if err != nil { + return false, err + } + left, okLeft := workflowNumberOf(actual) + right, okRight := workflowNumberOf(expected) + if !okLeft || !okRight { + return false, workflowCompareTypeMismatch(o, workflowTypeLabel(expected)) + } + switch direction { + case "greater": + return left > right, nil + case "greater_eq": + return left >= right, nil + case "less": + return left < right, nil + default: + return left <= right, nil + } + } } -// workflowCompareNegate flips one operator's verdict. The absent-handling stays the inner -// operator's, so not_exists matches exactly what exists does not. -func workflowCompareNegate(inner workflowConditionOperator) workflowConditionOperator { - return func(resolved any, absent bool, expected any) bool { - return !inner(resolved, absent, expected) +// workflowNumberOf reads any Go numeric representation JSON decoding can leave in a document. +func workflowNumberOf(value any) (float64, bool) { + switch number := value.(type) { + case float64: + return number, true + case float32: + return float64(number), true + case int: + return float64(number), true + case int64: + return float64(number), true + case int32: + return float64(number), true + case uint: + return float64(number), true + case uint64: + return float64(number), true + case uint32: + return float64(number), true + case json.Number: + parsed, err := number.Float64() + return parsed, err == nil + default: + return 0, false } } -// workflowTextOf renders a JSON value as the text a text operator compares. A string is itself; -// anything else is its canonical JSON, so comparing against a number or object stays deterministic. -func workflowTextOf(value any) string { - if text, ok := value.(string); ok { - return text +// workflowTypeLabel names a JSON value's type for a type-mismatch message. Integer-valued floats are +// called integers, matching desktop's type_label, so the same document reports the same mismatch. +func workflowTypeLabel(value any) string { + switch value.(type) { + case nil: + return "null" + case bool: + return "boolean" + case string: + return "string" + case []any: + return "array" + } + if number, ok := workflowNumberOf(value); ok { + if number == math.Trunc(number) { + return "integer" + } + return "number" } - return canonicalJSON(value) + if _, ok := asWorkflowObject(value); ok { + return "object" + } + return "value" } // workflowJSONEqual compares two JSON values by canonical form (encoding/json sorts map keys), so -// object comparison is order-independent and deterministic. A value JSON cannot represent cannot be -// compared — that is a no-match, not a panic. -func workflowJSONEqual(resolved, expected any) bool { - a, b := canonicalJSON(resolved), canonicalJSON(expected) +// object comparison is order-independent and deterministic. Numeric spellings 2 and 2.0 render the +// same way, which is desktop's equal() across integer and float representations. +func workflowJSONEqual(actual, expected any) bool { + a, b := canonicalJSON(actual), canonicalJSON(expected) if a == "" || b == "" { return false } return a == b } -// workflowCompareNumeric serves the four ordering operators. Only two numbers are orderable; text -// against a number is a no-match rather than an error. -func workflowCompareNumeric(direction string) workflowConditionOperator { - return func(resolved any, absent bool, expected any) bool { - if absent { - return false - } - resolvedNum, okA := resolved.(float64) - expectedNum, okB := expected.(float64) - if !okA || !okB { - return false - } - switch direction { - case "greater": - return resolvedNum > expectedNum - case "greater_eq": - return resolvedNum >= expectedNum - case "less": - return resolvedNum < expectedNum - default: - return resolvedNum <= expectedNum - } - } -} - // evaluateWorkflowCondition evaluates a Condition node's cases against the committed node states and -// returns the selected branch id: the first case whose rules match under the case's logic, or `else` -// when none did. -func evaluateWorkflowCondition(node workflowNodeView, states map[string]workflowNodeState) string { - cases, ok := node.Data["cases"].([]any) +// returns the selected branch id: the first case whose rules match under its case logic, or `else` +// when none did. A malformed document or an unevaluable rule is an error: the publish gate refuses +// such documents, and a snapshot that predates the gate must fail loudly rather than route wrongly. +func evaluateWorkflowCondition(node workflowNodeView, states map[string]workflowNodeState) (string, error) { + raw, present := node.Data["cases"] + if !present || raw == nil { + return workflowBranchElse, nil + } + cases, ok := raw.([]any) if !ok { - return workflowBranchElse + return "", workflowConditionFailure("condition_invalid_config", "condition cases must be a list") } - for _, raw := range cases { - c, ok := asWorkflowObject(raw) + for _, item := range cases { + c, ok := asWorkflowObject(item) if !ok { - continue + return "", workflowConditionFailure("condition_invalid_config", "condition case must be an object") } - rules, _ := c["conditions"].([]any) - if len(rules) == 0 { + id := c.S("id") + if id == "" { + return "", workflowConditionFailure("condition_invalid_config", "condition case is missing an id") + } + logic := c.S("logic") + if logic != "" && logic != "and" && logic != "or" { + return "", workflowConditionFailure("condition_invalid_config", "condition case has unknown logic %s", logic) + } + rawRules, present := c["conditions"] + if !present || rawRules == nil { continue // an authored shell with no rule yet falls through } - matched := false - if c.S("logic") == "or" { - for _, raw := range rules { - if workflowConditionRule(raw, states) { - matched = true - break - } + rules, ok := rawRules.([]any) + if !ok { + return "", workflowConditionFailure("condition_invalid_config", "condition case conditions must be a list") + } + if len(rules) == 0 { + continue + } + // Evaluate every rule before combining them, as desktop does: an or-case whose first rule + // matches must still report an unset variable in its second rule instead of skipping it. + verdicts := make([]bool, len(rules)) + for i, rawRule := range rules { + verdict, err := workflowConditionRule(rawRule, states) + if err != nil { + return "", err } - } else { - matched = true - for _, raw := range rules { - if !workflowConditionRule(raw, states) { - matched = false - break - } + verdicts[i] = verdict + } + matched := logic != "or" + for _, verdict := range verdicts { + if logic == "or" { + matched = matched || verdict + } else { + matched = matched && verdict } } if matched { - return c.S("id") + return id, nil } } - return workflowBranchElse + return workflowBranchElse, nil } -// workflowConditionRule resolves a rule's variableSelector and applies its operator. A malformed -// rule (not an object, malformed selector, unknown operator) is a no-match. -func workflowConditionRule(raw any, states map[string]workflowNodeState) bool { +// workflowConditionRule resolves a rule's variableSelector and expected value, then applies its +// operator. A malformed rule is an error, not a no-match. +func workflowConditionRule(raw any, states map[string]workflowNodeState) (bool, error) { r, ok := asWorkflowObject(raw) if !ok { - return false + return false, workflowConditionFailure("condition_invalid_rule", "condition rule must be an object") } - selector, ok := r["variableSelector"].([]any) - if !ok || len(selector) == 0 { - return false - } - nodeID, ok := selector[0].(string) - if !ok || nodeID == "" { - return false - } - state, ok := states[nodeID] - if !ok || state.Status != "succeeded" { - return false + selector, ok := parseWorkflowSelector(r["variableSelector"]) + if !ok { + return false, workflowConditionFailure("condition_invalid_rule", + "condition rule selector is not a node id and root variable") } - resolved, absent := walkWorkflowOutputPath(state.Output, selector[1:]) - operator, ok := workflowConditionOperators[r.S("operator")] + operatorName := r.S("operator") + operator, ok := workflowConditionOperators[operatorName] if !ok { - return false + return false, workflowConditionFailure("condition_invalid_rule", + "condition rule has unknown operator %s", operatorName) } - return operator(resolved, absent, r["value"]) -} - -// walkWorkflowOutputPath walks a variableSelector's path into one node's output. The first path -// element indexes the output object; deeper elements walk further in. Absent is the honest answer -// for a node whose output is not an object or a path that leaves the object. -func walkWorkflowOutputPath(output Object, path []any) (any, bool) { - if output == nil { - return nil, true + actual, present, err := resolveWorkflowSelector(selector, states) + if err != nil { + return false, err } - current := any(output) - for _, element := range path { - obj, ok := asWorkflowObject(current) - if !ok { - return nil, true - } - key, ok := element.(string) - if !ok { - return nil, true - } - value, present := obj[key] - if !present { - return nil, true - } - current = value + expected, hasExpected, err := resolveWorkflowExpected(r, states) + if err != nil { + return false, err } - return current, false + return operator(&workflowConditionOperand{ + actual: actual, + present: present, + expected: expected, + hasExpected: hasExpected, + selector: selector.qualified(), + operator: operatorName, + }) } diff --git a/internal/core/workflow_condition_parity_test.go b/internal/core/workflow_condition_parity_test.go new file mode 100644 index 000000000..73b342eb7 --- /dev/null +++ b/internal/core/workflow_condition_parity_test.go @@ -0,0 +1,181 @@ +package core + +import "testing" + +// Cross-checks for circus#210: the same graph input and the same condition document must produce the +// same branch on Cloud and on desktop. Each case mirrors a test in desktop's +// crates/application/src/workflow_run/engine/condition.rs (named in the table), so the expectation +// is not Cloud's opinion of the operator but the reference implementation's answer. +// +// Desktop's tests are named here as the source of the expected behavior; this file does not run the +// Rust engine. A change to either side that breaks parity must break one of these cases. +func TestWorkflowConditionDesktopParity(t *testing.T) { + // The pool shapes mirror pool_with_review(): a text root, a structured_output object, and a + // start node whose variables include a numeric limit. + pool := map[string]workflowNodeState{ + "review": {Status: "succeeded", Output: Object{ + "text": "审查完成", + "structured_output": Object{"approved": true, "score": 92.0}, + }}, + "start": {Status: "succeeded", Output: Object{ + "token": "审查", + "limit": 90.0, + }}, + } + evaluate := func(t *testing.T, cases []any) (string, error) { + t.Helper() + return evaluateWorkflowCondition(workflowNodeView{ID: "c", Kind: "condition", Data: Object{"cases": cases}}, pool) + } + rule := func(selector []any, operator string, value any) Object { + return Object{"variableSelector": selector, "operator": operator, "value": value} + } + one := func(selector []any, operator string, value any) []any { + return []any{Object{"id": "c", "logic": "and", "conditions": []any{rule(selector, operator, value)}}} + } + + cases := []struct { + name string + desktop string + cases []any + want string + wantError bool + }{ + { + name: "and requires every rule", + desktop: "logic_and_requires_all_or_requires_any", + cases: []any{Object{"id": "high", "logic": "and", "conditions": []any{ + rule([]any{"review", "text"}, "not_empty", nil), + rule([]any{"review", "structured_output", "score"}, "greater_than", 90.0), + }}}, + want: "high", + }, + { + name: "or requires any rule", + desktop: "logic_and_requires_all_or_requires_any", + cases: []any{Object{"id": "flag", "logic": "or", "conditions": []any{ + rule([]any{"review", "text"}, "contains", "不存在"), + rule([]any{"review", "structured_output", "score"}, "greater_than_or_equal", 92.0), + }}}, + want: "flag", + }, + { + name: "existence tolerates an unassigned variable", + desktop: "existence_and_empty_handle_unset_variables", + cases: []any{Object{"id": "missing", "logic": "and", "conditions": []any{ + rule([]any{"agent-a", "text"}, "not_exists", nil), + }}}, + want: "missing", + }, + { + name: "a value comparison on an unassigned variable fails", + desktop: "value_comparison_fails_on_unset_variable", + cases: one([]any{"agent-a", "text"}, "equals", "x"), + wantError: true, + }, + { + name: "string operators match on text", + desktop: "string_operators_match_on_text", + cases: one([]any{"review", "text"}, "contains", "完成"), + want: "c", + }, + { + name: "numeric operators compare across representations", + desktop: "numeric_operators_compare_across_representations", + // The expected value is an integer while the pool value is a float, as desktop's json!(90) + // against a float score. + cases: one([]any{"review", "structured_output", "score"}, "greater_than", 90), + want: "c", + }, + { + name: "contains on an object is a type mismatch", + desktop: "string_operator_on_object_fails_as_type_mismatch", + cases: one([]any{"review", "structured_output"}, "contains", "approved"), + wantError: true, + }, + { + name: "an exclusive placeholder keeps the bound type", + desktop: "comparison_value_placeholder_keeps_bound_type", + cases: one([]any{"review", "structured_output", "score"}, "greater_than", "{{#start.limit#}}"), + want: "c", + }, + { + name: "a mixed template renders as a string", + desktop: "mixed_comparison_value_template_renders_as_string", + cases: one([]any{"review", "text"}, "contains", "{{#start.token#}}完"), + want: "c", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := evaluate(t, tc.cases) + if tc.wantError { + if err == nil { + t.Fatalf("selected %q; want the error desktop raises for %s", got, tc.desktop) + } + return + } + if err != nil { + t.Fatalf("evaluation error: %v (desktop: %s)", err, tc.desktop) + } + if got != tc.want { + t.Fatalf("selected %q; want %q (desktop: %s)", got, tc.want, tc.desktop) + } + }) + } +} + +// The selector shape is part of parity: desktop indexes [node, root, ...nested] against the node's +// variable roots, so [start, limit] reads a run-form variable and [start, input] reads the kickoff +// prompt. The old Cloud shape nested the whole form under input, which made [start, limit] resolve +// nothing and [start, input, limit] accidentally work. +func TestWorkflowConditionSelectorShapeMatchesDesktop(t *testing.T) { + node := workflowNodeView{ID: "start", Kind: "start", Data: Object{"kind": "start", "input": "kickoff"}} + output := workflowStartOutput(node, Object{"limit": 90.0}) + states := map[string]workflowNodeState{"start": {Status: "succeeded", Output: output}} + + rule := func(selector []any) Object { + return Object{"id": "c", "logic": "and", "conditions": []any{ + Object{"variableSelector": selector, "operator": "equals", "value": 90.0}, + }} + } + if branch, err := evaluateWorkflowCondition( + workflowNodeView{ID: "c", Kind: "condition", Data: Object{"cases": []any{rule([]any{"start", "limit"})}}}, states, + ); err != nil || branch != "c" { + t.Fatalf("desktop-shaped selector [start, limit] = %q, %v; want c", branch, err) + } + if _, err := evaluateWorkflowCondition( + workflowNodeView{ID: "c", Kind: "condition", Data: Object{"cases": []any{rule([]any{"start", "input", "limit"})}}}, states, + ); err == nil { + t.Fatal("old Cloud-shaped selector [start, input, limit] resolved; want a path error") + } + + promptRule := Object{"id": "c", "logic": "and", "conditions": []any{ + Object{"variableSelector": []any{"start", "input"}, "operator": "equals", "value": "kickoff"}, + }} + if branch, err := evaluateWorkflowCondition( + workflowNodeView{ID: "c", Kind: "condition", Data: Object{"cases": []any{promptRule}}}, states, + ); err != nil || branch != "c" { + t.Fatalf("[start, input] = %q, %v; want c", branch, err) + } +} + +// A condition error fails the node and the run instead of committing a branch. The run's failure +// reason is the evaluation message, so the viewer names the selector that needs fixing. +func TestWorkflowConditionFailureFailsTheNode(t *testing.T) { + node := workflowNodeView{ID: "c", Kind: "condition", Data: Object{"cases": []any{ + Object{"id": "yes", "logic": "and", "conditions": []any{ + Object{"variableSelector": []any{"s", "missing"}, "operator": "equals", "value": true}, + }}, + }}} + states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{}}} + state := executeWorkflowNode(node, nil, states) + if state.Status != "failed" || state.Error == "" { + t.Fatalf("state = %+v; want a failed node carrying the evaluation error", state) + } + if state.Output.S("selectedBranch") != "" { + t.Fatalf("failed condition committed branch %q", state.Output.S("selectedBranch")) + } + if state.Output.S("reason") != state.Error { + t.Fatalf("reason %q != error %q", state.Output.S("reason"), state.Error) + } +} diff --git a/internal/core/workflow_condition_template.go b/internal/core/workflow_condition_template.go new file mode 100644 index 000000000..45ef4b728 --- /dev/null +++ b/internal/core/workflow_condition_template.go @@ -0,0 +1,97 @@ +package core + +import "strings" + +// Condition expected-value templates, ported from desktop's engine/variable_template.rs and +// engine/condition.rs. A rule's expected value may reference the run's variable pool, and the form +// of the reference decides the result type: a value that is only `{{#node.var#}}` keeps the bound +// JSON type, while a template mixed with literal text always renders a string. Resolution is eager, +// so an unset variable or a malformed placeholder fails the condition instead of comparing text that +// was never expanded. + +// resolveWorkflowExpected expands a template expected value. The bool reports whether the rule +// carried a `value` at all; explicit null is a value desktop compares against. +func resolveWorkflowExpected(rule Object, states map[string]workflowNodeState) (expected any, hasExpected bool, err error) { + raw, present := rule["value"] + if !present { + return nil, false, nil + } + text, isText := raw.(string) + if !isText || !strings.Contains(text, "{{#") { + return raw, true, nil + } + if selector, ok := exclusiveWorkflowTemplate(text); ok { + value, assigned, err := resolveWorkflowSelector(selector, states) + if err != nil { + return nil, false, err + } + if !assigned { + return nil, false, workflowConditionFailure("condition_variable_unset", + "condition variable %s has not been assigned yet", selector.qualified()) + } + return value, true, nil + } + rendered, err := renderWorkflowTemplate(text, states) + if err != nil { + return nil, false, err + } + return rendered, true, nil +} + +// exclusiveWorkflowTemplate reads a string that is exactly one `{{#selector#}}` placeholder. It +// returns false for anything else — including a malformed one, which the mixed renderer then reports +// with the same selector diagnosis desktop's template renderer gives. +func exclusiveWorkflowTemplate(text string) (workflowSelector, bool) { + trimmed := strings.TrimSpace(text) + inner, ok := strings.CutPrefix(trimmed, "{{#") + if !ok { + return workflowSelector{}, false + } + inner, ok = strings.CutSuffix(inner, "#}}") + if !ok || strings.Contains(inner, "{{#") || strings.Contains(inner, "#}}") { + return workflowSelector{}, false + } + return buildWorkflowSelector(strings.Split(inner, ".")) +} + +// renderWorkflowTemplate renders every placeholder in a mixed template: strings verbatim, every +// other value as JSON. An unterminated placeholder or an invalid selector is an error, never a +// silent blank, matching desktop's render_variable_template. +func renderWorkflowTemplate(text string, states map[string]workflowNodeState) (string, error) { + var rendered strings.Builder + rest := text + for { + open := strings.Index(rest, "{{#") + if open < 0 { + rendered.WriteString(rest) + return rendered.String(), nil + } + rendered.WriteString(rest[:open]) + afterOpen := rest[open+len("{{#"):] + closeAt := strings.Index(afterOpen, "#}}") + if closeAt < 0 { + return "", workflowConditionFailure("condition_invalid_template", + "condition template has an unterminated variable reference") + } + selectorText := afterOpen[:closeAt] + selector, ok := buildWorkflowSelector(strings.Split(selectorText, ".")) + if !ok { + return "", workflowConditionFailure("condition_invalid_template", + "condition template selector %s is not a node id and root variable", selectorText) + } + value, assigned, err := resolveWorkflowSelector(selector, states) + if err != nil { + return "", err + } + if !assigned { + return "", workflowConditionFailure("condition_variable_unset", + "condition variable %s has not been assigned yet", selector.qualified()) + } + if text, ok := value.(string); ok { + rendered.WriteString(text) + } else { + rendered.WriteString(canonicalJSON(value)) + } + rest = afterOpen[closeAt+len("#}}"):] + } +} diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 91154c1a1..36c074d66 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -235,17 +235,18 @@ func validateAgentConfig(nodeID string, data Object) []workflowGraphProblem { // written out rather than derived because it is a statement about the two implementations, not about // Cloud's own code: each entry is a place where the evaluator would otherwise answer silently — // an unknown operator is a no-match, an unknown logic falls back to `and`, a missing case id becomes -// the else branch, a one-segment selector becomes the whole node output, and an expected value -// carrying `{{#selector#}}` is compared as literal text where desktop resolves it and keeps the -// bound type (circus#202 A 条件 2, #141 结论 5). Every one of them is decidable from the document -// alone, which is what makes refusing at publish possible; the differences that depend on a -// *resolved* value's type at run time are not, and are named in the ADR instead. +// the else branch, and a one-segment selector becomes the whole node output (circus#202 A 条件 2, +// #141 结论 5). Every one of them is decidable from the document alone, which is what makes refusing +// at publish possible; the differences that depend on a *resolved* value's type at run time are not, +// and are named in the ADR instead. // // A condition node with no cases, or a case with no rules, is deliberately NOT on this list: falling // through to else is what an unfinished node is meant to do, and both sides already agree on it. // -// When the parity work lands (circus#210, split out by circus#202 A 条件 3) these refusals come off -// one at a time, each with the cross-check case that proves the two sides now answer the same thing. +// circus#210 closed the first entry: an expected value carrying `{{#selector#}}` now resolves the +// same way desktop does — an exclusive placeholder keeps the bound JSON type, a mixed one renders a +// string, and an unset selector fails the node — so the gate no longer refuses it. The remaining +// entries stay until the evaluator stops silently reinterpreting each one. var workflowUnsupportedConditionConfigs = []string{ "condition_cases_not_a_list", "condition_case_not_an_object", @@ -255,7 +256,6 @@ var workflowUnsupportedConditionConfigs = []string{ "condition_rule_not_an_object", "condition_selector_unsupported", "condition_operator_unsupported", - "condition_value_template_unsupported", } // validateConditionConfig refuses the condition configuration the first phase does not evaluate the @@ -331,9 +331,6 @@ func validateConditionRule(nodeID string, raw any) []workflowGraphProblem { if _, ok := workflowConditionOperators[r.S("operator")]; !ok { problems = append(problems, workflowGraphProblem{Code: "condition_operator_unsupported", NodeID: nodeID, Field: "cases.conditions.operator"}) } - if text, ok := r["value"].(string); ok && strings.Contains(text, "{{#") && strings.Contains(text, "#}}") { - problems = append(problems, workflowGraphProblem{Code: "condition_value_template_unsupported", NodeID: nodeID, Field: "cases.conditions.value"}) - } return problems } diff --git a/internal/core/workflow_graph_test.go b/internal/core/workflow_graph_test.go index 23ec2e70a..37e5c5067 100644 --- a/internal/core/workflow_graph_test.go +++ b/internal/core/workflow_graph_test.go @@ -493,12 +493,6 @@ func TestWorkflowPublishRefusesUnsupportedConditionConfigs(t *testing.T) { node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "", true)})}), want: []any{Object{"code": "condition_operator_unsupported", "nodeId": "c", "field": "cases.conditions.operator"}}, }, - { - // desktop resolves this and keeps the bound type; Cloud would compare the literal text. - name: "an expected value carrying a selector template", - node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "equals", "{{#s.input.name#}}")})}), - want: []any{Object{"code": "condition_value_template_unsupported", "nodeId": "c", "field": "cases.conditions.value"}}, - }, } exercised := map[string]bool{} @@ -522,13 +516,16 @@ func TestWorkflowPublishRefusesUnsupportedConditionConfigs(t *testing.T) { t.Fatalf("these cases exercise %v; the declared list is %v — the two must name the same set", exercised, declared) } - // Refusing is not the same as refusing everything. The two documents the editor legitimately - // publishes — a filled-in node, and one the author has added but not configured yet, which falls - // through to else — must both pass. + // Refusing is not the same as refusing everything. The documents the editor legitimately + // publishes — a filled-in node, one the author has added but not configured yet (falls through to + // else), and an expected value carrying a selector template (circus#210 now evaluates it the way + // desktop does) — must all pass. for name, node := range map[string]Object{ - "a filled-in condition node": graphCondition("c", []any{graphConditionCase("yes", "and", filled)}), - "a condition node with no cases yet": graphCondition("c", []any{}), - "a condition node with an unfinished case": graphCondition("c", []any{graphConditionCase("yes", "and", []any{})}), + "an expected value carrying a selector template": graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "equals", "{{#s.input.name#}}")})}), + "a mixed expected-value template": graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "equals", "branch {{#s.input.name#}}")})}), + "a filled-in condition node": graphCondition("c", []any{graphConditionCase("yes", "and", filled)}), + "a condition node with no cases yet": graphCondition("c", []any{}), + "a condition node with an unfinished case": graphCondition("c", []any{graphConditionCase("yes", "and", []any{})}), } { got := problemObjects(validateWorkflowGraph(decodeGraph(t, workflowGraph([]any{graphStart("s"), node}, []any{graphEdge("s", "c")}, nil)))) if len(got) != 0 { diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 7961be01d..af6ea17ab 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -2,6 +2,7 @@ package core import ( "context" + "strings" "time" ) @@ -616,9 +617,10 @@ func workflowRunVersion(t *transaction, runID string, fallback int64) int64 { } // executeWorkflowNode commits one ready non-agent node's result from committed state. start reports -// what the run was kicked off with; condition evaluates its cases against prior node outputs and -// commits the selected branch (first match wins, else when nothing matched); output is a terminal -// marker whose produce stays in the node state for the run viewer (D7: 成果交付延后). +// the variable roots desktop's Start node exposes; condition evaluates its cases against prior node +// outputs 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: 成果交付延后). func executeWorkflowNode(node workflowNodeView, input Object, states map[string]workflowNodeState) workflowNodeState { now := nowRFC3339() state := workflowNodeState{ @@ -629,9 +631,19 @@ func executeWorkflowNode(node workflowNodeView, input Object, states map[string] } switch node.Kind { case "start": - state.Output = Object{"input": input} + state.Output = workflowStartOutput(node, input) case "condition": - state.Output = Object{"selectedBranch": evaluateWorkflowCondition(node, states)} + branch, err := evaluateWorkflowCondition(node, states) + if err != nil { + // A condition that cannot evaluate the document it was given fails the node and the run, + // exactly as desktop's ConditionRuntime does. Falling through to else would hide a + // malformed or unassigned-variable reference behind a branch the author never chose. + state.Status = "failed" + state.Error = err.Error() + state.Output = Object{"reason": err.Error()} + return state + } + state.Output = Object{"selectedBranch": branch} case "output": state.Output = Object{} default: @@ -644,6 +656,29 @@ func executeWorkflowNode(node workflowNodeView, input Object, states map[string] return state } +// workflowStartOutput is the Start node's variable pool in the shape desktop declares it: `input` is +// the kickoff prompt (the reserved free-text selector), and every declared input variable is a root +// named by the variable itself. Before circus#210 Cloud nested the whole run input under `input`, so +// `[start, name]` resolved to nothing and `[start, input]` answered for the entire form — the same +// document meant different things on the two sides. +// +// The run form's values are keyed by variable name, so flattening them is exact. A key literally +// named `input` is dropped: desktop reserves that root, and letting a user variable shadow the +// kickoff selector would make the same selector mean a run value on Cloud and a prompt on desktop. +func workflowStartOutput(node workflowNodeView, input Object) Object { + output := make(Object, len(input)+1) + for key, value := range input { + if key == "input" { + continue + } + output[key] = value + } + // Cloud does not persist the per-run prompt override, so the authored prompt is the only kickoff + // text available to the variable pool. The launch field still shows that same default. + output["input"] = strings.TrimSpace(node.Data.S("input")) + return output +} + // workflowNodeDisplayName is the label the run viewer shows: the author's title, or the node kind // when the title is empty — the same projection the simulator writes. func workflowNodeDisplayName(node workflowNodeView) string { diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 99a505f21..0481a7d23 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -9,6 +9,7 @@ package core import ( "context" "encoding/json" + "strings" "testing" "github.com/google/uuid" @@ -413,14 +414,25 @@ func TestWorkflowAdvanceConditionNotEmptyOnEmptyRunInput(t *testing.T) { } } -// not_contains must not match an absent variable, and the run has to prove it end to end: the -// selector names a path the start node never produced, and the case is written with not_contains, the -// one negated comparator whose absence handling is not simply the inner verdict flipped. +// A comparator on an absent variable fails the node and the run, as desktop's ConditionRuntime does. +// not_contains is the case that used to be special-cased: #67 made it answer false so its negated +// form would agree with not_equals, but desktop raises UnsetVariable for both. Parity means the run +// says which selector was never assigned instead of quietly taking else. func TestWorkflowAdvanceConditionNotContainsOnAbsentVariable(t *testing.T) { store := dispatcherDB(t) runID := wfConditionScene(t, store, "absent", []any{"s", "never_written"}, "not_contains") - if branch := wfSelectedBranch(t, store, runID); branch != workflowBranchElse { - t.Fatalf("condition selected %q; want else — not_contains matched an absent variable", branch) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" { + t.Fatalf("run status = %q; want failed", status) + } + if !strings.Contains(runErr, "s.never_written") || !strings.Contains(runErr, "has not been assigned") { + t.Fatalf("run error = %q; want the unset selector named", runErr) + } + if got := wfNodeStatus(t, states, "c"); got != "failed" { + t.Fatalf("condition status = %q; want failed", got) } } diff --git a/internal/core/workflow_scheduler_test.go b/internal/core/workflow_scheduler_test.go index 5acc5010d..19f156c34 100644 --- a/internal/core/workflow_scheduler_test.go +++ b/internal/core/workflow_scheduler_test.go @@ -86,7 +86,7 @@ func wfConditionNode(id string, cases any) Object { // goes inactive, and downstream nodes resolve accordingly. func TestWorkflowProjectionBranchSelection(t *testing.T) { yes := Object{"id": "yes", "logic": "and", "conditions": []any{ - Object{"variableSelector": []any{"s", "input", "flag"}, "operator": "equals", "value": true}, + Object{"variableSelector": []any{"s", "flag"}, "operator": "equals", "value": true}, }} graph := readWorkflowGraph(wfGraph( []Object{wfNode("s", "start", ""), wfConditionNode("c", []any{yes}), wfNode("y", "output", ""), wfNode("e", "output", "")}, @@ -95,7 +95,10 @@ func TestWorkflowProjectionBranchSelection(t *testing.T) { states := map[string]workflowNodeState{} states["s"] = executeWorkflowNode(graph.ByNode["s"], Object{"flag": true}, states) - branch := evaluateWorkflowCondition(graph.ByNode["c"], states) + branch, err := evaluateWorkflowCondition(graph.ByNode["c"], states) + if err != nil { + t.Fatalf("condition evaluation: %v", err) + } if branch != "yes" { t.Fatalf("condition selected %q; want yes", branch) } @@ -114,7 +117,7 @@ func TestWorkflowProjectionBranchSelection(t *testing.T) { // active (desktop branch_projection: an unlabeled edge defaults to else). func TestWorkflowProjectionUnlabeledEdgeIsElse(t *testing.T) { miss := Object{"id": "yes", "logic": "and", "conditions": []any{ - Object{"variableSelector": []any{"s", "input", "flag"}, "operator": "equals", "value": false}, + Object{"variableSelector": []any{"s", "flag"}, "operator": "equals", "value": false}, }} graph := readWorkflowGraph(wfGraph( []Object{wfNode("s", "start", ""), wfConditionNode("c", []any{miss}), wfNode("y", "output", ""), wfNode("e", "output", "")}, @@ -123,8 +126,8 @@ func TestWorkflowProjectionUnlabeledEdgeIsElse(t *testing.T) { states := map[string]workflowNodeState{} states["s"] = executeWorkflowNode(graph.ByNode["s"], Object{"flag": true}, states) - if branch := evaluateWorkflowCondition(graph.ByNode["c"], states); branch != workflowBranchElse { - t.Fatalf("all-miss condition selected %q; want else", branch) + if branch, err := evaluateWorkflowCondition(graph.ByNode["c"], states); err != nil || branch != workflowBranchElse { + t.Fatalf("all-miss condition = %q, %v; want else", branch, err) } states["c"] = workflowNodeState{Status: "succeeded", Output: Object{"selectedBranch": workflowBranchElse}} @@ -230,22 +233,28 @@ func TestWorkflowRunFailureReasonFallbacks(t *testing.T) { } // wfRuleEvaluate runs one condition rule against a start node whose output the test authors -// directly, returning whether the rule matches. -func wfRuleEvaluate(t *testing.T, selector []any, operator string, value any, output Object) bool { +// directly, returning whether the rule matches and any evaluation error. +func wfRuleEvaluate(t *testing.T, selector []any, operator string, value any, output Object) (bool, error) { t.Helper() states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: output}} return workflowConditionRule(Object{"variableSelector": selector, "operator": operator, "value": value}, states) } // The operator table, evaluated against a start node whose output is -// {"flag": true, "count": 2, "text": "hello world", "list": [], "nothing": null, "emptyObj": {}, -// "fullObj": {"a": 1}, "plainObj": {}}. The three object keys are deliberately three different Go -// types for the same JSON: a start node's output nests the run input as the package's named Object, -// while a jsonb-decoded row nests a plain map. Both must classify as empty the same way. +// {"flag": true, "count": 2, "text": "hello world", "list": [], "tags": ["a","b"], +// "nothing": null, "emptyObj": {}, "fullObj": {"a": 1}, "plainObj": {}}. The three object keys are +// deliberately three different Go types for the same JSON: a start node's output nests the run input +// as the package's named Object, while a jsonb-decoded row nests a plain map. Both must classify as +// empty the same way. +// +// The expected behavior comes from desktop's condition.rs: contains is substring-or-array-element, +// starts_with/ends_with require strings on both sides, numeric ordering requires two numbers, and +// every comparator other than exists/not_exists/empty/not_empty fails on an unassigned variable. func TestWorkflowConditionOperators(t *testing.T) { output := Object{ - "flag": true, "count": 2.0, "text": "hello world", "list": []any{}, "nothing": nil, - "emptyObj": Object{}, "fullObj": Object{"a": 1.0}, "plainObj": map[string]any{}, + "flag": true, "count": 2.0, "text": "hello world", "list": []any{}, "tags": []any{"a", "b"}, + "nothing": nil, "emptyObj": Object{}, "fullObj": Object{"a": 1.0}, "plainObj": map[string]any{}, + "nested": Object{"tags": []any{"x"}}, "label": "n=2", } cases := []struct { name string @@ -253,50 +262,82 @@ func TestWorkflowConditionOperators(t *testing.T) { operator string value any wantMatch bool + wantErr bool }{ - {"equals match", []any{"s", "flag"}, "equals", true, true}, - {"is is equals", []any{"s", "flag"}, "is", true, true}, - {"is_not is not_equals", []any{"s", "flag"}, "is_not", false, true}, - {"equals mismatch", []any{"s", "flag"}, "equals", false, false}, - {"not_equals mismatch", []any{"s", "flag"}, "not_equals", true, false}, - {"not_equals match", []any{"s", "flag"}, "not_equals", false, true}, - {"contains match", []any{"s", "text"}, "contains", "lo wo", true}, - {"not_contains match", []any{"s", "text"}, "not_contains", "zz", true}, - {"starts_with match", []any{"s", "text"}, "starts_with", "hello", true}, - {"ends_with match", []any{"s", "text"}, "ends_with", "world", true}, - {"empty on empty list", []any{"s", "list"}, "empty", nil, true}, - {"empty on null", []any{"s", "nothing"}, "empty", nil, true}, - {"not_empty on text", []any{"s", "text"}, "not_empty", nil, true}, + {"equals match", []any{"s", "flag"}, "equals", true, true, false}, + {"is is equals", []any{"s", "flag"}, "is", true, true, false}, + {"is_not is not_equals", []any{"s", "flag"}, "is_not", false, true, false}, + {"equals mismatch", []any{"s", "flag"}, "equals", false, false, false}, + {"equals null", []any{"s", "nothing"}, "equals", nil, true, false}, + {"not_equals mismatch", []any{"s", "flag"}, "not_equals", true, false, false}, + {"not_equals match", []any{"s", "flag"}, "not_equals", false, true, false}, + {"contains string match", []any{"s", "text"}, "contains", "lo wo", true, false}, + {"contains array element", []any{"s", "tags"}, "contains", "b", true, false}, + {"contains array miss", []any{"s", "tags"}, "contains", "z", false, false}, + {"contains nested array", []any{"s", "nested", "tags"}, "contains", "x", true, false}, + {"contains object is a type error", []any{"s", "fullObj"}, "contains", "a", false, true}, + {"contains non-string needle is a type error", []any{"s", "text"}, "contains", 1.0, false, true}, + {"not_contains match", []any{"s", "text"}, "not_contains", "zz", true, false}, + {"not_contains mismatch", []any{"s", "text"}, "not_contains", "hello", false, false}, + {"starts_with match", []any{"s", "text"}, "starts_with", "hello", true, false}, + {"ends_with match", []any{"s", "text"}, "ends_with", "world", true, false}, + {"starts_with non-string actual is a type error", []any{"s", "count"}, "starts_with", "2", false, true}, + {"starts_with non-string expected is a type error", []any{"s", "text"}, "starts_with", 2.0, false, true}, + {"empty on empty list", []any{"s", "list"}, "empty", nil, true, false}, + {"empty on null", []any{"s", "nothing"}, "empty", nil, true, false}, + {"not_empty on text", []any{"s", "text"}, "not_empty", nil, true, false}, // An empty object is empty whichever Go type carries it. Before asWorkflowObject was used // here, the named Object fell through the type switch and an empty run input read as // not-empty, so a `start -> condition` graph on input {} took the opposite branch from // desktop's is_empty. - {"empty on empty Object", []any{"s", "emptyObj"}, "empty", nil, true}, - {"not_empty on empty Object", []any{"s", "emptyObj"}, "not_empty", nil, false}, - {"not_empty on full Object", []any{"s", "fullObj"}, "not_empty", nil, true}, - {"empty on full Object", []any{"s", "fullObj"}, "empty", nil, false}, - {"empty on empty plain map", []any{"s", "plainObj"}, "empty", nil, true}, - {"exists on present", []any{"s", "flag"}, "exists", nil, true}, - {"not_exists on absent", []any{"s", "missing"}, "not_exists", nil, true}, - {"exists on absent", []any{"s", "missing"}, "exists", nil, false}, - {"empty on absent", []any{"s", "missing"}, "empty", nil, true}, - {"not_empty on absent", []any{"s", "missing"}, "not_empty", nil, false}, - {"comparator on absent", []any{"s", "missing"}, "equals", nil, false}, - // Absence is a no-match for every comparator, negated ones included: not_contains must not - // select its branch on a variable no prior node ever produced, or it disagrees with - // not_equals about the same absent variable. - {"not_contains on absent", []any{"s", "missing"}, "not_contains", "zz", false}, - {"not_contains mismatch", []any{"s", "text"}, "not_contains", "hello", false}, - {"greater_than match", []any{"s", "count"}, "greater_than", 1.0, true}, - {"greater_than no match", []any{"s", "count"}, "greater_than", 2.0, false}, - {"greater_than_or_equal boundary", []any{"s", "count"}, "greater_than_or_equal", 2.0, true}, - {"less_than match", []any{"s", "count"}, "less_than", 3.0, true}, - {"less_than_or_equal boundary", []any{"s", "count"}, "less_than_or_equal", 2.0, true}, - {"numeric against text", []any{"s", "text"}, "greater_than", 1.0, false}, + {"empty on empty Object", []any{"s", "emptyObj"}, "empty", nil, true, false}, + {"not_empty on empty Object", []any{"s", "emptyObj"}, "not_empty", nil, false, false}, + {"not_empty on full Object", []any{"s", "fullObj"}, "not_empty", nil, true, false}, + {"empty on full Object", []any{"s", "fullObj"}, "empty", nil, false, false}, + {"empty on empty plain map", []any{"s", "plainObj"}, "empty", nil, true, false}, + {"exists on present", []any{"s", "flag"}, "exists", nil, true, false}, + {"not_exists on absent", []any{"s", "missing"}, "not_exists", nil, true, false}, + {"exists on absent", []any{"s", "missing"}, "exists", nil, false, false}, + {"empty on absent", []any{"s", "missing"}, "empty", nil, true, false}, + {"not_empty on absent", []any{"s", "missing"}, "not_empty", nil, false, false}, + // desktop's exists/not_exists/empty/not_empty are the only operators that answer absence; + // every comparator, negated ones included, reports the unassigned variable instead of + // silently choosing else. + {"equals on absent is an error", []any{"s", "missing"}, "equals", nil, false, true}, + {"not_equals on absent is an error", []any{"s", "missing"}, "not_equals", nil, false, true}, + {"not_contains on absent is an error", []any{"s", "missing"}, "not_contains", "zz", false, true}, + {"greater_than on absent is an error", []any{"s", "missing"}, "greater_than", 1.0, false, true}, + {"invalid nested path is an error", []any{"s", "text", "deeper"}, "exists", nil, false, true}, + {"missing nested path is an error", []any{"s", "nested", "absent"}, "exists", nil, false, true}, + {"one-segment selector is an error", []any{"s"}, "exists", nil, false, true}, + {"greater_than match", []any{"s", "count"}, "greater_than", 1.0, true, false}, + {"greater_than no match", []any{"s", "count"}, "greater_than", 2.0, false, false}, + {"greater_than_or_equal boundary", []any{"s", "count"}, "greater_than_or_equal", 2.0, true, false}, + {"less_than match", []any{"s", "count"}, "less_than", 3.0, true, false}, + {"less_than_or_equal boundary", []any{"s", "count"}, "less_than_or_equal", 2.0, true, false}, + {"numeric against text is a type error", []any{"s", "text"}, "greater_than", 1.0, false, true}, + // Expected-value templates: exclusive keeps the bound JSON type, mixed renders a string, and + // an unset or malformed selector fails rather than comparing the placeholder text. + {"exclusive template keeps number", []any{"s", "count"}, "equals", "{{#s.count#}}", true, false}, + {"exclusive template keeps boolean", []any{"s", "flag"}, "equals", "{{#s.flag#}}", true, false}, + {"exclusive template numeric compare", []any{"s", "count"}, "greater_than", "{{#s.count#}}", false, false}, + {"mixed template renders string", []any{"s", "label"}, "equals", "n={{#s.count#}}", true, false}, + {"mixed template resolves text verbatim", []any{"s", "text"}, "contains", "{{#s.text#}}", true, false}, + {"template on unset variable is an error", []any{"s", "text"}, "equals", "{{#s.missing#}}", false, true}, + {"unterminated template is an error", []any{"s", "text"}, "equals", "{{#s.count", false, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := wfRuleEvaluate(t, tc.selector, tc.operator, tc.value, output) + got, err := wfRuleEvaluate(t, tc.selector, tc.operator, tc.value, output) + if tc.wantErr { + if err == nil { + t.Fatalf("rule %q %v returned %v; want an evaluation error", tc.operator, tc.value, got) + } + return + } + if err != nil { + t.Fatalf("rule %q %v: %v", tc.operator, tc.value, err) + } if got != tc.wantMatch { t.Fatalf("rule %q %v got %v; want %v", tc.operator, tc.value, got, tc.wantMatch) } @@ -304,49 +345,87 @@ func TestWorkflowConditionOperators(t *testing.T) { } } -// Case logic: "and" requires every rule, "or" any rule, the first matching case wins; a case with -// no rules is an authored shell and falls through; a malformed rule, an unknown operator and a -// selector naming a node that never produced output are all no-matches. +// A rule with no expected value is not the same as one whose expected value is null: desktop +// compares explicit null, and reports the missing operand for everything else. +func TestWorkflowConditionMissingExpectedValue(t *testing.T) { + states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{"flag": true}}} + rule := Object{"variableSelector": []any{"s", "flag"}, "operator": "equals"} + if _, err := workflowConditionRule(rule, states); err == nil { + t.Fatal("rule with no value matched; want a type-mismatch error") + } +} + +// Case logic: "and" requires every rule, "or" any rule, the first matching case wins; a case with no +// rules is an authored shell and falls through; a malformed rule, an unknown operator and an unknown +// logic are errors rather than silent else branches, while an exists rule on a node that never ran +// matches nothing (absence is that operator's normal answer). func TestWorkflowConditionCaseLogic(t *testing.T) { states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{"flag": true, "count": 2.0}}} - pick := func(cases any) string { + pick := func(cases any) (string, error) { return evaluateWorkflowCondition(workflowNodeView{ID: "c", Kind: "condition", Data: Object{"cases": cases}}, states) } rule := func(key string, value any) Object { return Object{"variableSelector": []any{"s", key}, "operator": "equals", "value": value} } and := []any{Object{"id": "both", "logic": "and", "conditions": []any{rule("flag", true), rule("count", 3.0)}}} - if got := pick(and); got != workflowBranchElse { - t.Fatalf("and-case with one failed rule selected %q; want else", got) + if got, err := pick(and); err != nil || got != workflowBranchElse { + t.Fatalf("and-case with one failed rule = %q, %v; want else", got, err) } or := []any{Object{"id": "either", "logic": "or", "conditions": []any{rule("flag", false), rule("count", 2.0)}}} - if got := pick(or); got != "either" { - t.Fatalf("or-case with one matching rule selected %q; want either", got) + if got, err := pick(or); err != nil || got != "either" { + t.Fatalf("or-case with one matching rule = %q, %v; want either", got, err) } first := []any{ Object{"id": "first", "logic": "or", "conditions": []any{rule("flag", true)}}, Object{"id": "second", "logic": "or", "conditions": []any{rule("count", 2.0)}}, } - if got := pick(first); got != "first" { - t.Fatalf("first matching case selected %q; want first", got) + if got, err := pick(first); err != nil || got != "first" { + t.Fatalf("first matching case = %q, %v; want first", got, err) } - if got := pick([]any{Object{"id": "shell", "logic": "and"}}); got != workflowBranchElse { - t.Fatalf("case with no rules selected %q; want else", got) + if got, err := pick([]any{Object{"id": "shell", "logic": "and"}}); err != nil || got != workflowBranchElse { + t.Fatalf("case with no rules = %q, %v; want else", got, err) } malformed := []any{Object{"id": "bad", "logic": "or", "conditions": []any{Object{"operator": "equals"}}}} - if got := pick(malformed); got != workflowBranchElse { - t.Fatalf("malformed rule selected %q; want else", got) + if _, err := pick(malformed); err == nil { + t.Fatal("malformed rule selected a branch; want an error") } badOp := []any{Object{"id": "unk", "logic": "or", "conditions": []any{ Object{"variableSelector": []any{"s", "flag"}, "operator": "frobnicate", "value": true}, }}} - if got := pick(badOp); got != workflowBranchElse { - t.Fatalf("unknown operator selected %q; want else", got) + if _, err := pick(badOp); err == nil { + t.Fatal("unknown operator selected a branch; want an error") + } + badLogic := []any{Object{"id": "xor", "logic": "xor", "conditions": []any{rule("flag", true)}}} + if _, err := pick(badLogic); err == nil { + t.Fatal("unknown logic selected a branch; want an error") } ghost := []any{Object{"id": "ghost", "logic": "or", "conditions": []any{ Object{"variableSelector": []any{"nope", "flag"}, "operator": "exists", "value": nil}, }}} - if got := pick(ghost); got != workflowBranchElse { - t.Fatalf("rule on a node that never ran selected %q; want else", got) + if got, err := pick(ghost); err != nil || got != workflowBranchElse { + t.Fatalf("rule on a node that never ran = %q, %v; want else", got, err) + } + // Desktop resolves every rule before combining them, so an unset variable in a later rule of an + // already-matching or-case still fails instead of being short-circuited away. + unsetLater := []any{Object{"id": "partial", "logic": "or", "conditions": []any{ + rule("flag", true), + Object{"variableSelector": []any{"s", "missing"}, "operator": "equals", "value": 1.0}, + }}} + if _, err := pick(unsetLater); err == nil { + t.Fatal("or-case with an unset later rule selected a branch; want an error") + } +} + +// The start node's output is the variable pool desktop declares: `input` is the kickoff prompt and +// each run-form variable is its own root, so selector [start, name] resolves the value the author +// selected in the editor instead of walking the whole form under [start, input, name]. +func TestWorkflowStartOutputMatchesDesktopVariableRoots(t *testing.T) { + node := wfNode("s", "start", "") + node["data"].(Object)["input"] = " review the diff " + output := workflowStartOutput(workflowNodeView{ID: "s", Kind: "start", Data: node["data"].(Object)}, + Object{"flag": true, "count": 2.0, "input": "shadowed"}) + want := Object{"flag": true, "count": 2.0, "input": "review the diff"} + if canonicalJSON(output) != canonicalJSON(want) { + t.Fatalf("start output = %s; want %s", canonicalJSON(output), canonicalJSON(want)) } } From 3b6cabbeaf6d0e2da349a3ac02e516e69cbbc05b Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 18:58:13 +0800 Subject: [PATCH 02/11] fix(core): refuse condition selectors on undeclared variable roots Desktop builds its variable pool from the graph, so a selector naming a node or root the graph never declares is Undeclared there and fails the node. Cloud's evaluator has no declaration set and would answer absent instead, letting exists/not_exists pick a branch the author could not have written. The graph decides both facts, so the publish gate now refuses such selectors as condition_selector_undeclared. Co-Authored-By: Claude Code --- internal/core/workflow_graph.go | 88 ++++++++++++++++++++++++--- internal/core/workflow_graph_test.go | 89 ++++++++++++++++++++++++---- 2 files changed, 157 insertions(+), 20 deletions(-) diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 36c074d66..5c6d40e2a 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -166,6 +166,7 @@ func validateWorkflowAcyclic(graph Object) []workflowGraphProblem { func validateWorkflowNodes(nodes []any) []workflowGraphProblem { problems := []workflowGraphProblem{} seen := map[string]bool{} + declaredRoots := workflowDeclaredRoots(nodes) starts := 0 for _, raw := range nodes { node, ok := raw.(map[string]any) @@ -199,7 +200,7 @@ func validateWorkflowNodes(nodes []any) []workflowGraphProblem { // delete anyway. problems = append(problems, validateAgentConfig(id, o.O("data"))...) case kind == "condition": - problems = append(problems, validateConditionConfig(id, o.O("data"))...) + problems = append(problems, validateConditionConfig(id, o.O("data"), declaredRoots)...) } } if starts != 1 { @@ -245,8 +246,14 @@ func validateAgentConfig(nodeID string, data Object) []workflowGraphProblem { // // circus#210 closed the first entry: an expected value carrying `{{#selector#}}` now resolves the // same way desktop does — an exclusive placeholder keeps the bound JSON type, a mixed one renders a -// string, and an unset selector fails the node — so the gate no longer refuses it. The remaining -// entries stay until the evaluator stops silently reinterpreting each one. +// string, and an unset selector fails the node — so the gate no longer refuses it. +// +// `condition_selector_undeclared` is the one entry about the variable pool rather than the operator: +// desktop declares every node's roots up front, so a selector naming a node or root the graph never +// declares is `Undeclared` and fails the node, while Cloud's evaluator has no declaration set and +// would answer "absent" — `exists` would return false and the run would take a branch the author +// could not have written. The node exists and its kind exposes a fixed set of roots, so the gate can +// decide this from the document alone, which is what makes refusing at publish the right side. var workflowUnsupportedConditionConfigs = []string{ "condition_cases_not_a_list", "condition_case_not_an_object", @@ -255,13 +262,66 @@ var workflowUnsupportedConditionConfigs = []string{ "condition_rules_not_a_list", "condition_rule_not_an_object", "condition_selector_unsupported", + "condition_selector_undeclared", "condition_operator_unsupported", } +// workflowDeclaredRoots maps each graph node to the variable roots its kind exposes, the declaration +// set desktop's WorkflowVariablePool::from_graph builds before a run starts. The roots follow the +// approved contract rather than what today's nodes happen to write: an agent exposes `output` and +// `structured_output` when it declares an output contract, an output node exposes `output`, a start +// node exposes `input` plus its declared form variables, and a condition node exposes none. The +// agent and output values arrive with cloud#70/#74; until then a selector on them is declared but +// unassigned, the same state desktop has before the node produces its output. +// +// The map is keyed by node id and holds an empty set for kinds that expose no roots, so a selector +// on a condition node is refused rather than accidentally matching an absent key. +func workflowDeclaredRoots(nodes []any) map[string]map[string]bool { + roots := make(map[string]map[string]bool, len(nodes)) + for _, raw := range nodes { + node, ok := raw.(map[string]any) + if !ok { + continue + } + o := Object(node) + id := o.S("id") + if id == "" { + continue + } + data := o.O("data") + set := map[string]bool{} + switch data.S("kind") { + case "start": + set["input"] = true + declared, _ := data["inputVariables"].([]any) + for _, rawVariable := range declared { + variable, ok := rawVariable.(map[string]any) + if !ok { + continue + } + if name := Object(variable).S("name"); name != "" { + set[name] = true + } + } + case "agent": + set["output"] = true + // The contract lives in the node's `agentConfig`, the same place desktop's wire node reads + // it from; a contract present with a null value declares nothing extra. + if contract, present := data.O("agentConfig")["outputContract"]; present && contract != nil { + set["structured_output"] = true + } + case "output": + set["output"] = true + } + roots[id] = set + } + return roots +} + // validateConditionConfig refuses the condition configuration the first phase does not evaluate the // way desktop does. Every problem names the node and the field, so the author is told which branch // of which node to fix rather than that the graph is invalid somewhere. -func validateConditionConfig(nodeID string, data Object) []workflowGraphProblem { +func validateConditionConfig(nodeID string, data Object, declaredRoots map[string]map[string]bool) []workflowGraphProblem { problems := []workflowGraphProblem{} raw, present := data["cases"] if !present || raw == nil { @@ -297,7 +357,7 @@ func validateConditionConfig(nodeID string, data Object) []workflowGraphProblem continue } for _, rawRule := range rules { - problems = append(problems, validateConditionRule(nodeID, rawRule)...) + problems = append(problems, validateConditionRule(nodeID, rawRule, declaredRoots)...) } } return problems @@ -306,7 +366,7 @@ func validateConditionConfig(nodeID string, data Object) []workflowGraphProblem // validateConditionRule checks one rule of one case. The operator set is read from the evaluator's // own table rather than restated, so the gate cannot admit an operator the evaluator would then // no-match — the two lists are the same list. -func validateConditionRule(nodeID string, raw any) []workflowGraphProblem { +func validateConditionRule(nodeID string, raw any, declaredRoots map[string]map[string]bool) []workflowGraphProblem { problems := []workflowGraphProblem{} r, ok := asWorkflowObject(raw) if !ok { @@ -319,15 +379,29 @@ func validateConditionRule(nodeID string, raw any) []workflowGraphProblem { // The selector needs the node AND a path into its output. The editor hands a new comparison an // empty list, and one element names the whole output, which desktop refuses and Cloud would read // as everything the node produced. - if len(selector) < 2 { + wellFormed := len(selector) >= 2 + if !wellFormed { problems = append(problems, workflowGraphProblem{Code: "condition_selector_unsupported", NodeID: nodeID, Field: "cases.conditions.variableSelector"}) } for _, element := range selector { if text, ok := element.(string); !ok || text == "" { problems = append(problems, workflowGraphProblem{Code: "condition_selector_unsupported", NodeID: nodeID, Field: "cases.conditions.variableSelector"}) + wellFormed = false break } } + // A well-formed selector must still name a node the graph declares and a root that node's kind + // exposes. Desktop declares the pool from the graph, so anything else is Undeclared there and + // fails the node; Cloud has no declarations at run time and would quietly answer "absent", so the + // gate refuses the document instead. Malformed selectors already failed above; reporting them + // twice would only bury the fixable problem. + if wellFormed { + node, nodeOK := selector[0].(string) + root, rootOK := selector[1].(string) + if !nodeOK || !rootOK || !declaredRoots[node][root] { + problems = append(problems, workflowGraphProblem{Code: "condition_selector_undeclared", NodeID: nodeID, Field: "cases.conditions.variableSelector"}) + } + } if _, ok := workflowConditionOperators[r.S("operator")]; !ok { problems = append(problems, workflowGraphProblem{Code: "condition_operator_unsupported", NodeID: nodeID, Field: "cases.conditions.operator"}) } diff --git a/internal/core/workflow_graph_test.go b/internal/core/workflow_graph_test.go index 37e5c5067..05db28680 100644 --- a/internal/core/workflow_graph_test.go +++ b/internal/core/workflow_graph_test.go @@ -483,6 +483,15 @@ func TestWorkflowPublishRefusesUnsupportedConditionConfigs(t *testing.T) { node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule([]any{"s", 0}, "equals", true)})}), want: []any{Object{"code": "condition_selector_unsupported", "nodeId": "c", "field": "cases.conditions.variableSelector"}}, }, + { + // Desktop builds its pool from the graph, so a root the Start node does not declare is + // Undeclared there and fails the node. Cloud's evaluator has no declaration set and would + // answer "absent" instead — a branch the author could not have written — so publishing + // refuses it. + name: "a selector on an undeclared root", + node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule([]any{"s", "missing"}, "exists", nil)})}), + want: []any{Object{"code": "condition_selector_undeclared", "nodeId": "c", "field": "cases.conditions.variableSelector"}}, + }, { name: "an operator neither side knows", node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "matches", true)})}), @@ -518,19 +527,73 @@ func TestWorkflowPublishRefusesUnsupportedConditionConfigs(t *testing.T) { // Refusing is not the same as refusing everything. The documents the editor legitimately // publishes — a filled-in node, one the author has added but not configured yet (falls through to - // else), and an expected value carrying a selector template (circus#210 now evaluates it the way - // desktop does) — must all pass. - for name, node := range map[string]Object{ - "an expected value carrying a selector template": graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "equals", "{{#s.input.name#}}")})}), - "a mixed expected-value template": graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "equals", "branch {{#s.input.name#}}")})}), - "a filled-in condition node": graphCondition("c", []any{graphConditionCase("yes", "and", filled)}), - "a condition node with no cases yet": graphCondition("c", []any{}), - "a condition node with an unfinished case": graphCondition("c", []any{graphConditionCase("yes", "and", []any{})}), - } { - got := problemObjects(validateWorkflowGraph(decodeGraph(t, workflowGraph([]any{graphStart("s"), node}, []any{graphEdge("s", "c")}, nil)))) - if len(got) != 0 { - t.Fatalf("%s was refused: %v", name, got) - } + // else), an expected value carrying a selector template (circus#210 now evaluates it the way + // desktop does), and a selector on each root a node's kind declares — must all pass. + startWithVariables := Object{"id": "s", "type": "workflow", "data": Object{ + "kind": "start", + "inputVariables": []any{Object{"name": "limit", "valueType": "number"}}, + }} + agentNode := graphAgent("a", "", false) + outputNode := Object{"id": "o", "type": "workflow", "data": Object{"kind": "output"}} + oneRule := func(selector []any, operator string, value any) Object { + return graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, operator, value)})}) + } + positive := []struct { + name string + nodes []any + edges []any + }{ + { + name: "an expected value carrying a selector template", + nodes: []any{graphStart("s"), graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "equals", "{{#s.input.name#}}")})})}, + edges: []any{graphEdge("s", "c")}, + }, + { + name: "a mixed expected-value template", + nodes: []any{graphStart("s"), graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "equals", "branch {{#s.input.name#}}")})})}, + edges: []any{graphEdge("s", "c")}, + }, + { + name: "a filled-in condition node", + nodes: []any{graphStart("s"), graphCondition("c", []any{graphConditionCase("yes", "and", filled)})}, + edges: []any{graphEdge("s", "c")}, + }, + { + name: "a condition node with no cases yet", + nodes: []any{graphStart("s"), graphCondition("c", []any{})}, + edges: []any{graphEdge("s", "c")}, + }, + { + name: "a condition node with an unfinished case", + nodes: []any{graphStart("s"), graphCondition("c", []any{graphConditionCase("yes", "and", []any{})})}, + edges: []any{graphEdge("s", "c")}, + }, + { + // Start declares every run-form variable by name, so [s, limit] is a root the pool has. + name: "a declared start form variable", + nodes: []any{startWithVariables, oneRule([]any{"s", "limit"}, "greater_than", 90.0)}, + edges: []any{graphEdge("s", "c")}, + }, + { + // Every non-start, non-condition node declares `output`; the agent's value arrives with + // cloud#70, and until then the root is declared but unassigned. + name: "a declared agent output root", + nodes: []any{graphStart("s"), agentNode, oneRule([]any{"a", "output"}, "not_empty", nil)}, + edges: []any{graphEdge("s", "a"), graphEdge("a", "c")}, + }, + { + name: "a declared output node root", + nodes: []any{graphStart("s"), outputNode, oneRule([]any{"o", "output"}, "not_empty", nil)}, + edges: []any{graphEdge("s", "o"), graphEdge("o", "c")}, + }, + } + for _, tc := range positive { + t.Run(tc.name, func(t *testing.T) { + got := problemObjects(validateWorkflowGraph(decodeGraph(t, workflowGraph(tc.nodes, tc.edges, nil)))) + if len(got) != 0 { + t.Fatalf("refused: %v", got) + } + }) } } From aa6a80b98147934b22f4560e5db57f727fca4f9d Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 18:58:36 +0800 Subject: [PATCH 03/11] fix(core): settle a failed run before dispatching its siblings A node that fails decides the run, but the advance pass kept asking for the next ready node in the same wave. A ready agent sibling would then be dispatched for a run already terminal: it provisioned a Workspace and waited on a session only to settle failed afterwards, and it committed a state claiming work that never had a chance. Stop the wave as soon as the projection reports a failure, so the pass settles the run and the unrun siblings keep no state. Co-Authored-By: Claude Code --- internal/core/workflow_scheduler.go | 8 +++++ internal/core/workflow_scheduler_db_test.go | 34 +++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index af6ea17ab..535e97ab3 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -524,6 +524,14 @@ func advanceWorkflowRun(t *transaction, runID string) { } for { projection := projectWorkflowNodes(graph, 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 + // here lets the run settle in this pass and leaves the unrun siblings without a committed + // state, which is what "never executed" should look like. + if projection.hasFailure() { + break + } node, ready := projection.readyNode() if !ready { break diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 0481a7d23..967de34d0 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -414,6 +414,40 @@ func TestWorkflowAdvanceConditionNotEmptyOnEmptyRunInput(t *testing.T) { } } +// A failed node decides the run in the pass that produced it. Here the condition fails and an agent +// sibling is ready in the same wave; the pass must stop before dispatching it. Dispatching would +// provision a Workspace and start a session for a run that is already terminal, and it would give the +// agent a committed state — the row would then claim work that could only end in a failure the +// condition had already decided. +func TestWorkflowAdvanceDoesNotDispatchSiblingAfterFailure(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunScene(t, store, wfGraph( + []Object{ + wfNode("s", "start", ""), + wfOneRuleNode("c", "matched", []any{"s", "never_written"}, "equals"), + wfNode("a", "agent", "worker"), + }, + [][3]string{{"s", "c", ""}, {"s", "a", ""}}, + )) + + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" { + t.Fatalf("run status = %q; want failed", status) + } + if !strings.Contains(runErr, "s.never_written") { + t.Fatalf("run error = %q; want the condition's evaluation error", runErr) + } + if got := wfNodeStatus(t, states, "c"); got != "failed" { + t.Fatalf("condition status = %q; want failed", got) + } + if state, ran := states["a"]; ran { + t.Fatalf("sibling agent committed %v; want no state — it must not be dispatched for a failed run", state) + } +} + // A comparator on an absent variable fails the node and the run, as desktop's ConditionRuntime does. // not_contains is the case that used to be special-cased: #67 made it answer false so its negated // form would agree with not_equals, but desktop raises UnsetVariable for both. Parity means the run From 434f0f12425d0cc76a4d7038e8284829ed8dbe78 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 18:58:44 +0800 Subject: [PATCH 04/11] fix(core): keep the start prompt verbatim in the variable pool The Start node's `input` root is desktop's kickoff prompt as the author wrote it. Cloud trimmed it, so a condition comparing that selector could match differently on the two sides. Store it verbatim, and make the dev simulator report the same shape: run-form variables as their own roots and `input` as the authored prompt. Co-Authored-By: Claude Code --- integration/workflow_run_test.go | 10 +++++++--- internal/collab/workflow-run-simulator.go | 13 +++++++++++-- internal/core/workflow_scheduler.go | 8 +++++--- internal/core/workflow_scheduler_db_test.go | 2 +- internal/core/workflow_scheduler_test.go | 6 ++++-- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/integration/workflow_run_test.go b/integration/workflow_run_test.go index 3fd64dac1..c8528f430 100644 --- a/integration/workflow_run_test.go +++ b/integration/workflow_run_test.go @@ -97,10 +97,14 @@ func TestWorkflowRunLifecycle(t *testing.T) { if states.O("start").S("status") != "succeeded" || states.O("agent-1").S("status") != "succeeded" { t.Fatalf("run node_states missing nodes: %v", states) } - // The run echoes the kickoff input as its start-node output: the one thing the sim can say truthfully. - output := states.O("start").O("output").O("input") + // The start node reports the run form the way desktop's variable pool does: each run-form variable + // is its own root, and `input` is the authored kickoff prompt (unset in this fixture). + output := states.O("start").O("output") if output.S("branch") != "main" { - t.Fatalf("start output did not echo input: %v", output) + t.Fatalf("start output did not expose the run input variable: %v", output) + } + if prompt, ok := output["input"].(string); !ok || prompt != "" { + t.Fatalf("start output input = %v; want the authored kickoff prompt as a string", output["input"]) } // definitionSnapshot is the frozen graph, not the live document: the run carries its own copy. ds := run.O("definitionSnapshot") diff --git a/internal/collab/workflow-run-simulator.go b/internal/collab/workflow-run-simulator.go index e912a0f9c..363921fad 100644 --- a/internal/collab/workflow-run-simulator.go +++ b/internal/collab/workflow-run-simulator.go @@ -46,8 +46,17 @@ func (MockWorkflowRunSimulator) SimulateWorkflowRun(graph, input core.Object) (n "finishedAt": finishedAt.UTC().Format(time.RFC3339), } if isStart { - // The one thing the sim can truthfully report: what it was kicked off with. - state["output"] = core.Object{"input": input} + // The one thing the sim can truthfully report: what it was kicked off with. Match the + // real Start output shape — `input` is the authored prompt and each run-form variable is + // a root — so a UI negative case reads the same pool the scheduler writes. + output := core.Object{} + for key, value := range input { + if key != "input" { + output[key] = value + } + } + output["input"] = data.S("input") + state["output"] = output } else { state["output"] = core.Object{"message": "Simulated " + title + " result (simulated; no real work was performed)."} } diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 535e97ab3..b41a035f8 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -2,7 +2,6 @@ package core import ( "context" - "strings" "time" ) @@ -682,8 +681,11 @@ func workflowStartOutput(node workflowNodeView, input Object) Object { output[key] = value } // Cloud does not persist the per-run prompt override, so the authored prompt is the only kickoff - // text available to the variable pool. The launch field still shows that same default. - output["input"] = strings.TrimSpace(node.Data.S("input")) + // text available to the variable pool. The launch field still shows that same default. The text is + // used verbatim, whitespace included, because desktop's start.input is the kickoff prompt as the + // author wrote it; trimming here would make the same selector compare differently on the two + // sides. + output["input"] = node.Data.S("input") return output } diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 967de34d0..8f1c85e12 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -91,7 +91,7 @@ func TestWorkflowAdvanceSucceedsLinearRun(t *testing.T) { } } if start := states["s"].(map[string]any)["output"].(map[string]any); start["input"] == nil { - t.Fatal("start node output does not report the run input") + t.Fatal("start node output does not carry the kickoff prompt root") } } diff --git a/internal/core/workflow_scheduler_test.go b/internal/core/workflow_scheduler_test.go index 19f156c34..1d3debfc2 100644 --- a/internal/core/workflow_scheduler_test.go +++ b/internal/core/workflow_scheduler_test.go @@ -418,13 +418,15 @@ func TestWorkflowConditionCaseLogic(t *testing.T) { // The start node's output is the variable pool desktop declares: `input` is the kickoff prompt and // each run-form variable is its own root, so selector [start, name] resolves the value the author -// selected in the editor instead of walking the whole form under [start, input, name]. +// selected in the editor instead of walking the whole form under [start, input, name]. The prompt is +// kept verbatim, whitespace included: desktop's start.input is the authored text, and trimming it +// here would let the same selector compare differently on the two sides. func TestWorkflowStartOutputMatchesDesktopVariableRoots(t *testing.T) { node := wfNode("s", "start", "") node["data"].(Object)["input"] = " review the diff " output := workflowStartOutput(workflowNodeView{ID: "s", Kind: "start", Data: node["data"].(Object)}, Object{"flag": true, "count": 2.0, "input": "shadowed"}) - want := Object{"flag": true, "count": 2.0, "input": "review the diff"} + want := Object{"flag": true, "count": 2.0, "input": " review the diff "} if canonicalJSON(output) != canonicalJSON(want) { t.Fatalf("start output = %s; want %s", canonicalJSON(output), canonicalJSON(want)) } From 65d04596a310ec8a74aef74a4041c1cc906a3df9 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 18:58:50 +0800 Subject: [PATCH 05/11] docs(core): state the condition pool contract accurately Correct three claims the review found inaccurate: the variable pool now names the roots each node kind declares and the publish gate that stands in for undeclared selectors at run time; the type-label note says Cloud labels any integral float "integer" while desktop distinguishes it; and the equality note records the container int/float and -0.0 edge differences. The parity fixture now models desktop's declared-but-unset variable with an agent node present, since an undeclared selector is a different case. Co-Authored-By: Claude Code --- internal/core/workflow_condition.go | 28 +++++++++++++++---- .../core/workflow_condition_parity_test.go | 6 +++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/internal/core/workflow_condition.go b/internal/core/workflow_condition.go index bf914279e..d27f56d67 100644 --- a/internal/core/workflow_condition.go +++ b/internal/core/workflow_condition.go @@ -12,10 +12,19 @@ import ( // ports, and desktop is the reference implementation). // // The variable pool is the run's committed node outputs: a rule's variableSelector is -// [nodeId, root, ...nested]. nodeId names a prior node, root is one variable that node exposes -// (start: `input` plus each declared input variable; agent: `output`/`structured_output`; condition: -// `selected_branch_id`; output: `output`), and nested walks object fields past the root. A variable a -// prior node has not produced (unknown node, unassigned root, or a node that never ran) is absent. +// [nodeId, root, ...nested]. nodeId names a prior node, root is one variable that node's kind +// declares (start: `input` plus each declared input variable; agent: `output`, plus +// `structured_output` when the node declares an output contract; output: `output`; condition: none), +// and nested walks object fields past the root. +// +// Today only the start node actually writes roots. Agent and output roots are declared by the +// contract but stay unassigned until cloud#70/#74 completes the variable passing, so a selector on +// them reads the same declared-but-unassigned state desktop has before a node produces its output. +// Cloud's runtime has no declaration set — the states map cannot tell an unassigned variable from one +// the graph never declared — so the publish gate refuses selectors naming an unknown node or a root +// its kind does not expose (`condition_selector_undeclared`), which desktop's pool reports as +// `Undeclared` and fails. What reaches the evaluator is therefore either assigned or genuinely +// unassigned, and a variable a prior node has not produced is absent. // // Absence is not a universal no-match: desktop's existence and emptiness operators classify it, and // every other operator fails the node and the run rather than silently falling through to else. The @@ -352,8 +361,10 @@ func workflowNumberOf(value any) (float64, bool) { } } -// workflowTypeLabel names a JSON value's type for a type-mismatch message. Integer-valued floats are -// called integers, matching desktop's type_label, so the same document reports the same mismatch. +// workflowTypeLabel names a JSON value's type for a type-mismatch message. Cloud decodes every number +// as float64, so it cannot tell 92 from 92.0 and labels any integral value "integer"; desktop's +// type_label calls only is_i64/is_u64 "integer" and 92.0 "number". The difference is confined to the +// message text — comparison and equality are unaffected. func workflowTypeLabel(value any) string { switch value.(type) { case nil: @@ -380,6 +391,11 @@ func workflowTypeLabel(value any) string { // workflowJSONEqual compares two JSON values by canonical form (encoding/json sorts map keys), so // object comparison is order-independent and deterministic. Numeric spellings 2 and 2.0 render the // same way, which is desktop's equal() across integer and float representations. +// +// Two edge cases differ from desktop's Value equality: inside a container desktop keeps integer and +// float distinct (`{a:2}` and `{a:2.0}` are not equal), while the canonical form unifies them; and +// desktop's as_f64 comparison treats -0.0 and 0 as equal, while their canonical forms "-0" and "0" +// differ. Top-level numeric comparison matches on both sides. func workflowJSONEqual(actual, expected any) bool { a, b := canonicalJSON(actual), canonicalJSON(expected) if a == "" || b == "" { diff --git a/internal/core/workflow_condition_parity_test.go b/internal/core/workflow_condition_parity_test.go index 73b342eb7..ff68d8e61 100644 --- a/internal/core/workflow_condition_parity_test.go +++ b/internal/core/workflow_condition_parity_test.go @@ -11,7 +11,10 @@ import "testing" // Rust engine. A change to either side that breaks parity must break one of these cases. func TestWorkflowConditionDesktopParity(t *testing.T) { // The pool shapes mirror pool_with_review(): a text root, a structured_output object, and a - // start node whose variables include a numeric limit. + // start node whose variables include a numeric limit. `agent-a` is present with an empty output, + // modeling desktop's declared-but-unassigned `agent-a.text` (that test declares the variable by + // hand). This exercises the evaluator's unset answer; a selector the graph itself does not + // declare is a different case, refused at publish as condition_selector_undeclared. pool := map[string]workflowNodeState{ "review": {Status: "succeeded", Output: Object{ "text": "审查完成", @@ -21,6 +24,7 @@ func TestWorkflowConditionDesktopParity(t *testing.T) { "token": "审查", "limit": 90.0, }}, + "agent-a": {Status: "succeeded", Output: Object{}}, } evaluate := func(t *testing.T, cases []any) (string, error) { t.Helper() From e3cbe58c68bf9db2350fe531e79b6cd76dafc17f Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 19:02:10 +0800 Subject: [PATCH 06/11] test(core): pin ends_with type errors against desktop The operator table covered starts_with's two non-string operands but not ends_with's. Both share workflowCompareTextOrder, and the specs evidence now names this table as the direct evidence for that type error. Co-Authored-By: Claude Code --- internal/core/workflow_scheduler_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/core/workflow_scheduler_test.go b/internal/core/workflow_scheduler_test.go index 1d3debfc2..8eb4825e8 100644 --- a/internal/core/workflow_scheduler_test.go +++ b/internal/core/workflow_scheduler_test.go @@ -283,6 +283,8 @@ func TestWorkflowConditionOperators(t *testing.T) { {"ends_with match", []any{"s", "text"}, "ends_with", "world", true, false}, {"starts_with non-string actual is a type error", []any{"s", "count"}, "starts_with", "2", false, true}, {"starts_with non-string expected is a type error", []any{"s", "text"}, "starts_with", 2.0, false, true}, + {"ends_with non-string actual is a type error", []any{"s", "count"}, "ends_with", "2", false, true}, + {"ends_with non-string expected is a type error", []any{"s", "text"}, "ends_with", 2.0, false, true}, {"empty on empty list", []any{"s", "list"}, "empty", nil, true, false}, {"empty on null", []any{"s", "nothing"}, "empty", nil, true, false}, {"not_empty on text", []any{"s", "text"}, "not_empty", nil, true, false}, From 44a46fc4632ebb039449710268ff857b7c2c9056 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 19:03:07 +0800 Subject: [PATCH 07/11] test(core): pin the undeclared-node half of the selector gate The gate's comment and the specs decision name two cases: a selector on a node the graph does not contain, and a root the node's kind does not declare. Only the second had a case. Add the first with `sys.workflow_id`: desktop declares the system globals, Cloud neither provides nor declares them. Co-Authored-By: Claude Code --- internal/core/workflow_graph_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/core/workflow_graph_test.go b/internal/core/workflow_graph_test.go index 05db28680..45dac99e2 100644 --- a/internal/core/workflow_graph_test.go +++ b/internal/core/workflow_graph_test.go @@ -492,6 +492,13 @@ func TestWorkflowPublishRefusesUnsupportedConditionConfigs(t *testing.T) { node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule([]any{"s", "missing"}, "exists", nil)})}), want: []any{Object{"code": "condition_selector_undeclared", "nodeId": "c", "field": "cases.conditions.variableSelector"}}, }, + { + // Desktop's pool declares the system globals; Cloud neither provides their values nor + // declares them, so `sys` is not a node in the graph and the selector is undeclared. + name: "a selector on a node the graph does not contain", + node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule([]any{"sys", "workflow_id"}, "equals", "wf-1")})}), + want: []any{Object{"code": "condition_selector_undeclared", "nodeId": "c", "field": "cases.conditions.variableSelector"}}, + }, { name: "an operator neither side knows", node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "matches", true)})}), From 7d294533ff78aa8f8dd2807da1bc8cb87031770e Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 19:04:48 +0800 Subject: [PATCH 08/11] docs(core): separate undeclared selectors from unassigned variables The case-logic test called the undeclared-node path "a node that never ran", which under option (a) means a graph node that has not produced output yet. Say that the states have no such node, that desktop fails it as Undeclared, and that only pre-gate snapshots reach this runtime answer. Co-Authored-By: Claude Code --- internal/core/workflow_scheduler_test.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/core/workflow_scheduler_test.go b/internal/core/workflow_scheduler_test.go index 8eb4825e8..7cc616cc4 100644 --- a/internal/core/workflow_scheduler_test.go +++ b/internal/core/workflow_scheduler_test.go @@ -359,8 +359,10 @@ func TestWorkflowConditionMissingExpectedValue(t *testing.T) { // Case logic: "and" requires every rule, "or" any rule, the first matching case wins; a case with no // rules is an authored shell and falls through; a malformed rule, an unknown operator and an unknown -// logic are errors rather than silent else branches, while an exists rule on a node that never ran -// matches nothing (absence is that operator's normal answer). +// logic are errors rather than silent else branches. An exists rule on a node the states do not +// contain matches nothing: the evaluator has no declaration set, so this is the runtime answer for +// snapshots frozen before the publish gate. A new publish naming that node is refused as +// condition_selector_undeclared instead. func TestWorkflowConditionCaseLogic(t *testing.T) { states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{"flag": true, "count": 2.0}}} pick := func(cases any) (string, error) { @@ -401,11 +403,14 @@ func TestWorkflowConditionCaseLogic(t *testing.T) { if _, err := pick(badLogic); err == nil { t.Fatal("unknown logic selected a branch; want an error") } + // `nope` is not a node in the states. Cloud answers absent and the rule matches nothing; desktop + // would call the selector Undeclared and fail the node. Snapshots frozen before the publish gate + // still reach this path, so the runtime answer is pinned here; a new publish is refused instead. ghost := []any{Object{"id": "ghost", "logic": "or", "conditions": []any{ Object{"variableSelector": []any{"nope", "flag"}, "operator": "exists", "value": nil}, }}} if got, err := pick(ghost); err != nil || got != workflowBranchElse { - t.Fatalf("rule on a node that never ran = %q, %v; want else", got, err) + t.Fatalf("exists rule on an undeclared node = %q, %v; want else", got, err) } // Desktop resolves every rule before combining them, so an unset variable in a later rule of an // already-matching or-case still fails instead of being short-circuited away. From 57f453804382916efe8bc033f5d0e5a90cb6c8ad Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 19:42:10 +0800 Subject: [PATCH 09/11] fix(core): treat an explicit null expected value as the missing operand desktop's WireConditionRule.value is Option and serde reads a JSON null as None, so a comparison operator reports a type mismatch instead of comparing against null. resolveWorkflowExpected now reports a present-but-null `value` as absent, and a wire-shape test pins the decoded JSON spelling. Refs ora-space/circus#215 Co-Authored-By: Claude Code --- internal/core/workflow_condition.go | 5 +-- .../core/workflow_condition_parity_test.go | 31 ++++++++++++++++++- internal/core/workflow_condition_template.go | 10 ++++-- internal/core/workflow_graph.go | 5 ++- internal/core/workflow_scheduler_test.go | 7 +++-- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/internal/core/workflow_condition.go b/internal/core/workflow_condition.go index d27f56d67..7b73f94c9 100644 --- a/internal/core/workflow_condition.go +++ b/internal/core/workflow_condition.go @@ -136,8 +136,9 @@ func resolveWorkflowSelector(selector workflowSelector, states map[string]workfl } // workflowConditionOperand is one rule's fully resolved inputs: the variable, the expected value and -// the names both are reported with. hasExpected distinguishes a missing `value` from an explicit -// null, which desktop compares as a real JSON value. +// the names both are reported with. hasExpected is false for a missing `value` and for an explicit +// JSON null alike, matching desktop's Option: serde reads null as None, and a comparison +// operator then reports the missing operand instead of comparing against null. type workflowConditionOperand struct { actual any present bool diff --git a/internal/core/workflow_condition_parity_test.go b/internal/core/workflow_condition_parity_test.go index ff68d8e61..0b5f2d25d 100644 --- a/internal/core/workflow_condition_parity_test.go +++ b/internal/core/workflow_condition_parity_test.go @@ -1,6 +1,9 @@ package core -import "testing" +import ( + "encoding/json" + "testing" +) // Cross-checks for circus#210: the same graph input and the same condition document must produce the // same branch on Cloud and on desktop. Each case mirrors a test in desktop's @@ -128,6 +131,32 @@ func TestWorkflowConditionDesktopParity(t *testing.T) { } } +// An explicit `"value": null` is the missing operand, not a value to compare: desktop's +// WireConditionRule.value is Option and serde reads a JSON null as None, so a comparison +// operator reports a type mismatch instead of comparing against null. This goes through a decoded +// JSON document so the wire spelling is what is pinned, not a Go nil the test author typed. +func TestWorkflowConditionExplicitNullExpected(t *testing.T) { + var doc map[string]any + if err := json.Unmarshal([]byte(`{ + "cases": [{ + "id": "c", + "logic": "and", + "conditions": [{ + "variableSelector": ["s", "nothing"], + "operator": "equals", + "value": null + }] + }] + }`), &doc); err != nil { + t.Fatal(err) + } + states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{"nothing": nil}}} + node := workflowNodeView{ID: "c", Kind: "condition", Data: Object(doc)} + if branch, err := evaluateWorkflowCondition(node, states); err == nil { + t.Fatalf("explicit null expected value matched %q; want a type-mismatch error as desktop's None", branch) + } +} + // The selector shape is part of parity: desktop indexes [node, root, ...nested] against the node's // variable roots, so [start, limit] reads a run-form variable and [start, input] reads the kickoff // prompt. The old Cloud shape nested the whole form under input, which made [start, limit] resolve diff --git a/internal/core/workflow_condition_template.go b/internal/core/workflow_condition_template.go index 45ef4b728..4a5c7fda8 100644 --- a/internal/core/workflow_condition_template.go +++ b/internal/core/workflow_condition_template.go @@ -10,10 +10,12 @@ import "strings" // was never expanded. // resolveWorkflowExpected expands a template expected value. The bool reports whether the rule -// carried a `value` at all; explicit null is a value desktop compares against. +// carried a comparison value; a missing `value` and an explicit JSON null are both reported as +// absent, because desktop's WireConditionRule.value is Option and serde reads a null as None, +// which a comparison operator then reports as a type mismatch rather than comparing against null. func resolveWorkflowExpected(rule Object, states map[string]workflowNodeState) (expected any, hasExpected bool, err error) { raw, present := rule["value"] - if !present { + if !present || raw == nil { return nil, false, nil } text, isText := raw.(string) @@ -90,6 +92,10 @@ func renderWorkflowTemplate(text string, states map[string]workflowNodeState) (s if text, ok := value.(string); ok { rendered.WriteString(text) } else { + // canonicalJSON renders an integer-valued float as its integer spelling (2.0 -> 2), while + // desktop's serde_json keeps the JSON number spelling (2.0). Cloud cannot tell 2 from 2.0 + // after decoding, so a mixed template is not byte-identical to desktop for such values; + // the rendered values still compare equal as JSON. rendered.WriteString(canonicalJSON(value)) } rest = afterOpen[closeAt+len("#}}"):] diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 5c6d40e2a..48dac1de6 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -306,7 +306,10 @@ func workflowDeclaredRoots(nodes []any) map[string]map[string]bool { case "agent": set["output"] = true // The contract lives in the node's `agentConfig`, the same place desktop's wire node reads - // it from; a contract present with a null value declares nothing extra. + // it from; a contract present with a null value declares nothing extra. desktop declares + // structured_output only for an OutputContract::Structured, while this accepts any non-null + // contract; the difference is unreachable today because outputContract is refused by + // node_config_unsupported, so tighten it when cloud#70/#74 makes contracts reachable. if contract, present := data.O("agentConfig")["outputContract"]; present && contract != nil { set["structured_output"] = true } diff --git a/internal/core/workflow_scheduler_test.go b/internal/core/workflow_scheduler_test.go index 7cc616cc4..6a8cb713a 100644 --- a/internal/core/workflow_scheduler_test.go +++ b/internal/core/workflow_scheduler_test.go @@ -268,7 +268,7 @@ func TestWorkflowConditionOperators(t *testing.T) { {"is is equals", []any{"s", "flag"}, "is", true, true, false}, {"is_not is not_equals", []any{"s", "flag"}, "is_not", false, true, false}, {"equals mismatch", []any{"s", "flag"}, "equals", false, false, false}, - {"equals null", []any{"s", "nothing"}, "equals", nil, true, false}, + {"equals null is a type error", []any{"s", "nothing"}, "equals", nil, false, true}, {"not_equals mismatch", []any{"s", "flag"}, "not_equals", true, false, false}, {"not_equals match", []any{"s", "flag"}, "not_equals", false, true, false}, {"contains string match", []any{"s", "text"}, "contains", "lo wo", true, false}, @@ -347,8 +347,9 @@ func TestWorkflowConditionOperators(t *testing.T) { } } -// A rule with no expected value is not the same as one whose expected value is null: desktop -// compares explicit null, and reports the missing operand for everything else. +// A rule with no expected value and one whose expected value is an explicit JSON null behave the +// same: desktop's WireConditionRule.value is Option, serde reads null as None, and a +// comparison operator reports the missing operand rather than comparing against null. func TestWorkflowConditionMissingExpectedValue(t *testing.T) { states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{"flag": true}}} rule := Object{"variableSelector": []any{"s", "flag"}, "operator": "equals"} From f5cf62196b9e3bd1f927cee6ab07dd9bbb3cee63 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 19:52:01 +0800 Subject: [PATCH 10/11] fix(core): gate expected-value template selectors against declared roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish gate checked variableSelector against the graph's declared variable roots but not the placeholders in an expected-value template, so a template like {{#c.selectedBranch#}} — a root Cloud's condition node writes at run time but desktop never declares — passed the gate and then diverged at evaluation. The gate now parses every placeholder through the same declaredRoots and refuses an undeclared node or root with condition_selector_undeclared on cases.conditions.value, covered by a negative publish case. Refs ora-space/circus#215 Co-Authored-By: Claude Code --- internal/core/workflow_condition_template.go | 25 ++++++++++++++++++++ internal/core/workflow_graph.go | 12 ++++++++++ internal/core/workflow_graph_test.go | 9 +++++++ 3 files changed, 46 insertions(+) diff --git a/internal/core/workflow_condition_template.go b/internal/core/workflow_condition_template.go index 4a5c7fda8..26d69dd94 100644 --- a/internal/core/workflow_condition_template.go +++ b/internal/core/workflow_condition_template.go @@ -56,6 +56,31 @@ func exclusiveWorkflowTemplate(text string) (workflowSelector, bool) { return buildWorkflowSelector(strings.Split(inner, ".")) } +// workflowTemplateSelectors returns the placeholder selectors a `value` template references, so the +// publish gate can hold a template to the same declaredRoots contract as `variableSelector`. It +// reads every `{{#node.root#}}` occurrence the renderer expands; a placeholder that is not a +// well-formed selector is skipped, because the renderer already reports that shape as an error at +// evaluation time and the gate's only concern here is a reference desktop would never declare. +func workflowTemplateSelectors(text string) []workflowSelector { + var selectors []workflowSelector + rest := text + for { + open := strings.Index(rest, "{{#") + if open < 0 { + return selectors + } + afterOpen := rest[open+len("{{#"):] + closeAt := strings.Index(afterOpen, "#}}") + if closeAt < 0 { + return selectors + } + if selector, ok := buildWorkflowSelector(strings.Split(afterOpen[:closeAt], ".")); ok { + selectors = append(selectors, selector) + } + rest = afterOpen[closeAt+len("#}}"):] + } +} + // renderWorkflowTemplate renders every placeholder in a mixed template: strings verbatim, every // other value as JSON. An unterminated placeholder or an invalid selector is an error, never a // silent blank, matching desktop's render_variable_template. diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 48dac1de6..45c4b8f6a 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -408,6 +408,18 @@ func validateConditionRule(nodeID string, raw any, declaredRoots map[string]map[ if _, ok := workflowConditionOperators[r.S("operator")]; !ok { problems = append(problems, workflowGraphProblem{Code: "condition_operator_unsupported", NodeID: nodeID, Field: "cases.conditions.operator"}) } + // An expected-value template names the same variable pool as variableSelector, so a placeholder + // must hold to the same declarations. Cloud's condition node writes `selectedBranch` at run time, + // but desktop declares no root on a condition node, so without this check a template like + // {{#c.selectedBranch#}} would resolve on Cloud and fail desktop; the gate refuses it the same way + // it refuses a bare selector. Only strings can carry a placeholder. + if text, ok := r["value"].(string); ok { + for _, selector := range workflowTemplateSelectors(text) { + if !declaredRoots[selector.nodeID][selector.root] { + problems = append(problems, workflowGraphProblem{Code: "condition_selector_undeclared", NodeID: nodeID, Field: "cases.conditions.value"}) + } + } + } return problems } diff --git a/internal/core/workflow_graph_test.go b/internal/core/workflow_graph_test.go index 45dac99e2..4e14e57c4 100644 --- a/internal/core/workflow_graph_test.go +++ b/internal/core/workflow_graph_test.go @@ -499,6 +499,15 @@ func TestWorkflowPublishRefusesUnsupportedConditionConfigs(t *testing.T) { node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule([]any{"sys", "workflow_id"}, "equals", "wf-1")})}), want: []any{Object{"code": "condition_selector_undeclared", "nodeId": "c", "field": "cases.conditions.variableSelector"}}, }, + { + // An expected-value template names the same variable pool as variableSelector, so its + // placeholder must hold to the same declarations. `selectedBranch` is exactly what Cloud's + // condition node writes at run time, but desktop declares no root on a condition node, so a + // template reading it would resolve on Cloud and fail desktop. + name: "an expected-value template on an undeclared root", + node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule([]any{"s", "input"}, "equals", "{{#c.selectedBranch#}}")})}), + want: []any{Object{"code": "condition_selector_undeclared", "nodeId": "c", "field": "cases.conditions.value"}}, + }, { name: "an operator neither side knows", node: graphCondition("c", []any{graphConditionCase("yes", "and", []any{graphRule(selector, "matches", true)})}), From 75b02d797a7836553491b53d3d5d8cf8a202ae67 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Mon, 19 Oct 2026 19:52:01 +0800 Subject: [PATCH 11/11] test(core): pin null literal versus null template expected values A literal `value: null` is the missing operand, but a template that resolves to null stays a present value desktop can compare (Option::None versus Some(Value::Null)). The existing null fix already behaves this way; this test pins the distinction so a future change to either path cannot collapse it. Refs ora-space/circus#215 Co-Authored-By: Claude Code --- .../core/workflow_condition_parity_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/core/workflow_condition_parity_test.go b/internal/core/workflow_condition_parity_test.go index 0b5f2d25d..b763573f6 100644 --- a/internal/core/workflow_condition_parity_test.go +++ b/internal/core/workflow_condition_parity_test.go @@ -157,6 +157,24 @@ func TestWorkflowConditionExplicitNullExpected(t *testing.T) { } } +// A template that resolves to null is a present operand, unlike a literal `value: null`: desktop's +// Option reads the literal null as None, but a placeholder resolves to Some(Value::Null), +// which the operator then compares. resolveWorkflowExpected keeps hasExpected=true for the template +// even though the bound value is JSON null, while the literal is reported absent. +func TestWorkflowExpectedNullLiteralVersusTemplate(t *testing.T) { + states := map[string]workflowNodeState{"s": {Status: "succeeded", Output: Object{"nothing": nil}}} + + literal, hasExpected, err := resolveWorkflowExpected(Object{"value": nil}, states) + if err != nil || hasExpected || literal != nil { + t.Fatalf("literal null = (%v, %v, %v); want an absent operand", literal, hasExpected, err) + } + + templated, hasExpected, err := resolveWorkflowExpected(Object{"value": "{{#s.nothing#}}"}, states) + if err != nil || !hasExpected { + t.Fatalf("template null = (%v, %v, %v); want a present null value", templated, hasExpected, err) + } +} + // The selector shape is part of parity: desktop indexes [node, root, ...nested] against the node's // variable roots, so [start, limit] reads a run-form variable and [start, input] reads the kickoff // prompt. The old Cloud shape nested the whole form under input, which made [start, limit] resolve