Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@ called out explicitly under **Changed** or **Removed**.
`tests/fixtures/examples/service_ticket/`, checked two ways: an offline
test that fails if the doc and the fixtures ever diverge, and an opt-in
drift test that applies the same fixtures live.
- **`kizen automations diff <api_name> --spec-file <path>`** (stdin also
accepted) previews what `automations update` from that spec would actually
change on the live automation — trigger/step additions, removals,
reparenting, and config-field changes — without writing anything. Steps and
triggers are matched by `id` first, position as a fallback for a spec with
no `id`s at all; `key`/`parent_key`/`prefix` are excluded from the
comparison since they're per-side synthetic naming, not automation content,
so an unchanged spec produces an empty diff instead of showing every step's
resynthesized `key` as "changed." Each diff line is labelled with the first
octet of the step/trigger's `id` (matching what's visible in the UI), which
is unique within a single automation; under `--json`, an addition or removal
also carries the whole step/trigger including its full `id`.
`kizen automations get`'s Steps table also
gains an `id` column (first octet) and shortens `parent` to match, so the
two can be read against each other without `--json`.
- **The package declares its license.** `kizen-builder` is MIT-licensed, and the
built wheel and sdist now carry `License-Expression: MIT` along with a copy of
`LICENSE`.
Expand Down
33 changes: 33 additions & 0 deletions scripts/cli-tree-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,8 @@
│ business_plugin_app_id (the value `call_llm.business_plugin_app_id` needs │
│ for any non-`kizen/*` model_name — see automation.md). │
│ roundtrip Verify GET→PUT translation fidelity for one automation. │
│ diff Show what `automations update` from this spec would change on the │
│ live automation — read-only, no write is made. │
│ show Render the automation as a step tree with stable step keys. │
│ start Trigger an automation, optionally on a record and seeding variables. │
│ modification-history Who changed this automation, when, and what changed. │
Expand Down Expand Up @@ -520,6 +522,34 @@
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

Usage: automations diff [OPTIONS] {api_name}

Show what `automations update` from this spec would change on the live automation — read-only, no
write is made.

Triggers/steps are matched by `id` first (regardless of `key`/order),
position among the rest as a fallback for a spec with no `id`s at all.
`key`/`parent_key`/`prefix` are excluded from the comparison — they're
per-side synthetic naming (see `kizen docs show automation`), not
automation content — but a genuine reparenting still shows, compared by
matched identity rather than raw key. Each line is labelled with the
first octet of the step/trigger's `id` so it can be matched to what's
visible in the UI; it is unique within one automation. Under `--json`, an
added or removed step/trigger also carries its full `id` in the leaf
value.

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮
│ * api_name <str> Automation api_name. [required] │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮
│ --spec-file <str> Path to JSON AutomationDef. Default: read from stdin. │
│ --json Emit full result as JSON. │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

Spec shape (an AutomationDef: triggers + step graph): see `kizen docs show automation`


Usage: automations duplicate [OPTIONS] {api_name}

Duplicate one automation. The copy is named/numbered by the server.
Expand Down Expand Up @@ -4069,6 +4099,9 @@
===== automations delete =====


===== automations diff =====


===== automations duplicate =====


Expand Down
74 changes: 72 additions & 2 deletions src/kizen_builder/cli/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,23 @@
from typing import Any

import typer
from pydantic import ValidationError
from rich.table import Table

from kizen_builder import output as out
from kizen_builder.api.client import KizenAPIError
from kizen_builder.cli._mutations import _read_spec
from kizen_builder.cli._shared import (
JSON_OPTION,
OUTPUT_OPTION,
app,
cli_errors,
console,
err_console,
)
from kizen_builder.tools import automations as auto_tools
from kizen_builder.tools import steps as step_tools
from kizen_builder.tools.planners import automations as auto_planners
from kizen_builder.tools.plans import PlanError

autos_app = typer.Typer(
Expand Down Expand Up @@ -122,16 +126,18 @@ def table() -> None:

step_table = Table(title="Steps")
step_table.add_column("ord", justify="right")
step_table.add_column("id", style="dim")
step_table.add_column("type")
step_table.add_column("description")
step_table.add_column("parent")
step_table.add_column("parent", style="dim")
step_table.add_column("branch")
for s in result["steps"]:
step_table.add_row(
str(s["order"]) if s["order"] is not None else "",
(s["id"] or "")[:8],
s["step_type"] or "",
s["description"] or "",
(s["parent_step_id"] or ""),
(s["parent_step_id"] or "")[:8],
s["parent_condition"] or "",
)
console.print(step_table)
Expand Down Expand Up @@ -274,6 +280,70 @@ def autos_roundtrip(
raise typer.Exit(code=1)


@autos_app.command(
"diff",
epilog="Spec shape (an AutomationDef: triggers + step graph): see `kizen docs show automation`",
)
def autos_diff(
api_name: str = typer.Argument(..., help="Automation api_name."),
spec_file: str = typer.Option(
"",
"--spec-file",
help="Path to JSON AutomationDef. Default: read from stdin.",
),
json_out: bool = typer.Option(False, "--json", help="Emit full result as JSON."),
) -> None:
"""Show what `automations update` from this spec would change on the
live automation — read-only, no write is made.

Triggers/steps are matched by `id` first (regardless of `key`/order),
position among the rest as a fallback for a spec with no `id`s at all.
`key`/`parent_key`/`prefix` are excluded from the comparison — they're
per-side synthetic naming (see `kizen docs show automation`), not
automation content — but a genuine reparenting still shows, compared by
matched identity rather than raw key. Each line is labelled with the
first octet of the step/trigger's `id` so it can be matched to what's
visible in the UI; it is unique within one automation. Under `--json`, an
added or removed step/trigger also carries its full `id` in the leaf
value.
"""
spec_dict, _from_stdin = _read_spec(spec_file)
spec_api_name = spec_dict.get("api_name")
if spec_api_name and spec_api_name != api_name:
err_console.print(
f"[red]error:[/red] spec api_name '{spec_api_name}' does not match "
f"'{api_name}' — diffing against the wrong automation. Pass the "
"spec's own api_name, or fix --spec-file."
)
raise typer.Exit(code=2)
try:
with cli_errors(LookupError, PlanError):
result = auto_planners.diff_automation(spec_dict)
except ValidationError as e:
for err in e.errors():
loc = ".".join(str(p) for p in err.get("loc", ())) or "spec"
msg = err.get("msg", "invalid value")
err_console.print(f"[red]spec error:[/red] {loc}: {msg}")
raise typer.Exit(code=1) from e

if json_out:
typer.echo(json.dumps(result, indent=2))
return

console.print(
f"[bold]{result['api_name']}[/bold] [dim](rev {result['revision']})[/dim]"
)
diff = result["diff"]
if not diff:
console.print("[green]no changes[/green] — update would be a no-op")
return
console.print(f"[yellow]{len(diff)} change(s):[/yellow]")
for d in diff:
console.print(
f" [yellow]{d['path']}[/yellow]: {d['before']!r} → {d['after']!r}"
)


def _step_label(step: dict[str, Any]) -> str:
desc = (step.get("description") or step.get("user_description") or "").strip()
if len(desc) > 72:
Expand Down
1 change: 1 addition & 0 deletions src/kizen_builder/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ kizen automations get <api_name> # one automation incl. triggers + s
kizen automations show <api_name> # step tree with synthesized step keys (handles for steps verbs)
kizen automations steps get <api> <key> # one step's wire JSON (starting point for steps edit)
kizen automations roundtrip <api_name> # translate + graph-validate (add --execute to PUT + drift-check)
kizen automations diff <api_name> --spec-file <path> # preview what `update` from this spec would change — read-only
kizen automations llm-models # live model_name + business_plugin_app_id catalog (see kizen docs show automation)
kizen automations runs list <api_name> # recent runs for an automation
kizen automations runs view <exec_uuid> # one run: summary + step-by-step trace (per-step status/duration)
Expand Down
23 changes: 23 additions & 0 deletions src/kizen_builder/docs/specs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,29 @@ Why a translator is mandatory rather than nice-to-have:
Also: **automation updates need PUT, not PATCH.** PATCH refuses step/trigger
changes, and PUT requires the current `revision` as `last_revision`.

### Previewing an update before you send it

`kizen automations diff <api_name> --spec-file <path>` (stdin also accepted)
shows what `automations update` from that spec would actually change on the
live automation — trigger/step additions, removals, reparenting, and
config-field changes — without writing anything. It normalizes both sides
into the wire (PUT) dialect above and matches steps/triggers by `id` first,
falling back to position for a spec with no `id`s at all (see "A
step/trigger's `id` field controls..." above for what setting `id` does).

Because `key` is synthesized fresh from live order on one side and
hand-authored on the other, a literal field comparison would show every
step's `key`/`parent_key` as "changed" even when nothing actually changed.
`diff` excludes `key`, `parent_key`, and `prefix` from the comparison — they
are per-side synthetic naming, not automation content — but still catches a
genuine reparenting by comparing each step's parent by matched identity, not
by the raw key string. Each diff line is labelled with the first octet of
the step/trigger's `id` (e.g. `76af48bd`) so it can be matched against the
same value visible in the UI, and it is unique within one automation. A
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.

### Read→write transforms

Each of these was discovered via a live 400, 500, or silent data loss:
Expand Down
59 changes: 59 additions & 0 deletions src/kizen_builder/tools/planners/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,65 @@ def plan_update_automation(automation: dict[str, Any] | AutomationDef) -> Plan:
)


def diff_automation(automation: dict[str, Any] | AutomationDef) -> dict[str, Any]:
"""Compare a spec's would-be `update` against the live automation,
without writing anything — the read-only counterpart to
`plan_update_automation`.

Fetches the live automation exactly once and derives both comparison
sides from that single `current`: the live side via
`translate.live_to_payload`, the spec side via the same
`_build_automation_payload` + `_merge_server_state` calls
`plan_update_automation` itself makes (not by calling
`plan_update_automation`/building a `Plan` a second time, which would
re-fetch `current` and admit a race). This also guarantees
`last_revision` matches on both sides by construction. Mirrors
`plan_update_automation`'s `active` resolution (an omitted `active` in
the spec means "leave live's value alone," not "set False") so `active`
only shows a diff when the spec genuinely asks for a flip.

See `translate.diff_wire_payloads` for the id-first/position-fallback
step and trigger matching, and the `key`/`parent_key`/`prefix`
exclusions that keep synthesized-key churn out of the result.
"""
from kizen_builder.translate import diff_wire_payloads, live_to_payload

auto = (
automation
if isinstance(automation, AutomationDef)
else AutomationDef.model_validate(automation)
)
ctx = LiveContext()
env = ctx.env

existing = next(
(a for a in list_automations() if a["api_name"] == auto.api_name),
None,
)
if existing is None:
raise PlanError(
f"no automation with api_name '{auto.api_name}'. "
"Use plan_create_automation."
)

current = get_automation(auto.api_name)["raw"]

live_active = bool(current.get("active", False))
resolved_active = auto.active if auto.active is not None else live_active
auto = auto.model_copy(update={"active": resolved_active})

live_payload = live_to_payload(current)
spec_payload = _merge_server_state(_build_automation_payload(auto, ctx), current)

return {
"env": env,
"api_name": auto.api_name,
"id": existing["id"],
"revision": current.get("revision"),
"diff": diff_wire_payloads(live_payload, spec_payload),
}


def _merge_server_state(
payload: dict[str, Any], current: dict[str, Any]
) -> dict[str, Any]:
Expand Down
Loading