diff --git a/CHANGELOG.md b/CHANGELOG.md index 415f0746..bd442176 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. - Starting a run no longer slows down as executions accumulate. The usage accounting that runs on every run start and every change to quota-counted records read every reservation in the database, one per run ever started, and now finds its reservation and allocation entries by primary key. Usage figures and limits are unchanged. New 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/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/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. 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/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/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/compiler/analyzer.py b/src/firefly_weave/compiler/analyzer.py index 293db228..6bd8ef51 100644 --- a/src/firefly_weave/compiler/analyzer.py +++ b/src/firefly_weave/compiler/analyzer.py @@ -48,13 +48,22 @@ ) 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, + MAX_CONCURRENT_TASKS, + 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 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 @@ -82,11 +91,28 @@ "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_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." + ), + "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: @@ -127,6 +153,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 @@ -190,8 +229,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 @@ -215,6 +259,10 @@ 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, ...] = () + # 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: @@ -245,15 +293,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, ) ) @@ -292,15 +344,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 +566,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 +592,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,11 +624,28 @@ 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 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)] @@ -580,6 +659,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 +686,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 +766,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: @@ -814,8 +903,13 @@ 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": + 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") @@ -831,9 +925,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) @@ -873,11 +969,109 @@ def block( reachable, ) ) + self.kinds[identifier] = kind if reachable and completes: visible[identifier] = output 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"]) + if len(self.loops) >= self.limits.max_loop_depth: + 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=_message("LOOP_MAX_ITEMS", limit=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) + 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"]) + nested = len(self.loop_chains) + 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") + self.observe(location + "/body/output", body_visible, evaluated=False) + 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", + 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 loop_budget(self) -> None: + """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 + 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. @@ -1006,10 +1200,13 @@ 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"]) ) + 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) @@ -1069,6 +1266,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/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/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/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/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/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/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/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/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/acceptance/journeys.toml b/tests/acceptance/journeys.toml index d93f6b64..ac55217f 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/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/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_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_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") 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_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, + ) diff --git a/tests/unit/compiler/test_loop_limits.py b/tests/unit/compiler/test_loop_limits.py new file mode 100644 index 00000000..ae4db092 --- /dev/null +++ b/tests/unit/compiler/test_loop_limits.py @@ -0,0 +1,196 @@ +# 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.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 + +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_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) + 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) + ] 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/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): 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 == [] diff --git a/tests/unit/test_acceptance_journeys.py b/tests/unit/test_acceptance_journeys.py index 654c5648..5cf242db 100644 --- a/tests/unit/test_acceptance_journeys.py +++ b/tests/unit/test_acceptance_journeys.py @@ -129,7 +129,14 @@ 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", "ai-models-screen", "acceptance-foundations"] + assert landed == [ + "text-operators", + "instance-keys", + "loop-compiler", + "ai-gateway", + "ai-models-screen", + "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") 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