From b6dd7d2287acb0aee008062652abbc04f89eb63d Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:37:53 -0700 Subject: [PATCH 1/9] Model loop nodes, each edges and joins, and loop budgets in the workflow IR --- src/firefly_weave/compiler/ir.py | 63 +++- .../contracts/language_features.py | 3 +- src/firefly_weave/contracts/limits.py | 6 + tests/unit/compiler/test_contracts.py | 3 + tests/unit/compiler/test_loop_ir.py | 281 ++++++++++++++++++ 5 files changed, 340 insertions(+), 16 deletions(-) create mode 100644 tests/unit/compiler/test_loop_ir.py diff --git a/src/firefly_weave/compiler/ir.py b/src/firefly_weave/compiler/ir.py index 3527d09e..61e2eb0b 100644 --- a/src/firefly_weave/compiler/ir.py +++ b/src/firefly_weave/compiler/ir.py @@ -42,6 +42,7 @@ DecisionTableSpec, DecisionTableStep, Expression, + ForEachStep, HumanTaskStep, JsonPointer, LLMStep, @@ -107,6 +108,7 @@ class Guard(ContractModel): "signal_payload", "human_output", "branch_output", + "loop_items", "workflow_input", "workflow_output", ] @@ -201,6 +203,18 @@ class ParallelNode(Node): join: NodeId +class ForEachNode(Node): + """Runs the body that starts at ``target`` once per element of ``items``; ``join`` collects the results.""" + + kind: Literal["forEach"] + items: Expression + concurrency: PositiveInt + max_items: PositiveInt = Field(alias="maxItems") + collect: Literal["all", "nonNull"] + target: NodeId + join: NodeId + + class BranchOutputNode(Node): kind: Literal["branch-output"] owner: NodeId @@ -212,7 +226,7 @@ class BranchOutputNode(Node): class JoinNode(Node): kind: Literal["join"] owner: NodeId - mode: Literal["all", "selected"] + mode: Literal["all", "selected", "each"] branches: list[NodeId] = Field(min_length=1) completes: bool @@ -229,16 +243,21 @@ class JoinNode(Node): | FailNode | SwitchNode | ParallelNode + | ForEachNode | BranchOutputNode | JoinNode, Field(discriminator="kind"), ] +# Nodes that own lexical blocks and a join. +CONTROL_NODES = (SwitchNode, ParallelNode, ForEachNode) + + class IREdge(ContractModel): source: NodeId target: NodeId - kind: Literal["next", "case", "default", "fork", "join"] + kind: Literal["next", "case", "default", "fork", "each", "join"] branch: UnicodeString | None @@ -265,10 +284,14 @@ def topology(self) -> IRGraph: join_members = {n.id: set(n.branches) for n in self.nodes if isinstance(n, JoinNode)} control_names = { n.id: ( - {c.branch for c in n.cases} | {"default"} if isinstance(n, SwitchNode) else {b.name for b in n.branches} + {c.branch for c in n.cases} | {"default"} + if isinstance(n, SwitchNode) + else {"body"} + if isinstance(n, ForEachNode) + else {b.name for b in n.branches} ) for n in self.nodes - if isinstance(n, (SwitchNode, ParallelNode)) + if isinstance(n, CONTROL_NODES) } seen = set() for edge in self.edges: @@ -295,7 +318,7 @@ def topology(self) -> IRGraph: actual = {(e.target, e.kind, e.branch) for e in outgoing[node.id]} if isinstance(node, (FailNode, EndNode)) and actual: raise ValueError("Terminal node has outgoing edges") - if isinstance(node, (SwitchNode, ParallelNode)): + if isinstance(node, CONTROL_NODES): join = nodes.get(node.join) if not isinstance(join, JoinNode) or join.owner != node.id: raise ValueError("Invalid control join") @@ -305,6 +328,10 @@ def topology(self) -> IRGraph: } names = [c.branch for c in node.cases] + ["default"] mode = "selected" + elif isinstance(node, ForEachNode): + expected = {(node.target, "each", "body")} + names = ["body"] + mode = "each" else: expected = {(b.target, "fork", b.name) for b in node.branches} names = [b.name for b in node.branches] @@ -319,11 +346,13 @@ def topology(self) -> IRGraph: if [n.branch for n in outputs if isinstance(n, BranchOutputNode)] != names: raise ValueError("Invalid join branch order") completion = [n.completes for n in outputs if isinstance(n, BranchOutputNode)] - if join.completes != (all(completion) if mode == "all" else any(completion)): + if join.completes != (any(completion) if mode == "selected" else all(completion)): raise ValueError("Invalid join completion") + if mode == "each" and not join.completes: + raise ValueError("A loop body must complete") elif isinstance(node, BranchOutputNode): owner = nodes.get(node.owner) - if not isinstance(owner, (SwitchNode, ParallelNode)): + if not isinstance(owner, CONTROL_NODES): raise ValueError("Orphan branch output") owner_join = nodes.get(owner.join) if not isinstance(owner_join, JoinNode) or node.id not in join_members[owner_join.id]: @@ -335,11 +364,7 @@ def topology(self) -> IRGraph: raise ValueError("Invalid branch completion edges") elif isinstance(node, JoinNode): owner = nodes.get(node.owner) - if ( - not isinstance(owner, (SwitchNode, ParallelNode)) - or owner.join != node.id - or owner.scope != node.scope - ): + if not isinstance(owner, CONTROL_NODES) or owner.join != node.id or owner.scope != node.scope: raise ValueError("Orphan join") if not node.completes and actual: raise ValueError("Noncompleting join has successor") @@ -357,14 +382,15 @@ def topology(self) -> IRGraph: source, target = nodes[edge.source], nodes[edge.target] if edge.kind == "next" and source.scope != target.scope: raise ValueError("Sequential edge crosses lexical scopes") - if edge.kind in {"case", "default", "fork"} and target.scope != source.scope + [source.id, edge.branch]: + control = edge.kind in {"case", "default", "fork", "each"} + if control and target.scope != source.scope + [source.id, edge.branch]: raise ValueError("Control edge crosses lexical scopes") for node in self.nodes: if len(node.scope) % 2: raise ValueError("Invalid lexical scope") for index in range(0, len(node.scope), 2): owner = nodes.get(node.scope[index]) - if not isinstance(owner, (SwitchNode, ParallelNode)) or owner.scope != node.scope[:index]: + if not isinstance(owner, CONTROL_NODES) or owner.scope != node.scope[:index]: raise ValueError("Unknown lexical owner") if node.scope[index + 1] not in control_names[owner.id]: raise ValueError("Unknown lexical branch") @@ -383,13 +409,15 @@ def topology(self) -> IRGraph: # A completing branch must have a path to its output within that lexical block. # Nested scopes cap repeated traversal to the declared lexical nesting ceiling. for node in self.nodes: - if not isinstance(node, (SwitchNode, ParallelNode)): + if not isinstance(node, CONTROL_NODES): continue join = nodes[node.join] assert isinstance(join, JoinNode) entries = ( {c.branch: c.target for c in node.cases} | {"default": node.default} if isinstance(node, SwitchNode) + else {"body": node.target} + if isinstance(node, ForEachNode) else {b.name: b.target for b in node.branches} ) for output_id in join.branches: @@ -438,6 +466,9 @@ def workflow_requirements(graph: IRGraph) -> tuple[str, list[LanguageFeature]]: expressions.extend((node.title, node.context)) elif isinstance(node, SwitchNode): expressions.extend(case.when for case in node.cases) + elif isinstance(node, ForEachNode): + features.add("flow.forEach") + expressions.append(node.items) while expressions: expression = expressions.pop() if isinstance(expression, OpExpression): @@ -666,6 +697,8 @@ def binding( elif isinstance(step, ParallelStep): for branch in step.branches.values(): pending.extend(branch.steps) + elif isinstance(step, ForEachStep): + pending.extend(step.body.steps) if isinstance(executable, WorkflowIR): connections(executable.connections) actions = { diff --git a/src/firefly_weave/contracts/language_features.py b/src/firefly_weave/contracts/language_features.py index c14fd6e0..b5d3ee44 100644 --- a/src/firefly_weave/contracts/language_features.py +++ b/src/firefly_weave/contracts/language_features.py @@ -40,7 +40,8 @@ ) WORKFLOW_FIELD_FEATURES: Final[Mapping[str, LanguageFeature]] = MappingProxyType({"callable": "flow.callWorkflow"}) -# Language limits the manifest publishes; loop and call support enforces them in a later release. +# Language limits the manifest publishes. Limits uses the loop limits as defaults and the compiler enforces them; +# workflow calls enforce the call depth in a later release. DEFAULT_LOOP_MAX_ITEMS: Final = 1000 MAX_LOOP_ITEMS: Final = 10_000 MAX_LOOP_DEPTH: Final = 3 diff --git a/src/firefly_weave/contracts/limits.py b/src/firefly_weave/contracts/limits.py index 81579e39..0551ca61 100644 --- a/src/firefly_weave/contracts/limits.py +++ b/src/firefly_weave/contracts/limits.py @@ -18,6 +18,8 @@ from pydantic import BaseModel, ConfigDict, Field +from firefly_weave.contracts.language_features import MAX_LOOP_DEPTH, MAX_LOOP_ITEMS, MAX_RUN_ITERATIONS + class Limits(BaseModel): """Operator budgets; these are never accepted inside definition documents.""" @@ -30,3 +32,7 @@ class Limits(BaseModel): max_document_nodes: int = Field(default=100_000, gt=0) max_payload_bytes: int = Field(default=1_048_576, gt=0) max_diagnostics: int = Field(default=100, gt=0) + # Loop budgets: elements per loop, loops nested in one another, and loop iterations one run may need. + max_loop_items: int = Field(default=MAX_LOOP_ITEMS, gt=0) + max_loop_depth: int = Field(default=MAX_LOOP_DEPTH, gt=0) + max_run_iterations: int = Field(default=MAX_RUN_ITERATIONS, gt=0) diff --git a/tests/unit/compiler/test_contracts.py b/tests/unit/compiler/test_contracts.py index aaa5d07f..04220358 100644 --- a/tests/unit/compiler/test_contracts.py +++ b/tests/unit/compiler/test_contracts.py @@ -60,6 +60,9 @@ def test_definition_budget_cannot_coerce_strings(): "max_document_nodes": 100_000, "max_payload_bytes": 1_048_576, "max_diagnostics": 100, + "max_loop_items": 10_000, + "max_loop_depth": 3, + "max_run_iterations": 100_000, } diff --git a/tests/unit/compiler/test_loop_ir.py b/tests/unit/compiler/test_loop_ir.py new file mode 100644 index 00000000..eae17703 --- /dev/null +++ b/tests/unit/compiler/test_loop_ir.py @@ -0,0 +1,281 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""The loop node, its each edge and join, and the graph rules that keep a loop body well formed.""" + +import copy +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from firefly_weave.compiler.catalog import CatalogResource, FrozenDocument +from firefly_weave.compiler.ir import ( + IR_VERSION_EXTENSIONS, + ForEachNode, + IRGraph, + WorkflowIR, + workflow_requirements, +) +from firefly_weave.contracts.language import language_manifest +from firefly_weave.contracts.limits import Limits + +EXECUTABLE = json.loads(Path("tests/fixtures/canonical/empty-workflow.executable.json").read_text()) +CONCAT = {"op": {"name": "concat", "args": [{"literal": "n="}, {"ref": "/input"}]}} + + +def edge(source, target, kind="next", branch=None): + return {"source": source, "target": target, "kind": kind, "branch": branch} + + +def loop_graph(*, body=True, items=None): + """notify loops over /input/items; its body holds one transform unless ``body`` is false.""" + first = "send" if body else "@branch:notify:0" + nodes = [ + {"id": "@start", "kind": "start", "scope": [], "path": "/spec"}, + {"id": "@end", "kind": "end", "scope": [], "path": "/spec/output", "output": {"ref": "/steps/notify/output"}}, + { + "id": "notify", + "kind": "forEach", + "scope": [], + "path": "/spec/steps/0", + "items": items or {"ref": "/input/items"}, + "concurrency": 2, + "maxItems": 10, + "collect": "all", + "target": first, + "join": "@join:notify", + }, + { + "id": "@branch:notify:0", + "kind": "branch-output", + "scope": ["notify", "body"], + "path": "/spec/steps/0/body/output", + "owner": "notify", + "branch": "body", + "output": {"ref": "/item"}, + "completes": True, + }, + { + "id": "@join:notify", + "kind": "join", + "scope": [], + "path": "/spec/steps/0", + "owner": "notify", + "mode": "each", + "branches": ["@branch:notify:0"], + "completes": True, + }, + ] + edges = [ + edge("@start", "notify"), + edge("notify", first, "each", "body"), + edge("@branch:notify:0", "@join:notify", "join", "body"), + edge("@join:notify", "@end"), + ] + if body: + nodes.append( + { + "id": "send", + "kind": "transform", + "scope": ["notify", "body"], + "path": "/spec/steps/0/body/steps/0", + "value": {"ref": "/item"}, + } + ) + edges.append(edge("send", "@branch:notify:0")) + return {"entry": "@start", "exit": "@end", "nodes": nodes, "edges": edges} + + +def node(graph, identifier): + return next(item for item in graph["nodes"] if item["id"] == identifier) + + +def test_a_loop_graph_is_valid_with_or_without_body_steps(): + for body in (True, False): + graph = IRGraph.model_validate(loop_graph(body=body)) + loop = next(item for item in graph.nodes if isinstance(item, ForEachNode)) + assert (loop.concurrency, loop.max_items, loop.collect, loop.join) == (2, 10, "all", "@join:notify") + + +def test_the_loop_node_serializes_its_canonical_field_names(): + dumped = IRGraph.model_validate(loop_graph()).model_dump(by_alias=True) + assert node(dumped, "notify") == node(loop_graph(), "notify") + assert node(dumped, "@join:notify")["mode"] == "each" + + +def test_a_loop_nested_in_a_loop_body_is_valid(): + graph = loop_graph() + inner = copy.deepcopy(loop_graph()) + renamed = {"notify": "inner", "send": "deep", "@branch:notify:0": "@branch:inner:0", "@join:notify": "@join:inner"} + for item in inner["nodes"][2:]: + item["id"] = renamed[item["id"]] + item["scope"] = ["notify", "body"] + [renamed.get(part, part) for part in item["scope"]] + for key in ("owner", "target", "join"): + if key in item: + item[key] = renamed[item[key]] + if "branches" in item: + item["branches"] = [renamed[name] for name in item["branches"]] + graph["nodes"] = [item for item in graph["nodes"] if item["id"] != "send"] + inner["nodes"][2:] + graph["edges"] = [item for item in graph["edges"] if "send" not in (item["source"], item["target"])] + [ + edge("notify", "inner", "each", "body"), + edge("inner", "deep", "each", "body"), + edge("deep", "@branch:inner:0"), + edge("@branch:inner:0", "@join:inner", "join", "body"), + edge("@join:inner", "@branch:notify:0"), + ] + node(graph, "notify")["target"] = "inner" + assert len(IRGraph.model_validate(graph).nodes) == 9 + + +def _mutate(change): + graph = loop_graph() + change(graph) + return graph + + +def _set(identifier, key, value): + return lambda graph: node(graph, identifier).__setitem__(key, value) + + +def _edge(source, key, value): + return lambda graph: next(item for item in graph["edges"] if item["source"] == source).__setitem__(key, value) + + +@pytest.mark.parametrize( + ("change", "message"), + [ + pytest.param(_edge("notify", "branch", "items"), "Invalid control edges", id="each-edge-names-another-block"), + pytest.param(_edge("notify", "kind", "fork"), "Invalid control edges", id="loop-entered-by-a-fork-edge"), + pytest.param(_set("@join:notify", "mode", "all"), "Invalid control edges", id="join-mode-all"), + pytest.param( + _set("notify", "target", "@branch:notify:0"), "Invalid control edges", id="target-is-not-the-edge" + ), + pytest.param(_set("@join:notify", "completes", False), "Invalid join completion", id="join-never-completes"), + pytest.param(_set("send", "scope", []), "crosses lexical scopes", id="body-step-outside-the-body"), + pytest.param(_set("send", "scope", ["notify", "loop"]), "lexical scopes", id="unknown-block-name"), + pytest.param( + _set("@branch:notify:0", "branch", "loop"), "Join predecessor", id="body-output-names-another-block" + ), + pytest.param( + lambda graph: ( + graph["nodes"].append( + {"id": "stop", "kind": "fail", "scope": [], "path": "/spec/steps/1", "code": "x", "message": "x"} + ), + graph["edges"].append(edge("notify", "stop")), + ), + "Invalid control edges", + id="loop-with-a-second-edge", + ), + pytest.param( + lambda graph: ( + node(graph, "@branch:notify:0").__setitem__("completes", False), + node(graph, "@join:notify").__setitem__("completes", False), + graph["edges"].remove(edge("@branch:notify:0", "@join:notify", "join", "body")), + graph["edges"].remove(edge("@join:notify", "@end")), + ), + "A loop body must complete", + id="body-never-completes", + ), + ], +) +def test_malformed_loop_graphs_are_rejected(change, message): + with pytest.raises(ValidationError, match=message): + IRGraph.model_validate(_mutate(change)) + + +def test_an_each_join_belongs_only_to_a_loop(): + graph = loop_graph() + node(graph, "notify").update(kind="parallel", branches=[{"name": "body", "target": "send"}]) + for key in ("items", "maxItems", "collect", "target"): + del node(graph, "notify")[key] + next(item for item in graph["edges"] if item["source"] == "notify")["kind"] = "fork" + with pytest.raises(ValidationError, match="Invalid control edges"): + IRGraph.model_validate(graph) + + +def test_only_a_loop_starts_an_each_edge(): + graph = loop_graph() + next(item for item in graph["edges"] if item["source"] == "send").update(kind="each", branch="body") + with pytest.raises(ValidationError): + IRGraph.model_validate(graph) + + +@pytest.mark.parametrize( + ("items", "features"), + [({"ref": "/input/items"}, ["flow.forEach"]), (CONCAT, ["flow.forEach", "text.concat"])], +) +def test_a_loop_requires_v1alpha4_with_the_loop_feature(items, features): + assert workflow_requirements(IRGraph.model_validate(loop_graph(items=items))) == (IR_VERSION_EXTENSIONS, features) + + +def test_the_workflow_validator_requires_the_loop_feature(): + executable = {**EXECUTABLE, "graph": loop_graph(), "irVersion": IR_VERSION_EXTENSIONS} + assert WorkflowIR.model_validate({**executable, "features": ["flow.forEach"]}).features == ["flow.forEach"] + with pytest.raises(ValidationError, match="requires language features"): + WorkflowIR.model_validate(executable) + with pytest.raises(ValidationError, match="exactly the features"): + WorkflowIR.model_validate({**executable, "features": ["text.concat"]}) + + +def test_the_dependency_closure_follows_steps_inside_loop_bodies(): + workflow = { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "outer", "version": "1.0.0"}, + "spec": { + "inputSchema": {}, + "outputSchema": {}, + "steps": [ + { + "id": "notify", + "kind": "forEach", + "items": {"ref": "/input"}, + "body": { + "steps": [{"id": "send", "kind": "action", "uses": "missing@1.0.0", "with": {"ref": "/item"}}], + "output": {"literal": None}, + }, + } + ], + "output": {"literal": None}, + }, + } + resource = CatalogResource("Workflow", "outer@1.0.0", FrozenDocument.from_value(workflow)) + dependency = { + "kind": "Workflow", + "reference": "outer@1.0.0", + "digest": resource.digest, + "document": resource.definition.value, + } + with pytest.raises(ValidationError, match="Unresolved executable dependency"): + WorkflowIR.model_validate({**EXECUTABLE, "dependencies": [dependency]}) + + +def test_loop_limits_are_operator_budgets_with_the_published_defaults(): + limits = Limits() + assert (limits.max_loop_items, limits.max_loop_depth, limits.max_run_iterations) == (10_000, 3, 100_000) + with pytest.raises(ValidationError): + Limits(max_loop_items=0) + + +def test_the_language_manifest_publishes_the_compiler_loop_limits(): + limits, published = Limits(), language_manifest().limits + assert (published.max_loop_items, published.max_loop_depth, published.max_run_iterations) == ( + limits.max_loop_items, + limits.max_loop_depth, + limits.max_run_iterations, + ) From ea70daae4289081f98d24b915b503e9c22f10dfa Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:39:07 -0700 Subject: [PATCH 2/9] Compile loop scope, typing and lowering for forEach steps --- .../language/notify-overdue.workflow.yaml | 4 +- src/firefly_weave/compiler/analyzer.py | 109 ++++- .../compiler/expression_types.py | 19 + .../compiler/language_support.py | 2 +- src/firefly_weave/compiler/loops.py | 89 ++++ src/firefly_weave/compiler/lowering.py | 9 +- tests/unit/compiler/test_language_examples.py | 4 +- tests/unit/compiler/test_loop_compiler.py | 401 ++++++++++++++++++ .../compiler/test_unsupported_features.py | 11 +- 9 files changed, 628 insertions(+), 20 deletions(-) create mode 100644 src/firefly_weave/compiler/loops.py create mode 100644 tests/unit/compiler/test_loop_compiler.py diff --git a/examples/language/notify-overdue.workflow.yaml b/examples/language/notify-overdue.workflow.yaml index 02fe638b..d51854fd 100644 --- a/examples/language/notify-overdue.workflow.yaml +++ b/examples/language/notify-overdue.workflow.yaml @@ -14,8 +14,8 @@ # Author: Firefly Software Foundation # SPDX-License-Identifier: Apache-2.0 -# Loop over items with a text template. This version compiles concat but reports "not yet supported" for -# forEach; the document shape is final. +# Loop over items with a text template: one reminder per overdue invoice, four at a time. The compiler checks and +# compiles the loop; a platform runs it once its language manifest lists the flow.forEach feature. apiVersion: weave/v1alpha1 kind: Workflow metadata: diff --git a/src/firefly_weave/compiler/analyzer.py b/src/firefly_weave/compiler/analyzer.py index 293db228..b391cdd9 100644 --- a/src/firefly_weave/compiler/analyzer.py +++ b/src/firefly_weave/compiler/analyzer.py @@ -48,6 +48,14 @@ ) from firefly_weave.compiler.language_support import unsupported_uses from firefly_weave.compiler.llm import llm_action_valid, llm_input, llm_output_schema +from firefly_weave.compiler.loops import ( + LOOP_ROOTS, + LoopScope, + index_schema, + item_schema, + loops_schema, + output_schema, +) from firefly_weave.compiler.parser import ParsedSource from firefly_weave.compiler.schema_profile import DEFAULT_CONTRACT_LIMITS, SchemaLimits from firefly_weave.compiler.schemas import validate_contract_payload, validate_payload, validate_schema @@ -82,6 +90,11 @@ "REFERENCE_PRESENCE": "Reference presence is unproved; runtime validation is required.", "UNAVAILABLE_REFERENCE": "Referenced step does not dominate this expression in its lexical scope.", "CATALOG_PENDING": "This reference is checked against the project catalog when the definition is compiled.", + "LOOP_ITEMS": "The items of a loop must be a list.", + "LOOP_SCOPE": ( + "/item, /index and /loops are available only inside a forEach body, and /loops/ID must name an enclosing loop." + ), + "LOOP_BODY_INCOMPLETE": "The loop body never completes, so any non-empty list would stop the run.", } @@ -215,6 +228,8 @@ def __init__( self.llm_profiles: JsonObject = {} self.kind: Literal["Workflow", "Action", "Connector", "DecisionTable"] | None = None self.definition: FrozenDocument | None = None + # Loops enclosing the position being analyzed, outermost first. + self.loops: tuple[LoopScope, ...] = () def location(self, path: str) -> SourceRange | None: while path not in self.source.locations and path: @@ -292,15 +307,19 @@ def compatibility(self, source: JsonObject, target: JsonObject, path: str, *, qu def reads_pending(self, expression: JsonObject) -> bool: """Whether a value reads an output that only the absent catalog leaves unconstrained.""" - if not self.pending_outputs: + pending_loops = {loop.id for loop in self.loops if loop.quiet} + if not self.pending_outputs and not pending_loops: return False stack = [expression] while stack: node = stack.pop() if "ref" in node: segments = pointer_segments(cast(str, node["ref"])) - if not segments or ( - segments[0] == "steps" and (len(segments) == 1 or segments[1] in self.pending_outputs) + if ( + not segments + or (segments[0] == "steps" and (len(segments) == 1 or segments[1] in self.pending_outputs)) + or (segments[0] == "item" and bool(self.loops) and self.loops[-1].quiet) + or (segments[0] == "loops" and (len(segments) == 1 or segments[1] in pending_loops)) ): return True stack.extend(child for child, _ in _children(node, "")) @@ -510,6 +529,8 @@ def preflight(self, steps: list[JsonObject], path: str, depth: int = 1) -> bool: if kind in {"action", "decisionTable", "transform"}: key = "value" if kind == "transform" else "with" self.count(cast(JsonObject, step[key]), step_path + "/" + key) + if kind == "forEach": + self.count(cast(JsonObject, step["items"]), step_path + "/items") for _, child, child_path in self.branch_values(step, step_path): if "when" in child: self.count(cast(JsonObject, child["when"]), child_path + "/when") @@ -534,18 +555,22 @@ def branch_values(step: JsonObject, path: str) -> list[tuple[str, JsonObject, st (name, cast(JsonObject, branch), pointer_child(path + "/branches", name)) for name, branch in sorted(cast(JsonObject, step["branches"]).items()) ] + if step["kind"] == "forEach": + return [("body", cast(JsonObject, step["body"]), path + "/body")] return [] def infer(self, expression: JsonObject, visible: dict[str, JsonObject]) -> InferredType: requested: set[str] = set() - include_input = include_all = include_steps = False + include_input = include_all = include_steps = include_loops = False stack = [expression] while stack: node = stack.pop() if "ref" in node: segments = pointer_segments(cast(str, node["ref"])) if not segments: - include_input = include_all = include_steps = True + include_input = include_all = include_steps = include_loops = True + elif segments[0] in LOOP_ROOTS: + include_loops = True elif segments[0] == "input": include_input = True elif segments[0] == "steps": @@ -562,6 +587,10 @@ def infer(self, expression: JsonObject, visible: dict[str, JsonObject]) -> Infer context["steps"] = _object( {key: _object({"output": schema}) for key, schema in visible.items() if include_all or key in requested} ) + if include_loops and self.loops: + context["item"] = self.loops[-1].item + context["index"] = self.loops[-1].index + context["loops"] = loops_schema(self.loops) return infer_expression(expression, context, limits=self.limits, schema_limits=self.schema_limits) def expression( @@ -580,6 +609,8 @@ def expression( and not reference_available(segments[1], frozenset(visible)) ): self.issue("UNAVAILABLE_REFERENCE", location + "/ref") + if not self.in_loop_scope(segments): + self.issue("LOOP_SCOPE", location + "/ref") stack.extend(_children(node, location)) self.operands(expression, path, visible) result = self.infer(expression, visible) @@ -605,7 +636,7 @@ def operands( result = self.infer(expression, visible) segments = pointer_segments(cast(str, expression["ref"])) unavailable = len(segments) >= 2 and segments[0] == "steps" and segments[1] not in visible - if unavailable: + if unavailable or not self.in_loop_scope(segments): return if not handled and len(segments) > 3 and segments[0] == "steps" and segments[2] == "output": variants = [visible[segments[1]]] @@ -685,6 +716,14 @@ def operands( if not definite: self.guard(path, "operator_operands", {}) + def in_loop_scope(self, segments: tuple[str, ...]) -> bool: + """Whether a reference to a loop root names a loop enclosing the current position.""" + if not segments or segments[0] not in LOOP_ROOTS: + return True + if not self.loops: + return False + return segments[0] != "loops" or len(segments) == 1 or any(loop.id == segments[1] for loop in self.loops) + def text_operand( self, operand: JsonObject, path: str, schema: JsonObject, accepted: JsonObject, rejected: tuple[JsonObject, ...] ) -> None: @@ -816,6 +855,9 @@ def block( elif kind == "signal": output = cast(JsonObject, step["payloadSchema"]) self.guard(location, "signal_payload", output) + elif kind == "forEach": + output, body = self.loop(step, location, visible, scope) + branches.append(body) elif kind in {"switch", "parallel"}: if kind == "parallel" and cast(int, step["concurrency"]) > self.concurrency: self.issue("CONCURRENCY_LIMIT", location + "/concurrency") @@ -878,6 +920,61 @@ def block( reachable = reachable and completes return tuple(nodes), visible, reachable + def loop( + self, step: JsonObject, location: str, visible: dict[str, JsonObject], scope: tuple[str, ...] + ) -> tuple[JsonObject, AnalyzedBranch]: + """Check one forEach and its body; returns the loop's output schema and its analyzed body.""" + identifier = cast(str, step["id"]) + concurrency, max_items = cast(int, step["concurrency"]), cast(int, step["maxItems"]) + items = cast(JsonObject, step["items"]) + before = self.error_count + source = self.expression(items, location + "/items", visible) + if self.error_count == before: + self.loop_items(source, items, location + "/items") + # Items that are already wrong, or unknown until the catalog is present, leave /item unchecked. + quiet = self.error_count != before or self.reads_pending(items) + scope_entry = LoopScope(identifier, item_schema(source), index_schema(max_items), concurrency, max_items, quiet) + body = cast(JsonObject, step["body"]) + expression = cast(JsonObject, body["output"]) + self.loops += (scope_entry,) + try: + children, body_visible, completes = self.block( + cast(list[JsonObject], body["steps"]), location + "/body/steps", visible, scope + (identifier, "body") + ) + if completes: + body_schema = self.expression(expression, location + "/body/output", body_visible) + if self.reads_pending(expression): + self.pending_outputs.add(identifier) + else: + self.issue("LOOP_BODY_INCOMPLETE", location + "/body") + body_schema = {} + finally: + self.loops = self.loops[:-1] + self.guard(location + "/body/output", "branch_output", body_schema) + branch = AnalyzedBranch( + "body", + location + "/body", + children, + FrozenDocument.from_value(expression), + self.freeze_schema(body_schema), + completes, + ) + return output_schema(body_schema, max_items, cast(str, step["collect"])), branch + + def loop_items(self, schema: JsonObject, items: JsonObject, path: str) -> None: + """Items must be a list: provably not is LOOP_ITEMS; unproved warns and keeps a loop_items guard.""" + try: + outcome = check_compatibility(schema, {"type": "array"}, self.bundle, limits=self.schema_limits) + except TypeCheckLimit: + self.issue("RESOURCE_LIMIT", path) + return + if outcome == "incompatible": + self.issue("LOOP_ITEMS", path) + elif outcome == "unknown": + if not self.reads_pending(items): + self.issue("UNKNOWN_COMPATIBILITY", path, unknown=True) + self.guard(path, "loop_items", {"type": "array"}) + def settle_pending(self) -> None: """Catalog-pending notes are the lowest priority: they only fill capacity that findings left. diff --git a/src/firefly_weave/compiler/expression_types.py b/src/firefly_weave/compiler/expression_types.py index b17425dc..faacd7ad 100644 --- a/src/firefly_weave/compiler/expression_types.py +++ b/src/firefly_weave/compiler/expression_types.py @@ -305,6 +305,25 @@ def _infer(expression: JsonObject, context_schemas: dict[str, JsonObject], budge return InferredType(budget.object((("anyOf", budget.array(item.schema for item in alternatives)),))) +def nonnull_schema( + schema: JsonObject, *, limits: Limits = DEFAULT_LIMITS, schema_limits: SchemaLimits = DEFAULT_INFERENCE_LIMITS +) -> JsonObject | None: + """``schema`` without ``null``, the narrowing ``coalesce`` applies to a fallback; ``None`` when only null is left. + + A schema whose types are not listed directly (``{}``, ``anyOf``) stays ``{}``. + """ + schema_domain_limits = limits.model_copy( + update={ + "max_depth": schema_limits.max_schema_depth, + "max_payload_bytes": schema_limits.max_schema_bytes, + "max_document_nodes": schema_limits.max_schema_nodes, + } + ) + budget = _InferenceBudget(schema_domain_limits, schema_limits.max_expansion_nodes) + narrowed = _nonnull(InferredType(schema, "known" if schema else "unknown"), budget) + return None if narrowed is None else cast(JsonObject, _copy(narrowed.schema)) + + def infer_expression( expression: JsonObject, context_schemas: dict[str, JsonObject], diff --git a/src/firefly_weave/compiler/language_support.py b/src/firefly_weave/compiler/language_support.py index 324ccd8f..2ae38d87 100644 --- a/src/firefly_weave/compiler/language_support.py +++ b/src/firefly_weave/compiler/language_support.py @@ -29,7 +29,7 @@ from firefly_weave.contracts.language_features import KIND_FEATURES, OPERATOR_FEATURES, LanguageFeature from firefly_weave.contracts.values import JsonObject, JsonValue -COMPILED_FEATURES: Final[frozenset[LanguageFeature]] = frozenset({"text.concat", "text.join"}) +COMPILED_FEATURES: Final[frozenset[LanguageFeature]] = frozenset({"flow.forEach", "text.concat", "text.join"}) # Expression positions per step kind; blocks are walked separately. _EXPRESSIONS: Final[dict[str, tuple[str, ...]]] = { diff --git a/src/firefly_weave/compiler/loops.py b/src/firefly_weave/compiler/loops.py new file mode 100644 index 00000000..7a1b1fd2 --- /dev/null +++ b/src/firefly_weave/compiler/loops.py @@ -0,0 +1,89 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Typing rules of ``forEach``: the schemas of ``/item``, ``/index`` and the collected output. + +A loop body reads the current element as ``/item`` and its zero-based position as ``/index``; any enclosing loop is +``/loops//item`` and ``/loops//index``. These roots exist only inside loop bodies. +""" + +from dataclasses import dataclass +from typing import Final, cast + +from firefly_weave.compiler.expression_types import nonnull_schema +from firefly_weave.contracts.values import JsonObject, JsonValue + +LOOP_ROOTS: Final = frozenset({"item", "index", "loops"}) +# Tasks one turn of a run can issue; nested loop concurrency above it is spread over several turns. +MAX_CONCURRENT_TASKS: Final = 1000 +_COMPOSITION: Final = ("$ref", "allOf", "anyOf", "oneOf", "not", "if", "dependentSchemas", "const", "enum") + + +@dataclass(frozen=True) +class LoopScope: + """One enclosing loop as an expression inside its body sees it.""" + + id: str + item: JsonObject + index: JsonObject + concurrency: int + max_items: int + # References to the item are not checked: the items are already wrong, or unknown until the catalog is present. + quiet: bool = False + + +def item_schema(items: JsonObject) -> JsonObject: + """The schema of one element of ``items``: ``items``, ``anyOf`` the tuple positions, or ``{}`` when unknown.""" + if items.get("type") != "array" or any(keyword in items for keyword in _COMPOSITION): + return {} + rest = items.get("items", True) + prefix = items.get("prefixItems", []) + if not isinstance(prefix, list) or not all(isinstance(position, dict) for position in prefix): + return {} + if not prefix: + return rest if isinstance(rest, dict) else {} + if isinstance(rest, dict): + alternatives = [*prefix, rest] + elif rest is False: + alternatives = list(prefix) + else: + return {} + return cast(JsonObject, alternatives[0]) if len(alternatives) == 1 else {"anyOf": cast(JsonValue, alternatives)} + + +def index_schema(max_items: int) -> JsonObject: + """``/index``: a position from 0 to ``max_items - 1``.""" + return {"type": "integer", "minimum": 0, "maximum": max_items - 1} + + +def output_schema(body: JsonObject, max_items: int, collect: str) -> JsonObject: + """The list a loop returns: one body result per element, in element order, without nulls for ``nonNull``.""" + result = body + if collect == "nonNull": + narrowed = nonnull_schema(body) + if narrowed is None: + return {"type": "array", "maxItems": 0} + result = narrowed + return {"type": "array", "items": result, "maxItems": max_items} + + +def loops_schema(loops: tuple[LoopScope, ...]) -> JsonObject: + """The ``/loops`` root: each enclosing loop by its step ID, with its item and index.""" + + def closed(properties: dict[str, JsonValue]) -> JsonObject: + return {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False} + + return closed({loop.id: closed({"item": loop.item, "index": loop.index}) for loop in loops}) diff --git a/src/firefly_weave/compiler/lowering.py b/src/firefly_weave/compiler/lowering.py index 5dc65609..51b3b817 100644 --- a/src/firefly_weave/compiler/lowering.py +++ b/src/firefly_weave/compiler/lowering.py @@ -117,7 +117,7 @@ def block(self, steps: tuple[AnalyzedStep, ...], successor: str) -> str: following = steps[i + 1].id if i + 1 < len(steps) else successor value = step.definition.value base: JsonObject = {"id": step.id, "kind": step.kind, "scope": list(step.scope), "path": step.path} - if step.kind in {"switch", "parallel"}: + if step.kind in {"switch", "parallel", "forEach"}: join_id = "@join:" + step.id branch_ids: list[JsonValue] = [] branches: list[JsonValue] = [] @@ -144,6 +144,9 @@ def block(self, steps: tuple[AnalyzedStep, ...], successor: str) -> str: if step.kind == "parallel": branches.append({"name": branch.name, "target": target}) self.edge(step.id, target, "fork", branch.name) + elif step.kind == "forEach": + base["target"] = target + self.edge(step.id, target, "each", branch.name) elif branch.name == "default": default = target self.edge(step.id, target, "default", "default") @@ -157,7 +160,7 @@ def block(self, steps: tuple[AnalyzedStep, ...], successor: str) -> str: "scope": list(step.scope), "path": step.path, "owner": step.id, - "mode": "all" if step.kind == "parallel" else "selected", + "mode": {"parallel": "all", "forEach": "each"}.get(step.kind, "selected"), "branches": branch_ids, "completes": step.completes, } @@ -165,6 +168,8 @@ def block(self, steps: tuple[AnalyzedStep, ...], successor: str) -> str: base["join"] = join_id if step.kind == "parallel": base.update({"branches": branches, "concurrency": value["concurrency"]}) + elif step.kind == "forEach": + base.update({key: value[key] for key in ("items", "concurrency", "maxItems", "collect")}) else: base.update({"cases": branches, "default": default}) if step.completes: diff --git a/tests/unit/compiler/test_language_examples.py b/tests/unit/compiler/test_language_examples.py index 1460c244..c9a699ca 100644 --- a/tests/unit/compiler/test_language_examples.py +++ b/tests/unit/compiler/test_language_examples.py @@ -29,8 +29,8 @@ EXPECTED = { "notify-customer.workflow.yaml": [], "order-summary.workflow.yaml": [], - # Text operators compile; loops and workflow calls are still reported. - "notify-overdue.workflow.yaml": [("/spec/steps/0/kind", "flow.forEach")], + # Text operators and loops compile; workflow calls are still reported. + "notify-overdue.workflow.yaml": [], "order-intake.workflow.yaml": [ ("/spec/steps/0/kind", "flow.callWorkflow"), ("/spec/steps/1/kind", "flow.callWorkflow"), diff --git a/tests/unit/compiler/test_loop_compiler.py b/tests/unit/compiler/test_loop_compiler.py new file mode 100644 index 00000000..98cb8280 --- /dev/null +++ b/tests/unit/compiler/test_loop_compiler.py @@ -0,0 +1,401 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Loop scope, typing and lowering: what a forEach body may read and what the step returns.""" + +from pathlib import Path + +import pytest + +from firefly_weave.compiler.analyzer import analyze, analyze_authoring +from firefly_weave.compiler.api import compile_source, validate_authoring +from firefly_weave.compiler.catalog import CatalogSnapshot +from firefly_weave.compiler.language_support import COMPILED_FEATURES +from firefly_weave.compiler.parser import parse_source +from firefly_weave.contracts.definitions import load_definition +from firefly_weave.contracts.language_features import ADVERTISED_FEATURES + +INVOICE = { + "type": "object", + "properties": { + "number": {"type": "string"}, + "customerEmail": {"type": "string"}, + "daysLate": {"type": "integer"}, + "note": {"type": ["string", "null"]}, + }, + "required": ["number", "customerEmail", "daysLate", "note"], + "additionalProperties": False, +} +INPUT = { + "type": "object", + "properties": { + "invoices": {"type": "array", "items": INVOICE}, + "count": {"type": "integer"}, + "anything": {}, + "batches": {"type": "array", "items": {"type": "array", "items": INVOICE}}, + }, + "required": ["invoices", "count", "anything", "batches"], + "additionalProperties": False, +} +SENT = {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"], "additionalProperties": False} +TASK = { + "taskType": "send", + "taskVersion": "1.0.0", + "inputSchema": {}, + "outputSchema": SENT, + "sideEffect": "idempotent", + "timeoutSeconds": 30, +} +ACTION = load_definition( + { + "apiVersion": "weave/v1alpha1", + "kind": "Action", + "metadata": {"name": "send-reminder", "version": "1.0.0"}, + "spec": { + "implementation": {"kind": "worker", "taskType": "send", "taskVersion": "1.0.0"}, + "inputSchema": {}, + "outputSchema": SENT, + "sideEffect": "idempotent", + "timeoutSeconds": 30, + }, + } +) +CATALOG = CatalogSnapshot.from_definitions([ACTION], tasks=[TASK]) + + +def ref(pointer): + return {"ref": pointer} + + +def lit(value): + return {"literal": value} + + +def concat(*args): + return {"op": {"name": "concat", "args": list(args)}} + + +def transform(identifier, value): + return {"id": identifier, "kind": "transform", "value": value} + + +def send(identifier="send"): + return { + "id": identifier, + "kind": "action", + "uses": "send-reminder@1.0.0", + "with": {"object": {"to": ref("/item/customerEmail"), "subject": concat(lit("Invoice "), ref("/item/number"))}}, + } + + +def loop(identifier="notify", *, items=None, steps=None, output=None, **options): + return { + "id": identifier, + "kind": "forEach", + "items": items or ref("/input/invoices"), + **options, + "body": {"steps": [send()] if steps is None else steps, "output": output or ref("/item/number")}, + } + + +def workflow(*steps, output=None, output_schema=None): + return { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "notify-overdue", "version": "1.0.0"}, + "spec": { + "inputSchema": INPUT, + "outputSchema": output_schema or {}, + "steps": list(steps), + "output": output or lit(None), + }, + } + + +def compiled(document, **options): + return compile_source(document, format="object", catalog=CATALOG, **options) + + +def codes(result): + return sorted((d.code, d.path, d.severity) for d in result.diagnostics) + + +def output_schema(document, step=0): + """The compiler's inferred output schema of a top-level step.""" + analysis = analyze(parse_source(document, format="object"), CATALOG) + return analysis.typed_graph[step].output_schema.value + + +def body_schema(document, step=0, inner=0): + analysis = analyze(parse_source(document, format="object"), CATALOG) + return analysis.typed_graph[step].branches[0].steps[inner].output_schema.value + + +SPEC_EXAMPLE = workflow( + loop( + items=ref("/input/invoices"), + concurrency=4, + maxItems=500, + collect="all", + output={"object": {"invoice": ref("/item/number"), "messageId": ref("/steps/send/output/id")}}, + ), + transform("report", ref("/steps/notify/output")), + output=ref("/steps/report/output"), +) + + +def test_the_loop_feature_compiles_but_is_not_advertised_until_loops_run(): + assert "flow.forEach" in COMPILED_FEATURES + assert "flow.forEach" not in ADVERTISED_FEATURES + + +def test_a_loop_lowers_to_an_each_edge_a_body_and_a_join(): + result = compiled(SPEC_EXAMPLE) + assert result.ok, result.to_bytes() + executable = result.artifact.executable + assert (executable["irVersion"], executable["features"]) == ("weave/ir-v1alpha4", ["flow.forEach", "text.concat"]) + nodes = {node["id"]: node for node in executable["graph"]["nodes"]} + assert {(identifier, node["kind"], tuple(node["scope"])) for identifier, node in nodes.items()} == { + ("@start", "start", ()), + ("@end", "end", ()), + ("notify", "forEach", ()), + ("send", "action", ("notify", "body")), + ("@branch:notify:0", "branch-output", ("notify", "body")), + ("@join:notify", "join", ()), + ("report", "transform", ()), + } + assert nodes["notify"] == { + "id": "notify", + "kind": "forEach", + "scope": [], + "path": "/spec/steps/0", + "items": ref("/input/invoices"), + "concurrency": 4, + "maxItems": 500, + "collect": "all", + "target": "send", + "join": "@join:notify", + } + assert nodes["@join:notify"] == { + "id": "@join:notify", + "kind": "join", + "scope": [], + "path": "/spec/steps/0", + "owner": "notify", + "mode": "each", + "branches": ["@branch:notify:0"], + "completes": True, + } + assert nodes["@branch:notify:0"]["path"] == "/spec/steps/0/body/output" + assert {(e["source"], e["target"], e["kind"], e["branch"]) for e in executable["graph"]["edges"]} == { + ("@start", "notify", "next", None), + ("notify", "send", "each", "body"), + ("send", "@branch:notify:0", "next", None), + ("@branch:notify:0", "@join:notify", "join", "body"), + ("@join:notify", "report", "next", None), + ("report", "@end", "next", None), + } + guards = {(guard["path"], guard["purpose"]) for guard in executable["guards"]} + assert ("/spec/steps/0/body/output", "branch_output") in guards + assert not any(purpose == "loop_items" for _, purpose in guards) + + +def test_an_empty_body_enters_its_output_directly_and_defaults_are_stored(): + result = compiled(workflow(loop(steps=[]))) + assert result.ok, result.to_bytes() + nodes = {node["id"]: node for node in result.artifact.executable["graph"]["nodes"]} + assert (nodes["notify"]["target"], nodes["notify"]["concurrency"], nodes["notify"]["maxItems"]) == ( + "@branch:notify:0", + 1, + 1000, + ) + assert nodes["notify"]["collect"] == "all" + + +def test_a_workflow_without_loops_keeps_its_version_and_features(): + result = compiled(workflow(transform("t", ref("/input/count")), output=ref("/steps/t/output"))) + assert result.ok and result.artifact.executable["irVersion"] == "weave/ir-v1alpha1" + assert "features" not in result.artifact.executable + + +def test_the_output_is_a_list_of_body_results_bounded_by_max_items(): + assert output_schema(SPEC_EXAMPLE) == { + "type": "array", + "items": { + "type": "object", + "properties": {"invoice": {"type": "string"}, "messageId": {"type": "string"}}, + "required": ["invoice", "messageId"], + "additionalProperties": False, + }, + "maxItems": 500, + } + + +def test_skip_empty_results_removes_null_from_the_item_type(): + keep = workflow(loop(steps=[], output=ref("/item/note"))) + skip = workflow(loop(steps=[], output=ref("/item/note"), collect="nonNull")) + assert output_schema(keep)["items"] == {"type": ["string", "null"]} + assert output_schema(skip)["items"] == {"type": "string"} + assert output_schema(workflow(loop(steps=[], output=lit(None), collect="nonNull"))) == { + "type": "array", + "maxItems": 0, + } + + +def test_after_the_loop_only_its_output_is_visible_and_typed(): + step = SPEC_EXAMPLE["spec"]["steps"][0] + good = workflow(step, output=ref("/steps/notify/output"), output_schema={"type": "array"}) + assert compiled(good).ok + wrong = workflow(step, output=ref("/steps/notify/output"), output_schema={"type": "string"}) + assert ("WV-COMP-TYPE_MISMATCH", "/spec/output", "error") in codes(compiled(wrong)) + leaked = workflow(loop(), output=ref("/steps/send/output")) + assert codes(compiled(leaked)) == [("WV-COMP-UNAVAILABLE_REFERENCE", "/spec/output/ref", "error")] + + +def test_the_body_reads_the_current_item_its_position_and_earlier_steps_of_its_iteration(): + document = workflow( + loop( + steps=[transform("label", concat(ref("/item/number"), lit(" #"), ref("/index"))), send()], + output={"object": {"label": ref("/steps/label/output"), "sent": ref("/steps/send/output/id")}}, + maxItems=500, + ) + ) + assert compiled(document).ok + assert body_schema(workflow(loop(steps=[transform("i", ref("/index"))], maxItems=500))) == { + "type": "integer", + "minimum": 0, + "maximum": 499, + } + assert body_schema(workflow(loop(steps=[transform("i", ref("/item"))]))) == INVOICE + + +def test_a_loop_cannot_read_its_own_output_from_inside_its_body(): + document = workflow(loop(steps=[transform("x", ref("/steps/notify/output"))])) + assert codes(compiled(document)) == [ + ("WV-COMP-UNAVAILABLE_REFERENCE", "/spec/steps/0/body/steps/0/value/ref", "error") + ] + + +def test_tuple_items_type_the_item_as_any_of_their_positions(): + items = {"array": [lit("a"), lit(1)]} + document = workflow(loop(items=items, steps=[transform("i", ref("/item"))])) + assert body_schema(document) == {"anyOf": [{"type": "string", "const": "a"}, {"type": "integer", "const": 1}]} + + +@pytest.mark.parametrize( + ("document", "path"), + [ + pytest.param(workflow(output=ref("/item")), "/spec/output/ref", id="item-outside-a-loop"), + pytest.param(workflow(output=ref("/index")), "/spec/output/ref", id="index-outside-a-loop"), + pytest.param(workflow(output=ref("/loops/notify/item")), "/spec/output/ref", id="loops-outside-a-loop"), + pytest.param( + workflow(loop(), output=ref("/loops/notify/item")), "/spec/output/ref", id="loop-variables-after-the-loop" + ), + pytest.param( + workflow(loop(steps=[transform("t", ref("/loops/other/item"))])), + "/spec/steps/0/body/steps/0/value/ref", + id="loops-names-a-loop-that-does-not-enclose", + ), + pytest.param( + workflow(loop(items={"array": [ref("/index")]})), "/spec/steps/0/items/array/0/ref", id="own-index-in-items" + ), + pytest.param( + workflow(loop(items={"array": [ref("/loops/notify/item")]})), + "/spec/steps/0/items/array/0/ref", + id="own-variables-in-items", + ), + ], +) +def test_loop_variables_outside_their_loop_are_a_scope_error(document, path): + assert codes(compiled(document)) == [("WV-COMP-LOOP_SCOPE", path, "error")] + + +def test_nested_loops_read_the_innermost_item_and_outer_loops_by_id(): + inner = loop( + "line", + items=ref("/loops/batch/item"), + steps=[transform("pair", {"array": [ref("/item/number"), ref("/loops/batch/index"), ref("/index")]})], + output=ref("/steps/pair/output"), + maxItems=10, + ) + outer = loop("batch", items=ref("/input/batches"), steps=[inner], output=ref("/steps/line/output"), maxItems=10) + result = compiled(workflow(outer)) + assert result.ok, result.to_bytes() + assert output_schema(workflow(outer))["items"]["items"]["prefixItems"][0] == {"type": "string"} + # Items of an inner loop read the enclosing loop's item through /item, never its own. + shorthand = loop("line", items=ref("/item"), steps=[], output=ref("/item/number"), maxItems=10) + assert compiled( + workflow( + loop("batch", items=ref("/input/batches"), steps=[shorthand], maxItems=10, output=ref("/steps/line/output")) + ) + ).ok + + +@pytest.mark.parametrize( + ("items", "path"), + [ + (lit("not a list"), "/spec/steps/0/items"), + (ref("/input/count"), "/spec/steps/0/items"), + ({"object": {}}, "/spec/steps/0/items"), + ], +) +def test_items_that_can_never_be_a_list_are_rejected(items, path): + assert codes(compiled(workflow(loop(items=items, steps=[], output=lit(None))))) == [ + ("WV-COMP-LOOP_ITEMS", path, "error") + ] + + +def test_items_not_proved_to_be_a_list_warn_and_keep_a_runtime_guard(): + document = workflow(loop(items=ref("/input/anything"), steps=[], output=lit(None))) + result = compiled(document) + assert codes(result) == [("WV-COMP-UNKNOWN_COMPATIBILITY", "/spec/steps/0/items", "warning")] + guards = [g for g in result.artifact.executable["guards"] if g["purpose"] == "loop_items"] + assert [g["path"] for g in guards] == ["/spec/steps/0/items"] + assert result.artifact.executable["schemas"][guards[0]["schemaRef"]] == {"type": "array"} + assert codes(compiled(document, strict=True)) == [("WV-COMP-UNKNOWN_COMPATIBILITY", "/spec/steps/0/items", "error")] + + +def test_a_body_that_never_completes_is_rejected(): + stop = {"id": "stop", "kind": "fail", "code": "nope", "message": "Never finishes."} + assert codes(compiled(workflow(loop(steps=[stop])))) == [ + ("WV-COMP-LOOP_BODY_INCOMPLETE", "/spec/steps/0/body", "error") + ] + + +def test_offline_authoring_of_the_loop_example_reports_only_pending_catalog_notes(): + text = Path("examples/language/notify-overdue.workflow.yaml").read_text() + result = validate_authoring(text, format="yaml") + assert {d.code for d in result.diagnostics} == {"WV-COMP-CATALOG_PENDING"} + + +def test_offline_items_from_a_pending_step_keep_item_references_quiet(): + fetch = {"id": "fetch", "kind": "action", "uses": "fetch-invoices@1.0.0", "with": lit({})} + body = [transform("t", concat(ref("/item/number"), ref("/loops/notify/item/total")))] + document = workflow(fetch, loop(items=ref("/steps/fetch/output/invoices"), steps=body, output=ref("/item/x"))) + analysis = analyze_authoring(parse_source(document, format="object")) + assert {d.code for d in analysis.diagnostics} == {"WV-COMP-CATALOG_PENDING"} + + +def test_offline_loop_variables_outside_a_loop_are_a_scope_error_next_to_pending_steps(): + fetch = {"id": "fetch", "kind": "action", "uses": "fetch-invoices@1.0.0", "with": lit({})} + document = workflow(fetch, output=ref("/item"), output_schema={"type": "string"}) + analysis = analyze_authoring(parse_source(document, format="object")) + assert ("WV-COMP-LOOP_SCOPE", "/spec/output/ref") in {(d.code, d.path) for d in analysis.diagnostics} + + +def test_items_that_are_not_a_list_leave_item_references_unchecked(): + document = workflow(loop(items=ref("/input/count"), steps=[transform("t", ref("/item/number"))])) + assert codes(compiled(document, strict=True)) == [("WV-COMP-LOOP_ITEMS", "/spec/steps/0/items", "error")] diff --git a/tests/unit/compiler/test_unsupported_features.py b/tests/unit/compiler/test_unsupported_features.py index 5b5a2354..e182d281 100644 --- a/tests/unit/compiler/test_unsupported_features.py +++ b/tests/unit/compiler/test_unsupported_features.py @@ -231,16 +231,13 @@ def _switch(cases: list[dict], default: dict | None = None) -> dict: ] -def test_text_features_compile_and_nothing_is_advertised_that_the_compiler_cannot_compile(): - assert set(COMPILED_FEATURES) == {"text.concat", "text.join"} +def test_text_and_loops_compile_and_nothing_is_advertised_that_the_compiler_cannot_compile(): + assert set(COMPILED_FEATURES) == {"flow.forEach", "text.concat", "text.join"} assert set(ADVERTISED_FEATURES) <= COMPILED_FEATURES -def test_with_text_compiled_only_loops_and_calls_are_reported(): - assert _uses(EVERYTHING) == [ - ("/spec/steps/0/body/steps/1/kind", "flow.callWorkflow"), - ("/spec/steps/0/kind", "flow.forEach"), - ] +def test_with_text_and_loops_compiled_only_calls_are_reported(): + assert _uses(EVERYTHING) == [("/spec/steps/0/body/steps/1/kind", "flow.callWorkflow")] def test_scan_finds_every_kind_and_operator_at_its_field(nothing_compiled): From fdd6e8de7bc05de6c143cb8e278e487599c20f11 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:40:10 -0700 Subject: [PATCH 3/9] Check loop nesting, item limits, concurrency, signals and the run iteration budget --- src/firefly_weave/compiler/analyzer.py | 62 ++++++++- tests/unit/compiler/test_loop_limits.py | 162 ++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 tests/unit/compiler/test_loop_limits.py diff --git a/src/firefly_weave/compiler/analyzer.py b/src/firefly_weave/compiler/analyzer.py index b391cdd9..4e915200 100644 --- a/src/firefly_weave/compiler/analyzer.py +++ b/src/firefly_weave/compiler/analyzer.py @@ -50,6 +50,7 @@ from firefly_weave.compiler.llm import llm_action_valid, llm_input, llm_output_schema from firefly_weave.compiler.loops import ( LOOP_ROOTS, + MAX_CONCURRENT_TASKS, LoopScope, index_schema, item_schema, @@ -62,7 +63,7 @@ from firefly_weave.compiler.source_map import pointer_child from firefly_weave.compiler.typecheck import TypeCheckLimit, check_compatibility, schema_types from firefly_weave.contracts.definitions import load_definition -from firefly_weave.contracts.diagnostics import Diagnostic, RelatedLocation, SourceRange +from firefly_weave.contracts.diagnostics import Diagnostic, RelatedLocation, SourceRange, SuggestedEdit from firefly_weave.contracts.limits import Limits from firefly_weave.contracts.values import JsonObject, JsonValue @@ -94,7 +95,12 @@ "LOOP_SCOPE": ( "/item, /index and /loops are available only inside a forEach body, and /loops/ID must name an enclosing loop." ), + "LOOP_SIGNAL": "A loop body cannot wait for a signal; move the wait before or after the loop.", "LOOP_BODY_INCOMPLETE": "The loop body never completes, so any non-empty list would stop the run.", + "LOOP_FANOUT": ( + f"Nested concurrency can issue more than {MAX_CONCURRENT_TASKS:,} tasks at once; " + "work will be spread over several turns." + ), } @@ -230,6 +236,8 @@ def __init__( self.definition: FrozenDocument | None = None # Loops enclosing the position being analyzed, outermost first. self.loops: tuple[LoopScope, ...] = () + # Every loop as (path, product of the enclosing loops' maxItems, its maxItems, whether it holds no loop). + self.loop_chains: list[tuple[str, int, int, bool]] = [] def location(self, path: str) -> SourceRange | None: while path not in self.source.locations and path: @@ -260,15 +268,19 @@ def issue( unknown: bool = False, stage: Literal["resolution", "semantic"] = "semantic", related: str | None = None, + severity: Literal["error", "warning"] | None = None, + message: str | None = None, + edit: SuggestedEdit | None = None, ) -> None: self.add( Diagnostic( code="WV-COMP-" + code, - severity=severity_for_unknown(self.strict) if unknown else "error", + severity=severity or (severity_for_unknown(self.strict) if unknown else "error"), stage=stage, path=path, - message=_message(code), + message=message or _message(code), related=[] if related is None else [RelatedLocation(path=related, source=self.location(related))], + suggestedEdit=edit, ) ) @@ -853,6 +865,8 @@ def block( } self.guard(location, "human_output", output) elif kind == "signal": + if self.loops: + self.issue("LOOP_SIGNAL", location) output = cast(JsonObject, step["payloadSchema"]) self.guard(location, "signal_payload", output) elif kind == "forEach": @@ -926,6 +940,23 @@ def loop( """Check one forEach and its body; returns the loop's output schema and its analyzed body.""" identifier = cast(str, step["id"]) concurrency, max_items = cast(int, step["concurrency"]), cast(int, step["maxItems"]) + if len(self.loops) >= self.limits.max_loop_depth: + self.issue( + "LOOP_DEPTH", location, message=f"Loops can be nested at most {self.limits.max_loop_depth} deep." + ) + if concurrency > self.concurrency: + self.issue("CONCURRENCY_LIMIT", location + "/concurrency") + if max_items > self.limits.max_loop_items: + self.issue( + "LOOP_MAX_ITEMS", + location + "/maxItems", + message=f"maxItems exceeds this platform's limit of {self.limits.max_loop_items:,}.", + ) + tasks = 1 + for enclosing in self.loops: + tasks *= enclosing.concurrency + if self.loops and tasks <= MAX_CONCURRENT_TASKS < tasks * concurrency: + self.issue("LOOP_FANOUT", location, severity="warning") items = cast(JsonObject, step["items"]) before = self.error_count source = self.expression(items, location + "/items", visible) @@ -936,6 +967,7 @@ def loop( scope_entry = LoopScope(identifier, item_schema(source), index_schema(max_items), concurrency, max_items, quiet) body = cast(JsonObject, step["body"]) expression = cast(JsonObject, body["output"]) + nested = len(self.loop_chains) self.loops += (scope_entry,) try: children, body_visible, completes = self.block( @@ -950,6 +982,10 @@ def loop( body_schema = {} finally: self.loops = self.loops[:-1] + outer = 1 + for enclosing in self.loops: + outer *= enclosing.max_items + self.loop_chains.append((location, outer, max_items, len(self.loop_chains) == nested)) self.guard(location + "/body/output", "branch_output", body_schema) branch = AnalyzedBranch( "body", @@ -975,6 +1011,25 @@ def loop_items(self, schema: JsonObject, items: JsonObject, path: str) -> None: self.issue("UNKNOWN_COMPATIBILITY", path, unknown=True) self.guard(path, "loop_items", {"type": "array"}) + def loop_budget(self) -> None: + """All loops together, each multiplied by the loops around it, stay within the run's iteration budget.""" + limit = self.limits.max_run_iterations + total = sum(outer * max_items for _, outer, max_items, _ in self.loop_chains) + if total <= limit: + return + location, outer, max_items, _ = max( + (chain for chain in self.loop_chains if chain[3]), key=lambda chain: chain[1] * chain[2] + ) + lowered = (limit - (total - outer * max_items)) // outer + self.issue( + "LOOP_BUDGET", + location, + message=( + f"Nested loops could run more than {limit:,} iterations in one run; lower maxItems on an inner loop." + ), + edit=SuggestedEdit(path=location + "/maxItems", value=lowered) if lowered >= 1 else None, + ) + def settle_pending(self) -> None: """Catalog-pending notes are the lowest priority: they only fill capacity that findings left. @@ -1103,6 +1158,7 @@ def run(self, *, partial: bool = False) -> AnalysisResult: "Connector", cast(str, cast(JsonObject, slot)["connector"]), pointer_child("/spec/connections", name) ) graph, visible, completes = self.block(cast(list[JsonObject], spec["steps"]), "/spec/steps", {}, ()) + self.loop_budget() if completes: self.expression( cast(JsonObject, spec["output"]), "/spec/output", visible, cast(JsonObject, spec["outputSchema"]) diff --git a/tests/unit/compiler/test_loop_limits.py b/tests/unit/compiler/test_loop_limits.py new file mode 100644 index 00000000..f9ac4482 --- /dev/null +++ b/tests/unit/compiler/test_loop_limits.py @@ -0,0 +1,162 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Loop limits: nesting, element ceilings, the per-run iteration budget, concurrency and signals in bodies.""" + +from firefly_weave.compiler.api import compile_source +from firefly_weave.compiler.catalog import CatalogSnapshot +from firefly_weave.contracts.limits import Limits + +EMPTY = CatalogSnapshot.empty() +NUMBERS = {"type": "array", "items": {"type": "integer"}} + + +def loop(identifier, *steps, items=None, output=None, **options): + return { + "id": identifier, + "kind": "forEach", + "items": items or {"ref": "/input"}, + **options, + "body": {"steps": list(steps), "output": output or {"literal": None}}, + } + + +def nested(depth, **options): + """Loops a1 … aN, each the only step of the previous one's body; each iterates the enclosing item.""" + step = loop(f"a{depth}", items={"ref": "/item"}, **options) + for level in range(depth - 1, 0, -1): + step = loop(f"a{level}", step, items={"ref": "/item" if level > 1 else "/input"}, **options) + return step + + +def workflow(*steps, input_schema=None): + return { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "limits", "version": "1.0.0"}, + "spec": { + "inputSchema": input_schema or {"type": "array", "items": NUMBERS}, + "outputSchema": {}, + "steps": list(steps), + "output": {"literal": None}, + }, + } + + +def compiled(document, **options): + return compile_source(document, format="object", catalog=EMPTY, **options) + + +def codes(result): + return sorted((d.code, d.path, d.severity) for d in result.diagnostics) + + +def only(result, code): + return [d for d in result.diagnostics if d.code == code] + + +DEEP = {"type": "array", "items": {"type": "array", "items": {"type": "array", "items": {"type": "array"}}}} + + +def test_three_nested_loops_compile_and_a_fourth_is_too_deep(): + assert compiled(workflow(nested(3, maxItems=10), input_schema=DEEP)).ok + result = compiled(workflow(nested(4, maxItems=5), input_schema=DEEP)) + [issue] = only(result, "WV-COMP-LOOP_DEPTH") + assert (issue.path, issue.severity) == ("/spec/steps/0/body/steps/0/body/steps/0/body/steps/0", "error") + assert issue.message == "Loops can be nested at most 3 deep." + assert only(compiled(workflow(nested(2), input_schema=DEEP), limits=Limits(max_loop_depth=1)), "WV-COMP-LOOP_DEPTH") + + +def test_max_items_above_the_platform_limit_is_rejected_at_the_field(): + result = compiled(workflow(loop("big", maxItems=10_001))) + [issue] = only(result, "WV-COMP-LOOP_MAX_ITEMS") + assert (issue.path, issue.message) == ( + "/spec/steps/0/maxItems", + "maxItems exceeds this platform's limit of 10,000.", + ) + assert compiled(workflow(loop("big", maxItems=10_000))).ok + [smaller] = only( + compiled(workflow(loop("big", maxItems=51)), limits=Limits(max_loop_items=50)), "WV-COMP-LOOP_MAX_ITEMS" + ) + assert smaller.message == "maxItems exceeds this platform's limit of 50." + + +def test_concurrency_above_the_platform_limit_uses_the_parallel_code(): + assert codes(compiled(workflow(loop("wide", concurrency=1001)))) == [ + ("WV-COMP-CONCURRENCY_LIMIT", "/spec/steps/0/concurrency", "error") + ] + assert codes(compiled(workflow(loop("wide", concurrency=8)), max_parallel_concurrency=4)) == [ + ("WV-COMP-CONCURRENCY_LIMIT", "/spec/steps/0/concurrency", "error") + ] + + +def test_nested_default_loops_exceed_the_run_budget_with_an_edit_on_the_inner_loop(): + document = workflow(loop("outer", loop("inner", items={"ref": "/item"}))) + result = compiled(document) + [issue] = only(result, "WV-COMP-LOOP_BUDGET") + assert (issue.path, issue.severity) == ("/spec/steps/0/body/steps/0", "error") + assert issue.message == ( + "Nested loops could run more than 100,000 iterations in one run; lower maxItems on an inner loop." + ) + assert issue.suggested_edit is not None + assert (issue.suggested_edit.path, issue.suggested_edit.value) == ("/spec/steps/0/body/steps/0/maxItems", 99) + document["spec"]["steps"][0]["body"]["steps"][0]["maxItems"] = 99 + assert compiled(document).ok + document["spec"]["steps"][0]["body"]["steps"][0]["maxItems"] = 100 + assert only(compiled(document), "WV-COMP-LOOP_BUDGET") + + +def test_the_budget_counts_every_loop_and_suggests_nothing_when_one_edit_cannot_fix_it(): + sequential = workflow(*(loop(f"l{n}", maxItems=10_000) for n in range(11))) + [issue] = only(compiled(sequential), "WV-COMP-LOOP_BUDGET") + assert issue.path == "/spec/steps/0" and issue.suggested_edit is None + assert compiled(workflow(*(loop(f"l{n}", maxItems=10_000) for n in range(10)))).ok + assert only(compiled(workflow(loop("a", maxItems=11)), limits=Limits(max_run_iterations=10)), "WV-COMP-LOOP_BUDGET") + + +def test_nested_concurrency_over_a_thousand_tasks_warns_once_where_it_crosses(): + deepest = loop("c", items={"ref": "/item"}, concurrency=1, maxItems=2) + inner = loop("b", deepest, items={"ref": "/item"}, concurrency=20, maxItems=2) + document = workflow(loop("a", inner, concurrency=100, maxItems=2), input_schema=DEEP) + result = compiled(document) + assert result.ok + assert codes(result) == [("WV-COMP-LOOP_FANOUT", "/spec/steps/0/body/steps/0", "warning")] + assert codes(compiled(workflow(loop("a", concurrency=1000)))) == [] + + +def test_a_signal_anywhere_inside_a_loop_body_is_rejected(): + wait = {"id": "approval", "kind": "signal", "name": "approval", "timeoutSeconds": 60, "payloadSchema": {}} + decision = { + "id": "route", + "kind": "switch", + "cases": [{"when": {"literal": True}, "steps": [wait], "output": {"literal": None}}], + "default": {"steps": [], "output": {"literal": None}}, + } + assert codes(compiled(workflow(loop("each", wait)))) == [ + ("WV-COMP-LOOP_SIGNAL", "/spec/steps/0/body/steps/0", "error") + ] + assert codes(compiled(workflow(loop("each", decision)))) == [ + ("WV-COMP-LOOP_SIGNAL", "/spec/steps/0/body/steps/0/cases/0/steps/0", "error") + ] + assert compiled(workflow(wait, loop("each"))).ok + + +def test_items_and_body_expressions_count_toward_the_expression_budget(): + items = {"array": [{"literal": n} for n in range(20)]} + result = compiled(workflow(loop("each", items=items)), limits=Limits(max_expression_nodes=10)) + assert [(d.code, d.path.startswith("/spec/steps/0/items")) for d in result.diagnostics] == [ + ("WV-EXPR-RESOURCE_LIMIT", True) + ] From 5fba996ae8dd62bc1e7590fabaa698bdc93909bc Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:49:32 -0700 Subject: [PATCH 4/9] Refuse to publish or run loops before the runtime supports them, and show loops in explain and graph --- src/firefly_weave/cli/graph.py | 4 +- src/firefly_weave/cli/workflow.py | 5 ++ src/firefly_weave/definitions/service.py | 7 +- src/firefly_weave/runtime/kernel.py | 5 ++ src/firefly_weave/sdk/visualization.py | 10 ++- tests/integration/test_loop_publication.py | 84 ++++++++++++++++++++++ tests/unit/cli/test_graph.py | 34 +++++++++ tests/unit/cli/test_offline.py | 37 ++++++++++ tests/unit/runtime/test_loop_refusal.py | 69 ++++++++++++++++++ 9 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 tests/integration/test_loop_publication.py create mode 100644 tests/unit/runtime/test_loop_refusal.py diff --git a/src/firefly_weave/cli/graph.py b/src/firefly_weave/cli/graph.py index d43dbea6..f6a0b4c1 100644 --- a/src/firefly_weave/cli/graph.py +++ b/src/firefly_weave/cli/graph.py @@ -25,6 +25,7 @@ from firefly_weave.compiler.api import import_artifact from firefly_weave.compiler.canonical import canonical_bytes from firefly_weave.compiler.lowering import DEFAULT_ARTIFACT_LIMITS +from firefly_weave.contracts.language_features import LANGUAGE_FEATURES from firefly_weave.sdk.visualization import render_graph @@ -45,7 +46,8 @@ def graph( try: data = read_bounded(artifact, DEFAULT_ARTIFACT_LIMITS.max_bytes) try: - compiled = import_artifact(data) + # Drawing runs nothing, so it accepts every language feature, including ones this platform cannot run. + compiled = import_artifact(data, features=LANGUAGE_FEATURES) content = render_graph(compiled, format) except (ValueError, TypeError, RecursionError): raise LocalError( diff --git a/src/firefly_weave/cli/workflow.py b/src/firefly_weave/cli/workflow.py index 962373b8..6d4a96fe 100644 --- a/src/firefly_weave/cli/workflow.py +++ b/src/firefly_weave/cli/workflow.py @@ -41,6 +41,7 @@ ArtifactEnvelope, BranchOutputNode, EndNode, + ForEachNode, SwitchNode, TransformNode, ) @@ -85,6 +86,8 @@ def _explain(artifact: CompiledArtifact) -> None: span = envelope.source_map.get(node.path) location = f"{span.file or ''}:{span.line}:{span.column}" if span else node.path or "/" detail = f" uses={dependencies[node.dependency].reference}" if isinstance(node, ActionNode) else "" + if isinstance(node, ForEachNode): + detail = f" concurrency={node.concurrency} maxItems={node.max_items} collect={node.collect}" click.echo(f" {node.id} ({node.kind}) {location}{detail}") expressions: list[Expression] = [] if isinstance(node, ActionNode): @@ -95,6 +98,8 @@ def _explain(artifact: CompiledArtifact) -> None: expressions.append(node.output) elif isinstance(node, SwitchNode): expressions.extend(case.when for case in node.cases) + elif isinstance(node, ForEachNode): + expressions.append(node.items) for expression in expressions: for reference in _references(expression): click.echo(f" reads {reference}") diff --git a/src/firefly_weave/definitions/service.py b/src/firefly_weave/definitions/service.py index f8c9ad65..0161add1 100644 --- a/src/firefly_weave/definitions/service.py +++ b/src/firefly_weave/definitions/service.py @@ -35,7 +35,7 @@ from firefly_weave.compiler.catalog import CatalogLock, CatalogSnapshot, FrozenDocument from firefly_weave.compiler.decision_tables import DecisionFailure, evaluate_decision_table from firefly_weave.compiler.expressions import ExpressionFailure, measure_value -from firefly_weave.compiler.ir import DecisionTableIR, UnsupportedIR +from firefly_weave.compiler.ir import DecisionTableIR, UnsupportedIR, require_supported_ir from firefly_weave.compiler.parser import parse_source from firefly_weave.connections.registry import ConnectorRegistry from firefly_weave.contracts.access import Scope @@ -331,6 +331,11 @@ async def publish( raise CatalogError( 422, "WV-COMPILE", "Definition compilation failed", result=json.loads(result.to_bytes()) ) + # A version this platform could not read back or run is never stored. + try: + require_supported_ir(result.artifact.executable) + except UnsupportedIR as error: + raise ir_unsupported(error.missing) from None model = load_definition(parse_source(source, format=format).value) if model.kind != kind: raise CatalogError(422, "WV-KIND", "Definition kind does not match collection") diff --git a/src/firefly_weave/runtime/kernel.py b/src/firefly_weave/runtime/kernel.py index e9e3d8a5..52ac1de1 100644 --- a/src/firefly_weave/runtime/kernel.py +++ b/src/firefly_weave/runtime/kernel.py @@ -34,6 +34,7 @@ DecisionTableNode, EndNode, FailNode, + ForEachNode, HumanTaskNode, IRNode, JoinNode, @@ -676,6 +677,10 @@ def accept(completion: RuntimeEvent) -> None: result.commands.append(ControlCommand(kind="join", node_id=current)) current_state.joins[owner] = join.model_copy(update={"status": "joined"}) queue.append(successor(current)) + elif isinstance(node, ForEachNode): + # This runtime does not run loops: import refuses their artifacts, and a loop that still + # arrives here stops behind an incident instead of being skipped. + raise KernelError("WV-RUNTIME-ARTIFACT") elif isinstance(node, (ActionNode, SignalNode, WaitNode, HumanTaskNode)): if current in current_state.active: raise KernelError("WV-RUNTIME-ACTIVATION") diff --git a/src/firefly_weave/sdk/visualization.py b/src/firefly_weave/sdk/visualization.py index 95129a2b..0314f572 100644 --- a/src/firefly_weave/sdk/visualization.py +++ b/src/firefly_weave/sdk/visualization.py @@ -36,6 +36,7 @@ "humanTask": "Wait for a human decision", "switch": "Choose a branch", "parallel": "Run branches", + "forEach": "Loop over items", "branch-output": "Collect branch result", "join": "Continue after branches", "fail": "Stop with a failure", @@ -43,7 +44,14 @@ # The light diagram palette of the documentation: an exported file is opened outside the product, on paper. GROUND, INK, LINE, NUMERAL = "#F3F1EB", "#272820", "#62645B", "#FFFFFF" -NODE_FILLS = {"start": "#EAE7DF", "end": "#EAE7DF", "switch": "#FFF0D8", "parallel": "#FFF0D8", "join": "#FFF0D8"} +NODE_FILLS = { + "start": "#EAE7DF", + "end": "#EAE7DF", + "switch": "#FFF0D8", + "parallel": "#FFF0D8", + "forEach": "#FFF0D8", + "join": "#FFF0D8", +} TASK_FILL = "#FFFFFF" diff --git a/tests/integration/test_loop_publication.py b/tests/integration/test_loop_publication.py new file mode 100644 index 00000000..29af9e73 --- /dev/null +++ b/tests/integration/test_loop_publication.py @@ -0,0 +1,84 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""A platform publishes a workflow only when it runs every language feature the workflow uses.""" + +import json + +import pytest +from sqlalchemy import text + +from firefly_weave.access.audit import AuditContext +from firefly_weave.contracts import language_features + +pytestmark = pytest.mark.integration + +LOOP = { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "label-items", "version": "1.0.0"}, + "spec": { + "inputSchema": {"type": "array", "items": {"type": "string"}}, + "outputSchema": {"type": "array"}, + "steps": [ + { + "id": "label", + "kind": "forEach", + "items": {"ref": "/input"}, + "body": { + "steps": [], + "output": {"op": {"name": "concat", "args": [{"literal": "#"}, {"ref": "/item"}]}}, + }, + } + ], + "output": {"ref": "/steps/label/output"}, + }, +} + + +async def versions(access_db): + async with access_db[1]() as session: + return await session.scalar(text("SELECT count(*) FROM definition_versions WHERE name='label-items'")) + + +async def test_a_platform_that_does_not_run_loops_refuses_to_publish_one(worker_setup, services, access_db): + from firefly_weave.definitions.models import CatalogError + from firefly_weave.definitions.service import DefinitionService + + actor, scope = worker_setup[3:5] + definitions = services(access_db[0]).resolve(DefinitionService) + with pytest.raises(CatalogError) as refused: + await definitions.publish(actor, scope, "Workflow", json.dumps(LOOP), "json", "loop", context=AuditContext()) + assert (refused.value.status, refused.value.code) == (422, "WV-IR-UNSUPPORTED") + assert refused.value.result == {"reason": "ir_unsupported", "missing_features": ["flow.forEach"]} + assert await versions(access_db) == 0 + + +async def test_a_platform_that_runs_loops_publishes_one_and_reads_it_back( + worker_setup, services, access_db, monkeypatch +): + from firefly_weave.definitions.service import DefinitionService + + monkeypatch.setattr(language_features, "ADVERTISED_FEATURES", ("flow.forEach", "text.concat", "text.join")) + actor, scope = worker_setup[3:5] + definitions = services(access_db[0]).resolve(DefinitionService) + published = await definitions.publish( + actor, scope, "Workflow", json.dumps(LOOP), "json", "loop", context=AuditContext() + ) + read = await definitions.read(actor, scope, "Workflow", published.id, export=True, context=AuditContext()) + assert read["digest"] == published.digest + assert read["artifact"]["executable"]["features"] == ["flow.forEach", "text.concat"] + assert await versions(access_db) == 1 diff --git a/tests/unit/cli/test_graph.py b/tests/unit/cli/test_graph.py index 34c78b03..9cdb5ff5 100644 --- a/tests/unit/cli/test_graph.py +++ b/tests/unit/cli/test_graph.py @@ -237,3 +237,37 @@ def test_svg_uses_the_light_diagram_palette_on_an_opaque_paper_ground(): assert (box.get("fill"), box.get("stroke")) == (expected.get(kind, "#FFFFFF"), "#62645B"), kind assert (disc.get("fill"), numeral.get("fill")) == ("#272820", "#FFFFFF") assert not {color for color in LEGACY_GRAPH_COLORS if color.lower() in svg.lower()} + + +def test_graph_draws_a_compiled_loop_without_running_it(tmp_path): + from firefly_weave.compiler.catalog import CatalogSnapshot + + document = { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "label-items", "version": "1.0.0"}, + "spec": { + "inputSchema": {"type": "array"}, + "outputSchema": {}, + "steps": [ + { + "id": "label", + "kind": "forEach", + "items": {"ref": "/input"}, + "body": {"steps": [], "output": {"ref": "/item"}}, + } + ], + "output": {"ref": "/steps/label/output"}, + }, + } + result = compile_source(document, format="object", catalog=CatalogSnapshot.empty()) + assert result.ok and result.artifact is not None + path = tmp_path / "compiled.json" + path.write_bytes(result.artifact.to_bytes()) + for format in ("text", "mermaid", "svg"): + drawn = CliRunner().invoke(cli, ["workflow", "graph", str(path), "--format", format, "--output", "json"]) + assert drawn.exit_code == 0, drawn.output + content = json.loads(drawn.output)["content"] + assert "Loop over items" in content + text = CliRunner().invoke(cli, ["workflow", "graph", str(path)]).output + assert "[label] Loop over items\n +-- each: body --> [@branch:label:0]" in text diff --git a/tests/unit/cli/test_offline.py b/tests/unit/cli/test_offline.py index 7d17c23b..99106a07 100644 --- a/tests/unit/cli/test_offline.py +++ b/tests/unit/cli/test_offline.py @@ -320,3 +320,40 @@ def test_admin_migrate_help_does_not_load_server(): ) result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) assert result.returncode == 0, result.stderr + + +def test_explain_prints_a_loop_with_its_settings_and_its_each_edge(tmp_path): + loop = { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "label-items", "version": "1.0.0"}, + "spec": { + "inputSchema": {"type": "array", "items": {"type": "string"}}, + "outputSchema": {"type": "array"}, + "steps": [ + { + "id": "label", + "kind": "forEach", + "items": {"ref": "/input"}, + "concurrency": 2, + "body": {"steps": [], "output": {"ref": "/item"}}, + } + ], + "output": {"ref": "/steps/label/output"}, + }, + } + source = tmp_path / "label-items.workflow.json" + source.write_text(json.dumps(loop)) + lock = tmp_path / "empty.lock.json" + lock.write_text('{"definitions":[],"tasks":[],"adapters":[],"schemas":{}}') + result = invoke("workflow", "explain", source, "--catalog", lock) + assert result.exit_code == 0, result.output + for expected in ( + "label (forEach)", + " concurrency=2 maxItems=1000 collect=all", + " reads /input\n", + " reads /item\n", + "label -> @branch:label:0 (each branch=body)", + "@branch:label:0 -> @join:label (join branch=body)", + ): + assert expected in result.output diff --git a/tests/unit/runtime/test_loop_refusal.py b/tests/unit/runtime/test_loop_refusal.py new file mode 100644 index 00000000..fccac898 --- /dev/null +++ b/tests/unit/runtime/test_loop_refusal.py @@ -0,0 +1,69 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""A runtime that does not run loops refuses a compiled loop; it never skips the step.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from firefly_weave.compiler.api import compile_source, import_artifact +from firefly_weave.compiler.catalog import CatalogSnapshot +from firefly_weave.compiler.ir import UnsupportedIR +from firefly_weave.runtime.kernel import transition +from firefly_weave.runtime.models import RunState, RuntimeEvent + +LOOP = { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "label-items", "version": "1.0.0"}, + "spec": { + "inputSchema": {"type": "array", "items": {"type": "string"}}, + "outputSchema": {"type": "array"}, + "steps": [ + { + "id": "label", + "kind": "forEach", + "items": {"ref": "/input"}, + "body": {"steps": [], "output": {"ref": "/item"}}, + } + ], + "output": {"ref": "/steps/label/output"}, + }, +} + + +@pytest.fixture +def artifact(): + result = compile_source(LOOP, format="object", catalog=CatalogSnapshot.empty()) + assert result.ok, result.to_bytes() + return result.artifact + + +def test_import_refuses_a_loop_while_the_platform_does_not_list_the_loop_feature(artifact): + with pytest.raises(UnsupportedIR) as refused: + import_artifact(artifact.to_bytes()) + assert refused.value.missing == ("flow.forEach",) + assert import_artifact(artifact.to_bytes(), features=["flow.forEach"]).digest == artifact.digest + + +def test_the_kernel_opens_an_incident_at_a_loop_it_cannot_run(artifact): + started = RuntimeEvent(id=uuid4(), type="started", timestamp=datetime(2026, 10, 13, tzinfo=UTC), sequence=1) + result = transition(RunState(input=["a", "b"]), started, artifact) + assert result.state.status == "suspended" + assert [(item.node_id, item.code) for item in result.state.incidents.values()] == [("label", "WV-RUNTIME-ARTIFACT")] + assert result.steps == [] From 9642059c518c368822d442c07f887b13fb7f9425 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:51:31 -0700 Subject: [PATCH 5/9] Suggest the data a field may use, including loop items, from the compiler's scope --- src/firefly_weave/compiler/analyzer.py | 67 +++++- src/firefly_weave/compiler/scope.py | 238 +++++++++++++++++++- src/firefly_weave/studio/host.py | 34 +++ tests/unit/compiler/test_scope_at.py | 242 +++++++++++++++++++++ tests/unit/compiler/test_scope_contract.py | 8 +- tests/unit/test_studio_host.py | 35 +++ 6 files changed, 613 insertions(+), 11 deletions(-) create mode 100644 tests/unit/compiler/test_scope_at.py diff --git a/src/firefly_weave/compiler/analyzer.py b/src/firefly_weave/compiler/analyzer.py index 4e915200..eae56e3a 100644 --- a/src/firefly_weave/compiler/analyzer.py +++ b/src/firefly_weave/compiler/analyzer.py @@ -146,6 +146,19 @@ class AnalyzedStep: reachable: bool = True +@dataclass(frozen=True) +class ScopeCapture: + """What an expression at one definition path may read, captured during analysis.""" + + input_schema: JsonObject + # Steps whose outputs are visible, in execution order: step ID to (kind, output schema). + steps: tuple[tuple[str, str, JsonObject], ...] + # Enclosing loops, outermost first. + loops: tuple[LoopScope, ...] + # False when every path to the position fails, so the compiler never evaluates it. + evaluated: bool + + @dataclass(frozen=True) class AnalysisResult: kind: Literal["Workflow", "Action", "Connector", "DecisionTable"] | None @@ -209,8 +222,13 @@ def __init__( action_validators: Mapping[str, ActionConfigValidator] | None = None, *, catalog_absent: bool = False, + capture: str | None = None, ) -> None: self.source, self.catalog, self.strict = source, catalog, strict + # A definition path whose scope analysis records in ``captured``. + self.capture = capture + self.captured: ScopeCapture | None = None + self.kinds: dict[str, str] = {} self.action_validators = dict(action_validators or {}) # Offline authoring: every catalog reference is pending rather than unknown. self.catalog_absent = catalog_absent @@ -605,9 +623,22 @@ def infer(self, expression: JsonObject, visible: dict[str, JsonObject]) -> Infer context["loops"] = loops_schema(self.loops) return infer_expression(expression, context, limits=self.limits, schema_limits=self.schema_limits) + def observe(self, path: str, visible: dict[str, JsonObject], evaluated: bool = True) -> None: + """Record the scope at the expression ``path`` when it holds the captured position.""" + if self.capture is None or self.captured is not None: + return + if self.capture == path or self.capture.startswith(path + "/"): + self.captured = ScopeCapture( + self.input_schema, + tuple((key, self.kinds[key], schema) for key, schema in visible.items()), + self.loops, + evaluated, + ) + def expression( self, expression: JsonObject, path: str, visible: dict[str, JsonObject], target: JsonObject | None = None ) -> JsonObject: + self.observe(path, visible) try: # Dominance is a syntactic requirement, including lazy operands. stack = [(expression, path)] @@ -887,9 +918,11 @@ def block( scope + (identifier, name), ) expression = cast(JsonObject, branch["output"]) - branch_schema = ( - self.expression(expression, branch_path + "/output", branch_visible) if branch_completes else {} - ) + if branch_completes: + branch_schema = self.expression(expression, branch_path + "/output", branch_visible) + else: + self.observe(branch_path + "/output", branch_visible, evaluated=False) + branch_schema = {} self.guard(branch_path + "/output", "branch_output", branch_schema) if branch_completes and self.reads_pending(expression): self.pending_outputs.add(identifier) @@ -929,6 +962,7 @@ def block( reachable, ) ) + self.kinds[identifier] = kind if reachable and completes: visible[identifier] = output reachable = reachable and completes @@ -979,6 +1013,7 @@ def loop( self.pending_outputs.add(identifier) else: self.issue("LOOP_BODY_INCOMPLETE", location + "/body") + self.observe(location + "/body/output", body_visible, evaluated=False) body_schema = {} finally: self.loops = self.loops[:-1] @@ -1163,6 +1198,8 @@ def run(self, *, partial: bool = False) -> AnalysisResult: self.expression( cast(JsonObject, spec["output"]), "/spec/output", visible, cast(JsonObject, spec["outputSchema"]) ) + else: + self.observe("/spec/output", visible, evaluated=False) self.guard("/spec/output", "workflow_output", cast(JsonObject, spec["outputSchema"])) return self.finish(model.kind, definition, graph) @@ -1222,6 +1259,30 @@ def analyze_authoring( return analyzer.finish(analyzer.kind, analyzer.definition) +def analyze_scope(source: ParsedSource, path: str, catalog: CatalogSnapshot | None = None) -> ScopeCapture | None: + """The scope of the expression at ``path``, or ``None`` when analysis never reaches an expression there. + + Without a catalog, references resolve as in offline authoring and their outputs stay unconstrained. + """ + analyzer = _Analyzer( + source, + catalog or CatalogSnapshot.empty(), + False, + _DEFAULT_LIMITS, + _DEFAULT_SCHEMA_LIMITS, + DEFAULT_CONTRACT_LIMITS, + 1000, + catalog_absent=catalog is None, + capture=path, + ) + try: + analyzer.run() + except (ExpressionFailure, RecursionError): + # A budget failure later in the document keeps the scope already captured. + return analyzer.captured + return analyzer.captured + + def analyze_partial( source: ParsedSource, *, diff --git a/src/firefly_weave/compiler/scope.py b/src/firefly_weave/compiler/scope.py index 5450e0fd..8a56590d 100644 --- a/src/firefly_weave/compiler/scope.py +++ b/src/firefly_weave/compiler/scope.py @@ -22,11 +22,14 @@ does not reimplement. """ -from typing import Literal +from typing import Final, Literal, cast from pydantic import Field +from firefly_weave.compiler.analyzer import analyze_scope from firefly_weave.compiler.catalog import CatalogSnapshot +from firefly_weave.compiler.loops import LoopScope +from firefly_weave.compiler.parser import ParseFailure, parse_source from firefly_weave.contracts.definitions import ( ContractModel, JsonPointer, @@ -35,11 +38,35 @@ _is_absent, _omit_absent_default, ) -from firefly_weave.contracts.values import JsonObject, JsonObjectData +from firefly_weave.contracts.values import JsonObject, JsonObjectData, JsonValue type ScopeSource = Literal["input", "step", "item", "index"] type JsonType = Literal["array", "boolean", "integer", "null", "number", "object", "string"] +# Deepest property path expanded below a root, and the most suggestions returned, roots included. +MAX_DEPTH: Final = 4 +MAX_ENTRIES: Final = 200 +_JSON_TYPES: Final = frozenset({"array", "boolean", "integer", "null", "number", "object", "string"}) +# Studio's value names (studio/src/app/forms/core/type-labels.ts); the shared scope fixtures pin them. +_TYPE_LABELS: Final[dict[str, str]] = { + "string": "Text", + "number": "Number", + "integer": "Whole number", + "boolean": "Yes/No", + "object": "Group", + "array": "List", + "null": "Empty", +} +_PLURAL_LABELS: Final[dict[str, str]] = { + "string": "text", + "number": "numbers", + "integer": "whole numbers", + "boolean": "yes/no values", + "object": "groups", + "array": "lists", + "null": "empty values", +} + class ScopeEntry(ContractModel): """One suggestion; field names match Studio's ``ScopeEntry`` (``studio/src/app/forms/core/scope.ts``).""" @@ -76,6 +103,209 @@ def scope_at(document: JsonObject, path: JsonPointer, *, catalog: CatalogSnapsho """Suggestions for the expression at ``path`` (an RFC 6901 pointer into ``document``). Without a catalog, resources resolve as in offline authoring and their outputs stay unconstrained. - Arrives with loop compilation in a later release; until then it raises ``NotImplementedError``. + ``found`` is false when ``path`` is not inside an expression the compiler analyzes, including documents it + rejects before flow analysis. Entries list the workflow input, the current loop's item and position, enclosing + loops (inner to outer), then visible step outputs in execution order, each followed by its fields. """ - raise NotImplementedError("Scope suggestions are not available in this version of Firefly Weave") + try: + capture = analyze_scope(parse_source(document, format="object"), path, catalog) + except ParseFailure: + capture = None + if capture is None: + return ScopeResult(found=False, evaluated=False, truncated=False, entries=[]) + roots: list[JsonObject] = [_root("/input", "input", "Workflow input", "Workflow input", capture.input_schema)] + for depth, loop in enumerate(reversed(capture.loops)): + roots.extend(_loop_roots(loop, innermost=depth == 0)) + for identifier, kind, schema in capture.steps: + step = _root(f"/steps/{_escape(identifier)}/output", "step", identifier, identifier, schema) + roots.append(step | {"stepId": identifier, "stepKind": kind}) + entries, truncated = _expand(roots) + return ScopeResult( + found=True, + evaluated=capture.evaluated, + truncated=truncated, + entries=[ScopeEntry.model_validate(entry) for entry in entries], + ) + + +def _escape(segment: str) -> str: + return segment.replace("~", "~0").replace("/", "~1") + + +def _root(ref: str, source: str, label: str, breadcrumb: str, schema: JsonObject) -> JsonObject: + return {"ref": ref, "source": source, "path": [], "label": label, "breadcrumb": breadcrumb, "schema": schema} + + +def _loop_roots(loop: LoopScope, *, innermost: bool) -> list[JsonObject]: + """The item and position of a loop: ``/item`` and ``/index`` for the innermost, ``/loops//…`` otherwise.""" + base = "" if innermost else f"/loops/{_escape(loop.id)}" + prefix = f"Loop {loop.id} › " + return [ + _root(f"{base}/item", "item", "Current item", prefix + "Current item", loop.item) | {"loop": loop.id}, + _root(f"{base}/index", "index", "Item position (from 0)", prefix + "Item position", loop.index) + | {"loop": loop.id}, + ] + + +def _expand(roots: list[JsonObject]) -> tuple[list[JsonObject], bool]: + """Roots and their fields, breadth first to ``MAX_DEPTH`` and at most ``MAX_ENTRIES``, in display order.""" + accepted: list[tuple[tuple[int, ...], JsonObject, JsonObject]] = [] + for index, root in enumerate(roots[:MAX_ENTRIES]): + schema = cast(JsonObject, root["schema"]) + accepted.append(((index,), _typed(root, schema), schema)) + truncated = len(roots) > MAX_ENTRIES + level = list(accepted) + depth = 1 + while depth <= MAX_DEPTH and level and not truncated: + following: list[tuple[tuple[int, ...], JsonObject, JsonObject]] = [] + for order, parent, root in level: + properties = _object_properties(_resolve(cast(JsonObject, parent["schema"]), root), root) or {} + for position, (key, (schema, required)) in enumerate(properties.items()): + if len(accepted) >= MAX_ENTRIES: + truncated = True + break + schema = _resolve(schema, root) + title = schema.get("title") + label = title.strip() if isinstance(title, str) and title.strip() else key + child = parent | { + "ref": f"{parent['ref']}/{_escape(key)}", + "path": [*cast(list[str], parent["path"]), key], + "label": label, + "breadcrumb": f"{parent['breadcrumb']} › {label}", + "schema": schema, + "optional": parent["optional"] or "null" in cast(list[str], parent["types"]) or not required, + } + item = ((*order, position), _typed(child, root), root) + accepted.append(item) + following.append(item) + if truncated: + break + level = following + depth += 1 + accepted.sort(key=lambda item: item[0]) + return [entry for _, entry, _ in accepted], truncated + + +def _typed(entry: JsonObject, root: JsonObject) -> JsonObject: + schema = cast(JsonObject, entry["schema"]) + types: list[JsonValue] = [name for name in _schema_types(schema, root) if name in _JSON_TYPES] + return entry | {"types": types, "typeLabel": _type_label(schema, root), "optional": entry.get("optional", False)} + + +def _schema_of(value: JsonValue) -> JsonObject: + return value if isinstance(value, dict) else {"not": {}} if value is False else {} + + +def _resolve(schema: JsonObject, root: JsonObject) -> JsonObject: + """Follow local ``#/…`` references within ``root``, at most 16 hops.""" + current = schema + for _ in range(16): + target = current.get("$ref") + if not isinstance(target, str) or not target.startswith("#/"): + return current + node: JsonValue = root + for segment in target[2:].split("/"): + key = segment.replace("~1", "/").replace("~0", "~") + node = node.get(key) if isinstance(node, dict) else None + if not isinstance(node, dict): + return current + current = node + return current + + +def _object_properties( + schema: JsonObject, root: JsonObject, seen: frozenset[int] = frozenset() +) -> dict[str, tuple[JsonObject, bool]] | None: + """Properties of an object-shaped schema: ``allOf`` merged, local ``$ref`` followed, ``anyOf`` intersected.""" + if id(schema) in seen or len(seen) > 32: + return None + seen = seen | {id(schema)} + resolved = _resolve(schema, root) + if resolved is not schema: + return _object_properties(resolved, root, seen) + alternatives = schema.get("anyOf") if schema.get("anyOf") is not None else schema.get("oneOf") + if isinstance(alternatives, list) and alternatives: + views = [_object_properties(_schema_of(value), root, seen) for value in alternatives] + if any(view is None for view in views): + return None + first, *rest = cast(list[dict[str, tuple[JsonObject, bool]]], views) + # A field set on only some paths is rejected by the compiler. + return {key: value for key, value in first.items() if all(key in view for view in rest)} + required = set(cast(list[str], schema["required"])) if isinstance(schema.get("required"), list) else set() + found: dict[str, tuple[JsonObject, bool]] = {} + properties = schema.get("properties") + if isinstance(properties, dict): + for key, value in properties.items(): + found[key] = (_schema_of(value), key in required) + parts = schema.get("allOf") + for part in parts if isinstance(parts, list) else []: + for key, (value, part_required) in (_object_properties(_schema_of(part), root, seen) or {}).items(): + found[key] = (value, part_required or key in required or (key in found and found[key][1])) + return {key: (value, value_required or key in required) for key, (value, value_required) in found.items()} + + +def _json_type(value: JsonValue) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int) or (isinstance(value, float) and value.is_integer()): + return "integer" + if isinstance(value, float): + return "number" + return "string" if isinstance(value, str) else "array" if isinstance(value, list) else "object" + + +def _unique(values: list[str]) -> list[str]: + return list(dict.fromkeys(values)) + + +def _schema_types(schema: JsonObject, root: JsonObject) -> list[str]: + """JSON types a schema admits; empty when unknown.""" + resolved = _resolve(schema, root) + if "const" in resolved: + return [_json_type(resolved["const"])] + if isinstance(resolved.get("enum"), list): + return _unique([_json_type(value) for value in cast(list[JsonValue], resolved["enum"])]) + kind = resolved.get("type") + if isinstance(kind, str): + return [kind] + if isinstance(kind, list): + return _unique([str(name) for name in kind]) + alternatives = resolved.get("anyOf") if resolved.get("anyOf") is not None else resolved.get("oneOf") + if isinstance(alternatives, list) and alternatives: + sets = [_schema_types(_schema_of(value), root) for value in alternatives] + return [] if any(not item for item in sets) else _unique([name for item in sets for name in item]) + if isinstance(resolved.get("properties"), dict): + return ["object"] + if isinstance(resolved.get("items"), dict): + return ["array"] + return [] + + +def _type_label(schema: JsonObject, root: JsonObject) -> str: + """A plain-language description of a schema's type, as Studio's suggestion badges show it.""" + resolved = _resolve(schema, root) + if "const" in resolved: + return "Fixed value" + if isinstance(resolved.get("enum"), list): + return "Choice" + types = _schema_types(resolved, root) + if not types: + return "Any value" + if types == ["string"] and resolved.get("format") == "date-time": + return "Date and time" + named: list[str] = [] + for name in types: + if name == "null" and len(types) > 1: + continue + if name != "array": + named.append(_TYPE_LABELS.get(name, name)) + continue + items = resolved.get("items") + inner = _schema_types(items, root) if isinstance(items, dict) else [] + named.append( + f"List of {_PLURAL_LABELS[inner[0]]}" if len(inner) == 1 and inner[0] in _PLURAL_LABELS else "List" + ) + label = " or ".join(text if position == 0 else text.lower() for position, text in enumerate(named)) + return f"{label} or empty" if "null" in types and len(types) > 1 else label diff --git a/src/firefly_weave/studio/host.py b/src/firefly_weave/studio/host.py index 1de2bf7a..dd706aff 100644 --- a/src/firefly_weave/studio/host.py +++ b/src/firefly_weave/studio/host.py @@ -94,6 +94,15 @@ class ValidationRequest(BaseModel): strict: bool = False +class ScopeRequest(BaseModel): + """A local definition and the RFC 6901 path of one expression in it.""" + + model_config = ConfigDict(extra="forbid", strict=True) + source: str = Field(max_length=1024 * 1024) + format: Literal["yaml", "json"] + path: str = Field(max_length=4096, pattern=r"^(?:/(?:[^~/]|~[01])*)*$") + + class OpenAPIInventoryRequest(BaseModel): """Pasted or uploaded OpenAPI text; there is deliberately no URL field (no fetch from Studio's host).""" @@ -434,6 +443,31 @@ async def validate(self, request: Request) -> Response: slow="Checking this definition took too long; simplify it and try again", ) + @post_mapping("/studio/local/scope") + async def scope(self, request: Request) -> Response: + try: + body = ScopeRequest.model_validate_json(await request.body()) + except ValidationError: + return problem( + 422, "WV-STUDIO-REQUEST", "Provide a bounded source string, YAML or JSON, and a JSON Pointer" + ) + + def work() -> Any: + from firefly_weave.compiler.parser import ParseFailure, parse_source + from firefly_weave.compiler.scope import ScopeResult, scope_at + + try: + document = parse_source(body.source, format=body.format).value + except ParseFailure: + document = None + if not isinstance(document, dict): + return ScopeResult(found=False, evaluated=False, truncated=False, entries=[]).model_dump( + mode="json", by_alias=True + ) + return scope_at(document, body.path).model_dump(mode="json", by_alias=True) + + return await local_work(work, slow="Finding the data for this field took too long; simplify the workflow") + @post_mapping("/studio/local/openapi/inventory") async def openapi_inventory(self, request: Request) -> Response: from firefly_weave.sdk.openapi_import import inventory diff --git a/tests/unit/compiler/test_scope_at.py b/tests/unit/compiler/test_scope_at.py new file mode 100644 index 00000000..3e9f8d5c --- /dev/null +++ b/tests/unit/compiler/test_scope_at.py @@ -0,0 +1,242 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Scope suggestions come from the compiler's own dominance, loop scope and type analysis.""" + +from firefly_weave.compiler.catalog import CatalogSnapshot +from firefly_weave.compiler.scope import MAX_ENTRIES, scope_at +from firefly_weave.contracts.definitions import load_definition + +INVOICE = { + "type": "object", + "properties": {"number": {"type": "string"}, "customerEmail": {"type": "string", "title": "Customer email"}}, + "required": ["number"], + "additionalProperties": False, +} +INPUT = { + "type": "object", + "properties": {"invoices": {"type": "array", "items": INVOICE}, "batches": {"type": "array"}}, + "required": ["invoices", "batches"], + "additionalProperties": False, +} + + +def ref(pointer): + return {"ref": pointer} + + +def workflow(*steps, output=None, input_schema=None): + return { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "scope", "version": "1.0.0"}, + "spec": { + "inputSchema": input_schema or INPUT, + "outputSchema": {}, + "steps": list(steps), + "output": output or {"literal": None}, + }, + } + + +def loop(identifier, *steps, items=None, output=None, **options): + return { + "id": identifier, + "kind": "forEach", + "items": items or ref("/input/invoices"), + **options, + "body": {"steps": list(steps), "output": output or {"literal": None}}, + } + + +def transform(identifier, value): + return {"id": identifier, "kind": "transform", "value": value} + + +def rows(result): + return [ + (entry.ref, entry.source, entry.loop, entry.step_id, entry.label, entry.breadcrumb, entry.optional) + for entry in result.entries + ] + + +def entry(result, pointer): + return next(item for item in result.entries if item.ref == pointer) + + +LOOP_DOCUMENT = workflow( + transform("region", {"literal": "eu"}), + loop( + "notify", + transform("label", ref("/item/number")), + transform("send", {"object": {"to": ref("/item/customerEmail")}}), + maxItems=500, + output=ref("/steps/send/output"), + ), + output=ref("/steps/notify/output"), +) + + +def test_a_loop_body_sees_the_input_the_current_item_its_position_and_earlier_steps(): + result = scope_at(LOOP_DOCUMENT, "/spec/steps/1/body/steps/1/value/object/to") + assert (result.found, result.evaluated, result.truncated) == (True, True, False) + assert rows(result) == [ + ("/input", "input", None, None, "Workflow input", "Workflow input", False), + ("/input/invoices", "input", None, None, "invoices", "Workflow input › invoices", False), + ("/input/batches", "input", None, None, "batches", "Workflow input › batches", False), + ("/item", "item", "notify", None, "Current item", "Loop notify › Current item", False), + ("/item/number", "item", "notify", None, "number", "Loop notify › Current item › number", False), + ( + "/item/customerEmail", + "item", + "notify", + None, + "Customer email", + "Loop notify › Current item › Customer email", + True, + ), + ("/index", "index", "notify", None, "Item position (from 0)", "Loop notify › Item position", False), + ("/steps/region/output", "step", None, "region", "region", "region", False), + ("/steps/label/output", "step", None, "label", "label", "label", False), + ] + index = entry(result, "/index") + assert (index.schema_value, index.types, index.type_label) == ( + {"type": "integer", "minimum": 0, "maximum": 499}, + ["integer"], + "Whole number", + ) + item = entry(result, "/item") + assert (item.schema_value, item.types, item.type_label, item.path) == (INVOICE, ["object"], "Group", []) + assert entry(result, "/item/number").path == ["number"] + assert entry(result, "/input/invoices").type_label == "List of groups" + assert entry(result, "/steps/label/output").step_kind == "transform" + + +def test_entries_serialize_as_studio_scope_entries(): + dumped = scope_at(LOOP_DOCUMENT, "/spec/steps/1/body/output").model_dump(mode="json", by_alias=True) + assert dumped["entries"][0] == { + "ref": "/input", + "source": "input", + "path": [], + "label": "Workflow input", + "breadcrumb": "Workflow input", + "schema": INPUT, + "types": ["object"], + "typeLabel": "Group", + "optional": False, + } + step = next(item for item in dumped["entries"] if item["ref"] == "/steps/send/output") + assert (step["stepId"], step["stepKind"]) == ("send", "transform") + assert "loop" not in step + + +def test_after_a_loop_only_its_typed_output_is_visible(): + result = scope_at(LOOP_DOCUMENT, "/spec/output") + assert [item.ref for item in result.entries if item.source != "input"] == [ + "/steps/region/output", + "/steps/notify/output", + ] + output = entry(result, "/steps/notify/output") + assert output.schema_value["type"] == "array" and output.schema_value["maxItems"] == 500 + assert output.type_label == "List of groups" + + +def test_nested_loops_offer_the_innermost_item_first_and_outer_loops_by_id(): + inner = loop("line", transform("pair", ref("/item")), items=ref("/loops/batch/item"), maxItems=10) + document = workflow(loop("batch", inner, items=ref("/input/batches"), maxItems=10)) + result = scope_at(document, "/spec/steps/0/body/steps/0/body/steps/0/value") + assert [(item.ref, item.loop) for item in result.entries if item.source in {"item", "index"}] == [ + ("/item", "line"), + ("/index", "line"), + ("/loops/batch/item", "batch"), + ("/loops/batch/index", "batch"), + ] + assert entry(result, "/loops/batch/item").breadcrumb == "Loop batch › Current item" + # The items of the inner loop are read where the outer loop is the innermost one. + items = scope_at(document, "/spec/steps/0/body/steps/0/items") + assert [(item.ref, item.loop) for item in items.entries if item.source == "item"] == [("/item", "batch")] + + +def test_positions_outside_loops_offer_no_loop_entries(): + result = scope_at(workflow(transform("t", ref("/input"))), "/spec/steps/0/value") + assert [item.ref for item in result.entries] == ["/input", "/input/invoices", "/input/batches"] + + +def test_positions_that_are_not_analyzed_expressions_are_not_found(): + for document, pointer in ( + (LOOP_DOCUMENT, "/spec/steps/1/maxItems"), + (LOOP_DOCUMENT, "/spec/steps/9/value"), + ({"kind": "Workflow"}, "/spec/output"), + (workflow(transform("t", ref("/input")), transform("t", ref("/input"))), "/spec/steps/1/value"), + ): + assert scope_at(document, pointer).model_dump(mode="json", by_alias=True) == { + "found": False, + "evaluated": False, + "truncated": False, + "entries": [], + } + + +def test_an_output_after_a_failure_is_offered_but_not_evaluated(): + stop = {"id": "stop", "kind": "fail", "code": "nope", "message": "Stops."} + decision = { + "id": "route", + "kind": "switch", + "cases": [{"when": {"literal": True}, "steps": [stop], "output": ref("/input")}], + "default": {"steps": [], "output": {"literal": None}}, + } + result = scope_at(workflow(decision), "/spec/steps/0/cases/0/output") + assert (result.found, result.evaluated) == (True, False) + + +def test_a_catalog_types_action_outputs_and_without_one_they_are_any_value(): + action = load_definition( + { + "apiVersion": "weave/v1alpha1", + "kind": "Action", + "metadata": {"name": "lookup", "version": "1.0.0"}, + "spec": { + "implementation": {"kind": "worker", "taskType": "lookup", "taskVersion": "1.0.0"}, + "inputSchema": {}, + "outputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}, + "sideEffect": "read_only", + "timeoutSeconds": 5, + }, + } + ) + task = { + "taskType": "lookup", + "taskVersion": "1.0.0", + "inputSchema": {}, + "outputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}, + "sideEffect": "read_only", + "timeoutSeconds": 5, + } + document = workflow( + {"id": "find", "kind": "action", "uses": "lookup@1.0.0", "with": {"literal": {}}}, + transform("t", ref("/steps/find/output")), + ) + offline = scope_at(document, "/spec/steps/1/value") + assert entry(offline, "/steps/find/output").type_label == "Any value" + catalog = CatalogSnapshot.from_definitions([action], tasks=[task]) + online = scope_at(document, "/spec/steps/1/value", catalog=catalog) + assert entry(online, "/steps/find/output/id").type_label == "Text" + + +def test_large_inputs_are_truncated_at_the_entry_budget(): + wide = {"type": "object", "properties": {f"field{n:03}": {"type": "string"} for n in range(250)}} + result = scope_at(workflow(transform("t", ref("/input")), input_schema=wide), "/spec/steps/0/value") + assert result.truncated and len(result.entries) == MAX_ENTRIES diff --git a/tests/unit/compiler/test_scope_contract.py b/tests/unit/compiler/test_scope_contract.py index 7dc39124..5be5d37a 100644 --- a/tests/unit/compiler/test_scope_contract.py +++ b/tests/unit/compiler/test_scope_contract.py @@ -14,7 +14,7 @@ # Author: Firefly Software Foundation # SPDX-License-Identifier: Apache-2.0 -"""The frozen scope_at signature and result shape; the analysis itself arrives in a later release.""" +"""The frozen scope_at signature and result shape.""" import inspect @@ -97,6 +97,6 @@ def test_malformed_entries_are_rejected(entry): ScopeEntry.model_validate(entry) -def test_analysis_is_not_available_before_the_loop_compiler(): - with pytest.raises(NotImplementedError): - scope_at({}, "/spec/output", catalog=CatalogSnapshot.empty()) +def test_a_document_the_compiler_cannot_analyze_has_no_scope(): + result = scope_at({}, "/spec/output", catalog=CatalogSnapshot.empty()) + assert (result.found, result.entries) == (False, []) diff --git a/tests/unit/test_studio_host.py b/tests/unit/test_studio_host.py index 77b6e814..c8f1fb57 100644 --- a/tests/unit/test_studio_host.py +++ b/tests/unit/test_studio_host.py @@ -343,3 +343,38 @@ def test_brand_files_are_served_from_the_studio_origin_and_license_texts_are_not # Only the allowlisted suffixes are served: license texts and a web manifest in the bundle are not. assert browser.get("/licenses/NOTICE.txt").status_code == 404 assert browser.get("/site.webmanifest").status_code == 404 + + +def test_local_scope_suggests_loop_data_for_a_paired_browser(tmp_path): + source = """ +apiVersion: weave/v1alpha1 +kind: Workflow +metadata: {name: notify, version: 1.0.0} +spec: + inputSchema: {type: array, items: {type: string}} + outputSchema: {} + steps: + - id: notify + kind: forEach + items: {ref: /input} + body: {steps: [], output: {ref: /item}} + output: {ref: /steps/notify/output} +""" + payload = {"source": source, "format": "yaml", "path": "/spec/steps/0/body/output"} + with client(tmp_path) as browser: + assert browser.post("/studio/local/scope", json=payload, headers={"Origin": ORIGIN}).status_code == 401 + headers = pair(browser) + response = browser.post("/studio/local/scope", json=payload, headers=headers) + assert response.status_code == 200 + value = response.json() + assert value["found"] is True and value["evaluated"] is True + assert [(item["ref"], item.get("loop")) for item in value["entries"]] == [ + ("/input", None), + ("/item", "notify"), + ("/index", "notify"), + ] + assert value["entries"][1]["typeLabel"] == "Text" + missing = browser.post("/studio/local/scope", json={**payload, "source": "- a list"}, headers=headers) + assert missing.status_code == 200 and missing.json()["found"] is False + for bad in ({**payload, "path": "spec"}, {**payload, "format": "xml"}, {**payload, "extra": 1}): + assert browser.post("/studio/local/scope", json=bad, headers=headers).status_code == 422 From 5f84ac7adaf37ec026ae81a8989bd0c3b5040e3b Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:53:12 -0700 Subject: [PATCH 6/9] Share scope suggestion fixtures with Studio and explain loop diagnostics in plain language --- docs/contributing/source-inventory.toml | 9 + scripts/language_fixtures.py | 135 +- studio/src/app/designer/diagnostic-copy.ts | 34 +- studio/tests/diagnostic-copy.test.ts | 8 + studio/tests/fixtures/language/scope.json | 2101 +++++++++++++++++ studio/tests/language-fixtures.test.ts | 38 + tests/unit/compiler/test_language_fixtures.py | 7 + 7 files changed, 2328 insertions(+), 4 deletions(-) create mode 100644 studio/tests/fixtures/language/scope.json diff --git a/docs/contributing/source-inventory.toml b/docs/contributing/source-inventory.toml index b39de934..367cc041 100644 --- a/docs/contributing/source-inventory.toml +++ b/docs/contributing/source-inventory.toml @@ -469,6 +469,15 @@ copyright = "Copyright 2026 Firefly Software Foundation." author = "Firefly Software Foundation" license = "Apache-2.0" +[[exceptions]] +path = "studio/tests/fixtures/language/scope.json" +kind = "generated" +source = "scripts/language_fixtures.py" +reason = "Strict JSON scope suggestion cases inside and around loops shared by the Python and Studio suites; regenerate with the generator, which also checks freshness." +copyright = "Copyright 2026 Firefly Software Foundation." +author = "Firefly Software Foundation" +license = "Apache-2.0" + [[exceptions]] path = "studio/tests/fixtures/language/template-segments.json" kind = "generated" diff --git a/scripts/language_fixtures.py b/scripts/language_fixtures.py index d917208b..ee146ceb 100644 --- a/scripts/language_fixtures.py +++ b/scripts/language_fixtures.py @@ -18,9 +18,10 @@ The fixtures pin the stable language contracts: instance-key parse and format cases with the ``INSTANCE_KEY_PATTERN`` expression, the text conversion table for ``concat`` and ``join``, template segments -with their saved form, and the language manifest snapshot. Expected values are written here by hand; the -generator refuses to write a fixture that the implementation disagrees with. Later releases add ``scope_at`` -cases. +with their saved form, ``scope_at`` suggestions inside and around loops, and the language manifest snapshot. +Expected values are written here by hand; the generator refuses to write a fixture that the implementation +disagrees with. Scope cases pin each suggestion's reference, source, loop and type label by hand and record the +rest of each entry as ``scope_at`` returns it. Run ``uv run --locked --all-extras python scripts/language_fixtures.py`` to regenerate the files, or add ``--check`` to fail when they are out of date. @@ -36,6 +37,7 @@ import rfc8785 from firefly_weave.compiler.expressions import text_of as evaluator_text +from firefly_weave.compiler.scope import scope_at from firefly_weave.compiler.templates import Segment, template_expression from firefly_weave.compiler.templates import segments as template_segments from firefly_weave.contracts.instance_keys import ( @@ -232,6 +234,121 @@ def _concat(*args: dict[str, Any]) -> dict[str, Any]: ) +_INVOICE = { + "type": "object", + "properties": {"number": {"type": "string"}, "customerEmail": {"type": "string", "title": "Customer email"}}, + "required": ["number"], + "additionalProperties": False, +} + + +def _workflow(*steps: dict[str, Any], output: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": {"name": "scope-cases", "version": "1.0.0"}, + "spec": { + "inputSchema": { + "type": "object", + "properties": { + "invoices": {"type": "array", "items": _INVOICE}, + "batches": {"type": "array", "items": {"type": "array", "items": _INVOICE}}, + }, + "required": ["invoices", "batches"], + "additionalProperties": False, + }, + "outputSchema": {}, + "steps": list(steps), + "output": output or {"literal": None}, + }, + } + + +def _loop(identifier: str, items: str, *steps: dict[str, Any], **options: Any) -> dict[str, Any]: + return { + "id": identifier, + "kind": "forEach", + "items": {"ref": items}, + **options, + "body": {"steps": list(steps), "output": {"ref": "/item"}}, + } + + +_LABEL = {"id": "label", "kind": "transform", "value": _concat({"literal": "Invoice "}, {"ref": "/item/number"})} +_NOTIFY = _loop( + "notify", "/input/invoices", _LABEL, {"id": "send", "kind": "transform", "value": {"ref": "/item"}}, maxItems=500 +) +_LINES = _loop( + "line", "/item", {"id": "pair", "kind": "transform", "value": {"ref": "/loops/batch/index"}}, maxItems=50 +) +_INPUT_ROWS = [ + ("/input", "input", None, "Group"), + ("/input/invoices", "input", None, "List of groups"), + ("/input/batches", "input", None, "List of lists"), +] +_ITEM_ROWS = [ + ("/item", "item", "notify", "Group"), + ("/item/number", "item", "notify", "Text"), + ("/item/customerEmail", "item", "notify", "Text"), + ("/index", "index", "notify", "Whole number"), +] +# (name, document, path, (ref, source, loop, typeLabel) per entry, or None when the position is not found) +SCOPE_CASES: tuple[tuple[str, dict[str, Any], str, list[tuple[str, str, str | None, str]] | None], ...] = ( + ( + "a loop body sees the current item, its position and earlier steps", + _workflow(_NOTIFY), + "/spec/steps/0/body/steps/1/value", + [*_INPUT_ROWS, *_ITEM_ROWS, ("/steps/label/output", "step", None, "Text")], + ), + ( + "the loop result per item sees every step of the iteration", + _workflow(_NOTIFY), + "/spec/steps/0/body/output", + [ + *_INPUT_ROWS, + *_ITEM_ROWS, + ("/steps/label/output", "step", None, "Text"), + ("/steps/send/output", "step", None, "Group"), + ("/steps/send/output/number", "step", None, "Text"), + ("/steps/send/output/customerEmail", "step", None, "Text"), + ], + ), + ( + "loop items see what is visible before the loop, not the loop's own item", + _workflow(_NOTIFY), + "/spec/steps/0/items", + _INPUT_ROWS, + ), + ( + "after a loop only its list of results is visible", + _workflow(_NOTIFY, output={"ref": "/steps/notify/output"}), + "/spec/output", + [*_INPUT_ROWS, ("/steps/notify/output", "step", None, "List of groups")], + ), + ( + "an inner loop reads its own item and outer loops by ID", + _workflow(_loop("batch", "/input/batches", _LINES, maxItems=20)), + "/spec/steps/0/body/steps/0/body/steps/0/value", + [ + *_INPUT_ROWS, + ("/item", "item", "line", "Group"), + ("/item/number", "item", "line", "Text"), + ("/item/customerEmail", "item", "line", "Text"), + ("/index", "index", "line", "Whole number"), + ("/loops/batch/item", "item", "batch", "List of groups"), + ("/loops/batch/index", "index", "batch", "Whole number"), + ], + ), + ( + "inner loop items read the outer item", + _workflow(_loop("batch", "/input/batches", _LINES, maxItems=20)), + "/spec/steps/0/body/steps/0/items", + [*_INPUT_ROWS, ("/item", "item", "batch", "List of groups"), ("/index", "index", "batch", "Whole number")], + ), + ("a field that is not an expression has no scope", _workflow(_NOTIFY), "/spec/steps/0/maxItems", None), +) + + def text_of(value: str | bool | int | float) -> str: """The reference conversion of a value to text for ``concat`` and ``join``.""" if isinstance(value, str): @@ -317,6 +434,17 @@ def template_cases() -> dict[str, Any]: return {"generator": GENERATOR, "rule": "concat template segments", "cases": cases} +def scope_cases() -> dict[str, Any]: + cases = [] + for name, document, path, expected in SCOPE_CASES: + result = scope_at(document, path).model_dump(mode="json", by_alias=True) + actual = [(e["ref"], e["source"], e.get("loop"), e["typeLabel"]) for e in result["entries"]] + if result["found"] is (expected is None) or (expected is not None and actual != expected): + raise SystemExit(f"Implementation disagrees with the scope case {name!r}") + cases.append({"name": name, "document": document, "path": path, "result": result}) + return {"generator": GENERATOR, "rule": "scope suggestions", "cases": cases} + + def manifest_snapshot() -> dict[str, Any]: return {"generator": GENERATOR, "manifest": language_manifest().model_dump(mode="json")} @@ -326,6 +454,7 @@ def render() -> dict[str, str]: "instance-keys.json": instance_key_cases(), "text-conversion.json": conversion_cases(), "template-segments.json": template_cases(), + "scope.json": scope_cases(), "manifest.json": manifest_snapshot(), } return {name: json.dumps(value, indent=2, ensure_ascii=False) + "\n" for name, value in files.items()} diff --git a/studio/src/app/designer/diagnostic-copy.ts b/studio/src/app/designer/diagnostic-copy.ts index a5429b73..2b47c864 100644 --- a/studio/src/app/designer/diagnostic-copy.ts +++ b/studio/src/app/designer/diagnostic-copy.ts @@ -120,7 +120,39 @@ const compiler: Record = { text: "Branches are nested deeper than the platform allows.", }, CONCURRENCY_LIMIT: { - text: "The parallel limit is higher than the platform allows.", + text: "More steps would run at the same time than the platform allows.", + }, + LOOP_ITEMS: { + text: "A loop needs a list to go through.", + hint: "Choose a list, such as the rows an earlier step returns.", + }, + LOOP_SCOPE: { + text: "This uses a loop's current item outside that loop.", + hint: "Use the current item only in the loop's own steps, or choose a loop that contains this step.", + }, + LOOP_SIGNAL: { + text: "A loop can't wait for a signal.", + hint: "Move the wait before or after the loop.", + }, + LOOP_BODY_INCOMPLETE: { + text: "The steps inside this loop never finish, so the run would stop at the first item.", + hint: "Finish the loop's steps with a result for each item.", + }, + LOOP_DEPTH: { + text: "Loops are nested deeper than the platform allows.", + hint: "Put fewer loops inside one another.", + }, + LOOP_MAX_ITEMS: { + text: "This loop accepts more items than the platform allows.", + hint: "Lower the number of items in Up to.", + }, + LOOP_BUDGET: { + text: "These loops together could go through more items than one run allows.", + hint: "Lower Up to on an inner loop.", + }, + LOOP_FANOUT: { + text: "These nested loops could start more than 1,000 tasks at once, so the work is spread over time.", + hint: "Lower At a time on one of the loops if the tasks must start together.", }, RESOURCE_LIMIT: { text: "This part of the definition is too large or complex to check.", diff --git a/studio/tests/diagnostic-copy.test.ts b/studio/tests/diagnostic-copy.test.ts index f2c98e3a..58a36976 100644 --- a/studio/tests/diagnostic-copy.test.ts +++ b/studio/tests/diagnostic-copy.test.ts @@ -55,6 +55,14 @@ const compilerCodes = [ "INPUT_CONTRACT", "OUTPUT_CONTRACT", "ROUTING_CONTRACT", + "LOOP_ITEMS", + "LOOP_SCOPE", + "LOOP_SIGNAL", + "LOOP_BODY_INCOMPLETE", + "LOOP_DEPTH", + "LOOP_MAX_ITEMS", + "LOOP_BUDGET", + "LOOP_FANOUT", ].map((code) => `WV-COMP-${code}`); describe("plain-language diagnostic copy", () => { diff --git a/studio/tests/fixtures/language/scope.json b/studio/tests/fixtures/language/scope.json new file mode 100644 index 00000000..b50c7f2f --- /dev/null +++ b/studio/tests/fixtures/language/scope.json @@ -0,0 +1,2101 @@ +{ + "generator": "scripts/language_fixtures.py", + "rule": "scope suggestions", + "cases": [ + { + "name": "a loop body sees the current item, its position and earlier steps", + "document": { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": { + "name": "scope-cases", + "version": "1.0.0" + }, + "spec": { + "inputSchema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "outputSchema": {}, + "steps": [ + { + "id": "notify", + "kind": "forEach", + "items": { + "ref": "/input/invoices" + }, + "maxItems": 500, + "body": { + "steps": [ + { + "id": "label", + "kind": "transform", + "value": { + "op": { + "name": "concat", + "args": [ + { + "literal": "Invoice " + }, + { + "ref": "/item/number" + } + ] + } + } + }, + { + "id": "send", + "kind": "transform", + "value": { + "ref": "/item" + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "literal": null + } + } + }, + "path": "/spec/steps/0/body/steps/1/value", + "result": { + "found": true, + "evaluated": true, + "truncated": false, + "entries": [ + { + "ref": "/input", + "source": "input", + "path": [], + "label": "Workflow input", + "breadcrumb": "Workflow input", + "schema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/input/invoices", + "source": "input", + "path": [ + "invoices" + ], + "label": "invoices", + "breadcrumb": "Workflow input › invoices", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + }, + { + "ref": "/input/batches", + "source": "input", + "path": [ + "batches" + ], + "label": "batches", + "breadcrumb": "Workflow input › batches", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + }, + "types": [ + "array" + ], + "typeLabel": "List of lists", + "optional": false + }, + { + "ref": "/item", + "source": "item", + "loop": "notify", + "path": [], + "label": "Current item", + "breadcrumb": "Loop notify › Current item", + "schema": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/item/number", + "source": "item", + "loop": "notify", + "path": [ + "number" + ], + "label": "number", + "breadcrumb": "Loop notify › Current item › number", + "schema": { + "type": "string" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": false + }, + { + "ref": "/item/customerEmail", + "source": "item", + "loop": "notify", + "path": [ + "customerEmail" + ], + "label": "Customer email", + "breadcrumb": "Loop notify › Current item › Customer email", + "schema": { + "type": "string", + "title": "Customer email" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": true + }, + { + "ref": "/index", + "source": "index", + "loop": "notify", + "path": [], + "label": "Item position (from 0)", + "breadcrumb": "Loop notify › Item position", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 499 + }, + "types": [ + "integer" + ], + "typeLabel": "Whole number", + "optional": false + }, + { + "ref": "/steps/label/output", + "source": "step", + "stepId": "label", + "stepKind": "transform", + "path": [], + "label": "label", + "breadcrumb": "label", + "schema": { + "type": "string" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": false + } + ] + } + }, + { + "name": "the loop result per item sees every step of the iteration", + "document": { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": { + "name": "scope-cases", + "version": "1.0.0" + }, + "spec": { + "inputSchema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "outputSchema": {}, + "steps": [ + { + "id": "notify", + "kind": "forEach", + "items": { + "ref": "/input/invoices" + }, + "maxItems": 500, + "body": { + "steps": [ + { + "id": "label", + "kind": "transform", + "value": { + "op": { + "name": "concat", + "args": [ + { + "literal": "Invoice " + }, + { + "ref": "/item/number" + } + ] + } + } + }, + { + "id": "send", + "kind": "transform", + "value": { + "ref": "/item" + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "literal": null + } + } + }, + "path": "/spec/steps/0/body/output", + "result": { + "found": true, + "evaluated": true, + "truncated": false, + "entries": [ + { + "ref": "/input", + "source": "input", + "path": [], + "label": "Workflow input", + "breadcrumb": "Workflow input", + "schema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/input/invoices", + "source": "input", + "path": [ + "invoices" + ], + "label": "invoices", + "breadcrumb": "Workflow input › invoices", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + }, + { + "ref": "/input/batches", + "source": "input", + "path": [ + "batches" + ], + "label": "batches", + "breadcrumb": "Workflow input › batches", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + }, + "types": [ + "array" + ], + "typeLabel": "List of lists", + "optional": false + }, + { + "ref": "/item", + "source": "item", + "loop": "notify", + "path": [], + "label": "Current item", + "breadcrumb": "Loop notify › Current item", + "schema": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/item/number", + "source": "item", + "loop": "notify", + "path": [ + "number" + ], + "label": "number", + "breadcrumb": "Loop notify › Current item › number", + "schema": { + "type": "string" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": false + }, + { + "ref": "/item/customerEmail", + "source": "item", + "loop": "notify", + "path": [ + "customerEmail" + ], + "label": "Customer email", + "breadcrumb": "Loop notify › Current item › Customer email", + "schema": { + "type": "string", + "title": "Customer email" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": true + }, + { + "ref": "/index", + "source": "index", + "loop": "notify", + "path": [], + "label": "Item position (from 0)", + "breadcrumb": "Loop notify › Item position", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 499 + }, + "types": [ + "integer" + ], + "typeLabel": "Whole number", + "optional": false + }, + { + "ref": "/steps/label/output", + "source": "step", + "stepId": "label", + "stepKind": "transform", + "path": [], + "label": "label", + "breadcrumb": "label", + "schema": { + "type": "string" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": false + }, + { + "ref": "/steps/send/output", + "source": "step", + "stepId": "send", + "stepKind": "transform", + "path": [], + "label": "send", + "breadcrumb": "send", + "schema": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/steps/send/output/number", + "source": "step", + "stepId": "send", + "stepKind": "transform", + "path": [ + "number" + ], + "label": "number", + "breadcrumb": "send › number", + "schema": { + "type": "string" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": false + }, + { + "ref": "/steps/send/output/customerEmail", + "source": "step", + "stepId": "send", + "stepKind": "transform", + "path": [ + "customerEmail" + ], + "label": "Customer email", + "breadcrumb": "send › Customer email", + "schema": { + "type": "string", + "title": "Customer email" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": true + } + ] + } + }, + { + "name": "loop items see what is visible before the loop, not the loop's own item", + "document": { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": { + "name": "scope-cases", + "version": "1.0.0" + }, + "spec": { + "inputSchema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "outputSchema": {}, + "steps": [ + { + "id": "notify", + "kind": "forEach", + "items": { + "ref": "/input/invoices" + }, + "maxItems": 500, + "body": { + "steps": [ + { + "id": "label", + "kind": "transform", + "value": { + "op": { + "name": "concat", + "args": [ + { + "literal": "Invoice " + }, + { + "ref": "/item/number" + } + ] + } + } + }, + { + "id": "send", + "kind": "transform", + "value": { + "ref": "/item" + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "literal": null + } + } + }, + "path": "/spec/steps/0/items", + "result": { + "found": true, + "evaluated": true, + "truncated": false, + "entries": [ + { + "ref": "/input", + "source": "input", + "path": [], + "label": "Workflow input", + "breadcrumb": "Workflow input", + "schema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/input/invoices", + "source": "input", + "path": [ + "invoices" + ], + "label": "invoices", + "breadcrumb": "Workflow input › invoices", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + }, + { + "ref": "/input/batches", + "source": "input", + "path": [ + "batches" + ], + "label": "batches", + "breadcrumb": "Workflow input › batches", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + }, + "types": [ + "array" + ], + "typeLabel": "List of lists", + "optional": false + } + ] + } + }, + { + "name": "after a loop only its list of results is visible", + "document": { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": { + "name": "scope-cases", + "version": "1.0.0" + }, + "spec": { + "inputSchema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "outputSchema": {}, + "steps": [ + { + "id": "notify", + "kind": "forEach", + "items": { + "ref": "/input/invoices" + }, + "maxItems": 500, + "body": { + "steps": [ + { + "id": "label", + "kind": "transform", + "value": { + "op": { + "name": "concat", + "args": [ + { + "literal": "Invoice " + }, + { + "ref": "/item/number" + } + ] + } + } + }, + { + "id": "send", + "kind": "transform", + "value": { + "ref": "/item" + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "ref": "/steps/notify/output" + } + } + }, + "path": "/spec/output", + "result": { + "found": true, + "evaluated": true, + "truncated": false, + "entries": [ + { + "ref": "/input", + "source": "input", + "path": [], + "label": "Workflow input", + "breadcrumb": "Workflow input", + "schema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/input/invoices", + "source": "input", + "path": [ + "invoices" + ], + "label": "invoices", + "breadcrumb": "Workflow input › invoices", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + }, + { + "ref": "/input/batches", + "source": "input", + "path": [ + "batches" + ], + "label": "batches", + "breadcrumb": "Workflow input › batches", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + }, + "types": [ + "array" + ], + "typeLabel": "List of lists", + "optional": false + }, + { + "ref": "/steps/notify/output", + "source": "step", + "stepId": "notify", + "stepKind": "forEach", + "path": [], + "label": "notify", + "breadcrumb": "notify", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + }, + "maxItems": 500 + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + } + ] + } + }, + { + "name": "an inner loop reads its own item and outer loops by ID", + "document": { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": { + "name": "scope-cases", + "version": "1.0.0" + }, + "spec": { + "inputSchema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "outputSchema": {}, + "steps": [ + { + "id": "batch", + "kind": "forEach", + "items": { + "ref": "/input/batches" + }, + "maxItems": 20, + "body": { + "steps": [ + { + "id": "line", + "kind": "forEach", + "items": { + "ref": "/item" + }, + "maxItems": 50, + "body": { + "steps": [ + { + "id": "pair", + "kind": "transform", + "value": { + "ref": "/loops/batch/index" + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "literal": null + } + } + }, + "path": "/spec/steps/0/body/steps/0/body/steps/0/value", + "result": { + "found": true, + "evaluated": true, + "truncated": false, + "entries": [ + { + "ref": "/input", + "source": "input", + "path": [], + "label": "Workflow input", + "breadcrumb": "Workflow input", + "schema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/input/invoices", + "source": "input", + "path": [ + "invoices" + ], + "label": "invoices", + "breadcrumb": "Workflow input › invoices", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + }, + { + "ref": "/input/batches", + "source": "input", + "path": [ + "batches" + ], + "label": "batches", + "breadcrumb": "Workflow input › batches", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + }, + "types": [ + "array" + ], + "typeLabel": "List of lists", + "optional": false + }, + { + "ref": "/item", + "source": "item", + "loop": "line", + "path": [], + "label": "Current item", + "breadcrumb": "Loop line › Current item", + "schema": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/item/number", + "source": "item", + "loop": "line", + "path": [ + "number" + ], + "label": "number", + "breadcrumb": "Loop line › Current item › number", + "schema": { + "type": "string" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": false + }, + { + "ref": "/item/customerEmail", + "source": "item", + "loop": "line", + "path": [ + "customerEmail" + ], + "label": "Customer email", + "breadcrumb": "Loop line › Current item › Customer email", + "schema": { + "type": "string", + "title": "Customer email" + }, + "types": [ + "string" + ], + "typeLabel": "Text", + "optional": true + }, + { + "ref": "/index", + "source": "index", + "loop": "line", + "path": [], + "label": "Item position (from 0)", + "breadcrumb": "Loop line › Item position", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 49 + }, + "types": [ + "integer" + ], + "typeLabel": "Whole number", + "optional": false + }, + { + "ref": "/loops/batch/item", + "source": "item", + "loop": "batch", + "path": [], + "label": "Current item", + "breadcrumb": "Loop batch › Current item", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + }, + { + "ref": "/loops/batch/index", + "source": "index", + "loop": "batch", + "path": [], + "label": "Item position (from 0)", + "breadcrumb": "Loop batch › Item position", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 19 + }, + "types": [ + "integer" + ], + "typeLabel": "Whole number", + "optional": false + } + ] + } + }, + { + "name": "inner loop items read the outer item", + "document": { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": { + "name": "scope-cases", + "version": "1.0.0" + }, + "spec": { + "inputSchema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "outputSchema": {}, + "steps": [ + { + "id": "batch", + "kind": "forEach", + "items": { + "ref": "/input/batches" + }, + "maxItems": 20, + "body": { + "steps": [ + { + "id": "line", + "kind": "forEach", + "items": { + "ref": "/item" + }, + "maxItems": 50, + "body": { + "steps": [ + { + "id": "pair", + "kind": "transform", + "value": { + "ref": "/loops/batch/index" + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "literal": null + } + } + }, + "path": "/spec/steps/0/body/steps/0/items", + "result": { + "found": true, + "evaluated": true, + "truncated": false, + "entries": [ + { + "ref": "/input", + "source": "input", + "path": [], + "label": "Workflow input", + "breadcrumb": "Workflow input", + "schema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "types": [ + "object" + ], + "typeLabel": "Group", + "optional": false + }, + { + "ref": "/input/invoices", + "source": "input", + "path": [ + "invoices" + ], + "label": "invoices", + "breadcrumb": "Workflow input › invoices", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + }, + { + "ref": "/input/batches", + "source": "input", + "path": [ + "batches" + ], + "label": "batches", + "breadcrumb": "Workflow input › batches", + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + }, + "types": [ + "array" + ], + "typeLabel": "List of lists", + "optional": false + }, + { + "ref": "/item", + "source": "item", + "loop": "batch", + "path": [], + "label": "Current item", + "breadcrumb": "Loop batch › Current item", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "types": [ + "array" + ], + "typeLabel": "List of groups", + "optional": false + }, + { + "ref": "/index", + "source": "index", + "loop": "batch", + "path": [], + "label": "Item position (from 0)", + "breadcrumb": "Loop batch › Item position", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 19 + }, + "types": [ + "integer" + ], + "typeLabel": "Whole number", + "optional": false + } + ] + } + }, + { + "name": "a field that is not an expression has no scope", + "document": { + "apiVersion": "weave/v1alpha1", + "kind": "Workflow", + "metadata": { + "name": "scope-cases", + "version": "1.0.0" + }, + "spec": { + "inputSchema": { + "type": "object", + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + }, + "batches": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "string" + }, + "customerEmail": { + "type": "string", + "title": "Customer email" + } + }, + "required": [ + "number" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "invoices", + "batches" + ], + "additionalProperties": false + }, + "outputSchema": {}, + "steps": [ + { + "id": "notify", + "kind": "forEach", + "items": { + "ref": "/input/invoices" + }, + "maxItems": 500, + "body": { + "steps": [ + { + "id": "label", + "kind": "transform", + "value": { + "op": { + "name": "concat", + "args": [ + { + "literal": "Invoice " + }, + { + "ref": "/item/number" + } + ] + } + } + }, + { + "id": "send", + "kind": "transform", + "value": { + "ref": "/item" + } + } + ], + "output": { + "ref": "/item" + } + } + } + ], + "output": { + "literal": null + } + } + }, + "path": "/spec/steps/0/maxItems", + "result": { + "found": false, + "evaluated": false, + "truncated": false, + "entries": [] + } + } + ] +} diff --git a/studio/tests/language-fixtures.test.ts b/studio/tests/language-fixtures.test.ts index a9344119..5707992a 100644 --- a/studio/tests/language-fixtures.test.ts +++ b/studio/tests/language-fixtures.test.ts @@ -21,6 +21,7 @@ SPDX-License-Identifier: Apache-2.0 import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { schemaTypes, typeLabel } from "../src/app/forms/core/scope"; const fixture = (name: string) => JSON.parse( @@ -105,6 +106,43 @@ describe("shared language fixtures", () => { }); } }); + it("names loop data by the loop that owns it and labels types as Studio does", () => { + type Entry = { + ref: string; + source: string; + loop?: string; + path: string[]; + schema: Record; + types: string[]; + typeLabel: string; + }; + const { cases } = fixture("scope.json") as { + cases: { + name: string; + result: { found: boolean; entries: Entry[] }; + }[]; + }; + expect(cases.length).toBeGreaterThan(5); + expect(cases.some(({ result }) => !result.found)).toBe(true); + for (const { name, result } of cases) { + const loops = result.entries.filter((entry) => entry.loop !== undefined); + // The innermost loop is read as /item and /index; enclosing loops by ID. + const innermost = loops.find((entry) => entry.ref === "/item")?.loop; + for (const entry of result.entries) { + expect(typeLabel(entry.schema), `${name}: ${entry.ref}`).toBe( + entry.typeLabel, + ); + expect(schemaTypes(entry.schema), `${name}: ${entry.ref}`).toEqual( + entry.types, + ); + if (entry.source === "item" || entry.source === "index") { + const base = entry.loop === innermost ? "" : `/loops/${entry.loop}`; + const root = `${base}/${entry.source}`; + expect(entry.ref, name).toBe([root, ...entry.path].join("/")); + } else expect(entry.loop, name).toBeUndefined(); + } + } + }); it("publishes the manifest with Studio marks on every entry", () => { const { manifest } = fixture("manifest.json") as { manifest: Record; diff --git a/tests/unit/compiler/test_language_fixtures.py b/tests/unit/compiler/test_language_fixtures.py index 22db4483..da92978b 100644 --- a/tests/unit/compiler/test_language_fixtures.py +++ b/tests/unit/compiler/test_language_fixtures.py @@ -24,6 +24,7 @@ import pytest import rfc8785 +from firefly_weave.compiler.scope import scope_at from firefly_weave.contracts.instance_keys import ( INSTANCE_KEY_PATTERN, InstanceKey, @@ -47,6 +48,7 @@ def test_committed_fixtures_are_what_the_generator_writes_today(): assert sorted(rendered) == [ "instance-keys.json", "manifest.json", + "scope.json", "template-segments.json", "text-conversion.json", ] @@ -85,6 +87,11 @@ def test_text_conversion_cases_follow_ecmascript_number_formatting(case): assert case["text"] == expected +@pytest.mark.parametrize("case", fixture("scope.json")["cases"], ids=lambda case: case["name"]) +def test_scope_cases_are_what_scope_at_returns(case): + assert scope_at(case["document"], case["path"]).model_dump(mode="json", by_alias=True) == case["result"] + + def test_manifest_snapshot_is_the_served_manifest(): assert fixture("manifest.json")["manifest"] == language_manifest().model_dump(mode="json") From abc81e8ec40ceb31841c426f4ea9345e4f52dde5 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:59:56 -0700 Subject: [PATCH 7/9] Document loop scope, typing, limits, IR and data suggestions --- CHANGELOG.md | 11 +++++++ docs/concepts.md | 2 +- docs/contracts.md | 31 +++++++++++++++--- docs/reference/compiler.md | 47 +++++++++++++++++++++++++--- docs/reference/history-and-replay.md | 4 ++- 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fcba1d..25b98ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,17 @@ SPDX-License-Identifier: Apache-2.0 ## Unreleased +- The compiler checks and compiles **Loop over items** (`forEach`): `/item`, + `/index`, and `/loops//…` inside a loop body, a typed list of results after + it, `WV-COMP-LOOP_*` diagnostics for wrong items, scope, nesting, limits, and + signals in a body (with a suggested `maxItems` for a loop budget that is too + high), and an IR `forEach` node with an `each` edge and an `each` join that + requires the `flow.forEach` feature. This platform does not run loops yet, so + it refuses to publish, import, or activate such a workflow with + `WV-IR-UNSUPPORTED`; workflows without loops compile to the same bytes as + before. The compiler limits add `max_loop_items`, `max_loop_depth`, and + `max_run_iterations`. Studio's local host answers `POST /studio/local/scope` + with the data a field may use, including the current loop item and position. - Run state, history, tasks, deadlines, and incidents name each run of a step by its instance key, which equals the step ID outside loops, so existing workflows keep byte-identical transitions and history. Deadline scans, incident diff --git a/docs/concepts.md b/docs/concepts.md index ed7735b4..e02896f1 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -306,7 +306,7 @@ Use this table to translate what you know: | Error boundary event | Not in the language | A failed Action retries when that is safe; otherwise the run is suspended with an incident for an operator. To branch on a business outcome, return it in the Action's output and test it with a Decision | | Message throw or end event | **Call an action**, or an integration event subscription | A subscription notifies another system when a run's status changes | | Call activity, subprocess | **Call a workflow** (`callWorkflow`) | Defined in the [workflow language](contracts.md#steps), but this version does not compile or run it: the compiler reports `WV-COMP-UNSUPPORTED_FEATURE`. Until then, keep the part inside the same workflow, or make it a separate workflow that your product starts | -| Loop, multi-instance activity | **Loop over items** (`forEach`) | Defined in the [workflow language](contracts.md#steps), but this version does not compile or run it: the compiler reports `WV-COMP-UNSUPPORTED_FEATURE`. Until then, use a Parallel with a fixed set of branches, or start one run per item | +| Loop, multi-instance activity | **Loop over items** (`forEach`) | Defined in the [workflow language](contracts.md#steps). This version compiles and checks it, but the platform does not run it yet and refuses to publish it (`WV-IR-UNSUPPORTED`). Until then, use a Parallel with a fixed set of branches, or start one run per item | | Compensation | Not in the language | Model compensating Actions as ordinary steps; Weave never undoes an external effect | | Instance migration | Not available | A running run keeps its version; new runs use the new activation | | Process monitoring, cockpit | **Runs**, history, and incidents in Studio and the CLI; logs, metrics, and traces | Studio draws a run's graph and marks its current step **Now**; see [follow and manage runs](guides/studio.md#save-publish-activate-and-run), [execution management](guides/execution-management.md), and [observability](operations/observability.md) | diff --git a/docs/contracts.md b/docs/contracts.md index 33b703a0..ef581ccf 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -217,10 +217,13 @@ other workflows are part of the language; compensation is not. **New language constructs.** `forEach`, `callWorkflow`, `concat`, and `join` each need a language feature: `flow.forEach`, `flow.callWorkflow`, `text.concat`, -and `text.join`. This version compiles and runs `concat` and `join`. The shape of -`forEach` and `callWorkflow` is final, so `load_definition` accepts them, but this -version of the compiler reports `WV-COMP-UNSUPPORTED_FEATURE` at each use instead -of compiling it. A platform runs a construct only when its +and `text.join`. This version compiles and runs `concat` and `join`, and compiles +`forEach`: the compiler checks a loop's scope, types, and limits and lowers it, +but this platform does not run loops yet, so it refuses to publish, import, or +activate a workflow that uses one (`WV-IR-UNSUPPORTED`). The shape of +`callWorkflow` is final, so `load_definition` accepts it, but this version of the +compiler reports `WV-COMP-UNSUPPORTED_FEATURE` at each use instead of compiling +it. A platform runs a construct only when its [language manifest](#language-manifest) lists the feature. Step IDs can never contain `[`, `#`, or `~`, which keeps [instance keys](reference/compiler.md#instance-keys) unambiguous. @@ -229,6 +232,20 @@ Branches may contain zero steps, but each must declare its `output`. The compile not the shape check, enforces unique step IDs, unique signal names, branch scope, output compatibility, and concurrency budgets. +**Loop scope.** Inside a `forEach` body, `/item` is the current element and +`/index` its position from 0; an enclosing loop's are `/loops//item` and +`/loops//index`. A body step sees what is visible at the loop plus the +earlier steps of the same iteration, and `body.output` also sees the last body +step. After the loop only `/steps//output` is visible: a list with one +result per element, in element order, holding at most `maxItems` results (with +`collect: nonNull`, results that are `null` are left out). `items` sees what is +visible at the loop, never the loop's own item. Loops nest at most three deep, a +body cannot wait for a signal, and all loops of a workflow together, each +multiplied by the `maxItems` of the loops around it, may need at most 100,000 +iterations; the compiler suggests a lower `maxItems` for the inner loop when they +need more. The [compiler reference](reference/compiler.md#troubleshooting) lists +the diagnostics. + **Human tasks** need an environment assignment binding at activation, and completing one requires the current claim and task permission. A workflow with a human task compiles to IR version `weave/ir-v1alpha2` or higher, because the @@ -363,6 +380,9 @@ The operator's `Limits()` defaults bound compilation: | `max_document_nodes` | 100,000 | Parsed values, including the root, containers, and scalars (mapping keys excluded) | | `max_expression_nodes` | 10,000 | Expression nodes, counted by the compiler stage that owns expressions | | `max_diagnostics` | 100 | Diagnostics returned per result | +| `max_loop_items` | 10,000 | The largest `maxItems` a loop may declare | +| `max_loop_depth` | 3 | Loops nested inside one another | +| `max_run_iterations` | 100,000 | Iterations all loops of one workflow may need, each loop multiplied by the `maxItems` of the loops around it | Definitions cannot change these budgets. Building a model alone does not apply them; the compiler does. @@ -438,7 +458,8 @@ backend is absent. The `integration` and `e2e` pytest markers are registered. | A field is rejected although it looks right | Field names are camelCase and case-sensitive; snake_case keys are rejected | Use the published name, such as `timeoutSeconds` | | `null` is rejected for an optional field | Optional fields may be omitted but not set to null | Remove the field | | A step's `retry` or `timeoutSeconds` is rejected | Retry and per-attempt timeout belong to the action definition | Move them to the action; use `spec.timeoutSeconds` for a workflow-wide timeout | -| `WV-COMP-UNSUPPORTED_FEATURE` | The document uses `forEach` or `callWorkflow`, which this version of the compiler does not compile yet | Keep the document for a later version, or replace the construct with the steps the compiler supports | +| `WV-COMP-UNSUPPORTED_FEATURE` | The document uses `callWorkflow`, which this version of the compiler does not compile yet | Keep the document for a later version, or replace the construct with the steps the compiler supports | +| `WV-IR-UNSUPPORTED` when publishing a workflow with `forEach` | The compiler accepts the loop, but this platform does not run loops yet, so it refuses to publish it; `result.missing_features` is `["flow.forEach"]` | Keep the workflow as a draft until the platform lists `flow.forEach` in its language manifest | | `WV-DECISION-OPERATOR` | A decision table rule uses `concat` or `join` | Build the text in a Transform step before the table and pass it in as input | | `WV-IR-UNSUPPORTED` on activation, a catalog read, or a run read | The platform does not list an IR version or a language feature the workflow uses; `result.missing_features` names the missing features and is empty when the IR version itself is unknown. A catalog list shows such a version as an unavailable item with `reason: ir_unsupported` and the same `missing_features`. A run that uses such a feature waits for an upgrade; signals and task results for it answer the same way, and run lists show it as unavailable | Upgrade the platform, or activate a version that does not use the feature | | `onFailure` is rejected on a call step | `onFailure` applies only when the call waits for its result | Remove `onFailure`, or set `mode: wait` | diff --git a/docs/reference/compiler.md b/docs/reference/compiler.md index 861990e7..f8ce0e0e 100644 --- a/docs/reference/compiler.md +++ b/docs/reference/compiler.md @@ -156,8 +156,25 @@ catalog: - Definite problems are still errors: `WV-COMP-UNAVAILABLE_REFERENCE` (a step reads a step that has not run yet in its scope), `WV-COMP-TYPE_MISMATCH`, and `WV-COMP-CONNECTION` for a step that names an undeclared connection slot. +- A loop whose `items` read a pending output leaves `/item` unconstrained in the + same way. - Pending notes only use diagnostic capacity that real findings leave free. +**Data suggestions.** For a field Studio is editing, the host answers +`POST /studio/local/scope` with `{source, format, path}`, where `path` is the +JSON Pointer of an expression in the definition. It returns +`firefly_weave.compiler.scope.scope_at(document, path, *, catalog=None)`: `found` +(false when the path is not inside an expression the compiler analyzes, or the +document stops before flow analysis), `evaluated` (false when every path to it +fails), `truncated`, and `entries`. Entries list the workflow input, then the +current loop's item (`/item`, `source: item`) and position (`/index`, `source: +index`), then enclosing loops by ID (`/loops//item`), then the visible step +outputs in run order, each followed by its fields up to four levels deep and at +most 200 entries in all. Each entry carries `ref`, `source`, `loop` or `stepId` +and `stepKind`, `path`, `label`, `breadcrumb`, `schema`, `types`, `typeLabel`, and +`optional`. Without a catalog, outputs of actions and decision tables stay +unconstrained, as in offline validation. + The result is always `partial=True`, `artifact=None`, and `ok=False`; `validation_ok` counts error-severity findings only. When Studio is connected and your account may compile, **Validate** compiles against the project catalog @@ -213,9 +230,9 @@ Executables also define a sorted `features` list of the language features they need (`text.concat`, `text.join`, `flow.forEach`, `flow.callWorkflow`, `ai.agent`, `ai.memory`). It is omitted when empty, so existing digests do not change. A workflow that uses `concat` or `join` in any expression compiles to -`weave/ir-v1alpha4` with `text.concat`, `text.join`, or both; v1alpha4 always -has a non-empty list, and the validator recomputes the version and the list from -the graph. The version is the highest level any construct needs, so a workflow +`weave/ir-v1alpha4` with `text.concat`, `text.join`, or both, and a workflow with +a `forEach` step lists `flow.forEach`; v1alpha4 always has a non-empty list, and +the validator recomputes the version and the list from the graph. The version is the highest level any construct needs, so a workflow with a human task and `join` is v1alpha4, not v1alpha2. A platform imports and activates such an artifact only when its capabilities list every feature in `language_features`; otherwise it answers `ir_unsupported`. `ir.py` defines @@ -315,6 +332,16 @@ flow as: start, decide, take one branch, join, end. The rules behind it: `fork` edges name the branches, `concurrency` carries the declared cap, and the join uses `mode: all`, collecting every named branch output into the step's output object. +- **Loops.** A `forEach` node carries `items`, `concurrency`, `maxItems`, and + `collect` (defaults written out), its `target`, and its `join`. Its only edge is + an `each` edge with branch `body` to `target`: the first body step, or the + body's `branch-output` node when the body is empty. Body nodes have the scope + `[, body]` after the loop's own scope. The body's `branch-output` + (`@branch::0`) runs `body.output` and always completes into the join + `@join:`, which uses `mode: each` and continues with a `next` edge. + The graph stays acyclic: the runtime enters the body once per element. A + `loop_items` guard on `/items` (schema `{"type": "array"}`) is added + only when the compiler cannot prove that `items` is a list. - **Branch outputs.** Every branch has a `branch-output` node, even an empty one. Its output expression runs in the branch's scope; an empty branch enters it directly. @@ -414,7 +441,7 @@ Each kind of input has its own policy, passed as a separate keyword: | `SchemaLimits` | Author schemas | See [schema profile limits](schema-profile.md#limits) | | `DEFAULT_CONTRACT_LIMITS` | Weave's generated outer contracts | Author limits with a larger work budget | | `ArtifactLimits` | The compiled artifact and its import | 33,554,432 bytes (32 MiB), 2,000,000 JSON value nodes, depth 128 | -| `max_parallel_concurrency` | The largest `concurrency` a parallel step may declare | 1,000 | +| `max_parallel_concurrency` | The largest `concurrency` a parallel or `forEach` step may declare | 1,000 | Limits are strict positive integers that source cannot raise. Artifact construction charges each fragment before adding it, then measures the whole @@ -537,7 +564,17 @@ is not classified. Safe ordinary artifacts keep their canonical digests. | `WV-COMP-TYPE_MISMATCH` at `/spec/output` | An expression's type cannot satisfy the target schema | Change the expression or the schema; the authoring guide shows an example | | `WV-COMP-UNAVAILABLE_REFERENCE` | An expression reads a step that has not run yet in its scope, or a value private to another branch | Read the step only after it runs, or read the decision or parallel step's output | | `WV-COMP-UNKNOWN_ACTION` (or `_CONNECTOR`, `_TASK`, `_ADAPTER`) | The catalog does not contain that exact reference | Add the exact version to the catalog, or fix the reference | -| `WV-COMP-UNSUPPORTED_FEATURE` | The workflow uses a construct whose language feature this compiler does not compile yet (`forEach` or `callWorkflow`); the message names the feature | Keep the document for a later version, or use the steps and operators this version compiles | +| `WV-COMP-UNSUPPORTED_FEATURE` | The workflow uses a construct whose language feature this compiler does not compile yet (`callWorkflow`); the message names the feature | Keep the document for a later version, or use the steps and operators this version compiles | +| `WV-COMP-LOOP_ITEMS` at `…/items` | A loop's `items` can never be a list | Point `items` at a list, such as an array field of the input or of a step's output | +| `WV-COMP-UNKNOWN_COMPATIBILITY` at `…/items` | The compiler cannot prove that `items` is a list; the run checks it | Declare the field as an array where it comes from | +| `WV-COMP-LOOP_SCOPE` at `…/ref` | `/item`, `/index`, or `/loops/` is read outside a loop body, in the loop's own `items`, or names a loop that does not enclose the expression | Read loop data only inside the loop's body; use `/loops//item` for an enclosing loop | +| `WV-COMP-LOOP_SIGNAL` | A loop body contains a **Wait for signal** step | Move the wait before or after the loop | +| `WV-COMP-LOOP_BODY_INCOMPLETE` at `…/body` | Every path through the body ends in **Fail** | Let the body finish with a result for each element | +| `WV-COMP-LOOP_DEPTH` | A loop is nested more than three deep | Flatten the inner loops | +| `WV-COMP-LOOP_MAX_ITEMS` at `…/maxItems` | `maxItems` is above the platform's 10,000 | Lower `maxItems` | +| `WV-COMP-LOOP_BUDGET` | All loops together, each multiplied by the `maxItems` of the loops around it, could need more than 100,000 iterations in one run | Apply the suggested edit, which lowers the inner loop's `maxItems`, or lower another loop's | +| `WV-COMP-CONCURRENCY_LIMIT` at `…/concurrency` | A loop or parallel step runs more at a time than the platform allows (1,000) | Lower `concurrency` | +| `WV-COMP-LOOP_FANOUT` (warning) | Nested loops together could start more than 1,000 tasks at once | Nothing to fix; the run spreads the work over several turns. Lower `concurrency` if the tasks must start together | | `WV-DECISION-OPERATOR` | A decision table rule uses `concat` or `join` | Build the text in a Transform step and pass it to the table as input | | `UnsupportedIR` on import, `WV-IR-UNSUPPORTED` on activation, a catalog read, or a run read | The artifact needs an IR version or language feature this platform does not list; `missing` names the missing features and is empty when the IR version itself is unknown. A run that needs it waits for an upgrade, and signals and task results for it answer the same way | Upgrade the platform, or use an artifact without that feature | | `WV-COMP-CATALOG_PENDING` notes in Studio | Studio works without the catalog and checks references later | Nothing; connect and **Validate** against the project catalog | diff --git a/docs/reference/history-and-replay.md b/docs/reference/history-and-replay.md index 2692b1de..7add9d25 100644 --- a/docs/reference/history-and-replay.md +++ b/docs/reference/history-and-replay.md @@ -205,7 +205,9 @@ sidecar, committed in the same transaction as the fact: - Facts and issued commands name each run of a step by its [instance key](compiler.md#instance-keys), which is the step ID outside loops. Replay finds the step from the step ID part of a canonical key; any other - spelling makes the history inconsistent. + spelling makes the history inconsistent. An export's `node_paths` stays keyed + by node ID, so map an event's `node_id` to its path through the node ID part of + the key (`node_of`). - The expected transition is the SHA-256 of a canonical `classified-v1` safe projection of the kernel state, identified as `sha256-rfc8785-v1`; no duplicate snapshot is stored. From 49da8d8d80c76586512c84b00ee44c91a16877f4 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:00:09 -0700 Subject: [PATCH 8/9] Enable the loop-compiler acceptance capability --- tests/acceptance/journeys.toml | 2 +- tests/unit/test_acceptance_journeys.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/journeys.toml b/tests/acceptance/journeys.toml index 3294013c..86138094 100644 --- a/tests/acceptance/journeys.toml +++ b/tests/acceptance/journeys.toml @@ -42,7 +42,7 @@ "language-contracts" = false "text-operators" = true "instance-keys" = true -"loop-compiler" = false +"loop-compiler" = true "loop-runtime" = false "loop-simulation" = false "workflow-call-compiler" = false diff --git a/tests/unit/test_acceptance_journeys.py b/tests/unit/test_acceptance_journeys.py index a8ca77fe..c624ae34 100644 --- a/tests/unit/test_acceptance_journeys.py +++ b/tests/unit/test_acceptance_journeys.py @@ -129,7 +129,7 @@ def test_the_committed_map_is_valid(): enablement = journeys.Enablement.load(ROOT / "tests/acceptance/journeys.toml") assert enablement.capabilities["acceptance-foundations"] is True landed = [name for name, value in enablement.capabilities.items() if value] - assert landed == ["text-operators", "instance-keys", "ai-gateway", "acceptance-foundations"] + assert landed == ["text-operators", "instance-keys", "loop-compiler", "ai-gateway", "acceptance-foundations"] assert enablement.journey_steps("J0") == tuple(f"J0.{n}" for n in range(1, 15)) assert enablement.profiles["pr"] == ("J0", "J1", "J2", "J3", "J5", "J8", "J9") assert enablement.criteria["J3"] == ("SC1", "SC3", "SC7") From 28647c08c86d27776405f09efac67da556a7f51b Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:25:09 -0700 Subject: [PATCH 9/9] Suggest a budget edit on any inner loop that fits, within the item limit, and name loop limit messages --- src/firefly_weave/compiler/analyzer.py | 43 ++++++++++++++----------- tests/unit/compiler/test_loop_limits.py | 34 +++++++++++++++++++ 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/firefly_weave/compiler/analyzer.py b/src/firefly_weave/compiler/analyzer.py index eae56e3a..6bd8ef51 100644 --- a/src/firefly_weave/compiler/analyzer.py +++ b/src/firefly_weave/compiler/analyzer.py @@ -101,11 +101,18 @@ f"Nested concurrency can issue more than {MAX_CONCURRENT_TASKS:,} tasks at once; " "work will be spread over several turns." ), + "LOOP_DEPTH": "Loops can be nested at most {limit:,} deep.", + "LOOP_MAX_ITEMS": "maxItems exceeds this platform's limit of {limit:,}.", + "LOOP_BUDGET": ( + "Nested loops could run more than {limit:,} iterations in one run; lower maxItems on an inner loop." + ), } -def _message(code: str) -> str: - return _MESSAGES.get(code, "Definition violates the " + code.lower().replace("_", " ") + " contract.") +def _message(code: str, **values: object) -> str: + """The message for ``code``; ``values`` fill the configured limits that some messages name.""" + template = _MESSAGES.get(code, "Definition violates the " + code.lower().replace("_", " ") + " contract.") + return template.format(**values) if values else template def reference_available(step_id: str, completed: frozenset[str]) -> bool: @@ -975,16 +982,14 @@ def loop( identifier = cast(str, step["id"]) concurrency, max_items = cast(int, step["concurrency"]), cast(int, step["maxItems"]) if len(self.loops) >= self.limits.max_loop_depth: - self.issue( - "LOOP_DEPTH", location, message=f"Loops can be nested at most {self.limits.max_loop_depth} deep." - ) + self.issue("LOOP_DEPTH", location, message=_message("LOOP_DEPTH", limit=self.limits.max_loop_depth)) if concurrency > self.concurrency: self.issue("CONCURRENCY_LIMIT", location + "/concurrency") if max_items > self.limits.max_loop_items: self.issue( "LOOP_MAX_ITEMS", location + "/maxItems", - message=f"maxItems exceeds this platform's limit of {self.limits.max_loop_items:,}.", + message=_message("LOOP_MAX_ITEMS", limit=self.limits.max_loop_items), ) tasks = 1 for enclosing in self.loops: @@ -1047,23 +1052,25 @@ def loop_items(self, schema: JsonObject, items: JsonObject, path: str) -> None: self.guard(path, "loop_items", {"type": "array"}) def loop_budget(self) -> None: - """All loops together, each multiplied by the loops around it, stay within the run's iteration budget.""" + """All loops together, each multiplied by the loops around it, stay within the run's iteration budget. + + The finding sits at the innermost loop with the largest product that one lower ``maxItems`` brings within + the budget, with that edit; when no innermost loop can, it sits at the largest product without an edit. + """ limit = self.limits.max_run_iterations total = sum(outer * max_items for _, outer, max_items, _ in self.loop_chains) if total <= limit: return - location, outer, max_items, _ = max( - (chain for chain in self.loop_chains if chain[3]), key=lambda chain: chain[1] * chain[2] - ) - lowered = (limit - (total - outer * max_items)) // outer - self.issue( - "LOOP_BUDGET", - location, - message=( - f"Nested loops could run more than {limit:,} iterations in one run; lower maxItems on an inner loop." - ), - edit=SuggestedEdit(path=location + "/maxItems", value=lowered) if lowered >= 1 else None, + innermost = sorted( + (chain for chain in self.loop_chains if chain[3]), key=lambda chain: chain[1] * chain[2], reverse=True ) + location, edit = innermost[0][0], None + for path, outer, max_items, _ in innermost: + lowered = min((limit - (total - outer * max_items)) // outer, self.limits.max_loop_items) + if lowered >= 1: + location, edit = path, SuggestedEdit(path=path + "/maxItems", value=lowered) + break + self.issue("LOOP_BUDGET", location, message=_message("LOOP_BUDGET", limit=limit), edit=edit) def settle_pending(self) -> None: """Catalog-pending notes are the lowest priority: they only fill capacity that findings left. diff --git a/tests/unit/compiler/test_loop_limits.py b/tests/unit/compiler/test_loop_limits.py index f9ac4482..ae4db092 100644 --- a/tests/unit/compiler/test_loop_limits.py +++ b/tests/unit/compiler/test_loop_limits.py @@ -16,6 +16,7 @@ """Loop limits: nesting, element ceilings, the per-run iteration budget, concurrency and signals in bodies.""" +from firefly_weave.compiler.analyzer import _message from firefly_weave.compiler.api import compile_source from firefly_weave.compiler.catalog import CatalogSnapshot from firefly_weave.contracts.limits import Limits @@ -127,6 +128,39 @@ def test_the_budget_counts_every_loop_and_suggests_nothing_when_one_edit_cannot_ assert only(compiled(workflow(loop("a", maxItems=11)), limits=Limits(max_run_iterations=10)), "WV-COMP-LOOP_BUDGET") +def test_the_budget_edit_goes_to_an_innermost_loop_whose_reduction_fits_the_budget(): + # The inner loop carries the largest product (200) but can only save 100; lowering the later loop to 49 fits. + document = workflow( + loop("a", loop("x", items={"ref": "/item"}, maxItems=2), maxItems=100), + loop("z", maxItems=199), + ) + limits = Limits(max_run_iterations=349) + [issue] = only(compiled(document, limits=limits), "WV-COMP-LOOP_BUDGET") + assert issue.path == "/spec/steps/1" + assert issue.suggested_edit is not None + assert (issue.suggested_edit.path, issue.suggested_edit.value) == ("/spec/steps/1/maxItems", 49) + document["spec"]["steps"][1]["maxItems"] = 49 + assert compiled(document, limits=limits).ok + document["spec"]["steps"][1]["maxItems"] = 50 + assert only(compiled(document, limits=limits), "WV-COMP-LOOP_BUDGET") + + +def test_the_budget_edit_never_exceeds_the_platform_item_limit(): + result = compiled(workflow(*(loop(f"l{n}", maxItems=40_000) for n in range(3)))) + [issue] = only(result, "WV-COMP-LOOP_BUDGET") + assert issue.suggested_edit is not None + assert issue.suggested_edit.value == 10_000 + assert len(only(result, "WV-COMP-LOOP_MAX_ITEMS")) == 3 + + +def test_loop_limit_codes_have_their_own_messages(): + assert _message("LOOP_DEPTH", limit=3) == "Loops can be nested at most 3 deep." + assert _message("LOOP_MAX_ITEMS", limit=10_000) == "maxItems exceeds this platform's limit of 10,000." + assert _message("LOOP_BUDGET", limit=100_000) == ( + "Nested loops could run more than 100,000 iterations in one run; lower maxItems on an inner loop." + ) + + def test_nested_concurrency_over_a_thousand_tasks_warns_once_where_it_crosses(): deepest = loop("c", items={"ref": "/item"}, concurrency=1, maxItems=2) inner = loop("b", deepest, items={"ref": "/item"}, concurrency=20, maxItems=2)