From 66fdf287592941120b6b2ed542b0ab95dabaf944 Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 10:16:24 -0400 Subject: [PATCH 1/8] Add a kizen service-accounts command group A Call LLM step runs its MCP tools as a service account, and builds had no way to make one without clicking through Settings. The new group lists, creates, updates, deletes and shows the history of integration accounts, with permission groups by name as roles takes them. Kizen returns the new account's API key in full on create, under an api_key object the schema doesn't declare. The API wrapper drops it before anything else sees the response, so no plan, result, table or --json output can contain it; reads show only Kizen's own preview. A rename sends the current api_name with the new name, because Kizen re-derives it from any PATCH that includes name. Update and delete refuse automation and plugin accounts, which Kizen or a plugin manages, and create refuses a duplicate name, which Kizen answers with a 500. --- CHANGELOG.md | 3 + CONTRIBUTING.md | 4 + scripts/cli-tree-baseline.txt | 143 +++++ src/kizen_builder/api/service_accounts.py | 78 +++ src/kizen_builder/cli/__init__.py | 1 + src/kizen_builder/cli/service_accounts.py | 286 ++++++++++ src/kizen_builder/docs/commands.md | 7 + src/kizen_builder/docs/reference.md | 1 + src/kizen_builder/docs/specs/README.md | 1 + .../docs/specs/service-account.md | 112 ++++ src/kizen_builder/docs/specs/team.md | 3 + src/kizen_builder/tools/permissions.py | 8 +- .../tools/planners/service_accounts.py | 182 +++++++ src/kizen_builder/tools/plans.py | 26 + src/kizen_builder/tools/service_accounts.py | 45 ++ tests/drift/contracts.py | 15 + tests/drift/schema_snapshot.json | 63 ++- tests/test_execute_dispatch.py | 6 + tests/test_service_accounts.py | 500 ++++++++++++++++++ 19 files changed, 1479 insertions(+), 5 deletions(-) create mode 100644 src/kizen_builder/api/service_accounts.py create mode 100644 src/kizen_builder/cli/service_accounts.py create mode 100644 src/kizen_builder/docs/specs/service-account.md create mode 100644 src/kizen_builder/tools/planners/service_accounts.py create mode 100644 src/kizen_builder/tools/service_accounts.py create mode 100644 tests/test_service_accounts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bb40..fb65167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,9 @@ called out explicitly under **Changed** or **Removed**. ### Added +- **`kizen service-accounts`** lists, creates, updates, deletes and shows + the history of integration service accounts, with permission groups by + name. API keys are never printed. - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dab20d8..98a7afb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,7 @@ src/kizen_builder/ records.py # record-level reads/writes (custom objects + built-ins) saved_views.py # filter groups / quick filters / column templates schema.py # schema lookups backing the filtering DSL + service_accounts.py # service-account CRUD + history (strips the key from create) smart_connectors.py # smart-connector CRUD, scripts, executions team.py # team-member reads + role changes tools/ # Tool functions. What the CLI mostly calls. @@ -132,6 +133,7 @@ src/kizen_builder/ plans.py # Plan / PlanOperation models, apply_plan() records.py # get_record(), search_records() saved_views.py # list/get filter groups, quick filters, column templates + service_accounts.py # resolve a service account; list/get/history smart_connectors/ # inspection + authoring + local pull/run/push dev loop __init__.py # re-export facade — `from ... import smart_connectors as sct` _common.py # MARKER_NAME, execution-metadata keys, shared helpers @@ -165,6 +167,7 @@ src/kizen_builder/ pipeline_stages.py # plan_create/update/remove stages, record stage-move records.py # plan_create/update/delete records (data, not schema) saved_views.py # plan_create/update/delete the three saved-view kinds + service_accounts.py # plan_create/update/delete integration service accounts team.py # plan_update_team_member_roles filtering.py # Filter DSL (Field, All/Any, search/filter-config rendering). translate.py # Automation GET → PUT wire translation. @@ -187,6 +190,7 @@ src/kizen_builder/ filters.py # kizen filters (the operator reference) team.py # kizen team permissions.py # kizen roles + kizen permissions + service_accounts.py # kizen service-accounts automations.py # kizen automations — reads, start, diagnostics steps.py # kizen automations steps messages.py # kizen messages diff --git a/scripts/cli-tree-baseline.txt b/scripts/cli-tree-baseline.txt index 2b1c4a6..1fe8f20 100644 --- a/scripts/cli-tree-baseline.txt +++ b/scripts/cli-tree-baseline.txt @@ -32,6 +32,8 @@ │ team Look up team members and change their roles. │ │ roles Read and manage roles. │ │ permissions Read and manage permission groups + the permissions catalog. │ +│ service-accounts Read and manage integration service accounts (run-tools-as identities). See │ +│ `kizen docs show service-account`. │ │ automations Read, create, update, and run automations. │ │ messages Email templates and automation-scoped messages — the content │ │ notify_member_via_email steps reference (see `kizen docs show │ @@ -3372,6 +3374,126 @@ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Usage: service-accounts [OPTIONS] COMMAND [ARGS]... + + Read and manage integration service accounts (run-tools-as identities). See `kizen docs show + service-account`. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────╮ +│ list List service accounts with their groups and key count (previews only). │ +│ get Show one service account and its permission groups. │ +│ history Show a service account's history events. │ +│ create Create an integration service account. Its API key is never shown. │ +│ update Update an integration service account. Only the flags you pass change. │ +│ delete Delete (archive) an integration service account. This is permanent. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Usage: service-accounts create [OPTIONS] + + Create an integration service account. Its API key is never shown. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ * --name Account name; Kizen derives api_name. [required] │ +│ --group -g Permission group name or UUID (repeatable). With none, Kizen │ +│ creates the account disabled. │ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts delete [OPTIONS] {account} + + Delete (archive) an integration service account. This is permanent. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * account Service account name, api_name or UUID. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts get [OPTIONS] {account} + + Show one service account and its permission groups. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * account Service account name, api_name or UUID. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts history [OPTIONS] {account} + + Show a service account's history events. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * account Service account name, api_name or UUID. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --limit -n Max events to return. [default: 25] │ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts list [OPTIONS] + + List service accounts with their groups and key count (previews only). + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --search -s Filter by name. │ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + + Usage: service-accounts update [OPTIONS] {account} + + Update an integration service account. Only the flags you pass change. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * account Service account name, api_name or UUID. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --name Rename the account (api_name is kept). │ +│ --api-name Set the api_name. │ +│ --group -g Replace the account's permission groups with these (name or UUID, │ +│ repeatable). │ +│ --clear-groups Remove every permission group; Kizen disables the account. │ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Endpoint behavior and quirks: see `kizen docs show service-account` + + Usage: smart-connectors [OPTIONS] COMMAND [ARGS]... Build, inspect, and iterate on smart connectors. Read verbs @@ -4901,6 +5023,27 @@ ===== roles update ===== +===== service-accounts ===== + + +===== service-accounts create ===== + + +===== service-accounts delete ===== + + +===== service-accounts get ===== + + +===== service-accounts history ===== + + +===== service-accounts list ===== + + +===== service-accounts update ===== + + ===== smart-connectors ===== diff --git a/src/kizen_builder/api/service_accounts.py b/src/kizen_builder/api/service_accounts.py new file mode 100644 index 0000000..1e943b2 --- /dev/null +++ b/src/kizen_builder/api/service_accounts.py @@ -0,0 +1,78 @@ +"""Service accounts against the Kizen API (``/api/service-accounts``). + +A service account is a non-human employee that holds API keys and permission +groups. Integration accounts (``account_type: service_integration``) are the +ones a business creates; automation and plugin accounts are Kizen's own. + +Nothing here touches ``…/api-tokens/*``: the CLI never reads, reveals or +rotates a key. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api.client import KizenClient + +_BASE = "/api/service-accounts" + + +def list_service_accounts( + client: KizenClient, search: str | None = None +) -> list[dict[str, Any]]: + """GET /api/service-accounts — every account, previews only. + + ``search`` filters by name; the schema doesn't declare it, but it works + (confirmed live 2026-10-27). + """ + params: dict[str, Any] = {"page_size": 100} + if search: + params["search"] = search + return client.paginate(_BASE, params) + + +def get_service_account(client: KizenClient, account_id: str) -> dict[str, Any]: + """GET /api/service-accounts/{id} — the list row shape, never a key.""" + return client.get(f"{_BASE}/{account_id}") + + +def create_service_account( + client: KizenClient, payload: dict[str, Any] +) -> dict[str, Any]: + """POST /api/service-accounts — ``{name, permission_groups}``. + + The response carries the new account's API key in full under an + ``api_key`` object the schema doesn't declare (confirmed live 2026-10-27). + It is removed here, before anything else can see it, so no plan result, + table, log or ``--json`` output can contain it. + """ + resp = client.post(_BASE, json=payload) + if isinstance(resp, dict): + resp.pop("api_key", None) + return resp + + +def update_service_account( + client: KizenClient, account_id: str, payload: dict[str, Any] +) -> dict[str, Any]: + """PATCH /api/service-accounts/{id} — only the supplied keys change. + + A body that includes ``name`` re-derives ``api_name`` from it unless + ``api_name`` is sent too (confirmed live 2026-10-27). + """ + return client.patch(f"{_BASE}/{account_id}", json=payload) + + +def delete_service_account(client: KizenClient, account_id: str) -> None: + """DELETE /api/service-accounts/{id} — archives it; no read can see it after.""" + client.delete(f"{_BASE}/{account_id}") + + +def list_service_account_history( + client: KizenClient, account_id: str, limit: int +) -> list[dict[str, Any]]: + """GET /api/service-accounts/{id}/history — one page of up to ``limit`` events.""" + resp = client.get(f"{_BASE}/{account_id}/history", params={"page_size": limit}) + if isinstance(resp, dict): + return list(resp.get("results") or []) + return list(resp or []) diff --git a/src/kizen_builder/cli/__init__.py b/src/kizen_builder/cli/__init__.py index fdbff26..46033cf 100644 --- a/src/kizen_builder/cli/__init__.py +++ b/src/kizen_builder/cli/__init__.py @@ -37,6 +37,7 @@ from kizen_builder.cli import filters # noqa: F401 from kizen_builder.cli import team # noqa: F401 from kizen_builder.cli import permissions # noqa: F401 +from kizen_builder.cli import service_accounts # noqa: F401 from kizen_builder.cli import automations # noqa: F401 from kizen_builder.cli import steps # noqa: F401 from kizen_builder.cli import messages # noqa: F401 diff --git a/src/kizen_builder/cli/service_accounts.py b/src/kizen_builder/cli/service_accounts.py new file mode 100644 index 0000000..14e8575 --- /dev/null +++ b/src/kizen_builder/cli/service_accounts.py @@ -0,0 +1,286 @@ +"""`kizen service-accounts` — integration service accounts and their +permission groups. No command reads, reveals or prints an API key. +""" + +from __future__ import annotations + +from typing import Any + +import typer +from rich.markup import escape +from rich.table import Table +from rich.text import Text + +from kizen_builder import output as out +from kizen_builder.api.client import KizenClient +from kizen_builder.cli._mutations import _run_mutation +from kizen_builder.cli._shared import ( + JSON_OPTION, + OUTPUT_OPTION, + app, + cli_errors, + console, + err_console, +) +from kizen_builder.cli.permissions import _resolve_group_id +from kizen_builder.config import load_env_config +from kizen_builder.tools import service_accounts as sa_tools +from kizen_builder.tools.planners import service_accounts as sa_planners + +_EPILOG = "Endpoint behavior and quirks: see `kizen docs show service-account`" + +sa_app = typer.Typer( + help=( + "Read and manage integration service accounts (run-tools-as " + "identities). See `kizen docs show service-account`." + ), + no_args_is_help=True, +) +app.add_typer(sa_app, name="service-accounts") + +_REF_HELP = "Service account name, api_name or UUID." +_DRY_RUN = typer.Option(False, "--dry-run", help="Show the plan without applying.") +_YES = typer.Option(False, "--yes", "-y", help="Skip the y/N confirmation prompt.") +_JSON_WRITE = typer.Option( + False, "--json", help="Emit JSON (plan with --dry-run, results otherwise)." +) + + +def _resolve_id(ref: str) -> str: + with cli_errors(LookupError), KizenClient(load_env_config()) as client: + return sa_tools.resolve_service_account(client, ref)["id"] + + +def _group_names(row: dict[str, Any]) -> str: + return ", ".join( + g.get("name") or g.get("id") for g in row.get("permission_groups") or [] + ) + + +@sa_app.command("list", epilog=_EPILOG) +def sa_list( + search: str = typer.Option(None, "--search", "-s", help="Filter by name."), + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """List service accounts with their groups and key count (previews only).""" + fmt = out.resolve_format(output, json_out) + with cli_errors(): + items = sa_tools.list_service_accounts(search=search) + + def table() -> None: + t = Table(title="Service accounts") + for col in ("name", "api_name", "type", "status", "groups"): + t.add_column(col) + t.add_column("keys", justify="right") + t.add_column("key preview", style="dim") + for a in items: + t.add_row( + Text(a.get("name") or "—"), + Text(a.get("api_name") or "—"), + a.get("account_type") or "—", + a.get("status") or "—", + Text(_group_names(a) or "—"), + str(a.get("api_token_count") or 0), + a.get("primary_api_token_preview") or "—", + ) + console.print(t) + if not items: + console.print("[dim]No service accounts.[/dim]") + + out.render( + fmt, + json_data=items, + table=table, + csv_rows=items, + csv_columns=[ + out.Column("id", "id"), + out.Column("name", "name"), + out.Column("api_name", "api_name"), + out.Column("account_type", "account_type"), + out.Column("status", "status"), + out.Column("groups", _group_names), + out.Column("api_token_count", "api_token_count"), + out.Column("primary_api_token_preview", "primary_api_token_preview"), + ], + ) + + +@sa_app.command("get", epilog=_EPILOG) +def sa_get( + account: str = typer.Argument(..., help=_REF_HELP), + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """Show one service account and its permission groups.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(LookupError): + d = sa_tools.get_service_account(account) + + def table() -> None: + console.print( + f"[bold]{escape(d.get('name') or '—')}[/bold] [dim](id={d['id']})[/dim]", + emoji=False, + ) + for label, key in ( + ("api_name", "api_name"), + ("type", "account_type"), + ("status", "status"), + ("email", "email"), + ("keys", "api_token_count"), + ("key preview", "primary_api_token_preview"), + ("created", "created"), + ("last login", "last_login"), + ): + value = d.get(key) + console.print( + f"[dim]{label}:[/dim] {escape(str(value if value is not None else '—'))}", + emoji=False, + ) + t = Table(title="Permission groups") + t.add_column("name") + t.add_column("id", style="dim") + for g in d.get("permission_groups") or []: + t.add_row(Text(g.get("name") or "—"), g.get("id") or "—") + console.print(t) + if not d.get("permission_groups"): + console.print("[dim]No permission groups (the account is disabled).[/dim]") + + out.render(fmt, json_data=d, table=table) + + +@sa_app.command("history", epilog=_EPILOG) +def sa_history( + account: str = typer.Argument(..., help=_REF_HELP), + limit: int = typer.Option(25, "--limit", "-n", help="Max events to return."), + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """Show a service account's history events.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(LookupError): + events = sa_tools.list_service_account_history(account, limit=limit) + + def by(e: dict[str, Any]) -> str: + who = e.get("initiator") or {} + full = " ".join(p for p in (who.get("first_name"), who.get("last_name")) if p) + return who.get("email") or full or "—" + + def table() -> None: + if not events: + console.print("No history.") + return + t = Table(title="History") + t.add_column("when", style="dim") + t.add_column("event") + t.add_column("by") + for e in events: + t.add_row( + e.get("created") or "—", + Text(e.get("type_display_name") or e.get("type_name") or "—"), + Text(by(e)), + ) + console.print(t) + + out.render( + fmt, + json_data=events, + table=table, + csv_rows=events, + csv_columns=[ + out.Column("when", "created"), + out.Column("event", "type_display_name"), + out.Column("by", by), + ], + ) + + +# --- mutations -------------------------------------------------------------- + + +@sa_app.command("create", epilog=_EPILOG) +def sa_create( + name: str = typer.Option( + ..., "--name", help="Account name; Kizen derives api_name." + ), + group: list[str] = typer.Option( + None, + "--group", + "-g", + help="Permission group name or UUID (repeatable). With none, Kizen " + "creates the account disabled.", + ), + dry_run: bool = _DRY_RUN, + yes: bool = _YES, + json_out: bool = _JSON_WRITE, +) -> None: + """Create an integration service account. Its API key is never shown.""" + group_ids = [_resolve_group_id(g) for g in (group or [])] + _run_mutation( + lambda: sa_planners.plan_create_service_account(name, group_ids), + dry_run=dry_run, + yes=yes, + json_out=json_out, + ) + + +@sa_app.command("update", epilog=_EPILOG) +def sa_update( + account: str = typer.Argument(..., help=_REF_HELP), + name: str = typer.Option( + None, "--name", help="Rename the account (api_name is kept)." + ), + api_name: str = typer.Option(None, "--api-name", help="Set the api_name."), + group: list[str] = typer.Option( + None, + "--group", + "-g", + help="Replace the account's permission groups with these (name or UUID, " + "repeatable).", + ), + clear_groups: bool = typer.Option( + False, + "--clear-groups", + help="Remove every permission group; Kizen disables the account.", + ), + dry_run: bool = _DRY_RUN, + yes: bool = _YES, + json_out: bool = _JSON_WRITE, +) -> None: + """Update an integration service account. Only the flags you pass change.""" + if group and clear_groups: + err_console.print("[red]error:[/red] pass --group or --clear-groups, not both.") + raise typer.Exit(code=2) + account_id = _resolve_id(account) + changes: dict[str, Any] = {} + if name is not None: + changes["name"] = name + if api_name is not None: + changes["api_name"] = api_name + if group: + changes["permission_groups"] = [_resolve_group_id(g) for g in group] + elif clear_groups: + changes["permission_groups"] = [] + _run_mutation( + lambda: sa_planners.plan_update_service_account(account_id, changes), + dry_run=dry_run, + yes=yes, + json_out=json_out, + ) + + +@sa_app.command("delete", epilog=_EPILOG) +def sa_delete( + account: str = typer.Argument(..., help=_REF_HELP), + dry_run: bool = _DRY_RUN, + yes: bool = _YES, + json_out: bool = _JSON_WRITE, +) -> None: + """Delete (archive) an integration service account. This is permanent.""" + account_id = _resolve_id(account) + _run_mutation( + lambda: sa_planners.plan_delete_service_account(account_id), + dry_run=dry_run, + yes=yes, + json_out=json_out, + ) diff --git a/src/kizen_builder/docs/commands.md b/src/kizen_builder/docs/commands.md index f515c50..3d153ac 100644 --- a/src/kizen_builder/docs/commands.md +++ b/src/kizen_builder/docs/commands.md @@ -72,6 +72,10 @@ kizen permissions groups # list permission groups (raw) kizen permissions group # one group as a sectioned permission map (UI-style sliders); --field-permissions adds per-field rows; --raw for wire JSON kizen permissions meta # the permissions catalog (sections, capabilities, defaults) +kizen service-accounts list [--search ] # name, api_name, type, status, groups, key count + preview (never a key) +kizen service-accounts get # one account's fields + permission groups +kizen service-accounts history [-n 25] # history events (when, event, by) + kizen activities list [--object ] [--search ] # activity types kizen activities get # one activity type: metadata, fields, visibility rules kizen activities fields list # fields on an activity type @@ -295,6 +299,9 @@ kizen team update [--role R ...] [--add-role R ...] [--remove-role R .. kizen permissions group-create --name X [--base default|clone] [--source-group ] [--settings-file f] kizen permissions group-update --settings-file f # raise/lower controls on an EXISTING group; same op shapes as group-create kizen permissions group-delete +kizen service-accounts create --name X [--group ...] # integration account; Kizen derives api_name; no groups = disabled +kizen service-accounts update [--name Y] [--api-name y] [--group ... | --clear-groups] # --group REPLACES the set; rename keeps api_name +kizen service-accounts delete # permanent archive; integration accounts only # patch one automation step (GET → translate → mutate node → validate → atomic PUT) kizen automations steps edit [--spec-file path | < stdin] diff --git a/src/kizen_builder/docs/reference.md b/src/kizen_builder/docs/reference.md index 714a3b5..67c7f89 100644 --- a/src/kizen_builder/docs/reference.md +++ b/src/kizen_builder/docs/reference.md @@ -24,6 +24,7 @@ the wire formats, endpoints, and confirmed quirks together. | Record layouts | `layout` | | Filter groups, quick filters, column templates | `saved-views` | | Roles & permission groups | `permission-group` | +| Service accounts (run-tools-as identities) | `service-account` | | Smart connectors (API + local dev loop) | `smart-connectors` | | A connector's flow spec (`configure-flow`) | `smart-connector-flow` | | Email templates & automation messages | `email-templates` | diff --git a/src/kizen_builder/docs/specs/README.md b/src/kizen_builder/docs/specs/README.md index e7b6537..adf6e34 100644 --- a/src/kizen_builder/docs/specs/README.md +++ b/src/kizen_builder/docs/specs/README.md @@ -27,6 +27,7 @@ field Kizen rejected. | `filter-groups`, `quick-filters`, `columns` create\|update | [saved-views.md](saved-views.md) | filter DSL / `configuration_json` | | `permissions group-create`, `roles …` | [permission-group.md](permission-group.md) | shaping-op list | | `team search\|get\|update` | [team.md](team.md) | — | +| `service-accounts …` (flag-driven) | [service-account.md](service-account.md) | — | | `smart-connectors` (all but `configure-flow`) | [smart-connectors.md](smart-connectors.md) | — | | `smart-connectors configure-flow` | [smart-connector-flow.md](smart-connector-flow.md) | execution variables + load steps | | `messages …` (and email template ground truth) | [email-templates.md](email-templates.md) | — | diff --git a/src/kizen_builder/docs/specs/service-account.md b/src/kizen_builder/docs/specs/service-account.md new file mode 100644 index 0000000..87fea06 --- /dev/null +++ b/src/kizen_builder/docs/specs/service-account.md @@ -0,0 +1,112 @@ +# Surface: service accounts + +**Consumed by:** `kizen service-accounts list|get|history|create|update|delete`. +Flag-driven; no spec shape. Run each command's `--help` for its flags. + +A service account is a non-human identity with permission groups and API keys. +A Call LLM step can run its MCP tools as one (`run_tools_as`). The CLI creates +and changes **integration** accounts only, and never reads, reveals or prints a +key. + +```bash +kizen service-accounts create --name "Ops Bot" -g "Integrations" --dry-run +kizen service-accounts update "Ops Bot" --group "Integrations" --group "Reporting" +``` + +Name accounts by **name** in specs and scripts. A CLI rename keeps the +api_name, but any rename that doesn't send it changes it (below). + +--- + +## Endpoints (confirmed live 2026-10-27) + +| Call | Used by | +|---|---| +| `GET /api/service-accounts` (page-number pagination, `?search=` by name) | `list`, every name lookup | +| `GET /api/service-accounts/{id}` | `get`, the update/delete planners | +| `POST /api/service-accounts` `{name, permission_groups}` | `create` | +| `PATCH /api/service-accounts/{id}` (only the keys sent change) | `update` | +| `DELETE /api/service-accounts/{id}` | `delete` | +| `GET /api/service-accounts/{id}/history` | `history` | + +`?search=` isn't in the schema, but it filters by name. + +Read rows: `id, name, api_name, account_type, status, email, +permission_groups[{id, name}], api_token_count, primary_api_token_preview, +secondary_api_token_preview, created, updated, last_login`. A read never +carries a key. + +## Account types + +- `service_integration`: accounts a business creates. The only type the CLI + updates or deletes. +- `service_automation`: Kizen's own automation account (`api_name` null). +- `service_automationplugin`: an account a plugin installs. **It isn't in the + schema's `account_type` enum** (`service_automation`, `service_integration`). + +`update` and `delete` refuse the last two at plan time. + +## api_name + +- **Kizen derives it from `name` on create and ignores a supplied one** + (`Ops Bot (MCP)!` → `ops_bot_mcp`). The plan previews the prediction; the + result reports what Kizen derived and flags a difference. +- **Any PATCH that includes `name` re-derives it**, even when the name is + unchanged. Sending `api_name` in the same body keeps it. `update --name` + therefore sends the current api_name with the new name, so a rename keeps + the identifier. +- `PATCH {api_name}` alone sets it, slugified (`"Bad Name!"` → `bad_name`). + +## Status follows the group set + +No permission groups means `status: "disabled"`; one or more means `active`. +That holds on create and on every PATCH. A `status` key in the body is +ignored silently. `suspended` is in the schema's enum but wasn't seen. + +## Duplicates + +- **A duplicate `name` on create answers 500**, and no account appears. The + create planner refuses a name that matches an existing account + case-insensitively, and a derived api_name another account holds. +- A duplicate api_name, or a rename onto an existing name or api_name, answers + 400 `{"name": ["A service account with this name already exists."]}`. The + error is keyed `name` even when the clash is the api_name. +- An unknown permission-group id answers 400 under `permission_groups`. + +## Delete is a permanent archive + +`DELETE` answers 204. Afterwards the account is gone from every read: GET, +list and typeahead give 404 or nothing, and a second DELETE or a PATCH gives +404. Nothing reachable through the API can see or restore it. The same name +can be created again at once. + +Not probed: whether its keys stop working, and what an automation whose +`run_tools_as` points at it reads back. + +## API keys + +- **The create response carries the new key in full**, under a top-level + `api_key` object the schema doesn't declare. The CLI drops `api_key` from + the response the moment it arrives, so no plan, result, table, log or + `--json` output can contain it. +- A read shows `primary_api_token_preview`: the key's first 3 characters + followed by asterisks, or null with no key. `api_token_count` is the number + of keys. +- The CLI calls no `…/api-tokens/*` endpoint. To see or rotate a key, use + Settings in Kizen. + +## History + +Page-number paginated events in the record-timeline shape (`created`, +`type_name`, `type_display_name`, `initiator{email, first_name, last_name}`, +…). An account created through the API has no events, even after renames and +group changes. + +## Run-tools-as + +`GET /api/automation2/automations/run-tools-as-options` lists the accounts a +Call LLM step can run its tools as. It includes `service_automation` and every +integration account, **even a disabled one**. It never lists the plugin +account. Whether a disabled account's tool calls succeed wasn't probed. + +Service accounts aren't team members: see [`team.md`](team.md). diff --git a/src/kizen_builder/docs/specs/team.md b/src/kizen_builder/docs/specs/team.md index 3e4a43e..fa81033 100644 --- a/src/kizen_builder/docs/specs/team.md +++ b/src/kizen_builder/docs/specs/team.md @@ -73,6 +73,9 @@ and falls back to `/api/team/typeahead` (name/email search) otherwise. sends the whole set, its replace-vs-merge semantics are undocumented, and the same body carries `fields` and `permission_groups`. +Service accounts are not team members: `/api/team` and its typeahead exclude +them (confirmed live 2026-10-27). See [`service-account.md`](service-account.md). + ## See also - [`permission-group.md`](permission-group.md) — `role -> group -> control`, diff --git a/src/kizen_builder/tools/permissions.py b/src/kizen_builder/tools/permissions.py index bba19b7..5fbd416 100644 --- a/src/kizen_builder/tools/permissions.py +++ b/src/kizen_builder/tools/permissions.py @@ -24,7 +24,7 @@ def level_label(value: Any) -> str: return str(value) -def _resolve(ref: str, items: list[dict[str, Any]], what: str) -> dict[str, Any]: +def resolve_by_name(ref: str, items: list[dict[str, Any]], what: str) -> dict[str, Any]: """Resolve ``ref`` (a UUID or an exact/case-insensitive name) to one item.""" from kizen_builder.utils import is_uuid @@ -51,14 +51,14 @@ def resolve_role(ref: str) -> dict[str, Any]: """Resolve a role by name or UUID → the role dict (id, name, ...).""" config = load_env_config() with KizenClient(config) as client: - return _resolve(ref, perm_api.list_roles(client), "role") + return resolve_by_name(ref, perm_api.list_roles(client), "role") def resolve_group(ref: str) -> dict[str, Any]: """Resolve a permission group by name or UUID → the group dict.""" config = load_env_config() with KizenClient(config) as client: - return _resolve( + return resolve_by_name( ref, perm_api.list_permission_groups(client), "permission group" ) @@ -107,7 +107,7 @@ def describe_role(role_ref: str) -> dict[str, Any]: """Resolve a role and expand its permission-group ids to names + summaries.""" config = load_env_config() with KizenClient(config) as client: - role = _resolve(role_ref, perm_api.list_roles(client), "role") + role = resolve_by_name(role_ref, perm_api.list_roles(client), "role") full = perm_api.get_role(client, role["id"]) names = { g["id"]: g.get("name") for g in perm_api.list_permission_groups(client) diff --git a/src/kizen_builder/tools/planners/service_accounts.py b/src/kizen_builder/tools/planners/service_accounts.py new file mode 100644 index 0000000..13f9837 --- /dev/null +++ b/src/kizen_builder/tools/planners/service_accounts.py @@ -0,0 +1,182 @@ +"""Plan creation/update/deletion of integration service accounts. + +The write body is three keys: ``name``, ``api_name`` and +``permission_groups``. Kizen derives ``api_name`` from ``name`` on create and +on every PATCH that sends ``name``, so the update planner sends only what +changed and pins the current ``api_name`` through a rename. Automation and +plugin accounts are Kizen's own and are refused. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api import permissions as perm_api +from kizen_builder.api import service_accounts as sa_api +from kizen_builder.api.client import KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.tools.plans import Plan, PlanError, PlanOperation, fetch_existing +from kizen_builder.tools.service_accounts import INTEGRATION +from kizen_builder.utils import derived_object_api_name + +_NO_GROUPS_NOTE = ( + "no permission groups — Kizen creates the account disabled until it has " + "at least one." +) + + +def _client() -> KizenClient: + return KizenClient(load_env_config()) + + +def _group_names(c: KizenClient) -> dict[str, str]: + return { + g["id"]: g.get("name") or g["id"] for g in perm_api.list_permission_groups(c) + } + + +def _check_groups(group_ids: list[str], groups: dict[str, str]) -> None: + unknown = [g for g in group_ids if g not in groups] + if unknown: + raise PlanError( + f"permission group id(s) not found: {unknown}. Available: {list(groups)}" + ) + + +def _require_integration(account: dict[str, Any]) -> None: + kind = account.get("account_type") + if kind != INTEGRATION: + raise PlanError( + f"'{account.get('name')}' is a {kind} account, which Kizen or a plugin " + "manages. Only integration service accounts can be changed from the CLI." + ) + + +def _fetch(c: KizenClient, account_id: str) -> dict[str, Any]: + return fetch_existing( + "service account", + account_id, + lambda: sa_api.get_service_account(c, account_id), + ) + + +def plan_create_service_account(name: str, group_ids: list[str] | None = None) -> Plan: + env = load_env_config().name + group_ids = list(group_ids or []) + predicted = derived_object_api_name(name) + with _client() as c: + accounts = sa_api.list_service_accounts(c) + groups = _group_names(c) + + same_name = next( + (a for a in accounts if (a.get("name") or "").lower() == name.lower()), None + ) + if same_name is not None: + raise PlanError( + f"service account '{name}' already exists (id {same_name['id']})." + ) + same_api = next((a for a in accounts if a.get("api_name") == predicted), None) + if same_api is not None: + raise PlanError( + f"api_name '{predicted}' (derived from the name) is already used by " + f"service account '{same_api.get('name')}'." + ) + _check_groups(group_ids, groups) + + preview: dict[str, Any] = { + "env": env, + "name": name, + "predicted api_name": predicted, + "permission_groups": [groups[g] for g in group_ids], + } + if not group_ids: + preview["note"] = _NO_GROUPS_NOTE + op = PlanOperation( + action="create", + kind="service_account", + key=name, + preview=preview, + payload={"name": name, "permission_groups": group_ids}, + ) + return Plan.build( + env=env, summary=f"Create service account '{name}'", operations=[op] + ) + + +def plan_update_service_account(account_id: str, changes: dict[str, Any]) -> Plan: + """``changes`` may carry ``name``, ``api_name`` and ``permission_groups`` + (ids; the list replaces the account's set).""" + env = load_env_config().name + with _client() as c: + current = _fetch(c, account_id) + _require_integration(current) + groups = _group_names(c) if "permission_groups" in changes else {} + + current_groups = [g["id"] for g in current.get("permission_groups") or []] + payload: dict[str, Any] = {} + diff: dict[str, Any] = {} + for field in ("name", "api_name"): + if field in changes and changes[field] != current.get(field): + payload[field] = changes[field] + diff[field] = (current.get(field), changes[field]) + if "permission_groups" in changes: + wanted = list(dict.fromkeys(changes["permission_groups"])) + _check_groups(wanted, groups) + if set(wanted) != set(current_groups): + payload["permission_groups"] = wanted + diff["permission_groups"] = ( + sorted(groups.get(g, g) for g in current_groups), + sorted(groups[g] for g in wanted), + ) + if "name" in payload and "api_name" not in payload: + # Without it, Kizen re-derives api_name from the new name. + payload["api_name"] = current.get("api_name") + + name = current.get("name") or account_id + preview: dict[str, Any] = { + "env": env, + "service account": name, + "diff": {k: f"{v[0]} → {v[1]}" for k, v in diff.items()} or "no changes", + } + if payload.get("permission_groups") == []: + preview["note"] = "no permission groups — Kizen disables the account." + op = PlanOperation( + action="update" if payload else "skip", + kind="service_account", + key=name, + preview=preview, + payload=payload, + existing_uuid=account_id, + ) + summary = ( + f"Update service account '{name}' ({len(diff)} change(s))" + if diff + else f"No changes to service account '{name}'" + ) + return Plan.build(env=env, summary=summary, operations=[op]) + + +def plan_delete_service_account(account_id: str) -> Plan: + env = load_env_config().name + with _client() as c: + current = _fetch(c, account_id) + _require_integration(current) + name = current.get("name") or account_id + op = PlanOperation( + action="delete", + kind="service_account", + key=name, + preview={ + "env": env, + "service account": name, + "id": account_id, + "note": ( + "Kizen archives the account; it disappears from every read and " + "can't be restored through the API." + ), + }, + existing_uuid=account_id, + ) + return Plan.build( + env=env, summary=f"Delete service account '{name}'", operations=[op] + ) diff --git a/src/kizen_builder/tools/plans.py b/src/kizen_builder/tools/plans.py index dbddba2..57fd8da 100644 --- a/src/kizen_builder/tools/plans.py +++ b/src/kizen_builder/tools/plans.py @@ -38,6 +38,7 @@ from kizen_builder.api import pipelines as pipelines_api from kizen_builder.api import records as records_api from kizen_builder.api import saved_views as sv_api +from kizen_builder.api import service_accounts as sa_api from kizen_builder.api import team as team_api from kizen_builder.api.client import KizenAPIError, KizenClient from kizen_builder.config import EnvConfig, load_env_config @@ -102,6 +103,7 @@ def copy_name_preview(source_name: str | None, name: str | None) -> str: "role", "team_member", "permission_group", + "service_account", "permission_setting", "filter_group", "quick_filter", @@ -416,6 +418,18 @@ def apply_plan(plan: Plan) -> ApplyResult: message = f"api_name {resp['name']}" if resp["name"] != op.key: message += f" (the plan predicted {op.key})" + elif ( + op.kind == "service_account" + and op.action == "create" + and isinstance(resp, dict) + ): + message = ( + f"api_name {resp.get('api_name')}; Kizen issued an API key, " + "which the CLI does not show" + ) + predicted = op.preview.get("predicted api_name") + if predicted and resp.get("api_name") != predicted: + message += f" (the plan predicted {predicted})" elif op.kind == "record_archive" and isinstance(resp, dict): # Counts ids sent, not records archived (confirmed live # 2026-09-28: an already-archived id still counts). @@ -1019,6 +1033,18 @@ def patch_and_read(copy_id: str) -> dict[str, Any]: return {} return perm_api.update_role(client, op.existing_uuid, op.payload) + if op.kind == "service_account": + if op.action == "create": + return sa_api.create_service_account(client, op.payload) + if op.existing_uuid is None: + raise PlanError( + f"{op.action} service_account op '{op.key}' has no existing_uuid — planning bug" + ) + if op.action == "delete": + sa_api.delete_service_account(client, op.existing_uuid) + return {} + return sa_api.update_service_account(client, op.existing_uuid, op.payload) + if op.kind == "team_member": team_api.change_team_member_roles( client, diff --git a/src/kizen_builder/tools/service_accounts.py b/src/kizen_builder/tools/service_accounts.py new file mode 100644 index 0000000..f0e894e --- /dev/null +++ b/src/kizen_builder/tools/service_accounts.py @@ -0,0 +1,45 @@ +"""Read tools for service accounts. + +Config-resolving wrappers over ``kizen_builder.api.service_accounts``. +Mutations live in the planners. No read here returns a key: the list and +detail rows carry only Kizen's own preview. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api import service_accounts as sa_api +from kizen_builder.api.client import KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.tools.permissions import resolve_by_name +from kizen_builder.utils import is_uuid + +INTEGRATION = "service_integration" + + +def resolve_service_account(client: KizenClient, ref: str) -> dict[str, Any]: + """Resolve a UUID, an exact api_name or a name to one account row.""" + items = sa_api.list_service_accounts(client) + if not is_uuid(ref): + by_api_name = [i for i in items if i.get("api_name") == ref] + if len(by_api_name) == 1: + return by_api_name[0] + return resolve_by_name(ref, items, "service account") + + +def list_service_accounts(search: str | None = None) -> list[dict[str, Any]]: + with KizenClient(load_env_config()) as client: + return sa_api.list_service_accounts(client, search=search) + + +def get_service_account(ref: str) -> dict[str, Any]: + with KizenClient(load_env_config()) as client: + account = resolve_service_account(client, ref) + return sa_api.get_service_account(client, account["id"]) + + +def list_service_account_history(ref: str, limit: int = 25) -> list[dict[str, Any]]: + with KizenClient(load_env_config()) as client: + account = resolve_service_account(client, ref) + return sa_api.list_service_account_history(client, account["id"], limit) diff --git a/tests/drift/contracts.py b/tests/drift/contracts.py index 45a02d4..f4a2f5e 100644 --- a/tests/drift/contracts.py +++ b/tests/drift/contracts.py @@ -172,6 +172,21 @@ def key(self) -> str: Contract("permissions", "create role", "post", "/api/role"), Contract("permissions", "update role", "patch", "/api/role/{id}"), Contract("permissions", "delete role", "delete", "/api/role/{id}"), + Contract( + "service_accounts", "create service account", "post", "/api/service-accounts" + ), + Contract( + "service_accounts", + "update service account", + "patch", + "/api/service-accounts/{id}", + ), + Contract( + "service_accounts", + "delete service account", + "delete", + "/api/service-accounts/{id}", + ), # --- records ------------------------------------------------------- Contract( "records", "create record", "post", "/api/records/{object_identifier}/add" diff --git a/tests/drift/schema_snapshot.json b/tests/drift/schema_snapshot.json index df782a0..2dafa47 100644 --- a/tests/drift/schema_snapshot.json +++ b/tests/drift/schema_snapshot.json @@ -2,7 +2,7 @@ "_meta": { "base_url": "https://app.go.kizen.com", "captured": "2026-10-14", - "contracts_tracked": 69, + "contracts_tracked": 72, "how_to_refresh": "KIZEN_DRIFT_PROFILE= KIZEN_DRIFT_UPDATE_SNAPSHOT=1 uv run pytest -m drift", "info": { "title": "Kizen API", @@ -87,6 +87,19 @@ "schema_path": "/api/role/{id}", "surface": "permissions" }, + "DELETE /api/service-accounts/{id}": { + "label": "delete service account", + "present": true, + "query_params": [ + "ordering" + ], + "request_schema": null, + "responses": [ + "204" + ], + "schema_path": "/api/service-accounts/{id}", + "surface": "service_accounts" + }, "PATCH /api/custom-objects/{object_identifier}": { "label": "update custom object", "present": true, @@ -275,6 +288,30 @@ "schema_path": "/api/role/{id}", "surface": "permissions" }, + "PATCH /api/service-accounts/{id}": { + "label": "update service account", + "present": true, + "query_params": [ + "ordering" + ], + "request_body": { + "present": true, + "properties": { + "api_name": "string", + "name": "string", + "permission_groups": "array[string]" + }, + "required": [], + "type": "object" + }, + "request_required": false, + "request_schema": "PatchedServiceAccountWriteRequest", + "responses": [ + "200" + ], + "schema_path": "/api/service-accounts/{id}", + "surface": "service_accounts" + }, "POST /api/automation2/automations": { "label": "create automation", "present": true, @@ -744,6 +781,30 @@ "schema_path": "/api/role", "surface": "permissions" }, + "POST /api/service-accounts": { + "label": "create service account", + "present": true, + "query_params": [ + "ordering" + ], + "request_body": { + "present": true, + "properties": { + "api_name": "string", + "name": "string", + "permission_groups": "array[string]" + }, + "required": [], + "type": "object" + }, + "request_required": false, + "request_schema": "ServiceAccountWriteRequest", + "responses": [ + "201" + ], + "schema_path": "/api/service-accounts", + "surface": "service_accounts" + }, "PUT /api/automation2/automations/{automation_identifier}": { "label": "replace automation", "present": true, diff --git a/tests/test_execute_dispatch.py b/tests/test_execute_dispatch.py index 6f3210b..22b1f9a 100644 --- a/tests/test_execute_dispatch.py +++ b/tests/test_execute_dispatch.py @@ -397,6 +397,12 @@ def _saved_view(kind: str, base: str, served: set[str]) -> list[Any]: *_form_like("form", "/api/forms"), *_form_like("survey", "/api/surveys"), *_crud("role", "/api/role", f"/api/role/{E}", parent=False), + *_crud( + "service_account", + "/api/service-accounts", + f"/api/service-accounts/{E}", + parent=False, + ), _row( "team_member", "update", diff --git a/tests/test_service_accounts.py b/tests/test_service_accounts.py new file mode 100644 index 0000000..70fc374 --- /dev/null +++ b/tests/test_service_accounts.py @@ -0,0 +1,500 @@ +"""`kizen service-accounts`: the API wrappers, the three planners, the CLI, +and the guarantee that a create response's API key reaches no output. + +Planners build their own `KizenClient` from config, so these go through +`respx` against `FAKE_BASE_URL`, as `test_permission_plans.py` does. +""" + +from __future__ import annotations + +import copy +import json + +import httpx +import pytest +import respx +from typer.testing import CliRunner + +import kizen_builder.cli as cli +from kizen_builder.api import service_accounts as sa_api +from kizen_builder.api.client import KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.tools import plans as plan_tools +from kizen_builder.tools import service_accounts as sa_tools +from kizen_builder.tools.planners.service_accounts import ( + plan_create_service_account, + plan_delete_service_account, + plan_update_service_account, +) +from kizen_builder.tools.plans import PlanError +from tests.conftest import FAKE_BASE_URL + +runner = CliRunner() + +SA_URL = f"{FAKE_BASE_URL}/api/service-accounts" +GROUPS_URL = f"{FAKE_BASE_URL}/api/permission-group" +SENTINEL = "SENTINEL-KEY-DO-NOT-PRINT" + +ACCOUNT_ID = "00000000-0000-4000-8000-0000000005a1" +AUTOMATION_ID = "00000000-0000-4000-8000-0000000005a2" +PLUGIN_ID = "00000000-0000-4000-8000-0000000005a3" +NEW_ID = "00000000-0000-4000-8000-0000000005a4" +GROUP_ID = "00000000-0000-4000-8000-000000000201" +GROUP_ID_2 = "00000000-0000-4000-8000-000000000202" +UNKNOWN_GROUP_ID = "00000000-0000-4000-8000-0000000009ff" + +GROUPS = [ + {"id": GROUP_ID, "name": "Integrations"}, + {"id": GROUP_ID_2, "name": "Reporting"}, +] + + +def _account(id_, name, api_name, account_type="service_integration", groups=()): + return { + "id": id_, + "name": name, + "api_name": api_name, + "account_type": account_type, + "status": "active" if groups else "disabled", + "email": f"{api_name}@serviceaccounts.example.com", + "permission_groups": [g for g in GROUPS if g["id"] in groups], + "api_token_count": 1, + "primary_api_token_preview": "abc" + "*" * 33, + "secondary_api_token_preview": None, + "created": "2026-10-27T12:00:00Z", + "updated": "2026-10-27T12:00:00Z", + "last_login": None, + } + + +INTEGRATION = _account(ACCOUNT_ID, "Example Bot", "example_bot", groups=(GROUP_ID,)) +AUTOMATION = _account(AUTOMATION_ID, "Workflow Code", None, "service_automation") +PLUGIN = _account( + PLUGIN_ID, "Plugin Service", "plugin__service", "service_automationplugin" +) +ACCOUNTS = [INTEGRATION, AUTOMATION, PLUGIN] + + +def _envelope(results): + return {"count": len(results), "next": None, "previous": None, "results": results} + + +def _mock_reads(accounts=ACCOUNTS): + respx.get(SA_URL).mock(return_value=httpx.Response(200, json=_envelope(accounts))) + respx.get(GROUPS_URL).mock(return_value=httpx.Response(200, json=_envelope(GROUPS))) + for a in accounts: + respx.get(f"{SA_URL}/{a['id']}").mock(return_value=httpx.Response(200, json=a)) + + +def _created(name, api_name, groups=()): + body = _account(NEW_ID, name, api_name, groups=groups) + body["api_key"] = { + "id": "00000000-0000-4000-8000-0000000005b1", + "key": SENTINEL, + "key_preview": "SEN" + "*" * 33, + "employee": {"id": NEW_ID, "display_name": name}, + } + return body + + +def _client(): + return KizenClient(load_env_config()) + + +# --------------------------------------------------------------------------- +# api +# --------------------------------------------------------------------------- + + +@respx.mock +def test_list_pages_at_100_and_sends_search(): + route = respx.get(SA_URL).mock( + return_value=httpx.Response(200, json=_envelope([INTEGRATION])) + ) + with _client() as c: + assert sa_api.list_service_accounts(c, search="Example") == [INTEGRATION] + assert dict(route.calls[0].request.url.params) == { + "page_size": "100", + "search": "Example", + } + + +@respx.mock +def test_history_makes_one_request_with_page_size_limit(): + route = respx.get(f"{SA_URL}/{ACCOUNT_ID}/history").mock( + return_value=httpx.Response( + 200, + json={**_envelope([{"id": "e1"}]), "next": f"{SA_URL}/x?page=2"}, + ) + ) + with _client() as c: + assert sa_api.list_service_account_history(c, ACCOUNT_ID, 5) == [{"id": "e1"}] + assert route.call_count == 1 + assert dict(route.calls[0].request.url.params) == {"page_size": "5"} + + +@respx.mock +def test_create_strips_the_api_key_from_its_return(): + respx.post(SA_URL).mock( + return_value=httpx.Response(201, json=_created("New Bot", "new_bot")) + ) + with _client() as c: + resp = sa_api.create_service_account(c, {"name": "New Bot"}) + assert "api_key" not in resp + assert SENTINEL not in json.dumps(resp) + + +def test_no_function_calls_an_api_tokens_endpoint(): + import inspect + + functions = inspect.getmembers(sa_api, inspect.isfunction) + own = [f for _, f in functions if f.__module__ == sa_api.__name__] + assert len(own) == 6 + assert not [f.__name__ for f in own if "api-tokens" in inspect.getsource(f)] + + +# --------------------------------------------------------------------------- +# resolve +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "ref", [ACCOUNT_ID, "example_bot", "Example Bot", "example bot"] +) +@respx.mock +def test_resolve_by_uuid_api_name_or_name(ref): + _mock_reads() + with _client() as c: + assert sa_tools.resolve_service_account(c, ref)["id"] == ACCOUNT_ID + + +@respx.mock +def test_resolve_unknown_raises_lookup_error_naming_service_account(): + _mock_reads() + with _client() as c, pytest.raises(LookupError, match="service account 'nope'"): + sa_tools.resolve_service_account(c, "nope") + + +# --------------------------------------------------------------------------- +# plan_create_service_account +# --------------------------------------------------------------------------- + + +@respx.mock +def test_create_plans_name_and_groups_with_predicted_api_name(): + _mock_reads() + plan = plan_create_service_account("New Bot (MCP)!", [GROUP_ID]) + (op,) = plan.operations + assert (op.action, op.kind) == ("create", "service_account") + assert op.payload == {"name": "New Bot (MCP)!", "permission_groups": [GROUP_ID]} + assert op.preview["predicted api_name"] == "new_bot_mcp" + assert op.preview["permission_groups"] == ["Integrations"] + assert "note" not in op.preview + + +@respx.mock +def test_create_without_groups_notes_the_account_starts_disabled(): + _mock_reads() + (op,) = plan_create_service_account("New Bot").operations + assert op.payload == {"name": "New Bot", "permission_groups": []} + assert op.preview["note"] == ( + "no permission groups — Kizen creates the account disabled until it has " + "at least one." + ) + + +@respx.mock +def test_create_refuses_a_duplicate_name_case_insensitively(): + _mock_reads() + with pytest.raises(PlanError) as exc: + plan_create_service_account("EXAMPLE BOT") + assert str(exc.value) == ( + f"service account 'EXAMPLE BOT' already exists (id {ACCOUNT_ID})." + ) + + +@respx.mock +def test_create_refuses_a_taken_derived_api_name(): + _mock_reads() + with pytest.raises(PlanError) as exc: + plan_create_service_account("Example-Bot") + assert str(exc.value) == ( + "api_name 'example_bot' (derived from the name) is already used by " + "service account 'Example Bot'." + ) + + +@respx.mock +def test_create_refuses_an_unknown_group(): + _mock_reads() + with pytest.raises(PlanError, match="permission group id"): + plan_create_service_account("New Bot", [UNKNOWN_GROUP_ID]) + + +# --------------------------------------------------------------------------- +# plan_update_service_account +# --------------------------------------------------------------------------- + + +@respx.mock +def test_update_with_the_same_group_set_skips(): + account = copy.deepcopy(INTEGRATION) + account["permission_groups"] = list(GROUPS) + _mock_reads([account]) + (op,) = plan_update_service_account( + ACCOUNT_ID, {"permission_groups": [GROUP_ID_2, GROUP_ID]} + ).operations + assert op.action == "skip" + assert op.payload == {} + + +@respx.mock +def test_rename_keeps_the_current_api_name(): + _mock_reads() + (op,) = plan_update_service_account(ACCOUNT_ID, {"name": "Renamed Bot"}).operations + assert op.action == "update" + assert op.payload == {"name": "Renamed Bot", "api_name": "example_bot"} + + +@respx.mock +def test_update_sends_only_the_keys_that_changed(): + _mock_reads() + (op,) = plan_update_service_account( + ACCOUNT_ID, + { + "name": "Example Bot", + "api_name": "example_bot_v2", + "permission_groups": [GROUP_ID_2], + }, + ).operations + assert op.payload == { + "api_name": "example_bot_v2", + "permission_groups": [GROUP_ID_2], + } + assert op.preview["diff"]["permission_groups"] == "['Integrations'] → ['Reporting']" + + +@respx.mock +def test_clearing_groups_is_allowed_and_notes_the_account_is_disabled(): + _mock_reads() + (op,) = plan_update_service_account( + ACCOUNT_ID, {"permission_groups": []} + ).operations + assert op.payload == {"permission_groups": []} + assert "disables the account" in op.preview["note"] + + +@respx.mock +def test_update_refuses_an_unknown_group(): + _mock_reads() + with pytest.raises(PlanError, match="permission group id"): + plan_update_service_account( + ACCOUNT_ID, {"permission_groups": [UNKNOWN_GROUP_ID]} + ) + + +@respx.mock +def test_update_of_a_missing_account_is_a_plan_error(): + respx.get(f"{SA_URL}/{NEW_ID}").mock( + return_value=httpx.Response(404, json={"detail": "Not found."}) + ) + with pytest.raises(PlanError, match="not found"): + plan_update_service_account(NEW_ID, {"name": "x"}) + + +@pytest.mark.parametrize( + ("account", "kind"), + [(AUTOMATION, "service_automation"), (PLUGIN, "service_automationplugin")], +) +@pytest.mark.parametrize("planner", ["update", "delete"]) +@respx.mock +def test_update_and_delete_refuse_accounts_kizen_or_a_plugin_manages( + account, kind, planner +): + _mock_reads() + with pytest.raises(PlanError) as exc: + if planner == "update": + plan_update_service_account(account["id"], {"name": "x"}) + else: + plan_delete_service_account(account["id"]) + assert str(exc.value) == ( + f"'{account['name']}' is a {kind} account, which Kizen or a plugin " + "manages. Only integration service accounts can be changed from the CLI." + ) + + +@respx.mock +def test_delete_plans_one_delete_with_the_archive_note(): + _mock_reads() + (op,) = plan_delete_service_account(ACCOUNT_ID).operations + assert (op.action, op.existing_uuid) == ("delete", ACCOUNT_ID) + assert op.preview["note"] == ( + "Kizen archives the account; it disappears from every read and can't be " + "restored through the API." + ) + + +# --------------------------------------------------------------------------- +# apply: the create result message +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("returned", "suffix"), + [("new_bot", ""), ("new_bot_2", " (the plan predicted new_bot)")], +) +@respx.mock +def test_create_result_message_reports_api_name_and_never_the_key(returned, suffix): + _mock_reads() + respx.post(SA_URL).mock( + return_value=httpx.Response(201, json=_created("New Bot", returned)) + ) + plan = plan_create_service_account("New Bot") + result = plan_tools.apply_plan(plan) + (r,) = result.results + assert r.status == "ok" and r.server_uuid == NEW_ID + assert r.message == ( + f"api_name {returned}; Kizen issued an API key, which the CLI does not show" + + suffix + ) + assert SENTINEL not in plan_tools.result_to_json(result) + assert SENTINEL not in plan_tools.plan_to_json(plan) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("extra", [["--json"], []]) +@respx.mock +def test_cli_create_never_prints_the_api_key(extra): + _mock_reads() + post = respx.post(SA_URL).mock( + return_value=httpx.Response(201, json=_created("New Bot", "new_bot")) + ) + result = runner.invoke( + cli.app, ["service-accounts", "create", "--name", "New Bot", "--yes", *extra] + ) + assert result.exit_code == 0, result.output + assert post.called + assert SENTINEL not in result.stdout + assert SENTINEL not in result.stderr + assert "Kizen issued an API key" in result.stdout + + +@respx.mock +def test_cli_create_resolves_groups_by_name(): + _mock_reads() + result = runner.invoke( + cli.app, + [ + "service-accounts", + "create", + "--name", + "New Bot", + "-g", + "Integrations", + "--dry-run", + "--json", + ], + ) + assert result.exit_code == 0, result.output + op = json.loads(result.stdout)["operations"][0] + assert op["payload"] == {"name": "New Bot", "permission_groups": [GROUP_ID]} + + +@respx.mock +def test_cli_list_json_emits_the_api_rows(): + _mock_reads() + result = runner.invoke(cli.app, ["service-accounts", "list", "--json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == ACCOUNTS + + +@respx.mock +def test_cli_list_csv_names_groups(): + _mock_reads() + result = runner.invoke(cli.app, ["service-accounts", "list", "-o", "csv"]) + assert result.exit_code == 0, result.output + assert "Example Bot,example_bot,service_integration,active,Integrations,1," in ( + result.stdout + ) + + +@respx.mock +def test_cli_get_by_api_name_shows_groups(): + _mock_reads() + result = runner.invoke(cli.app, ["service-accounts", "get", "example_bot"]) + assert result.exit_code == 0, result.output + assert "Integrations" in result.stdout + assert "abc***" in result.stdout + + +@respx.mock +def test_cli_unknown_ref_ends_on_error_and_exits_1(): + _mock_reads() + result = runner.invoke(cli.app, ["service-accounts", "delete", "nope", "--yes"]) + assert result.exit_code == 1 + assert result.stderr.startswith("error:") + + +def test_cli_update_refuses_group_with_clear_groups(): + result = runner.invoke( + cli.app, + ["service-accounts", "update", "x", "-g", "Integrations", "--clear-groups"], + ) + assert result.exit_code == 2 + assert "error: pass --group or --clear-groups, not both." in result.stderr + + +@respx.mock +def test_cli_update_clear_groups_plans_an_empty_set(): + _mock_reads() + result = runner.invoke( + cli.app, + [ + "service-accounts", + "update", + "Example Bot", + "--clear-groups", + "--dry-run", + "--json", + ], + ) + assert result.exit_code == 0, result.output + op = json.loads(result.stdout)["operations"][0] + assert op["payload"] == {"permission_groups": []} + + +@respx.mock +def test_cli_history_table_and_empty(): + _mock_reads() + event = { + "id": "e1", + "created": "2026-10-27T12:00:00Z", + "type_name": "employee_created", + "type_display_name": "Team Member Invited", + "initiator": { + "email": "admin@example.com", + "first_name": "A", + "last_name": "B", + }, + } + route = respx.get(f"{SA_URL}/{ACCOUNT_ID}/history").mock( + side_effect=[ + httpx.Response(200, json=_envelope([event])), + httpx.Response(200, json=_envelope([])), + ] + ) + result = runner.invoke( + cli.app, ["service-accounts", "history", "Example Bot", "-n", "3"] + ) + assert result.exit_code == 0, result.output + assert "Team Member Invited" in result.stdout + assert "admin@example.com" in result.stdout + assert dict(route.calls[0].request.url.params) == {"page_size": "3"} + + result = runner.invoke(cli.app, ["service-accounts", "history", "Example Bot"]) + assert result.exit_code == 0, result.output + assert result.stdout.strip() == "No history." From c56aa19f01c363b00b1ab762ff10974f94cc2389 Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 10:20:18 -0400 Subject: [PATCH 2/8] Keep a webhook trigger's auth when an update spec leaves it out Since Kizen's 2026-10-26 release a webhook trigger carries auth_type, headers and secret, and a PUT without auth_type resets the trigger to Kizen API auth, which also changes its URL under external callers. Every spec written before the release omits the key, so an update now carries the live trigger's auth across, matched by trigger id and then by webhook name. Setting auth_type still changes it. Specs can now set all four auth types. The model refuses headers or secret on a type Kizen silently drops them for, and the retired auth_header_* keys. Headers get the same html_value the UI writes, labelling a secret reference with the secret's description, and automations get shows each webhook's auth type. --- CHANGELOG.md | 10 + CONTRIBUTING.md | 1 + src/kizen_builder/api/integration_secrets.py | 14 + src/kizen_builder/cli/automations.py | 7 +- src/kizen_builder/docs/specs/automation.md | 40 ++- src/kizen_builder/models/spec/__init__.py | 4 + .../models/spec/automations_triggers.py | 59 ++++ src/kizen_builder/tools/automations.py | 10 +- src/kizen_builder/tools/merge_fields.py | 5 + .../tools/planners/automations.py | 89 ++++- .../drift/test_active_preserved_on_update.py | 86 +++++ .../webhook_auth_triggers.raw.json | 175 ++++++++++ tests/test_automation_payloads.py | 303 ++++++++++++++++++ tests/test_cli.py | 20 ++ 14 files changed, 814 insertions(+), 9 deletions(-) create mode 100644 src/kizen_builder/api/integration_secrets.py create mode 100644 tests/fixtures/automations/webhook_auth_triggers.raw.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bb40..728c6c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,10 @@ called out explicitly under **Changed** or **Removed**. ### Added +- **Webhook trigger specs accept `auth_type`, `headers` and `secret`** (all + four auth types), and **`automations get` shows each webhook's auth + type**. See `kizen docs show automation`. + - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes @@ -495,6 +499,12 @@ called out explicitly under **Changed** or **Removed**. ### Fixed +- **`automations update` no longer resets a webhook trigger's + authentication to Kizen API auth when the spec leaves it out**, and + `automations diff` no longer shows that reset. The trigger keeps its live + auth, so its URL no longer changes under external callers. Set + `auth_type` to change it. + - **`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/CONTRIBUTING.md b/CONTRIBUTING.md index dab20d8..dd7fc5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,6 +106,7 @@ src/kizen_builder/ external_refs.py # name→UUID lookups for by-name spec references files.py # raw file bytes: the S3 upload/download legs (not JSON) forms.py # form/survey CRUD (base_path-shared with surveys) + integration_secrets.py # integration-secret listing (read-only; values never kept) layouts.py # record-layout CRUD (field/block arrangement) messages.py # automation-scoped message resources (notify_* step content) permissions.py # roles, permission groups, permissions catalog diff --git a/src/kizen_builder/api/integration_secrets.py b/src/kizen_builder/api/integration_secrets.py new file mode 100644 index 0000000..07ee2dc --- /dev/null +++ b/src/kizen_builder/api/integration_secrets.py @@ -0,0 +1,14 @@ +"""Integration secret reads against the Kizen API.""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api.client import KizenClient + + +def list_integration_secrets(client: KizenClient) -> list[dict[str, Any]]: + """GET /api/integration-secrets — every secret's ``api_name``, + ``description`` and ``obfuscated_value``. Callers keep what they need and + drop ``obfuscated_value``.""" + return client.paginate("/api/integration-secrets") diff --git a/src/kizen_builder/cli/automations.py b/src/kizen_builder/cli/automations.py index 14858fc..834dedb 100644 --- a/src/kizen_builder/cli/automations.py +++ b/src/kizen_builder/cli/automations.py @@ -129,8 +129,13 @@ def table() -> None: trig_table = Table(title="Triggers") trig_table.add_column("type") trig_table.add_column("description") + trig_table.add_column("auth") for t in result["triggers"]: - trig_table.add_row(t["trigger_type"] or "", t["description"] or "") + trig_table.add_row( + t["trigger_type"] or "", + t["description"] or "", + t.get("auth_type") or "", + ) console.print(trig_table) step_table = Table(title="Steps") diff --git a/src/kizen_builder/docs/specs/automation.md b/src/kizen_builder/docs/specs/automation.md index 0ac497c..7afbac0 100644 --- a/src/kizen_builder/docs/specs/automation.md +++ b/src/kizen_builder/docs/specs/automation.md @@ -158,6 +158,42 @@ Kizen (e.g. `email_interaction`, `email_link_clicked`, `contact_tag_added_removed`) but aren't wired here and raise `PlanError` if you try to spec them. +### Webhook trigger auth (confirmed live 2026-10-27) + +```json +"trigger_webhook": { + "name": "partner_feed", + "auth_type": "http_header", + "headers": [{"name": "Authorization", "value": "Bearer {{ integration_secret.partner_token }}"}] +} +``` + +- `auth_type` is `kizen_api` (the default), `http_header`, `basic_auth` or + `none`. Every type but `kizen_api` adds `?business_id=` to + `webhook_url`. +- `http_header` takes `headers: [{name, value}]`: at least one, names 2-100 + characters, no name twice. A caller must send every header, each matching + exactly. Put `{{ integration_secret. }}` or + `{{ business_variable. }}` in the value. Text around the reference is + sent as written, so `Bearer {{ integration_secret.token }}` expects + `Bearer `. A literal value is accepted, but it's stored in plain + text and `show --json` prints it, so use a reference. The CLI fills each + header's `html_value` the way the UI does. +- `basic_auth` takes `secret: {api_name}`, an integration secret whose value + is `user:pass`. Callers send `Authorization: Basic `. +- `none` accepts any request. Kizen API headers don't get past `http_header` + or `basic_auth`; a failed check answers 401 `Invalid webhook credentials.` +- Kizen silently drops `headers` on any type but `http_header`, and `secret` + on any type but `basic_auth`, so the spec refuses both. The old + `auth_header_name`/`auth_header_value` keys are no longer read and are + refused too. +- On `update`, a webhook trigger that leaves out `auth_type` keeps the live + automation's auth. Set `auth_type` to change it. The live trigger is matched + by trigger `id`, else by webhook `name`; a renamed trigger without its `id` + is a new trigger and gets `kizen_api` (`diff` shows it). +- `automations duplicate` keeps each webhook's auth and name. The copy's URL + uses the copy's api_name. + A `goal` step's `step_goal.triggers` are wait-until triggers with the same `trigger_type` names and config blocks as the top level, e.g. `email_received` to wait for a reply. Its `wait_type` is `never`, `delay` or @@ -661,7 +697,9 @@ Other reserved namespaces (not real object fields, no API-queryable catalog, never carry `data-merge-field-objectname`): `team_member.` (the notified team member's own fields), `business.` (tenant settings), `contact.`, `automation_variable.`, -`automation_history.`, and `entity_record` pseudo-fields like +`automation_history.`, `integration_secret.` and +`business_variable.` (webhook header values; a secret's label is its +description), and `entity_record` pseudo-fields like `link_url`/`created`/`estimated_close_date`. Any OTHER namespace is treated as a real custom object's own api_name, and its span gets `data-merge-field-objectname` holding that object's display name. diff --git a/src/kizen_builder/models/spec/__init__.py b/src/kizen_builder/models/spec/__init__.py index 3762f40..9c15a2f 100644 --- a/src/kizen_builder/models/spec/__init__.py +++ b/src/kizen_builder/models/spec/__init__.py @@ -75,6 +75,8 @@ TriggerWebhookConfig, TriggerManualConfig, AutomationTriggerDef, + WebhookAuthHeader, + WebhookAuthSecret, ) from kizen_builder.models.spec.automations_actions_control import ( StepConditionConfig, @@ -263,6 +265,8 @@ def build_payload_meta(model: BaseModel) -> dict[str, Any]: "TriggerScheduledActivityOverdueConfig", "TriggerNewEntityCreatedConfig", "TriggerWebhookConfig", + "WebhookAuthHeader", + "WebhookAuthSecret", "TriggerManualConfig", "AutomationTriggerDef", "StepConditionConfig", diff --git a/src/kizen_builder/models/spec/automations_triggers.py b/src/kizen_builder/models/spec/automations_triggers.py index 1c183ad..0a8162d 100644 --- a/src/kizen_builder/models/spec/automations_triggers.py +++ b/src/kizen_builder/models/spec/automations_triggers.py @@ -277,6 +277,35 @@ class TriggerNewEntityCreatedConfig(BaseModel): ) +class WebhookAuthHeader(BaseModel): + """One header an `http_header` webhook caller must send.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=2, max_length=100) + value: str = Field( + min_length=1, + description="Use a `{{ integration_secret. }}` reference.", + ) + html_value: str | None = None + + +class WebhookAuthSecret(BaseModel): + """The integration secret a `basic_auth` webhook checks against. A read + also carries `description` and `deleted`, which are ignored.""" + + model_config = ConfigDict(extra="ignore") + + api_name: str = Field(min_length=1) + + +RETIRED_WEBHOOK_AUTH_KEYS = ( + "auth_header_name", + "auth_header_value", + "html_auth_header_value", +) + + class TriggerWebhookConfig(BaseModel): """Config for `trigger_type: webhook`.""" @@ -289,6 +318,36 @@ class TriggerWebhookConfig(BaseModel): extract_raw_body_content: bool = False extract_url_query_string: bool = True extractors: list[dict[str, Any]] = Field(default_factory=list) + auth_type: Literal["kizen_api", "http_header", "basic_auth", "none"] | None = Field( + default=None, + description="Omitted on update: the live webhook keeps its auth.", + ) + headers: list[WebhookAuthHeader] | None = None + secret: WebhookAuthSecret | None = None + + # Only the combinations Kizen accepts with a 200 and then drops are + # refused here (confirmed live 2026-10-27); the rest it rejects with a + # precise 400 of its own. + @model_validator(mode="after") + def _validate_auth(self) -> Self: + if any(k in (self.model_extra or {}) for k in RETIRED_WEBHOOK_AUTH_KEYS): + raise ValueError( + "trigger_webhook.auth_header_name, auth_header_value and " + "html_auth_header_value are no longer read by Kizen. Use " + "auth_type 'http_header' with headers: " + '[{"name": ..., "value": ...}].' + ) + if self.headers and self.auth_type != "http_header": + raise ValueError( + "trigger_webhook.headers only applies when auth_type is " + "'http_header'; Kizen drops it for any other auth type." + ) + if self.secret is not None and self.auth_type != "basic_auth": + raise ValueError( + "trigger_webhook.secret only applies when auth_type is " + "'basic_auth'; Kizen drops it for any other auth type." + ) + return self class TriggerManualConfig(BaseModel): diff --git a/src/kizen_builder/tools/automations.py b/src/kizen_builder/tools/automations.py index 2695851..50a2309 100644 --- a/src/kizen_builder/tools/automations.py +++ b/src/kizen_builder/tools/automations.py @@ -132,14 +132,16 @@ def get_automation(api_name: str) -> dict[str, Any]: raise LookupError(f"no automation with api_name '{api_name}'") detail = auto_api.get_automation(client, match["id"]) - triggers = [ - { + triggers = [] + for t in detail.get("triggers") or []: + row = { "id": t.get("id"), "trigger_type": t.get("trigger_type"), "description": t.get("description"), } - for t in (detail.get("triggers") or []) - ] + if t.get("trigger_type") == "webhook": + row["auth_type"] = (t.get("trigger_webhook") or {}).get("auth_type") + triggers.append(row) steps = [] for s in detail.get("steps") or []: diff --git a/src/kizen_builder/tools/merge_fields.py b/src/kizen_builder/tools/merge_fields.py index cc8210b..037549c 100644 --- a/src/kizen_builder/tools/merge_fields.py +++ b/src/kizen_builder/tools/merge_fields.py @@ -52,6 +52,9 @@ # `automation_history`, whose labels vary by containing automation # (`merge-field-markup-captured-live.md`) — it is reserved because it has no # queryable live metadata source, not because its labels are stable. +# `integration_secret` and `business_variable` are the webhook-header +# namespaces: the UI's 2026-10-27 capture of an `integration_secret` header +# carries no `objectname`, and its label is the secret's description. # # This must be an explicit allowlist, not "did a live object lookup for this # token succeed" — `contact` is itself a real, live-queryable object api_name @@ -66,6 +69,8 @@ "contact", "automation_variable", "automation_history", + "integration_secret", + "business_variable", } ) diff --git a/src/kizen_builder/tools/planners/automations.py b/src/kizen_builder/tools/planners/automations.py index 713e85d..354664d 100644 --- a/src/kizen_builder/tools/planners/automations.py +++ b/src/kizen_builder/tools/planners/automations.py @@ -55,7 +55,10 @@ AutomationStepDef, AutomationTriggerDef, ) -from kizen_builder.models.spec.automations_triggers import trigger_config_field +from kizen_builder.models.spec.automations_triggers import ( + RETIRED_WEBHOOK_AUTH_KEYS, + trigger_config_field, +) from kizen_builder.tools import merge_fields from kizen_builder.tools.automations import get_automation, list_automations from kizen_builder.tools.objects import get_object @@ -83,6 +86,7 @@ def __init__(self) -> None: # rather than a single value shape. self._objects_by_api: dict[str, Any] = {} self._automations_by_api: dict[str, dict[str, Any]] | None = None + self._secret_descriptions: dict[str, str] | None = None def object_uuid(self, api_name: str) -> str: return self.object_data(api_name)["id"] @@ -153,6 +157,20 @@ def automation_uuid(self, api_name: str) -> str | None: rec = self._automations_by_api.get(api_name) return rec["id"] if rec else None + def integration_secret_description(self, api_name: str) -> str | None: + """The secret's description, the label the UI gives its merge field. + An unknown name returns None; the server's 400 names it.""" + if self._secret_descriptions is None: + from kizen_builder.api.client import KizenClient + from kizen_builder.api.integration_secrets import list_integration_secrets + + with KizenClient(load_env_config()) as client: + self._secret_descriptions = { + s["api_name"]: s.get("description") or s["api_name"] + for s in list_integration_secrets(client) + } + return self._secret_descriptions.get(api_name) + def filter_group_uuid(self, object_api_name: str, identifier: str) -> str: """UUID of a saved filter group on ``object_api_name`` by name or UUID.""" from kizen_builder.api.saved_views import FILTER_GROUPS_BASE @@ -397,6 +415,8 @@ def _merge_server_state( if current.get(field) is not None: server_fields[field] = current[field] merged = {**server_fields, **payload} + if "triggers" in merged: + merged["triggers"] = _carry_webhook_auth(merged["triggers"], current) merged["last_revision"] = current.get("revision") return merged @@ -968,9 +988,72 @@ def _trigger_on_or_around_date( @honours_all -def _trigger_webhook(block: dict[str, Any], _ctx: LiveContext) -> dict[str, Any]: +def _trigger_webhook(block: dict[str, Any], ctx: LiveContext) -> dict[str, Any]: + def resolve_label(namespace: str, field_path: str) -> str | None: + if namespace == "integration_secret": + return ctx.integration_secret_description(field_path) + return None + # Drop server-assigned id; pass through the rest as-is. - return _strip(block, drop={"id"}) + return _webhook_auth(_strip(block, drop={"id"}), resolve_label) + + +_WEBHOOK_AUTH_KEYS = ("auth_type", "headers", "secret") + + +def _webhook_auth( + block: dict[str, Any], resolve_label: merge_fields.ResolveLabel +) -> dict[str, Any]: + """Reduce a webhook block's auth to the shape the UI writes (confirmed + live 2026-10-27). Reads carry `headers: []`, `secret: null`, an expanded + `secret` and, on older captures, the retired `auth_header_*` keys. A + header without an `html_value` key gets the UI's rich-text mirror, whose + merge-field label for a secret is the secret's description.""" + out = {k: v for k, v in block.items() if k not in RETIRED_WEBHOOK_AUTH_KEYS} + if out.get("secret") is None: + out.pop("secret", None) + else: + out["secret"] = {"api_name": out["secret"].get("api_name")} + if not out.get("headers"): + out.pop("headers", None) + else: + out["headers"] = [ + h + if "html_value" in h + else { + **h, + "html_value": "

" + + merge_fields.render(h["value"], resolve_label=resolve_label) + + "

", + } + for h in out["headers"] + ] + return out + + +def _carry_webhook_auth( + triggers: list[dict[str, Any]], current: dict[str, Any] +) -> list[dict[str, Any]]: + """A spec webhook block without `auth_type` keeps the live trigger's auth: + a PUT without it resets the trigger to Kizen API auth and changes its + URL. Matched by trigger id, else by webhook name.""" + live = [t for t in current.get("triggers") or [] if t.get("trigger_webhook")] + by_id = {t["id"]: t["trigger_webhook"] for t in live if t.get("id")} + by_name = {t["trigger_webhook"].get("name"): t["trigger_webhook"] for t in live} + out = [] + for t in triggers: + block = t.get("trigger_webhook") + if t.get("type") != "webhook" or block is None or "auth_type" in block: + out.append(t) + continue + match = by_id.get(t.get("id")) or by_name.get(block.get("name")) + if match is None: + out.append(t) + continue + auth = _webhook_auth(match, lambda _ns, _path: None) + carried = {k: auth[k] for k in _WEBHOOK_AUTH_KEYS if k in auth} + out.append({**t, "trigger_webhook": {**block, **carried}}) + return out @honours_all diff --git a/tests/drift/test_active_preserved_on_update.py b/tests/drift/test_active_preserved_on_update.py index e54c60e..5c9092c 100644 --- a/tests/drift/test_active_preserved_on_update.py +++ b/tests/drift/test_active_preserved_on_update.py @@ -74,3 +74,89 @@ def test_update_omitting_active_preserves_live_active_automation(drift_client, s "an update spec that omitted `active` deactivated a live automation " "— this is the regression First-Use Feedback §7/§9 row #12 reported" ) + + +def test_update_omitting_webhook_auth_preserves_live_auth(drift_client, scratch): + """Same guard for webhook trigger auth: a PUT without `auth_type` resets a + trigger to Kizen API auth and changes its URL. References an existing + integration secret by name rather than creating one, so it never writes + a secret.""" + from kizen_builder.api import automations as auto_api + from kizen_builder.api.integration_secrets import list_integration_secrets + from kizen_builder.models.spec import AutomationDef + from kizen_builder.tools.planners.automations import ( + LiveContext, + _build_automation_payload, + ) + + secrets = list_integration_secrets(drift_client) + if not secrets: + pytest.skip("the drift environment has no integration secret to reference") + secret = secrets[0]["api_name"] + + api_name = debris_api_name("webhook_auth") + name = debris_name("webhook auth") + hooks = { + f"{api_name}_header": { + "auth_type": "http_header", + "headers": [ + { + "name": "X-Drift-Token", + "value": f"{{{{ integration_secret.{secret} }}}}", + } + ], + }, + f"{api_name}_basic": { + "auth_type": "basic_auth", + "secret": {"api_name": secret}, + }, + } + + def spec(with_auth: bool) -> dict: + return { + "api_name": api_name, + "name": name, + "type": "global", + "active": False, + "triggers": [ + { + "trigger_type": "webhook", + "order": i + 1, + "trigger_webhook": {"name": hook, **(auth if with_auth else {})}, + } + for i, (hook, auth) in enumerate(hooks.items()) + ], + "steps": [], + } + + payload = _build_automation_payload( + AutomationDef.model_validate(spec(True)), LiveContext() + ) + created = auto_api.create_automation(drift_client, payload) + scratch.track( + "automation", + created["id"], + lambda: auto_api.delete_automation(drift_client, created["id"]), + ) + + def auth_of(raw: dict) -> dict: + return { + t["trigger_webhook"]["name"]: ( + t["trigger_webhook"]["auth_type"], + [(h["name"], h["value"]) for h in t["trigger_webhook"]["headers"]], + (t["trigger_webhook"]["secret"] or {}).get("api_name"), + t["trigger_webhook"]["webhook_url"], + ) + for t in raw["triggers"] + if t["trigger_type"] == "webhook" + } + + before = auth_of(get_automation(api_name)["raw"]) + assert before[f"{api_name}_header"][0] == "http_header" + assert before[f"{api_name}_basic"][2] == secret + + result = apply_plan(plan_update_automation(spec(False))) + assert result.all_ok, [r.message for r in result.results if r.status != "ok"] + assert auth_of(get_automation(api_name)["raw"]) == before, ( + "an update spec that omitted webhook auth reset it on the live automation" + ) diff --git a/tests/fixtures/automations/webhook_auth_triggers.raw.json b/tests/fixtures/automations/webhook_auth_triggers.raw.json new file mode 100644 index 0000000..988ad48 --- /dev/null +++ b/tests/fixtures/automations/webhook_auth_triggers.raw.json @@ -0,0 +1,175 @@ +{ + "id": "6d0c3f0e-5b8a-4c6e-9a51-0b5f0a1e2d93", + "name": "Webhook Auth Triggers", + "api_name": "webhook_auth_triggers", + "ai_description": null, + "active": false, + "type": "global", + "custom_object": null, + "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 + } + }, + { + "id": "229d6694-f315-4fad-a0f6-40c06bb894f3", + "order": 1, + "description": "‘webhook_auth_triggers’ Received", + "user_description": "Standard webhook auth", + "trigger_type": "webhook", + "trigger_webhook": { + "id": "405006e7-ef0b-4066-8192-313c4d04d244", + "name": "webhook_auth_triggers", + "http_method": "POST", + "content_type": "application/json", + "sample_post_body": "", + "extract_raw_body_content": false, + "extract_url_query_string": true, + "auth_type": "kizen_api", + "webhook_url": "https://app.example.com/api/automations/webhook_auth_triggers/webhook/webhook_auth_triggers", + "extractors": [], + "headers": [], + "secret": null + }, + "stats": { + "number_completed": 0 + } + }, + { + "id": "c6c51a0a-0007-4713-a56c-7ec742378dc2", + "order": 2, + "description": "‘webhook_auth_triggers_1’ Received", + "user_description": "http header", + "trigger_type": "webhook", + "trigger_webhook": { + "id": "0b6e0b7f-e367-464d-a63e-d2120d518de7", + "name": "webhook_auth_triggers_1", + "http_method": "GET", + "content_type": null, + "sample_post_body": "", + "extract_raw_body_content": false, + "extract_url_query_string": true, + "auth_type": "http_header", + "webhook_url": "https://app.example.com/api/automations/webhook_auth_triggers/webhook/webhook_auth_triggers_1?business_id=00000000-0000-4000-8000-000000000000", + "extractors": [], + "headers": [ + { + "name": "token-name", + "value": "{{ integration_secret.partner_token }} -merge field", + "html_value": "

{{ integration_secret.partner_token }} -merge field

" + } + ], + "secret": null + }, + "stats": { + "number_completed": 0 + } + }, + { + "id": "fec8df6f-1018-45e8-81ce-4d1dcdfdb49b", + "order": 3, + "description": "‘webhook_auth_triggers_2’ Received", + "user_description": "basic auth", + "trigger_type": "webhook", + "trigger_webhook": { + "id": "2f561cf3-d85b-47c6-940f-d92c94a25196", + "name": "webhook_auth_triggers_2", + "http_method": "POST", + "content_type": "application/xml", + "sample_post_body": "", + "extract_raw_body_content": true, + "extract_url_query_string": false, + "auth_type": "basic_auth", + "webhook_url": "https://app.example.com/api/automations/webhook_auth_triggers/webhook/webhook_auth_triggers_2?business_id=00000000-0000-4000-8000-000000000000", + "extractors": [], + "headers": [], + "secret": { + "api_name": "partner_basic", + "description": "partner basic (api)", + "deleted": false + } + }, + "stats": { + "number_completed": 0 + } + }, + { + "id": "fbcd43b4-293b-4476-b310-e4d9d0b801ff", + "order": 4, + "description": "‘webhook_auth_triggers_3’ Received", + "user_description": "no auth", + "trigger_type": "webhook", + "trigger_webhook": { + "id": "08b26eec-7a5d-4569-8792-0dc3ebaaea14", + "name": "webhook_auth_triggers_3", + "http_method": "POST", + "content_type": "application/json", + "sample_post_body": "{\n \"key\": \"value\"\n}", + "extract_raw_body_content": true, + "extract_url_query_string": true, + "auth_type": "none", + "webhook_url": "https://app.example.com/api/automations/webhook_auth_triggers/webhook/webhook_auth_triggers_3?business_id=00000000-0000-4000-8000-000000000000", + "extractors": [ + { + "id": "07e955eb-02a3-4a1f-8421-a61e7fd2200e", + "value_name": "key", + "value_json_path": "$['key']", + "order": 0 + } + ], + "headers": [], + "secret": null + }, + "stats": { + "number_completed": 0 + } + } + ], + "steps": [ + { + "id": "a3cd61b8-a788-4e17-86fe-877553f0378b", + "step_type": "stop_execution", + "order": 0, + "description": "Stop and mark as Successfully Completed and Do Not Notify", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": null, + "parent_condition": "", + "action_stop_execution": { + "action": "stop_and_complete", + "notify": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + } + ], + "variables": [], + "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..21250de 100644 --- a/tests/test_automation_payloads.py +++ b/tests/test_automation_payloads.py @@ -1825,6 +1825,309 @@ def test_plan_update_strips_variable_ids(patch_live_lookups): assert all("id" not in v and "created" not in v for v in sent) +# --------------------------------------------------------------------------- +# Webhook trigger auth: kept on update, typed, mirrored as the UI writes it +# (confirmed live 2026-10-27) +# --------------------------------------------------------------------------- + +_WEBHOOK_RAW = "automations/webhook_auth_triggers.raw.json" +_SECRET_DESCRIPTIONS = { + "partner_token": "partner token (api)", + "partner_basic": "partner basic (api)", +} + + +@pytest.fixture +def webhook_live(patch_live_lookups, monkeypatch): + """Serve the four-auth-type fixture as a live automation, with the + integration secret descriptions the header label resolves against.""" + import kizen_builder.tools.planners.automations as ma + + raw = load_fixture(_WEBHOOK_RAW) + listing = [*ma.list_automations(), {"id": raw["id"], "api_name": raw["api_name"]}] + monkeypatch.setattr(ma, "list_automations", lambda: listing) + monkeypatch.setattr( + ma.LiveContext, + "integration_secret_description", + lambda self, api_name: _SECRET_DESCRIPTIONS.get(api_name), + ) + return raw + + +def _webhook_spec(raw: dict, *, auth: bool = False, ids: bool = True) -> dict: + """A spec reproducing the fixture, with or without the webhook auth keys. + With auth, headers carry no `html_value` and `secret` is the read shape.""" + unspec = {"id", "webhook_url"} | ( + set() if auth else {"auth_type", "headers", "secret"} + ) + triggers = [] + for t in raw["triggers"]: + spec_t = {"trigger_type": t["trigger_type"], "order": t.get("order", 0)} + if ids: + spec_t["id"] = t["id"] + block = t.get("trigger_webhook") + if block is not None: + block = {k: v for k, v in block.items() if k not in unspec} + if auth and block["headers"]: + block["headers"] = [ + {k: v for k, v in h.items() if k != "html_value"} + for h in block["headers"] + ] + spec_t["trigger_webhook"] = block + triggers.append(spec_t) + stop = raw["steps"][0] + return { + "api_name": raw["api_name"], + "name": raw["name"], + "type": "global", + "triggers": triggers, + "steps": [ + { + "key": "stop", + "id": stop["id"], + "step_type": "stop_execution", + "order": 0, + "parent_key": None, + "description": stop["description"], + "action_on_failure": stop["action_on_failure"], + "action_stop_execution": stop["action_stop_execution"], + } + ], + } + + +def _webhook_blocks(payload: dict) -> dict[str, dict]: + return { + t["trigger_webhook"]["name"]: t["trigger_webhook"] + for t in payload["triggers"] + if t["type"] == "webhook" + } + + +def _auth(block: dict) -> dict: + return {k: block[k] for k in ("auth_type", "headers", "secret") if k in block} + + +_LIVE_HTTP_HEADER = { + "auth_type": "http_header", + "headers": [ + { + "name": "token-name", + "value": "{{ integration_secret.partner_token }} -merge field", + "html_value": '

' + "{{ integration_secret.partner_token }} -merge field

", + } + ], +} + + +def test_plan_update_omitted_webhook_auth_keeps_live_auth(webhook_live): + """Before 2026-10-27 the PUT went out without auth and Kizen reset every + webhook to Kizen API auth, changing its URL. The live auth now rides + along, with the read-shape secret reduced to its api_name.""" + (op,) = plan_update_automation(_webhook_spec(webhook_live)).operations + blocks = _webhook_blocks(op.payload) + assert _auth(blocks["webhook_auth_triggers"]) == {"auth_type": "kizen_api"} + assert _auth(blocks["webhook_auth_triggers_1"]) == _LIVE_HTTP_HEADER + assert _auth(blocks["webhook_auth_triggers_2"]) == { + "auth_type": "basic_auth", + "secret": {"api_name": "partner_basic"}, + } + assert _auth(blocks["webhook_auth_triggers_3"]) == {"auth_type": "none"} + + +def test_plan_update_matches_live_webhook_by_name_without_id(webhook_live): + (op,) = plan_update_automation(_webhook_spec(webhook_live, ids=False)).operations + blocks = _webhook_blocks(op.payload) + assert _auth(blocks["webhook_auth_triggers_1"]) == _LIVE_HTTP_HEADER + assert blocks["webhook_auth_triggers_2"]["auth_type"] == "basic_auth" + assert blocks["webhook_auth_triggers_3"]["auth_type"] == "none" + + +def test_plan_update_matches_renamed_webhook_by_id(webhook_live): + """A renamed webhook that keeps its trigger id still keeps the live auth; + no live trigger carries the new name, so only the id can match.""" + spec = _webhook_spec(webhook_live) + for t in spec["triggers"]: + block = t.get("trigger_webhook") + if block: + block["name"] = f"renamed_{block['name']}" + (op,) = plan_update_automation(spec).operations + blocks = _webhook_blocks(op.payload) + assert _auth(blocks["renamed_webhook_auth_triggers_1"]) == _LIVE_HTTP_HEADER + assert blocks["renamed_webhook_auth_triggers_2"]["auth_type"] == "basic_auth" + assert blocks["renamed_webhook_auth_triggers_3"]["auth_type"] == "none" + + +def test_plan_update_explicit_webhook_auth_is_sent_as_written(webhook_live): + spec = _webhook_spec(webhook_live) + for t in spec["triggers"]: + if "trigger_webhook" in t: + t["trigger_webhook"]["auth_type"] = "kizen_api" + (op,) = plan_update_automation(spec).operations + for block in _webhook_blocks(op.payload).values(): + assert _auth(block) == {"auth_type": "kizen_api"} + + +def test_plan_update_new_webhook_trigger_copies_no_auth(webhook_live): + spec = _webhook_spec(webhook_live) + spec["triggers"].append( + { + "trigger_type": "webhook", + "order": 5, + "trigger_webhook": {"name": "brand_new"}, + } + ) + (op,) = plan_update_automation(spec).operations + assert _auth(_webhook_blocks(op.payload)["brand_new"]) == {} + + +@pytest.mark.parametrize("auth", [False, True], ids=["omitted", "restated"]) +def test_diff_automation_shows_no_webhook_auth_lines(webhook_live, auth): + """A spec that leaves auth out, or restates live's auth (headers without + `html_value`, `secret` in its read shape), diffs clean on auth.""" + diff = diff_automation(_webhook_spec(webhook_live, auth=auth))["diff"] + auth_lines = [ + d + for d in diff + if any(f".{k}" in d["path"] for k in ("auth_type", "headers", "secret")) + ] + assert auth_lines == [] + + +@pytest.mark.parametrize( + "block, message", + [ + ( + {"auth_type": "basic_auth", "headers": [{"name": "X-A", "value": "v"}]}, + "trigger_webhook.headers only applies when auth_type is 'http_header'; " + "Kizen drops it for any other auth type.", + ), + ( + {"headers": [{"name": "X-A", "value": "v"}]}, + "trigger_webhook.headers only applies when auth_type is 'http_header'; " + "Kizen drops it for any other auth type.", + ), + ( + {"auth_type": "http_header", "secret": {"api_name": "s"}}, + "trigger_webhook.secret only applies when auth_type is 'basic_auth'; " + "Kizen drops it for any other auth type.", + ), + ( + {"auth_header_name": "X-A", "auth_header_value": "v"}, + "trigger_webhook.auth_header_name, auth_header_value and " + "html_auth_header_value are no longer read by Kizen. Use auth_type " + """'http_header' with headers: [{"name": ..., "value": ...}].""", + ), + ], + ids=["headers", "headers-no-auth-type", "secret", "retired-keys"], +) +def test_webhook_auth_validator_messages(block, message): + import re + + from pydantic import ValidationError + + from kizen_builder.models.spec import TriggerWebhookConfig + + with pytest.raises(ValidationError, match=re.escape(message)): + TriggerWebhookConfig.model_validate({"name": "hook", **block}) + + +def test_webhook_auth_models_accept_the_read_shape(): + from kizen_builder.models.spec import TriggerWebhookConfig + + raw = load_fixture(_WEBHOOK_RAW) + for t in raw["triggers"]: + if "trigger_webhook" in t: + TriggerWebhookConfig.model_validate(t["trigger_webhook"]) + + +def test_webhook_header_html_value_matches_the_ui_byte_for_byte(): + from kizen_builder.tools.planners.automations import _webhook_auth + + live = _LIVE_HTTP_HEADER["headers"][0] + block = { + "auth_type": "http_header", + "headers": [{"name": live["name"], "value": live["value"]}], + } + out = _webhook_auth( + block, + lambda ns, path: ( + _SECRET_DESCRIPTIONS[path] if ns == "integration_secret" else None + ), + ) + assert out["headers"][0]["html_value"] == live["html_value"] + + +@pytest.mark.parametrize("html_value", ["

kept

", "", None]) +def test_webhook_header_present_html_value_is_kept(html_value): + from kizen_builder.tools.planners.automations import _webhook_auth + + header = { + "name": "X-A", + "value": "{{ integration_secret.x }}", + "html_value": html_value, + } + + def no_lookup(ns, path): + raise AssertionError("a present html_value must not be derived") + + out = _webhook_auth({"auth_type": "http_header", "headers": [header]}, no_lookup) + assert out["headers"] == [header] + + +def test_live_to_payload_reduces_webhook_auth_without_lookups(): + """The read path (`translate`, and every `steps`/`activate`/`move` PUT) + runs under a context that raises on any lookup; live headers always carry + `html_value`, so nothing is derived.""" + from kizen_builder.translate import live_to_payload + + blocks = _webhook_blocks(live_to_payload(load_fixture(_WEBHOOK_RAW))) + assert _auth(blocks["webhook_auth_triggers_1"]) == _LIVE_HTTP_HEADER + assert blocks["webhook_auth_triggers_2"]["secret"] == {"api_name": "partner_basic"} + assert "headers" not in blocks["webhook_auth_triggers_2"] + assert "secret" not in blocks["webhook_auth_triggers_3"] + + +def test_live_to_payload_drops_retired_webhook_auth_keys(): + from kizen_builder.translate import live_to_payload + + payload = live_to_payload( + load_fixture("automations/condition_variable_rhs_pipeline.raw.json") + ) + for block in _webhook_blocks(payload).values(): + assert not [k for k in block if k.startswith(("auth_header", "html_auth"))] + + +def test_integration_secret_description_is_fetched_once(env_config): + import respx + + from kizen_builder.tools.planners.automations import LiveContext + + with respx.mock(base_url=env_config.base_url) as router: + route = router.get("/api/integration-secrets").respond( + json={ + "results": [ + { + "api_name": "partner_token", + "description": "partner token (api)", + "obfuscated_value": "****", + } + ], + "next": None, + } + ) + ctx = LiveContext() + assert ( + ctx.integration_secret_description("partner_token") == "partner token (api)" + ) + assert ctx.integration_secret_description("missing") is None + assert route.call_count == 1 + assert "****" not in repr(vars(ctx)) + + # --------------------------------------------------------------------------- # `active`: preserve-on-omission (the regression the reporter hit) # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index 1bdd38a..3a309e8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,6 +33,7 @@ FAKE_BUSINESS_ID, OTHER_BUSINESS_ID, load_fixture, + mock_automation_lookup, plain, ) @@ -364,6 +365,25 @@ def test_automations_get_raw_emits_full_response(monkeypatch): assert json.loads(result.stdout) == raw +def test_automations_get_shows_webhook_auth_type(): + """Webhook rows carry their auth type; other trigger rows don't get the key.""" + raw = load_fixture("automations/webhook_auth_triggers.raw.json") + with respx.mock: + mock_automation_lookup(raw) + rows = auto_tools.get_automation(raw["api_name"])["triggers"] + result = runner.invoke(cli.app, ["automations", "get", raw["api_name"]]) + assert [r.get("auth_type") for r in rows] == [ + None, + "kizen_api", + "http_header", + "basic_auth", + "none", + ] + assert "auth_type" not in rows[0] + assert result.exit_code == 0, result.output + assert "auth" in result.stdout and "basic_auth" in result.stdout + + def test_records_list_csv_flattens_fields(monkeypatch): records = load_fixture("records/list_tax_lot.json") monkeypatch.setattr(record_tools, "search_records", lambda *a, **k: records) From 3810bd955169356bc7a454578da53a72e909a8d2 Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 10:24:07 -0400 Subject: [PATCH 3/8] Model a Call LLM step's MCP server and run-as account Kizen's 2026-10-26 release gave every Call LLM step mcp_server, run_tools_as, max_sequential_turns and tool_failure_policy. The CLI refused the unread keys, so show, diff, steps, activate, deactivate, move and roundtrip all stopped on any automation with such a step. Specs can now name the server by api_name and the account by name, resolved against the step's own pickers, and dry-run refuses one without the other, as Kizen does. The builder always sends all four keys: a PUT that omits both ids keeps the server and clears the account, storing a step Kizen's own validator then rejects. A spec without them therefore detaches a server attached in the UI, so update's preview names each step that loses its server or account. --- CHANGELOG.md | 15 + src/kizen_builder/api/automations.py | 15 + src/kizen_builder/docs/specs/automation.md | 37 ++- .../models/spec/automations_actions_code.py | 39 +++ .../tools/planners/automations.py | 135 +++++++- tests/drift/test_roundtrip_automations.py | 67 ++++ .../automations/call_llm_mcp.raw.json | 161 ++++++++++ tests/test_automation_payloads.py | 289 ++++++++++++++++++ tests/test_translate.py | 29 ++ 9 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/automations/call_llm_mcp.raw.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bb40..2b10eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,13 @@ called out explicitly under **Changed** or **Removed**. ### Added +- **Automation specs can give a Call LLM step an MCP server's tools:** + `mcp_server` (api_name or UUID), `run_tools_as` (a service account's name + or UUID), `max_sequential_turns` and `tool_failure_policy`. Set the server + and the account together; `--dry-run` refuses one without the other. A + spec that leaves them out gets no server, so `update` detaches one attached + in the UI. `update --dry-run` names those steps (`detaches_mcp_server=`), + and `diff` shows the change. - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes @@ -495,6 +502,14 @@ called out explicitly under **Changed** or **Removed**. ### Fixed +- **Automations with an AI (Call LLM) step can be shown, diffed and edited + again.** Since Kizen's 2026-10-26 release every such step carries two + settings the CLI refused, so `show`, `diff`, `steps`, `activate`, + `deactivate`, `move` and `roundtrip` stopped on any automation with one. +- **`automations update` from a spec no longer leaves an MCP-attached Call + LLM step without its run-as account.** Kizen kept the server and dropped + the account when a step's spec left both out, storing a step it then + refuses to save. - **`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/src/kizen_builder/api/automations.py b/src/kizen_builder/api/automations.py index dfe2b09..202c3e4 100644 --- a/src/kizen_builder/api/automations.py +++ b/src/kizen_builder/api/automations.py @@ -55,6 +55,21 @@ def get_metadata(client: KizenClient) -> dict[str, Any]: return client.get("/api/automation2/automations/metadata") +def list_mcp_step_options(client: KizenClient) -> list[dict[str, Any]]: + """GET /api/mcp-servers/step-options: the MCP servers a `call_llm` step + can attach, as its picker lists them (`{id, name, api_name, is_enabled, + deleted, tool_count}`). Unpaginated; the server ignores `search`.""" + return client.get("/api/mcp-servers/step-options") + + +def list_run_tools_as_options(client: KizenClient) -> list[dict[str, Any]]: + """GET /api/automation2/automations/run-tools-as-options: the service + accounts a `call_llm` step can run its MCP tools as (`{id, name}`). + Narrower than the service-account typeahead: a plugin's own service + account isn't listed, and the write rejects it.""" + return client.get("/api/automation2/automations/run-tools-as-options") + + def start_automation( client: KizenClient, api_name: str, diff --git a/src/kizen_builder/docs/specs/automation.md b/src/kizen_builder/docs/specs/automation.md index 0ac497c..fcf19b8 100644 --- a/src/kizen_builder/docs/specs/automation.md +++ b/src/kizen_builder/docs/specs/automation.md @@ -174,7 +174,7 @@ to wait for a reply. Its `wait_type` is `never`, `delay` or | `go_to_automation_step` | `action_go_to_automation_step` | jump to another step (`step_key`) | | `code_step` | `action_code_step` | run Python (`script`, `inputs`, `outputs`, `secrets`) | | `plugin_code_step` | `action_plugin_code_step` | run a step that an installed plugin ships | -| `call_llm` | `action_call_llm` | prompt an LLM, write to `destinations` | +| `call_llm` | `action_call_llm` | prompt an LLM, optionally with an MCP server's tools, write to `destinations` | | `file_content_extraction` | `action_file_content_extraction` | extract from a file field on the record, a related record, or in a variable (`input_value_source`) | | `audio_transcription` | `action_audio_transcription` | transcribe an audio field on the record, a related record, or in a variable (`input_value_source`) | | `change_field_value` | `action_change_field_value` | set/clear a field | @@ -538,6 +538,41 @@ still runs fine via the raw API, but its rich-text prompt editor renders blank in the builder UI without this. Pass `html_prompt` explicitly to override the derivation. +**Tools (MCP server)** — a `call_llm` step can let the model call an MCP +server's tools. Confirmed live 2026-10-27: + +| Spec key | Wire key | Value | +|---|---|---| +| `mcp_server` | `mcp_server_id` | the server's `api_name` or UUID | +| `run_tools_as` | `run_tools_as_id` | the service account's `name` or UUID | +| `max_sequential_turns` | same | 1–32767, default 25 | +| `tool_failure_policy` | same | `never_fail`, the only value Kizen accepts | + +- Names resolve against the step's own pickers: + `GET /api/mcp-servers/step-options` for servers and + `GET /api/automation2/automations/run-tools-as-options` for accounts. The + second leaves out a plugin's own service account, and a write naming that + account is a 400. A service account resolves by `name` because a user-made + one has no `api_name`. A name two accounts share is an error; give the UUID. + The `*_id` spellings take the same values. +- Set both `mcp_server` and `run_tools_as`, or neither. Kizen 400s on either + alone, and `--dry-run` refuses it first. +- A model without tool calling 400s with "The selected model does not support + tool calling". The model catalog has no flag for it, so the server's 400 is + the only check. +- Reads expand both references (`mcp_server {id, name, api_name, …}`, + `run_tools_as {id, name}`), and every `call_llm` step reads + `max_sequential_turns` and `tool_failure_policy`, server or not. +- The planner always sends all four keys. A step that omits them gets no + server, no account, 25 turns and `never_fail`, so `update` from a spec + without them **detaches a server attached in the UI**. `update --dry-run` + names those steps in its preview: `detaches_mcp_server=[, …]`, + and `clears_run_tools_as=[…]` for a run-as account that goes. Steps pair + as `diff` pairs them, and `diff` shows each change as + `mcp_server_id: '' → None` and `run_tools_as_id: '' → None`. + Kizen itself, given a PUT that omits both ids, keeps the server and clears + the account, which leaves a step its own validator rejects. + ### Variable-comparison condition step A `condition` step's `filter_config` normally uses the record-field filter diff --git a/src/kizen_builder/models/spec/automations_actions_code.py b/src/kizen_builder/models/spec/automations_actions_code.py index bdfc7ac..e49086b 100644 --- a/src/kizen_builder/models/spec/automations_actions_code.py +++ b/src/kizen_builder/models/spec/automations_actions_code.py @@ -124,6 +124,45 @@ class ActionLlmCallConfig(BaseModel): "audio_transcription, and a condition step's llm_decision block." ), ) + mcp_server: str | dict[str, Any] | None = Field( + default=None, + description=( + "The MCP server whose tools the model may call: its api_name or " + "UUID, from the Call LLM step's own server picker " + "(GET /api/mcp-servers/step-options). Needs run_tools_as. Omitted " + "means no server, so an update detaches one set in the UI." + ), + ) + mcp_server_id: str | None = Field( + default=None, + description="The MCP server's UUID (the wire spelling of mcp_server).", + ) + run_tools_as: str | dict[str, Any] | None = Field( + default=None, + description=( + "The service account the MCP server's tools run as: its name or " + "UUID, from the step's run-as picker " + "(GET /api/automation2/automations/run-tools-as-options), which " + "leaves out a plugin's own service account. Needs mcp_server." + ), + ) + run_tools_as_id: str | None = Field( + default=None, + description="The service account's UUID (the wire spelling of run_tools_as).", + ) + max_sequential_turns: int | None = Field( + default=None, + ge=1, + le=32767, + description="How many model turns the step may take in a row. Default 25.", + ) + tool_failure_policy: Literal["never_fail"] | None = Field( + default=None, + description=( + "What a failed tool call does to the step. 'never_fail' (the " + "default) is the only value Kizen accepts." + ), + ) class ActionAudioTranscriptionConfig(BaseModel): diff --git a/src/kizen_builder/tools/planners/automations.py b/src/kizen_builder/tools/planners/automations.py index 713e85d..be5730b 100644 --- a/src/kizen_builder/tools/planners/automations.py +++ b/src/kizen_builder/tools/planners/automations.py @@ -83,6 +83,52 @@ def __init__(self) -> None: # rather than a single value shape. self._objects_by_api: dict[str, Any] = {} self._automations_by_api: dict[str, dict[str, Any]] | None = None + self._step_pickers: dict[str, list[dict[str, Any]]] = {} + + def mcp_server_uuid(self, api_name: str) -> str: + """UUID of an MCP server a call_llm step can attach, by api_name.""" + from kizen_builder.api.automations import list_mcp_step_options + + options = self._step_picker("mcp_servers", list_mcp_step_options) + match = next((o for o in options if o.get("api_name") == api_name), None) + if match is None: + available = ", ".join(str(o.get("api_name")) for o in options) + raise PlanError( + f"MCP server '{api_name}' isn't one a Call LLM step can attach " + f"in '{self.env}'. Available: {available or '(none)'}" + ) + return match["id"] + + def service_account_uuid(self, name: str) -> str: + """UUID of a service account a call_llm step can run its tools as, + by name: a user-made account has no api_name.""" + from kizen_builder.api.automations import list_run_tools_as_options + + options = self._step_picker("run_tools_as", list_run_tools_as_options) + matches = [o for o in options if o.get("name") == name] + if not matches: + available = ", ".join(str(o.get("name")) for o in options) + raise PlanError( + f"service account '{name}' can't run a Call LLM step's tools " + f"in '{self.env}'. Available: {available or '(none)'}" + ) + if len(matches) > 1: + ids = ", ".join(m["id"] for m in matches) + raise PlanError( + f"service account name '{name}' matches {len(matches)} accounts " + f"in '{self.env}': {ids}. Use the id instead." + ) + return matches[0]["id"] + + def _step_picker( + self, name: str, fetch: Callable[[Any], list[dict[str, Any]]] + ) -> list[dict[str, Any]]: + if name not in self._step_pickers: + from kizen_builder.api.client import KizenClient + + with KizenClient(load_env_config()) as client: + self._step_pickers[name] = fetch(client) + return self._step_pickers[name] def object_uuid(self, api_name: str) -> str: return self.object_data(api_name)["id"] @@ -285,6 +331,7 @@ def plan_update_automation(automation: dict[str, Any] | AutomationDef) -> Plan: payload = _merge_server_state(_build_automation_payload(auto, ctx), current) preview = _automation_preview(env, auto, live_active=live_active) preview["current_revision"] = current.get("revision") + preview.update(_llm_tools_dropped(current, payload)) return Plan.build( env=env, summary=f"Update automation '{auto.api_name}'", @@ -602,6 +649,31 @@ def _fan_out_warnings(auto: AutomationDef) -> dict[str, Any]: return {"warnings": warnings} if warnings else {} +def _llm_tools_dropped( + current: dict[str, Any], payload: dict[str, Any] +) -> dict[str, list[str]]: + """Call LLM steps whose live MCP server or run-as account the update + clears, by spec step key. A spec that omits the keys sends null, so an + `update` detaches a server attached in the UI. Steps pair as `diff` + pairs them: by id, then by position.""" + from kizen_builder.translate import _wire_pairs + + live_steps = sorted(current.get("steps") or [], key=lambda s: s.get("order") or 0) + dropped: dict[str, list[str]] = {} + for live, built in _wire_pairs(live_steps, payload.get("steps") or []): + if live is None or built is None or built.get("type") != "call_llm": + continue + was = live.get("action_call_llm") or {} + now = built.get("action_call_llm") or {} + for read_key, wire_key, cell in ( + ("mcp_server", "mcp_server_id", "detaches_mcp_server"), + ("run_tools_as", "run_tools_as_id", "clears_run_tools_as"), + ): + if was.get(read_key) and now.get(wire_key) is None: + dropped.setdefault(cell, []).append(built["key"]) + return dropped + + # --------------------------------------------------------------------------- # Top-level payload builder # --------------------------------------------------------------------------- @@ -1438,6 +1510,24 @@ def _built_block_problem( return _scalar_value_resolution_problem(key, block, auto) if step_type == "assign_team_member": return _assign_team_member_problem(key, block, auto, ctx) + if step_type == "call_llm": + return _llm_tools_pairing_problem(key, block) + return None + + +def _llm_tools_pairing_problem(key: str, block: dict[str, Any]) -> str | None: + """The write rejects a call_llm step with only one of the pair.""" + server, account = block.get("mcp_server_id"), block.get("run_tools_as_id") + if server and not account: + return ( + f"step '{key}': mcp_server is set without run_tools_as. Kizen runs " + "an MCP server's tools as a service account, so set both or neither." + ) + if account and not server: + return ( + f"step '{key}': run_tools_as is set without mcp_server. " + "Set both or neither." + ) return None @@ -2255,6 +2345,12 @@ def _normalize_variable(v: Any) -> Any: "destinations", "is_advanced", "merge_field_validation", + "mcp_server", + "mcp_server_id", + "run_tools_as", + "run_tools_as_id", + "max_sequential_turns", + "tool_failure_policy", ) def _step_call_llm( block: dict[str, Any], auto: AutomationDef, ctx: LiveContext @@ -2289,7 +2385,44 @@ def _step_call_llm( out["is_advanced"] = block["is_advanced"] if block.get("merge_field_validation"): out["merge_field_validation"] = block["merge_field_validation"] - return {k: v for k, v in out.items() if v is not None} + out = {k: v for k, v in out.items() if v is not None} + # Always sent, null included: a PUT that omits both ids keeps the server + # and clears the account, a pairing the write itself rejects. + out["mcp_server_id"] = _tool_ref_uuid( + "mcp_server", + block.get("mcp_server_id"), + block.get("mcp_server"), + ctx.mcp_server_uuid, + ) + out["run_tools_as_id"] = _tool_ref_uuid( + "run_tools_as", + block.get("run_tools_as_id"), + block.get("run_tools_as"), + ctx.service_account_uuid, + ) + turns = block.get("max_sequential_turns") + out["max_sequential_turns"] = 25 if turns is None else turns + out["tool_failure_policy"] = block.get("tool_failure_policy") or "never_fail" + return out + + +def _tool_ref_uuid( + key: str, id_value: Any, value: Any, resolve: Callable[[str], str] +) -> str | None: + """A call_llm tool reference as the bare UUID the wire takes. A read's + expanded object yields its id and a UUID passes through, so translate + never reaches ``resolve``; any other string is a name to look up.""" + resolved: set[str] = set() + for v in (id_value, value): + if isinstance(v, dict): + v = v.get("id") + elif v is not None and not is_uuid(v): + v = resolve(v) + if v is not None: + resolved.add(v) + if len(resolved) > 1: + raise PlanError(f"set {key} or {key}_id, not both") + return next(iter(resolved), None) def _normalize_destinations(destinations: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/tests/drift/test_roundtrip_automations.py b/tests/drift/test_roundtrip_automations.py index 60e4182..1ea2116 100644 --- a/tests/drift/test_roundtrip_automations.py +++ b/tests/drift/test_roundtrip_automations.py @@ -2067,6 +2067,72 @@ def test_extraction_input_sources_put_back_unchanged( assert list(semantic_diff(before, after)) == [] +@pytest.fixture(scope="session") +def drift_ai_mcp_step( + drift_client, scratch, drift_object, drift_step_fields, drift_llm_models +) -> dict[str, Any]: + """A `call_llm` step with an MCP server and a run-as account, named the + way a spec names them: the first row of each of the step's own pickers. + Skips when either picker is empty. Stored config only, as above.""" + from kizen_builder.api import automations as auto_api + + servers = auto_api.list_mcp_step_options(drift_client) + accounts = auto_api.list_run_tools_as_options(drift_client) + if not servers or not accounts: + pytest.skip( + "the drift environment has no MCP server or no service account a " + "Call LLM step can use" + ) + spec = _base_spec("ai_mcp", drift_object) + spec["steps"] = [ + { + "key": "llm_tools", + "step_type": "call_llm", + "order": 0, + "parent_key": None, + "action_call_llm": _llm_block( + drift_llm_models["call"], + prompt="Summarize this record using the available tools.", + destinations=[{"field": drift_step_fields["longtext"]["id"]}], + mcp_server=servers[0]["api_name"], + run_tools_as=accounts[0]["id"], + max_sequential_turns=10, + ), + }, + ] + record = _create_automation(drift_client, scratch, spec) + return {**record, "server": servers[0], "account": accounts[0]} + + +def test_call_llm_mcp_server_and_account_read_back(drift_ai_mcp_step): + """The wire takes `mcp_server_id`/`run_tools_as_id`; the read expands + both to objects. A spec names the server by api_name.""" + (live,) = _live_steps_by_type(drift_ai_mcp_step)["call_llm"] + block = live["action_call_llm"] + assert block["mcp_server"]["id"] == drift_ai_mcp_step["server"]["id"] + assert block["run_tools_as"]["id"] == drift_ai_mcp_step["account"]["id"] + assert block["max_sequential_turns"] == 10 + assert block["tool_failure_policy"] == "never_fail" + + +def test_call_llm_mcp_step_puts_back_unchanged(drift_client, drift_ai_mcp_step): + """What every read-modify-write verb does: translate, PUT, re-read.""" + from kizen_builder.api import automations as auto_api + from kizen_builder.translate import live_to_payload, semantic_diff, validate_payload + + before = drift_ai_mcp_step["live"] + payload = live_to_payload(before) + assert validate_payload(payload) == [] + auto_api.update_automation( + drift_client, + drift_ai_mcp_step["uuid"], + payload, + last_revision=payload.get("last_revision"), + ) + after = auto_api.get_automation(drift_client, drift_ai_mcp_step["uuid"]) + assert list(semantic_diff(before, after)) == [] + + def test_input_field_reads_back_mirrored_as_a_context_source( drift_ai_steps, drift_step_fields ): @@ -2485,6 +2551,7 @@ def test_start_and_wait_streams_a_live_run_without_crashing( "drift_related_steps", "drift_messaging", "drift_ai_steps", + "drift_ai_mcp_step", "drift_extraction_sources", "drift_plugin_steps", "drift_activity_steps", diff --git a/tests/fixtures/automations/call_llm_mcp.raw.json b/tests/fixtures/automations/call_llm_mcp.raw.json new file mode 100644 index 0000000..88405b1 --- /dev/null +++ b/tests/fixtures/automations/call_llm_mcp.raw.json @@ -0,0 +1,161 @@ +{ + "id": "9fa618d0-380c-448d-9ed4-7a0d89847e2c", + "name": "Call LLM MCP Tools", + "api_name": "call_llm_mcp", + "ai_description": null, + "active": false, + "type": "record_based", + "custom_object": { + "id": "8c3f0149-6915-4370-940f-2e61848d6842", + "name": "tickets", + "description": null, + "entity_name": "ticket", + "object_name": "ticket", + "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": "f2aa539d-a9ab-4e29-a746-328a6ee1608d", + "description": "Manual", + "user_description": "", + "trigger_type": "manual", + "stats": { + "number_completed": 0 + } + } + ], + "steps": [ + { + "id": "a2ad1433-725d-4ce0-bd2b-857e8bcf3cd1", + "step_type": "call_llm", + "order": 0, + "description": "Call LLM", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": null, + "parent_condition": "", + "action_call_llm": { + "model_name": "kizen/pro", + "business_plugin_app": null, + "prompt": "Summarize the record using the available tools.", + "html_prompt": "

Summarize the record using the available tools.

", + "merge_field_validation": "error_if_required", + "destinations": [ + { + "related_object_field": null, + "field": { + "id": "e7f1ddd1-7861-4328-94b3-1f9008c81f24", + "name": "long_text", + "display_name": "long text", + "deleted": false, + "field_type": "longtext", + "options": [], + "relation": null, + "rating": null + }, + "variable": null, + "conflict_resolution": "overwrite_except_null", + "options": [], + "confidence_threshold": 0.7, + "is_required": false + } + ], + "is_advanced": false, + "mcp_server": { + "id": "bde29351-2a8b-4479-a0d3-c65d8292782e", + "name": "Example Tools", + "api_name": "example_tools", + "is_enabled": true, + "deleted": false, + "tool_count": 7 + }, + "run_tools_as": { + "id": "afa714ac-33c3-49a9-87b0-be75430a7ec6", + "name": "Automation Runner", + "deleted": false + }, + "max_sequential_turns": 10, + "tool_failure_policy": "never_fail" + }, + "action_on_failure": "notify_continue", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + }, + { + "id": "d9a0c41d-af54-4390-9079-dff3a2c67c7c", + "step_type": "call_llm", + "order": 1, + "description": "Call LLM", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": "a2ad1433-725d-4ce0-bd2b-857e8bcf3cd1", + "parent_condition": "", + "action_call_llm": { + "model_name": "kizen/pro", + "business_plugin_app": null, + "prompt": "Summarize the record.", + "html_prompt": "

Summarize the record.

", + "merge_field_validation": "error_if_required", + "destinations": [ + { + "related_object_field": null, + "field": { + "id": "e7f1ddd1-7861-4328-94b3-1f9008c81f24", + "name": "long_text", + "display_name": "long text", + "deleted": false, + "field_type": "longtext", + "options": [], + "relation": null, + "rating": null + }, + "variable": null, + "conflict_resolution": "overwrite_except_null", + "options": [], + "confidence_threshold": 0.7, + "is_required": false + } + ], + "is_advanced": false, + "mcp_server": null, + "run_tools_as": null, + "max_sequential_turns": 25, + "tool_failure_policy": "never_fail" + }, + "action_on_failure": "notify_continue", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + } + ], + "variables": [], + "revision": 5, + "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:54:34.400081-05:00", + "updated": "2026-10-27T08:55:40.471600-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..8949633 100644 --- a/tests/test_automation_payloads.py +++ b/tests/test_automation_payloads.py @@ -588,6 +588,295 @@ def test_call_llm_related_object_field_without_hop_raises(patch_live_lookups): _build(spec) +# --------------------------------------------------------------------------- +# call_llm MCP server, run-as account, turn limit, tool failure policy +# (wire shapes confirmed live 2026-10-27) +# --------------------------------------------------------------------------- + +_SERVER_ID = "bde29351-0000-4000-8000-000000000001" +_ACCOUNT_ID = "afa714ac-0000-4000-8000-000000000002" +_TWIN_ID = "afa714ac-0000-4000-8000-000000000003" + + +@pytest.fixture +def step_pickers(monkeypatch): + """Serve the call_llm step's two pickers; count the GETs.""" + from kizen_builder.api import automations as api + from kizen_builder.api import client + + class _Client: + def __init__(self, cfg): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + calls = {"servers": 0, "accounts": 0} + lists = { + "servers": [ + {"id": _SERVER_ID, "name": "Example Tools", "api_name": "example_tools"} + ], + "accounts": [ + {"id": _ACCOUNT_ID, "name": "Automation Runner"}, + {"id": _TWIN_ID, "name": "Twin"}, + {"id": "afa714ac-0000-4000-8000-000000000004", "name": "Twin"}, + ], + } + + def serve(which): + def fetch(_client): + calls[which] += 1 + return lists[which] + + return fetch + + monkeypatch.setattr(client, "KizenClient", _Client) + monkeypatch.setattr(api, "list_mcp_step_options", serve("servers")) + monkeypatch.setattr(api, "list_run_tools_as_options", serve("accounts")) + return {"calls": calls, "lists": lists} + + +def _llm_spec(*blocks: dict) -> dict: + return { + "api_name": "call_llm_tools_test", + "name": "Call LLM Tools Test", + "type": "record_based", + "target_object": "patients", + "steps": [ + { + "key": f"llm{i}", + "step_type": "call_llm", + "order": i, + "parent_key": f"llm{i - 1}" if i else None, + "action_call_llm": {"model_name": "kizen/pro", "prompt": "Hi", **b}, + } + for i, b in enumerate(blocks) + ], + } + + +_TOOL_KEYS = ( + "mcp_server_id", + "run_tools_as_id", + "max_sequential_turns", + "tool_failure_policy", +) + + +def _tool_keys(payload: dict, i: int = 0) -> tuple: + block = payload["steps"][i]["action_call_llm"] + return tuple(block[k] for k in _TOOL_KEYS) + + +def test_call_llm_omitted_tool_keys_build_to_explicit_defaults(patch_live_lookups): + """Omitting both ids on a PUT keeps the server and clears the account, + so the builder always sends the pair, null included.""" + assert _tool_keys(_build(_llm_spec({}))) == (None, None, 25, "never_fail") + + +def test_call_llm_names_resolve_through_the_step_pickers( + patch_live_lookups, step_pickers +): + payload = _build( + _llm_spec( + {"mcp_server": "example_tools", "run_tools_as": "Automation Runner"}, + { + "mcp_server": "example_tools", + "run_tools_as": "Automation Runner", + "max_sequential_turns": 10, + "tool_failure_policy": "never_fail", + }, + ) + ) + assert _tool_keys(payload, 0) == (_SERVER_ID, _ACCOUNT_ID, 25, "never_fail") + assert _tool_keys(payload, 1) == (_SERVER_ID, _ACCOUNT_ID, 10, "never_fail") + assert step_pickers["calls"] == {"servers": 1, "accounts": 1} + + +def test_call_llm_ids_and_read_objects_need_no_lookup(patch_live_lookups): + """A UUID or a read's expanded object never reaches a resolver.""" + from kizen_builder.translate import _SHIM_CTX + + for block in ( + {"mcp_server_id": _SERVER_ID, "run_tools_as_id": _ACCOUNT_ID}, + {"mcp_server": _SERVER_ID, "run_tools_as": _ACCOUNT_ID}, + { + "mcp_server": {"id": _SERVER_ID, "api_name": "example_tools"}, + "run_tools_as": {"id": _ACCOUNT_ID, "name": "Automation Runner"}, + }, + ): + out = _STEP_BUILDERS["call_llm"]( + {"model_name": "kizen/pro", "html_prompt": "

Hi

", **block}, + AutomationDef.model_validate(_llm_spec()), + _SHIM_CTX, + ) + assert (out["mcp_server_id"], out["run_tools_as_id"]) == ( + _SERVER_ID, + _ACCOUNT_ID, + ) + + +@pytest.mark.parametrize( + "block, message", + [ + ( + {"mcp_server": "nope", "run_tools_as": "Automation Runner"}, + "MCP server 'nope' isn't one a Call LLM step can attach in " + "'{env}'. Available: example_tools", + ), + ( + {"mcp_server": "example_tools", "run_tools_as": "Nobody"}, + "service account 'Nobody' can't run a Call LLM step's tools in " + "'{env}'. Available: Automation Runner, Twin, Twin", + ), + ( + {"mcp_server": "example_tools", "run_tools_as": "Twin"}, + "service account name 'Twin' matches 2 accounts in '{env}': " + f"{_TWIN_ID}, afa714ac-0000-4000-8000-000000000004. Use the id instead.", + ), + ( + {"mcp_server": "example_tools", "mcp_server_id": _TWIN_ID}, + "set mcp_server or mcp_server_id, not both", + ), + ( + {"run_tools_as": "Automation Runner", "run_tools_as_id": _TWIN_ID}, + "set run_tools_as or run_tools_as_id, not both", + ), + ( + {"mcp_server": "example_tools"}, + "step 'llm0': mcp_server is set without run_tools_as. Kizen runs an " + "MCP server's tools as a service account, so set both or neither.", + ), + ( + {"run_tools_as_id": _ACCOUNT_ID}, + "step 'llm0': run_tools_as is set without mcp_server. Set both or neither.", + ), + ], + ids=[ + "unknown-server", + "unknown-account", + "ambiguous-account", + "server-twice", + "account-twice", + "server-alone", + "account-alone", + ], +) +def test_call_llm_tool_reference_errors( + patch_live_lookups, step_pickers, block, message +): + env = LiveContext().env + with pytest.raises(PlanError) as e: + _build(_llm_spec(block)) + assert message.format(env=env) in str(e.value) + + +def test_call_llm_both_spellings_agreeing_is_fine(patch_live_lookups, step_pickers): + payload = _build( + _llm_spec( + { + "mcp_server": "example_tools", + "mcp_server_id": _SERVER_ID, + "run_tools_as": {"id": _ACCOUNT_ID}, + "run_tools_as_id": _ACCOUNT_ID, + } + ) + ) + assert _tool_keys(payload)[:2] == (_SERVER_ID, _ACCOUNT_ID) + + +@pytest.mark.parametrize( + "field, value", + [ + ("max_sequential_turns", 0), + ("max_sequential_turns", 32768), + ("tool_failure_policy", "fail_step"), + ], +) +def test_call_llm_tool_settings_are_bounded_in_the_spec(field, value): + from pydantic import ValidationError + + with pytest.raises(ValidationError, match=field): + AutomationDef.model_validate(_llm_spec({field: value})) + + +@pytest.fixture +def live_mcp_automation(monkeypatch, patch_live_lookups): + """The captured call_llm_mcp automation as the live one `update` reads: + step 0 has a server and account, step 1 has neither.""" + import kizen_builder.tools.planners.automations as ma + + raw = load_fixture("automations/call_llm_mcp.raw.json") + raw["custom_object"]["name"] = "patients" + monkeypatch.setattr( + ma, "list_automations", lambda: [{"api_name": raw["api_name"], "id": raw["id"]}] + ) + monkeypatch.setattr(ma, "get_automation", lambda api_name: {"raw": raw}) + return raw + + +def _update_spec(raw: dict, *blocks: dict) -> dict: + spec = _llm_spec(*blocks) + spec.update(api_name=raw["api_name"], name=raw["name"]) + steps = sorted(raw["steps"], key=lambda s: s["order"]) + for step, live in zip(spec["steps"], steps, strict=True): + step["id"] = live["id"] + return spec + + +def test_update_dry_run_names_steps_whose_mcp_server_goes(live_mcp_automation): + """A spec that omits the keys sends nulls, so the update detaches a server + attached in the UI. The preview `--dry-run` prints says which steps.""" + plan = plan_update_automation(_update_spec(live_mcp_automation, {}, {})) + (op,) = plan.operations + assert op.preview["detaches_mcp_server"] == ["llm0"] + assert op.preview["clears_run_tools_as"] == ["llm0"] + + +def test_update_dry_run_is_quiet_when_the_server_stays( + live_mcp_automation, step_pickers +): + step_pickers["lists"]["servers"][0]["id"] = live_mcp_automation["steps"][0][ + "action_call_llm" + ]["mcp_server"]["id"] + step_pickers["lists"]["accounts"][0]["id"] = live_mcp_automation["steps"][0][ + "action_call_llm" + ]["run_tools_as"]["id"] + tools = {"mcp_server": "example_tools", "run_tools_as": "Automation Runner"} + (op,) = plan_update_automation( + _update_spec(live_mcp_automation, tools, {}) + ).operations + assert "detaches_mcp_server" not in op.preview + assert "clears_run_tools_as" not in op.preview + + +def test_steps_edit_call_llm_names_the_server_by_api_name( + patch_live_lookups, step_pickers +): + from kizen_builder.tools.automations import normalize_step_patch + + patch = { + "action_call_llm": { + "model_name": "kizen/pro", + "html_prompt": "

Hi

", + "mcp_server": "example_tools", + "run_tools_as": "Automation Runner", + "max_sequential_turns": 10, + } + } + out = normalize_step_patch(patch, "llm", "call_llm", _LIVE_PAYLOAD, "patients") + block = out["action_call_llm"] + assert tuple(block[k] for k in _TOOL_KEYS) == ( + _SERVER_ID, + _ACCOUNT_ID, + 10, + "never_fail", + ) + + def test_find_relationship_field_auto_detects_unambiguous_hop(monkeypatch): """LiveContext.find_relationship_field is the auto-detect fallback used when a related_object_field destination omits relationship_field_ref.""" diff --git a/tests/test_translate.py b/tests/test_translate.py index 4b52285..17cc7de 100644 --- a/tests/test_translate.py +++ b/tests/test_translate.py @@ -32,6 +32,7 @@ "automations/activity_logged_schedule_activity.raw.json", "automations/archive_record.raw.json", "automations/branching_test.raw.json", + "automations/call_llm_mcp.raw.json", "automations/condition_code_step.raw.json", "automations/condition_variable_rhs.raw.json", "automations/condition_variable_rhs_pipeline.raw.json", @@ -1278,6 +1279,34 @@ def test_validator_flags_skipped_goal_without_branch(kitchen: dict) -> None: ] +def test_call_llm_tool_settings_translate_to_ids() -> None: + """Reads expand mcp_server/run_tools_as; the wire takes bare UUIDs. A + step with no server sends an explicit null pair: a PUT that omits both + keeps the server and clears the account (confirmed live 2026-10-27). + live_to_payload runs the builder with a context that fails on any lookup.""" + raw = load_fixture("automations/call_llm_mcp.raw.json") + steps = sorted(raw["steps"], key=lambda s: s["order"]) + with_tools, bare = (s["action_call_llm"] for s in steps) + payload = live_to_payload(raw) + keys = ( + "mcp_server_id", + "run_tools_as_id", + "max_sequential_turns", + "tool_failure_policy", + ) + assert [_step_block(payload, "call_llm", i)[k] for i in (0, 1) for k in keys] == [ + with_tools["mcp_server"]["id"], + with_tools["run_tools_as"]["id"], + with_tools["max_sequential_turns"], + "never_fail", + None, + None, + 25, + "never_fail", + ] + assert bare["mcp_server"] is None and with_tools["max_sequential_turns"] == 10 + + def test_validator_accepts_plain_fan_out() -> None: """A step with several plain children and no Branch card between them: the server still accepts it, so the group rules must not reject it.""" From 71859bc4c819df2161662b620595323035303d09 Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 10:27:51 -0400 Subject: [PATCH 4/8] Add a kizen mcp-servers command group An MCP server is the set of Kizen tools a Call LLM step or an outside MCP client can call, and builds had no way to make or change one from the CLI. The new group lists, creates, updates and deletes servers from a spec, and shows a server's tools and change history. Tool names are checked against the live catalogue, because Kizen rejects the whole request for one unknown tool. Tools compare as a set and sharing compares after normalising both sides, so get --raw output sent back unchanged plans a skip. Kizen-managed servers are refused, except for setting one back as the business default. Kizen keeps one default per business, so the preview names the server that loses it and how to restore it. --- CHANGELOG.md | 3 + CONTRIBUTING.md | 5 + scripts/cli-tree-baseline.txt | 162 +++++++ src/kizen_builder/api/mcp_servers.py | 59 +++ src/kizen_builder/cli/__init__.py | 1 + src/kizen_builder/cli/mcp_servers.py | 306 ++++++++++++ src/kizen_builder/docs/commands.md | 7 + src/kizen_builder/docs/reference.md | 1 + src/kizen_builder/docs/specs/README.md | 1 + src/kizen_builder/docs/specs/mcp-server.md | 190 ++++++++ src/kizen_builder/models/spec/__init__.py | 10 + src/kizen_builder/models/spec/mcp_servers.py | 106 ++++ src/kizen_builder/tools/mcp_servers.py | 110 +++++ .../tools/planners/mcp_servers.py | 299 ++++++++++++ src/kizen_builder/tools/plans.py | 16 + tests/drift/contracts.py | 4 + tests/drift/schema_snapshot.json | 74 ++- tests/fixtures/README.md | 7 + tests/fixtures/mcp_servers/detail.json | 88 ++++ .../fixtures/mcp_servers/detail_readonly.json | 136 ++++++ .../fixtures/mcp_servers/events_history.json | 172 +++++++ tests/fixtures/mcp_servers/list.json | 100 ++++ tests/fixtures/mcp_servers/step_options.json | 18 + tests/fixtures/mcp_servers/tools.json | 93 ++++ tests/test_cli.py | 77 +++ tests/test_execute_dispatch.py | 1 + tests/test_mcp_server_plans.py | 454 ++++++++++++++++++ tests/test_mcp_servers_api.py | 91 ++++ 28 files changed, 2590 insertions(+), 1 deletion(-) create mode 100644 src/kizen_builder/api/mcp_servers.py create mode 100644 src/kizen_builder/cli/mcp_servers.py create mode 100644 src/kizen_builder/docs/specs/mcp-server.md create mode 100644 src/kizen_builder/models/spec/mcp_servers.py create mode 100644 src/kizen_builder/tools/mcp_servers.py create mode 100644 src/kizen_builder/tools/planners/mcp_servers.py create mode 100644 tests/fixtures/mcp_servers/detail.json create mode 100644 tests/fixtures/mcp_servers/detail_readonly.json create mode 100644 tests/fixtures/mcp_servers/events_history.json create mode 100644 tests/fixtures/mcp_servers/list.json create mode 100644 tests/fixtures/mcp_servers/step_options.json create mode 100644 tests/fixtures/mcp_servers/tools.json create mode 100644 tests/test_mcp_server_plans.py create mode 100644 tests/test_mcp_servers_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bb40..a8b73ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,9 @@ called out explicitly under **Changed** or **Removed**. ### Added +- **`kizen mcp-servers`** lists, creates, updates and deletes MCP servers + from a spec, checks tool names against the live catalogue, and shows a + server's tools and change history. - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dab20d8..83cf00a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,6 +107,7 @@ src/kizen_builder/ files.py # raw file bytes: the S3 upload/download legs (not JSON) forms.py # form/survey CRUD (base_path-shared with surveys) layouts.py # record-layout CRUD (field/block arrangement) + mcp_servers.py # MCP server CRUD, tool catalogue, events history messages.py # automation-scoped message resources (notify_* step content) permissions.py # roles, permission groups, permissions catalog pipelines.py # pipeline-stage CRUD + record stage-move @@ -125,6 +126,7 @@ src/kizen_builder/ form_ui.py # build a form/survey's visual, submittable page layout forms.py # list/get forms and surveys (base_path-shared) layouts.py # list/get, block helpers, inject_layout_ids + mcp_servers.py # resolve/list/get MCP servers, tool catalogue, history messages.py # automation message content (notify_* step templates) objects.py # list_objects(), get_object(api_name) permission_builder.py # enumerate/build/mutate the full permission-group structure @@ -159,6 +161,7 @@ src/kizen_builder/ fields.py # plan_create_field, plan_update_field forms.py # plan_create/update/delete forms/surveys + fields layouts.py # plan_update_layout + mcp_servers.py # plan_create/update/delete MCP servers messages.py # plan_create automation message content objects.py # plan_create/update_object, plan_create/update_category permissions.py # plan_create/update/delete roles + permission groups @@ -170,6 +173,7 @@ src/kizen_builder/ translate.py # Automation GET → PUT wire translation. models/spec/ # Pydantic shapes for desired state (spec inputs); # package, one module per cluster — see __init__.py. + mcp_servers.py # MCPServerDef + its sharing block output.py # Shared table/json/csv output layer for read commands. cli/ # Typer wrappers around tools/. The `kizen` command. __init__.py # exports `app`; imports every module below, IN ORDER @@ -203,6 +207,7 @@ src/kizen_builder/ forms_fields.py # …and its `fields` / `fields options` sub-apps automations_write.py # kizen automations — create/update/lifecycle folders.py # kizen automations folders + mcp_servers.py # kizen mcp-servers apply.py # kizen apply smart_connectors.py # kizen smart-connectors — app, shared helpers, build/configure smart_connectors_seeds.py # kizen smart-connectors seeds diff --git a/scripts/cli-tree-baseline.txt b/scripts/cli-tree-baseline.txt index 2b1c4a6..c28bb85 100644 --- a/scripts/cli-tree-baseline.txt +++ b/scripts/cli-tree-baseline.txt @@ -50,6 +50,7 @@ │ layout (form_ui). │ │ surveys Create, read, update, and delete surveys, their fields, and their visual page │ │ layout (form_ui). │ +│ mcp-servers Read, create, update, and delete MCP servers. │ │ smart-connectors Build, inspect, and iterate on smart connectors. Read verbs │ │ (list/get/executions/scripts/events/metadata) are safe. The local SQL loop is │ │ pull → run → push: `pull` builds a working directory, `run` executes │ @@ -2323,6 +2324,143 @@ Spec shape (a LayoutDef; PUT-replaces the layout): see `kizen docs show layout` + Usage: mcp-servers [OPTIONS] COMMAND [ARGS]... + + Read, create, update, and delete MCP servers. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────╮ +│ list List the MCP servers in the configured env. │ +│ get Show one MCP server, its endpoint URL and its tools. │ +│ tools List the tools a server can serve — the valid `tool_names`. │ +│ history Show a server's change history, newest first. │ +│ create Create an MCP server from a JSON MCPServerDef. │ +│ update Update an MCP server from a JSON MCPServerDef. │ +│ delete Delete an MCP server. Kizen deletes it outright, with its history. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Usage: mcp-servers create [OPTIONS] + + Create an MCP server from a JSON MCPServerDef. + + `name` and `api_name` are required. Tool names are checked against + `kizen mcp-servers tools`. With no `sharing_settings`, every team member + can view it. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --spec-file Path to a JSON MCPServerDef. Default: read from stdin. │ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers delete [OPTIONS] {server} + + Delete an MCP server. Kizen deletes it outright, with its history. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * server MCP server UUID or api_name. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers get [OPTIONS] {server} + + Show one MCP server, its endpoint URL and its tools. + + The endpoint URL is what an MCP client connects to; the CLI never calls it. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * server MCP server UUID or api_name. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --raw Emit the full API response — a valid `mcp-servers update` spec. Implies │ +│ JSON. │ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers history [OPTIONS] {server} + + Show a server's change history, newest first. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * server MCP server UUID or api_name. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers list [OPTIONS] + + List the MCP servers in the configured env. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers tools [OPTIONS] + + List the tools a server can serve — the valid `tool_names`. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers update [OPTIONS] {server} + + Update an MCP server from a JSON MCPServerDef. + + Only the keys the spec sets are compared. `get --raw` output works as a + spec: its read-only keys are ignored. A Kizen-managed server takes only + `{"is_default_for_business": true}`, which makes it the default again. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * server MCP server UUID or api_name. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --spec-file Path to a JSON MCPServerDef. Default: read from stdin. │ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + Usage: messages [OPTIONS] COMMAND [ARGS]... Email templates and automation-scoped messages — the content notify_member_via_email steps @@ -4733,6 +4871,30 @@ ===== layouts update ===== +===== mcp-servers ===== + + +===== mcp-servers create ===== + + +===== mcp-servers delete ===== + + +===== mcp-servers get ===== + + +===== mcp-servers history ===== + + +===== mcp-servers list ===== + + +===== mcp-servers tools ===== + + +===== mcp-servers update ===== + + ===== messages ===== diff --git a/src/kizen_builder/api/mcp_servers.py b/src/kizen_builder/api/mcp_servers.py new file mode 100644 index 0000000..1ea4ba3 --- /dev/null +++ b/src/kizen_builder/api/mcp_servers.py @@ -0,0 +1,59 @@ +"""MCP server configuration against the Kizen API. + +An MCP server is a named set of Kizen tools that AI steps and outside MCP +clients call. This module configures servers; it does not call one. A server's +``endpoint_url`` (``/api/mcp/{api_name}``) is the JSON-RPC endpoint MCP clients +use at run time, and nothing here touches it. + +Endpoints: + GET /api/mcp-servers list servers (DRF page) + POST /api/mcp-servers create + GET /api/mcp-servers/{id} one server, incl. its tools + PATCH /api/mcp-servers/{id} partial update + DELETE /api/mcp-servers/{id} hard delete (204) + GET /api/mcp-servers/tools the tool catalogue, by group + GET /api/mcp-servers/{id}/events-history change history (DRF page) +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api.client import KizenClient + + +def list_mcp_servers(client: KizenClient) -> list[dict[str, Any]]: + """GET /api/mcp-servers — every server, with ``tool_count`` and no ``tools``.""" + return client.paginate("/api/mcp-servers") + + +def get_mcp_server(client: KizenClient, server_id: str) -> dict[str, Any]: + """GET /api/mcp-servers/{id} — one server, with ``tools`` expanded.""" + return client.get(f"/api/mcp-servers/{server_id}") + + +def create_mcp_server(client: KizenClient, payload: dict[str, Any]) -> dict[str, Any]: + """POST /api/mcp-servers — returns the created server's read shape.""" + return client.post("/api/mcp-servers", json=payload) + + +def update_mcp_server( + client: KizenClient, server_id: str, payload: dict[str, Any] +) -> dict[str, Any]: + """PATCH /api/mcp-servers/{id} — only the supplied keys change.""" + return client.patch(f"/api/mcp-servers/{server_id}", json=payload) + + +def delete_mcp_server(client: KizenClient, server_id: str) -> None: + """DELETE /api/mcp-servers/{id}.""" + client.delete(f"/api/mcp-servers/{server_id}") + + +def get_tool_catalog(client: KizenClient) -> dict[str, Any]: + """GET /api/mcp-servers/tools — ``{groups: [{key, label, tools: [...]}]}``.""" + return client.get("/api/mcp-servers/tools") + + +def list_events_history(client: KizenClient, server_id: str) -> list[dict[str, Any]]: + """GET /api/mcp-servers/{id}/events-history — written a second or so after a change.""" + return client.paginate(f"/api/mcp-servers/{server_id}/events-history") diff --git a/src/kizen_builder/cli/__init__.py b/src/kizen_builder/cli/__init__.py index fdbff26..8bcb6f7 100644 --- a/src/kizen_builder/cli/__init__.py +++ b/src/kizen_builder/cli/__init__.py @@ -52,6 +52,7 @@ from kizen_builder.cli import forms # noqa: F401 from kizen_builder.cli import automations_write # noqa: F401 from kizen_builder.cli import folders # noqa: F401 +from kizen_builder.cli import mcp_servers # noqa: F401 from kizen_builder.cli import apply # noqa: F401 from kizen_builder.cli import smart_connectors # noqa: F401 from kizen_builder.cli import smart_connectors_seeds # noqa: F401 diff --git a/src/kizen_builder/cli/mcp_servers.py b/src/kizen_builder/cli/mcp_servers.py new file mode 100644 index 0000000..91efa96 --- /dev/null +++ b/src/kizen_builder/cli/mcp_servers.py @@ -0,0 +1,306 @@ +"""`kizen mcp-servers` — the tool sets AI steps and MCP clients use.""" + +from __future__ import annotations + +import typer +from rich.table import Table +from rich.text import Text + +from kizen_builder import output as out +from kizen_builder.cli._mutations import _read_spec, _run_mutation +from kizen_builder.cli._shared import ( + JSON_OPTION, + OUTPUT_OPTION, + app, + cli_errors, + console, +) +from kizen_builder.tools import mcp_servers as mcp_tools +from kizen_builder.tools.planners import mcp_servers as mcp_planners + +EPILOG = "See `kizen docs show mcp-server`" +SERVER_ARG = typer.Argument(..., help="MCP server UUID or api_name.") + +mcp_servers_app = typer.Typer( + help="Read, create, update, and delete MCP servers.", no_args_is_help=True +) +app.add_typer(mcp_servers_app, name="mcp-servers") + + +def _flag(value: object) -> str: + return "✓" if value else "" + + +@mcp_servers_app.command("list", epilog=EPILOG) +def mcp_servers_list( + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """List the MCP servers in the configured env.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(): + items = mcp_tools.list_mcp_servers() + columns = [ + out.Column("api_name", "api_name"), + out.Column("name", "name"), + out.Column("tools", "tool_count"), + out.Column("enabled", "is_enabled"), + out.Column("default", "is_default_for_business"), + out.Column("system", "is_system_managed"), + out.Column("id", "id"), + ] + + def table() -> None: + t = Table(title="MCP servers") + t.add_column("api_name") + t.add_column("name") + t.add_column("tools", justify="right") + t.add_column("enabled", justify="center") + t.add_column("default", justify="center") + t.add_column("system", justify="center") + t.add_column("id") + for s in items: + t.add_row( + s.get("api_name") or "", + Text(s.get("name") or ""), + str(s.get("tool_count", "")), + _flag(s.get("is_enabled")), + _flag(s.get("is_default_for_business")), + _flag(s.get("is_system_managed")), + s.get("id") or "", + ) + console.print(t) + + out.render(fmt, json_data=items, table=table, csv_rows=items, csv_columns=columns) + + +@mcp_servers_app.command("get", epilog=EPILOG) +def mcp_servers_get( + server: str = SERVER_ARG, + raw: bool = typer.Option( + False, + "--raw", + help="Emit the full API response — a valid `mcp-servers update` spec. " + "Implies JSON.", + ), + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """Show one MCP server, its endpoint URL and its tools. + + The endpoint URL is what an MCP client connects to; the CLI never calls it. + """ + fmt = out.resolve_format(output, json_out) + with cli_errors(LookupError): + detail = mcp_tools.get_mcp_server_detail(server) + + if raw: + out.emit_json(detail["raw"]) + return + + def table() -> None: + t = Table(show_header=False, box=None) + t.add_column(style="bold") + t.add_column() + for label, value in ( + ("name", detail["name"]), + ("api_name", detail["api_name"]), + ("description", detail["description"]), + ("enabled", detail["is_enabled"]), + ("default", detail["is_default_for_business"]), + ("system", detail["is_system_managed"]), + ("endpoint_url", detail["endpoint_url"]), + ("owner", detail["owner"]), + ("id", detail["id"]), + ): + t.add_row(label, Text("" if value is None else str(value))) + console.print(t) + tools = Table(title="Tools") + tools.add_column("group") + tools.add_column("name") + for tool in detail["tools"]: + tools.add_row(tool["group"] or "", tool["name"] or "") + console.print(tools) + + out.render( + fmt, + json_data={k: v for k, v in detail.items() if k != "raw"}, + table=table, + csv_rows=detail["tools"], + csv_columns=[out.Column("group", "group"), out.Column("name", "name")], + ) + + +def _tool_rows(catalog: dict) -> list[dict]: + return [ + { + "group": g.get("key"), + "name": t.get("name"), + "read_only": (t.get("annotations") or {}).get("readOnlyHint"), + "description": (t.get("description") or "")[:80], + } + for g in catalog.get("groups") or [] + for t in g.get("tools") or [] + ] + + +@mcp_servers_app.command("tools", epilog=EPILOG) +def mcp_servers_tools( + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """List the tools a server can serve — the valid `tool_names`.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(): + catalog = mcp_tools.tool_catalog() + rows = _tool_rows(catalog) + columns = [ + out.Column("group", "group"), + out.Column("name", "name"), + out.Column("read_only", "read_only"), + out.Column("description", "description"), + ] + + def table() -> None: + t = Table(title="MCP tools") + for c in columns: + t.add_column(c.header) + for r in rows: + t.add_row( + r["group"] or "", + r["name"] or "", + _flag(r["read_only"]), + Text(r["description"]), + ) + console.print(t) + + out.render( + fmt, + json_data=catalog.get("groups") or [], + table=table, + csv_rows=rows, + csv_columns=columns, + ) + + +def _history_rows(events: list[dict]) -> list[dict]: + return [ + { + "when": e.get("created"), + "event": e.get("type_display_name"), + "by": mcp_tools.initiator_label(e), + "changed": ", ".join(mcp_tools.changed_keys(e)) or "—", + } + for e in events + ] + + +@mcp_servers_app.command("history", epilog=EPILOG) +def mcp_servers_history( + server: str = SERVER_ARG, + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """Show a server's change history, newest first.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(LookupError): + events = mcp_tools.list_history(server) + rows = _history_rows(events) + columns = [ + out.Column("when", "when"), + out.Column("event", "event"), + out.Column("by", "by"), + out.Column("changed", "changed"), + ] + + def table() -> None: + if not rows: + console.print( + "no events yet (Kizen records them a second or so after a change)" + ) + return + t = Table(title="History") + for c in columns: + t.add_column(c.header) + for r in rows: + t.add_row(*(Text(str(r[c.header] or "")) for c in columns)) + console.print(t) + + out.render(fmt, json_data=events, table=table, csv_rows=rows, csv_columns=columns) + + +SPEC_FILE_OPTION = typer.Option( + "", "--spec-file", help="Path to a JSON MCPServerDef. Default: read from stdin." +) +DRY_RUN_OPTION = typer.Option( + False, "--dry-run", help="Show the plan without applying." +) +YES_OPTION = typer.Option( + False, "--yes", "-y", help="Skip the y/N confirmation prompt." +) +MUTATION_JSON_OPTION = typer.Option( + False, "--json", help="Emit JSON (plan with --dry-run, results otherwise)." +) + + +@mcp_servers_app.command("create", epilog=EPILOG) +def mcp_servers_create( + spec_file: str = SPEC_FILE_OPTION, + dry_run: bool = DRY_RUN_OPTION, + yes: bool = YES_OPTION, + json_out: bool = MUTATION_JSON_OPTION, +) -> None: + """Create an MCP server from a JSON MCPServerDef. + + `name` and `api_name` are required. Tool names are checked against + `kizen mcp-servers tools`. With no `sharing_settings`, every team member + can view it. + """ + spec, from_stdin = _read_spec(spec_file, what="MCP server") + _run_mutation( + lambda: mcp_planners.plan_create_mcp_server(spec), + dry_run=dry_run, + yes=yes, + json_out=json_out, + stdin_consumed=from_stdin, + ) + + +@mcp_servers_app.command("update", epilog=EPILOG) +def mcp_servers_update( + server: str = SERVER_ARG, + spec_file: str = SPEC_FILE_OPTION, + dry_run: bool = DRY_RUN_OPTION, + yes: bool = YES_OPTION, + json_out: bool = MUTATION_JSON_OPTION, +) -> None: + """Update an MCP server from a JSON MCPServerDef. + + Only the keys the spec sets are compared. `get --raw` output works as a + spec: its read-only keys are ignored. A Kizen-managed server takes only + `{"is_default_for_business": true}`, which makes it the default again. + """ + spec, from_stdin = _read_spec(spec_file, what="MCP server") + _run_mutation( + lambda: mcp_planners.plan_update_mcp_server(server, spec), + dry_run=dry_run, + yes=yes, + json_out=json_out, + stdin_consumed=from_stdin, + ) + + +@mcp_servers_app.command("delete", epilog=EPILOG) +def mcp_servers_delete( + server: str = SERVER_ARG, + dry_run: bool = DRY_RUN_OPTION, + yes: bool = YES_OPTION, + json_out: bool = MUTATION_JSON_OPTION, +) -> None: + """Delete an MCP server. Kizen deletes it outright, with its history.""" + _run_mutation( + lambda: mcp_planners.plan_delete_mcp_server(server), + dry_run=dry_run, + yes=yes, + json_out=json_out, + ) diff --git a/src/kizen_builder/docs/commands.md b/src/kizen_builder/docs/commands.md index f515c50..1aea3ac 100644 --- a/src/kizen_builder/docs/commands.md +++ b/src/kizen_builder/docs/commands.md @@ -32,6 +32,10 @@ kizen automations modification-history # who changed this automati kizen automations failures # recent step-failure history kizen dashboards list # list dashboards + homepages (mine) kizen dashboards get # one dashboard + dashlet summary (--raw for full config) +kizen mcp-servers list # MCP servers: api_name, tool count, enabled, default +kizen mcp-servers get # one server: endpoint_url + its tools (--raw is a valid update spec) +kizen mcp-servers tools # the tool catalogue — valid tool_names +kizen mcp-servers history # who changed a server, when, which keys kizen layouts list # record layouts on a custom object kizen layouts get [--name] # one layout's block/column structure kizen records get # one record with all field values @@ -173,6 +177,7 @@ kizen fields create --api-name X --type text --category Y [... kizen fields create [--spec-file path | < stdin] # many at once kizen automations create [--spec-file path | < stdin] kizen dashboards create [--spec-file path | < stdin] # dashboard/homepage + dashlets +kizen mcp-servers create [--spec-file path | < stdin] # MCP server: tools + sharing (kizen docs show mcp-server) kizen layouts update [--spec-file | < stdin] # PUT-replace a record layout kizen records create --field api_name=value [...] # one record (values resolved vs schema) kizen records create [--spec-file f.csv|f.json | < stdin] # bulk create from CSV/JSON @@ -184,6 +189,7 @@ kizen fields update [--name X] [--required] [ kizen automations update [--spec-file path | < stdin] kizen dashboards update [--spec-file | < stdin] # metadata + dashlets (diff by id) kizen dashboards duplicate [--name X] [--api-name Y] # unpublished copy, source's sharing +kizen mcp-servers update [--spec-file | < stdin] # tools compare as a set; `get --raw` output re-applies as a skip kizen records update --field api_name=value [...] # one record kizen records update [--spec-file f.csv|f.json | < stdin] # bulk update (each row needs an 'id') kizen records upsert --field api_name=value [...] # create-or-update by lookup_value @@ -211,6 +217,7 @@ kizen categories delete kizen filter-groups delete kizen quick-filters delete kizen columns delete +kizen mcp-servers delete # hard delete; Kizen-managed servers are refused # activities (activity types / loggable definitions) — logging/scheduling stays in the UI kizen activities create --name X [--api-name Y] [--association-mode ...] [--object ...] [--editable] [--spec-file f] kizen activities update [--name] [--description] [--object ...] \ diff --git a/src/kizen_builder/docs/reference.md b/src/kizen_builder/docs/reference.md index 714a3b5..94ead92 100644 --- a/src/kizen_builder/docs/reference.md +++ b/src/kizen_builder/docs/reference.md @@ -21,6 +21,7 @@ the wire formats, endpoints, and confirmed quirks together. | Activity types, logged & scheduled activities | `activity` | | Forms & surveys (incl. `form_ui` pages) | `form` | | Dashboards & homepages | `dashboard` | +| MCP servers (the tools AI steps and MCP clients use) | `mcp-server` | | Record layouts | `layout` | | Filter groups, quick filters, column templates | `saved-views` | | Roles & permission groups | `permission-group` | diff --git a/src/kizen_builder/docs/specs/README.md b/src/kizen_builder/docs/specs/README.md index e7b6537..2f3bef8 100644 --- a/src/kizen_builder/docs/specs/README.md +++ b/src/kizen_builder/docs/specs/README.md @@ -23,6 +23,7 @@ field Kizen rejected. | `activities create`, `activities fields create` | [activity.md](activity.md) | `ActivityDef` / `ActivityFieldDef` | | `forms create\|update\|set-ui`, `forms fields create` (and `surveys …`) | [form.md](form.md) | `FormDef` / `FormFieldDef` / `form_ui` | | `dashboards create\|update` | [dashboard.md](dashboard.md) | `DashboardDef` + dashlets | +| `mcp-servers create\|update` | [mcp-server.md](mcp-server.md) | `MCPServerDef` | | `layouts update` | [layout.md](layout.md) | `LayoutDef` (record layout) | | `filter-groups`, `quick-filters`, `columns` create\|update | [saved-views.md](saved-views.md) | filter DSL / `configuration_json` | | `permissions group-create`, `roles …` | [permission-group.md](permission-group.md) | shaping-op list | diff --git a/src/kizen_builder/docs/specs/mcp-server.md b/src/kizen_builder/docs/specs/mcp-server.md new file mode 100644 index 0000000..f65beb7 --- /dev/null +++ b/src/kizen_builder/docs/specs/mcp-server.md @@ -0,0 +1,190 @@ +# Spec shape: `MCPServerDef` — MCP servers + +**Consumed by:** `kizen mcp-servers create|update --spec-file ` +(also stdin). `delete ` takes no spec. + +An MCP server is a named set of Kizen tools. AI steps and outside MCP clients +call those tools through the server. A server is referenced by UUID or +`api_name`; display names aren't unique, so the CLI never matches them. + +Read first: `kizen mcp-servers list`, `get ` (add `--raw` for the wire +JSON), and `tools` for the valid `tool_names`. + +--- + +## Quick example + +A server with two tools, shared with one service account and nobody else: + +```json +{ + "name": "Support agent tools", + "api_name": "support_agent_tools", + "description": "What the support agent may read", + "tool_names": ["listCustomObjects", "searchCatalog"], + "is_enabled": true, + "sharing_settings": { + "private": false, + "all_team_members": 0, + "roles": {"view": [], "edit": [], "admin": []}, + "team_members": {"view": [""], "edit": [], "admin": []} + } +} +``` + +## `MCPServerDef` fields + +Every key is optional, so an update spec carries only what changes. `create` +needs `name` and `api_name`. Any other key is a spec error, and so is `null` +for any key but `description`. + +| Key | Type | Notes | +|---|---|---| +| `name` | string | Not unique. | +| `api_name` | string | `^[a-z_][a-z0-9_]*$`, 2–64 characters, unique. Fixed once created: `update` refuses a different one. | +| `description` | string or null | `null` clears it. | +| `tool_names` | list of strings | Names from `kizen mcp-servers tools`. Checked at plan time; duplicates are dropped (first one kept). `[]` is a server with no tools. | +| `is_enabled` | bool | | +| `is_default_for_business` | bool | One default per business. See below. | +| `sharing_settings` | object | The whole block: `private` (bool), `all_team_members` (0–3), `roles` and `team_members`, each `{view, edit, admin}` lists of UUIDs. Read-shape `{id, display_name}` entries are accepted too; an entry without `id` is a spec error. | + +**Service accounts are shared as team members.** Put the account's id in +`team_members`. There is no `service_accounts` bucket; the spec rejects one. + +**`get --raw` output is a valid spec.** These read-only keys are dropped +before validation, and the plan preview lists them under +`ignored_read_only_keys`: `id`, `tools`, `tool_count`, `is_system_managed`, +`endpoint_url`, `created`, `updated`, `owner`, `employee_access`, +`sharing_stats`. + +## What the plan does + +- **create** refuses an `api_name` that exists. With no `sharing_settings`, + it sends "every team member can view": `{"private": false, + "all_team_members": 1, "roles": {…empty…}, "team_members": {…empty…}}`. +- **update** compares only the keys the spec sets. `tool_names` compares as a + set, so reordering is a skip. `sharing_settings` compares after both sides + are normalised to bare UUIDs, so re-sending `get --raw` output unchanged is + a skip. A changed sharing block is sent whole; the preview lists the + changed scalars and each grant added (`+roles.view:`) or removed + (`-team_members.view:`). +- **delete** previews the name, api_name, id and tool count, plus + `is_business_default: true` when the server is the default. +- **`is_default_for_business: true`** previews `business_default`, naming the + server that stops being the default and how to restore it. +- **`is_default_for_business: false`** on the current default is refused: set + another server as the default instead. A business always has one. +- **Kizen-managed servers** (`is_system_managed`, e.g. `readonly`) can't be + updated or deleted from the CLI, with one exception: an update spec of + exactly `{"is_default_for_business": true}` makes one the default again: + + ```bash + echo '{"is_default_for_business": true}' | kizen mcp-servers update readonly --yes + ``` + +## Permission + +`mcp_servers_section.mcp_server_configuration` takes `{view, edit, remove}` +(labels None / View/Use Shared / Create/Edit All / Delete/All; default +`view`). Set it on a permission group with a `section` op (see +`kizen docs show permission-group`): + +```json +[{ "type": "section", "section_key": "mcp_servers_section", + "value": { "enabled": true, + "mcp_server_configuration": { "view": true, "edit": true, "remove": false } } }] +``` + +The CLI doesn't call a server's `endpoint_url` itself. That's the JSON-RPC +endpoint MCP clients use at run time; `get` prints it so you can wire one up. + +--- + +# Wire format & API behavior + +Confirmed live 2026-10-27 on a disposable test business. + +## Endpoints (no trailing slashes) + +``` +GET /api/mcp-servers # list (DRF page); rows carry tool_count, not tools +POST /api/mcp-servers # create → 201, the read shape +GET /api/mcp-servers/{id} # one server; tools[] adds group and group_label +PATCH /api/mcp-servers/{id} # partial update +DELETE /api/mcp-servers/{id} # hard delete → 204 +GET /api/mcp-servers/tools # {groups: [{key, label, tools: [{name, display_name, description, annotations}]}]} +GET /api/mcp-servers/{id}/events-history # DRF page, newest first +GET /api/mcp-servers/step-options # what a Call LLM step's mcp_server carries +POST /api/mcp/{api_name} # JSON-RPC, for MCP clients (endpoint_url) +``` + +## Create, update, delete + +- `sharing_settings` is required on create (400 without it). A partial block + (`{"all_team_members": 2}`) 400s with `roles` / `team_members` required. + `all_team_members: 5` 400s. An empty `roles.admin` is accepted (dashboards + need the Admin role there; MCP servers don't). +- Without `api_name`, Kizen derives one from `name`. A hyphen 400s: + `Must start with a letter or underscore and contain only lowercase letters, + numbers, or underscores.` A taken api_name, including `readonly`, 400s + `MCP Server 'api name' must be unique.` A name can repeat. +- One unknown tool 400s the whole request: `Unknown MCP tools: `, on + create and on PATCH. +- PATCH `tool_names` replaces the list in the order given. `[]` is valid. +- PATCH `api_name` 400s: `This field cannot be changed after creation.` +- PATCH ignores read-only keys (`is_system_managed`, `endpoint_url`, `tools`) + without an error. +- A `service_accounts` key inside `sharing_settings` returns **200 and is + dropped**. A service account's id in `team_members.view` is saved and reads + back as `{id, display_name: ""}`. +- DELETE is permanent. GET, events-history and a second DELETE then 404 + `No MCPServerConfig matches the given query.` The api_name is free again. + +## Reads + +- Writes take bare UUIDs in sharing; reads return `{id, display_name}`. +- `owner` is the creator (`{id, display_name}`; `{}` on the system server). + `employee_access` is `Owner` for the creator, `View` on the system server. + `sharing_stats` counts levels. +- `endpoint_url` is `https:///api/mcp/`. +- The tool catalogue on a business with knowledge and custom objects is + `custom_knowledge` (`listCatalogs`, `searchCatalog`, `getDocument`) and + `custom_objects` (`listCustomObjects`, `listObjectFields`, + `listObjectFieldOptions`, `getCustomObject`). All seven are + `readOnlyHint: true`. The system `readonly` server serves all seven. +- `step-options` is a bare list of `{id, name, api_name, is_enabled, deleted, + tool_count}`. It lists disabled servers and leaves out deleted ones. + +## Events history + +- **Written asynchronously.** Read right after a change, it can be empty; a + second later the events are there. +- Event keys: `id, group_id, business_id, type_name, type_display_name, + has_relationship, instance, initiator, data, comments, created`. Types: + `mcp_server_created`, `mcp_server_updated`. +- `data` is `{api_name, mcp_server_name, old_value, new_value}`, each value + carrying `name, is_enabled, tool_names, description, + is_default_for_business`. A create has no `old_value`. A sharing change logs + an update whose old and new values are equal. `history` shows `—` for both. +- `instance.custom_object_id` / `entity_record_name: "Contact"` are noise. +- The system server's history is empty. + +## The business default + +**Kizen keeps one default MCP server per business.** Creating a server with +`is_default_for_business: true` made it the default and cleared the flag on +the previous default (the system `readonly` server), without changing that +server's `updated`. PATCHing `readonly` back to `is_default_for_business: true` +then cleared it on the other server; `kizen mcp-servers update readonly` +with that one key does the same. Setting the flag to `false` on the current +default was not tried, so the CLI refuses it. + +## Permission key + +`GET /api/permissions/meta-data` has a section `mcp_servers_section` ("MCP +Servers") with one permission, `mcp_server_configuration` ("MCP Server +Configuration"), `allowed_access` `view/edit/remove`. An Admin group carries +`"mcp_servers_section": {"enabled": true, "mcp_server_configuration": +{"view": true, "edit": true, "remove": true}}`. `kizen permissions group` +renders it as "MCP Servers / MCP Server Configuration", and a `section` op +dry-runs on it, with no MCP-specific code. diff --git a/src/kizen_builder/models/spec/__init__.py b/src/kizen_builder/models/spec/__init__.py index 3762f40..c321b2a 100644 --- a/src/kizen_builder/models/spec/__init__.py +++ b/src/kizen_builder/models/spec/__init__.py @@ -131,6 +131,12 @@ ColumnTemplateDef, LayoutDef, ) +from kizen_builder.models.spec.mcp_servers import ( + MCPServerApiName, + SharingBuckets, + MCPServerSharing, + MCPServerDef, +) from kizen_builder.models.spec.email_templates import ( ColumnPreset, COLUMN_FRACTIONS, @@ -310,6 +316,10 @@ def build_payload_meta(model: BaseModel) -> dict[str, Any]: "QuickFilterDef", "ColumnTemplateDef", "LayoutDef", + "MCPServerApiName", + "SharingBuckets", + "MCPServerSharing", + "MCPServerDef", "ColumnPreset", "COLUMN_FRACTIONS", "TextBlockDef", diff --git a/src/kizen_builder/models/spec/mcp_servers.py b/src/kizen_builder/models/spec/mcp_servers.py new file mode 100644 index 0000000..335fb9b --- /dev/null +++ b/src/kizen_builder/models/spec/mcp_servers.py @@ -0,0 +1,106 @@ +"""MCP server spec: the tools a server serves and who can use it. + +Every key is optional so an update spec can carry only what changes. A create +needs ``name`` and ``api_name``; the planner checks that. ``kizen mcp-servers +get --raw`` output is a valid spec once the planner drops its read-only keys. +""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator +from pydantic_core import PydanticCustomError + +MCPServerApiName = Annotated[ + str, + StringConstraints(pattern=r"^[a-z_][a-z0-9_]*$", min_length=2, max_length=64), +] +"""Kizen's own rule: lowercase letters, digits and underscores, not starting +with a digit. Unique per business and fixed once the server exists.""" + +_SHARING_KEYS = ("private", "all_team_members", "roles", "team_members") +_SHARING_KEYS_MSG = ( + "sharing_settings takes private, all_team_members, roles and team_members. " + "Service accounts are shared as team members: put the account's id in " + "team_members." +) + + +class GrantRef(BaseModel): + """The read shape of a grant, `{id, display_name}`, as `get --raw` prints it. + + `id` is required: a grant without one would be dropped from the PATCH, + revoking it without a word. + """ + + model_config = ConfigDict(extra="allow") + + id: str = Field(min_length=1) + + +# A grant is a bare UUID (the write shape) or the read shape. +Grant = str | GrantRef + + +class SharingBuckets(BaseModel): + """Grants per access level. A level left out grants nobody.""" + + model_config = ConfigDict(extra="forbid") + + view: list[Grant] = Field(default_factory=list) + edit: list[Grant] = Field(default_factory=list) + admin: list[Grant] = Field(default_factory=list) + + +class MCPServerSharing(BaseModel): + """The whole sharing block. Kizen rejects a partial one, so all four keys + are required.""" + + model_config = ConfigDict(extra="forbid") + + private: bool + all_team_members: Literal[0, 1, 2, 3] + roles: SharingBuckets + team_members: SharingBuckets + + @model_validator(mode="before") + @classmethod + def _known_keys_only(cls, data: Any) -> Any: + unknown = ( + sorted(set(data) - set(_SHARING_KEYS)) if isinstance(data, dict) else [] + ) + if unknown: + raise PydanticCustomError( + "sharing_settings_keys", + "unknown key(s) {keys}. " + _SHARING_KEYS_MSG, + {"keys": ", ".join(unknown)}, + ) + return data + + +class MCPServerDef(BaseModel): + """One MCP server. ``tool_names`` come from ``kizen mcp-servers tools``.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1) + api_name: MCPServerApiName | None = None + description: str | None = None + tool_names: list[str] | None = None + is_enabled: bool | None = None + is_default_for_business: bool | None = None + sharing_settings: MCPServerSharing | None = None + + @model_validator(mode="before") + @classmethod + def _null_only_clears_description(cls, data: Any) -> Any: + if isinstance(data, dict): + for key, value in data.items(): + if value is None and key != "description" and key in cls.model_fields: + raise PydanticCustomError( + "null_value", + "{key} can't be null; leave it out to keep the current value.", + {"key": key}, + ) + return data diff --git a/src/kizen_builder/tools/mcp_servers.py b/src/kizen_builder/tools/mcp_servers.py new file mode 100644 index 0000000..610e479 --- /dev/null +++ b/src/kizen_builder/tools/mcp_servers.py @@ -0,0 +1,110 @@ +"""Read tools for MCP servers: resolve, list, detail, tool catalogue, history. + +Servers are referenced by UUID or ``api_name``. Display names are not unique in +Kizen, so they are never matched. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api import mcp_servers as mcp_api +from kizen_builder.api.client import KizenAPIError, KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.utils import is_uuid + + +def _client() -> KizenClient: + return KizenClient(load_env_config()) + + +def _resolve(client: KizenClient, ref: str) -> dict[str, Any]: + if is_uuid(ref): + try: + return mcp_api.get_mcp_server(client, ref) + except KizenAPIError as e: + if e.status_code != 404: + raise + servers = mcp_api.list_mcp_servers(client) + else: + servers = mcp_api.list_mcp_servers(client) + match = next((s for s in servers if s.get("api_name") == ref), None) + if match is not None: + return mcp_api.get_mcp_server(client, match["id"]) + names = ", ".join(sorted(s.get("api_name") or "" for s in servers)) or "none" + raise LookupError(f"no MCP server '{ref}'; available: {names}") + + +def resolve_mcp_server(ref: str) -> dict[str, Any]: + """The server's full GET, from its UUID or api_name.""" + with _client() as c: + return _resolve(c, ref) + + +def list_mcp_servers() -> list[dict[str, Any]]: + """Every server in the business, as the list endpoint returns them.""" + with _client() as c: + return mcp_api.list_mcp_servers(c) + + +def get_mcp_server_detail(ref: str) -> dict[str, Any]: + """One server's summary, its tools as ``{group, name}`` rows, and ``raw``.""" + raw = resolve_mcp_server(ref) + owner = raw.get("owner") or {} + return { + "name": raw.get("name"), + "api_name": raw.get("api_name"), + "description": raw.get("description"), + "is_enabled": raw.get("is_enabled"), + "is_default_for_business": raw.get("is_default_for_business"), + "is_system_managed": raw.get("is_system_managed"), + "endpoint_url": raw.get("endpoint_url"), + "owner": owner.get("display_name"), + "id": raw.get("id"), + "tools": [ + {"group": t.get("group"), "name": t.get("name")} + for t in raw.get("tools") or [] + ], + "raw": raw, + } + + +def tool_catalog() -> dict[str, Any]: + """The business's tool catalogue: ``{groups: [{key, label, tools}]}``.""" + with _client() as c: + return mcp_api.get_tool_catalog(c) + + +def catalog_tool_names(catalog: dict[str, Any]) -> set[str]: + return { + t["name"] + for g in catalog.get("groups") or [] + for t in g.get("tools") or [] + if t.get("name") + } + + +def list_history(ref: str) -> list[dict[str, Any]]: + """The server's change events, as Kizen returns them.""" + with _client() as c: + server = _resolve(c, ref) + return mcp_api.list_events_history(c, server["id"]) + + +def changed_keys(event: dict[str, Any]) -> list[str]: + """Keys of ``data.new_value`` whose value differs from ``data.old_value``. + + A create event has no ``old_value``, so it reports nothing changed. + """ + data = event.get("data") or {} + old, new = data.get("old_value"), data.get("new_value") + if not isinstance(old, dict) or not isinstance(new, dict): + return [] + return [k for k, v in new.items() if old.get(k) != v] + + +def initiator_label(event: dict[str, Any]) -> str: + who = event.get("initiator") or {} + if who.get("email"): + return str(who["email"]) + return " ".join(p for p in (who.get("first_name"), who.get("last_name")) if p) diff --git a/src/kizen_builder/tools/planners/mcp_servers.py b/src/kizen_builder/tools/planners/mcp_servers.py new file mode 100644 index 0000000..a05321c --- /dev/null +++ b/src/kizen_builder/tools/planners/mcp_servers.py @@ -0,0 +1,299 @@ +"""Plan creation, update and deletion of MCP servers. One op per server. + +A spec may be ``kizen mcp-servers get --raw`` output: its read-only keys are +dropped before validation and listed in the preview. Tool names are checked +against the live catalogue, because Kizen rejects the whole request for one +unknown tool. Kizen-managed servers (``is_system_managed``) are refused. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.config import load_env_config +from kizen_builder.models.spec import MCPServerDef, MCPServerSharing +from kizen_builder.tools.dashboards import normalize_sharing_settings +from kizen_builder.tools.mcp_servers import ( + catalog_tool_names, + list_mcp_servers, + resolve_mcp_server, + tool_catalog, +) +from kizen_builder.tools.plans import Plan, PlanError, PlanOperation, fetch_existing + +READ_ONLY_KEYS = ( + "id", + "tools", + "tool_count", + "is_system_managed", + "endpoint_url", + "created", + "updated", + "owner", + "employee_access", + "sharing_stats", +) + +DEFAULT_SHARING: dict[str, Any] = { + "private": False, + "all_team_members": 1, + "roles": {"view": [], "edit": [], "admin": []}, + "team_members": {"view": [], "edit": [], "admin": []}, +} + +_SCALAR_KEYS = ("name", "description", "is_enabled", "is_default_for_business") + + +def _parse(spec: dict[str, Any] | MCPServerDef) -> tuple[MCPServerDef, list[str]]: + if isinstance(spec, MCPServerDef): + return spec, [] + if not isinstance(spec, dict): + raise PlanError("an MCP server spec is a JSON object.") + dropped = [k for k in READ_ONLY_KEYS if k in spec] + kept = {k: v for k, v in spec.items() if k not in READ_ONLY_KEYS} + return MCPServerDef.model_validate(kept), dropped + + +def _check_tools(names: list[str]) -> list[str]: + """Drop duplicates (first seen wins) and refuse names the catalogue lacks.""" + deduped = list(dict.fromkeys(names)) + valid = catalog_tool_names(tool_catalog()) + unknown = [n for n in deduped if n not in valid] + if unknown: + raise PlanError( + f"unknown MCP tool(s): {', '.join(unknown)}. " + f"Valid tools: {', '.join(sorted(valid))}. " + "List them with `kizen mcp-servers tools`." + ) + return deduped + + +def _sharing(sharing: MCPServerSharing) -> dict[str, Any]: + return normalize_sharing_settings(sharing.model_dump()) + + +def _comparable(sharing: dict[str, Any]) -> dict[str, Any]: + """A normalised sharing block with each grant list as a set.""" + norm = normalize_sharing_settings(sharing) + for bucket in ("roles", "team_members"): + if isinstance(norm.get(bucket), dict): + norm[bucket] = {lvl: set(ids) for lvl, ids in norm[bucket].items()} + return norm + + +def _sharing_diff(old: dict[str, Any], new: dict[str, Any]) -> str: + """Which scalars changed and which grants are added (+) or removed (-).""" + names = { + g["id"]: g.get("display_name") or g["id"] + for block in (old, new) + for bucket in ("roles", "team_members") + for grants in (block.get(bucket) or {}).values() + for g in grants + if isinstance(g, dict) and g.get("id") + } + a, b = _comparable(old), _comparable(new) + parts = [ + f"{k} {a.get(k)} → {b.get(k)}" + for k in ("private", "all_team_members") + if a.get(k) != b.get(k) + ] + for bucket in ("roles", "team_members"): + for lvl in ("view", "edit", "admin"): + was = (a.get(bucket) or {}).get(lvl, set()) + now = (b.get(bucket) or {}).get(lvl, set()) + parts += [f"+{bucket}.{lvl}:{names.get(i, i)}" for i in sorted(now - was)] + parts += [f"-{bucket}.{lvl}:{names.get(i, i)}" for i in sorted(was - now)] + return ", ".join(parts) + + +def _default_takeover(servers: list[dict[str, Any]], api_name: str) -> str | None: + """Preview text for a server becoming the business default. + + Kizen keeps one default per business: setting the flag clears it on the + current default (confirmed live 2026-10-27). + """ + current = next((s for s in servers if s.get("is_default_for_business")), None) + if current is None or current.get("api_name") == api_name: + return None + old = current.get("api_name") + return ( + f"replaces '{old}', which stops being the default. To restore it, run " + f"`kizen mcp-servers update {old}` with " + '{"is_default_for_business": true}' + ) + + +def _system_message(live: dict[str, Any]) -> str: + return ( + f"'{live.get('api_name')}' is managed by Kizen and can't be changed or " + "deleted from the CLI. The one exception: an update that sets only " + '{"is_default_for_business": true}.' + ) + + +def _check_system_update(live: dict[str, Any], d: MCPServerDef) -> None: + """A Kizen-managed server takes only the default flag set to true.""" + if live.get("is_system_managed") and ( + d.model_fields_set != {"is_default_for_business"} + or d.is_default_for_business is not True + ): + raise PlanError(_system_message(live)) + + +def plan_create_mcp_server(spec: dict[str, Any] | MCPServerDef) -> Plan: + """Plan one new MCP server.""" + d, dropped = _parse(spec) + env = load_env_config().name + missing = [k for k in ("name", "api_name") if getattr(d, k) is None] + if missing: + raise PlanError(f"an MCP server create needs {' and '.join(missing)}.") + assert d.api_name is not None + + servers = list_mcp_servers() + existing = next((s for s in servers if s.get("api_name") == d.api_name), None) + if existing is not None: + raise PlanError( + f"MCP server '{d.api_name}' already exists (id {existing['id']}). " + "Use `kizen mcp-servers update` instead." + ) + + payload: dict[str, Any] = {"name": d.name, "api_name": d.api_name} + for key in ("description", "is_enabled", "is_default_for_business"): + if key in d.model_fields_set: + payload[key] = getattr(d, key) + if d.tool_names is not None: + payload["tool_names"] = _check_tools(d.tool_names) + payload["sharing_settings"] = ( + _sharing(d.sharing_settings) + if d.sharing_settings is not None + else DEFAULT_SHARING + ) + + preview: dict[str, Any] = { + "env": env, + "api_name": d.api_name, + "name": d.name, + "tools": len(payload.get("tool_names", [])), + } + if d.is_default_for_business: + takeover = _default_takeover(servers, d.api_name) + if takeover: + preview["business_default"] = takeover + if dropped: + preview["ignored_read_only_keys"] = dropped + op = PlanOperation( + action="create", + kind="mcp_server", + key=d.api_name, + preview=preview, + payload=payload, + ) + return Plan.build( + env=env, summary=f"Create MCP server '{d.api_name}'", operations=[op] + ) + + +def plan_update_mcp_server(server: str, spec: dict[str, Any] | MCPServerDef) -> Plan: + """Plan a PATCH of the keys the spec sets that differ from live. + + ``server`` is a UUID or api_name. Tools compare as a set and sharing + compares after normalising both sides, so re-sending ``get --raw`` output + unchanged plans a skip. + """ + d, dropped = _parse(spec) + env = load_env_config().name + live = fetch_existing("MCP server", server, lambda: resolve_mcp_server(server)) + _check_system_update(live, d) + api_name = live.get("api_name") or live["id"] + if d.is_default_for_business is False and live.get("is_default_for_business"): + raise PlanError( + f"'{api_name}' is the business default and Kizen keeps one. To change " + "it, set another server as the default instead " + '({"is_default_for_business": true} on that server).' + ) + if d.api_name is not None and d.api_name != live.get("api_name"): + raise PlanError( + "api_name can't change after creation; create a new server instead." + ) + + payload: dict[str, Any] = {} + diff: dict[str, str] = {} + for key in _SCALAR_KEYS: + if key in d.model_fields_set and getattr(d, key) != live.get(key): + payload[key] = getattr(d, key) + diff[key] = f"{live.get(key)} → {getattr(d, key)}" + if d.tool_names is not None: + wanted = _check_tools(d.tool_names) + current = live.get("tool_names") or [] + if set(wanted) != set(current): + payload["tool_names"] = wanted + added = [t for t in wanted if t not in current] + removed = [t for t in current if t not in wanted] + diff["tool_names"] = ", ".join( + [f"+{t}" for t in added] + [f"-{t}" for t in removed] + ) + if d.sharing_settings is not None: + new = _sharing(d.sharing_settings) + if _comparable(new) != _comparable(live.get("sharing_settings") or {}): + payload["sharing_settings"] = new + diff["sharing_settings"] = ( + _sharing_diff( + live.get("sharing_settings") or {}, + d.sharing_settings.model_dump(), + ) + + " (the whole block is sent)" + ) + + preview: dict[str, Any] = { + "env": env, + "mcp_server": api_name, + "diff": diff or "no changes", + } + if payload.get("is_default_for_business"): + takeover = _default_takeover(list_mcp_servers(), api_name) + if takeover: + preview["business_default"] = takeover + if dropped: + preview["ignored_read_only_keys"] = dropped + op = PlanOperation( + action="update" if payload else "skip", + kind="mcp_server", + key=api_name, + preview=preview, + payload=payload, + existing_uuid=live["id"], + ) + summary = ( + f"Update MCP server '{api_name}' ({len(diff)} change(s))" + if payload + else f"No changes to MCP server '{api_name}'" + ) + return Plan.build(env=env, summary=summary, operations=[op]) + + +def plan_delete_mcp_server(server: str) -> Plan: + """Plan a hard delete. ``server`` is a UUID or api_name.""" + env = load_env_config().name + live = fetch_existing("MCP server", server, lambda: resolve_mcp_server(server)) + if live.get("is_system_managed"): + raise PlanError(_system_message(live)) + api_name = live.get("api_name") or live["id"] + preview: dict[str, Any] = { + "env": env, + "name": live.get("name"), + "api_name": live.get("api_name"), + "id": live["id"], + "tool_count": len(live.get("tool_names") or []), + } + if live.get("is_default_for_business"): + preview["is_business_default"] = True + op = PlanOperation( + action="delete", + kind="mcp_server", + key=api_name, + preview=preview, + existing_uuid=live["id"], + ) + return Plan.build( + env=env, summary=f"Delete MCP server '{api_name}'", operations=[op] + ) diff --git a/src/kizen_builder/tools/plans.py b/src/kizen_builder/tools/plans.py index dbddba2..aae726e 100644 --- a/src/kizen_builder/tools/plans.py +++ b/src/kizen_builder/tools/plans.py @@ -34,6 +34,7 @@ from kizen_builder.api import files as files_api from kizen_builder.api import forms as forms_api from kizen_builder.api import layouts as layout_api +from kizen_builder.api import mcp_servers as mcp_api from kizen_builder.api import permissions as perm_api from kizen_builder.api import pipelines as pipelines_api from kizen_builder.api import records as records_api @@ -84,6 +85,7 @@ def copy_name_preview(source_name: str | None, name: str | None) -> str: "automation_folder", "dashboard", "dashlet", + "mcp_server", "layout", "record", "activity", @@ -791,6 +793,20 @@ def patch_and_read(copy_id: str) -> dict[str, Any]: client, op.parent_object_uuid, op.existing_uuid, op.payload ) + if op.kind == "mcp_server": + if op.action == "create": + return mcp_api.create_mcp_server(client, op.payload) + if op.existing_uuid is None: + raise PlanError( + f"{op.action} mcp_server op '{op.key}' has no existing_uuid — planning bug" + ) + if op.action == "update": + return mcp_api.update_mcp_server(client, op.existing_uuid, op.payload) + if op.action == "delete": + mcp_api.delete_mcp_server(client, op.existing_uuid) + return {} + raise PlanError(f"unsupported mcp_server action '{op.action}'") + if op.kind == "layout": # Layouts are PUT-replace only (Kizen auto-creates the Standard View). # parent_object_uuid = custom object id, existing_uuid = layout id. diff --git a/tests/drift/contracts.py b/tests/drift/contracts.py index 45a02d4..046f454 100644 --- a/tests/drift/contracts.py +++ b/tests/drift/contracts.py @@ -152,6 +152,10 @@ def key(self) -> str: "post", "/api/custom-objects/{object_identifier}/fields/{field_identifier}/options/{id}/replace", ), + # --- mcp_servers ------------------------------------------------------- + Contract("mcp_servers", "create MCP server", "post", "/api/mcp-servers"), + Contract("mcp_servers", "update MCP server", "patch", "/api/mcp-servers/{id}"), + Contract("mcp_servers", "delete MCP server", "delete", "/api/mcp-servers/{id}"), # --- permissions ------------------------------------------------------- Contract("permissions", "create permission group", "post", "/api/permission-group"), Contract( diff --git a/tests/drift/schema_snapshot.json b/tests/drift/schema_snapshot.json index df782a0..bf434d8 100644 --- a/tests/drift/schema_snapshot.json +++ b/tests/drift/schema_snapshot.json @@ -2,7 +2,7 @@ "_meta": { "base_url": "https://app.go.kizen.com", "captured": "2026-10-14", - "contracts_tracked": 69, + "contracts_tracked": 72, "how_to_refresh": "KIZEN_DRIFT_PROFILE= KIZEN_DRIFT_UPDATE_SNAPSHOT=1 uv run pytest -m drift", "info": { "title": "Kizen API", @@ -50,6 +50,19 @@ "schema_path": "/api/custom-objects/{object_identifier}/fields/{id}", "surface": "fields" }, + "DELETE /api/mcp-servers/{id}": { + "label": "delete MCP server", + "present": true, + "query_params": [ + "ordering" + ], + "request_schema": null, + "responses": [ + "204" + ], + "schema_path": "/api/mcp-servers/{id}", + "surface": "mcp_servers" + }, "DELETE /api/permission-group/{id}": { "label": "delete permission group", "present": true, @@ -178,6 +191,34 @@ "schema_path": "/api/custom-objects/{object_identifier}/fields/{id}", "surface": "fields" }, + "PATCH /api/mcp-servers/{id}": { + "label": "update MCP server", + "present": true, + "query_params": [ + "ordering" + ], + "request_body": { + "present": true, + "properties": { + "api_name": "string", + "description": "string?", + "is_default_for_business": "boolean", + "is_enabled": "boolean", + "name": "string", + "sharing_settings": "EntityPermissionRequest", + "tool_names": "array[string]" + }, + "required": [], + "type": "object" + }, + "request_required": false, + "request_schema": "PatchedMCPServerConfigRequest", + "responses": [ + "200" + ], + "schema_path": "/api/mcp-servers/{id}", + "surface": "mcp_servers" + }, "PATCH /api/permission-group/{id}": { "label": "update permission group sections", "present": true, @@ -611,6 +652,37 @@ "schema_path": "/api/custom-objects/{object_pk}/quick-filters", "surface": "saved_views" }, + "POST /api/mcp-servers": { + "label": "create MCP server", + "present": true, + "query_params": [ + "ordering" + ], + "request_body": { + "present": true, + "properties": { + "api_name": "string", + "description": "string?", + "is_default_for_business": "boolean", + "is_enabled": "boolean", + "name": "string", + "sharing_settings": "EntityPermissionRequest", + "tool_names": "array[string]" + }, + "required": [ + "name", + "sharing_settings" + ], + "type": "object" + }, + "request_required": true, + "request_schema": "MCPServerConfigRequest", + "responses": [ + "201" + ], + "schema_path": "/api/mcp-servers", + "surface": "mcp_servers" + }, "POST /api/permission-group": { "label": "create permission group", "present": true, diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 803c348..c2e7a27 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -43,6 +43,13 @@ automation steps). `role_detail.json` are still hand-authored and round out the read/resolve surface. UUIDs and the group name are replaced with `conftest.py`'s `00000000-0000-4000-8000-…` convention and `Sample Group`. +- `mcp_servers/` — live captures from 2026-10-27, sanitized (server, + person and service-account names, emails, host and business id replaced). + `list.json` is a `GET /api/mcp-servers` page: the system `readonly` server + plus `support_tools`, whose `detail.json` is shared with one service account + as a team member. `detail_readonly.json`, `tools.json`, `step_options.json` + and `events_history.json` (newest first; three updates and the create) are + the matching raw responses. ## Coverage diff --git a/tests/fixtures/mcp_servers/detail.json b/tests/fixtures/mcp_servers/detail.json new file mode 100644 index 0000000..a208b52 --- /dev/null +++ b/tests/fixtures/mcp_servers/detail.json @@ -0,0 +1,88 @@ +{ + "id": "e38f1367-267b-4344-823f-5126284b004d", + "name": "Support Tools", + "api_name": "support_tools", + "description": "Tools the support agent uses", + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "searchCatalog" + ], + "tools": [ + { + "name": "listCustomObjects", + "display_name": "listCustomObjects", + "description": "List the objects configured in this business - Kizen's built-in objects (such as Contacts) plus any custom Standard or Pipeline objects. Use this to discover what kinds of records exist before querying them. Returns each object's api_name, display names, type, id, creation date, and record count. Supports search, sorting, and pagination. Only objects you have permission to see are returned. To fetch one object you already have an api_name or id for, use getCustomObject instead.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFields", + "display_name": "listObjectFields", + "description": "List the fields defined on one object, which is how you discover its schema before reading or writing records. Takes the object's api_name or id from listCustomObjects. Each row leads with `name` - the api_name to use in API payloads - alongside the display name, field type, category name, and the flags that govern writes (is_required, is_read_only, allows_nulls, allows_empty). Relation fields show their target as ' -> ', e.g. 'many_to_one -> receipts', with '(inverse)' appended on the mirror side of the relation. Dropdown-style fields list their valid `options`. Supports search on the display name or api_name, sorting, and filtering by category name. Not paginated - every field you have permission to see is returned. For a dropdown-style field, use listObjectFieldOptions (passing this row's `name`) to list its valid options.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "searchCatalog", + "display_name": "searchCatalog", + "description": "Semantic search across knowledge catalogs. Returns the most relevant content chunks with their source description, source_id, url, and a relevance score. Optionally narrow the search to specific catalogs by api_name or UUID.\n\n{catalog_list}", + "group": "custom_knowledge", + "group_label": "Custom Knowledge", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ], + "is_system_managed": false, + "endpoint_url": "https://kizen.test/api/mcp/support_tools", + "is_enabled": true, + "is_default_for_business": false, + "created": "2026-10-27T08:18:35.333889-05:00", + "updated": "2026-10-27T08:18:36.775948-05:00", + "owner": { + "id": "00000000-0000-4000-8000-0000000005e7", + "display_name": "Sample User (user@example.com)" + }, + "employee_access": "Owner", + "sharing_settings": { + "private": false, + "all_team_members": 0, + "roles": { + "view": [], + "edit": [], + "admin": [] + }, + "team_members": { + "view": [ + { + "id": "00000000-0000-4000-8000-0000000005a1", + "display_name": "Sample Service Account" + } + ], + "edit": [], + "admin": [] + } + }, + "sharing_stats": { + "None": 2, + "View": 0, + "Edit": 0, + "Admin": 0 + } +} diff --git a/tests/fixtures/mcp_servers/detail_readonly.json b/tests/fixtures/mcp_servers/detail_readonly.json new file mode 100644 index 0000000..5d15c6a --- /dev/null +++ b/tests/fixtures/mcp_servers/detail_readonly.json @@ -0,0 +1,136 @@ +{ + "id": "bde29351-2a8b-4479-a0d3-c65d8292782e", + "name": "Read Only", + "api_name": "readonly", + "description": null, + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "listObjectFieldOptions", + "getCustomObject", + "listCatalogs", + "searchCatalog", + "getDocument" + ], + "tools": [ + { + "name": "listCustomObjects", + "display_name": "listCustomObjects", + "description": "List the objects configured in this business - Kizen's built-in objects (such as Contacts) plus any custom Standard or Pipeline objects. Use this to discover what kinds of records exist before querying them. Returns each object's api_name, display names, type, id, creation date, and record count. Supports search, sorting, and pagination. Only objects you have permission to see are returned. To fetch one object you already have an api_name or id for, use getCustomObject instead.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFields", + "display_name": "listObjectFields", + "description": "List the fields defined on one object, which is how you discover its schema before reading or writing records. Takes the object's api_name or id from listCustomObjects. Each row leads with `name` - the api_name to use in API payloads - alongside the display name, field type, category name, and the flags that govern writes (is_required, is_read_only, allows_nulls, allows_empty). Relation fields show their target as ' -> ', e.g. 'many_to_one -> receipts', with '(inverse)' appended on the mirror side of the relation. Dropdown-style fields list their valid `options`. Supports search on the display name or api_name, sorting, and filtering by category name. Not paginated - every field you have permission to see is returned. For a dropdown-style field, use listObjectFieldOptions (passing this row's `name`) to list its valid options.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFieldOptions", + "display_name": "listObjectFieldOptions", + "description": "List the valid options for one dropdown-style field (checkboxes, choices, dropdown, dynamictags, radio, rating, status, yesnomaybe), as discovered via listObjectFields. Write back a row's `name` - or its `code` on rating fields, whose options are a fixed numeric scale that search and sort do not apply to. Not paginated. team_selector and timezone fields have no options here.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "getCustomObject", + "display_name": "getCustomObject", + "description": "Get one object's configuration by api_name or id - use this when you already have a reference to a specific object (from an automation, a dashboard, or another tool) and do not want to list everything. Returns its identity (api_name, display name, entity name, type), description, record count, ownership and configuration flags; for pipelines also its stage names and lost/disqualified reasons. For the object's fields use listObjectFields, for a field's options use listObjectFieldOptions, and to browse or search across many objects use listCustomObjects.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listCatalogs", + "display_name": "listCatalogs", + "description": "List the knowledge catalogs available to this business (owned and installed). Use this first to discover what knowledge exists before searching. Returns each catalog's id, api_name, and description.", + "group": "custom_knowledge", + "group_label": "Custom Knowledge", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "searchCatalog", + "display_name": "searchCatalog", + "description": "Semantic search across knowledge catalogs. Returns the most relevant content chunks with their source description, source_id, url, and a relevance score. Optionally narrow the search to specific catalogs by api_name or UUID.\n\n{catalog_list}", + "group": "custom_knowledge", + "group_label": "Custom Knowledge", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "getDocument", + "display_name": "getDocument", + "description": "Retrieve the full text of a knowledge source by its source_id (obtained from searchCatalog). Returns source metadata and the concatenated content of all its chunks in order.", + "group": "custom_knowledge", + "group_label": "Custom Knowledge", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ], + "is_system_managed": true, + "endpoint_url": "https://kizen.test/api/mcp/readonly", + "is_enabled": true, + "is_default_for_business": true, + "created": "2026-09-14T18:52:55.847024-05:00", + "updated": "2026-09-14T18:52:55.847026-05:00", + "owner": {}, + "employee_access": "View", + "sharing_settings": { + "private": false, + "all_team_members": 1, + "roles": { + "view": [], + "edit": [], + "admin": [] + }, + "team_members": { + "view": [], + "edit": [], + "admin": [] + } + }, + "sharing_stats": { + "None": 0, + "View": 2, + "Edit": 0, + "Admin": 0 + } +} diff --git a/tests/fixtures/mcp_servers/events_history.json b/tests/fixtures/mcp_servers/events_history.json new file mode 100644 index 0000000..56e1547 --- /dev/null +++ b/tests/fixtures/mcp_servers/events_history.json @@ -0,0 +1,172 @@ +{ + "count": 4, + "next": null, + "previous": null, + "results": [ + { + "id": "81c3921d-b81c-481c-9e73-010dbb9378cf", + "group_id": null, + "business_id": "00000000-0000-4000-8000-00000000b1d0", + "type_name": "mcp_server_updated", + "type_display_name": "MCP Server Updated", + "has_relationship": false, + "instance": { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "custom_object_id": "9305ce04-f8c5-405c-a9fc-5eaa50dcb7ce", + "entity_record_name": "Contact" + }, + "initiator": { + "type": "employee_api_request", + "id": "00000000-0000-4000-8000-0000000005e7", + "first_name": "Sample", + "last_name": "User", + "email": "user@example.com", + "account_type": "user_standard" + }, + "data": { + "api_name": "support_tools", + "new_value": { + "name": "Support Tools", + "is_enabled": false, + "tool_names": [], + "description": null, + "is_default_for_business": false + }, + "old_value": { + "name": "Support Tools", + "is_enabled": false, + "tool_names": [], + "description": null, + "is_default_for_business": false + }, + "mcp_server_name": "Support Tools" + }, + "comments": [], + "created": "2026-10-27T13:18:37.057531Z" + }, + { + "id": "af19dc24-8d50-418b-9332-50d6459bb863", + "group_id": null, + "business_id": "00000000-0000-4000-8000-00000000b1d0", + "type_name": "mcp_server_updated", + "type_display_name": "MCP Server Updated", + "has_relationship": false, + "instance": { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "custom_object_id": "9305ce04-f8c5-405c-a9fc-5eaa50dcb7ce", + "entity_record_name": "Contact" + }, + "initiator": { + "type": "employee_api_request", + "id": "00000000-0000-4000-8000-0000000005e7", + "first_name": "Sample", + "last_name": "User", + "email": "user@example.com", + "account_type": "user_standard" + }, + "data": { + "api_name": "support_tools", + "new_value": { + "name": "Support Tools", + "is_enabled": false, + "tool_names": [], + "description": null, + "is_default_for_business": false + }, + "old_value": { + "name": "Support Tools", + "is_enabled": false, + "tool_names": [], + "description": "Tools the support agent uses", + "is_default_for_business": false + }, + "mcp_server_name": "Support Tools" + }, + "comments": [], + "created": "2026-10-27T13:18:36.117629Z" + }, + { + "id": "f20a3d0a-01d5-4c52-b1fd-ac7a23402aae", + "group_id": null, + "business_id": "00000000-0000-4000-8000-00000000b1d0", + "type_name": "mcp_server_updated", + "type_display_name": "MCP Server Updated", + "has_relationship": false, + "instance": { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "custom_object_id": "9305ce04-f8c5-405c-a9fc-5eaa50dcb7ce", + "entity_record_name": "Contact" + }, + "initiator": { + "type": "employee_api_request", + "id": "00000000-0000-4000-8000-0000000005e7", + "first_name": "Sample", + "last_name": "User", + "email": "user@example.com", + "account_type": "user_standard" + }, + "data": { + "api_name": "support_tools", + "new_value": { + "name": "Support Tools", + "is_enabled": true, + "tool_names": [], + "description": "Tools the support agent uses", + "is_default_for_business": false + }, + "old_value": { + "name": "Support Tools", + "is_enabled": true, + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "searchCatalog" + ], + "description": "Tools the support agent uses", + "is_default_for_business": false + }, + "mcp_server_name": "Support Tools" + }, + "comments": [], + "created": "2026-10-27T13:18:35.888702Z" + }, + { + "id": "cc6ce8fd-8c1f-408f-b71e-926d081accf0", + "group_id": null, + "business_id": "00000000-0000-4000-8000-00000000b1d0", + "type_name": "mcp_server_created", + "type_display_name": "MCP Server Created", + "has_relationship": false, + "instance": { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "custom_object_id": "9305ce04-f8c5-405c-a9fc-5eaa50dcb7ce", + "entity_record_name": "Contact" + }, + "initiator": { + "type": "employee_api_request", + "id": "00000000-0000-4000-8000-0000000005e7", + "first_name": "Sample", + "last_name": "User", + "email": "user@example.com", + "account_type": "user_standard" + }, + "data": { + "api_name": "support_tools", + "new_value": { + "name": "Support Tools", + "is_enabled": true, + "tool_names": [ + "listCustomObjects", + "getCustomObject" + ], + "description": "Tools the support agent uses", + "is_default_for_business": false + }, + "mcp_server_name": "Support Tools" + }, + "comments": [], + "created": "2026-10-27T13:18:35.367536Z" + } + ], + "errors": null +} diff --git a/tests/fixtures/mcp_servers/list.json b/tests/fixtures/mcp_servers/list.json new file mode 100644 index 0000000..873f55b --- /dev/null +++ b/tests/fixtures/mcp_servers/list.json @@ -0,0 +1,100 @@ +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": "bde29351-2a8b-4479-a0d3-c65d8292782e", + "name": "Read Only", + "api_name": "readonly", + "description": null, + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "listObjectFieldOptions", + "getCustomObject", + "listCatalogs", + "searchCatalog", + "getDocument" + ], + "is_system_managed": true, + "endpoint_url": "https://kizen.test/api/mcp/readonly", + "is_enabled": true, + "is_default_for_business": true, + "created": "2026-09-14T18:52:55.847024-05:00", + "updated": "2026-09-14T18:52:55.847026-05:00", + "owner": {}, + "employee_access": "View", + "sharing_settings": { + "private": false, + "all_team_members": 1, + "roles": { + "view": [], + "edit": [], + "admin": [] + }, + "team_members": { + "view": [], + "edit": [], + "admin": [] + } + }, + "sharing_stats": { + "None": 0, + "View": 2, + "Edit": 0, + "Admin": 0 + }, + "tool_count": 7 + }, + { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "name": "Support Tools", + "api_name": "support_tools", + "description": "Tools the support agent uses", + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "searchCatalog" + ], + "is_system_managed": false, + "endpoint_url": "https://kizen.test/api/mcp/support_tools", + "is_enabled": true, + "is_default_for_business": false, + "created": "2026-10-27T08:18:35.333889-05:00", + "updated": "2026-10-27T08:18:36.775948-05:00", + "owner": { + "id": "00000000-0000-4000-8000-0000000005e7", + "display_name": "Sample User (user@example.com)" + }, + "employee_access": "Owner", + "sharing_settings": { + "private": false, + "all_team_members": 0, + "roles": { + "view": [], + "edit": [], + "admin": [] + }, + "team_members": { + "view": [ + { + "id": "00000000-0000-4000-8000-0000000005a1", + "display_name": "Sample Service Account" + } + ], + "edit": [], + "admin": [] + } + }, + "sharing_stats": { + "None": 2, + "View": 0, + "Edit": 0, + "Admin": 0 + }, + "tool_count": 3 + } + ], + "errors": null +} diff --git a/tests/fixtures/mcp_servers/step_options.json b/tests/fixtures/mcp_servers/step_options.json new file mode 100644 index 0000000..a680bd9 --- /dev/null +++ b/tests/fixtures/mcp_servers/step_options.json @@ -0,0 +1,18 @@ +[ + { + "id": "bde29351-2a8b-4479-a0d3-c65d8292782e", + "name": "Read Only", + "api_name": "readonly", + "is_enabled": true, + "deleted": false, + "tool_count": 7 + }, + { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "name": "Support Tools", + "api_name": "support_tools", + "is_enabled": false, + "deleted": false, + "tool_count": 0 + } +] diff --git a/tests/fixtures/mcp_servers/tools.json b/tests/fixtures/mcp_servers/tools.json new file mode 100644 index 0000000..330911b --- /dev/null +++ b/tests/fixtures/mcp_servers/tools.json @@ -0,0 +1,93 @@ +{ + "groups": [ + { + "key": "custom_knowledge", + "label": "Custom Knowledge", + "tools": [ + { + "name": "listCatalogs", + "display_name": "listCatalogs", + "description": "List the knowledge catalogs available to this business (owned and installed). Use this first to discover what knowledge exists before searching. Returns each catalog's id, api_name, and description.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "searchCatalog", + "display_name": "searchCatalog", + "description": "Semantic search across knowledge catalogs. Returns the most relevant content chunks with their source description, source_id, url, and a relevance score. Optionally narrow the search to specific catalogs by api_name or UUID.\n\n{catalog_list}", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "getDocument", + "display_name": "getDocument", + "description": "Retrieve the full text of a knowledge source by its source_id (obtained from searchCatalog). Returns source metadata and the concatenated content of all its chunks in order.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ] + }, + { + "key": "custom_objects", + "label": "Custom Objects", + "tools": [ + { + "name": "listCustomObjects", + "display_name": "listCustomObjects", + "description": "List the objects configured in this business - Kizen's built-in objects (such as Contacts) plus any custom Standard or Pipeline objects. Use this to discover what kinds of records exist before querying them. Returns each object's api_name, display names, type, id, creation date, and record count. Supports search, sorting, and pagination. Only objects you have permission to see are returned. To fetch one object you already have an api_name or id for, use getCustomObject instead.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFields", + "display_name": "listObjectFields", + "description": "List the fields defined on one object, which is how you discover its schema before reading or writing records. Takes the object's api_name or id from listCustomObjects. Each row leads with `name` - the api_name to use in API payloads - alongside the display name, field type, category name, and the flags that govern writes (is_required, is_read_only, allows_nulls, allows_empty). Relation fields show their target as ' -> ', e.g. 'many_to_one -> receipts', with '(inverse)' appended on the mirror side of the relation. Dropdown-style fields list their valid `options`. Supports search on the display name or api_name, sorting, and filtering by category name. Not paginated - every field you have permission to see is returned. For a dropdown-style field, use listObjectFieldOptions (passing this row's `name`) to list its valid options.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFieldOptions", + "display_name": "listObjectFieldOptions", + "description": "List the valid options for one dropdown-style field (checkboxes, choices, dropdown, dynamictags, radio, rating, status, yesnomaybe), as discovered via listObjectFields. Write back a row's `name` - or its `code` on rating fields, whose options are a fixed numeric scale that search and sort do not apply to. Not paginated. team_selector and timezone fields have no options here.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "getCustomObject", + "display_name": "getCustomObject", + "description": "Get one object's configuration by api_name or id - use this when you already have a reference to a specific object (from an automation, a dashboard, or another tool) and do not want to list everything. Returns its identity (api_name, display name, entity name, type), description, record count, ownership and configuration flags; for pipelines also its stage names and lost/disqualified reasons. For the object's fields use listObjectFields, for a field's options use listObjectFieldOptions, and to browse or search across many objects use listCustomObjects.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ] + } + ] +} diff --git a/tests/test_cli.py b/tests/test_cli.py index 1bdd38a..3a35025 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3243,3 +3243,80 @@ def no_apply(plan): "remove": [], } assert json.loads(result.stdout)["operations"][0]["kind"] == "team_member" + + +# --- mcp-servers ------------------------------------------------------------- + + +@respx.mock +@pytest.mark.parametrize( + ("args", "check"), + [ + ( + ["list"], + lambda d: [s["api_name"] for s in d] == ["readonly", "support_tools"], + ), + ( + ["get", "support_tools"], + lambda d: ( + d["tools"][0] + == {"group": "custom_objects", "name": "listCustomObjects"} + and "raw" not in d + ), + ), + (["get", "support_tools", "--raw"], lambda d: "sharing_stats" in d), + ( + ["tools"], + lambda d: [g["key"] for g in d] == ["custom_knowledge", "custom_objects"], + ), + ( + ["history", "support_tools"], + lambda d: d[-1]["type_name"] == "mcp_server_created", + ), + ], + ids=["list", "get", "get-raw", "tools", "history"], +) +def test_mcp_servers_reads_emit_json(args, check): + base = f"{FAKE_BASE_URL}/api/mcp-servers" + detail = load_fixture("mcp_servers/detail.json") + respx.get(base).respond(json=load_fixture("mcp_servers/list.json")) + respx.get(f"{base}/tools").respond(json=load_fixture("mcp_servers/tools.json")) + respx.get(f"{base}/{detail['id']}").respond(json=detail) + respx.get(f"{base}/{detail['id']}/events-history").respond( + json=load_fixture("mcp_servers/events_history.json") + ) + flags = [] if "--raw" in args else ["--json"] + result = runner.invoke(cli.app, ["mcp-servers", *args, *flags]) + assert result.exit_code == 0, result.output + assert check(json.loads(result.stdout)) + + +def test_mcp_servers_history_changed_column(monkeypatch): + from kizen_builder.tools import mcp_servers as mcp_tools + + events = load_fixture("mcp_servers/events_history.json")["results"] + monkeypatch.setattr(mcp_tools, "list_history", lambda ref: events) + result = runner.invoke( + cli.app, ["mcp-servers", "history", "support_tools", "-o", "csv"] + ) + assert result.exit_code == 0, result.output + rows = result.stdout.strip().splitlines() + assert rows[0] == "when,event,by,changed" + assert [r.split(",", 3)[3] for r in rows[1:]] == [ + "—", + "description", + "tool_names", + "—", + ] + assert rows[1].split(",")[2] == "user@example.com" + + +def test_mcp_servers_history_with_no_events(monkeypatch): + from kizen_builder.tools import mcp_servers as mcp_tools + + monkeypatch.setattr(mcp_tools, "list_history", lambda ref: []) + result = runner.invoke(cli.app, ["mcp-servers", "history", "support_tools"]) + assert result.exit_code == 0, result.output + assert "no events yet (Kizen records them a second or so after a change)" in plain( + result.stdout + ) diff --git a/tests/test_execute_dispatch.py b/tests/test_execute_dispatch.py index 6f3210b..7a132d2 100644 --- a/tests/test_execute_dispatch.py +++ b/tests/test_execute_dispatch.py @@ -355,6 +355,7 @@ def _saved_view(kind: str, base: str, served: set[str]) -> list[Any]: f"/api/dashboards/{P}/dashlet/{E}", parent=True, )[:2], + *_crud("mcp_server", "/api/mcp-servers", f"/api/mcp-servers/{E}", parent=False), _row( "layout", "update", diff --git a/tests/test_mcp_server_plans.py b/tests/test_mcp_server_plans.py new file mode 100644 index 0000000..a84551d --- /dev/null +++ b/tests/test_mcp_server_plans.py @@ -0,0 +1,454 @@ +"""MCP server planners: create/update/delete, the tool check and sharing diffs. + +Planners build their own client from config, so these go through respx +against `FAKE_BASE_URL`, served from the sanitized live captures in +`tests/fixtures/mcp_servers/`. +""" + +from __future__ import annotations + +import copy +import json + +import httpx +import pytest +import respx +from pydantic import ValidationError +from typer.testing import CliRunner + +import kizen_builder.cli as cli +from kizen_builder.api.client import KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.tools import plans as plan_tools +from kizen_builder.tools.planners.mcp_servers import ( + DEFAULT_SHARING, + plan_create_mcp_server, + plan_delete_mcp_server, + plan_update_mcp_server, +) +from kizen_builder.tools.plans import PlanError, PlanOperation +from tests.conftest import FAKE_BASE_URL, load_fixture + +BASE = f"{FAKE_BASE_URL}/api/mcp-servers" +LIST = load_fixture("mcp_servers/list.json") +DETAIL = load_fixture("mcp_servers/detail.json") +READONLY = load_fixture("mcp_servers/detail_readonly.json") +TOOLS = load_fixture("mcp_servers/tools.json") +SID = DETAIL["id"] +SA_ID = DETAIL["sharing_settings"]["team_members"]["view"][0]["id"] + + +def _serve( + detail: dict | None = None, + servers: list | None = None, + readonly: dict | None = None, +) -> None: + """Serve the list, the catalogue, and each server's detail by id.""" + rows = servers if servers is not None else LIST["results"] + respx.get(BASE).respond(json={**LIST, "results": rows}) + respx.get(f"{BASE}/tools").respond(json=TOOLS) + respx.get(f"{BASE}/{SID}").respond(json=detail or DETAIL) + respx.get(f"{BASE}/{READONLY['id']}").respond(json=readonly or READONLY) + + +def _op(plan) -> PlanOperation: + assert len(plan.operations) == 1 + return plan.operations[0] + + +# --- create --------------------------------------------------------------- + + +@respx.mock +def test_create_payload_carries_only_write_keys_and_default_sharing(): + _serve() + op = _op( + plan_create_mcp_server( + { + "name": "Intake", + "api_name": "intake", + "tool_names": ["listCatalogs", "getDocument", "listCatalogs"], + } + ) + ) + assert op.action == "create" + assert op.kind == "mcp_server" + assert op.payload == { + "name": "Intake", + "api_name": "intake", + "tool_names": ["listCatalogs", "getDocument"], + "sharing_settings": DEFAULT_SHARING, + } + + +@respx.mock +def test_create_normalises_read_shape_sharing(): + _serve() + op = _op( + plan_create_mcp_server( + { + "name": "Intake", + "api_name": "intake", + "sharing_settings": DETAIL["sharing_settings"], + } + ) + ) + assert op.payload["sharing_settings"]["team_members"]["view"] == [SA_ID] + + +@respx.mock +def test_create_refuses_an_existing_api_name(): + _serve() + with pytest.raises(PlanError) as e: + plan_create_mcp_server({"name": "Again", "api_name": "support_tools"}) + assert str(e.value) == ( + f"MCP server 'support_tools' already exists (id {SID}). " + "Use `kizen mcp-servers update` instead." + ) + + +def test_create_needs_name_and_api_name(): + with pytest.raises(PlanError, match="needs name and api_name"): + plan_create_mcp_server({"description": "no names"}) + + +@respx.mock +def test_create_refuses_unknown_tools_naming_the_valid_ones(): + _serve() + with pytest.raises(PlanError) as e: + plan_create_mcp_server( + { + "name": "X", + "api_name": "intake", + "tool_names": ["nope", "getDocument", "nah"], + } + ) + assert str(e.value) == ( + "unknown MCP tool(s): nope, nah. Valid tools: getCustomObject, " + "getDocument, listCatalogs, listCustomObjects, listObjectFieldOptions, " + "listObjectFields, searchCatalog. List them with `kizen mcp-servers tools`." + ) + + +@respx.mock +def test_unknown_tool_sends_no_request(tmp_path): + _serve() + post = respx.post(BASE).respond(201, json={}) + spec = tmp_path / "spec.json" + spec.write_text( + json.dumps({"name": "X", "api_name": "intake", "tool_names": ["nope"]}) + ) + result = CliRunner().invoke( + cli.app, ["mcp-servers", "create", "--spec-file", str(spec), "-y"] + ) + assert result.exit_code == 1 + assert "unknown MCP tool(s): nope" in result.stderr + assert post.call_count == 0 + + +@respx.mock +def test_create_as_default_names_the_current_default(): + _serve() + op = _op( + plan_create_mcp_server( + {"name": "X", "api_name": "intake", "is_default_for_business": True} + ) + ) + assert op.payload["is_default_for_business"] is True + assert op.preview["business_default"] == ( + "replaces 'readonly', which stops being the default. To restore it, run " + '`kizen mcp-servers update readonly` with {"is_default_for_business": true}' + ) + + +# --- update --------------------------------------------------------------- + + +@respx.mock +def test_get_raw_output_resent_unchanged_plans_skip(): + _serve() + op = _op(plan_update_mcp_server("support_tools", copy.deepcopy(DETAIL))) + assert op.action == "skip" + assert op.payload == {} + assert op.preview["ignored_read_only_keys"] == [ + "id", + "tools", + "is_system_managed", + "endpoint_url", + "created", + "updated", + "owner", + "employee_access", + "sharing_stats", + ] + + +@respx.mock +def test_reordered_tools_plan_skip(): + _serve() + op = _op( + plan_update_mcp_server( + SID, {"tool_names": list(reversed(DETAIL["tool_names"]))} + ) + ) + assert op.action == "skip" + + +@respx.mock +def test_read_shape_sharing_with_reordered_grants_plans_skip(): + detail = copy.deepcopy(DETAIL) + detail["sharing_settings"]["roles"]["view"] = [ + {"id": "00000000-0000-4000-8000-0000000000a1", "display_name": "A"}, + {"id": "00000000-0000-4000-8000-0000000000a2", "display_name": "B"}, + ] + _serve(detail) + sharing = copy.deepcopy(detail["sharing_settings"]) + sharing["roles"]["view"] = [ + "00000000-0000-4000-8000-0000000000a2", + "00000000-0000-4000-8000-0000000000a1", + ] + op = _op(plan_update_mcp_server(SID, {"sharing_settings": sharing})) + assert op.action == "skip" + + +@respx.mock +def test_changed_sharing_is_sent_whole(): + _serve() + sharing = copy.deepcopy(DETAIL["sharing_settings"]) + sharing["all_team_members"] = 1 + sharing["roles"]["view"] = ["00000000-0000-4000-8000-0000000000a1"] + sharing["team_members"]["view"] = [] + op = _op(plan_update_mcp_server(SID, {"sharing_settings": sharing})) + assert op.action == "update" + assert op.preview["diff"]["sharing_settings"] == ( + "all_team_members 0 → 1, +roles.view:00000000-0000-4000-8000-0000000000a1, " + "-team_members.view:Sample Service Account (the whole block is sent)" + ) + assert op.payload == { + "sharing_settings": { + "private": False, + "all_team_members": 1, + "roles": { + "view": ["00000000-0000-4000-8000-0000000000a1"], + "edit": [], + "admin": [], + }, + "team_members": {"view": [], "edit": [], "admin": []}, + } + } + + +@respx.mock +def test_update_diffs_only_the_keys_the_spec_sets(): + _serve() + op = _op( + plan_update_mcp_server( + SID, + {"description": None, "tool_names": ["listCustomObjects", "getDocument"]}, + ) + ) + assert op.existing_uuid == SID + assert op.payload == { + "description": None, + "tool_names": ["listCustomObjects", "getDocument"], + } + assert op.preview["diff"]["tool_names"] == ( + "+getDocument, -listObjectFields, -searchCatalog" + ) + + +@respx.mock +def test_update_checks_tools_against_the_catalogue(): + _serve() + with pytest.raises(PlanError, match=r"unknown MCP tool\(s\): bogus\."): + plan_update_mcp_server(SID, {"tool_names": ["bogus"]}) + + +@respx.mock +def test_update_refuses_an_api_name_change(): + _serve() + with pytest.raises(PlanError) as e: + plan_update_mcp_server(SID, {"api_name": "renamed"}) + assert str(e.value) == ( + "api_name can't change after creation; create a new server instead." + ) + + +@respx.mock +@pytest.mark.parametrize( + "plan", + [ + lambda: plan_update_mcp_server("readonly", {"is_enabled": False}), + lambda: plan_update_mcp_server( + "readonly", {"is_default_for_business": True, "name": "Read Only"} + ), + lambda: plan_update_mcp_server("readonly", copy.deepcopy(READONLY)), + lambda: plan_delete_mcp_server("readonly"), + ], + ids=["update", "default-plus-extra-key", "get-raw", "delete"], +) +def test_system_server_is_refused(plan): + _serve(readonly={**READONLY, "is_default_for_business": False}) + with pytest.raises(PlanError) as e: + plan() + assert str(e.value) == ( + "'readonly' is managed by Kizen and can't be changed or deleted from the " + 'CLI. The one exception: an update that sets only {"is_default_for_business": ' + "true}." + ) + + +@respx.mock +def test_system_server_can_be_made_the_default_again(): + readonly = {**READONLY, "is_default_for_business": False} + servers = [ + {**LIST["results"][0], "is_default_for_business": False}, + {**LIST["results"][1], "is_default_for_business": True}, + ] + _serve(servers=servers, readonly=readonly) + op = _op(plan_update_mcp_server("readonly", {"is_default_for_business": True})) + assert op.action == "update" + assert op.existing_uuid == READONLY["id"] + assert op.payload == {"is_default_for_business": True} + assert op.preview["business_default"].startswith( + "replaces 'support_tools', which stops being the default." + ) + + +@respx.mock +def test_system_server_already_default_plans_skip(): + _serve() + op = _op(plan_update_mcp_server("readonly", {"is_default_for_business": True})) + assert op.action == "skip" + + +@respx.mock +def test_unsetting_the_current_default_is_refused(): + _serve({**DETAIL, "is_default_for_business": True}) + with pytest.raises(PlanError) as e: + plan_update_mcp_server(SID, {"is_default_for_business": False}) + assert str(e.value) == ( + "'support_tools' is the business default and Kizen keeps one. To change " + "it, set another server as the default instead " + '({"is_default_for_business": true} on that server).' + ) + + +@respx.mock +def test_unsetting_a_non_default_is_a_skip(): + _serve() + op = _op(plan_update_mcp_server(SID, {"is_default_for_business": False})) + assert op.action == "skip" + + +@respx.mock +def test_unknown_server_lists_api_names(): + _serve() + with pytest.raises(PlanError, match="available: readonly, support_tools"): + plan_update_mcp_server("Support Tools", {"is_enabled": False}) + + +# --- delete --------------------------------------------------------------- + + +@respx.mock +def test_delete_preview(): + _serve() + op = _op(plan_delete_mcp_server("support_tools")) + assert op.action == "delete" + assert op.existing_uuid == SID + assert {k: op.preview[k] for k in ("name", "api_name", "id", "tool_count")} == { + "name": "Support Tools", + "api_name": "support_tools", + "id": SID, + "tool_count": 3, + } + assert "is_business_default" not in op.preview + + +@respx.mock +def test_delete_preview_flags_the_business_default(): + _serve({**DETAIL, "is_default_for_business": True}) + op = _op(plan_delete_mcp_server(SID)) + assert op.preview["is_business_default"] is True + + +# --- spec validation ------------------------------------------------------ + + +def test_unknown_sharing_key_names_the_team_members_route(): + sharing = {**DEFAULT_SHARING, "service_accounts": {"view": [SA_ID]}} + with pytest.raises(ValidationError) as e: + plan_create_mcp_server( + {"name": "X", "api_name": "intake", "sharing_settings": sharing} + ) + assert e.value.errors()[0]["msg"] == ( + "unknown key(s) service_accounts. sharing_settings takes private, all_team_members, roles and team_members. " + "Service accounts are shared as team members: put the account's id in " + "team_members." + ) + + +@pytest.mark.parametrize( + ("spec", "loc"), + [ + ({"name": "X", "api_name": "Has-Caps"}, "api_name"), + ({"name": "X", "api_name": "9lives"}, "api_name"), + ({"name": "X", "api_name": "intake", "colour": "red"}, "colour"), + ( + {"name": "X", "api_name": "intake", "sharing_settings": {"private": True}}, + "sharing_settings.all_team_members", + ), + ( + { + "name": "X", + "api_name": "intake", + "sharing_settings": { + **DEFAULT_SHARING, + "roles": {"view": [{"display_name": "No id"}]}, + }, + }, + "sharing_settings.roles.view.0.GrantRef.id", + ), + ({"name": None}, "spec"), + ({"is_enabled": None}, "spec"), + ], + ids=[ + "caps", + "leading-digit", + "unknown-key", + "partial-sharing", + "grant-without-id", + "null-name", + "null-is-enabled", + ], +) +def test_bad_specs_fail_validation(spec, loc): + with pytest.raises(ValidationError) as e: + plan_create_mcp_server(spec) + errors = e.value.errors() + locs = {".".join(str(p) for p in err["loc"]) or "spec" for err in errors} + assert loc in locs + + +# --- execute -------------------------------------------------------------- + + +def test_execute_refuses_an_unsupported_action(): + op = PlanOperation( + action="duplicate", kind="mcp_server", key="x", existing_uuid=SID + ) + with KizenClient(load_env_config()) as c, pytest.raises(PlanError) as e: + plan_tools._execute(c, op) + assert str(e.value) == "unsupported mcp_server action 'duplicate'" + + +@respx.mock +def test_execute_create_reaches_the_api(): + route = respx.post(BASE).mock(return_value=httpx.Response(201, json={"id": SID})) + op = PlanOperation( + action="create", kind="mcp_server", key="x", payload={"name": "X"} + ) + with KizenClient(load_env_config()) as c: + assert plan_tools._execute(c, op) == {"id": SID} + assert route.call_count == 1 diff --git a/tests/test_mcp_servers_api.py b/tests/test_mcp_servers_api.py new file mode 100644 index 0000000..162178f --- /dev/null +++ b/tests/test_mcp_servers_api.py @@ -0,0 +1,91 @@ +"""api/mcp_servers.py: each wrapper calls exactly one method and path.""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from kizen_builder.api import mcp_servers as mcp_api +from kizen_builder.api.client import KizenClient +from tests.conftest import FAKE_BASE_URL, load_fixture + +BASE = f"{FAKE_BASE_URL}/api/mcp-servers" +SID = "00000000-0000-4000-8000-0000000000e1" + + +@pytest.fixture +def client(env_config): + with KizenClient(env_config) as c: + yield c + + +@respx.mock +def test_list_follows_pages(client): + page = load_fixture("mcp_servers/list.json") + route = respx.get(BASE).mock( + side_effect=[ + httpx.Response(200, json={**page, "next": f"{BASE}?page=2"}), + httpx.Response(200, json={"results": [], "next": None}), + ] + ) + rows = mcp_api.list_mcp_servers(client) + assert route.call_count == 2 + assert [r["api_name"] for r in rows] == ["readonly", "support_tools"] + + +@respx.mock +def test_get(client): + route = respx.get(f"{BASE}/{SID}").respond(json={"id": SID}) + assert mcp_api.get_mcp_server(client, SID) == {"id": SID} + assert route.call_count == 1 + + +@respx.mock +def test_create_posts_without_trailing_slash(client): + route = respx.post(BASE).respond(201, json={"id": SID}) + mcp_api.create_mcp_server(client, {"name": "X", "api_name": "x"}) + assert json.loads(route.calls.last.request.content) == { + "name": "X", + "api_name": "x", + } + + +@respx.mock +def test_update_patches(client): + route = respx.patch(f"{BASE}/{SID}").respond(json={"id": SID}) + mcp_api.update_mcp_server(client, SID, {"is_enabled": False}) + assert json.loads(route.calls.last.request.content) == {"is_enabled": False} + + +@respx.mock +def test_delete(client): + route = respx.delete(f"{BASE}/{SID}").respond(204) + assert mcp_api.delete_mcp_server(client, SID) is None + assert route.call_count == 1 + + +@respx.mock +def test_tool_catalog_returns_the_dict(client): + tools = load_fixture("mcp_servers/tools.json") + respx.get(f"{BASE}/tools").respond(json=tools) + assert mcp_api.get_tool_catalog(client) == tools + + +@respx.mock +def test_events_history_is_paginated(client): + history = load_fixture("mcp_servers/events_history.json") + route = respx.get(f"{BASE}/{SID}/events-history").respond(json=history) + assert mcp_api.list_events_history(client, SID) == history["results"] + assert route.call_count == 1 + + +def test_step_options_rows_carry_server_keys(): + """step-options (wrapped with the automations API) returns a subset of the + server read shape plus `deleted`, so a step's `mcp_server` can be matched + to a server by id or api_name.""" + server_keys = set(load_fixture("mcp_servers/list.json")["results"][0]) + for row in load_fixture("mcp_servers/step_options.json"): + assert set(row) - {"deleted"} <= server_keys From beb3e16a3d43dff69165c65adb4c46f1f7b2ab1f Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 10:31:33 -0400 Subject: [PATCH 5/8] 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) From 66b43ab360dbdccf9ba1903e421b1154136c6247 Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 11:23:08 -0400 Subject: [PATCH 6/8] Author loops from a spec, and stop diff reporting Kizen's defaults A spec can now describe a loop the way Kizen's builder makes one: the array and item variables, the loop, its body, the next_or_finish closer and any exit_loop steps. A loop is always sent as a branch group initiator, since the server stores that regardless, and closers no longer count toward the fan-out warning. The plan refuses a loop nested more than three deep, or one over the same array as a loop around it. The API accepts both, but Kizen's builder doesn't, and such an automation can't be edited in the UI. diff now compares a key the spec leaves out as the value Kizen stores for it (error_notification_email, deduplicate_array, array_separator, value_html and the per-type default descriptions), ignores the keys the server derives on a loop's item variable, and reads a null key in a variable definition as absent. Without this, a spec that omits defaults never diffs clean against what it created. --- CHANGELOG.md | 18 ++ src/kizen_builder/docs/specs/automation.md | 122 +++++++-- src/kizen_builder/models/spec/automations.py | 3 +- .../tools/planners/automations.py | 14 +- src/kizen_builder/translate.py | 152 ++++++++++- tests/test_automation_payloads.py | 245 +++++++++++++++++- tests/test_translate.py | 186 ++++++++++++- 7 files changed, 699 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 094d504..79efcf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,16 @@ called out explicitly under **Changed** or **Removed**. ### Added +- **Automation specs can author loops.** A spec names the array variable, + its item variable, a `loop` step with `step_loop: {array_variable, + item_variable}`, the body, a `next_or_finish` closer and any `exit_loop` + steps, and `create`, `update` and `diff` handle them like any other step. + A loop is always sent as a branch group initiator. `diff` no longer + reports the keys Kizen adds to a loop's item variable. The plan preview + no longer warns about a fan-out on every loop. The plan refuses a loop + nested more than three deep, or one over the same array as a loop around + it, as Kizen's builder does. `kizen docs show automation` has a worked + example. - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes @@ -495,6 +505,14 @@ called out explicitly under **Changed** or **Removed**. ### Fixed +- **`automations diff` no longer reports Kizen's defaults as changes when a + spec leaves them out.** An omitted `error_notification_email`, + `deduplicate_array`, `array_separator` (on a static list source), + `value_html` (on an `update_variable` source), or `description` on the + steps and trigger Kizen gives a fixed one (`kizen docs show automation` + lists them) is compared as the value Kizen stores for it. So is a null + key in a top-level or `initialize_variable` variable definition. A spec + that sets a different value still shows the change. - **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 diff --git a/src/kizen_builder/docs/specs/automation.md b/src/kizen_builder/docs/specs/automation.md index 378f06d..eacd025 100644 --- a/src/kizen_builder/docs/specs/automation.md +++ b/src/kizen_builder/docs/specs/automation.md @@ -112,18 +112,11 @@ branches) and 2026-09-29 (goals). `merge_branches` child to rejoin them. Lanes can be `branch` steps themselves, merged or not, and a `branch` can follow a merge. Put a `branch` step in front of any fan-out: a plain step with two or more children still saves, but the -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. +plan preview warns about it. A group's closer (`merge_branches` or +`next_or_finish`) isn't a lane, so it doesn't count toward that warning. +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. **Skipping a condition or goal** (`should_skip_execution: true`) needs `continue_with_branch: "yes"` or `"no"`, the branch the run takes past it. The @@ -141,6 +134,87 @@ sets a step's own severity. Omitted, the step inherits. Confirmed live (`account_region`), *not* object-qualified — it's the filter DSL, same as `records list --filter` and saved views. +### Loops + +A loop runs its body once per item of an array variable. Loops are branch +group initiators, closed by `next_or_finish` instead of `merge_branches`. A +spec names all six parts; the CLI adds none of them: + +1. **The array variable:** an `initialize_variable` step with `is_array: true`. +2. **Its item variable:** an `initialize_variable` step with `sources: []`, + declaring a single variable of the array's type (for records, the same + `data_subtype`). It needs no sources, though the server accepts one. Each + loop needs its own. +3. **The `loop` step.** `step_loop` names both variables by **name**; their ids + are a 400. The CLI always sends a loop as `is_branch_group_initiator: true`, + because the server stores true even when false is sent. +4. **The body**, hanging off the loop. +5. **One `next_or_finish` child of the loop**, which closes the body. The steps + after the loop hang off it. +6. **Optional `exit_loop` steps** inside the body. + +Both variable steps go at the front of the automation, like every +`initialize_variable`. Confirmed live 2026-10-27. + +```json +{ "name": "Visit every account", "api_name": "visit_every_account", + "type": "global", + "steps": [ + { "key": "accounts", "parent_key": null, "step_type": "initialize_variable", + "order": 0, "description": "accounts = []", + "action_initialize_variable": { "sources": [], "array_aggregation_mode": "add_all", + "variable": { "name": "accounts", "data_type": "entity", "is_array": true, + "data_subtype": "" } } }, + { "key": "status", "parent_key": "accounts", "step_type": "initialize_variable", + "order": 1, "action_initialize_variable": { "sources": [], + "variable": { "name": "status", "data_type": "string" } } }, + { "key": "account", "parent_key": "status", "step_type": "initialize_variable", + "order": 2, "description": "Item Reference Variable", + "action_initialize_variable": { "sources": [], + "variable": { "name": "accounts_item", "data_type": "entity", + "data_subtype": "" } } }, + { "key": "find", "parent_key": "account", "step_type": "search_records", + "order": 3, "action_search_records": { "custom_object": "accounts", + "filter_type": "all_records", "destination_variable": "accounts" } }, + { "key": "each", "parent_key": "find", "step_type": "loop", "order": 4, + "description": "For each item in 'accounts'", + "step_loop": { "array_variable": "accounts", "item_variable": "accounts_item" } }, + { "key": "visit", "parent_key": "each", "step_type": "update_variable", "order": 5, + "action_update_variable": { "variable": { "name": "status", "data_type": "string" }, + "sources": [ { "source_type": "static", "source_subtype": "string", + "value": "visited" } ] } }, + { "key": "next", "parent_key": "each", "step_type": "next_or_finish", "order": 6 }, + { "key": "done", "parent_key": "next", "step_type": "update_variable", "order": 7, + "action_update_variable": { "variable": { "name": "status", "data_type": "string" }, + "sources": [ { "source_type": "static", "source_subtype": "string", + "value": "done" } ] } } + ] } +``` + +- **Array types:** strings, UUIDs and records (`data_type` `string`, `uuid` or + `entity`). Kizen rejects an array of any other type at the + `initialize_variable` step. A `search_records` destination can be the loop's + array, as above. Confirmed live 2026-10-27. +- **Descriptions:** a CLI-created loop reads back as "Loop" and its item step as + "Initialize Variable". The UI writes "For each item in ''" and "Item + Reference Variable". Set `description` to match the UI. Confirmed live + 2026-10-27. +- **`diff`** ignores `is_array_item_variable` and `source_array_variable`, which + the server adds to the item variable from the loop. It reads a null key in a + top-level or `initialize_variable` variable definition as absent. Confirmed + live 2026-10-27. +- **`exit_loop`** ends the innermost loop it sits in, and an outer loop carries + on with its next item. It can be the loop's direct child, sit under a + condition in the body, or follow an inner loop inside an outer body. After + the loop's `next_or_finish` it is a 400. Confirmed live 2026-10-27. +- **Body lanes:** a body can be empty or have two lanes. Two lanes get the + fan-out warning, like any plain fan-out. Confirmed live 2026-10-27. +- **Nesting:** a loop can sit in another loop's body, up to three levels, and a + nested loop needs a different array from every loop around it. Kizen's + builder refuses both, so the CLI refuses them at plan time (`--dry-run` shows + it). The API accepts both: six levels, and one array at two levels, each + confirmed live 2026-10-27. + --- ## Wired trigger types (11) @@ -202,8 +276,8 @@ 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 | +| `loop` | `step_loop` | run its body once per item of an array variable — see Loops | +| `next_or_finish` | none | close a loop body — see Loops | | `exit_loop` | none | leave the loop from inside its body | `search_records` (the automation-builder equivalent of `records list @@ -896,6 +970,18 @@ changed field is identified by that octet alone; an added or removed step/trigger carries the whole step/trigger, full `id` included, in its `--json` leaf value. +A key the spec leaves out compares as the value Kizen stores when it's +omitted, so a spec that leaves out a default shows no change. A non-default +value on either side still shows. Confirmed live 2026-10-27: + +| Key | Omitted reads back as | +|---|---| +| `error_notification_email` | `''` | +| `deduplicate_array` on `initialize_variable`/`update_variable` | `false` | +| `array_separator` on a static source whose `value` is a list | `'comma'` | +| `value_html` on an `update_variable` source | `''` | +| `description` on a step or trigger | a fixed text per type: `Initialize Variable`, `Update Variable`, `Action: Search Records`, `Loop`, `Next/Finish`, `Exit Loop`, `Condition`, `Branch`, `Merge Branches`, `Delay`, `Stop Execution`, and `Manual` for the manual trigger. It is written on create and on a PUT that omits it, so an update from a spec without one replaces a description set in the UI. Other types compare as sent | + ### Read→write transforms Each of these was discovered via a live 400, 500, or silent data loss: @@ -914,12 +1000,12 @@ Each of these was discovered via a live 400, 500, or silent data loss: | Step/trigger orders with gaps or nulls | sequential 0..N | | `change_field_value.fields_to_clear` (expanded field objects) | bare field UUIDs | | `start_automation.automation_variable_overrides` — flat list, every ref expanded (`target_automation`, `variable_to_override`, plus a `value_source`-specific ref) | grouped by target automation id: `[{automation_id, variable_overrides: [...]}]`, and the unwrapping is non-uniform — `context_entity_field` → bare UUID, `variable_to_override`/`automation_variable` → bare **name**, `specific_value` → passthrough (already a bare scalar on read), `blank` → no key at all | -| `is_branch_group_initiator` on every step | echoed as read, default `false`. Dropped, every merge step 400s: "Merge step's parent (…) must be a branch group initiator step." Confirmed live 2026-09-28 | +| `is_branch_group_initiator` on every step | echoed as read, default `false`, and always `true` on a loop (see Loops). Dropped, every merge step 400s: "Merge step's parent (…) must be a branch group initiator step." Confirmed live 2026-09-28 | | `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 | +| 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. `diff` ignores both. 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 | @@ -964,7 +1050,9 @@ inconsistent: 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. + The server has no nesting limit (6 deep saves) and no limit on body lanes. + The CLI adds two rules of its own, from Kizen's builder: at most three + levels, and no loop over the same array as a loop around it (see Loops). ### Unlisted step types diff --git a/src/kizen_builder/models/spec/automations.py b/src/kizen_builder/models/spec/automations.py index 536633b..212ebc2 100644 --- a/src/kizen_builder/models/spec/automations.py +++ b/src/kizen_builder/models/spec/automations.py @@ -182,7 +182,8 @@ class AutomationStepDef(BaseModel): description=( "Set on a `branch`, `condition` or `goal` step whose lanes rejoin. " "The step then needs exactly one `merge_branches` child, and steps " - "after the merge hang off that merge step." + "after the merge hang off that merge step. A `loop` is always sent " + "as an initiator, whatever this says." ), ) continue_with_branch: Literal["yes", "no"] | None = Field( diff --git a/src/kizen_builder/tools/planners/automations.py b/src/kizen_builder/tools/planners/automations.py index cec6650..a0a4ff4 100644 --- a/src/kizen_builder/tools/planners/automations.py +++ b/src/kizen_builder/tools/planners/automations.py @@ -585,12 +585,20 @@ def _automation_preview( } +# A group's closer is a child of its initiator, not a parallel lane. +_CLOSER_STEP_TYPES = frozenset({"merge_branches", "next_or_finish"}) + + def _fan_out_warnings(auto: AutomationDef) -> dict[str, Any]: """Parallel lanes belong under a `branch` step. A plain fan-out still saves, but Kizen's 2026-09-28 migration put a `branch` step in front of every one, and an update from a spec without it removes it again.""" lanes = Counter( - s.parent_key for s in auto.steps if s.parent_key and s.parent_branch is None + s.parent_key + for s in auto.steps + if s.parent_key + and s.parent_branch is None + and s.step_type not in _CLOSER_STEP_TYPES ) types = {s.key: s.step_type for s in auto.steps} warnings = [ @@ -1220,7 +1228,9 @@ def _build_step_payload( if step.description: p["description"] = step.description # Emitted only when set, so a spec that predates them builds unchanged. - if step.is_branch_group_initiator: + # A loop is always an initiator: the server stores true even when false + # is sent (confirmed live 2026-10-27). + if step.is_branch_group_initiator or step.step_type == "loop": p["is_branch_group_initiator"] = True if step.continue_with_branch is not None: p["continue_with_branch"] = step.continue_with_branch diff --git a/src/kizen_builder/translate.py b/src/kizen_builder/translate.py index 8ea0c75..024d6dc 100644 --- a/src/kizen_builder/translate.py +++ b/src/kizen_builder/translate.py @@ -454,12 +454,18 @@ def _branch_group_problems(steps: list[dict[str, Any]]) -> list[str]: # A loop may iterate an array of these (records, text or UUIDs). _LOOPABLE_DATA_TYPES = frozenset({"entity", "string", "uuid"}) +# Kizen's builder holds loops three deep. The API accepts more (six seen, +# confirmed live 2026-10-27), but the CLI refuses them. +_MAX_LOOP_DEPTH = 3 + 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.""" + """The server's loop rules, each one a live 400 (confirmed 2026-10-27), + plus two from Kizen's builder that the API doesn't enforce: nesting depth + and one array at two levels. 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]] = {} @@ -495,6 +501,7 @@ def _loop_problems(steps: list[dict[str, Any]]) -> list[str]: ) else: item_owners[item] = key + problems.extend(_builder_loop_problems(s, by_key)) elif step_type == "next_or_finish": pk = s.get("parent_key") if (by_key.get(pk) or {}).get("type") != "loop": @@ -547,20 +554,61 @@ def _loop_variable_problems( return [] +def _builder_loop_problems( + loop: dict[str, Any], by_key: dict[Any, dict[str, Any]] +) -> list[str]: + """Kizen's builder refuses these, though the API accepts them (confirmed + live 2026-10-27), so the CLI refuses them to keep the automation editable.""" + key = loop.get("key") + around = enclosing_loops(loop, by_key) + problems: list[str] = [] + depth = 1 + len(around) + if depth > _MAX_LOOP_DEPTH: + problems.append( + f"loop '{key}' is nested {depth} levels deep; Kizen's builder allows " + "at most three. The API accepts deeper loops, but the CLI refuses them " + "so the automation stays editable in the UI." + ) + array = (loop.get("step_loop") or {}).get("array_variable") + outer = next( + ( + o + for o in around + if (o.get("step_loop") or {}).get("array_variable") == array + ), + None, + ) + if array is not None and outer is not None: + problems.append( + f"loop '{key}' iterates '{array}', which loop '{outer.get('key')}' " + "around it already iterates; Kizen's builder needs a different array " + "variable at each level. The API accepts it, but the CLI refuses it so " + "the automation stays editable in the UI." + ) + return problems + + 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() + return bool(enclosing_loops(step, by_key)) + + +def enclosing_loops( + step: dict[str, Any], by_key: dict[Any, dict[str, Any]] +) -> list[dict[str, Any]]: + """The loops ``step`` sits inside, innermost first: walking up, the loops + reached through one of their body steps. Arriving through a loop's + `next_or_finish` means the step comes after that loop, so it isn't + included and the walk carries on to any loop around it.""" + cur, seen, loops = 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 + break if parent.get("type") == "loop" and cur.get("type") != "next_or_finish": - return True + loops.append(parent) cur = parent - return False + return loops # --------------------------------------------------------------------------- @@ -707,6 +755,76 @@ def _diff(a: Any, b: Any, path: str) -> list[tuple[str, Any, Any]]: "error_notification_severity_level": "inherit", } +# Keys a spec may omit that read back as the server's default (confirmed live +# 2026-10-27), so an omitted one compares as that default. A non-default value +# on either side still diffs. +_WIRE_TOP_DEFAULTS = {"error_notification_email": ""} +_VARIABLE_BLOCK_DEFAULTS = {"deduplicate_array": False} + +# What the server writes when a step or trigger is sent without a +# `description`, on create and on a PUT that omits it (confirmed live +# 2026-10-27). A type missing here compares as sent. +_DEFAULT_DESCRIPTIONS = { + "branch": "Branch", + "condition": "Condition", + "delay": "Delay", + "exit_loop": "Exit Loop", + "initialize_variable": "Initialize Variable", + "loop": "Loop", + "manual": "Manual", + "merge_branches": "Merge Branches", + "next_or_finish": "Next/Finish", + "search_records": "Action: Search Records", + "stop_execution": "Stop Execution", + "update_variable": "Update Variable", +} + +# Set by the server on a loop's item variable, from the loop (confirmed live +# 2026-10-27). A spec never names them. +_DERIVED_VARIABLE_KEYS = frozenset({"is_array_item_variable", "source_array_variable"}) + + +def _diffable_variable(var: Any) -> Any: + """A variable definition as `diff` compares it: without the keys the + server derives from a loop, and with a null key the same as an absent + one, since a spec can't send null and an omitted key reads back null.""" + if not isinstance(var, dict): + return var + return { + k: v + for k, v in var.items() + if k not in _DERIVED_VARIABLE_KEYS and v is not None + } + + +def _with_default_description(item: dict[str, Any]) -> dict[str, Any]: + default = _DEFAULT_DESCRIPTIONS.get(str(item.get("type"))) + if default is None or "description" in item: + return item + return {**item, "description": default} + + +def _diffable_variable_block(step_type: str, block: dict[str, Any]) -> dict[str, Any]: + """An initialize_variable/update_variable block with its omitted defaults + filled in: `deduplicate_array`, a static list source's `array_separator`, + and an update_variable source's `value_html`.""" + out = {**_VARIABLE_BLOCK_DEFAULTS, **block} + if step_type == "initialize_variable" and "variable" in out: + out["variable"] = _diffable_variable(out["variable"]) + if isinstance(out.get("sources"), list): + sources = [] + for src in out["sources"]: + if isinstance(src, dict): + if src.get("source_type") == "static" and isinstance( + src.get("value"), list + ): + src = {"array_separator": "comma", **src} + if step_type == "update_variable": + src = {"value_html": "", **src} + sources.append(src) + out["sources"] = sources + return out + def _wire_pairs( live_items: list[dict[str, Any]], spec_items: list[dict[str, Any]] @@ -805,6 +923,7 @@ def _normalize_wire_triggers( if item is None: continue norm = {k: v for k, v in item.items() if k not in _WIRE_DIFF_EXCLUDED | {"id"}} + norm = _with_default_description(norm) # `id` is carried as a normal field, but pinned to the pair's shared # canonical value on both sides so a matched pair never diffs on it # (position-fallback matches can have an id on only one side) while @@ -859,7 +978,11 @@ def _normalize_wire_steps( resolved["trigger_key"], resolved["trigger_key"] ) norm[_GO_TO_BLOCK_FIELD] = resolved - out[_wire_pair_label(pair)] = norm + for step_type in ("initialize_variable", "update_variable"): + field = _block_field_for(step_type) + if isinstance(norm.get(field), dict): + norm[field] = _diffable_variable_block(step_type, norm[field]) + out[_wire_pair_label(pair)] = _with_default_description(norm) return out @@ -897,6 +1020,11 @@ def diff_wire_payloads( norm_live = {k: v for k, v in live.items() if k not in ("triggers", "steps")} norm_spec = {k: v for k, v in spec.items() if k not in ("triggers", "steps")} + for norm in (norm_live, norm_spec): + for k, default in _WIRE_TOP_DEFAULTS.items(): + norm.setdefault(k, default) + if isinstance(norm.get("variables"), list): + norm["variables"] = [_diffable_variable(v) for v in norm["variables"]] norm_live["triggers"] = _normalize_wire_triggers(trigger_pairs, 0) norm_spec["triggers"] = _normalize_wire_triggers(trigger_pairs, 1) norm_live["steps"] = _normalize_wire_steps(step_pairs, trigger_pairs, 0) diff --git a/tests/test_automation_payloads.py b/tests/test_automation_payloads.py index 26c9316..e2b9002 100644 --- a/tests/test_automation_payloads.py +++ b/tests/test_automation_payloads.py @@ -3021,6 +3021,242 @@ def test_broken_loop_spec_fails_plan(patch_live_lookups): assert "next_or_finish step 'next' has parent 'check'" in str(e.value) +def _var(key, parent, name, order, data_type="string", is_array=False, sources=()): + variable = {"name": name, "data_type": data_type, "is_array": is_array} + if data_type == "entity": + variable["data_subtype"] = load_fixture("objects/patients.json")["id"] + block = {"sources": list(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, parent, array, item, order, **kw): + block = {"array_variable": array, "item_variable": item} + return _s(key, parent, "loop", order, step_loop=block, **kw) + + +_STRINGS = [{"source_type": "static", "source_subtype": "string", "value": ["a", "b"]}] + + +def _authored_loop_spec(steps: list[dict], record_based: bool = True) -> dict: + for i, s in enumerate(steps): + s["order"] = i + spec = { + "api_name": "authored_loop", + "name": "Authored Loop", + "type": "record_based" if record_based else "global", + "steps": steps, + } + if record_based: + spec["target_object"] = "patients" + return spec + + +def _string_loop_steps() -> list[dict]: + return [ + _var("arr", None, "arr", 0, is_array=True, sources=_STRINGS), + _var("item", "arr", "arr_item", 0), + _loop("each", "item", "arr", "arr_item", 0), + _s("body", "each", "delay", 0), + _s("next", "each", "next_or_finish", 0), + _s("after", "next", "delay", 0), + ] + + +def _nested_loops( + depth: int, root_parent: str | None = None, arrays: list[str] | None = None +) -> list[dict]: + """`depth` loops, each the body of the one before, and an `exit_loop` in + the innermost body. Each loop iterates its entry in `arrays` (by default + a different array per level) through its own item variable.""" + arrays = arrays or [f"arr{n}" for n in range(depth)] + steps: list[dict] = [] + parent = root_parent + for name in dict.fromkeys(arrays): + steps.append(_var(name, parent, name, 0, is_array=True, sources=_STRINGS)) + parent = name + for n in range(depth): + steps.append(_var(f"item{n}", parent, f"item{n}", 0)) + parent = f"item{n}" + closers = [] + for n, name in enumerate(arrays): + steps.append(_loop(f"l{n}", parent, name, f"item{n}", 0)) + closers.append(_s(f"n{n}", f"l{n}", "next_or_finish", 0)) + parent = f"l{n}" + steps.append(_s("leave", parent, "exit_loop", 0)) + return steps + closers[::-1] + + +def _built_steps(spec: dict) -> dict[str, dict]: + return {s["key"]: s for s in _build(spec)["steps"]} + + +def _preview(spec: dict) -> dict: + return plan_create_automation(spec).operations[0].preview + + +@pytest.mark.parametrize("flag", [None, False, True]) +def test_loop_is_always_sent_as_an_initiator(patch_live_lookups, flag): + """The server stores true on a loop whatever is sent (confirmed live + 2026-10-27), so the builder sends true and `diff` shows no flip.""" + steps = _string_loop_steps() + if flag is not None: + steps[2]["is_branch_group_initiator"] = flag + by_key = _built_steps(_authored_loop_spec(steps, record_based=False)) + assert by_key["each"]["is_branch_group_initiator"] is True + assert [k for k, s in by_key.items() if "is_branch_group_initiator" in s] == [ + "each" + ] + + +def test_string_array_loop_spec(patch_live_lookups): + spec = _authored_loop_spec(_string_loop_steps(), record_based=False) + by_key = _built_steps(spec) + assert by_key["each"]["step_loop"] == { + "loop_type": "for_each_item", + "array_variable": "arr", + "item_variable": "arr_item", + } + assert by_key["item"]["action_initialize_variable"]["sources"] == [] + assert by_key["after"]["parent_key"] == "next" + assert "warnings" not in _preview(spec) + + +def test_record_array_loop_over_search_records(patch_live_lookups): + steps = [ + _var("recs", None, "recs", 0, data_type="entity", is_array=True), + _var("rec", "recs", "recs_item", 0, data_type="entity"), + _s( + "find", + "rec", + "search_records", + 0, + action_search_records={ + "custom_object": "patients", + "filter_type": "all_records", + "destination_variable": "recs", + }, + ), + _loop("each", "find", "recs", "recs_item", 0), + _s("body", "each", "delay", 0), + _s("next", "each", "next_or_finish", 0), + ] + spec = _authored_loop_spec(steps) + by_key = _built_steps(spec) + assert by_key["find"]["action_search_records"]["destination_variable"] == { + "name": "recs" + } + assert by_key["each"]["step_loop"]["array_variable"] == "recs" + assert "warnings" not in _preview(spec) + + +def test_loop_on_a_condition_yes_branch(patch_live_lookups): + steps = _string_loop_steps()[:5] + check = _s( + "check", + "item", + "condition", + 0, + step_condition={"type": "custom_filter", "filter_config": NAME_IS_X}, + ) + steps[2].update(parent_key="check", parent_branch="yes") + other = _s("other", "check", "delay", 0, parent_branch="no") + spec = _authored_loop_spec([*steps[:2], check, *steps[2:], other]) + by_key = _built_steps(spec) + assert by_key["each"]["parent_yes_no"] == "yes" + assert by_key["each"]["is_branch_group_initiator"] is True + assert "warnings" not in _preview(spec) + + +def test_nested_loops_with_an_exit_loop(patch_live_lookups): + spec = _authored_loop_spec(_nested_loops(2), record_based=False) + by_key = _built_steps(spec) + assert by_key["l1"]["parent_key"] == "l0" + assert by_key["leave"]["parent_key"] == "l1" + assert (by_key["n0"]["parent_key"], by_key["n1"]["parent_key"]) == ("l0", "l1") + assert "warnings" not in _preview(spec) + + +def test_loop_with_two_body_lanes_still_warns(patch_live_lookups): + steps = [*_string_loop_steps(), _s("other_lane", "each", "delay", 0)] + assert _preview(_authored_loop_spec(steps))["warnings"] == [ + "step 'each' has 2 parallel children but is a 'loop', not a 'branch'; " + "put a 'branch' step in front of the fan-out" + ] + + +def test_loop_nested_past_three_is_refused(patch_live_lookups): + """Kizen's builder holds three levels. The API accepts more, so this is + the CLI's own rule, checked at plan time.""" + assert "warnings" not in _preview(_authored_loop_spec(_nested_loops(3), False)) + with pytest.raises(PlanError) as e: + _build(_authored_loop_spec(_nested_loops(5), False)) + problems = str(e.value).split("\n - ")[1:] + assert problems == [ + f"loop 'l{n}' is nested {n + 1} levels deep; Kizen's builder allows at " + "most three. The API accepts deeper loops, but the CLI refuses them so " + "the automation stays editable in the UI." + for n in (3, 4) + ] + + +def test_loop_after_a_next_or_finish_is_not_nested_in_that_loop(patch_live_lookups): + """`again` follows the innermost loop of three, so it is three deep, not four.""" + steps = [ + _var("again_arr", None, "again_arr", 0, is_array=True, sources=_STRINGS), + _var("again_item", "again_arr", "again_item", 0), + *_nested_loops(3, root_parent="again_item"), + _loop("again", "n2", "again_arr", "again_item", 0), + _s("again_next", "again", "next_or_finish", 0), + ] + assert "warnings" not in _preview(_authored_loop_spec(steps, False)) + + +def _same_array_problem(key: str, array: str, outer: str) -> str: + return ( + f"loop '{key}' iterates '{array}', which loop '{outer}' around it already " + "iterates; Kizen's builder needs a different array variable at each level. " + "The API accepts it, but the CLI refuses it so the automation stays " + "editable in the UI." + ) + + +@pytest.mark.parametrize( + ("arrays", "problem"), + [ + (["a", "a"], ("l1", "a", "l0")), + (["a", "b", "a"], ("l2", "a", "l0")), + ], + ids=["outer_and_inner", "two_levels_down"], +) +def test_nested_loop_over_an_array_already_iterated_is_refused( + patch_live_lookups, arrays, problem +): + spec = _authored_loop_spec(_nested_loops(len(arrays), arrays=arrays), False) + with pytest.raises(PlanError) as e: + _build(spec) + assert str(e.value).split("\n - ")[1:] == [_same_array_problem(*problem)] + + +def test_loops_over_one_array_in_sequence_build(patch_live_lookups): + """The second loop hangs off the first's next_or_finish: not nested.""" + variables, loop = _string_loop_steps()[:2], _string_loop_steps()[2:] + loop[0]["parent_key"] = "again_item" + steps = [ + *variables, + _var("again_item", "item", "again_item", 0), + *loop, + _loop("again", "after", "arr", "again_item", 0), + _s("again_body", "again", "delay", 0), + _s("again_next", "again", "next_or_finish", 0), + ] + by_key = _built_steps(_authored_loop_spec(steps, False)) + assert by_key["again"]["step_loop"]["array_variable"] == "arr" + + @pytest.mark.parametrize( ("field", "value"), [ @@ -3123,8 +3359,8 @@ def search(obj: str, var: str) -> dict: def test_branching_test_transcription_diffs_clean(patch_live_lookups, monkeypatch): """The spec reproduces the UI-built graph. What's left is noise any spec - shows: server-written descriptions, the plugin app the server fills in - for a `kizen/*` model, and a variable definition's null read defaults.""" + shows: server-written descriptions, and the plugin app the server fills + in for a `kizen/*` model.""" import kizen_builder.tools.planners.automations as ma raw = load_fixture("automations/branching_test.raw.json") @@ -3135,11 +3371,6 @@ def test_branching_test_transcription_diffs_clean(patch_live_lookups, monkeypatc d for d in diff_automation(_branching_test_spec())["diff"] if d["path"].rsplit(".", 1)[-1] not in ("description", "business_plugin_app_id") - and not ( - ".action_initialize_variable.variable." in d["path"] - and d["before"] in (None, False) - and d["after"] == "" - ) ] assert structural == [] diff --git a/tests/test_translate.py b/tests/test_translate.py index 08c00db..882be43 100644 --- a/tests/test_translate.py +++ b/tests/test_translate.py @@ -1328,6 +1328,132 @@ def test_diff_wire_payloads_reads_omitted_step_level_keys_as_defaults( } +def _step_variables(payload: dict) -> list[dict]: + steps = _steps_by_type(payload, "initialize_variable") + return [s["action_initialize_variable"]["variable"] for s in steps] + + +def _variable_definitions(payload: dict) -> list[dict]: + """Top-level `variables` and each initialize_variable step's definition.""" + return [*payload["variables"], *_step_variables(payload)] + + +def test_diff_ignores_what_the_server_derives_on_an_item_variable( + loops: dict, +) -> None: + """A spec never names `is_array_item_variable`/`source_array_variable`: + the server sets both from the loop (confirmed live 2026-10-27).""" + assert any(v["is_array_item_variable"] for v in _variable_definitions(loops)) + spec = copy.deepcopy(loops) + for v in _variable_definitions(spec): + del v["is_array_item_variable"], v["source_array_variable"] + assert diff_wire_payloads(loops, spec) == [] + assert diff_wire_payloads(spec, loops) == [] + + +def test_diff_reads_a_null_variable_key_as_absent(loops: dict) -> None: + """A spec can't send null, and an omitted key reads back null.""" + spec = copy.deepcopy(loops) + for v in _variable_definitions(spec): + for k in [k for k, x in v.items() if x is None]: + del v[k] + assert diff_wire_payloads(loops, spec) == [] + + +def _top(payload: dict) -> dict: + return payload + + +def _init_block(payload: dict) -> dict: + return _steps_by_type(payload, "initialize_variable")[0][ + "action_initialize_variable" + ] + + +def _static_list_source(payload: dict) -> dict: + return _init_block(payload)["sources"][0] + + +def _update_source(payload: dict) -> dict: + (step,) = _steps_by_type(payload, "update_variable") + return step["action_update_variable"]["sources"][0] + + +def _step_zero(payload: dict) -> dict: + return payload["steps"][0] + + +def _trigger_zero(payload: dict) -> dict: + return payload["triggers"][0] + + +# (fixture, where the key lives, key, server default, a non-default value) +_SERVER_DEFAULTS = { + "error_notification_email": ("loops", _top, "", "ops@example.com"), + "deduplicate_array": ("loops", _init_block, False, True), + "array_separator": ("loops", _static_list_source, "comma", "semicolon"), + "value_html": ("branching_test", _update_source, "", "

x

"), + "step description": ( + "loops", + _step_zero, + "Initialize Variable", + "arr0 = [2 items]", + ), + "trigger description": ("loops", _trigger_zero, "Manual", "Run by hand"), +} + + +def _defaults_case(name: str) -> tuple[dict, dict, str, object, object]: + fixture, where, default, other = _SERVER_DEFAULTS[name] + key = name.split()[-1] + live = live_to_payload(load_fixture(f"automations/{fixture}.raw.json")) + where(live)[key] = default + return live, copy.deepcopy(live), key, default, other + + +@pytest.mark.parametrize("name", sorted(_SERVER_DEFAULTS)) +def test_diff_reads_an_omitted_key_as_its_server_default(name: str) -> None: + """Omitted on a PUT, each reads back as its default (confirmed live + 2026-10-27), so a spec that leaves it out changes nothing.""" + live, spec, key, _, _ = _defaults_case(name) + del _SERVER_DEFAULTS[name][1](spec)[key] + assert diff_wire_payloads(live, spec) == [] + assert diff_wire_payloads(spec, live) == [] + + +@pytest.mark.parametrize("name", sorted(_SERVER_DEFAULTS)) +@pytest.mark.parametrize("non_default_side", ["live", "spec"]) +def test_diff_still_shows_a_non_default_value(name: str, non_default_side: str) -> None: + """The key is compared as its default, not dropped: a non-default value on + either side diffs against an omitted one.""" + live, spec, key, default, other = _defaults_case(name) + where = _SERVER_DEFAULTS[name][1] + del where(spec)[key] + if non_default_side == "live": + where(live)[key] = other + expected = [(other, default)] + else: + where(spec)[key] = other + expected = [(default, other)] + assert [(e["before"], e["after"]) for e in diff_wire_payloads(live, spec)] == ( + expected + ) + + +@pytest.mark.parametrize("top_level", [False, True]) +def test_diff_still_shows_an_item_variable_change(loops: dict, top_level: bool) -> None: + spec = copy.deepcopy(loops) + item = next( + v + for v in (spec["variables"] if top_level else _step_variables(spec)) + if v["is_array_item_variable"] + ) + item["data_type"] = "uuid" + assert [(e["before"], e["after"]) for e in diff_wire_payloads(loops, spec)] == [ + ("string", "uuid") + ] + + # --------------------------------------------------------------------------- # 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) @@ -1522,8 +1648,7 @@ def _accepted_shapes() -> dict[str, list[dict]]: _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) + shapes["B5-nest-3"] = _nest(3) head, p = _head() shapes["B7-loop-on-yes-branch"] = head + [ _ws("c", "condition", p), @@ -1569,6 +1694,63 @@ def test_loop_shapes_the_server_accepts_validate(name: str) -> None: assert validate_payload(_graph(copy.deepcopy(_ACCEPTED[name]))) == [] +@pytest.mark.parametrize("depth", [4, 6]) +def test_deep_nesting_the_server_accepts_fails_only_the_cli_rule(depth: int) -> None: + """B6 and C1 saved live; Kizen's builder can't hold them, so the CLI refuses.""" + assert _problems(_nest(depth)) == [ + f"loop 'l{d}' is nested {d + 1} levels deep; Kizen's builder allows at " + "most three. The API accepts deeper loops, but the CLI refuses them so " + "the automation stays editable in the UI." + for d in range(3, depth) + ] + + +def _nest_through_conditions(depth: int) -> list[dict]: + """Like `_nest`, but each inner loop sits on the yes lane of a condition + in the outer loop's body.""" + steps, parent = _head(depth) + for d in range(depth): + branch = "" + if d: + steps.append(_ws(f"c{d}", "condition", parent)) + parent, branch = f"c{d}", "yes" + steps.append(_loop(f"l{d}", parent, f"arr{d}", f"arr{d}_item", branch)) + parent = f"l{d}" + steps += [_ws(f"n{d}", "next_or_finish", f"l{d}") for d in reversed(range(depth))] + return steps + + +def test_depth_counts_loops_through_a_condition_lane() -> None: + """A condition between levels is not a level, and doesn't end the walk.""" + assert _problems(_nest_through_conditions(3)) == [] + assert _problems(_nest_through_conditions(4)) == [ + "loop 'l3' is nested 4 levels deep; Kizen's builder allows at most three. " + "The API accepts deeper loops, but the CLI refuses them so the automation " + "stays editable in the UI." + ] + + +def test_one_array_at_two_levels_the_server_accepts_fails_only_the_cli_rule() -> None: + """The API saved one array in an outer and an inner loop, each with its own + item variable (confirmed live 2026-10-27). Kizen's builder refuses it, so + the CLI does too, and with nothing else wrong.""" + steps, p = _head() + steps += [ + _var("vb", "inner_item", p), + _loop("outer", "vb", "arr0", "arr0_item"), + _loop("inner", "outer", "arr0", "inner_item"), + _delay("b1", "inner"), + _ws("n_in", "next_or_finish", "inner"), + _ws("n_out", "next_or_finish", "outer"), + ] + assert _problems(steps) == [ + "loop 'inner' iterates 'arr0', which loop 'outer' around it already " + "iterates; Kizen's builder needs a different array variable at each " + "level. The API accepts it, but the CLI refuses it so the automation " + "stays editable in the UI." + ] + + @pytest.mark.parametrize("fixture", ["loops", "ui_loops"]) def test_captured_loop_graphs_validate(request, fixture: str) -> None: assert validate_payload(request.getfixturevalue(fixture)) == [] From 2ab5d17c7884e061547725945956f5a5225c6a61 Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 11:46:27 -0400 Subject: [PATCH 7/8] Complete loops on steps add, and remove empty loops with their closer Kizen's builder creates a loop's next_or_finish and its item variable together, but 'steps add' refused a loop and sent a one-step edit through a whole-automation spec. It now adds all three: the item variable is always created, named '_item', and the parent's children follow the closer so they still run once, after the loop. Removing a loop needed --cascade, which also deleted every step after it. A loop with an empty body now goes with its closer and the steps after it move up. --- CHANGELOG.md | 3 + scripts/cli-tree-baseline.txt | 17 +- src/kizen_builder/cli/steps.py | 25 +- .../docs/specs/automation-step.md | 26 +- src/kizen_builder/docs/specs/automation.md | 4 +- src/kizen_builder/tools/automations.py | 94 ++++++- src/kizen_builder/tools/steps.py | 61 ++++- tests/test_steps.py | 234 ++++++++++++++++-- 8 files changed, 403 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79efcf7..a4bd992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -263,6 +263,9 @@ called out explicitly under **Changed** or **Removed**. nested more than three deep, or one over the same array as a loop around it, as Kizen's builder does. `kizen docs show automation` has a worked example. +- **`automations steps add` of a `loop` adds its `next_or_finish` and its + item variable, as Kizen's builder does.** `steps remove` of a loop with an + empty body removes its closer with it and keeps the steps after it. - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes diff --git a/scripts/cli-tree-baseline.txt b/scripts/cli-tree-baseline.txt index e4cec08..661d33d 100644 --- a/scripts/cli-tree-baseline.txt +++ b/scripts/cli-tree-baseline.txt @@ -1070,17 +1070,22 @@ │ 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, except │ -│ a next_or_finish or merge_branches, which stays on its loop or group. │ +│ a next_or_finish or merge_branches, which stays on its loop or group. A │ +│ loop comes with its next_or_finish and a new `_item` variable, and │ +│ the parent's children follow the next_or_finish. │ │ remove Splice one step out of the graph (children adopt its parent, go_to │ -│ references retarget). A condition, goal or loop with children needs │ -│ --cascade, and a next_or_finish goes only with its loop. │ +│ references retarget). A condition or goal with children, or a loop with a │ +│ body, needs --cascade. A loop with an empty body goes with its │ +│ next_or_finish, which 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, except a next_or_finish or merge_branches, which stays on its loop or group. + the new step, except a next_or_finish or merge_branches, which stays on its loop or group. A loop + comes with its next_or_finish and a new `_item` variable, and the parent's children follow + the next_or_finish. ╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ │ * api_name Automation api_name. [required] │ @@ -1141,8 +1146,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 or loop with children needs --cascade, and a next_or_finish goes only with its - loop. + condition or goal with children, or a loop with a body, needs --cascade. A loop with an empty body + goes with its next_or_finish, which goes only with its loop. ╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ │ * api_name Automation api_name. [required] │ diff --git a/src/kizen_builder/cli/steps.py b/src/kizen_builder/cli/steps.py index 00ead77..221ca32 100644 --- a/src/kizen_builder/cli/steps.py +++ b/src/kizen_builder/cli/steps.py @@ -243,7 +243,9 @@ def steps_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, except - a next_or_finish or merge_branches, which stays on its loop or group.""" + a next_or_finish or merge_branches, which stays on its loop or group. A + loop comes with its next_or_finish and a new `_item` variable, and + the parent's children follow the next_or_finish.""" if bool(parent) == root: err_console.print("[red]error:[/red] pass exactly one of --parent or --root") raise typer.Exit(code=2) @@ -251,14 +253,16 @@ def steps_add( def mutate(payload: dict[str, Any], raw: dict[str, Any]) -> Any: target_object = (raw.get("custom_object") or {}).get("name") + parent_key, adopt = parent or None, not leaf + if spec.get("step_type") == "loop": + loop, closer, item = auto_tools.build_wire_loop( + spec, payload, target_object + ) + return step_tools.insert_loop( + payload, loop, closer, item, parent_key, branch, adopt + ) wire = auto_tools.build_wire_step(spec, payload, target_object) - return step_tools.insert_step( - payload, - wire, - parent_key=parent or None, - branch=branch, - adopt_children=not leaf, - ) + return step_tools.insert_step(payload, wire, parent_key, branch, adopt) _run_step_patch(api_name, mutate, dry_run, yes, json_out, from_stdin) @@ -275,8 +279,9 @@ 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 or loop with children needs - --cascade, and a next_or_finish goes only with its loop.""" + references retarget). A condition or goal with children, or a loop with a + body, needs --cascade. A loop with an empty body goes with its + next_or_finish, which 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 2cf2c92..21c9783 100644 --- a/src/kizen_builder/docs/specs/automation-step.md +++ b/src/kizen_builder/docs/specs/automation-step.md @@ -99,16 +99,28 @@ Consequences worth knowing: `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`. + step. +- **`add` of a `loop` adds its `next_or_finish` and its item variable too**, as + Kizen's builder does. The spec is `{"step_type": "loop", "step_loop": + {"array_variable": ""}}`, and the array must already be declared by + an `initialize_variable` step; the CLI never creates it. The item variable is + always created, named `_item` (`_item_2`, `_3`… when the name + is taken), with the array's type, in a new `initialize_variable` step after + the other variable steps. A spec that sets `step_loop.item_variable` is + refused. The loop's body starts empty, and the parent's existing children + follow the `next_or_finish`, so they run once, after the loop (`--leaf` + leaves them on the parent). A `next_or_finish` can't be added on its own. + Confirmed live 2026-10-27, with `roundtrip --execute` clean after each add + and remove. - **`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. 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. + whole subtree. So does a loop with body steps, where the subtree is the body + and everything after the loop. A loop with an empty body is removed with its + `next_or_finish`, the steps after it move up, and its item variable step + stays. 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 eacd025..2d7fa5b 100644 --- a/src/kizen_builder/docs/specs/automation.md +++ b/src/kizen_builder/docs/specs/automation.md @@ -138,7 +138,9 @@ sets a step's own severity. Omitted, the step inherits. Confirmed live A loop runs its body once per item of an array variable. Loops are branch group initiators, closed by `next_or_finish` instead of `merge_branches`. A -spec names all six parts; the CLI adds none of them: +spec names all six parts; `create` and `update` add none of them. `steps add` +of a loop adds its closer and item variable (see `kizen docs show +automation-step`): 1. **The array variable:** an `initialize_variable` step with `is_array: true`. 2. **Its item variable:** an `initialize_variable` step with `sources: []`, diff --git a/src/kizen_builder/tools/automations.py b/src/kizen_builder/tools/automations.py index c65cc5f..38235d7 100644 --- a/src/kizen_builder/tools/automations.py +++ b/src/kizen_builder/tools/automations.py @@ -424,25 +424,97 @@ def build_wire_step( Linkage fields are ignored here — placement belongs to insert_step. Any ``id`` on the spec is dropped too: this builds a *new* step, and a spec authored by copying an existing step's ``steps get`` output must not - carry that step's id along and hijack its identity. + carry that step's id along and hijack its identity. A ``loop`` goes + through :func:`build_wire_loop` instead. """ + from kizen_builder.tools.plans import PlanError + + if spec.get("step_type") == "next_or_finish": + raise PlanError( + "a next_or_finish can't be added on its own; 'steps add' of a loop " + "adds the loop's next_or_finish with it." + ) + return _build_wire(spec, payload, target_object) + + +def build_wire_loop( + spec: dict[str, Any], payload: dict[str, Any], target_object: str | None +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]: + """Build a loop, its ``next_or_finish`` and its item variable step, as + Kizen's builder does. The item variable is always created, named + ``_item`` (``_2``, ``_3``… when taken), with the array's type. It is + ``None`` when the payload declares no such array: validation then refuses + the loop. Placement belongs to :func:`steps.insert_loop`. + """ + from kizen_builder.tools.planners.automations import _unwrap_variable_name + from kizen_builder.tools.plans import PlanError + + block = dict(spec.get("step_loop") or {}) + array = _unwrap_variable_name(block.get("array_variable")) + if "item_variable" in block: + raise PlanError( + "step_loop.item_variable can't be set in 'steps add': the item " + f"variable is created automatically, named '{array}_item'. Remove it " + "from the spec." + ) + declared: dict[str, dict[str, Any]] = {} + for s in payload["steps"]: + var = (s.get("action_initialize_variable") or {}).get("variable") + if s["type"] == "initialize_variable" and isinstance(var, dict): + declared.setdefault(var.get("name") or "", var) + item_name, n = f"{array}_item", 2 + while item_name in declared: + item_name, n = f"{array}_item_{n}", n + 1 + + block["item_variable"] = item_name + loop = _build_wire( + { + **spec, + "step_loop": block, + "description": spec.get("description") or f"For each item in '{array}'", + }, + payload, + target_object, + ) + closer = _build_wire( + { + "step_type": "next_or_finish", + "description": "Next item or finish loop and continue", + }, + payload, + target_object, + ) + item = None + if array in declared: + variable = { + "name": item_name, + "data_type": declared[array].get("data_type"), + "is_array": False, + } + if declared[array].get("data_subtype"): + variable["data_subtype"] = declared[array]["data_subtype"] + item = _build_wire( + { + "step_type": "initialize_variable", + "description": "Item Reference Variable", + "action_initialize_variable": {"sources": [], "variable": variable}, + }, + payload, + target_object, + ) + return loop, closer, item + + +def _build_wire( + spec: dict[str, Any], payload: dict[str, Any], target_object: str | None +) -> dict[str, Any]: from kizen_builder.models.spec import AutomationStepDef from kizen_builder.tools.planners.automations import ( 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/steps.py b/src/kizen_builder/tools/steps.py index 1bba449..0bd3c5f 100644 --- a/src/kizen_builder/tools/steps.py +++ b/src/kizen_builder/tools/steps.py @@ -88,7 +88,9 @@ def remove_step( Without cascade: children adopt the removed step's parent (inheriting its branch position), and go_to steps targeting it are retargeted to the same parent. Refuses to splice a condition/goal that still has children - — their yes/no positions have no meaning under a different parent. + — their yes/no positions have no meaning under a different parent — or a + loop with a body. A loop with an empty body goes with its + next_or_finish, whose children take the loop's place. Returns a report: removed keys, (child, new_parent) re-parentings, and retargeted go_to keys. @@ -111,15 +113,19 @@ 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} + if dead["type"] == "loop": + body = [k["key"] for k in kids if k["type"] != "next_or_finish"] + if body: + raise PlanError( + f"'{key}' is a loop with steps under it ({', '.join(body)}); " + "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." + ) + closers = [k["key"] for k in kids] + doomed.update(closers) + kids = [c for k in closers for c in children_of(payload, k)] new_parent = dead.get("parent_key") gotos = _gotos_pointing_at(payload, doomed) @@ -209,6 +215,43 @@ def insert_step( return report +def insert_loop( + payload: dict[str, Any], + loop: dict[str, Any], + closer: dict[str, Any], + item: dict[str, Any] | None, + parent_key: str | None = None, + branch: str = "", + adopt_children: bool = True, +) -> dict[str, Any]: + """Link a built loop, its ``next_or_finish`` and its item variable step. + + The loop goes where :func:`insert_step` puts any step, with an empty body. + With ``adopt_children`` the parent's children follow the closer, so they + run once after the loop, as in Kizen's builder. The item variable step goes + after the last ``initialize_variable``, since variables come first. + """ + report = insert_step(payload, loop, parent_key, branch, adopt_children) + insert_step(payload, closer, loop["key"]) + out: dict[str, Any] = { + "key": loop["key"], + "closer": closer["key"], + "item_variable": None, + "adopted": report["adopted"], + } + if item is not None: + last_var = max( + (s for s in payload["steps"] if s["type"] == "initialize_variable"), + key=lambda s: s.get("order") or 0, + ) + insert_step(payload, item, last_var["key"]) + out["item_variable"] = { + "name": item["action_initialize_variable"]["variable"]["name"], + "key": item["key"], + } + return out + + def edit_step( payload: dict[str, Any], key: str, patch: dict[str, Any] ) -> dict[str, Any]: diff --git a/tests/test_steps.py b/tests/test_steps.py index 713a4aa..a25aaab 100644 --- a/tests/test_steps.py +++ b/tests/test_steps.py @@ -256,9 +256,8 @@ 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 " + "'s10_loop' is a loop with steps under it (s11_delay); 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." ) @@ -300,22 +299,223 @@ def test_steps_add_builds_an_exit_loop(loops: dict) -> None: 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: +# `steps add` of a loop: the loop, its next_or_finish and its item variable. + + +def _loop_spec(array: str) -> dict: + return {"step_type": "loop", "step_loop": {"array_variable": array}} + + +def _chain() -> dict: + """arr (String array of three), seen (String), then stops a → b.""" + from kizen_builder.tools.automations import build_wire_step + + payload: dict = {"name": "Chain", "api_name": "chain", "type": "global"} + payload.update(steps=[], triggers=[]) + specs = [ + { + "key": "arr", + "step_type": "initialize_variable", + "action_initialize_variable": { + "variable": {"name": "arr", "data_type": "string", "is_array": True}, + "sources": [ + { + "source_type": "static", + "source_subtype": "string", + "value": ["x", "y", "z"], + } + ], + "array_aggregation_mode": "add_all", + }, + }, + { + "key": "seen", + "step_type": "initialize_variable", + "action_initialize_variable": { + "variable": {"name": "seen", "data_type": "string"}, + "sources": [], + }, + }, + {"key": "a", "step_type": "stop_execution", "action_stop_execution": {}}, + {"key": "b", "step_type": "stop_execution", "action_stop_execution": {}}, + ] + parent = None + for spec in specs: + insert_step(payload, build_wire_step(spec, payload, None), parent) + parent = spec["key"] + return payload + + +def _add_loop(payload: dict, array: str, parent: str, **placement) -> dict: + from kizen_builder.tools.automations import build_wire_loop + from kizen_builder.tools.steps import insert_loop + + target = "object_with_workflow" if payload["type"] == "record_based" else None + loop, closer, item = build_wire_loop(_loop_spec(array), payload, target) + return insert_loop(payload, loop, closer, item, parent, **placement) + + +def _chain_keys(payload: dict) -> list[str]: + return [s["key"] for s in sorted(payload["steps"], key=lambda s: s["order"])] + + +def _variables_first(payload: dict) -> bool: + types = [s["type"] for s in sorted(payload["steps"], key=lambda s: s["order"])] + n = types.count("initialize_variable") + return set(types[:n]) == {"initialize_variable"} + + +def test_steps_add_loop_mid_chain_follows_with_the_old_children() -> None: + payload = _chain() + report = _add_loop(payload, "arr", "a") + loop, closer = report["key"], report["closer"] + item = report["item_variable"] + assert item["name"] == "arr_item" + assert report["adopted"] == ["b"] + assert _chain_keys(payload) == ["arr", "seen", item["key"], "a", loop, closer, "b"] + assert find_step(payload, "b")["parent_key"] == closer + assert find_step(payload, closer)["parent_key"] == loop + assert find_step(payload, loop)["step_loop"]["item_variable"] == "arr_item" + assert find_step(payload, loop)["description"] == "For each item in 'arr'" + assert find_step(payload, closer)["description"] == ( + "Next item or finish loop and continue" + ) + item_step = find_step(payload, item["key"]) + assert item_step["description"] == "Item Reference Variable" + assert item_step["action_initialize_variable"]["sources"] == [] + variable = item_step["action_initialize_variable"]["variable"] + assert (variable["data_type"], variable["is_array"]) == ("string", False) + assert "data_subtype" not in variable + assert _variables_first(payload) + assert validate_payload(payload) == [] + + +def test_steps_add_loop_under_the_last_variable_step() -> None: + payload = _chain() + report = _add_loop(payload, "arr", "seen") + item, loop = report["item_variable"]["key"], report["key"] + assert report["adopted"] == ["a"] + assert _chain_keys(payload) == [ + "arr", + "seen", + item, + loop, + report["closer"], + "a", + "b", + ] + assert find_step(payload, loop)["parent_key"] == item + assert find_step(payload, item)["parent_key"] == "seen" + assert validate_payload(payload) == [] + + +def test_steps_add_loop_as_a_leaf() -> None: + payload = _chain() + report = _add_loop(payload, "arr", "b", adopt_children=False) + assert report["adopted"] == [] + assert find_step(payload, report["key"])["parent_key"] == "b" + assert children_of(payload, report["closer"]) == [] + assert validate_payload(payload) == [] + + +def test_steps_add_loop_in_a_condition_branch(loops: dict) -> None: + report = _add_loop(loops, "arr1", "s05_condition", branch="yes") + loop = find_step(loops, report["key"]) + assert (loop["parent_key"], loop["parent_yes_no"]) == ("s05_condition", "yes") + assert report["adopted"] == ["s06_delay"] + moved = find_step(loops, "s06_delay") + assert (moved["parent_key"], moved["parent_yes_no"]) == (report["closer"], "") + assert report["item_variable"]["name"] == "arr1_item_2" + assert _variables_first(loops) + assert validate_payload(loops) == [] + + +def test_steps_add_loop_over_records_copies_the_subtype() -> None: + payload = live_to_payload(load_fixture("automations/new_features_loops.raw.json")) + report = _add_loop(payload, "array_var_obj", "s08_file_content_extraction") + assert report["item_variable"]["name"] == "array_var_obj_item_2" + variable = find_step(payload, report["item_variable"]["key"])[ + "action_initialize_variable" + ]["variable"] + assert variable["data_type"] == "entity" + assert variable["data_subtype"] == "8c3f0149-6915-4370-940f-2e61848d6842" + assert find_step(payload, "s09_loop")["parent_key"] == report["closer"] + assert _variables_first(payload) + assert validate_payload(payload) == [] + + +def test_steps_add_loop_refuses_an_item_variable(loops: dict) -> None: + from kizen_builder.tools.automations import build_wire_loop + + before = [dict(s) for s in loops["steps"]] + spec = _loop_spec("arr0") + spec["step_loop"]["item_variable"] = "mine" + with pytest.raises(PlanError) as e: + build_wire_loop(spec, loops, None) + assert str(e.value) == ( + "step_loop.item_variable can't be set in 'steps add': the item variable " + "is created automatically, named 'arr0_item'. Remove it from the spec." + ) + assert loops["steps"] == before + + +def test_steps_add_loop_takes_the_next_free_item_name() -> None: + payload = _chain() + first = _add_loop(payload, "arr", "a") + second = _add_loop(payload, "arr", first["closer"]) + assert first["item_variable"]["name"] == "arr_item" + assert second["item_variable"]["name"] == "arr_item_2" + assert find_step(payload, "b")["parent_key"] == second["closer"] + assert validate_payload(payload) == [] + + +def test_steps_add_loop_over_an_undeclared_array_creates_no_item() -> None: + from kizen_builder.tools.automations import build_wire_loop + + payload = _chain() + *_, item = build_wire_loop(_loop_spec("nope"), payload, None) + assert item is None + report = _add_loop(payload, "nope", "a") + assert report["item_variable"] is None + assert validate_payload(payload) == [ + f"loop '{report['key']}' iterates 'nope', which is not an array " + "variable of records, strings or UUIDs (server 400s otherwise)" + ] + + +def test_steps_add_loop_over_the_outer_loops_array_is_refused(loops: dict) -> None: + report = _add_loop(loops, "arr0", "s04_loop") + assert any( + f"loop '{report['key']}' iterates 'arr0', which loop 's04_loop' around it" in p + for p in validate_payload(loops) + ) + + +def test_steps_add_refuses_a_lone_next_or_finish(loops: dict) -> 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") + build_wire_step({"step_type": "next_or_finish"}, 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'." + "a next_or_finish can't be added on its own; 'steps add' of a loop adds " + "the loop's next_or_finish with it." ) + + +def test_remove_empty_loop_takes_its_closer() -> None: + payload = _chain() + report = _add_loop(payload, "arr", "a") + item = report["item_variable"]["key"] + removed = remove_step(payload, report["key"]) + assert removed["removed"] == sorted([report["key"], report["closer"]]) + assert removed["reparented"] == [("b", "a")] + assert _chain_keys(payload) == ["arr", "seen", item, "a", "b"] + assert validate_payload(payload) == [] + + +def test_remove_empty_loop_in_a_branch_keeps_the_branch(loops: dict) -> None: + report = _add_loop(loops, "arr1", "s05_condition", branch="yes") + remove_step(loops, report["key"]) + moved = find_step(loops, "s06_delay") + assert (moved["parent_key"], moved["parent_yes_no"]) == ("s05_condition", "yes") + assert validate_payload(loops) == [] From 4d330418e704d4bf095913404eb47af2e71fd21b Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 14:37:38 -0400 Subject: [PATCH 8/8] Release 0.4.0 --- CHANGELOG.md | 5 ++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed933f8..292242a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ called out explicitly under **Changed** or **Removed**. ## [Unreleased] +## [0.4.0] — 2026-10-27 + ### Added - **Automation specs can author loops.** A spec names the array variable, @@ -1361,6 +1363,7 @@ who isn't its author. The tool's history before versioning is recorded in [ROADMAP.md](ROADMAP.md) under "Shipped before 0.2.0". -[Unreleased]: https://github.com/kizen/kizen-admin-cli/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/kizen/kizen-admin-cli/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/kizen/kizen-admin-cli/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/kizen/kizen-admin-cli/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/kizen/kizen-admin-cli/releases/tag/v0.2.0 diff --git a/pyproject.toml b/pyproject.toml index bfecae9..595bd62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "kizen-builder" # SemVer, static, and the single source of truth. Bumping it is a release # decision — see docs/RELEASING.md. `kizen --version` reads *installed* # metadata, so a bump only shows after a re-sync or reinstall. -version = "0.3.0" +version = "0.4.0" description = "Command-line tool for designing and iterating on a Kizen environment via the Kizen REST API." readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 1390c6a..58815cc 100644 --- a/uv.lock +++ b/uv.lock @@ -332,7 +332,7 @@ wheels = [ [[package]] name = "kizen-builder" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "httpx" },