From beb3e16a3d43dff69165c65adb4c46f1f7b2ab1f Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 10:31:33 -0400 Subject: [PATCH] Read, round-trip and edit automations that contain loops Kizen's 2026-10-26 release added loop steps, which the CLI read with no config block. Every read-modify-write verb then PUT the loop without its step_loop and got a 400 after a clean --dry-run, so activate, deactivate, move, steps and roundtrip --execute failed on any automation with one. Loops now translate with their array and item variables by name, and next_or_finish and exit_loop as the configless closers they are. --dry-run checks the server's loop rules and names the broken step, show puts each loop's body under it, and steps add and remove keep a loop closed. Translate also refuses a live step carrying a config block it doesn't read, instead of silently dropping it on PUT. --- CHANGELOG.md | 9 + scripts/cli-tree-baseline.txt | 11 +- src/kizen_builder/cli/automations.py | 21 +- src/kizen_builder/cli/steps.py | 6 +- .../docs/specs/automation-step.md | 13 +- src/kizen_builder/docs/specs/automation.md | 33 +- src/kizen_builder/models/spec/__init__.py | 2 + src/kizen_builder/models/spec/automations.py | 8 +- .../spec/automations_actions_control.py | 14 + src/kizen_builder/tools/automations.py | 10 + .../tools/planners/automations.py | 20 +- src/kizen_builder/tools/steps.py | 24 +- src/kizen_builder/translate.py | 142 ++- tests/drift/test_roundtrip_automations.py | 109 +++ tests/fixtures/automations/loops.raw.json | 632 +++++++++++++ .../automations/new_features_loops.raw.json | 852 ++++++++++++++++++ tests/test_automation_payloads.py | 88 +- tests/test_steps.py | 109 +++ tests/test_translate.py | 458 +++++++++- 19 files changed, 2530 insertions(+), 31 deletions(-) create mode 100644 tests/fixtures/automations/loops.raw.json create mode 100644 tests/fixtures/automations/new_features_loops.raw.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bb40..094d504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -495,6 +495,15 @@ called out explicitly under **Changed** or **Removed**. ### Fixed +- **CLI writes no longer fail on automations with loops, and `show` renders + loops.** `activate`, `deactivate`, `move`, `steps add|edit|remove` and + `roundtrip --execute` failed with `HTTP 400: … step_loop must be provided + for loop` on any automation with a loop, after a clean `--dry-run`. They + now write it back unchanged. `--dry-run` catches a broken loop and names + the step. `show` labels each loop with its array and item variables and + puts its body under it. `steps add --parent ` puts the new step at + the head of the body, `steps add` builds an `exit_loop`, and + `steps remove` refuses to leave a loop unclosed. - **`forms`/`surveys update` and `set-ui` no longer delete fields.** Kizen deletes every field that isn't placed on a page whenever a form is saved, so updating a form with no layout yet emptied it, and a partial `set-ui` diff --git a/scripts/cli-tree-baseline.txt b/scripts/cli-tree-baseline.txt index 2b1c4a6..e4cec08 100644 --- a/scripts/cli-tree-baseline.txt +++ b/scripts/cli-tree-baseline.txt @@ -1069,16 +1069,18 @@ │ edit Patch one step: top-level keys in the JSON replace the step's. │ │ add Insert one step. The spec is a single AutomationDef step (step_type + │ │ config block; field_refs welcome); placement comes from the flags, and │ -│ by default the parent's existing children move under the new step. │ +│ by default the parent's existing children move under the new step, except │ +│ a next_or_finish or merge_branches, which stays on its loop or group. │ │ remove Splice one step out of the graph (children adopt its parent, go_to │ -│ references retarget). A condition/goal with children needs --cascade. │ +│ references retarget). A condition, goal or loop with children needs │ +│ --cascade, and a next_or_finish goes only with its loop. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ Usage: automations steps add [OPTIONS] {api_name} Insert one step. The spec is a single AutomationDef step (step_type + config block; field_refs welcome); placement comes from the flags, and by default the parent's existing children move under - the new step. + the new step, except a next_or_finish or merge_branches, which stays on its loop or group. ╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ │ * api_name Automation api_name. [required] │ @@ -1139,7 +1141,8 @@ Usage: automations steps remove [OPTIONS] {api_name} {key} Splice one step out of the graph (children adopt its parent, go_to references retarget). A - condition/goal with children needs --cascade. + condition, goal or loop with children needs --cascade, and a next_or_finish goes only with its + loop. ╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ │ * api_name Automation api_name. [required] │ diff --git a/src/kizen_builder/cli/automations.py b/src/kizen_builder/cli/automations.py index 14858fc..08ca517 100644 --- a/src/kizen_builder/cli/automations.py +++ b/src/kizen_builder/cli/automations.py @@ -380,6 +380,12 @@ def _step_label(step: dict[str, Any]) -> str: if len(desc) > 72: desc = desc[:69] + "…" label = f"[bold]{step['key']}[/bold]" + loop = step.get("step_loop") + if step.get("type") == "loop" and loop: + label += ( + f" [cyan]\\[for each {escape(str(loop.get('array_variable')))} as " + f"{escape(str(loop.get('item_variable')))}][/cyan]" + ) goto = step.get("action_go_to_automation_step") if goto: target = goto.get("step_key") or goto.get("trigger_key") or "?" @@ -409,6 +415,16 @@ def add_children(node: Any, parent_key: str | None) -> None: child_node = node.add(_step_label(child)) descend(child_node, child) + def add_loop_children(node: Any, loop_key: str) -> None: + kids = step_tools.children_of(payload, loop_key, branch="") + body = [k for k in kids if k["type"] != "next_or_finish"] + if body: + body_node = node.add("[dim]body[/dim]") + for child in body: + descend(body_node.add(_step_label(child)), child) + for closer in (k for k in kids if k["type"] == "next_or_finish"): + descend(node.add(_step_label(closer)), closer) + def descend(node: Any, step: dict[str, Any]) -> None: for branch, style in (("yes", "green"), ("no", "red")): kids = step_tools.children_of(payload, step["key"], branch=branch) @@ -416,7 +432,10 @@ def descend(node: Any, step: dict[str, Any]) -> None: branch_node = node.add(f"[{style}]{branch}[/{style}]") for child in kids: descend(branch_node.add(_step_label(child)), child) - add_children(node, step["key"]) + if step["type"] == "loop": + add_loop_children(node, step["key"]) + else: + add_children(node, step["key"]) # The leading initialize_variable chain is always linear (the server # requires init-vars at the front), so render it flat instead of as a diff --git a/src/kizen_builder/cli/steps.py b/src/kizen_builder/cli/steps.py index ccdd747..00ead77 100644 --- a/src/kizen_builder/cli/steps.py +++ b/src/kizen_builder/cli/steps.py @@ -242,7 +242,8 @@ def steps_add( ) -> None: """Insert one step. The spec is a single AutomationDef step (step_type + config block; field_refs welcome); placement comes from the flags, and - by default the parent's existing children move under the new step.""" + by default the parent's existing children move under the new step, except + a next_or_finish or merge_branches, which stays on its loop or group.""" if bool(parent) == root: err_console.print("[red]error:[/red] pass exactly one of --parent or --root") raise typer.Exit(code=2) @@ -274,7 +275,8 @@ def steps_remove( json_out: bool = typer.Option(False, "--json", help="Emit JSON."), ) -> None: """Splice one step out of the graph (children adopt its parent, go_to - references retarget). A condition/goal with children needs --cascade.""" + references retarget). A condition, goal or loop with children needs + --cascade, and a next_or_finish goes only with its loop.""" def mutate(payload: dict[str, Any], raw: dict[str, Any]) -> Any: return step_tools.remove_step(payload, key, cascade=cascade) diff --git a/src/kizen_builder/docs/specs/automation-step.md b/src/kizen_builder/docs/specs/automation-step.md index 8a18c94..2cf2c92 100644 --- a/src/kizen_builder/docs/specs/automation-step.md +++ b/src/kizen_builder/docs/specs/automation-step.md @@ -95,11 +95,20 @@ Consequences worth knowing: complete config block, not a partial one. - **`add` inserts into the chain by default** — the parent's children move under the new step. `--leaf` appends without adopting them, `--branch yes|no` targets - a condition/goal branch, `--root` puts the new step first. + a condition/goal branch, `--root` puts the new step first. A + `next_or_finish` or `merge_branches` child is never adopted, so `--parent + ` puts the new step at the head of the loop body, and `--parent + ` puts it right after the loop. An `exit_loop` adds like any + step. A `loop` or `next_or_finish` can't be added on its own: add the loop, + its closer and its item variable with `kizen automations update + --spec-file`. - **`remove` splices:** children adopt the removed step's parent and inherit its branch position, and `go_to` references retarget to that parent. Removing a condition or goal that has children requires `--cascade`, which deletes the - whole subtree. + whole subtree. So does a loop with steps under it, where the subtree is the + body and everything after the loop. A `next_or_finish` can't be removed, even + with `--cascade`: remove its loop instead. Removing the last body step leaves + an empty body, which is valid. - **The post-apply diff is positional**, so a mid-graph change shows up as every later step shifting. Verify structure with `show` and treat the diff as the audit trail, not as a minimal changeset. diff --git a/src/kizen_builder/docs/specs/automation.md b/src/kizen_builder/docs/specs/automation.md index 0ac497c..378f06d 100644 --- a/src/kizen_builder/docs/specs/automation.md +++ b/src/kizen_builder/docs/specs/automation.md @@ -116,6 +116,15 @@ plan preview warns about it. Kizen's 2026-09-28 migration inserted a `branch` step ahead of every existing fan-out, and an `update` from a spec without one removes it again. Confirmed live 2026-09-28. +**Loops** are initiators too. A `loop` step's body hangs off the loop, its one +`next_or_finish` child closes the body, and the steps after the loop hang off +the `next_or_finish`. `exit_loop` leaves the loop from inside the body. +`next_or_finish` and `exit_loop` take no config block. `step_loop` names an +array variable of records, strings or UUIDs and a separate item variable, each +declared by an `initialize_variable` step. The item variable is a single +variable of the array's type. `--dry-run` checks loops against +the server's rules too. Confirmed live 2026-10-27. + **Skipping a condition or goal** (`should_skip_execution: true`) needs `continue_with_branch: "yes"` or `"no"`, the branch the run takes past it. The spec is rejected without it. Confirmed live 2026-09-28. @@ -163,7 +172,7 @@ A `goal` step's `step_goal.triggers` are wait-until triggers with the same to wait for a reply. Its `wait_type` is `never`, `delay` or `specific_datetime`; `wait_forever` is a 400. Confirmed live 2026-10-14. -## Wired step types (27) +## Wired step types (30) | step_type | config block | purpose | |-----------|--------------|---------| @@ -193,6 +202,9 @@ to wait for a reply. Its `wait_type` is `never`, `delay` or | `search_records` | `action_search_records` | search an object, write results into an array variable | | `branch` | none | run its children in parallel — see Branch groups | | `merge_branches` | none | rejoin a branch group — see Branch groups | +| `loop` | `step_loop` | run its body once per item of an array variable — see Branch groups | +| `next_or_finish` | none | close a loop body — see Branch groups | +| `exit_loop` | none | leave the loop from inside its body | `search_records` (the automation-builder equivalent of `records list --filter`) needs its own `custom_object` — independent of the automation's @@ -854,7 +866,9 @@ Why a translator is mandatory rather than nice-to-have: Derived read-only keys are expected and never sent: `yes_steps`/`no_steps` on a condition, and the message fields (`subject`, `content`, `name`, …) a `notify_member_via_email`/`_text` read flattens into the block (confirmed - live 2026-10-16). + live 2026-10-16). A whole non-empty `step_*`/`action_*` block the CLI + doesn't read for that step's type stops the same verbs, forwarding types + included. Also: **automation updates need PUT, not PATCH.** PATCH refuses step/trigger changes, and PUT requires the current `revision` as `last_revision`. @@ -904,6 +918,8 @@ Each of these was discovered via a live 400, 500, or silent data loss: | `continue_with_branch`, present only when set | echoed when non-null. A condition or goal with `should_skip_execution: true` 400s without it. Confirmed live 2026-09-28 | | `error_notification_severity_level` on every step | echoed as read. Omitted, the step is silently reset to `inherit`. Confirmed live 2026-09-28 | | `branch` and `merge_branches` steps, read with no config block | no `action_*` block written. Confirmed live 2026-09-28 | +| `step_loop` with `array_variable`/`item_variable` as variable definitions | bare variable **names**; their ids are a 400 ("The loop must iterate an array variable…"). `next_or_finish` and `exit_loop` are read with no config block, and none is written. Confirmed live 2026-10-27 | +| A loop's item variable read with `is_array_item_variable: true` and `source_array_variable: ` | echoed as read. The server sets both itself from the loop, so sending them is harmless and omitting them changes nothing. Confirmed live 2026-10-27 | | Extraction/transcription `input_field` plus its mirrored `context_entity_field` `input_value_source` | `input_field` only — see [Extraction input](#extraction-input) | | `modify_related_entities.automation_variables` (expanded variable definitions) and the block's own `id` | not sent; `record_source_variables` (bare names) is the write key | @@ -936,6 +952,19 @@ inconsistent: one `merge_branches` child, and every `merge_branches` step's parent is an initiator** (400). A skipped condition or goal needs `continue_with_branch` `yes` or `no` (400). Confirmed live 2026-09-28. +- **Loops** (each a 400, confirmed live 2026-10-27): + - a `loop` has exactly one `next_or_finish` child; + - a `loop` has no `merge_branches` child; + - a `next_or_finish` hangs directly off a `loop`; + - an `exit_loop` is inside a loop body: walking up its parents, it reaches a + loop through a body step, not through that loop's `next_or_finish`; + - a loop iterates an array variable of records, strings or UUIDs, declared + by an `initialize_variable` step; + - its item variable is declared by an `initialize_variable` step as a single + variable of the array's type (and, for records, the same object), and no + two loops share one. + + There is no nesting limit (6 deep saves) and no limit on body lanes. ### Unlisted step types diff --git a/src/kizen_builder/models/spec/__init__.py b/src/kizen_builder/models/spec/__init__.py index 3762f40..dccb372 100644 --- a/src/kizen_builder/models/spec/__init__.py +++ b/src/kizen_builder/models/spec/__init__.py @@ -80,6 +80,7 @@ StepConditionConfig, StepDelayConfig, StepGoalConfig, + StepLoopConfig, ActionGoToAutomationStepConfig, ActionStopExecutionConfig, ActionArchiveRecordConfig, @@ -268,6 +269,7 @@ def build_payload_meta(model: BaseModel) -> dict[str, Any]: "StepConditionConfig", "StepDelayConfig", "StepGoalConfig", + "StepLoopConfig", "ActionChangeFieldValueConfig", "ActionSendEmailConfig", "ActionSendRelatedContactEmailConfig", diff --git a/src/kizen_builder/models/spec/automations.py b/src/kizen_builder/models/spec/automations.py index edc94c0..536633b 100644 --- a/src/kizen_builder/models/spec/automations.py +++ b/src/kizen_builder/models/spec/automations.py @@ -25,6 +25,7 @@ StepConditionConfig, StepDelayConfig, StepGoalConfig, + StepLoopConfig, ) from kizen_builder.models.spec.automations_actions_data import ( ActionChangeFieldValueConfig, @@ -65,6 +66,7 @@ "delete_scheduled_activity": "action_delete_scheduled_activity", "file_content_extraction": "action_file_content_extraction", "goal": "step_goal", + "loop": "step_loop", "go_to_automation_step": "action_go_to_automation_step", "http_request": "action_http_request", "initialize_variable": "action_initialize_variable", @@ -88,6 +90,8 @@ "search_records": "action_search_records", "branch": None, "merge_branches": None, + "next_or_finish": None, + "exit_loop": None, } @@ -101,6 +105,7 @@ "go_to_automation_step", "http_request", "initialize_variable", + "loop", "modify_automation", "modify_related_entities_automation", "notify_member_via_email", @@ -195,6 +200,7 @@ class AutomationStepDef(BaseModel): step_condition: StepConditionConfig | None = None step_delay: StepDelayConfig | None = None step_goal: StepGoalConfig | None = None + step_loop: StepLoopConfig | None = None action_assign_team_member: ActionAssignTeamMemberConfig | None = None action_audio_transcription: ActionAudioTranscriptionConfig | None = None action_call_llm: ActionLlmCallConfig | None = None @@ -246,7 +252,7 @@ def _validate_step_config(self) -> Self: raise ValueError( f"step_type '{self.step_type}' requires an '{config_field}' block" ) - if self.step_type in ("branch", "merge_branches"): + if config_field is None: given = sorted( f for f in set(_STEP_TYPE_TO_CONFIG_FIELD.values()) diff --git a/src/kizen_builder/models/spec/automations_actions_control.py b/src/kizen_builder/models/spec/automations_actions_control.py index f978a2f..afdf2d4 100644 --- a/src/kizen_builder/models/spec/automations_actions_control.py +++ b/src/kizen_builder/models/spec/automations_actions_control.py @@ -57,6 +57,20 @@ class StepDelayConfig(BaseModel): variable: str | None = None +class StepLoopConfig(BaseModel): + """Config for `step_type: loop`. Both variables are named, and each must be + declared by an `initialize_variable` step: the array, and a single variable + of the array's type that holds the current item.""" + + model_config = ConfigDict(extra="allow") + + loop_type: Literal["for_each_item"] = "for_each_item" + array_variable: str = Field(description="Name of the array variable to iterate.") + item_variable: str = Field( + description="Name of the variable that holds the current item." + ) + + class StepGoalConfig(BaseModel): """Config for `step_type: goal`.""" diff --git a/src/kizen_builder/tools/automations.py b/src/kizen_builder/tools/automations.py index 2695851..c65cc5f 100644 --- a/src/kizen_builder/tools/automations.py +++ b/src/kizen_builder/tools/automations.py @@ -431,8 +431,18 @@ def build_wire_step( LiveContext, _build_step_payload, ) + from kizen_builder.tools.plans import PlanError from kizen_builder.tools.steps import synthesize_key + counterpart = {"loop": "next_or_finish", "next_or_finish": "loop"}.get( + spec.get("step_type", "") + ) + if counterpart: + raise PlanError( + f"a {spec['step_type']} is added together with its {counterpart} and " + "the loop's item variable, which one 'steps add' can't do. Add it " + "with 'kizen automations update --spec-file'." + ) spec = dict(spec) for linkage in ("parent_key", "parent_branch", "id"): spec.pop(linkage, None) diff --git a/src/kizen_builder/tools/planners/automations.py b/src/kizen_builder/tools/planners/automations.py index 713e85d..cec6650 100644 --- a/src/kizen_builder/tools/planners/automations.py +++ b/src/kizen_builder/tools/planners/automations.py @@ -1178,7 +1178,9 @@ class KnownChoices: # Read with no config block at all, so none is written either. -_CONFIGLESS_STEP_TYPES = frozenset({"branch", "merge_branches"}) +_CONFIGLESS_STEP_TYPES = frozenset( + {"branch", "merge_branches", "next_or_finish", "exit_loop"} +) def _prefix_for(step_type: str) -> str: @@ -1187,7 +1189,7 @@ def _prefix_for(step_type: str) -> str: def _block_field_for(step_type: str) -> str: """Map step_type to its config field on AutomationStepDef.""" - if step_type in ("condition", "delay", "goal"): + if step_type in ("condition", "delay", "goal", "loop"): return f"step_{step_type}" return f"action_{step_type}" @@ -1536,6 +1538,19 @@ def _step_goal( return out +@honours("loop_type", "array_variable", "item_variable") +def _step_loop( + block: dict[str, Any], _auto: AutomationDef, _ctx: LiveContext +) -> dict[str, Any]: + # Reads expand both variables; the write takes their names, and their ids + # are refused (confirmed live 2026-10-27). + return { + "loop_type": block.get("loop_type") or "for_each_item", + "array_variable": _unwrap_variable_name(block.get("array_variable")), + "item_variable": _unwrap_variable_name(block.get("item_variable")), + } + + @honours_all def _step_stop_execution( block: dict[str, Any], _auto: AutomationDef, _ctx: LiveContext @@ -3058,6 +3073,7 @@ def _code_output( "condition": _step_condition, "delay": _step_delay, "goal": _step_goal, + "loop": _step_loop, "stop_execution": _step_stop_execution, "code_step": _step_code_step, "plugin_code_step": _step_plugin_code_step, diff --git a/src/kizen_builder/tools/steps.py b/src/kizen_builder/tools/steps.py index f75c4fc..1bba449 100644 --- a/src/kizen_builder/tools/steps.py +++ b/src/kizen_builder/tools/steps.py @@ -18,6 +18,9 @@ from kizen_builder.tools.plans import PlanError _BRANCHING_TYPES = {"condition", "goal"} +# Closers stay on the loop or branch group they close: moved under a new +# step, the server rejects them. +_CLOSER_TYPES = {"next_or_finish", "merge_branches"} def find_step(payload: dict[str, Any], key: str) -> dict[str, Any]: @@ -93,6 +96,11 @@ def remove_step( dead = find_step(payload, key) kids = children_of(payload, key) + if dead["type"] == "next_or_finish": + raise PlanError( + f"'{key}' closes loop '{dead.get('parent_key')}' and can't be " + "removed on its own. Remove the loop instead." + ) if cascade: doomed = set(subtree_keys(payload, key)) else: @@ -103,6 +111,14 @@ def remove_step( "orphan its yes/no branches. Remove or re-parent them first, " "or pass cascade=True to delete the whole subtree." ) + if dead["type"] == "loop" and kids: + raise PlanError( + f"'{key}' is a loop with steps under it " + f"({', '.join(k['key'] for k in kids)}); removing it alone would " + "leave its next_or_finish without a loop. Remove its body first, " + "or pass cascade=True to delete the loop with its body and " + "everything after it." + ) doomed = {key} new_parent = dead.get("parent_key") @@ -160,8 +176,10 @@ def insert_step( (envelope linkage/order fields are overwritten here). With ``adopt_children`` (the default) the parent's existing children in the target branch move under the new step — i.e. insertion into the chain; - without it the new step is a leaf. ``parent_key=None`` inserts a new - root above the current one. + without it the new step is a leaf. A ``next_or_finish`` or + ``merge_branches`` child is never adopted, so under a loop the new step + heads the body. ``parent_key=None`` inserts a new root above the current + one. """ if any(s["key"] == new_step["key"] for s in payload["steps"]): raise PlanError(f"step key '{new_step['key']}' already exists") @@ -175,6 +193,8 @@ def insert_step( report: dict[str, Any] = {"key": new_step["key"], "adopted": []} if adopt_children: for child in children_of(payload, parent_key, branch if parent else None): + if child["type"] in _CLOSER_TYPES: + continue child["parent_key"] = new_step["key"] child["parent_yes_no"] = "" child["parent_condition"] = "" diff --git a/src/kizen_builder/translate.py b/src/kizen_builder/translate.py index 9942901..8ea0c75 100644 --- a/src/kizen_builder/translate.py +++ b/src/kizen_builder/translate.py @@ -47,6 +47,7 @@ _prefix_for, _reject_unhonoured_keys, _strip, + _unwrap_id, ) from kizen_builder.tools.plans import PlanError @@ -150,6 +151,10 @@ def live_to_payload(raw: dict[str, Any]) -> dict[str, Any]: return _merge_server_state(payload, raw) +# Step-level read keys that share the config blocks' prefixes. +_STEP_ENVELOPE_KEYS = frozenset({"step_type", "step_error", "action_on_failure"}) + + def _live_step_to_wire( step: dict[str, Any], step_keys: dict[str, str], @@ -187,10 +192,26 @@ def _live_step_to_wire( if step.get("description"): p["description"] = step["description"] - if step_type in _CONFIGLESS_STEP_TYPES: + cfg_key = ( + None if step_type in _CONFIGLESS_STEP_TYPES else _block_field_for(step_type) + ) + for k, v in step.items(): + if ( + v + and isinstance(v, dict) + and k.startswith(("step_", "action_")) + and k not in _STEP_ENVELOPE_KEYS + and k != cfg_key + ): + raise PlanError( + f"{where} step '{p['key']}': {k} would be lost on PUT — the CLI " + f"doesn't read that block for a '{step_type}' step, so it stopped " + "before writing anything. Change this step in the Kizen UI until " + "the CLI supports it." + ) + if cfg_key is None: return p - cfg_key = _block_field_for(step_type) block = dict(step.get(cfg_key) or {}) where = f"{where} step '{p['key']}' {cfg_key}" @@ -353,6 +374,7 @@ def validate_payload(payload: dict[str, Any]) -> list[str]: ) problems.extend(_branch_group_problems(steps)) + problems.extend(_loop_problems(steps)) # Cycle check via parent chain for s in steps: @@ -406,8 +428,8 @@ def _branch_group_problems(steps: list[dict[str, Any]]) -> list[str]: for s in steps: key = s.get("key") - # A live loop is also an initiator, closed by `next_or_finish` rather - # than a merge step. Loops are unsupported, and still 400 at PUT. + # A loop is also an initiator, closed by `next_or_finish` rather than + # a merge step (`_loop_problems`). if ( s.get("is_branch_group_initiator") and s.get("type") != "loop" @@ -429,6 +451,118 @@ def _branch_group_problems(steps: list[dict[str, Any]]) -> list[str]: return problems +# A loop may iterate an array of these (records, text or UUIDs). +_LOOPABLE_DATA_TYPES = frozenset({"entity", "string", "uuid"}) + + +def _loop_problems(steps: list[dict[str, Any]]) -> list[str]: + """The server's loop rules, each one a live 400 (confirmed 2026-10-27). + The body hangs off the loop, its one `next_or_finish` child closes it, and + what follows the loop hangs off the `next_or_finish`. A graph with no + loop, `next_or_finish` or `exit_loop` passes untouched.""" + problems: list[str] = [] + by_key = {s.get("key"): s for s in steps} + declared: dict[str, dict[str, Any]] = {} + for s in steps: + if s.get("type") == "initialize_variable": + var = (s.get("action_initialize_variable") or {}).get("variable") + if isinstance(var, dict) and var.get("name"): + declared.setdefault(var["name"], var) + + item_owners: dict[Any, Any] = {} + for s in steps: + key, step_type = s.get("key"), s.get("type") + if step_type == "loop": + kids = [c for c in steps if c.get("parent_key") == key] + closers = sum(c.get("type") == "next_or_finish" for c in kids) + if closers != 1: + problems.append( + f"loop '{key}' has {closers} next_or_finish children; the " + "server requires exactly one, which closes the loop body " + "(server 400s otherwise)" + ) + if any(c.get("type") == "merge_branches" for c in kids): + problems.append( + f"loop '{key}' has a merge_branches child; a loop is closed " + "by next_or_finish, not merge_branches (server 400s otherwise)" + ) + problems.extend(_loop_variable_problems(s, declared)) + item = (s.get("step_loop") or {}).get("item_variable") + if item in item_owners: + problems.append( + f"loops '{item_owners[item]}' and '{key}' share item variable " + f"'{item}'; each loop needs its own (server 400s otherwise)" + ) + else: + item_owners[item] = key + elif step_type == "next_or_finish": + pk = s.get("parent_key") + if (by_key.get(pk) or {}).get("type") != "loop": + problems.append( + f"next_or_finish step '{key}' has parent '{pk}', which is not " + "a loop; it must hang directly off the loop it closes (server " + "400s otherwise)" + ) + elif step_type == "exit_loop" and not _in_loop_body(s, by_key): + problems.append( + f"exit_loop step '{key}' is not inside a loop body (server 400s " + "otherwise)" + ) + return problems + + +def _loop_variable_problems( + loop: dict[str, Any], declared: dict[str, dict[str, Any]] +) -> list[str]: + cfg = loop.get("step_loop") or {} + key = loop.get("key") + array_name, item_name = cfg.get("array_variable"), cfg.get("item_variable") + array = declared.get(array_name) if isinstance(array_name, str) else None + if ( + array is None + or not array.get("is_array") + or array.get("data_type") not in _LOOPABLE_DATA_TYPES + ): + return [ + f"loop '{key}' iterates '{array_name}', which is not an array variable " + "of records, strings or UUIDs (server 400s otherwise)" + ] + item = declared.get(item_name) if isinstance(item_name, str) else None + data_type = array["data_type"] + if ( + item is None + or item.get("is_array") + or item.get("data_type") != data_type + or ( + data_type == "entity" + and _unwrap_id(item.get("data_subtype")) + != _unwrap_id(array.get("data_subtype")) + ) + ): + return [ + f"loop '{key}' item variable '{item_name}' must be declared by an " + f"initialize_variable step as a single {data_type} variable, the type " + f"of '{array_name}' (server 400s otherwise)" + ] + return [] + + +def _in_loop_body(step: dict[str, Any], by_key: dict[Any, dict[str, Any]]) -> bool: + """Whether walking up from ``step`` reaches a loop through one of its body + steps. Arriving through a loop's `next_or_finish` means the step comes + after that loop, so the walk carries on to any loop around it.""" + cur, seen = step, set() + while cur.get("parent_key") and cur.get("key") not in seen: + seen.add(cur.get("key")) + parent = by_key.get(cur["parent_key"]) + if parent is None: + return False + if parent.get("type") == "loop" and cur.get("type") != "next_or_finish": + return True + cur = parent + return False + + # --------------------------------------------------------------------------- # Semantic diff (GET-before vs GET-after, modulo volatile fields) # --------------------------------------------------------------------------- diff --git a/tests/drift/test_roundtrip_automations.py b/tests/drift/test_roundtrip_automations.py index 60e4182..b702367 100644 --- a/tests/drift/test_roundtrip_automations.py +++ b/tests/drift/test_roundtrip_automations.py @@ -107,12 +107,25 @@ BRANCH_GROUP_STEPS = frozenset({"condition", "merge_branches", "branch", "delay"}) +LOOP_STEPS = frozenset( + { + "initialize_variable", + "loop", + "delay", + "condition", + "merge_branches", + "exit_loop", + "next_or_finish", + } +) + #: Needs an installed plugin that ships steps; skipped when there is none. PLUGIN_STEPS = frozenset({"plugin_code_step"}) COVERED_STEP_TYPES = ( BRANCHING_STEPS | BRANCH_GROUP_STEPS + | LOOP_STEPS | CONTROL_FLOW_STEPS | DATA_STEPS | RELATED_STEPS @@ -1309,6 +1322,101 @@ def test_branch_groups_put_back_unchanged(drift_client, drift_branch_groups): _put_back_unchanged(drift_client, drift_branch_groups) +# --------------------------------------------------------------------------- +# Loops: a merged condition with an exit_loop in the body, a second loop after +# the first, and a step after that +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def drift_loops(drift_client, scratch, drift_object) -> dict[str, Any]: + def step(key: str, parent: str | None, step_type: str, **kw: Any) -> dict: + return {"key": key, "parent_key": parent, "step_type": step_type, **kw} + + def var(key: str, parent: str | None, is_array: bool) -> dict: + block: dict[str, Any] = { + "sources": [], + "variable": {"name": key, "data_type": "string", "is_array": is_array}, + } + if is_array: + block["array_aggregation_mode"] = "add_all" + block["sources"] = [ + {"source_type": "static", "source_subtype": "string", "value": ["a"]} + ] + return step( + key, parent, "initialize_variable", action_initialize_variable=block + ) + + def loop(key: str, parent: str, array: str) -> dict: + block = {"array_variable": array, "item_variable": f"{array}_item"} + return step( + key, parent, "loop", is_branch_group_initiator=True, step_loop=block + ) + + def wait(key: str, parent: str, **kw: Any) -> dict: + delay = {"days": 1, "value_origin": "static"} + return step(key, parent, "delay", step_delay=delay, **kw) + + spec = _base_spec("loops", drift_object) + spec["steps"] = [ + var("arr0", None, True), + var("arr0_item", "arr0", False), + var("arr1", "arr0_item", True), + var("arr1_item", "arr1", False), + loop("each", "arr1_item", "arr0"), + step( + "check", + "each", + "condition", + is_branch_group_initiator=True, + step_condition={ + "type": "custom_filter", + "filter_config": {"all": [{"field": "name", "op": "not_blank"}]}, + }, + ), + wait("wait_yes", "check", parent_branch="yes"), + step("leave", "check", "exit_loop", parent_branch="no"), + step("merge", "check", "merge_branches"), + step("next", "each", "next_or_finish"), + loop("each_again", "next", "arr1"), + wait("body", "each_again"), + step("next_again", "each_again", "next_or_finish"), + wait("after", "next_again"), + ] + for order, s in enumerate(spec["steps"]): + s["order"] = order + return _create_automation(drift_client, scratch, spec) + + +def test_loop_steps_are_accepted(drift_loops): + assert _sent_step_types(drift_loops) == set(LOOP_STEPS) + assert set(_live_steps_by_type(drift_loops)) == set(LOOP_STEPS) + + +def test_loop_linkage_survives_the_roundtrip(drift_loops): + """The body hangs off the loop, its next_or_finish closes it, and what + follows the loop hangs off the next_or_finish.""" + live = _live_steps_by_type(drift_loops) + each, each_again = sorted(live["loop"], key=lambda s: s["order"]) + nxt, nxt_again = sorted(live["next_or_finish"], key=lambda s: s["order"]) + (check,) = live["condition"] + (leave,) = live["exit_loop"] + assert each["is_branch_group_initiator"] is True + assert each["step_loop"]["array_variable"]["name"] == "arr0" + assert each["step_loop"]["item_variable"]["name"] == "arr0_item" + assert check["parent_step_id"] == each["id"] + assert (leave["parent_step_id"], leave["parent_condition"]) == (check["id"], "no") + assert nxt["parent_step_id"] == each["id"] + assert each_again["parent_step_id"] == nxt["id"] + assert nxt_again["parent_step_id"] == each_again["id"] + after = max(live["delay"], key=lambda s: s["order"]) + assert after["parent_step_id"] == nxt_again["id"] + + +def test_loops_put_back_unchanged(drift_client, drift_loops): + _put_back_unchanged(drift_client, drift_loops) + + # --------------------------------------------------------------------------- # Steps: data & variables # --------------------------------------------------------------------------- @@ -2480,6 +2588,7 @@ def test_start_and_wait_streams_a_live_run_without_crashing( "drift_code_step_automation", "drift_control_flow", "drift_branch_groups", + "drift_loops", "drift_data_steps", "drift_array_variable_steps", "drift_related_steps", diff --git a/tests/fixtures/automations/loops.raw.json b/tests/fixtures/automations/loops.raw.json new file mode 100644 index 0000000..31c0e29 --- /dev/null +++ b/tests/fixtures/automations/loops.raw.json @@ -0,0 +1,632 @@ +{ + "id": "e25d78f2-4465-4ce4-838a-5e6367e55ffd", + "name": "Loops Roundtrip", + "api_name": "loops_roundtrip", + "ai_description": null, + "active": false, + "type": "record_based", + "custom_object": { + "id": "8c3f0149-6915-4370-940f-2e61848d6842", + "name": "object_with_workflow", + "description": null, + "entity_name": "object with workflow", + "object_name": "object with workflow", + "object_class": "custom_objects", + "object_type": "pipeline", + "default_on_activities": true, + "deleted": false + }, + "user_description": "", + "folder": { + "id": "44b300c0-ce2c-4c49-a427-44a25f85bfad", + "name": "" + }, + "triggers": [ + { + "id": "88307ffc-c1b9-46b2-8df4-7c29af7583bd", + "description": "Manual", + "user_description": "", + "trigger_type": "manual", + "stats": { + "number_completed": 0 + } + } + ], + "steps": [ + { + "id": "fe4d4727-8270-455e-bcb5-63464f32d502", + "step_type": "initialize_variable", + "order": 0, + "description": "Initialize Variable", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": null, + "parent_condition": "", + "action_initialize_variable": { + "sources": [ + { + "source_type": "static", + "source_subtype": "string", + "value_category": null, + "field": null, + "relationship_field": null, + "variable": null, + "value": [ + "a", + "b" + ], + "array_separator": "comma" + } + ], + "variable": { + "id": "ca08499f-4e1b-4fca-8434-3ee6abe4ff4a", + "name": "arr0", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:19:51.305171Z", + "deleted": false, + "is_array": true, + "is_array_item_variable": false, + "source_array_variable": null + }, + "is_required": false, + "array_aggregation_mode": "add_all", + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "98eefb3b-7496-4af2-a8d9-b3eeb5f8d354", + "step_type": "initialize_variable", + "order": 1, + "description": "Initialize Variable", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "fe4d4727-8270-455e-bcb5-63464f32d502", + "parent_condition": "", + "action_initialize_variable": { + "sources": [], + "variable": { + "id": "81d4b7aa-176c-47c8-ba06-d0c66d4a3738", + "name": "arr0_item", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:19:51.316710Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": true, + "source_array_variable": "arr0" + }, + "is_required": false, + "array_aggregation_mode": null, + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "6440ab38-2e0e-4aa6-ac6c-1518880ec2ce", + "step_type": "initialize_variable", + "order": 2, + "description": "Initialize Variable", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "98eefb3b-7496-4af2-a8d9-b3eeb5f8d354", + "parent_condition": "", + "action_initialize_variable": { + "sources": [ + { + "source_type": "static", + "source_subtype": "string", + "value_category": null, + "field": null, + "relationship_field": null, + "variable": null, + "value": [ + "a", + "b" + ], + "array_separator": "comma" + } + ], + "variable": { + "id": "c000eff2-51ad-42ac-bb60-056b5f517630", + "name": "arr1", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:19:51.330685Z", + "deleted": false, + "is_array": true, + "is_array_item_variable": false, + "source_array_variable": null + }, + "is_required": false, + "array_aggregation_mode": "add_all", + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "75b6ab64-69ef-4e00-8cfe-86cc317b2e94", + "step_type": "initialize_variable", + "order": 3, + "description": "Initialize Variable", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "6440ab38-2e0e-4aa6-ac6c-1518880ec2ce", + "parent_condition": "", + "action_initialize_variable": { + "sources": [], + "variable": { + "id": "0e55c478-3d05-40e6-a1af-b4be510e9092", + "name": "arr1_item", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:19:51.342228Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": true, + "source_array_variable": "arr1" + }, + "is_required": false, + "array_aggregation_mode": null, + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "a48d77be-633e-4d54-9e28-ee6638a05832", + "step_type": "loop", + "order": 4, + "description": "Loop", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "75b6ab64-69ef-4e00-8cfe-86cc317b2e94", + "parent_condition": "", + "step_loop": { + "loop_type": "for_each_item", + "array_variable": { + "id": "ca08499f-4e1b-4fca-8434-3ee6abe4ff4a", + "name": "arr0", + "data_type": "string", + "data_subtype": null, + "deleted": false, + "is_array": true + }, + "item_variable": { + "id": "81d4b7aa-176c-47c8-ba06-d0c66d4a3738", + "name": "arr0_item", + "data_type": "string", + "data_subtype": null, + "deleted": false, + "is_array": false + } + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": true + }, + { + "id": "aee408db-665a-4b4e-a52a-18eafffd7a98", + "step_type": "condition", + "order": 5, + "description": "Condition", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0, + "number_yes": 0, + "number_no": 0 + }, + "parent_step_id": "a48d77be-633e-4d54-9e28-ee6638a05832", + "parent_condition": "", + "step_condition": { + "type": "custom_filter", + "filter_config": { + "and": false, + "query": [ + { + "id": "query-0", + "and": false, + "filters": [ + { + "type": "fields_v2", + "field": "stage", + "value": "7855ba7b-e9e9-46d1-9fbb-16fde74eb4bc", + "subtype": "non_custom", + "condition": "=", + "overwrite": { + "type": "stage" + }, + "view_model": [ + [ + "filter_type", + { + "vars": [ + [ + "fields_settings_search", + true + ], + [ + "custom_object_id", + "8c3f0149-6915-4370-940f-2e61848d6842" + ], + [ + "object_type", + "pipeline" + ], + [ + "client_tag_field_id", + "f6a89dd0-7e37-42fb-92f6-eef59ddade10" + ] + ], + "filter_type": "fields" + } + ], + [ + "field_id", + { + "url": "/pipelines/8c3f0149-6915-4370-940f-2e61848d6842/fields/dd7e1f05-351d-4b34-9953-478a3be82b79", + "method": "GET", + "error_message": { + "404": "The field has been deleted" + }, + "error_response": { + "name": "stage", + "relation": {}, + "field_type": "dropdown" + } + } + ], + [ + "condition", + "equals" + ], + [ + "value_type", + "static" + ], + [ + "value", + { + "url": "/pipelines/8c3f0149-6915-4370-940f-2e61848d6842/stages/7855ba7b-e9e9-46d1-9fbb-16fde74eb4bc", + "method": "GET", + "error_message": { + "404": "The stage has been deleted" + } + } + ] + ], + "description": "Fields 'Stage' Equals Static Value Stage 1" + } + ] + } + ], + "invalid": false + }, + "llm_decision": null, + "yes_steps": [ + { + "id": "180ef686-6ddb-408b-85bd-a27a09f6f11f" + } + ], + "no_steps": [ + { + "id": "2bb8fbdd-c72d-40b6-ba48-2a93f21a5a81" + } + ], + "groups": [] + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": true + }, + { + "id": "180ef686-6ddb-408b-85bd-a27a09f6f11f", + "step_type": "delay", + "order": 6, + "description": "Delay", + "user_description": "", + "stats": { + "number_completed": 0, + "number_pending": 0 + }, + "parent_step_id": "aee408db-665a-4b4e-a52a-18eafffd7a98", + "parent_condition": "yes", + "step_delay": { + "minutes": 1, + "hours": 0, + "days": 0, + "weeks": 0, + "months": 0, + "years": 0, + "time": null, + "skip_non_working_days": false, + "value_origin": "static", + "duration_unit": "minutes", + "variable": null + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "2bb8fbdd-c72d-40b6-ba48-2a93f21a5a81", + "step_type": "exit_loop", + "order": 7, + "description": "Exit Loop", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "aee408db-665a-4b4e-a52a-18eafffd7a98", + "parent_condition": "no", + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "e437e98d-cf99-4f67-95b8-76d83837f76c", + "step_type": "merge_branches", + "order": 8, + "description": "Merge Branches", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "aee408db-665a-4b4e-a52a-18eafffd7a98", + "parent_condition": "", + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "cbfeaf23-050e-43f2-ab77-71a738d6601a", + "step_type": "next_or_finish", + "order": 9, + "description": "Next/Finish", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "a48d77be-633e-4d54-9e28-ee6638a05832", + "parent_condition": "", + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "6e3af27e-6f34-462c-b81e-1ea192c36920", + "step_type": "loop", + "order": 10, + "description": "Loop", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "cbfeaf23-050e-43f2-ab77-71a738d6601a", + "parent_condition": "", + "step_loop": { + "loop_type": "for_each_item", + "array_variable": { + "id": "c000eff2-51ad-42ac-bb60-056b5f517630", + "name": "arr1", + "data_type": "string", + "data_subtype": null, + "deleted": false, + "is_array": true + }, + "item_variable": { + "id": "0e55c478-3d05-40e6-a1af-b4be510e9092", + "name": "arr1_item", + "data_type": "string", + "data_subtype": null, + "deleted": false, + "is_array": false + } + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": true + }, + { + "id": "6a81ee88-37ff-4223-a1bc-c6983ba0562e", + "step_type": "delay", + "order": 11, + "description": "Delay", + "user_description": "", + "stats": { + "number_completed": 0, + "number_pending": 0 + }, + "parent_step_id": "6e3af27e-6f34-462c-b81e-1ea192c36920", + "parent_condition": "", + "step_delay": { + "minutes": 1, + "hours": 0, + "days": 0, + "weeks": 0, + "months": 0, + "years": 0, + "time": null, + "skip_non_working_days": false, + "value_origin": "static", + "duration_unit": "minutes", + "variable": null + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "ad4924d1-5c10-4c84-8f73-44e6f814bd21", + "step_type": "next_or_finish", + "order": 12, + "description": "Next/Finish", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "6e3af27e-6f34-462c-b81e-1ea192c36920", + "parent_condition": "", + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "b93bab2e-4f6b-4f03-ab3b-e17da109a9e1", + "step_type": "delay", + "order": 13, + "description": "Delay", + "user_description": "", + "stats": { + "number_completed": 0, + "number_pending": 0 + }, + "parent_step_id": "ad4924d1-5c10-4c84-8f73-44e6f814bd21", + "parent_condition": "", + "step_delay": { + "minutes": 1, + "hours": 0, + "days": 0, + "weeks": 0, + "months": 0, + "years": 0, + "time": null, + "skip_non_working_days": false, + "value_origin": "static", + "duration_unit": "minutes", + "variable": null + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + } + ], + "variables": [ + { + "id": "ca08499f-4e1b-4fca-8434-3ee6abe4ff4a", + "name": "arr0", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:19:51.305171Z", + "deleted": false, + "is_array": true, + "is_array_item_variable": false, + "source_array_variable": null + }, + { + "id": "81d4b7aa-176c-47c8-ba06-d0c66d4a3738", + "name": "arr0_item", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:19:51.316710Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": true, + "source_array_variable": "arr0" + }, + { + "id": "c000eff2-51ad-42ac-bb60-056b5f517630", + "name": "arr1", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:19:51.330685Z", + "deleted": false, + "is_array": true, + "is_array_item_variable": false, + "source_array_variable": null + }, + { + "id": "0e55c478-3d05-40e6-a1af-b4be510e9092", + "name": "arr1_item", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:19:51.342228Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": true, + "source_array_variable": "arr1" + } + ], + "revision": 1, + "error_notification_email": "", + "skip_non_working_days": false, + "number_active": 0, + "number_paused": 0, + "number_completed": 0, + "priority_rank": 10, + "trigger_throttle_seconds": 0.5, + "created": "2026-10-27T08:19:51.254929-05:00", + "updated": "2026-10-27T08:19:51.254939-05:00", + "debug_next_n_executions": 0, + "error_notification_severity_level": "error" +} diff --git a/tests/fixtures/automations/new_features_loops.raw.json b/tests/fixtures/automations/new_features_loops.raw.json new file mode 100644 index 0000000..5303078 --- /dev/null +++ b/tests/fixtures/automations/new_features_loops.raw.json @@ -0,0 +1,852 @@ +{ + "id": "d95e9059-7924-4a45-bddb-ed7235768a9c", + "name": "Loops UI Build", + "api_name": "loops_ui_build", + "ai_description": null, + "active": false, + "type": "record_based", + "custom_object": { + "id": "8c3f0149-6915-4370-940f-2e61848d6842", + "name": "object_with_workflow", + "description": null, + "entity_name": "object with workflow", + "object_name": "object with workflow", + "object_class": "custom_objects", + "object_type": "pipeline", + "default_on_activities": true, + "deleted": false + }, + "user_description": "", + "folder": { + "id": "44b300c0-ce2c-4c49-a427-44a25f85bfad", + "name": "" + }, + "triggers": [ + { + "id": "0812ee6e-7fb3-4a32-9e72-2d9fc1b18a50", + "description": "Manual", + "user_description": "", + "trigger_type": "manual", + "stats": { + "number_completed": 0 + } + } + ], + "steps": [ + { + "id": "ebc6e884-fa7e-4f40-b432-c22a33c3a1e2", + "step_type": "initialize_variable", + "order": 0, + "description": "array_var_obj = []", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": null, + "parent_condition": "", + "action_initialize_variable": { + "sources": [], + "variable": { + "id": "8a25d92c-10d5-4269-9693-af1a360332a6", + "name": "array_var_obj", + "data_type": "entity", + "data_subtype": { + "id": "8c3f0149-6915-4370-940f-2e61848d6842", + "name": "object_with_workflow", + "description": null, + "entity_name": "object with workflow", + "object_name": "object with workflow", + "object_class": "custom_objects", + "object_type": "pipeline", + "default_on_activities": true, + "deleted": false + }, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:58.960475Z", + "deleted": false, + "is_array": true, + "is_array_item_variable": false, + "source_array_variable": null + }, + "is_required": false, + "array_aggregation_mode": "add_all", + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "e39d5b27-9173-4348-91fd-6adbef60adbf", + "step_type": "initialize_variable", + "order": 1, + "description": "string_arr_var = [5 items]", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "ebc6e884-fa7e-4f40-b432-c22a33c3a1e2", + "parent_condition": "", + "action_initialize_variable": { + "sources": [ + { + "source_type": "static", + "source_subtype": "string", + "value_category": null, + "field": null, + "relationship_field": null, + "variable": null, + "value": [ + "1", + "2", + "3", + "4", + "5" + ], + "array_separator": "newline" + } + ], + "variable": { + "id": "0658231a-cbaf-4815-b6e1-6bf230a29c3d", + "name": "string_arr_var", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:58.984891Z", + "deleted": false, + "is_array": true, + "is_array_item_variable": false, + "source_array_variable": null + }, + "is_required": false, + "array_aggregation_mode": "add_all", + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "fff1c7c2-43f5-4ad9-ba9f-63a7e7f4d9da", + "step_type": "initialize_variable", + "order": 2, + "description": "test = empty", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "e39d5b27-9173-4348-91fd-6adbef60adbf", + "parent_condition": "", + "action_initialize_variable": { + "sources": [], + "variable": { + "id": "34a3e1ce-f1c2-46d2-ad7d-091e0e9eadd0", + "name": "test", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:58.998444Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": false, + "source_array_variable": null + }, + "is_required": false, + "array_aggregation_mode": "add_all", + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "7bea623a-f922-41f4-a7e2-321e35ee929d", + "step_type": "initialize_variable", + "order": 3, + "description": "Item Reference Variable", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "fff1c7c2-43f5-4ad9-ba9f-63a7e7f4d9da", + "parent_condition": "", + "action_initialize_variable": { + "sources": [], + "variable": { + "id": "ce262c1a-0488-40da-9ca4-c47efe033d35", + "name": "array_var_obj_item", + "data_type": "entity", + "data_subtype": { + "id": "8c3f0149-6915-4370-940f-2e61848d6842", + "name": "object_with_workflow", + "description": null, + "entity_name": "object with workflow", + "object_name": "object with workflow", + "object_class": "custom_objects", + "object_type": "pipeline", + "default_on_activities": true, + "deleted": false + }, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:59.019703Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": true, + "source_array_variable": "array_var_obj" + }, + "is_required": false, + "array_aggregation_mode": "add_all", + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "7484d6ab-564f-4e1c-92f1-97b6ea7c7641", + "step_type": "initialize_variable", + "order": 4, + "description": "Item Reference Variable", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "7bea623a-f922-41f4-a7e2-321e35ee929d", + "parent_condition": "", + "action_initialize_variable": { + "sources": [], + "variable": { + "id": "0cc78f02-3c3e-428d-a6d4-53f55bb7d2d0", + "name": "string_arr_var_item", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:59.031935Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": true, + "source_array_variable": "string_arr_var" + }, + "is_required": false, + "array_aggregation_mode": "add_all", + "deduplicate_array": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "2b5b71bc-1a44-4622-aca7-653393aaf7f7", + "step_type": "loop", + "order": 5, + "description": "For each item in 'array_var_obj'", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "7484d6ab-564f-4e1c-92f1-97b6ea7c7641", + "parent_condition": "", + "step_loop": { + "loop_type": "for_each_item", + "array_variable": { + "id": "8a25d92c-10d5-4269-9693-af1a360332a6", + "name": "array_var_obj", + "data_type": "entity", + "data_subtype": "8c3f0149-6915-4370-940f-2e61848d6842", + "deleted": false, + "is_array": true + }, + "item_variable": { + "id": "ce262c1a-0488-40da-9ca4-c47efe033d35", + "name": "array_var_obj_item", + "data_type": "entity", + "data_subtype": "8c3f0149-6915-4370-940f-2e61848d6842", + "deleted": false, + "is_array": false + } + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": true + }, + { + "id": "5055c290-d89f-499b-ae30-309c3ec5a286", + "step_type": "call_llm", + "order": 6, + "description": "Prompt gpt-6-luna with ‘here's a step that uses tools’", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "2b5b71bc-1a44-4622-aca7-653393aaf7f7", + "parent_condition": "", + "action_call_llm": { + "model_name": "openai/gpt-6-luna", + "business_plugin_app": { + "id": "a004c895-b827-4432-8b70-b7f6ade0cb84", + "plugin_app_api_name": "open_ai", + "plugin_app_name": "OpenAI", + "disabled": false, + "secrets": [ + { + "api_name": "open_ai__api_key", + "obfuscated_value": "xx****xx" + } + ] + }, + "prompt": "here's a step that uses tools", + "html_prompt": "

here's a step that uses tools

", + "merge_field_validation": "default_to_unknown", + "destinations": [ + { + "related_object_field": null, + "field": null, + "variable": { + "id": "0658231a-cbaf-4815-b6e1-6bf230a29c3d", + "name": "string_arr_var", + "data_type": "string", + "data_subtype": null, + "deleted": false, + "is_array": true + }, + "conflict_resolution": "overwrite_except_null", + "options": [], + "confidence_threshold": 0.7, + "is_required": false + } + ], + "is_advanced": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "5760efa0-0d88-4c21-9268-6dcdf238d033", + "step_type": "next_or_finish", + "order": 7, + "description": "Next item or finish loop and continue", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "2b5b71bc-1a44-4622-aca7-653393aaf7f7", + "parent_condition": "", + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "059761c7-f2b0-412f-9602-ef0ad4899c8e", + "step_type": "file_content_extraction", + "order": 8, + "description": "Extract Content from ‘File’ field", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "5760efa0-0d88-4c21-9268-6dcdf238d033", + "parent_condition": "", + "action_file_content_extraction": { + "model_name": "openai/gpt-5.2", + "business_plugin_app": { + "id": "a004c895-b827-4432-8b70-b7f6ade0cb84", + "plugin_app_api_name": "open_ai", + "plugin_app_name": "OpenAI", + "disabled": false, + "secrets": [ + { + "api_name": "open_ai__api_key", + "obfuscated_value": "xx****xx" + } + ] + }, + "prompt": "Extract all text from these document pages. Preserve structure, including paragraphs, tables, columns, and headers. Maintain original formatting. Return only the extracted content with no additional commentary.", + "html_prompt": "Extract all text from these document pages. Preserve structure, including paragraphs, tables, columns, and headers. Maintain original formatting. Return only the extracted content with no additional commentary.", + "merge_field_validation": "default_to_unknown", + "destinations": [ + { + "related_object_field": null, + "field": null, + "variable": { + "id": "34a3e1ce-f1c2-46d2-ad7d-091e0e9eadd0", + "name": "test", + "data_type": "string", + "data_subtype": null, + "deleted": false, + "is_array": false + }, + "conflict_resolution": "overwrite_except_null", + "options": [], + "confidence_threshold": 0.7, + "is_required": false + } + ], + "is_advanced": false, + "data_type": "image", + "input_field": { + "id": "a8a8bbc5-9188-4ea4-be9e-40dafe061639", + "name": "audio", + "display_name": "audio", + "deleted": false, + "field_type": "files", + "options": [], + "relation": null, + "rating": null + }, + "input_value_source": { + "type": "context_entity_field", + "value_custom_object": null, + "value_datatype": null, + "value": null, + "field": { + "id": "a8a8bbc5-9188-4ea4-be9e-40dafe061639", + "name": "audio", + "display_name": "audio", + "deleted": false, + "field_type": "files", + "options": [], + "relation": null, + "rating": null + }, + "relationship_field": null, + "variable": null + } + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "9ade9f3d-fdc9-408b-989e-ead912564bab", + "step_type": "loop", + "order": 9, + "description": "For each item in 'string_arr_var'", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "059761c7-f2b0-412f-9602-ef0ad4899c8e", + "parent_condition": "", + "step_loop": { + "loop_type": "for_each_item", + "array_variable": { + "id": "0658231a-cbaf-4815-b6e1-6bf230a29c3d", + "name": "string_arr_var", + "data_type": "string", + "data_subtype": null, + "deleted": false, + "is_array": true + }, + "item_variable": { + "id": "0cc78f02-3c3e-428d-a6d4-53f55bb7d2d0", + "name": "string_arr_var_item", + "data_type": "string", + "data_subtype": null, + "deleted": false, + "is_array": false + } + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": true + }, + { + "id": "417ffb8e-4323-4e77-98ee-9fbec1746bf9", + "step_type": "condition", + "order": 10, + "description": "object with workflow Fields 'Stage' Equals Static Value Stage 1", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0, + "number_yes": 0, + "number_no": 0 + }, + "parent_step_id": "9ade9f3d-fdc9-408b-989e-ead912564bab", + "parent_condition": "", + "step_condition": { + "type": "custom_filter", + "filter_config": { + "and": false, + "query": [ + { + "id": "query-0", + "and": false, + "filters": [ + { + "type": "fields_v2", + "field": "stage", + "value": "7855ba7b-e9e9-46d1-9fbb-16fde74eb4bc", + "subtype": "non_custom", + "condition": "=", + "overwrite": { + "type": "stage" + }, + "view_model": [ + [ + "filter_type", + { + "vars": [ + [ + "fields_settings_search", + true + ], + [ + "custom_object_id", + "8c3f0149-6915-4370-940f-2e61848d6842" + ], + [ + "object_type", + "pipeline" + ], + [ + "client_tag_field_id", + "f6a89dd0-7e37-42fb-92f6-eef59ddade10" + ] + ], + "filter_type": "fields" + } + ], + [ + "field_id", + { + "url": "/pipelines/8c3f0149-6915-4370-940f-2e61848d6842/fields/dd7e1f05-351d-4b34-9953-478a3be82b79", + "method": "GET", + "error_message": { + "404": "The field has been deleted" + }, + "error_response": { + "name": "stage", + "relation": {}, + "field_type": "dropdown" + } + } + ], + [ + "condition", + "equals" + ], + [ + "value_type", + "static" + ], + [ + "value", + { + "url": "/pipelines/8c3f0149-6915-4370-940f-2e61848d6842/stages/7855ba7b-e9e9-46d1-9fbb-16fde74eb4bc", + "method": "GET", + "error_message": { + "404": "The stage has been deleted" + } + } + ] + ], + "description": "Fields 'Stage' Equals Static Value Stage 1" + } + ] + } + ], + "invalid": false + }, + "llm_decision": null, + "yes_steps": [ + { + "id": "6ecd9c02-ef32-48ba-9427-7595a79b7010" + } + ], + "no_steps": [ + { + "id": "849a972e-cf67-4886-9f83-1c4f095ae531" + } + ], + "groups": [] + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": true + }, + { + "id": "c1e24aaa-7c5a-4f0c-b923-c0be2963f9a5", + "step_type": "merge_branches", + "order": 11, + "description": "Merge Branch", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "417ffb8e-4323-4e77-98ee-9fbec1746bf9", + "parent_condition": "", + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "849a972e-cf67-4886-9f83-1c4f095ae531", + "step_type": "code_step", + "order": 12, + "description": "Run Code on (0 inputs) and set (0 outputs)", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "417ffb8e-4323-4e77-98ee-9fbec1746bf9", + "parent_condition": "no", + "action_code_step": { + "runtime": "python-3-13", + "secrets": [], + "inputs": [], + "outputs": [], + "script": "outputs.log(f\"Got input value: {inputs.in_var}\")\noutputs.out_var = \"example output value\"" + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "6ecd9c02-ef32-48ba-9427-7595a79b7010", + "step_type": "plugin_code_step", + "order": 13, + "description": "Resolves the given object, reads its fields once, translates the filter text into a server-side query, and searches the object (newest first). Renders one 'Label: value' section per record into the context output (dropdown options and relationships resolved to their names, money and dates formatted), and the same records as JSON into records_json. An unknown field or filter operator raises with a message naming exactly what to fix in the filter text.", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "417ffb8e-4323-4e77-98ee-9fbec1746bf9", + "parent_condition": "yes", + "action_plugin_code_step": { + "plugin_app_api_name": "agentic_os", + "action_step_api_name": "gather_context", + "notify_plugin_developer_on_failure": true, + "secrets": [], + "inputs": [ + { + "input_type": "business_plugin_config", + "name": "object", + "variable": null, + "field": null, + "related_field": null, + "static_value": null, + "data_type": "string", + "is_list": false + }, + { + "input_type": "static_value", + "name": "filter", + "static_value": { + "value": null, + "custom_object": null, + "entity_record": null, + "employee": null + }, + "data_type": "string", + "is_list": false + }, + { + "input_type": "static_value", + "name": "fields", + "static_value": { + "value": null, + "custom_object": null, + "entity_record": null, + "employee": null + }, + "data_type": "string", + "is_list": false + }, + { + "input_type": "static_value", + "name": "limit", + "static_value": { + "value": null, + "custom_object": null, + "entity_record": null, + "employee": null + }, + "data_type": "number", + "is_list": false + }, + { + "input_type": "static_value", + "name": "heading", + "static_value": { + "value": null, + "custom_object": null, + "entity_record": null, + "employee": null + }, + "data_type": "string", + "is_list": false + } + ], + "outputs": [] + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "5a37bd8a-ef12-4ed1-b1f3-dc265d3fb23b", + "step_type": "next_or_finish", + "order": 14, + "description": "Next item or finish loop and continue", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "9ade9f3d-fdc9-408b-989e-ead912564bab", + "parent_condition": "", + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + } + ], + "variables": [ + { + "id": "8a25d92c-10d5-4269-9693-af1a360332a6", + "name": "array_var_obj", + "data_type": "entity", + "data_subtype": { + "id": "8c3f0149-6915-4370-940f-2e61848d6842", + "name": "object_with_workflow", + "description": null, + "entity_name": "object with workflow", + "object_name": "object with workflow", + "object_class": "custom_objects", + "object_type": "pipeline", + "default_on_activities": true, + "deleted": false + }, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:58.960475Z", + "deleted": false, + "is_array": true, + "is_array_item_variable": false, + "source_array_variable": null + }, + { + "id": "0658231a-cbaf-4815-b6e1-6bf230a29c3d", + "name": "string_arr_var", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:58.984891Z", + "deleted": false, + "is_array": true, + "is_array_item_variable": false, + "source_array_variable": null + }, + { + "id": "34a3e1ce-f1c2-46d2-ad7d-091e0e9eadd0", + "name": "test", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:58.998444Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": false, + "source_array_variable": null + }, + { + "id": "ce262c1a-0488-40da-9ca4-c47efe033d35", + "name": "array_var_obj_item", + "data_type": "entity", + "data_subtype": { + "id": "8c3f0149-6915-4370-940f-2e61848d6842", + "name": "object_with_workflow", + "description": null, + "entity_name": "object with workflow", + "object_name": "object with workflow", + "object_class": "custom_objects", + "object_type": "pipeline", + "default_on_activities": true, + "deleted": false + }, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:59.019703Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": true, + "source_array_variable": "array_var_obj" + }, + { + "id": "0cc78f02-3c3e-428d-a6d4-53f55bb7d2d0", + "name": "string_arr_var_item", + "data_type": "string", + "data_subtype": null, + "input_format": null, + "output_format": null, + "created": "2026-10-27T13:04:59.031935Z", + "deleted": false, + "is_array": false, + "is_array_item_variable": true, + "source_array_variable": "string_arr_var" + } + ], + "revision": 3, + "error_notification_email": null, + "skip_non_working_days": false, + "number_active": 0, + "number_paused": 0, + "number_completed": 0, + "priority_rank": 10, + "trigger_throttle_seconds": 0.5, + "created": "2026-10-27T07:56:22.047574-05:00", + "updated": "2026-10-27T08:09:36.060150-05:00", + "debug_next_n_executions": 0, + "error_notification_severity_level": "error" +} diff --git a/tests/test_automation_payloads.py b/tests/test_automation_payloads.py index 1a5f032..26c9316 100644 --- a/tests/test_automation_payloads.py +++ b/tests/test_automation_payloads.py @@ -2911,7 +2911,9 @@ def test_skipped_condition_without_branch_rejected(): AutomationDef.model_validate(spec) -@pytest.mark.parametrize("step_type", ["branch", "merge_branches"]) +@pytest.mark.parametrize( + "step_type", ["branch", "merge_branches", "next_or_finish", "exit_loop"] +) def test_configless_step_with_config_block_rejected(step_type): spec = _merged_condition_spec() merge = next(s for s in spec["steps"] if s["step_type"] == "merge_branches") @@ -2939,6 +2941,86 @@ def test_merge_under_non_initiator_fails_plan(patch_live_lookups): assert f"merge_branches step '{key}' has parent 'check'" in str(e.value) +def _loop_spec() -> dict: + """The shape of fixture `loops`, built from a spec.""" + + def var(key: str, parent: str | None, name: str, is_array: bool, order: int): + variable = {"name": name, "data_type": "string", "is_array": is_array} + block = {"sources": [], "variable": variable} + if is_array: + block["array_aggregation_mode"] = "add_all" + return _s( + key, parent, "initialize_variable", order, action_initialize_variable=block + ) + + def loop(key: str, parent: str, array: str, order: int): + block = {"array_variable": array, "item_variable": f"{array}_item"} + return _s( + key, parent, "loop", order, is_branch_group_initiator=True, step_loop=block + ) + + return { + "api_name": "loops", + "name": "Loops", + "type": "record_based", + "target_object": "patients", + "steps": [ + var("arr0", None, "arr0", True, 0), + var("arr0_item", "arr0", "arr0_item", False, 1), + var("arr1", "arr0_item", "arr1", True, 2), + var("arr1_item", "arr1", "arr1_item", False, 3), + loop("each", "arr1_item", "arr0", 4), + _s( + "check", + "each", + "condition", + 5, + is_branch_group_initiator=True, + step_condition={"type": "custom_filter", "filter_config": NAME_IS_X}, + ), + _s("wait", "check", "delay", 6, parent_branch="yes"), + _s("leave", "check", "exit_loop", 7, parent_branch="no"), + _s("merge", "check", "merge_branches", 8), + _s("next", "each", "next_or_finish", 9), + loop("each_again", "next", "arr1", 10), + _s("body", "each_again", "delay", 11), + _s("next_again", "each_again", "next_or_finish", 12), + _s("tail", "next_again", "delay", 13), + ], + } + + +def test_loop_spec_payload(patch_live_lookups): + by_key = {s["key"]: s for s in _build(_loop_spec())["steps"]} + assert by_key["each"]["step_loop"] == { + "loop_type": "for_each_item", + "array_variable": "arr0", + "item_variable": "arr0_item", + } + assert by_key["each"]["is_branch_group_initiator"] is True + for key in ("leave", "next"): + assert not [k for k in by_key[key] if k.startswith("step_")] + assert [k for k in by_key[key] if k.startswith("action_")] == [ + "action_on_failure" + ] + + +def test_loop_spec_needs_its_step_loop(): + spec = _loop_spec() + del spec["steps"][4]["step_loop"] + with pytest.raises(ValueError, match="'loop' requires an 'step_loop' block"): + AutomationDef.model_validate(spec) + + +def test_broken_loop_spec_fails_plan(patch_live_lookups): + spec = _loop_spec() + spec["steps"][9]["parent_key"] = "check" + with pytest.raises(PlanError) as e: + plan_create_automation(spec) + assert "loop 'each' has 0 next_or_finish children" in str(e.value) + assert "next_or_finish step 'next' has parent 'check'" in str(e.value) + + @pytest.mark.parametrize( ("field", "value"), [ @@ -3797,7 +3879,9 @@ def test_stop_execution_maps_to_its_config_block(): AutomationDef.model_validate(spec) -@pytest.mark.parametrize("step_type", ["branch", "merge_branches"]) +@pytest.mark.parametrize( + "step_type", ["branch", "merge_branches", "next_or_finish", "exit_loop"] +) def test_configless_step_with_stop_execution_block_rejected(step_type): spec = _merged_condition_spec() merge = next(s for s in spec["steps"] if s["step_type"] == "merge_branches") diff --git a/tests/test_steps.py b/tests/test_steps.py index 26cd06d..713a4aa 100644 --- a/tests/test_steps.py +++ b/tests/test_steps.py @@ -210,3 +210,112 @@ def test_validator_flags_goto_to_initialize_variable(kitchen: dict) -> None: insert_step(kitchen, goto, parent_key=leaf["key"], adopt_children=False) problems = validate_payload(kitchen) assert any("initialize_variable" in p and "go_to" in p for p in problems) + + +# --------------------------------------------------------------------------- +# Loops: the body hangs off the loop, its next_or_finish closes it, and the +# steps after the loop hang off the next_or_finish. +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def loops() -> dict: + """s04_loop: body s05_condition (merged, exit_loop on no), closed by + s09_next_or_finish; then s10_loop: body s11_delay, closed by + s12_next_or_finish, then s13_delay.""" + return live_to_payload(load_fixture("automations/loops.raw.json")) + + +def test_insert_under_a_loop_heads_the_body(loops: dict) -> None: + new = _mk_stop(loops) + report = insert_step(loops, new, parent_key="s04_loop") + assert report["adopted"] == ["s05_condition"] + assert find_step(loops, "s05_condition")["parent_key"] == new["key"] + assert find_step(loops, "s09_next_or_finish")["parent_key"] == "s04_loop" + assert validate_payload(loops) == [] + + +def test_insert_under_a_branch_group_leaves_its_merge() -> None: + branching = live_to_payload(load_fixture("automations/branching_test.raw.json")) + new = _mk_stop(branching) + report = insert_step(branching, new, parent_key="s13_branch") + assert "s14_merge_branches" not in report["adopted"] + assert find_step(branching, "s14_merge_branches")["parent_key"] == "s13_branch" + assert validate_payload(branching) == [] + + +def test_insert_under_next_or_finish_goes_right_after_the_loop(loops: dict) -> None: + new = _mk_stop(loops) + report = insert_step(loops, new, parent_key="s09_next_or_finish") + assert report["adopted"] == ["s10_loop"] + assert new["parent_key"] == "s09_next_or_finish" + assert validate_payload(loops) == [] + + +def test_remove_loop_with_steps_under_it_refuses(loops: dict) -> None: + with pytest.raises(PlanError) as e: + remove_step(loops, "s10_loop") + assert str(e.value) == ( + "'s10_loop' is a loop with steps under it (s11_delay, " + "s12_next_or_finish); removing it alone would leave its next_or_finish " + "without a loop. Remove its body first, or pass cascade=True to delete " + "the loop with its body and everything after it." + ) + + +def test_remove_loop_with_cascade_takes_what_follows(loops: dict) -> None: + report = remove_step(loops, "s10_loop", cascade=True) + assert report["removed"] == [ + "s10_loop", + "s11_delay", + "s12_next_or_finish", + "s13_delay", + ] + assert validate_payload(loops) == [] + + +@pytest.mark.parametrize("cascade", [False, True]) +def test_remove_next_or_finish_refuses(loops: dict, cascade: bool) -> None: + with pytest.raises(PlanError) as e: + remove_step(loops, "s12_next_or_finish", cascade=cascade) + assert str(e.value) == ( + "'s12_next_or_finish' closes loop 's10_loop' and can't be removed on its " + "own. Remove the loop instead." + ) + + +def test_remove_only_body_step_leaves_an_empty_body(loops: dict) -> None: + remove_step(loops, "s11_delay") + assert [c["key"] for c in children_of(loops, "s10_loop")] == ["s12_next_or_finish"] + assert validate_payload(loops) == [] + + +def test_steps_add_builds_an_exit_loop(loops: dict) -> None: + from kizen_builder.tools.automations import build_wire_step + + wire = build_wire_step({"step_type": "exit_loop"}, loops, "object_with_workflow") + assert wire["type"] == "exit_loop" + assert not [k for k in wire if k.startswith("step_")] + insert_step(loops, wire, parent_key="s11_delay") + assert validate_payload(loops) == [] + + +@pytest.mark.parametrize( + "step_type, counterpart", + [("loop", "next_or_finish"), ("next_or_finish", "loop")], +) +def test_steps_add_refuses_half_a_loop( + loops: dict, step_type: str, counterpart: str +) -> None: + from kizen_builder.tools.automations import build_wire_step + + spec = {"step_type": step_type} + if step_type == "loop": + spec["step_loop"] = {"array_variable": "arr0", "item_variable": "arr0_item"} + with pytest.raises(PlanError) as e: + build_wire_step(spec, loops, "object_with_workflow") + assert str(e.value) == ( + f"a {step_type} is added together with its {counterpart} and the loop's " + "item variable, which one 'steps add' can't do. Add it with " + "'kizen automations update --spec-file'." + ) diff --git a/tests/test_translate.py b/tests/test_translate.py index 4b52285..08c00db 100644 --- a/tests/test_translate.py +++ b/tests/test_translate.py @@ -41,8 +41,10 @@ "automations/form_submission.raw.json", "automations/kitchen_sink_triggers.raw.json", "automations/llm_comparison.raw.json", + "automations/loops.raw.json", "automations/merged_condition.raw.json", "automations/merged_condition_skipped.raw.json", + "automations/new_features_loops.raw.json", "automations/on_or_around_date_goto.raw.json", "automations/plugin_code_steps.raw.json", "automations/schedule_trigger.raw.json", @@ -105,6 +107,25 @@ def skipped_condition() -> dict: return live_to_payload(raw) +@pytest.fixture(scope="module") +def loops() -> dict: + """Spec-built, then read back: a loop whose body is a merged condition + with an `exit_loop` on its no branch, then a second loop after the + first's `next_or_finish`, then a step after that. Its fixed translation + round-tripped live with `semantic_diff == []` (2026-10-27).""" + return live_to_payload(load_fixture("automations/loops.raw.json")) + + +@pytest.fixture(scope="module") +def ui_loops() -> dict: + """UI-built: a loop over a record array, then a loop over a string array + whose body is a merged condition. Only the manual trigger is kept. The + call_llm step's `mcp_server`, `run_tools_as`, `max_sequential_turns` and + `tool_failure_policy` were popped because the CLI doesn't model them yet; + the change that models them restores them here.""" + return live_to_payload(load_fixture("automations/new_features_loops.raw.json")) + + def _steps_by_type(payload: dict, step_type: str) -> list[dict]: return [s for s in payload["steps"] if s["type"] == step_type] @@ -1213,15 +1234,6 @@ def test_validator_flags_initiator_without_merge_step(branching: dict) -> None: ] -def test_validator_leaves_loop_initiator_to_the_server(branching: dict) -> None: - """A live loop is an initiator closed by `next_or_finish`, not a merge - step. Loops are unsupported, so the PUT still 400s exactly as before.""" - payload = copy.deepcopy(branching) - payload["steps"] = [s for s in payload["steps"] if s["key"] != "s14_merge_branches"] - _step(payload, "s13_branch")["type"] = "loop" - assert validate_payload(payload) == [] - - def test_validator_flags_initiator_with_two_merge_steps( merged_condition: dict, ) -> None: @@ -1314,3 +1326,431 @@ def test_diff_wire_payloads_reads_omitted_step_level_keys_as_defaults( "error_notification_severity_level": ("warning", "inherit"), "continue_with_branch": ("no", ""), } + + +# --------------------------------------------------------------------------- +# Loops: `step_loop` by name, configless closers, and the server's loop rules +# (each rule a live 400, each accepted shape a live 200, 2026-10-27) +# --------------------------------------------------------------------------- + + +def test_loop_reads_its_step_loop_block() -> None: + from kizen_builder.tools.planners.automations import _block_field_for + + assert _block_field_for("loop") == "step_loop" + + +def test_loop_variables_are_sent_by_name(loops: dict, ui_loops: dict) -> None: + """Reads expand both variables; ids are refused on write.""" + blocks = [s["step_loop"] for s in _steps_by_type(loops, "loop")] + assert blocks == [ + { + "loop_type": "for_each_item", + "array_variable": a, + "item_variable": f"{a}_item", + } + for a in ("arr0", "arr1") + ] + (record_loop, _) = _steps_by_type(ui_loops, "loop") + assert record_loop["step_loop"]["array_variable"] == "array_var_obj" + + +def test_loop_builder_takes_names_or_read_dicts() -> None: + from kizen_builder.tools.planners.automations import _step_loop + + names = {"array_variable": "arr", "item_variable": "arr_item"} + read = {k: {"id": "x", "name": v, "is_array": False} for k, v in names.items()} + expected = {"loop_type": "for_each_item", **names} + assert _step_loop(names, None, None) == expected + assert _step_loop(read, None, None) == expected + + +def test_loop_keeps_its_initiator_flag(loops: dict) -> None: + assert all(s["is_branch_group_initiator"] for s in _steps_by_type(loops, "loop")) + + +@pytest.mark.parametrize("step_type", ["next_or_finish", "exit_loop"]) +def test_loop_closers_carry_no_config_block(loops: dict, step_type: str) -> None: + for s in _steps_by_type(loops, step_type): + assert not [k for k in s if k.startswith("step_")] + assert [k for k in s if k.startswith("action_")] == ["action_on_failure"] + + +def test_misread_config_block_is_refused() -> None: + """A block the translator doesn't read for the step's type would be + dropped from the PUT, as `step_loop` once was.""" + raw = load_fixture("automations/loops.raw.json") + step = next(s for s in raw["steps"] if s["step_type"] == "delay") + step["step_foo"] = {"x": 1} + key = synthesize_step_keys(raw)[step["id"]] + with pytest.raises(PlanError) as e: + live_to_payload(raw) + assert str(e.value) == ( + f"automation '{raw['name']}' step '{key}': step_foo would be lost on PUT " + "— the CLI doesn't read that block for a 'delay' step, so it stopped " + "before writing anything. Change this step in the Kizen UI until the CLI " + "supports it." + ) + + +def test_block_on_a_configless_step_is_refused() -> None: + raw = load_fixture("automations/loops.raw.json") + step = next(s for s in raw["steps"] if s["step_type"] == "exit_loop") + step["action_exit_loop"] = {"x": 1} + with pytest.raises(PlanError, match="action_exit_loop would be lost on PUT"): + live_to_payload(raw) + + +@pytest.mark.parametrize("value", [True, "on", 3, ["x"]]) +def test_scalar_step_level_key_is_not_refused(value: object) -> None: + """Only a config block (a dict) can be lost; a new step-level setting + must not lock every automation out of CLI writes.""" + raw = load_fixture("automations/loops.raw.json") + next(s for s in raw["steps"] if s["step_type"] == "delay")["step_new"] = value + live_to_payload(raw) + + +@pytest.mark.parametrize("value", [None, {}]) +def test_empty_misread_block_is_not_refused(value: object) -> None: + raw = load_fixture("automations/loops.raw.json") + next(s for s in raw["steps"] if s["step_type"] == "loop")["action_loop"] = value + assert "action_loop" not in json.dumps(live_to_payload(raw)) + + +def _ws(key: str, step_type: str, parent: str | None = None, branch: str = "", **kw): + return { + "key": key, + "type": step_type, + "parent_key": parent, + "parent_yes_no": branch, + **kw, + } + + +def _var(key: str, name: str, parent: str | None, data_type: str = "string", **kw): + variable = {"name": name, "data_type": data_type, "is_array": False, **kw} + return _ws( + key, + "initialize_variable", + parent, + action_initialize_variable={ + "sources": [], + "variable": variable, + }, + ) + + +def _loop(key: str, parent: str, array: str, item: str, branch: str = "", **kw): + block = { + "loop_type": "for_each_item", + "array_variable": array, + "item_variable": item, + } + return _ws(key, "loop", parent, branch, step_loop=block, **kw) + + +def _head(n: int = 1, data_type: str = "string", subtype: str | None = None): + """`arr` arrays and their `arr_item` variables, chained.""" + steps, prev = [], None + for i in range(n): + steps.append( + _var( + f"va{i}", + f"arr{i}", + prev, + data_type, + is_array=True, + data_subtype=subtype, + ) + ) + steps.append( + _var(f"vi{i}", f"arr{i}_item", f"va{i}", data_type, data_subtype=subtype) + ) + prev = f"vi{i}" + return steps, prev + + +def _graph(steps: list[dict]) -> dict: + for order, s in enumerate(steps): + s["order"] = order + return {"steps": steps, "triggers": []} + + +def _delay(key: str, parent: str, branch: str = "") -> dict: + return _ws(key, "delay", parent, branch, step_delay={"minutes": 1}) + + +def _base(**kw) -> list[dict]: + """head → loop `l` → body `b1`, closed by `n`, then `after`.""" + steps, p = _head() + return steps + [ + _loop("l", p, "arr0", "arr0_item", is_branch_group_initiator=True), + _delay("b1", "l"), + _ws("n", "next_or_finish", "l"), + _delay("after", "n"), + ] + + +def _nest(depth: int) -> list[dict]: + steps, parent = _head(depth) + for d in range(depth): + steps.append(_loop(f"l{d}", parent, f"arr{d}", f"arr{d}_item")) + parent = f"l{d}" + steps.append(_delay("inner", parent)) + steps += [_ws(f"n{d}", "next_or_finish", f"l{d}") for d in reversed(range(depth))] + return steps + + +def _accepted_shapes() -> dict[str, list[dict]]: + shapes: dict[str, list[dict]] = {"A2-plain": _base()} + s = _base() + s[1]["action_initialize_variable"]["variable"].update( + is_array_item_variable=True, source_array_variable="arr0" + ) + shapes["A3-item-with-read-flags"] = s + s = _base() + s[2].pop("is_branch_group_initiator") + shapes["A4-loop-not-flagged"] = s + s = _base() + s[-1]["parent_key"] = "l" + shapes["A5-after-hangs-off-loop"] = s + shapes["A7-empty-body"] = [x for x in _base() if x["key"] != "b1"] + head, p = _head(data_type="entity", subtype="obj-1") + shapes["A8-record-array"] = head + [ + _loop("l", p, "arr0", "arr0_item"), + _delay("b1", "l"), + _ws("n", "next_or_finish", "l"), + ] + shapes["A9-exit-after-body-step"] = _base() + [_ws("x", "exit_loop", "b1")] + shapes["B6-nest-4"] = _nest(4) + shapes["C1-nest-6"] = _nest(6) + head, p = _head() + shapes["B7-loop-on-yes-branch"] = head + [ + _ws("c", "condition", p), + _loop("l", "c", "arr0", "arr0_item", branch="yes"), + _delay("b1", "l"), + _ws("n", "next_or_finish", "l"), + _delay("no1", "c", "no"), + ] + shapes["B9-step-after-exit"] = _base() + [ + _ws("x", "exit_loop", "b1"), + _delay("after_x", "x"), + ] + shapes["B10-exit-direct-child"] = _base() + [_ws("x", "exit_loop", "l")] + shapes["B13-two-body-lanes"] = _base() + [_delay("b2", "l")] + shapes["B14-branch-group-in-body"] = _base() + [ + _ws("br", "branch", "l", is_branch_group_initiator=True), + _delay("lane1", "br"), + _delay("lane2", "br"), + _ws("m", "merge_branches", "br"), + ] + head, p = _head(2) + shapes["D2-exit-after-inner-loop"] = head + [ + _loop("l0", p, "arr0", "arr0_item"), + _loop("l1", "l0", "arr1", "arr1_item"), + _delay("ib", "l1"), + _ws("n1", "next_or_finish", "l1"), + _ws("x", "exit_loop", "n1"), + _ws("n0", "next_or_finish", "l0"), + ] + head, p = _head(data_type="uuid") + shapes["D3-uuid-array"] = head + [ + _loop("l", p, "arr0", "arr0_item"), + _ws("n", "next_or_finish", "l"), + ] + return shapes + + +_ACCEPTED = _accepted_shapes() + + +@pytest.mark.parametrize("name", sorted(_ACCEPTED)) +def test_loop_shapes_the_server_accepts_validate(name: str) -> None: + assert validate_payload(_graph(copy.deepcopy(_ACCEPTED[name]))) == [] + + +@pytest.mark.parametrize("fixture", ["loops", "ui_loops"]) +def test_captured_loop_graphs_validate(request, fixture: str) -> None: + assert validate_payload(request.getfixturevalue(fixture)) == [] + + +def _problems(steps: list[dict]) -> list[str]: + return validate_payload(_graph(steps)) + + +_NOF_COUNT = ( + "loop 'l' has {n} next_or_finish children; the server requires exactly " + "one, which closes the loop body (server 400s otherwise)" +) + + +def test_loop_without_next_or_finish_is_flagged() -> None: + steps = [s for s in _base() if s["key"] not in ("n", "after")] + assert _problems(steps) == [_NOF_COUNT.format(n=0)] + + +def test_loop_with_two_next_or_finish_is_flagged() -> None: + assert _problems(_base() + [_ws("n2", "next_or_finish", "l")]) == [ + _NOF_COUNT.format(n=2) + ] + + +def test_merge_step_closing_a_loop_is_flagged() -> None: + steps = _base() + steps[-2] = _ws("n", "merge_branches", "l") + assert _problems(steps) == [ + _NOF_COUNT.format(n=0), + "loop 'l' has a merge_branches child; a loop is closed by next_or_finish, " + "not merge_branches (server 400s otherwise)", + ] + + +_NOT_UNDER_LOOP = ( + "next_or_finish step '{k}' has parent '{p}', which is not a loop; it must " + "hang directly off the loop it closes (server 400s otherwise)" +) + + +def test_next_or_finish_chained_after_the_body_is_flagged() -> None: + steps = _base() + steps[-2]["parent_key"] = "b1" + assert _problems(steps) == [ + _NOF_COUNT.format(n=0), + _NOT_UNDER_LOOP.format(k="n", p="b1"), + ] + + +def test_next_or_finish_closing_a_condition_is_flagged() -> None: + steps = _base() + [ + _ws("c", "condition", "b1", is_branch_group_initiator=True), + _ws("cn", "next_or_finish", "c"), + ] + assert _NOT_UNDER_LOOP.format(k="cn", p="c") in _problems(steps) + + +_EXIT = "exit_loop step 'x' is not inside a loop body (server 400s otherwise)" + + +def test_exit_loop_outside_any_loop_is_flagged() -> None: + head, p = _head() + assert _problems(head + [_delay("d", p), _ws("x", "exit_loop", "d")]) == [_EXIT] + + +def test_exit_loop_after_the_next_or_finish_is_flagged() -> None: + assert _problems(_base() + [_ws("x", "exit_loop", "n")]) == [_EXIT] + + +def test_loop_over_a_scalar_is_flagged() -> None: + steps = _base() + steps[0]["action_initialize_variable"]["variable"]["is_array"] = False + assert _problems(steps) == [ + "loop 'l' iterates 'arr0', which is not an array variable of records, " + "strings or UUIDs (server 400s otherwise)" + ] + + +def test_loop_over_an_undeclared_array_is_flagged() -> None: + steps = _base() + steps[2]["step_loop"]["array_variable"] = "nope" + assert _problems(steps) == [ + "loop 'l' iterates 'nope', which is not an array variable of records, " + "strings or UUIDs (server 400s otherwise)" + ] + + +_ITEM = ( + "loop 'l' item variable 'arr0_item' must be declared by an " + "initialize_variable step as a single {t} variable, the type of 'arr0' " + "(server 400s otherwise)" +) + + +def test_undeclared_item_variable_is_flagged() -> None: + steps = [s for s in _base() if s["key"] != "vi0"] + steps[1]["parent_key"] = "va0" + assert _problems(steps) == [_ITEM.format(t="string")] + + +@pytest.mark.parametrize( + "change", + [{"data_type": "uuid"}, {"is_array": True}], + ids=["other-type", "array"], +) +def test_mismatched_item_variable_is_flagged(change: dict) -> None: + steps = _base() + steps[1]["action_initialize_variable"]["variable"].update(change) + assert _problems(steps) == [_ITEM.format(t="string")] + + +def test_item_variable_of_another_object_is_flagged() -> None: + steps = _ACCEPTED["A8-record-array"] + steps = copy.deepcopy(steps) + steps[1]["action_initialize_variable"]["variable"]["data_subtype"] = "obj-2" + assert _problems(steps) == [_ITEM.format(t="entity")] + + +def test_loops_sharing_an_item_variable_are_flagged() -> None: + steps = _base() + steps[-1:] = [ + _loop("l2", "n", "arr0", "arr0_item"), + _ws("n2", "next_or_finish", "l2"), + ] + assert _problems(steps) == [ + "loops 'l' and 'l2' share item variable 'arr0_item'; each loop needs its " + "own (server 400s otherwise)" + ] + + +def _show(monkeypatch, fixture: str, *args: str) -> str: + from typer.testing import CliRunner + + import kizen_builder.cli as cli + from kizen_builder.api import automations as auto_api + + raw = load_fixture(fixture) + monkeypatch.setattr( + auto_api, + "list_automations", + lambda c: [{"api_name": raw["api_name"], "id": raw["id"]}], + ) + monkeypatch.setattr(auto_api, "get_automation", lambda c, i: raw) + result = CliRunner().invoke( + cli.app, ["automations", "show", raw["api_name"], *args] + ) + assert result.exit_code == 0, result.output + return result.stdout + + +def test_show_labels_a_loop_with_its_variables(monkeypatch) -> None: + out = _show(monkeypatch, "automations/loops.raw.json") + assert "s04_loop [for each arr0 as arr0_item] Loop" in out + assert "s10_loop [for each arr1 as arr1_item] Loop" in out + + +def test_show_puts_the_body_under_the_loop_and_the_rest_after_it( + monkeypatch, +) -> None: + out = _show(monkeypatch, "automations/loops.raw.json") + tree = out[out.index("└── s04_loop") :] + assert [line.rstrip() for line in tree.splitlines()] == [ + "└── s04_loop [for each arr0 as arr0_item] Loop", + " ├── body", + " │ └── s05_condition Condition", + " │ ├── yes", + " │ │ └── s06_delay Delay", + " │ ├── no", + " │ │ └── s07_exit_loop Exit Loop", + " │ └── s08_merge_branches Merge Branches", + " └── s09_next_or_finish Next/Finish", + " └── s10_loop [for each arr1 as arr1_item] Loop", + " ├── body", + " │ └── s11_delay Delay", + " └── s12_next_or_finish Next/Finish", + " └── s13_delay Delay", + ] + + +def test_show_json_is_the_translated_payload(monkeypatch) -> None: + out = _show(monkeypatch, "automations/loops.raw.json", "--json") + raw = load_fixture("automations/loops.raw.json") + assert json.loads(out)["payload"] == live_to_payload(raw)