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
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,26 @@ kbagent sync push --project ALIAS [--all-projects] [--dry-run] [--force] [--allo
# what push would delete. A config/row deleted on the remote since the last pull diffs as remote_deleted and
# is never re-created (it lands in skipped). Version gate in gotchas.md.
# sync push workspace delete (CLI-25): in a `syncWorkspaces` tree, a `push --force` that deletes a shared SQL workspace also deletes its SQL editor sessions (every user's, push branch) and their backend workspaces, which `config restore` does not bring back; `push --dry-run --force` lists them (warnings[] workspace_sessions), a plain push lists the workspace under skipped_deletions and touches no session. `--force` is destructive-class (FLAG_ESCALATIONS `sync.push --force`), so `--deny-destructive` / a cli:destructive deny blocks it while a plain push stays write-class. Version gate for this entry lives in gotchas.md.
# Partial failures change the exit code (#745): commands that collect per-item failures
# and keep going exit 1 (was 0) when at least one item failed:
# `sync push`, `sync push/pull/diff --all-projects`, `sync clone` (also `bucket_errors`),
# `org setup`, `project refresh`, `project invite --from-csv`, `workspace gc`,
# `semantic-layer import/promote/build/edit metric`, `storage describe-batch --json`,
# `flow schedule-remove` (new `errors[]` key, always present, empty when nothing
# failed; a partial failure fills it and exits 1; when every schedule delete
# fails it still raises `SCHEDULE_DELETE_FAILED`). Only `sync push`, `sync clone`,
# `storage describe-batch`, `storage describe-migrate` and `flow schedule-remove` print
# a `Failed:` headline instead of a green `Success:`; the others list the failed items
# in a table or in summary lines, and the `--all-projects` variants state the count in
# their summary line. `sync push --all-projects` counts a project
# whose push returned `errors[]` in `summary.failed`. The --json payload is emitted
# BEFORE the exit; the items that succeeded stay written. A `--dry-run` of these
# exits 1 when it reports a failed item, like the real run. `sync diff --all-projects`
# exits 1 when a project failed. Other read-only fan-outs (`billing credits`, `job list`,
# `schedule list`, ...) still exit 0. The helper is `item_failure_exit_code()` in
# commands/_helpers.py (returns the code, the caller raises). Version gate for this
# entry lives in gotchas.md -- the placeholder cannot be written on these `# ` comment
# lines (check_version_gates.py parses them as ATX markdown headings).
# sync push (since 0.91.0, #686): the manifest baseline `pull_config_hash` is stamped from the API
# response (or a read-back), never from disk -- push-deployed multi-statement SQL transformations
# (and anything disabled in the UI whose local YAML lacks `is_disabled`) no longer show permanent
Expand Down
13 changes: 13 additions & 0 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,19 @@ its absence is NOT a promise the entry is version-independent (see §1 Rule 6).
projects succeed. Branch on the code, never on the message. The fan-out
readers work on a session now, so a per-project `AUTH_NOT_SUPPORTED_ON_STACK`
comes only from one of the three static-only features.
- **Exit 1 with a full `--json` payload = a partial failure (vNEXT+, #745)**:
a write that keeps going after one item fails (`sync push`, `sync clone`,
`sync push/pull/diff --all-projects`, `org setup`, `project refresh`,
`project invite --from-csv`, `workspace gc`, `semantic-layer
import/promote/build`, `flow schedule-remove`, ...) exits 1 when any item
failed. The items that succeeded ARE written: read `errors[]` /
`projects_failed` / `projects_refresh_failed` / `failed` / `summary.failed`
and retry only the failed
items, never the whole command blind. A `--dry-run` of these exits 1 when it
reports a failed item, like the real run. Other read-only fan-outs
(`billing credits`, `job list`, ...) still exit 0.
On 0.97.0 and older these writes exit 0 even when items failed: check the
same keys.
- `project refresh` / `org setup --refresh` SKIP session projects (`--force`
does not override). A refresh TIMEOUT is exit 4 (network) -- re-run, do not
re-login.
Expand Down
30 changes: 15 additions & 15 deletions plugins/kbagent/skills/kbagent/references/commands-reference.md

Large diffs are not rendered by default.

90 changes: 86 additions & 4 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -2597,8 +2597,9 @@ config, the retry fires, and the retry destroys it for good.
The `rows[]` array in the result is in completion order, not CSV order.
- Per-row parsing of `failed_rows` should match by `email`, not by index.
- A failed row never aborts the run -- the executor accumulates results and
the command exits 0 with `failed > 0` reflected in the JSON summary. Mirror
the `org setup` partial-success exit semantics.
reports `failed > 0` in the JSON summary. *(since vNEXT)* The command then
exits 1, like `org setup`; on 0.97.0 and older it exited 0 even then, so
check `failed` there. The full JSON payload is emitted before the exit.

## `default_bucket` is per-config and only an output prefix

Expand Down Expand Up @@ -3541,7 +3542,8 @@ write descriptive metadata onto storage objects. Three behaviors are easy to mis
collected into `result.errors[]` but the batch keeps processing the remaining
items. The CLI exits non-zero only if `error_count > 0`, so in scripts always
inspect `errors[]` (or at least `error_count`) rather than relying solely on
the exit code — and when consuming `--json` output, never trust a zero-exit
the exit code. *(since vNEXT)* `--json` also exits 1 when `error_count > 0`;
on 0.97.0 and older it exited 0 even then, so there never trust a zero-exit
as "everything applied." That tolerance covers **API** failures only: a
`--from-file` whose shape is wrong (a `tables:` / `buckets:` / `columns:`
section that is a list instead of a mapping of ID to description, a column
Expand Down Expand Up @@ -5809,7 +5811,8 @@ examples. Surprises worth knowing before you touch this:
`promote` take `--scope` / `--target-project` to override the inherited
scope, like `add <kind>`: a project-admin token that is not an org admin
passes `--scope project` under an `organization` model, or every new item
fails with a 403 (listed under `failed`, exit 0).
fails with a 403 (listed under `failed`; since vNEXT the command exits 1,
on 0.97.0 and older it exited 0).
- **Item names are unique per project, not per model (since vNEXT, message).**
The metastore keeps names unique per object type across ALL models of a
project (and across the organization at `organization` scope). A second
Expand Down Expand Up @@ -5873,3 +5876,82 @@ examples. Surprises worth knowing before you touch this:
endpoint -- each item needs its own `request-create` + `set --scope
organization` call. Never loop this over a whole project's objects without
the user having named which ones should become org-wide.

## A partial failure is no longer reported as success

*(since vNEXT, closes #745)* A command that keeps going after one item fails
used to print a green `Success:` line and exit **0**, with the failures only as
warnings below it. A `sync clone` where every config failed reported
`Success ... 0 created` and exit 0. A script could not tell a clean run from a
total failure by the exit code.

Now, when at least one item failed:

- the exit code is **1** (the convention `storage delete-table`,
`storage file-tag` and `storage describe-migrate` already used);
- commands that used to print a green `Success:` headline (`sync push`,
`sync clone`, `storage describe-batch`, `storage describe-migrate`,
`flow schedule-remove`) now print `Failed:` and the failed count instead, for
example `Failed: Pushed: 3 created, 0 updated, 0 deleted, 1 failed`. The
other commands keep their output: `org setup`, `project refresh`,
`project invite --from-csv`, `workspace gc` and the `semantic-layer`
commands list the failed items in a table or in summary lines, and the
`--all-projects` sync variants state the failed count in their summary line;
- `--json` output keeps its keys and is emitted before the exit. New keys:
`org setup --refresh` lists failed token refreshes under
`projects_refresh_failed`, and `flow schedule-remove` returns `errors[]`. Parse the
payload, then check the exit code. Exit 1 here does not mean that nothing
was written: the items that succeeded stay written, so read the payload
before you run the command again.

Commands whose exit code changes from 0 to 1 (the key that holds the failures):

- `sync push` (`errors[]`);
- `sync push --all-projects` (`summary.failed`). `summary.failed` now also
counts a project whose push returned a non-empty `errors[]`; before, that
project counted in `summary.success` and its line read `OK`. Its line is now
marked `x` and states the failed count;
- `sync pull --all-projects` (`summary.failed`);
- `sync clone` (`errors[]` and `bucket_errors[]`);
- `org setup` (`projects_failed`, and with `--refresh` also
`projects_refresh_failed`: entries keyed by `alias`, fix them with
`project refresh`);
- `project refresh` (`projects_failed`);
- `project invite --from-csv` (`failed`);
- `workspace gc` (`errors[]`: a workspace that was not deleted, or a project
that could not be listed);
- `semantic-layer import` / `semantic-layer promote` (`failed[]` of each type);
- `semantic-layer build` (`fetch_errors[]`: a table left out of the model
because its schema could not be read; human mode now lists these tables);
- `semantic-layer edit metric --new-name` (a `cascaded_constraints[]` entry
with `status: "failed"`);
- `storage describe-batch --json` (`errors[]`; human mode already exited 1);
- `flow schedule-remove` (`errors[]`, a new key: a schedule whose delete
failed while other schedules were deleted. Before, that failure was dropped
and the command printed `Success: Removed N schedule(s)`). The result always
has the `errors` key, empty when nothing failed. A partial failure fills it
and the command exits 1. When every schedule delete fails, the command
still raises the error (`SCHEDULE_DELETE_FAILED`) and prints no result
payload, as before.

`storage describe-migrate` keeps its exit code; its human headline now starts
with `Failed:` too.

A `--dry-run` of every command above exits 1 when it reports a failed item,
like the real run. `sync diff --all-projects` also exits 1 when a project
failed (`summary.failed > 0`); a read that fails is still a failure.

Not failures (exit 0, as before):

- a clean run and a `no_changes` result;
- other read-only fan-outs, where one unreachable project must not fail the
whole read: `billing credits`, `job list`, `schedule list`, `notification list`, `config list`,
`storage tables` and the other multi-project reads (check `errors[]`);
- one item with a documented partial outcome: `flow schedule`
(`activated: false`), `notification replace-recipient`
(`old_deleted: false`);
- `semantic-layer build` `type_resolution_errors[]`: the table stays in the
model, and its columns without a type are classified as dimensions.

On 0.97.0 and older, exit 0 from these commands does not prove success. Parse
`--json` and check the keys above before you report the operation as done.
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ Result schema:
```

The `rows[]` array is in **completion order**, not CSV order (parallel
workers). Match by `email`, not by index. Partial-success exits 0 with
`failed > 0` reflected in the JSON; this mirrors `org setup`.
workers). Match by `email`, not by index. A run with `failed > 0` exits 1
*(since vNEXT)*, like `org setup`; the JSON is emitted first. On 0.97.0 and
older it exited 0, so check `failed` there.

`--dry-run` resolves every row and reports what *would* happen without
sending invitations. Use it before any large CSV.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ updated independently (an in-place PUT, no rollback needed).
If the metric rename succeeds but M of N dependent constraints fail
to repoint, the envelope sets `partial_state: true` at the top level
and a `recovery_hint` string. Human-mode CLI prints a bright red
`PARTIAL STATE` banner. Use the recovery recipe:
`PARTIAL STATE` banner, and *(since vNEXT)* the command exits 1. Use the
recovery recipe:

```bash
# 1. Diagnose: surface every dangling constraint reference
Expand Down
2 changes: 2 additions & 0 deletions plugins/kbagent/skills/kbagent/references/sync-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ kbagent sync push --all-projects # apply

Each project gets its own subdirectory (named by alias). Projects are processed in parallel.

A project that fails does not stop the others. *(since vNEXT)* `sync pull --all-projects` and `sync push --all-projects` then exit 1, and push counts a project whose push returned per-config `errors[]` as failed (its line is marked `x`, not `OK`). `sync diff --all-projects` exits 1 too when a project failed (`summary.failed`). A `--dry-run` exits 1 when it reports a failed project, like the real run.

## Reconciling a drifted tree with production (since v0.72.0)

When production was edited directly (by people or other tooling) and the local
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,8 @@ Behavior:
- **`workspace gc`** deletes each orphaned workspace one by one. Per-workspace
failures accumulate into `errors[]` without stopping the batch -- one
locked sandbox does not prevent the rest from being cleaned up.
*(since vNEXT)* A run with a non-empty `errors[]` (a failed delete, or a
project that could not be listed) exits 1, also under `--dry-run`.
- **`--dry-run`** surfaces the would-be-deleted list via `data.would_delete[]`
in JSON mode and a Rich table in human mode.
- Multi-project: `workspace list --orphaned` / `workspace gc` accept
Expand Down
22 changes: 22 additions & 0 deletions src/keboola_agent_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,28 @@ def emit_project_warnings(formatter: OutputFormatter, result: dict) -> None:
formatter.warning(f"Project '{alias}': {message}")


def item_failure_exit_code(failed: int) -> int | None:
"""Map the number of failed items of a multi-item operation to a CLI exit code.

Commands that keep going after one item fails (a config in ``sync push``,
a project in ``org setup``, a row in ``project invite --from-csv``) used to
collect those failures into the result and still exit 0 -- so a run where
every item failed was indistinguishable from a clean one by exit code
alone, and a script could not tell success from total failure (issue #745).

Returns ``None`` when ``failed`` is 0, else 1: the convention already used
by the bulk storage commands (``storage delete-table``, ``file-tag``,
``describe-migrate``). Like :func:`map_error_to_exit_code` it only maps;
the caller raises ``typer.Exit`` AFTER the result has been emitted, so
JSON callers still receive the full payload with the per-item errors. A
``--dry-run`` that reports a failed item exits 1 like the real run.
"""
if not failed:
return None
telemetry.note_command_error(f"{failed} item(s) failed")
return 1


def _is_help_request(ctx: typer.Context) -> bool:
"""Check if the current invocation is a --help request.

Expand Down
5 changes: 5 additions & 0 deletions src/keboola_agent_cli/commands/_semantic_layer_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
check_cli_permission,
get_formatter,
get_service,
item_failure_exit_code,
)
from ._semantic_layer_helpers import (
ScopeChoice,
Expand Down Expand Up @@ -409,6 +410,10 @@ def edit_metric(
confirm_cb=typer.confirm,
)
formatter.output(result, _print_edit_result("metric"))
# A constraint the rename could not repoint is a failed cascade item (#745).
cascaded = result.get("cascaded_constraints") or []
if code := item_failure_exit_code(sum(1 for c in cascaded if c.get("status") == "failed")):
raise typer.Exit(code=code)


@edit_app.command("dataset")
Expand Down
24 changes: 18 additions & 6 deletions src/keboola_agent_cli/commands/_storage_describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,9 +406,13 @@ def _on_item(obj_type: str, obj_id: str, current: int, total: int) -> None:
else:
applied = result["applied_count"]
errors = result["error_count"]
formatter.console.print(
f"[bold green]Batch complete:[/bold green] {applied} applied, {errors} error(s)"
# Never a green headline when items failed (#745).
label = (
"[bold red]Failed:[/bold red]"
if errors
else "[bold green]Batch complete:[/bold green]"
)
formatter.console.print(f"{label} {applied} applied, {errors} error(s)")
for item in result["applied"]:
obj_type = item["type"]
obj_id = item["id"]
Expand All @@ -421,8 +425,9 @@ def _on_item(obj_type: str, obj_id: str, current: int, total: int) -> None:
formatter.console.print(
f" [red]✗[/red] {item['type']} {item['id']}: {item['error']}"
)
if errors:
raise typer.Exit(code=1) from None
# Also under --json: the exit used to be raised in human mode only (#745).
if result.get("errors"):
raise typer.Exit(code=1) from None

@app.command("describe-migrate", rich_help_panel=_DESCRIBE)
def storage_describe_migrate(
Expand Down Expand Up @@ -533,9 +538,16 @@ def _migrate(scan_only: bool) -> dict[str, Any]:
formatter.output(result)
else:
verb = "Would migrate" if result["dry_run"] else "Migrated"
# Never a green headline when tables failed (#745).
failed = len(result["errors"])
label = (
f"[bold red]Failed:[/bold red] {verb}"
if failed
else f"[bold green]{verb}:[/bold green]"
)
formatter.console.print(
f"[bold green]{verb}:[/bold green] {len(result['migrated'])} table(s) of "
f"{result['tables_scanned']} scanned"
f"{label} {len(result['migrated'])} table(s) of "
f"{result['tables_scanned']} scanned" + (f", {failed} failed" if failed else "")
)
for item in result["migrated"]:
cols = ", ".join(sorted(item["columns"]))
Expand Down
25 changes: 19 additions & 6 deletions src/keboola_agent_cli/commands/_sync_clone_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,32 @@ def _format_clone_result(formatter: Any, result: dict[str, Any]) -> None:
f"[cyan]{result.get('target_alias')}[/cyan]."
)
return
errors = result.get("errors", [])
# A bucket error is a failed item too: the count matches the error lines printed below.
failed = len(errors) + len(result.get("bucket_errors", []))
if status == "no_changes":
formatter.console.print(
f"[green]Already cloned[/green] -- no changes to push into "
f"[cyan]{result.get('target_alias')}[/cyan]."
)
if failed:
formatter.console.print(
f"[bold red]Failed:[/bold red] Already cloned into "
f"[cyan]{result.get('target_alias')}[/cyan], {failed} failed"
)
else:
formatter.console.print(
f"[green]Already cloned[/green] -- no changes to push into "
f"[cyan]{result.get('target_alias')}[/cyan]."
)
_print_clone_buckets(formatter, result)
return
formatter.success(
headline = (
f"Cloned into {result.get('target_alias')}: {result.get('created', 0)} created "
f"({overrides}, flow_task_remaps={result.get('flow_task_remaps', 0)})"
)
if failed:
formatter.console.print(f"[bold red]Failed:[/bold red] {headline}, {failed} failed")
else:
formatter.success(headline)
_print_clone_buckets(formatter, result)
for err in result.get("errors", []):
for err in errors:
formatter.warning(
f" Error: {err.get('change_type')} "
f"{err.get('component_id')}/{err.get('config_id')}: {err.get('message')}"
Expand Down
Loading
Loading