mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-01 00:05:43 +08:00
Ports the agent canvas subsystem from Python to Go.
## What's included
### Canvas Engine (Phase 0/1)
- State engine, scheduler, variable resolver, Redis checkpoint store,
cancel protocol
- **209 tests** across canvas / component / io packages
### 22 Components (P0–P4)
| Tier | Components |
|---|---|
| P0 T1+T2+T3 | LLM, Agent, ExitLoop, Switch, Categorize, Begin,
Message, Invoke |
| P1 T3 | VariableAggregator, VariableAssigner, StringTransform,
ListOperations, DataOperations |
| P2 T3 | Iteration, IterationItem, Loop, LoopItem |
| P3 T3 | UserFillUp, Fillup |
| P4 T5 | Browser, ExcelProcessor, DocsGenerator |
### DSL v2 Schema (Phase 2.5)
- Typed v2 in-memory model with v1-to-v2 auto-detect converter
- v1 legacy field stripping per plan §2.11.7
### HTTP Endpoints & Bug Fixes (Plans PR1–PR3)
- **DELETE SQL bug fix**: gorm v2 `Where("id = ?", id).Delete(...)`
pattern
- **CreateAgent validation**: title/DSL required, duplicate check, 103
envelope
- **13 new endpoints**: templates, prompts, tags, sessions CRUD,
chat/completions (SSE + non-stream stubs), rerun, test_db_connection,
logs, webhook/logs
- **756 Go unit tests** (745 → 756, +18)
- **17 → 0 Python integration test failures** (test_agents.py +
test_session_management/)
### Tools
21 eino tools: HTTPHelper, search tools, financial/data tools, mandatory
stubs
### Infrastructure
OTel observability, NATS message queue, DeepDoc gRPC client, SSRF
guards, IDOR mitigation
756 lines
24 KiB
Go
756 lines
24 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
// loop_subgraph.go — Loop macro expansion for BuildWorkflow.
|
|
//
|
|
// The RAGFlow DSL expresses a loop as a parent Loop component with a
|
|
// chain of downstream body components. In the Go port we collapse this
|
|
// to a SINGLE eino node by:
|
|
// 1. Collecting the Loop's downstream descendants into a sub-graph
|
|
// (a *compose.Workflow[map[string]any, map[string]any]).
|
|
// 2. Prepending a synthetic "LoopInit" lambda that resolves the DSL's
|
|
// `loop_variables` and writes them into the per-run CanvasState
|
|
// under `state.Outputs[loopID][name]`, then passes the outer input
|
|
// through.
|
|
// 3. Translating the DSL's `loop_termination_condition` list into a
|
|
// `workflowx.LoopCondition[map[string]any]` closure that reads the
|
|
// same state slots via `state.GetVar` on every iteration.
|
|
//
|
|
// The actual installation into the outer graph is done by BuildWorkflow
|
|
// (canvas.go) via workflowx.AddLoopNode, which registers the resulting
|
|
// *WorkflowNode inside the outer *compose.Workflow.
|
|
package canvas
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"ragflow/internal/agent/workflowx"
|
|
|
|
"github.com/cloudwego/eino/compose"
|
|
)
|
|
|
|
// loopExpansion holds the two artefacts produced by buildLoopExpansion
|
|
// and consumed by BuildWorkflow to install the loop node.
|
|
type loopExpansion struct {
|
|
Sub *compose.Workflow[map[string]any, map[string]any]
|
|
ShouldQuit workflowx.LoopCondition[map[string]any]
|
|
MaxIters int
|
|
Members map[string]bool // cpn_ids consumed by the sub-graph; caller skips these in the main pass.
|
|
}
|
|
|
|
// buildLoopExpansion constructs the sub-workflow + termination condition
|
|
// for the given Loop cpn. It does NOT touch the outer workflow — the
|
|
// caller is responsible for installing the result via
|
|
// workflowx.AddLoopNode and for skipping the members in the main
|
|
// BuildWorkflow pass.
|
|
//
|
|
// Parameters:
|
|
//
|
|
// c — the parent Canvas (DSL representation).
|
|
// loopID — the cpn_id of the Loop component being expanded.
|
|
//
|
|
// The returned `Members` is the set of cpn_ids that the expansion
|
|
// consumed as body nodes. BuildWorkflow must skip these when iterating
|
|
// `c.Components` in the main pass (they will be wired inside the
|
|
// sub-graph, not the outer graph).
|
|
func buildLoopExpansion(ctx context.Context, c *Canvas, loopID string) (*loopExpansion, error) {
|
|
if c == nil {
|
|
return nil, fmt.Errorf("canvas: nil canvas")
|
|
}
|
|
if loopID == "" {
|
|
return nil, fmt.Errorf("canvas: buildLoopExpansion: empty loopID")
|
|
}
|
|
if _, ok := c.Components[loopID]; !ok {
|
|
return nil, fmt.Errorf("canvas: buildLoopExpansion: unknown cpn %q", loopID)
|
|
}
|
|
|
|
loopComp := c.Components[loopID]
|
|
|
|
members := collectDescendants(c, loopID)
|
|
|
|
initValues, err := resolveInitialVariables(loopComp.Obj.Params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
|
|
}
|
|
|
|
shouldQuit, err := translateLoopCondition(loopID, loopComp.Obj.Params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
|
|
}
|
|
|
|
maxIters := readMaxLoopCount(loopComp.Obj.Params)
|
|
|
|
sub, err := buildSubWorkflow(ctx, c, members, loopID, initValues)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("canvas: loop %q: %w", loopID, err)
|
|
}
|
|
|
|
return &loopExpansion{
|
|
Sub: sub,
|
|
ShouldQuit: shouldQuit,
|
|
MaxIters: maxIters,
|
|
Members: members,
|
|
}, nil
|
|
}
|
|
|
|
// collectDescendants returns the set of cpn_ids reachable from root via
|
|
// downstream edges, NOT including root itself. The BFS stops at the
|
|
// back-edge to root (i.e. a node whose Downstream contains root). This
|
|
// prevents infinite recursion on cyclic graphs.
|
|
func collectDescendants(c *Canvas, root string) map[string]bool {
|
|
visited := make(map[string]bool)
|
|
queue := []string{}
|
|
for _, child := range c.Components[root].Downstream {
|
|
if child == root {
|
|
continue
|
|
}
|
|
if !visited[child] {
|
|
visited[child] = true
|
|
queue = append(queue, child)
|
|
}
|
|
}
|
|
for len(queue) > 0 {
|
|
cur := queue[0]
|
|
queue = queue[1:]
|
|
for _, child := range c.Components[cur].Downstream {
|
|
if child == root || child == cur {
|
|
continue
|
|
}
|
|
if !visited[child] {
|
|
visited[child] = true
|
|
queue = append(queue, child)
|
|
}
|
|
}
|
|
}
|
|
return visited
|
|
}
|
|
|
|
// buildSubWorkflow constructs a fresh *compose.Workflow[map[string]any,
|
|
// map[string]any] containing one node per member cpn, plus a synthetic
|
|
// "LoopInit" entry node that seeds the loop variables into the per-run
|
|
// state. Edges within the sub-graph mirror the canvas's Downstream
|
|
// relations. The sub-workflow's START wires to LoopInit; the END wires
|
|
// to whichever member has no downstream within the sub-graph (the
|
|
// "tail" of the body).
|
|
//
|
|
// Body nodes are built through buildNodeBody so they share the same
|
|
// legacy-no-op / factory / placeholder routing as the outer graph,
|
|
// and receive the same statePre / statePost handlers so loop body
|
|
// outputs land in CanvasState.Outputs alongside outer-node outputs.
|
|
func buildSubWorkflow(
|
|
ctx context.Context,
|
|
c *Canvas,
|
|
members map[string]bool,
|
|
loopID string,
|
|
initValues map[string]initVarSpec,
|
|
) (*compose.Workflow[map[string]any, map[string]any], error) {
|
|
_ = ctx
|
|
sub := compose.NewWorkflow[map[string]any, map[string]any]()
|
|
nodes := make(map[string]*compose.WorkflowNode, len(members)+1)
|
|
|
|
// Synthetic entry: writes loop variables into the per-run state
|
|
// the FIRST TIME the sub-workflow runs, then returns the input
|
|
// map unchanged. Subsequent iterations skip the seeding so the
|
|
// body's mutations accumulate across iterations — otherwise a
|
|
// VariableAssigner that increments `counter` would be clobbered
|
|
// back to its initial value at the top of every iteration and
|
|
// the loop could never terminate on a condition that watches the
|
|
// counter.
|
|
//
|
|
// "First time" is detected by checking whether the loop's state
|
|
// bucket already holds the variable: a missing bucket entry
|
|
// (GetVar returns nil with no error) means the loop has not yet
|
|
// seeded; any non-nil value means the body already wrote it on
|
|
// a prior iteration. This is safe even for "zero-init" loop
|
|
// variables (number→0, string→"") because Go's typed zero
|
|
// values are non-nil when stored back through SetVar.
|
|
//
|
|
// input_mode dispatch (per agent/component/loop.py:60-77):
|
|
// "constant" → use the literal value from the DSL
|
|
// "variable" → dereference the value as a state ref via
|
|
// state.GetVar; store the resolved value
|
|
// (or nil if the ref is unresolvable — mirrors
|
|
// Python's "treat as literal" fallback)
|
|
// "" (zero) → use the type-derived zero value (resolved at
|
|
// build time by resolveLoopVarValue)
|
|
initNode := sub.AddLambdaNode(loopInitKey,
|
|
compose.InvokableLambda(func(ctx context.Context, in map[string]any) (map[string]any, error) {
|
|
state, _, err := GetStateFromContext[*CanvasState](ctx)
|
|
if err != nil || state == nil {
|
|
return in, nil
|
|
}
|
|
for k, spec := range initValues {
|
|
existing, _ := state.GetVar(loopID + "@" + k)
|
|
if existing != nil {
|
|
continue
|
|
}
|
|
v := spec.Value
|
|
if spec.InputMode == "variable" {
|
|
ref, _ := spec.Value.(string)
|
|
resolved, err := state.GetVar(ref)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("canvas: loop %q init: variable %q ref %q: %w", loopID, k, ref, err)
|
|
}
|
|
v = resolved
|
|
}
|
|
state.SetVar(loopID, k, v)
|
|
}
|
|
return in, nil
|
|
}),
|
|
)
|
|
nodes[loopInitKey] = initNode
|
|
|
|
// Body nodes: each member becomes a real factory-built (or
|
|
// placeholder, when no factory is registered) component invoke
|
|
// wrapped by withStateBracket so it shares the same state
|
|
// snapshot / result-persistence contract as outer-graph nodes.
|
|
// We do NOT use eino's StatePreHandler / StatePostHandler here
|
|
// because the sub-workflow has no WithGenLocalState of its own:
|
|
// state flows in through ctx (runtime.WithState) attached by
|
|
// the caller, and is read back via runtime.GetStateFromContext
|
|
// inside withStateBracket. This is what lets a Loop body
|
|
// actually mutate CanvasState (e.g. VariableAssigner
|
|
// incrementing the loop counter) so the LoopCondition closure
|
|
// can observe the change on the next iteration.
|
|
for cpnID := range members {
|
|
name := c.Components[cpnID].Obj.ComponentName
|
|
if name == "" {
|
|
return nil, fmt.Errorf("canvas: loop %q member %q has empty component_name", loopID, cpnID)
|
|
}
|
|
body, err := buildNodeBody(cpnID, name, c.Components[cpnID].Obj.Params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nodes[cpnID] = sub.AddLambdaNode(cpnID,
|
|
compose.InvokableLambda[map[string]any, map[string]any](withStateBracket(body)),
|
|
compose.WithNodeName(cpnID),
|
|
)
|
|
}
|
|
|
|
// Wire edges. The synthetic init node connects to every body node
|
|
// that has no upstream within the sub-graph (the body's "entry"
|
|
// nodes). For diamond / merge topologies within the body, we use
|
|
// the same eino one-data-input rule as BuildWorkflow: the first
|
|
// upstream carries data, the rest are exec-only AddDependency.
|
|
for cpnID := range members {
|
|
upstreams := c.Components[cpnID].Upstream
|
|
first := true
|
|
for _, up := range upstreams {
|
|
if up == loopID {
|
|
// Upstream is the parent Loop; in the sub-graph the
|
|
// data source is the synthetic init node.
|
|
if first {
|
|
nodes[cpnID].AddInput(loopInitKey)
|
|
first = false
|
|
} else {
|
|
nodes[cpnID].AddDependency(loopInitKey)
|
|
}
|
|
continue
|
|
}
|
|
if !members[up] {
|
|
continue
|
|
}
|
|
if first {
|
|
nodes[cpnID].AddInput(up)
|
|
first = false
|
|
} else {
|
|
nodes[cpnID].AddDependency(up)
|
|
}
|
|
}
|
|
if first {
|
|
// No in-subgraph upstream: wire from init (this happens
|
|
// for body entries whose only upstream in the DSL is the
|
|
// Loop itself).
|
|
nodes[cpnID].AddInput(loopInitKey)
|
|
}
|
|
}
|
|
|
|
// Wire END: every member that has no downstream within the
|
|
// sub-graph is a sub-graph terminal; wire sub.End() to it.
|
|
hasDownstream := make(map[string]bool, len(members))
|
|
for cpnID := range members {
|
|
for _, down := range c.Components[cpnID].Downstream {
|
|
if members[down] {
|
|
hasDownstream[cpnID] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
hasEnd := false
|
|
for cpnID := range members {
|
|
if hasDownstream[cpnID] {
|
|
continue
|
|
}
|
|
sub.End().AddInput(cpnID)
|
|
hasEnd = true
|
|
}
|
|
if !hasEnd {
|
|
// No body terminals — wire END to the init node so the
|
|
// sub-workflow at least echoes the input once.
|
|
sub.End().AddInput(loopInitKey)
|
|
}
|
|
|
|
// Wire START. The synthetic init node is the sub-workflow's
|
|
// entry; eino's Workflow requires every start node to be wired
|
|
// from compose.START explicitly. The init node takes the
|
|
// sub-workflow's input (the per-iteration `prev`) and seeds the
|
|
// loop variables into state.
|
|
initNode.AddInput(compose.START)
|
|
|
|
return sub, nil
|
|
}
|
|
|
|
// loopInitKey is the synthetic cpn_id used for the LoopInit entry node
|
|
// inside the sub-workflow. Using a reserved key avoids collisions with
|
|
// user-defined cpn_ids.
|
|
const loopInitKey = "__loop_init__"
|
|
|
|
// initVarSpec carries the per-variable info the init lambda needs to
|
|
// decide how to seed the loop variable into the per-run state.
|
|
//
|
|
// For input_mode == "variable", Value is the ref string to dereference
|
|
// at init time via state.GetVar; for "constant", Value is used as-is;
|
|
// for "" (zero-init), Value is the type-derived zero (resolved at build
|
|
// time by resolveLoopVarValue) and the init lambda stores it directly.
|
|
type initVarSpec struct {
|
|
Value any
|
|
InputMode string
|
|
}
|
|
|
|
// resolveInitialVariables applies the input_mode dispatch from
|
|
// agent/component/loop.py:60-77 to a list of loop_variable entries.
|
|
//
|
|
// input_mode == "variable" → returns the ref string in Value
|
|
// (the init lambda dereferences it at
|
|
// runtime via state.GetVar; resolution
|
|
// is deferred because this helper is
|
|
// state-free).
|
|
// input_mode == "constant" → Value is the literal value.
|
|
// otherwise (zero-init) → Value is the type-based zero value.
|
|
//
|
|
// The init lambda (buildSubWorkflow) iterates the returned map and
|
|
// writes each Value into the per-run state under
|
|
// `state.Outputs[loopID][name]`. The "variable" dereference happens
|
|
// there, in the lambda body, where the live CanvasState is available.
|
|
func resolveInitialVariables(params map[string]any) (map[string]initVarSpec, error) {
|
|
rawList, _ := params["loop_variables"].([]any)
|
|
out := make(map[string]initVarSpec, len(rawList))
|
|
for i, raw := range rawList {
|
|
item, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("loop_variable[%d]: not a map", i)
|
|
}
|
|
name, inputMode, value, typ, err := readLoopVarFields(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v, err := resolveLoopVarValue(inputMode, value, typ)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loop_variable[%d] %q: %w", i, name, err)
|
|
}
|
|
out[name] = initVarSpec{Value: v, InputMode: inputMode}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func readLoopVarFields(item map[string]any) (name, inputMode string, value, typ any, err error) {
|
|
if item == nil {
|
|
return "", "", nil, nil, fmt.Errorf("nil loop_variable entry")
|
|
}
|
|
vRaw, hasVar := item["variable"]
|
|
imRaw, hasIM := item["input_mode"]
|
|
valRaw, hasVal := item["value"]
|
|
typeRaw, hasType := item["type"]
|
|
|
|
if !hasVar || vRaw == nil {
|
|
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'variable')")
|
|
}
|
|
if !hasIM || imRaw == nil {
|
|
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'input_mode')")
|
|
}
|
|
if !hasVal {
|
|
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'value')")
|
|
}
|
|
if !hasType || typeRaw == nil {
|
|
return "", "", nil, nil, fmt.Errorf("loop_variable is not complete (missing 'type')")
|
|
}
|
|
|
|
name, _ = vRaw.(string)
|
|
if name == "" {
|
|
name = fmt.Sprintf("%v", vRaw)
|
|
}
|
|
inputMode, _ = imRaw.(string)
|
|
return name, inputMode, valRaw, typeRaw, nil
|
|
}
|
|
|
|
func resolveLoopVarValue(inputMode string, value, typ any) (any, error) {
|
|
switch inputMode {
|
|
case "variable":
|
|
// The "variable" path is handled at init time inside
|
|
// buildSubWorkflow's init lambda, where the state is
|
|
// available. Here we just return the ref string.
|
|
return value, nil
|
|
case "constant":
|
|
return value, nil
|
|
}
|
|
return zeroValueForType(typ), nil
|
|
}
|
|
|
|
// zeroValueForType implements the type→zero mapping from
|
|
// agent/component/loop.py:65-76:
|
|
//
|
|
// number → 0
|
|
// string → ""
|
|
// boolean → false
|
|
// object* → map[string]any{}
|
|
// array* → []any{}
|
|
// else → ""
|
|
func zeroValueForType(typ any) any {
|
|
s, _ := typ.(string)
|
|
switch {
|
|
case s == "number":
|
|
return 0
|
|
case s == "string":
|
|
return ""
|
|
case s == "boolean":
|
|
return false
|
|
case strings.HasPrefix(s, "object"):
|
|
return map[string]any{}
|
|
case strings.HasPrefix(s, "array"):
|
|
return []any{}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// translateLoopCondition converts the DSL's loop_termination_condition
|
|
// list into a workflowx.LoopCondition[map[string]any] closure.
|
|
//
|
|
// The closure reads each condition's variable via
|
|
// `state.GetVar(loopID + "." + variable)` on every iteration, applies
|
|
// the operator, and combines results via the configured logical
|
|
// operator ("and" by default, "or" otherwise).
|
|
//
|
|
// The closure's per-iteration cost is one state lookup per condition —
|
|
// no allocations once the conditions slice is captured.
|
|
func translateLoopCondition(loopID string, params map[string]any) (workflowx.LoopCondition[map[string]any], error) {
|
|
rawList, _ := params["loop_termination_condition"].([]any)
|
|
conditions := make([]loopConditionSpec, 0, len(rawList))
|
|
for i, raw := range rawList {
|
|
m, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("loop_termination_condition[%d]: not a map", i)
|
|
}
|
|
variable, hasVar := m["variable"].(string)
|
|
operator, hasOp := m["operator"].(string)
|
|
if !hasVar || variable == "" {
|
|
return nil, fmt.Errorf("loop_termination_condition[%d] is incomplete (missing 'variable')", i)
|
|
}
|
|
if !hasOp || operator == "" {
|
|
return nil, fmt.Errorf("loop_termination_condition[%d] is incomplete (missing 'operator')", i)
|
|
}
|
|
inputMode, _ := m["input_mode"].(string)
|
|
if inputMode == "" {
|
|
inputMode = "constant"
|
|
}
|
|
conditions = append(conditions, loopConditionSpec{
|
|
Variable: variable,
|
|
Operator: operator,
|
|
Value: m["value"],
|
|
InputMode: inputMode,
|
|
})
|
|
}
|
|
logicalOp, _ := params["logical_operator"].(string)
|
|
if logicalOp == "" {
|
|
logicalOp = "and"
|
|
}
|
|
if logicalOp != "and" && logicalOp != "or" {
|
|
return nil, fmt.Errorf("invalid logical_operator %q (want 'and' or 'or')", logicalOp)
|
|
}
|
|
|
|
return func(ctx context.Context, _ int, _, _ map[string]any) (bool, error) {
|
|
// The condition is evaluated at the end of each iteration.
|
|
// We need access to the per-run state to read loop variables
|
|
// and other DSL variables. The workflowx lambda passes the
|
|
// loop's outer context into this closure, so
|
|
// canvas.GetStateFromContext works.
|
|
state, _, err := GetStateFromContext[*CanvasState](ctx)
|
|
if err != nil || state == nil {
|
|
return false, fmt.Errorf("loop %q: condition eval: no canvas state in context", loopID)
|
|
}
|
|
if len(conditions) == 0 {
|
|
// No conditions means the loop only stops at max count
|
|
// — never quit on conditions. Mirrors Python fallback.
|
|
return false, nil
|
|
}
|
|
// Vacuous starting value: true for AND, false for OR.
|
|
combined := logicalOp == "and"
|
|
for _, spec := range conditions {
|
|
v, err := evalOneLoopCondition(state, loopID, spec)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if logicalOp == "or" {
|
|
combined = combined || v
|
|
} else {
|
|
combined = combined && v
|
|
}
|
|
}
|
|
return combined, nil
|
|
}, nil
|
|
}
|
|
|
|
type loopConditionSpec struct {
|
|
Variable string
|
|
Operator string
|
|
Value any
|
|
InputMode string // "constant" or "variable"
|
|
}
|
|
|
|
// evalOneLoopCondition resolves a single condition entry. Mirrors
|
|
// loopitem.py:128-142. Variable lookup is by full cpn_id path
|
|
// ("loopID.varName" for loop variables, or whatever ref the DSL
|
|
// supplies for state-level refs).
|
|
func evalOneLoopCondition(state *CanvasState, loopID string, spec loopConditionSpec) (bool, error) {
|
|
// Resolve the right-hand side value.
|
|
var rhs any
|
|
if spec.InputMode == "variable" {
|
|
ref, _ := spec.Value.(string)
|
|
v, err := state.GetVar(ref)
|
|
if err != nil {
|
|
return false, fmt.Errorf("loop %q: condition rhs ref %q: %w", loopID, ref, err)
|
|
}
|
|
rhs = v
|
|
} else if spec.InputMode != "constant" {
|
|
return false, fmt.Errorf("loop %q: invalid input mode %q", loopID, spec.InputMode)
|
|
} else {
|
|
rhs = spec.Value
|
|
}
|
|
// Resolve the variable being tested. The DSL stores either a bare
|
|
// variable name (loop variable) or a full cpn_id@param ref. For
|
|
// loop variables written by the init lambda, the bucket key is
|
|
// "loopID" so the ref is "loopID@name". For arbitrary state refs,
|
|
// the DSL passes the full path.
|
|
ref := spec.Variable
|
|
if !strings.Contains(ref, ".") && !strings.Contains(ref, "@") {
|
|
// Bare name — assume it's a loop variable.
|
|
ref = loopID + "@" + ref
|
|
}
|
|
got, err := state.GetVar(ref)
|
|
if err != nil {
|
|
return false, fmt.Errorf("loop %q: condition lhs ref %q: %w", loopID, ref, err)
|
|
}
|
|
return evaluateCondition(got, spec.Operator, rhs)
|
|
}
|
|
|
|
// evaluateCondition is the type-dispatched operator logic that mirrors
|
|
// loopitem.py:48-122. The operator set is the union of operators used
|
|
// across all type branches — at runtime only the branches matching
|
|
// the dynamic type of `var` are reachable.
|
|
func evaluateCondition(varVal any, op string, value any) (bool, error) {
|
|
switch v := varVal.(type) {
|
|
case nil:
|
|
if op == "empty" {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
case string:
|
|
return evalStringOp(v, op, value)
|
|
case bool:
|
|
return evalBoolOp(v, op, value)
|
|
case int:
|
|
return evalNumberOp(float64(v), op, value)
|
|
case int32:
|
|
return evalNumberOp(float64(v), op, value)
|
|
case int64:
|
|
return evalNumberOp(float64(v), op, value)
|
|
case float32:
|
|
return evalNumberOp(float64(v), op, value)
|
|
case float64:
|
|
return evalNumberOp(v, op, value)
|
|
case map[string]any:
|
|
return evalDictOp(v, op, value)
|
|
case []any:
|
|
return evalListOp(v, op, value)
|
|
}
|
|
return false, fmt.Errorf("invalid operator: %s (variable type %T unsupported)", op, varVal)
|
|
}
|
|
|
|
func evalStringOp(s, op string, value any) (bool, error) {
|
|
switch op {
|
|
case "contains":
|
|
vs, _ := value.(string)
|
|
return strings.Contains(s, vs), nil
|
|
case "not contains":
|
|
vs, _ := value.(string)
|
|
return !strings.Contains(s, vs), nil
|
|
case "start with":
|
|
vs, _ := value.(string)
|
|
return strings.HasPrefix(s, vs), nil
|
|
case "end with":
|
|
vs, _ := value.(string)
|
|
return strings.HasSuffix(s, vs), nil
|
|
case "is":
|
|
return s == value, nil
|
|
case "is not":
|
|
return s != value, nil
|
|
case "empty":
|
|
return s == "", nil
|
|
case "not empty":
|
|
return s != "", nil
|
|
}
|
|
return false, fmt.Errorf("invalid operator: %s (string variable)", op)
|
|
}
|
|
|
|
func evalBoolOp(b bool, op string, value any) (bool, error) {
|
|
switch op {
|
|
case "is":
|
|
vb, _ := value.(bool)
|
|
return b == vb, nil
|
|
case "is not":
|
|
vb, _ := value.(bool)
|
|
return b != vb, nil
|
|
case "empty":
|
|
// mirrors `var is None` for booleans
|
|
return b == false && value == nil, nil
|
|
case "not empty":
|
|
return b == true || value != nil, nil
|
|
}
|
|
return false, fmt.Errorf("invalid operator: %s (bool variable)", op)
|
|
}
|
|
|
|
func evalNumberOp(n float64, op string, value any) (bool, error) {
|
|
cmp, ok := toFloat(value)
|
|
if !ok && !isNilOp(op) {
|
|
return false, fmt.Errorf("invalid operator: %s (number variable, non-numeric value)", op)
|
|
}
|
|
switch op {
|
|
case "=":
|
|
return n == cmp, nil
|
|
case "≠":
|
|
return n != cmp, nil
|
|
case ">":
|
|
return n > cmp, nil
|
|
case "<":
|
|
return n < cmp, nil
|
|
case "≥":
|
|
return n >= cmp, nil
|
|
case "≤":
|
|
return n <= cmp, nil
|
|
case "empty":
|
|
return value == nil, nil
|
|
case "not empty":
|
|
return value != nil, nil
|
|
}
|
|
return false, fmt.Errorf("invalid operator: %s (number variable)", op)
|
|
}
|
|
|
|
func evalDictOp(m map[string]any, op string, _ any) (bool, error) {
|
|
switch op {
|
|
case "empty":
|
|
return len(m) == 0, nil
|
|
case "not empty":
|
|
return len(m) > 0, nil
|
|
}
|
|
return false, fmt.Errorf("invalid operator: %s (dict variable)", op)
|
|
}
|
|
|
|
func evalListOp(lst []any, op string, value any) (bool, error) {
|
|
switch op {
|
|
case "contains":
|
|
return listContains(lst, value), nil
|
|
case "not contains":
|
|
return !listContains(lst, value), nil
|
|
case "is":
|
|
return listEqual(lst, value), nil
|
|
case "is not":
|
|
return !listEqual(lst, value), nil
|
|
case "empty":
|
|
return len(lst) == 0, nil
|
|
case "not empty":
|
|
return len(lst) > 0, nil
|
|
}
|
|
return false, fmt.Errorf("invalid operator: %s (list variable)", op)
|
|
}
|
|
|
|
func listContains(lst []any, value any) bool {
|
|
for _, x := range lst {
|
|
if x == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func listEqual(lst []any, value any) bool {
|
|
other, ok := value.([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if len(lst) != len(other) {
|
|
return false
|
|
}
|
|
for i := range lst {
|
|
if lst[i] != other[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func toFloat(v any) (float64, bool) {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x, true
|
|
case float32:
|
|
return float64(x), true
|
|
case int:
|
|
return float64(x), true
|
|
case int32:
|
|
return float64(x), true
|
|
case int64:
|
|
return float64(x), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func isNilOp(op string) bool {
|
|
return op == "empty" || op == "not empty"
|
|
}
|
|
|
|
// readMaxLoopCount returns the configured `maximum_loop_count` for the
|
|
// Loop. 0 means "infinite" (no cap, only condition-driven termination).
|
|
func readMaxLoopCount(params map[string]any) int {
|
|
v, ok := params["maximum_loop_count"]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
switch x := v.(type) {
|
|
case int:
|
|
return x
|
|
case int64:
|
|
return int(x)
|
|
case int32:
|
|
return int(x)
|
|
case float64:
|
|
return int(x)
|
|
case float32:
|
|
return int(x)
|
|
}
|
|
return 0
|
|
}
|