diff --git a/CLAUDE.md b/CLAUDE.md index 576c29fe..486c6f77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 8969f24b..affcccb8 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -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. diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 6a7c3eba..94b9b482 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -80,7 +80,7 @@ end-to-end walkthrough and troubleshooting. - `project remove --project NAME` -- disconnect a project - `project edit --project NAME [--url URL] [--token TOKEN] [--new-alias NEW] [--dry-run]` -- update connection details and/or rename the alias. `--new-alias` cascades through config.json (`projects` key + `default_project` if matched) and the nested sync directory `//` when present (-2 collision suffix, git-mv with shutil fallback). Lineage cache rebuild is manual (see gotchas, since v0.31.0). Combined with `--url` / `--token` in one call, those mutations target the new alias post-rename. `--dry-run` previews everything (collision check, planned disk-rename method, lineage-cache warning) without mutating state -- same exit codes as live for validation errors. `--token` on a browser-login (session) project is allowed and converts it to a static-token project, but warns that `auth logout --remove-projects` will no longer clean that alias up (`project remove` does); `--json` carries the text in an additive top-level `warnings` array, and `--dry-run` previews the identical warning (since v0.80.0) - `project status [--project NAME]` -- test connectivity and response time. Same guaranteed `auth_mode` field as `project list`, with the `Auth` column right after `Status` and present on ERROR rows too (since v0.80.0) -- `project refresh --project ALIAS | --all [--dry-run] [--force] [--yes] [--token-description DESC] [--token-expires-in N]` -- mint replacement Storage tokens via the Manage API for projects whose token is expired or invalid. `--force` also replaces still-valid non-expiring tokens. Browser-login (session) projects land in `skipped` with the reason that there is no static token to replace -- their credential lives in `auth.json` and rotates on its own -- and `--force` does not convert them either; use `project edit --token` for a deliberate single-project conversion (since v0.80.0) +- `project refresh --project ALIAS | --all [--dry-run] [--force] [--yes] [--token-description DESC] [--token-expires-in N]` -- mint replacement Storage tokens via the Manage API for projects whose token is expired or invalid. `--force` also replaces still-valid non-expiring tokens. Browser-login (session) projects land in `skipped` with the reason that there is no static token to replace -- their credential lives in `auth.json` and rotates on its own -- and `--force` does not convert them either; use `project edit --token` for a deliberate single-project conversion (since v0.80.0). **Since vNEXT (#745)** a non-empty `projects_failed` exits **1** (was 0); `--dry-run` exits **1** on a failure too - `project description-get --project NAME` -- read the dashboard project description (KBC.projectDescription on the default branch). Returns `{"description": ""}` if not set, not an error - `project description-set --project NAME [--text STR | --file PATH | --stdin]` -- set the dashboard project description (markdown). Pass exactly one of `--text`, `--file`, or `--stdin`. Writes to `KBC.projectDescription` on the default branch -- always the main branch, regardless of any active dev branch - `project use ALIAS` -- pin `ALIAS` as the persistent default project. Stored as `default_project` in config.json. A registered project ID pins that project's alias *(since 0.96.1)*. Overridden at runtime by `KBAGENT_PROJECT=ALIAS` (env, beats pin) and by `--project ALIAS` (CLI flag, beats both) @@ -92,7 +92,7 @@ end-to-end walkthrough and troubleshooting. All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the project's Storage token. Allowed roles are exactly `admin`, `guest`, `readOnly`, `share` -- the API self-reports this list in its 400 validation error and `constants.PROJECT_ROLES` mirrors it. - `project invite --project ALIAS --email EMAIL --role admin|guest|readOnly|share [--reason TEXT] [--dry-run]` -- single-shot invitation. Returns `{"status": "ok", "invitation_id": ..., ...}`. Re-inviting an already-invited or already-member email returns `{"status": "noop", "note": "already_invited" | "already_member"}` (HTTP 400 from the Manage API, normalised to a no-op). -- `project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run]` -- bulk invitation. CSV header required; columns: `email`, `project` (alias, or a project ID -- an alias wins *(since 0.96.1)*, and a row fails when that alias hides another project's ID) or `project_id` (numeric, ID only), `role` (optional with `--default-role`), `reason` (optional). Parallelised via `ThreadPoolExecutor` (default 8 workers). Single-stack-URL invariant per file: rows referencing different stacks raise `ConfigError` upfront. Result is `{"total","succeeded","noop","failed","rows":[...]}`; `rows[]` order is *not deterministic*. Exit 0 even with `failed > 0` -- inspect the JSON. +- `project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run]` -- bulk invitation. CSV header required; columns: `email`, `project` (alias, or a project ID -- an alias wins *(since 0.96.1)*, and a row fails when that alias hides another project's ID) or `project_id` (numeric, ID only), `role` (optional with `--default-role`), `reason` (optional). Parallelised via `ThreadPoolExecutor` (default 8 workers). Single-stack-URL invariant per file: rows referencing different stacks raise `ConfigError` upfront. Result is `{"total","succeeded","noop","failed","rows":[...]}`; `rows[]` order is *not deterministic*. **Since vNEXT (#745)** a run with `failed > 0` exits **1**; `--dry-run` exits **1** on a failure too. The full JSON payload is still emitted first, so parse it and then check the exit code. On 0.97.0 and older the exit was 0 regardless -- inspect `failed` there. - `project member-list --project ALIAS [--include-pending]` -- list active members. Each member dict carries `id`, `email`, `name`, `role`, `status`, `mfa_enabled`. With `--include-pending`, the response also includes `pending_invitations: [...]`. - `project invitation-list --project ALIAS` -- list pending (unaccepted) invitations only. - `project invitation-cancel --project ALIAS --email EMAIL [--invitation-id ID] [--yes]` -- cancel a pending invitation. Without `--invitation-id`, the service resolves it by listing pending invitations and matching `--email` (case-insensitive). 204 No Content on success; `KeboolaApiError(NOT_FOUND)` if the email has no pending invitation. @@ -114,7 +114,7 @@ The `permissions` subcommands persist a write/destructive policy to config.json - `permissions check OPERATION` -- check if a specific operation is allowed (e.g. `permissions check storage.delete-table`) ## Organization -- `org setup --org-id ID --url URL [--dry-run] [--yes]` -- bulk-onboard all projects from an org (org admin; manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) +- `org setup --org-id ID --url URL [--dry-run] [--yes]` -- bulk-onboard all projects from an org. **Since vNEXT (#745)** a non-empty `projects_failed` exits **1** (was 0); with `--refresh`, a failed token refresh of an already-registered project is listed under `projects_refresh_failed` (keyed by `alias`) and also exits **1**; `--dry-run` exits **1** on a failure too. (org admin; manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) - `org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes]` -- onboard specific projects by ID (any project member; manage token / Personal Access Token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) ## Billing (PAYG Credits) (since v0.84.2) @@ -196,8 +196,8 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def - `storage describe-bucket --project NAME --bucket-id ID [--text STR | --file PATH | --stdin] [--branch ID]` -- set a bucket description (stored as `KBC.description` in bucket metadata, upsert). Provide exactly one of `--text`, `--file`, `--stdin`. Read back via `storage bucket-detail` - `storage describe-table --project NAME --table-id ID [--text STR | --file PATH | --stdin] [--branch ID]` -- set a table description (stored as `KBC.description` in table metadata, upsert). Provide exactly one of `--text`, `--file`, `--stdin`. Read back via `storage table-detail` - `storage describe-column --project NAME --table-id ID --column NAME=DESCRIPTION [--column ...] [--branch ID]` -- set one or more column descriptions. *(since v0.88.0)* Writes through the native `PUT /v2/storage/branch/{branch}/tables/{id}/definition` endpoint (the one the web UI uses; async `tableDefinitionUpdate` storage job) with `isDescriptionSystemManaged: false`, so the next component run's Output Mapping cannot overwrite the text. The backend mirrors the value into `columnMetadata` `KBC.description`, so the Keboola UI, the MCP server (`get_tables`) and the Snowflake `COMMENT` / BigQuery column description all see it. Unknown column names are rejected BEFORE any write (behavior change -- the pre-0.88.0 flat-metadata write accepted typos silently). Legacy flat `KBC.column.{name}.description` entries on the same table are migrated in the same write and then deleted. Read back in `storage table-detail` under `column_details[].description` -- `storage describe-batch --project NAME --from-file PATH [--branch ID]` -- apply bucket/table/column descriptions from a YAML file (top-level `buckets`, `tables`, `columns` sections, all optional). Column items go through the same native write (and same fail-fast + auto-migration) as `describe-column`. Partial-failure tolerant for API errors: per-item errors are collected and reported, the batch does not abort. Non-zero exit only when at least one item failed. *(since v0.89.0)* A malformed file (a section that is not a mapping of ID to description, a non-mapping `columns` entry, a `null` description, a document that is not a mapping, two keys colliding after `str()` coercion) is rejected whole before the first write -- `INVALID_ARGUMENT`, exit 2, message naming the offending key and its actual type. On 0.88.0 and earlier the same input raised an `AttributeError` traceback partway through the batch, leaving it half-applied -- `storage describe-migrate --project ALIAS [--table-id ID ...] [--bucket-id ID] [--prune-orphans] [--dry-run] [--yes] [--branch ID]` *(since v0.88.0)* -- bulk-convert legacy pre-0.88.0 flat `KBC.column.*.description` metadata to the native definition endpoint. Scope is explicit `--table-id` (repeatable), a single `--bucket-id`, or every table in the project; the two scope flags are mutually exclusive (exit 2). Scans first and prints the summary, then asks for confirmation -- `--dry-run` reports without writing, `--yes` skips the prompt. A column whose currently visible description already differs is skipped as `conflict` (the newer value wins); an entry for a column that no longer exists is skipped as `orphan` unless `--prune-orphans` deletes it. Migrated flat entries are deleted after a successful write, so a later `describe-column` clearing the text cannot be resurrected by the read fallback. Per-table failures are accumulated into `errors[]` and never abort the run. Permission class `write` +- `storage describe-batch --project NAME --from-file PATH [--branch ID]` -- apply bucket/table/column descriptions from a YAML file (top-level `buckets`, `tables`, `columns` sections, all optional). Column items go through the same native write (and same fail-fast + auto-migration) as `describe-column`. Partial-failure tolerant for API errors: per-item errors are collected and reported, the batch does not abort. Non-zero exit only when at least one item failed. **Since vNEXT (#745)** this also holds under `--json` (on 0.97.0 and older `--json` exited 0 even with errors), and the human headline reads `Failed:` instead of a green `Batch complete:`. *(since v0.89.0)* A malformed file (a section that is not a mapping of ID to description, a non-mapping `columns` entry, a `null` description, a document that is not a mapping, two keys colliding after `str()` coercion) is rejected whole before the first write -- `INVALID_ARGUMENT`, exit 2, message naming the offending key and its actual type. On 0.88.0 and earlier the same input raised an `AttributeError` traceback partway through the batch, leaving it half-applied +- `storage describe-migrate --project ALIAS [--table-id ID ...] [--bucket-id ID] [--prune-orphans] [--dry-run] [--yes] [--branch ID]` *(since v0.88.0)* -- bulk-convert legacy pre-0.88.0 flat `KBC.column.*.description` metadata to the native definition endpoint. Scope is explicit `--table-id` (repeatable), a single `--bucket-id`, or every table in the project; the two scope flags are mutually exclusive (exit 2). Scans first and prints the summary, then asks for confirmation -- `--dry-run` reports without writing, `--yes` skips the prompt. A column whose currently visible description already differs is skipped as `conflict` (the newer value wins); an entry for a column that no longer exists is skipped as `orphan` unless `--prune-orphans` deletes it. Migrated flat entries are deleted after a successful write, so a later `describe-column` clearing the text cannot be resurrected by the read fallback. Per-table failures are accumulated into `errors[]` and never abort the run; a non-empty `errors[]` exits 1, and **since vNEXT (#745)** the human headline then reads `Failed: Migrated N table(s) of M scanned, K failed` instead of a green `Migrated:`. Permission class `write` ## Storage Files - `storage files --project NAME [--tag TAG ...] [--limit N] [--offset N] [--query Q] [--branch ID]` -- list Storage Files, optionally filtered by tag/query @@ -279,7 +279,7 @@ Non-SOX Branches 2.0: merge a dev branch into production with review. Alias `mr` - `workspace password --project ALIAS --workspace-id ID` -- reset and return new password - `workspace load --project ALIAS --workspace-id ID --tables TABLE_ID [...] [--preserve] [--load-type clone|copy|view] [--force] [--timeout SECONDS]` -- load storage tables. **Since 0.91.0 (#687)**: default is per-table auto-decide -- zero-copy `clone` when eligible (same backend, full load, no external-schema bucket), else plain `copy`; `--load-type` forces one type for every table and fails loudly (never silently degrades) if a table is ineligible for it. A `copy` over 1 GiB needs interactive confirmation or `--force` in non-interactive mode. `--timeout` defaults to 300s (other storage-job commands keep 60s); on timeout the job keeps running server-side and the error now exits 4, not 1 - `workspace query --project ALIAS --workspace-id ID --sql "..." [--file F] [--transactional] [--full] [--limit N]` -- run SQL via Query Service. **Fast inline results since v0.59.0**: default reads the result set inline via `GET /api/v1/queries/{job}/{stmt}/results` (JSON `columns`+`rows`, no CSV-file materialization), capped at `--limit` rows (default 500) and marked `truncated` when there are more; pass `--full` for the complete CSV export (slower, uncapped). Each statement still carries `csv_data` (synthesized from the inline rows) so older parsers keep working. **Backend-agnostic since v0.58.0**: runs against both Snowflake and BigQuery workspaces. Mind the dialect: Snowflake quotes identifiers with `"..."`, BigQuery with backticks `` `...` `` -- `workspace gc [--project NAME ...] [--dry-run] [--yes]` -- garbage-collect orphaned workspaces (and any lingering `keboola.sandboxes` configs). `--dry-run` previews without deleting; `--project` repeatable, omit to GC across all connected projects +- `workspace gc [--project NAME ...] [--dry-run] [--yes]` -- garbage-collect orphaned workspaces (and any lingering `keboola.sandboxes` configs). `--dry-run` previews without deleting; `--project` repeatable, omit to GC across all connected projects. **Since vNEXT (#745)** a non-empty `errors[]` (a failed delete, or a project that could not be listed) exits **1**; `--dry-run` exits **1** on a failure too - `workspace from-transformation --project ALIAS --component-id ID --config-id ID [--row-id ID]` -- workspace from existing transform ## Data Apps (Python/JS by default; Streamlit via --type) @@ -335,7 +335,7 @@ Requires the project to be added with its **master ('owner') Storage API token** - `flow update --project NAME --flow-id ID [--name N] [--description D] [--file @path.yaml|-|JSON] [--branch ID]` -- update name, description, or phases/tasks; `--file` is a full-replace of phases+tasks; merge-aware validation against the live CF schema (same graceful semantic-only degradation on fetch failure); requires at least one of --name/--description/--file - `flow delete --project NAME --flow-id ID [--branch ID] [--yes]` -- delete a flow config (confirmation guard) - `flow schedule --project NAME --flow-id ID --cron "0 6 * * *" [--timezone TZ] [--disabled] [--branch ID]` -- attach a cron schedule (stored as keboola.scheduler config, target.componentId=keboola.flow); replaces any existing schedule. Since v0.66.1 the config is also activated on the Scheduler Service so the trigger fires; an activation failure keeps the config written and surfaces a warning (`activated: false`, exit 0) -- `flow schedule-remove --project NAME --flow-id ID [--branch ID] [--yes]` -- remove all cron schedules attached to a flow (since v0.66.1 also deregisters them from the Scheduler Service); idempotent +- `flow schedule-remove --project NAME --flow-id ID [--branch ID] [--yes]` -- remove all cron schedules attached to a flow (since v0.66.1 also deregisters them from the Scheduler Service); idempotent. When no schedule could be deleted it fails with `SCHEDULE_DELETE_FAILED`. **Since vNEXT (#745)** a schedule whose delete failed while others were deleted is listed in a new `errors[]` (`{schedule_id, error}`), the human headline reads `Failed: Removed N schedule(s) ..., M failed`, and the exit is **1**; before, that failure was dropped and the command printed `Success:` and exited 0 - `flow triggers --project NAME --flow-id ID [--branch ID]` -- **the honest answer to "what starts this flow automatically"** (since 0.92.0, #714). Returns `cron_schedules` (the `keboola.scheduler` configs `schedule list` already saw) **and** `table_triggers` (`GET /v2/storage/triggers` -- a separate Storage resource, NOT a component config, which is why no kbagent command saw them before). Cross-project triggers (a trigger-queue app config in ANOTHER project) are **not** covered: the result carries `cross_project_triggers_checked: false`, deliberately not an empty list -- an empty list reads as "checked, found none", the exact false negative this command exists to prevent. **Check that flag before concluding a flow has no trigger.** Table triggers are **production-only** (the Storage route is declared `isAvailableInBranch: false`), so `--branch` narrows the cron half only and `table_triggers_branch_scoped` is always `false`. A `last_run` of `null` means the trigger exists but has never fired -- not that it is disabled ## Schedule Discovery & Audit (Fleet-Wide) @@ -361,10 +361,10 @@ Requires the project to be added with its **master ('owner') Storage API token** ## Sync (GitOps) - `sync init --project ALIAS [--directory DIR] [--git-branching] [--adopt-existing] [--with-workspaces]` -- initialize sync working directory; `--adopt-existing` adopts a `.keboola/manifest.json` already written by the kbc Go CLI without overwriting (idempotent; validates `project_id` against the alias token). `--with-workspaces` *(since 0.96.1)* sets `syncWorkspaces` in the manifest, so pull/diff/push/clone also sync shared SQL workspaces (`keboola.sandboxes` without `parameters.id`, `runtime.shared: true`), config only; a `push --force` delete removes the workspace's SQL editor sessions first (a plain push holds it back under `skipped_deletions`). With `--adopt-existing` it turns the key on in an existing manifest. See `sync-workflow.md` > "Shared SQL workspaces". -- `sync pull --project ALIAS [--all-projects] [--force] [--theirs] [--dry-run] [--with-samples] [--no-storage] [--no-jobs] [--job-limit N] [--branch ID]` -- download configs to local files. **Auto-inits:** if the target directory has no `.keboola/manifest.json`, pull runs `init` first, so a separate `sync init` is not needed for a first checkout of a project. For large projects (>100 configs), automatically fetches jobs per-config when the grouped API limit is insufficient. `--force` is conflict-aware (since 0.53.0): a locally-modified config whose remote is unchanged is **preserved** (pending delta stays pushable, never silently re-stamped); a true merge conflict (local AND remote both changed since last pull) **aborts** the pull (exit 1, `SYNC_CONFLICT`; `--json` lists `details.conflicts`); local-untouched + remote-changed takes remote. `--theirs` (since v0.72.0) is the supported "discard local, take production" reconcile path: overwrites locally-modified configs/rows, restores deleted/missing files, resolves conflicts by taking remote (no abort, no manifest surgery). Since v0.72.0 plain pull also re-materializes a tracked config whose local dir was deleted (manifest<->disk invariant), so delete-dir-then-pull refetches. Config-level `isDisabled` is kept through pull and push (since v0.72.0) as sparse `is_disabled: true` in `_config.yml` -- absent key = enabled. `--branch` (0.47.0+) per-invocation dev-branch override, beats every other branch source. Ignored components (since 0.91.0): `keboola.sandboxes` + `keboola.mcp-server-tool` are always excluded (`keboola.sandboxes` shared SQL workspaces are synced when the manifest sets `syncWorkspaces`, *since 0.96.1*), unioned with the manifest's `ignoredComponents` list; a component newly ignored has its manifest entry dropped and local directory removed, reported with pull action `"ignored"` (distinct from `"removed"` = genuinely deleted on remote). Config-folder round-trip *(since 0.94.0)*: pull captures each config's UI folder (`KBC.configuration.folderName`, from the branch-only `search/component-configurations` endpoint) into the manifest, and reports `folder_lookup_failed` when that lookup fails, keeping the previously captured folder. -- `sync push --project ALIAS [--all-projects] [--dry-run] [--force] [--allow-plaintext-on-encrypt-failure] [--branch ID] [--no-name-drift-warnings]` -- push local changes (auto-encrypts secrets, fails if encryption fails). Workspace delete *(since 0.96.1)*: in a `syncWorkspaces` tree, `push --force` deleting 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, so check `push --dry-run --force` (`warnings[]` `workspace_sessions`) first; `--force` is destructive-class (`sync.push --force`, blocked by `--deny-destructive`). Fresh-CREATE writeback updates placeholder manifest entries in place (since 0.47.0) and propagates any `KBC.configuration.*` metadata via `set_config_metadata`. Fresh-CREATE variable binding (since 0.47.2): when a `keboola.variables` config + its values row are created alongside a transformation in the same push, the transformation's `variables_id` / `variables_values_id` placeholders are rebound to the assigned ULIDs and the row's `values` are hoisted even without a `_keboola` block, so `job run` succeeds with no post-push `config variables-set` step (unresolvable/ambiguous links add a `variable_link` entry in `errors[]`, never a broken link). Since 0.96.1 push also remaps shared-code links, flow and orchestrator task `configId`s / `configRowIds` and schedule targets to the configs created in the same push. A link it cannot set is an `errors[]` entry with `change_type` `shared_code_link`, `flow_task_link` or `schedule_target_link` (error code `LINK_UNRESOLVED` for a row id without a new row, `API_ERROR` for a failed PUT). After a failed PUT the local files already hold the new ids and the manifest hash stays stale, so the next `sync push` sends them; this also applies to `variable_link`. The result carries `link_remaps` (`flow_tasks`, `orchestrator_tasks`, `schedule_targets`, `shared_code`, `config_row_ids`); `flow_task_remaps` counts flow tasks only. Never-fetched guard (since v0.72.0): a manifest entry with an empty `pull_hash` and no local files (pre-0.72 name-collision phantom) is **never** planned as a remote DELETE -- diff/push exclude it and report it under `never_fetched` with a warning (run `sync pull` to materialize); local deletion of a properly-pulled config deletes on `push --force`; since 0.96.1 (#792) a plain push deletes nothing and lists the deletion under `skipped_deletions` (also in `--dry-run`), and a config or row deleted on the remote since the last pull is `remote_deleted`, which push never re-creates. Adopted-by-id writeback (since v0.72.0): pushing an untracked file whose `_keboola.config_id` resolves on the branch also writes the manifest entry, so follow-up diffs are stable. `--branch` (0.47.0+) per-invocation override; when no `/` subtree exists on disk (since 0.47.2) the local default tree (`main/`) is promoted to the target branch (API writes still target the branch id); `--no-name-drift-warnings` (0.47.0+) drops the cosmetic warnings array. Branch-scoped since v0.89.0 (issue #649): push consumes the diff's changeset, so configs tracked on another branch's tree are never planned as creates -- they ride along on the result envelope under `orphaned` instead (see `sync diff`). **Since 0.91.0 (#686)** the manifest baseline `pull_config_hash` is stamped from the API response (or a read-back), not from the files on disk, so a pushed multi-statement SQL transformation -- or anything disabled in the UI whose local YAML lacks `is_disabled` -- no longer shows permanent phantom `REMOTE MODIFIED` drift; if the config cannot be read back after the write the baseline is left UNTOUCHED and a `warnings[]` entry says to run `sync pull` (never a disk-derived fallback). One legacy change is refused per-change with `SYNC_LEGACY_BOUNDARY`: a tree pulled before statement-boundary markers existed whose only difference from the remote is the lost boundaries (pushing it would collapse separate SQL statements into one) -- run `sync pull` for that project first. Ignored components (since 0.91.0) are filtered out on both sides of the diff push builds on, so a stale local directory for an ignored component (e.g. `keboola.mcp-server-tool`) is never classified as `DELETED` and can never be pushed as a remote deletion. -- `sync clone --source DIR --target ALIAS --target-dir DIR [--bucket-map FILE] [--variable-values FILE] [--instance-rename FILE] [--no-create-buckets] [--dry-run] [--branch ID]` -- clone a reference synced project into a **fresh** target project and parameterize it. Copies the reference tree at `--source` into `--target-dir`, applies declarative overrides from JSON/YAML files (`--bucket-map` `{old_bucket_id: new_bucket_id}` rewrites storage input/output table refs; `--variable-values` `{var_name: value}` overrides `keboola.variables` rows; `--instance-rename` `{old_path_prefix: new_path_prefix}` renames config dirs + manifest paths), re-points the manifest at the target project, and pushes. Because the reference's config ids do not exist in the fresh target, every config is CREATEd fresh and **keboola.flow task `configId`s + transformation variable links are remapped reference->ULID** by push Phase C/D (the push result carries `flow_task_remaps`). Since 0.96.1 push also remaps shared-code links (`shared_code_id`, `shared_code_row_ids`, `{{}}` script placeholders), legacy `keboola.orchestrator` task `configId`s, task `configRowIds` and a schedule's `target.configurationId`; the clone result carries `link_remaps` per kind (see `sync push`). **`warnings[]` (since 0.96.1)**: the clone result (also `--dry-run`; human mode prints them) lists `missing_task_target` (a flow or orchestrator task runs a config that is not in the tree), `encrypted_values_copied` (the `KBC::` paths the target cannot decrypt, as `_config.yml` paths: `secret_keys` get a plaintext + `sync push`, `unencryptable_keys` need `kbagent encrypt values`, `oauth_keys` a new authorization), `data_app_not_deployed` (run `data-app deploy`) and `schedule_not_active` (clone never activates a schedule; `flow schedule` does, and the hint is left out when several schedules run one flow), plus the push warnings. Only the run that creates the configs reports them: a re-run returns `warnings: []`, so keep them from the first run. **Idempotent**: re-running with an existing `--target-dir` skips copy/overrides and just pushes, reporting `no_changes` / `created: 0`. Fails fast (`CONFIG_ERROR`) if the target already contains the reference's configs -- clone requires a fresh/empty target. `SyncService.clone_project(...)` returns a typed `CloneResult` for in-process SDK callers. Override files must be flat `{id: scalar}` mappings *(since v0.89.0)* -- a nested mapping, list, or null value is rejected with `CONFIG_ERROR` (exit 5) naming the key and its actual type. `--branch` is optional on a fresh clone *(since v0.93.1)*. It defaults to the target's production branch, resolved from the API the same way `sync init` does. Pass `--branch ` only to target a dev branch. The config folder (`KBC.configuration.folderName`) is recreated in the target *(since 0.94.0)* -- clone re-points its production configs onto the branch push resolves, so the create-path writeback carries the folder for a plain, `--branch`, and git-branching production clone. **Data-app runtime type (since 0.94.0)**: a `keboola.data-apps` config's type (`python-js` / `streamlit`) lives only on the Data Science `/apps` record, so `sync pull` records it in `_keboola.data_app_type` and clone sends it through `create_app`. A config with no recorded type is created as `python-js`, the default, with a `data_app_type_default` warning *(since 0.96.1)*. Re-pull a tree pulled by an older version before cloning a Streamlit app, or it is created as `python-js`. **Storage buckets (since 0.96.1)**: clone copies configs, not storage, so a cloned config's input/output mappings point at buckets a fresh target lacks. Clone reads the `storage/buckets.json` pull export and creates the missing buckets in the target **by default** (`--no-create-buckets` skips it) -- idempotent (an existing bucket is skipped, a per-bucket API failure is collected in `bucket_errors`), and the created id is `--bucket-map`-remapped so it matches the rewritten config refs. Each bucket is created on the backend the export recorded. A linked (shared) bucket is linked to the same source as in the reference, under the same id, and listed in `linked_buckets` with its source (an empty bucket in its place would stay empty). The source project's sharing settings decide whether the target may link it; a refused link lands in `bucket_errors`. A tree pulled by an older version does not record which buckets are linked, so clone creates no bucket from it and records one `bucket_errors` entry -- re-pull the reference, or pass `--no-create-buckets`. Only the buckets are created, never their tables or data (the export has no table data) -- populate tables by bucket sharing / `storage upload-table`, or by running the flows. -- `sync diff --project ALIAS [--all-projects] [--branch ID]` -- 3-way diff (local vs base vs remote), detects conflicts. `--branch` (0.47.0+) per-invocation dev-branch override. Branch-scoped since v0.89.0 (issue #649): the local side is read from exactly ONE tree (the target branch's subtree, or `main/` when the target has none). Manifest entries belonging to another branch's tree -- what `sync pull --branch ` leaves behind when it re-targets the manifest -- are excluded from the changeset and reported under `orphaned` (`summary.orphaned` + details with `component_id`, `config_id`, `path`, `branch_id`, `branch_path`, `exists_on_target`, `reason`, `hint`); human mode previews the first 10. An orphaned FILE whose `_keboola.config_id` still resolves on the target is adopted (diffed as `unchanged`/`modified`), never re-created; same-tree id claims keep the #482/#497 fork-by-copy CREATE. Fix a non-zero `summary.orphaned` with `sync pull`. **Since 0.91.0 (#686)** a manifest entry without `metadata.config_hash_version` (written by a pre-0.91.0 kbagent) is compared leniently: a stored hash equal to the pre-0.91.0 hash of the SAME remote config counts as in sync, so the phantom `codes changed` entries disappear immediately; every other field is still pinned by that hash, so real remote drift is unaffected. One `sync pull` per project stamps the version and ends the leniency. Ignored components (since 0.91.0) -- `keboola.sandboxes`, `keboola.mcp-server-tool`, and anything listed in the manifest's `ignoredComponents` -- are excluded from BOTH sides of the comparison, so a stale local directory for one of them never shows up as `DELETED`. Since 0.96.1 (#792) a config or row the manifest fetched from the target branch and that is missing on the remote is `remote_deleted` (human: `- REMOTE DELETED`, `summary.remote_deleted`): run `sync pull`, push never re-creates it. `DELETED` changes are applied only by `sync push --force`. +- `sync pull --project ALIAS [--all-projects] [--force] [--theirs] [--dry-run] [--with-samples] [--no-storage] [--no-jobs] [--job-limit N] [--branch ID]` -- download configs to local files. **Auto-inits:** if the target directory has no `.keboola/manifest.json`, pull runs `init` first, so a separate `sync init` is not needed for a first checkout of a project. For large projects (>100 configs), automatically fetches jobs per-config when the grouped API limit is insufficient. `--force` is conflict-aware (since 0.53.0): a locally-modified config whose remote is unchanged is **preserved** (pending delta stays pushable, never silently re-stamped); a true merge conflict (local AND remote both changed since last pull) **aborts** the pull (exit 1, `SYNC_CONFLICT`; `--json` lists `details.conflicts`); local-untouched + remote-changed takes remote. `--theirs` (since v0.72.0) is the supported "discard local, take production" reconcile path: overwrites locally-modified configs/rows, restores deleted/missing files, resolves conflicts by taking remote (no abort, no manifest surgery). Since v0.72.0 plain pull also re-materializes a tracked config whose local dir was deleted (manifest<->disk invariant), so delete-dir-then-pull refetches. Config-level `isDisabled` is kept through pull and push (since v0.72.0) as sparse `is_disabled: true` in `_config.yml` -- absent key = enabled. `--branch` (0.47.0+) per-invocation dev-branch override, beats every other branch source. Ignored components (since 0.91.0): `keboola.sandboxes` + `keboola.mcp-server-tool` are always excluded (`keboola.sandboxes` shared SQL workspaces are synced when the manifest sets `syncWorkspaces`, *since 0.96.1*), unioned with the manifest's `ignoredComponents` list; a component newly ignored has its manifest entry dropped and local directory removed, reported with pull action `"ignored"` (distinct from `"removed"` = genuinely deleted on remote). Config-folder round-trip *(since 0.94.0)*: pull captures each config's UI folder (`KBC.configuration.folderName`, from the branch-only `search/component-configurations` endpoint) into the manifest, and reports `folder_lookup_failed` when that lookup fails, keeping the previously captured folder. **Since vNEXT (#745)** `--all-projects` exits **1** when `summary.failed > 0` (a project whose pull raised); `--dry-run` exits **1** on a failure too. +- `sync push --project ALIAS [--all-projects] [--dry-run] [--force] [--allow-plaintext-on-encrypt-failure] [--branch ID] [--no-name-drift-warnings]` -- push local changes (auto-encrypts secrets, fails if encryption fails). Workspace delete *(since 0.96.1)*: in a `syncWorkspaces` tree, `push --force` deleting 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, so check `push --dry-run --force` (`warnings[]` `workspace_sessions`) first; `--force` is destructive-class (`sync.push --force`, blocked by `--deny-destructive`). Fresh-CREATE writeback updates placeholder manifest entries in place (since 0.47.0) and propagates any `KBC.configuration.*` metadata via `set_config_metadata`. Fresh-CREATE variable binding (since 0.47.2): when a `keboola.variables` config + its values row are created alongside a transformation in the same push, the transformation's `variables_id` / `variables_values_id` placeholders are rebound to the assigned ULIDs and the row's `values` are hoisted even without a `_keboola` block, so `job run` succeeds with no post-push `config variables-set` step (unresolvable/ambiguous links add a `variable_link` entry in `errors[]`, never a broken link). Since 0.96.1 push also remaps shared-code links, flow and orchestrator task `configId`s / `configRowIds` and schedule targets to the configs created in the same push. A link it cannot set is an `errors[]` entry with `change_type` `shared_code_link`, `flow_task_link` or `schedule_target_link` (error code `LINK_UNRESOLVED` for a row id without a new row, `API_ERROR` for a failed PUT). After a failed PUT the local files already hold the new ids and the manifest hash stays stale, so the next `sync push` sends them; this also applies to `variable_link`. The result carries `link_remaps` (`flow_tasks`, `orchestrator_tasks`, `schedule_targets`, `shared_code`, `config_row_ids`); `flow_task_remaps` counts flow tasks only. Never-fetched guard (since v0.72.0): a manifest entry with an empty `pull_hash` and no local files (pre-0.72 name-collision phantom) is **never** planned as a remote DELETE -- diff/push exclude it and report it under `never_fetched` with a warning (run `sync pull` to materialize); local deletion of a properly-pulled config deletes on `push --force`; since 0.96.1 (#792) a plain push deletes nothing and lists the deletion under `skipped_deletions` (also in `--dry-run`), and a config or row deleted on the remote since the last pull is `remote_deleted`, which push never re-creates. Adopted-by-id writeback (since v0.72.0): pushing an untracked file whose `_keboola.config_id` resolves on the branch also writes the manifest entry, so follow-up diffs are stable. `--branch` (0.47.0+) per-invocation override; when no `/` subtree exists on disk (since 0.47.2) the local default tree (`main/`) is promoted to the target branch (API writes still target the branch id); `--no-name-drift-warnings` (0.47.0+) drops the cosmetic warnings array. Branch-scoped since v0.89.0 (issue #649): push consumes the diff's changeset, so configs tracked on another branch's tree are never planned as creates -- they ride along on the result envelope under `orphaned` instead (see `sync diff`). **Since 0.91.0 (#686)** the manifest baseline `pull_config_hash` is stamped from the API response (or a read-back), not from the files on disk, so a pushed multi-statement SQL transformation -- or anything disabled in the UI whose local YAML lacks `is_disabled` -- no longer shows permanent phantom `REMOTE MODIFIED` drift; if the config cannot be read back after the write the baseline is left UNTOUCHED and a `warnings[]` entry says to run `sync pull` (never a disk-derived fallback). One legacy change is refused per-change with `SYNC_LEGACY_BOUNDARY`: a tree pulled before statement-boundary markers existed whose only difference from the remote is the lost boundaries (pushing it would collapse separate SQL statements into one) -- run `sync pull` for that project first. Ignored components (since 0.91.0) are filtered out on both sides of the diff push builds on, so a stale local directory for an ignored component (e.g. `keboola.mcp-server-tool`) is never classified as `DELETED` and can never be pushed as a remote deletion. **Since vNEXT (#745)** a push with a non-empty `errors[]` exits **1** and the human headline reads `Failed: Pushed: N created, ..., M failed` instead of a green `Success:`. `--all-projects` exits 1 when `summary.failed > 0`, and `summary.failed` now also counts a project whose push returned a non-empty `errors[]` (before, it counted as `success` and its line read `OK`; now the line is marked `x` and states the failed count). A `--dry-run` exits 1 on a failure too. `--json` output is still emitted before the non-zero exit. +- `sync clone --source DIR --target ALIAS --target-dir DIR [--bucket-map FILE] [--variable-values FILE] [--instance-rename FILE] [--no-create-buckets] [--dry-run] [--branch ID]` -- clone a reference synced project into a **fresh** target project and parameterize it. Copies the reference tree at `--source` into `--target-dir`, applies declarative overrides from JSON/YAML files (`--bucket-map` `{old_bucket_id: new_bucket_id}` rewrites storage input/output table refs; `--variable-values` `{var_name: value}` overrides `keboola.variables` rows; `--instance-rename` `{old_path_prefix: new_path_prefix}` renames config dirs + manifest paths), re-points the manifest at the target project, and pushes. Because the reference's config ids do not exist in the fresh target, every config is CREATEd fresh and **keboola.flow task `configId`s + transformation variable links are remapped reference->ULID** by push Phase C/D (the push result carries `flow_task_remaps`). Since 0.96.1 push also remaps shared-code links (`shared_code_id`, `shared_code_row_ids`, `{{}}` script placeholders), legacy `keboola.orchestrator` task `configId`s, task `configRowIds` and a schedule's `target.configurationId`; the clone result carries `link_remaps` per kind (see `sync push`). **`warnings[]` (since 0.96.1)**: the clone result (also `--dry-run`; human mode prints them) lists `missing_task_target` (a flow or orchestrator task runs a config that is not in the tree), `encrypted_values_copied` (the `KBC::` paths the target cannot decrypt, as `_config.yml` paths: `secret_keys` get a plaintext + `sync push`, `unencryptable_keys` need `kbagent encrypt values`, `oauth_keys` a new authorization), `data_app_not_deployed` (run `data-app deploy`) and `schedule_not_active` (clone never activates a schedule; `flow schedule` does, and the hint is left out when several schedules run one flow), plus the push warnings. Only the run that creates the configs reports them: a re-run returns `warnings: []`, so keep them from the first run. **Idempotent**: re-running with an existing `--target-dir` skips copy/overrides and just pushes, reporting `no_changes` / `created: 0`. Fails fast (`CONFIG_ERROR`) if the target already contains the reference's configs -- clone requires a fresh/empty target. `SyncService.clone_project(...)` returns a typed `CloneResult` for in-process SDK callers. Override files must be flat `{id: scalar}` mappings *(since v0.89.0)* -- a nested mapping, list, or null value is rejected with `CONFIG_ERROR` (exit 5) naming the key and its actual type. `--branch` is optional on a fresh clone *(since v0.93.1)*. It defaults to the target's production branch, resolved from the API the same way `sync init` does. Pass `--branch ` only to target a dev branch. The config folder (`KBC.configuration.folderName`) is recreated in the target *(since 0.94.0)* -- clone re-points its production configs onto the branch push resolves, so the create-path writeback carries the folder for a plain, `--branch`, and git-branching production clone. **Data-app runtime type (since 0.94.0)**: a `keboola.data-apps` config's type (`python-js` / `streamlit`) lives only on the Data Science `/apps` record, so `sync pull` records it in `_keboola.data_app_type` and clone sends it through `create_app`. A config with no recorded type is created as `python-js`, the default, with a `data_app_type_default` warning *(since 0.96.1)*. Re-pull a tree pulled by an older version before cloning a Streamlit app, or it is created as `python-js`. **Storage buckets (since 0.96.1)**: clone copies configs, not storage, so a cloned config's input/output mappings point at buckets a fresh target lacks. Clone reads the `storage/buckets.json` pull export and creates the missing buckets in the target **by default** (`--no-create-buckets` skips it) -- idempotent (an existing bucket is skipped, a per-bucket API failure is collected in `bucket_errors`), and the created id is `--bucket-map`-remapped so it matches the rewritten config refs. Each bucket is created on the backend the export recorded. A linked (shared) bucket is linked to the same source as in the reference, under the same id, and listed in `linked_buckets` with its source (an empty bucket in its place would stay empty). The source project's sharing settings decide whether the target may link it; a refused link lands in `bucket_errors`. A tree pulled by an older version does not record which buckets are linked, so clone creates no bucket from it and records one `bucket_errors` entry -- re-pull the reference, or pass `--no-create-buckets`. Only the buckets are created, never their tables or data (the export has no table data) -- populate tables by bucket sharing / `storage upload-table`, or by running the flows. **Since vNEXT (#745)** a clone with a non-empty `errors[]` or `bucket_errors[]` exits **1** and the human headline reads `Failed: ...` with the failed count -- a clone where every config failed used to print `Success ... 0 created` and exit 0. A re-run (`no_changes`) with a `bucket_errors[]` entry exits 1 too. +- `sync diff --project ALIAS [--all-projects] [--branch ID]` -- 3-way diff (local vs base vs remote), detects conflicts. `--branch` (0.47.0+) per-invocation dev-branch override. Branch-scoped since v0.89.0 (issue #649): the local side is read from exactly ONE tree (the target branch's subtree, or `main/` when the target has none). Manifest entries belonging to another branch's tree -- what `sync pull --branch ` leaves behind when it re-targets the manifest -- are excluded from the changeset and reported under `orphaned` (`summary.orphaned` + details with `component_id`, `config_id`, `path`, `branch_id`, `branch_path`, `exists_on_target`, `reason`, `hint`); human mode previews the first 10. An orphaned FILE whose `_keboola.config_id` still resolves on the target is adopted (diffed as `unchanged`/`modified`), never re-created; same-tree id claims keep the #482/#497 fork-by-copy CREATE. Fix a non-zero `summary.orphaned` with `sync pull`. **Since 0.91.0 (#686)** a manifest entry without `metadata.config_hash_version` (written by a pre-0.91.0 kbagent) is compared leniently: a stored hash equal to the pre-0.91.0 hash of the SAME remote config counts as in sync, so the phantom `codes changed` entries disappear immediately; every other field is still pinned by that hash, so real remote drift is unaffected. One `sync pull` per project stamps the version and ends the leniency. Ignored components (since 0.91.0) -- `keboola.sandboxes`, `keboola.mcp-server-tool`, and anything listed in the manifest's `ignoredComponents` -- are excluded from BOTH sides of the comparison, so a stale local directory for one of them never shows up as `DELETED`. Since 0.96.1 (#792) a config or row the manifest fetched from the target branch and that is missing on the remote is `remote_deleted` (human: `- REMOTE DELETED`, `summary.remote_deleted`): run `sync pull`, push never re-creates it. `DELETED` changes are applied only by `sync push --force`. **Since vNEXT (#745)** `--all-projects` exits **1** when `summary.failed > 0` (a project whose diff failed), like `sync pull --all-projects`. - `sync status [--directory DIR]` -- show locally modified/added/deleted configs. Also surfaces `plaintext_secret_warnings` (since 0.55.0): in-sync configs/rows whose `#`-secrets are still plaintext on the remote (a leftover from pre-0.54.0 writes; #378). Pending (un-pushed) edits are not flagged. Fix = re-push on >=0.54.0 + rotate (version history keeps the plaintext). - `sync branch-link --project ALIAS [--branch-id ID] [--branch-name NAME]` -- link git branch to Keboola dev branch - `sync branch-unlink [--directory DIR]` -- remove git-to-Keboola branch mapping @@ -423,7 +423,7 @@ Manage Keboola metastore models -- datasets, metrics, relationships, constraints - `semantic-layer add relationship --project P [--model M] --name N --from TABLE_ID --to TABLE_ID --on EXPR [--type left|inner]` -- add a join relationship. `--type` defaults to `left`. - `semantic-layer add constraint --project P [--model M] --name N --constraint-type T --rule "EXPR" --metrics M1,M2 [--severity error|warning|info]` -- add a constraint. `--constraint-type` is the closed enum `inequality|equality|range|composition|exclusion|temporal|conditional`. `--rule` is a **STRING expression** (e.g. `"value >= 0"`), NEVER a `{bounds: {min, max}}` object (sl-builder docs are wrong -- see [gotchas.md](gotchas.md)). `--metrics` is a comma-separated list of metric names that must already exist in the model. `--severity` defaults to `warning`. Name regex `^[a-z][a-z0-9_]*$`; the 4-band health convention lives in the name suffix `_critical / _warning / _healthy / _review`, distinct from the 3-value API `severity`. - `semantic-layer add glossary --project P [--model M] --term TERM --definition D` -- add a glossary term. `--definition` is required *(since vNEXT)*: every `semantic-glossary` schema requires it, and without it the metastore answered a bare 422 "Validation failed". -- `semantic-layer edit metric --project P [--model M] --name N [--new-name N2] [--new-sql SQL] [--new-dataset TABLE_ID] [--new-description D] [--yes]` -- edit a metric. Updates the metric in place (`PUT`: same id, scope and grants kept). Rename CASCADES through every constraint whose `metrics[]` includes the old name (DELETE old constraint + POST new with updated `metrics[]`). Prints the old/new CODE_METRIC computed via `re.sub(r"[^A-Z0-9]+", "_", name.upper()).strip("_")`. `--yes` skips the confirm prompt. On POST failure the service re-POSTs `original_attrs` and reports rollback success/failure explicitly in the envelope. **Partial-state envelope**: when metric rename succeeds but one or more dependent constraints fail to repoint, the response sets `partial_state: true` and `recovery_hint: ""` at the envelope's top level (was previously buried in `cascaded_constraints[i].status == 'failed'`). Human-mode CLI prints a bright red `PARTIAL STATE` banner above the per-entry list. Recovery recipe: `kbagent semantic-layer validate` to surface the dangling refs, then re-run each failed cascade via `edit constraint --new-metrics ...`. +- `semantic-layer edit metric --project P [--model M] --name N [--new-name N2] [--new-sql SQL] [--new-dataset TABLE_ID] [--new-description D] [--yes]` -- edit a metric. Updates the metric in place (`PUT`: same id, scope and grants kept). Rename CASCADES through every constraint whose `metrics[]` includes the old name (DELETE old constraint + POST new with updated `metrics[]`). Prints the old/new CODE_METRIC computed via `re.sub(r"[^A-Z0-9]+", "_", name.upper()).strip("_")`. `--yes` skips the confirm prompt. On POST failure the service re-POSTs `original_attrs` and reports rollback success/failure explicitly in the envelope. **Partial-state envelope**: when metric rename succeeds but one or more dependent constraints fail to repoint, the response sets `partial_state: true` and `recovery_hint: ""` at the envelope's top level (was previously buried in `cascaded_constraints[i].status == 'failed'`). Human-mode CLI prints a bright red `PARTIAL STATE` banner above the per-entry list. Recovery recipe: `kbagent semantic-layer validate` to surface the dangling refs, then re-run each failed cascade via `edit constraint --new-metrics ...`. **Since vNEXT (#745)** a failed cascade entry exits **1** (was 0). - `semantic-layer edit dataset --project P [--model M] --name N [--new-name N2] [--new-description D] [--new-grain G]` -- edit a dataset. No cascade -- metrics reference the dataset's tableId, not its name. - `semantic-layer edit constraint --project P [--model M] --name N [--new-name N2] [--new-rule "EXPR"] [--new-constraint-type T] [--new-severity error|warning|info] [--new-metrics M1,M2]` -- edit a constraint (in-place `PUT`). Local validators enforce the name regex, constraintType enum, severity enum, and that every entry in `--new-metrics` exists in the model. - `semantic-layer edit relationship --project P [--model M] --name N [--new-name N2] [--new-from TABLE_ID] [--new-to TABLE_ID] [--new-on EXPR] [--new-type left|inner]` -- edit a relationship (in-place `PUT`). No constraint cascade. Rollback on POST failure. @@ -433,9 +433,9 @@ Manage Keboola metastore models -- datasets, metrics, relationships, constraints - `semantic-layer remove constraint --project P [--model M] --name N [--yes]` -- destructive. Confirmation prompt unless `--yes`. - `semantic-layer remove relationship --project P [--model M] --name N [--yes]` -- destructive. Relationships aren't referenced by other entities; no orphan-check. - `semantic-layer remove glossary --project P [--model M] --term TERM [--yes]` -- destructive. Glossary entries aren't referenced by other entities; no orphan-check. -- `semantic-layer import --project P --file PATH [--model M] [--types T,T,...] [--dry-run] [--yes] [--overwrite] [--scope project|organization|targeted] [--target-project ALIAS|ID ...]` -- replay a snapshot. Default: SKIP on conflict (no surprise overwrites). `--overwrite` opts into an in-place update of conflicting items (they keep their scope). `--types` filters to a subset (`datasets,metrics,relationships,glossary,constraints`). Dependency-ordered push: datasets -> metrics -> relationships -> glossary -> constraints. Response: `imported: {: {created, skipped, overwritten, failed: [{name, reason}]}}`. -- `semantic-layer promote --from-project A --to-project B [--from-model M] [--to-model M] [--types T,T,...] [--dry-run] [--yes] [--scope project|organization|targeted] [--target-project ALIAS|ID ...]` -- cross-project model copy with `modelUUID` rewrite to the target model's UUID. Classifies items NEW / IDENTICAL / CHANGED (deep-equality after stripping `modelUUID` + timestamps). Additive + overwrite only: NEVER deletes target items absent from source. Holds two MetastoreClients in try/finally. Response: per-type counts + `changes[]` with `diff_keys` and `failed[]`. -- `semantic-layer build --project P [--model M] --tables T,T,... [--name N] [--dry-run] [--keep-on-failure] [--output PATH]` -- non-interactive heuristic builder. **AI caveat**: the existing `ai_client` has no arbitrary-JSON endpoint, so `build` falls back to a deterministic heuristic synthesising one dataset + one COUNT(*) metric + one glossary entry per table (FQN = the table's Storage location, *(since 0.95.0)* no longer a hardcoded `"KEBOOLA"` database; fields[] role-classified). Response carries `fallback_used: "heuristic"`. The push loop walks all 5 child types in dependency order -- this **fixes** the `sl-build` skill bug where `semantic-constraint` was silently dropped. `--model` omitted creates a new model (default name `kbagent_build_model` or `--name N`). **Rollback on push failure**: every successfully-POSTed child is DELETEd in reverse PUSH_ORDER, and the model itself is DELETEd if we created it during this call. The wrapped `KeboolaApiError` carries `details.rollback={attempted, posted_children, deleted, failed_deletes, model_created_here, model_deleted, model_uuid}` so operators get full diagnostics. Pass `--keep-on-failure` to preserve the partial state for forensic inspection (mirrors `data-app create --keep-on-failure`); the wrapped error then carries `details.rollback.attempted=False, reason='keep_on_failure'`. +- `semantic-layer import --project P --file PATH [--model M] [--types T,T,...] [--dry-run] [--yes] [--overwrite] [--scope project|organization|targeted] [--target-project ALIAS|ID ...]` -- replay a snapshot. Default: SKIP on conflict (no surprise overwrites). `--overwrite` opts into an in-place update of conflicting items (they keep their scope). `--types` filters to a subset (`datasets,metrics,relationships,glossary,constraints`). Dependency-ordered push: datasets -> metrics -> relationships -> glossary -> constraints. Response: `imported: {: {created, skipped, overwritten, failed: [{name, reason}]}}`. **Since vNEXT (#745)** a non-empty `failed[]` in any type exits **1** (was 0); `--dry-run` exits **1** on a failure too. +- `semantic-layer promote --from-project A --to-project B [--from-model M] [--to-model M] [--types T,T,...] [--dry-run] [--yes] [--scope project|organization|targeted] [--target-project ALIAS|ID ...]` -- cross-project model copy with `modelUUID` rewrite to the target model's UUID. Classifies items NEW / IDENTICAL / CHANGED (deep-equality after stripping `modelUUID` + timestamps). Additive + overwrite only: NEVER deletes target items absent from source. Holds two MetastoreClients in try/finally. Response: per-type counts + `changes[]` with `diff_keys` and `failed[]`. **Since vNEXT (#745)** a non-empty `failed[]` in any type exits **1** (was 0); `--dry-run` exits **1** on a failure too. +- `semantic-layer build --project P [--model M] --tables T,T,... [--name N] [--dry-run] [--keep-on-failure] [--output PATH]` -- non-interactive heuristic builder. **AI caveat**: the existing `ai_client` has no arbitrary-JSON endpoint, so `build` falls back to a deterministic heuristic synthesising one dataset + one COUNT(*) metric + one glossary entry per table (FQN = the table's Storage location, *(since 0.95.0)* no longer a hardcoded `"KEBOOLA"` database; fields[] role-classified). Response carries `fallback_used: "heuristic"`. The push loop walks all 5 child types in dependency order -- this **fixes** the `sl-build` skill bug where `semantic-constraint` was silently dropped. `--model` omitted creates a new model (default name `kbagent_build_model` or `--name N`). **Rollback on push failure**: every successfully-POSTed child is DELETEd in reverse PUSH_ORDER, and the model itself is DELETEd if we created it during this call. The wrapped `KeboolaApiError` carries `details.rollback={attempted, posted_children, deleted, failed_deletes, model_created_here, model_deleted, model_uuid}` so operators get full diagnostics. Pass `--keep-on-failure` to preserve the partial state for forensic inspection (mirrors `data-app create --keep-on-failure`); the wrapped error then carries `details.rollback.attempted=False, reason='keep_on_failure'`. **Since vNEXT (#745)** a table whose schema could not be read is left out of the model and listed in `fetch_errors[]`: human mode now lists it under `Failed: N table(s) left out of the model`, and the exit is **1** (was 0, and the table was not shown); `--dry-run` exits **1** on a failure too. `type_resolution_errors[]` stays a warning (exit 0). - `semantic-layer token --encrypt --project P --component-id C` -- encrypt the project's storage token for a transformation's `user_properties`. Builds `{"#metastore_token": }` from the project's already-stored Storage API token and delegates to the existing EncryptService. `--encrypt` is currently required; other modes are refused with `USAGE_ERROR` (exit 2). Output (human): the raw envelope ready to paste. JSON: full `{encrypted, component_id, project}`. ### Scope / target-project grants / elevation (`scope` sub-app, PSGO-140) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 36fe0eba..db187d97 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -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 @@ -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 @@ -5809,7 +5811,8 @@ examples. Surprises worth knowing before you touch this: `promote` take `--scope` / `--target-project` to override the inherited scope, like `add `: 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 @@ -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. diff --git a/plugins/kbagent/skills/kbagent/references/member-workflow.md b/plugins/kbagent/skills/kbagent/references/member-workflow.md index c38176f6..af674824 100644 --- a/plugins/kbagent/skills/kbagent/references/member-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/member-workflow.md @@ -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. diff --git a/plugins/kbagent/skills/kbagent/references/semantic-layer-workflow.md b/plugins/kbagent/skills/kbagent/references/semantic-layer-workflow.md index 0ec87bd1..afa88267 100644 --- a/plugins/kbagent/skills/kbagent/references/semantic-layer-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/semantic-layer-workflow.md @@ -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 diff --git a/plugins/kbagent/skills/kbagent/references/sync-workflow.md b/plugins/kbagent/skills/kbagent/references/sync-workflow.md index 626b694d..536bb9e6 100644 --- a/plugins/kbagent/skills/kbagent/references/sync-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/sync-workflow.md @@ -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 diff --git a/plugins/kbagent/skills/kbagent/references/workspace-workflow.md b/plugins/kbagent/skills/kbagent/references/workspace-workflow.md index bedb18bb..b731bbad 100644 --- a/plugins/kbagent/skills/kbagent/references/workspace-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/workspace-workflow.md @@ -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 diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index 175af9fa..d8d79005 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -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. diff --git a/src/keboola_agent_cli/commands/_semantic_layer_crud.py b/src/keboola_agent_cli/commands/_semantic_layer_crud.py index 31b1489f..efa1ccb4 100644 --- a/src/keboola_agent_cli/commands/_semantic_layer_crud.py +++ b/src/keboola_agent_cli/commands/_semantic_layer_crud.py @@ -17,6 +17,7 @@ check_cli_permission, get_formatter, get_service, + item_failure_exit_code, ) from ._semantic_layer_helpers import ( ScopeChoice, @@ -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") diff --git a/src/keboola_agent_cli/commands/_storage_describe.py b/src/keboola_agent_cli/commands/_storage_describe.py index d5921c33..fd8e0aa2 100644 --- a/src/keboola_agent_cli/commands/_storage_describe.py +++ b/src/keboola_agent_cli/commands/_storage_describe.py @@ -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"] @@ -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( @@ -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"])) diff --git a/src/keboola_agent_cli/commands/_sync_clone_render.py b/src/keboola_agent_cli/commands/_sync_clone_render.py index 38e93df9..009dcf3f 100644 --- a/src/keboola_agent_cli/commands/_sync_clone_render.py +++ b/src/keboola_agent_cli/commands/_sync_clone_render.py @@ -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')}" diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index c90bc780..0085a31a 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -2451,6 +2451,32 @@ When you receive a non-zero exit code, use --json to get structured error details. +Exit 1 also covers a PARTIAL failure (#745). Commands that keep going after one +item fails exit 1 when at least one item failed: sync push, sync +push/pull/diff --all-projects, sync clone, org setup, project refresh, +project invite --from-csv, workspace gc, semantic-layer +import/promote/build/edit metric, storage describe-batch, flow +schedule-remove. Only sync push, sync clone, storage describe-batch, storage +describe-migrate and flow schedule-remove print a "Failed:" headline with the +failed count instead of "Success:". The other commands list the failed items +in a table or in summary lines, and the --all-projects variants state the +count in the summary line. flow schedule-remove always returns the errors[] +key, empty when nothing failed; a partial failure fills it and exits 1; when +every schedule delete fails it still raises SCHEDULE_DELETE_FAILED. +The bulk storage commands +(delete-table, delete-bucket, file-delete, describe-migrate, ...) already +exited 1. The --json payload is still emitted in full (errors / projects_failed +/ projects_refresh_failed / failed / fetch_errors / summary.failed), BEFORE the exit, so parse it and then +branch on the exit code. Exit 1 here does not mean nothing was written: the +items that succeeded stay written, so read the payload before you run the +command again. A --dry-run of these commands exits 1 when it reports a failed +item, like the real run. sync diff --all-projects also exits 1 when a project +failed. +Other read-only multi-project fan-outs (billing credits, job list, schedule +list, notification list, ...) are the deliberate exception: a per-project +failure there degrades that project only and still exits 0, so check their +errors array rather than the exit code. + ## Claude Code Plugin If you are using Claude Code, install the kbagent plugin for richer guidance: diff --git a/src/keboola_agent_cli/commands/flow.py b/src/keboola_agent_cli/commands/flow.py index 00ade7e5..cd2cbc12 100644 --- a/src/keboola_agent_cli/commands/flow.py +++ b/src/keboola_agent_cli/commands/flow.py @@ -31,6 +31,7 @@ check_cli_permission, get_formatter, get_service, + item_failure_exit_code, map_error_to_exit_code, ) @@ -1034,13 +1035,25 @@ def flow_schedule_remove( formatter.output(result) else: count = result.get("deleted_count", 0) - if count == 0: + errors = result.get("errors", []) + headline = f"Removed {count} schedule(s) from flow {escape(flow_id)}" + if errors: + formatter.console.print( + f"[bold red]Failed:[/bold red] {headline}, {len(errors)} failed" + ) + elif count == 0: formatter.console.print("[dim]No schedules found โ€” nothing removed.[/dim]") else: - formatter.success(f"Removed {count} schedule(s) from flow {escape(flow_id)}") + formatter.success(headline) + for err in errors: + formatter.warning(f" Error: schedule {err['schedule_id']}: {escape(err['error'])}") for warning in result.get("warnings", []): formatter.warning(warning) + # A schedule whose config could not be deleted is collected, not raised (#745). + if code := item_failure_exit_code(len(result.get("errors", []))): + raise typer.Exit(code=code) + # Mounted from a private module: `commands/flow.py` is exactly at the # commands soft ceiling, so `triggers` lives in its own file (CONTRIBUTING.md diff --git a/src/keboola_agent_cli/commands/org.py b/src/keboola_agent_cli/commands/org.py index 4e83d37f..9e7c7c38 100644 --- a/src/keboola_agent_cli/commands/org.py +++ b/src/keboola_agent_cli/commands/org.py @@ -14,6 +14,7 @@ check_cli_permission, get_formatter, get_service, + item_failure_exit_code, map_error_to_exit_code, resolve_manage_token, ) @@ -47,6 +48,7 @@ def _format_setup_result(console: Console, data: dict) -> None: refreshed = data.get("projects_refreshed", []) skipped = data.get("projects_skipped", []) failed = data.get("projects_failed", []) + refresh_failed = data.get("projects_refresh_failed", []) token_expires_in = data.get("token_expires_in") mode_label = "[bold yellow]DRY RUN[/bold yellow] " if dry_run else "" @@ -126,6 +128,19 @@ def _format_setup_result(console: Console, data: dict) -> None: console.print(table) console.print() + # Token refresh failures of already-registered projects: keyed by alias. + if refresh_failed: + table = Table(title="Token Refresh Failed") + table.add_column("Alias", style="bold cyan") + table.add_column("Project Name") + table.add_column("Error", style="bold red") + + for p in refresh_failed: + table.add_row(p["alias"], p["project_name"], p["error"]) + + console.print(table) + console.print() + # Summary line summary_parts = [] if added: @@ -244,6 +259,9 @@ def org_setup( would_skip = len(preview.get("projects_skipped", [])) if would_add == 0 and not (refresh and would_skip > 0): formatter.console.print("\nNo new projects to add.") + # The preview is the whole run here: its failed projects are real (#745). + if code := item_failure_exit_code(len(preview.get("projects_failed", []))): + raise typer.Exit(code=code) return if would_add > 0 and not typer.confirm(f"\nProceed to add {would_add} project(s)?"): @@ -274,12 +292,21 @@ def org_setup( dry_run=dry_run, ) result["projects_refreshed"] = refresh_result.get("projects_refreshed", []) + # A failed refresh must reach the exit code (#745). It has its own key: + # its entries are keyed by alias, and `org setup` cannot retry them. + result["projects_refresh_failed"] = refresh_result.get("projects_failed", []) except KeboolaApiError as exc: _handle_api_error(formatter, exc) return formatter.output(result, _format_setup_result) + # Per-project failures are accumulated so one bad project does not abort the + # run -- but the run itself is not a success, and exit 0 hid that (#745). + failed = len(result.get("projects_failed", [])) + len(result.get("projects_refresh_failed", [])) + if code := item_failure_exit_code(failed): + raise typer.Exit(code=code) + def _handle_api_error(formatter, exc: KeboolaApiError) -> None: """Handle a KeboolaApiError by outputting it and raising Exit.""" diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 4a5d8f1d..fd42a74a 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -28,6 +28,7 @@ check_cli_permission, get_formatter, get_service, + item_failure_exit_code, map_error_to_exit_code, resolve_manage_token, ) @@ -641,7 +642,13 @@ def project_refresh( would_refresh = len(preview.get("projects_refreshed", [])) if would_refresh == 0: - formatter.console.print("\nAll tokens are valid.") + preview_failed = len(preview.get("projects_failed", [])) + formatter.console.print( + "\nNo token to refresh." if preview_failed else "\nAll tokens are valid." + ) + # The preview is the whole run here: its failed projects are real (#745). + if code := item_failure_exit_code(preview_failed): + raise typer.Exit(code=code) return if not typer.confirm(f"\nProceed to refresh {would_refresh} token(s)?"): @@ -662,6 +669,10 @@ def project_refresh( formatter.output(result, _format_refresh_result) + # A project whose token check or refresh failed is collected, not raised (#745). + if code := item_failure_exit_code(len(result.get("projects_failed", []))): + raise typer.Exit(code=code) + # โ”€โ”€ Project pin (default project) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -1094,6 +1105,10 @@ def project_invite( ) payload = result.model_dump() formatter.output(payload, _format_bulk_invite_result) + # A CSV where every row failed still printed "failed=N" and exit 0, + # so a CI step could not tell a clean run from a total one (#745). + if code := item_failure_exit_code(payload.get("failed", 0)): + raise typer.Exit(code=code) return result = service.invite( diff --git a/src/keboola_agent_cli/commands/semantic_layer.py b/src/keboola_agent_cli/commands/semantic_layer.py index 65ab183a..693bd9d7 100644 --- a/src/keboola_agent_cli/commands/semantic_layer.py +++ b/src/keboola_agent_cli/commands/semantic_layer.py @@ -17,11 +17,13 @@ from rich.table import Table from ..errors import ErrorCode +from ..services._semantic_layer_internals import PUSH_ORDER from ..services.semantic_layer_service import SCHEMA_TYPE_ALIAS from ._helpers import ( check_cli_permission, get_formatter, get_service, + item_failure_exit_code, ) from ._semantic_layer_crud import add_app, edit_app, remove_app from ._semantic_layer_helpers import ( @@ -381,6 +383,13 @@ def _print_build_result(console: Console, data: dict) -> None: console.print(f" [red]โœ—[/red] {e['type']} {e['item']} โ€” {e['detail']}") if warns: console.print(f"\n[bold yellow]Validation: {len(warns)} warning(s)[/bold yellow]") + fetch_errs = data.get("fetch_errors") or [] + if fetch_errs: + console.print( + f"\n[bold red]Failed: {len(fetch_errs)} table(s) left out of the model[/bold red]" + ) + for e in fetch_errs: + console.print(f" [red]โœ—[/red] {e['table_id']} โ€” {e['error']}") type_errs = data.get("type_resolution_errors") or [] if type_errs: console.print( @@ -550,6 +559,9 @@ def semantic_layer_build( auto_resolve_types=auto_types_workspace, ) formatter.output(result, _print_build_result) + # A table whose schema fetch failed is left out of the model (#745). + if code := item_failure_exit_code(len(result.get("fetch_errors") or [])): + raise typer.Exit(code=code) @semantic_layer_app.command("promote") @@ -622,6 +634,10 @@ def semantic_layer_promote( target_projects=target_projects, ) formatter.output(result, _print_promote_result) + # Per-item failures are collected per type, not raised (#745). + failed = sum(len((result.get(plural) or {}).get("failed", [])) for plural, _ in PUSH_ORDER) + if code := item_failure_exit_code(failed): + raise typer.Exit(code=code) @semantic_layer_app.command("import") @@ -683,6 +699,10 @@ def semantic_layer_import( target_projects=target_projects, ) formatter.output(result, _print_import_result) + # Per-item failures are collected per type, not raised (#745). + failed = sum(len(per.get("failed", [])) for per in (result.get("imported") or {}).values()) + if code := item_failure_exit_code(failed): + raise typer.Exit(code=code) @semantic_layer_app.command("show") diff --git a/src/keboola_agent_cli/commands/sync.py b/src/keboola_agent_cli/commands/sync.py index 71292bb5..6a764bf9 100644 --- a/src/keboola_agent_cli/commands/sync.py +++ b/src/keboola_agent_cli/commands/sync.py @@ -16,6 +16,7 @@ check_cli_permission, get_formatter, get_service, + item_failure_exit_code, map_error_to_exit_code, ) from ._sync_clone_render import print_clone_result @@ -386,11 +387,18 @@ def _format_push_result(formatter: Any, result: dict) -> None: _format_never_fetched(formatter, result.get("never_fetched", [])) _format_orphaned(formatter, result.get("orphaned", [])) return + errors = result.get("errors", []) + failed = f", [red]{len(errors)} failed[/red]" if errors else "" formatter.console.print( f" {result.get('created', 0)} created, " f"{result.get('updated', 0)} updated, " - f"{result.get('deleted', 0)} deleted" + f"{result.get('deleted', 0)} deleted{failed}" ) + for err in errors: + formatter.warning( + f" Error: {err['change_type']} {err['component_id']}/{err['config_id']}: " + f"{err['message']}" + ) print_push_skips(formatter, result) _format_never_fetched(formatter, result.get("never_fetched", [])) _format_orphaned(formatter, result.get("orphaned", [])) @@ -479,7 +487,8 @@ def _push_one_liner(result: dict) -> str: c = result.get("created", 0) u = result.get("updated", 0) d = result.get("deleted", 0) - return f"+{c} created, ~{u} updated, -{d} deleted{held}" + failed = f", [red]{len(result['errors'])} failed[/red]" if result.get("errors") else "" + return f"+{c} created, ~{u} updated, -{d} deleted{failed}{held}" def _format_all_results( @@ -501,13 +510,15 @@ def _format_all_results( for alias in sorted(projects): proj_result = projects[alias] + # A push with per-config errors[] is a failed project, not "OK" (#745). + mark = "[red]x[/red]" if proj_result.get("errors") else "[green]OK[/green]" if "error" in proj_result: formatter.console.print(f" [red]x[/red] {alias}: [red]{proj_result['error']}[/red]") elif verbose and per_project_formatter: formatter.console.print(f"\n[bold]{alias}:[/bold]") per_project_formatter(formatter, proj_result) elif one_liner: - formatter.console.print(f" [green]OK[/green] {alias}: {one_liner(proj_result)}") + formatter.console.print(f" {mark} {alias}: {one_liner(proj_result)}") else: formatter.console.print(f" [green]OK[/green] {alias}") @@ -656,6 +667,8 @@ def sync_pull( formatter.output(data) else: _format_all_results(formatter, data, _format_pull_result, _pull_one_liner) + if code := item_failure_exit_code(data["summary"]["failed"]): + raise typer.Exit(code=code) return project_root = _resolve_project_root(directory, project) @@ -861,6 +874,8 @@ def sync_diff( formatter.output(data) else: _format_all_results(formatter, data, _format_diff_result, _diff_one_liner) + if code := item_failure_exit_code(data["summary"]["failed"]): + raise typer.Exit(code=code) return project_root = _resolve_project_root(directory, project) @@ -1083,6 +1098,8 @@ def sync_push( formatter.output(data) else: _format_all_results(formatter, data, _format_push_result, _push_one_liner) + if code := item_failure_exit_code(data["summary"]["failed"]): + raise typer.Exit(code=code) return project_root = _resolve_project_root(directory, project) @@ -1110,47 +1127,64 @@ def sync_push( if formatter.json_mode: formatter.output(result) else: - status = result.get("status", "") + _render_push_result(formatter, result) - if status == "no_changes": - formatter.console.print("[green]No changes to push.[/green]") - print_push_skips(formatter, result) - _format_never_fetched(formatter, result.get("never_fetched", [])) - _format_orphaned(formatter, result.get("orphaned", [])) - return + # A per-config failure is collected, not raised -- without this the command + # reported a green "Pushed" and exit 0 even when every config failed (#745). + if code := item_failure_exit_code(len(result.get("errors", []))): + raise typer.Exit(code=code) - if status == "dry_run": - formatter.console.print("[yellow]Dry run -- no changes applied:[/yellow]") - for change in result.get("changes", []): - label = _change_label(change) - formatter.console.print(f" {change['change_type'].upper()} {label}") - summary = result["summary"] - formatter.console.print( - f"\nWould create {summary['added']}, update {summary['modified']}, " - f"delete {summary['deleted']}" - ) - print_push_skips(formatter, result) - _format_never_fetched(formatter, result.get("never_fetched", [])) - _format_orphaned(formatter, result.get("orphaned", [])) - return - formatter.success( - f"Pushed: {result['created']} created, " - f"{result['updated']} updated, " - f"{result['deleted']} deleted" - ) +def _render_push_result(formatter: Any, result: dict[str, Any]) -> None: + """Human-mode rendering for a single-project ``sync push``.""" + status = result.get("status", "") + + if status == "no_changes": + formatter.console.print("[green]No changes to push.[/green]") print_push_skips(formatter, result) _format_never_fetched(formatter, result.get("never_fetched", [])) _format_orphaned(formatter, result.get("orphaned", [])) - for change in result.get("pushed_details", []): + return + + if status == "dry_run": + formatter.console.print("[yellow]Dry run -- no changes applied:[/yellow]") + for change in result.get("changes", []): label = _change_label(change) - action = change["change_type"].upper() - formatter.console.print(f" {action} {label}") - for err in result.get("errors", []): - formatter.warning( - f" Error: {err['change_type']} {err['component_id']}/{err['config_id']}: " - f"{err['message']}" - ) + formatter.console.print(f" {change['change_type'].upper()} {label}") + summary = result["summary"] + formatter.console.print( + f"\nWould create {summary['added']}, update {summary['modified']}, " + f"delete {summary['deleted']}" + ) + print_push_skips(formatter, result) + _format_never_fetched(formatter, result.get("never_fetched", [])) + _format_orphaned(formatter, result.get("orphaned", [])) + return + + errors = result.get("errors", []) + headline = ( + f"Pushed: {result['created']} created, " + f"{result['updated']} updated, " + f"{result['deleted']} deleted" + ) + if errors: + # Never a green "Success" line when configs failed: the failed count + # belongs in the headline, not only in the warnings below it (#745). + formatter.console.print(f"[bold red]Failed:[/bold red] {headline}, {len(errors)} failed") + else: + formatter.success(headline) + print_push_skips(formatter, result) + _format_never_fetched(formatter, result.get("never_fetched", [])) + _format_orphaned(formatter, result.get("orphaned", [])) + for change in result.get("pushed_details", []): + label = _change_label(change) + action = change["change_type"].upper() + formatter.console.print(f" {action} {label}") + for err in errors: + formatter.warning( + f" Error: {err['change_type']} {err['component_id']}/{err['config_id']}: " + f"{err['message']}" + ) @sync_app.command("clone") @@ -1254,6 +1288,13 @@ def sync_clone( else: print_clone_result(formatter, result) + # A clone where every config failed used to print "Success ... 0 created" + # and exit 0 -- the failures were warnings under a green line (#745). A + # bucket the clone could not create or link is a failed item too. + failed = len(result.get("errors", [])) + len(result.get("bucket_errors", [])) + if code := item_failure_exit_code(failed): + raise typer.Exit(code=code) + @sync_app.command("branch-link") def sync_branch_link( diff --git a/src/keboola_agent_cli/commands/workspace.py b/src/keboola_agent_cli/commands/workspace.py index 2d6ba6e1..35f63b6e 100644 --- a/src/keboola_agent_cli/commands/workspace.py +++ b/src/keboola_agent_cli/commands/workspace.py @@ -25,6 +25,7 @@ emit_project_warnings, get_formatter, get_service, + item_failure_exit_code, map_error_to_exit_code, ) @@ -644,10 +645,20 @@ def workspace_gc( formatter.console.print( f" [green]deleted[/green] workspace {ws['id']} in '{escape(ws['project_alias'])}'" ) - for err in result.get("errors", []): - formatter.console.print( - f" [red]error[/red] workspace {err.get('workspace_id', '?')}: {escape(err.get('error', ''))}" - ) + # Listing failures carry `message`, delete failures carry `workspace_id` and `error`. + for err in result.get("errors", []): + target = ( + f"workspace {err['workspace_id']} in '{escape(err['project_alias'])}'" + if "workspace_id" in err + else f"project '{escape(err.get('project_alias', '?'))}'" + ) + text = escape(str(err.get("error") or err.get("message") or "")) + formatter.console.print(f" [red]error[/red] {target}: {text}") + + # A workspace that could not be deleted, or a project that could not be + # listed, is collected in errors[]; the run is then not a success (#745). + if code := item_failure_exit_code(len(result.get("errors", []))): + raise typer.Exit(code=code) @workspace_app.command("from-transformation") diff --git a/src/keboola_agent_cli/services/_sync_bulk.py b/src/keboola_agent_cli/services/_sync_bulk.py index 111df747..5e1efd0a 100644 --- a/src/keboola_agent_cli/services/_sync_bulk.py +++ b/src/keboola_agent_cli/services/_sync_bulk.py @@ -173,7 +173,9 @@ def push_all( """Push all registered projects that have a local manifest. Projects without an existing manifest are skipped. Returns a dict with - per-project push results, a summary, and a skipped list. + per-project push results, a summary, and a skipped list. ``summary.failed`` + counts a project whose push raised AND a project whose push returned a + non-empty ``errors[]``; the full push result is kept for the latter. """ projects = service.resolve_projects(None) results: dict[str, Any] = {} @@ -201,7 +203,12 @@ def _worker(alias: str) -> None: allow_plaintext_fallback=allow_plaintext_fallback, ) results[alias] = result - success_count += 1 + # A push that returned per-config errors[] did not succeed: count it + # as failed so summary.failed (and the exit code) include it (#745). + if result.get("errors"): + failed_count += 1 + else: + success_count += 1 except Exception as exc: results[alias] = {"error": str(exc)} failed_count += 1 diff --git a/src/keboola_agent_cli/services/flow_service.py b/src/keboola_agent_cli/services/flow_service.py index 20483672..48ab3817 100644 --- a/src/keboola_agent_cli/services/flow_service.py +++ b/src/keboola_agent_cli/services/flow_service.py @@ -938,7 +938,9 @@ def remove_flow_schedule( the cron trigger stops firing), then its Storage config is deleted. A missing service-side registration is ignored; other deregistration failures are reported via ``warnings`` and do not block the Storage - config deletion. + config deletion. A schedule whose config could not be deleted is + listed in ``errors`` (``{schedule_id, error}``); when no schedule + could be deleted, the call raises ``SCHEDULE_DELETE_FAILED`` instead. Idempotent: if no schedules exist, returns deleted_count=0. """ @@ -959,7 +961,7 @@ def remove_flow_schedule( raise deleted: list[str] = [] - errors: list[str] = [] + errors: list[dict[str, str]] = [] warnings: list[str] = [] matching = _schedules_targeting_flow(all_sched, config_id) if matching: @@ -983,13 +985,14 @@ def remove_flow_schedule( ) deleted.append(sched_id) except KeboolaApiError as exc: - errors.append(f"{sched_id}: {exc.message}") + errors.append({"schedule_id": sched_id, "error": exc.message}) finally: client.close() if errors and not deleted: + failures = "; ".join(f"{e['schedule_id']}: {e['error']}" for e in errors) raise KeboolaApiError( - message=f"Failed to delete schedules: {'; '.join(errors)}", + message=f"Failed to delete schedules: {failures}", status_code=0, error_code=ErrorCode.SCHEDULE_DELETE_FAILED, retryable=False, @@ -1002,6 +1005,8 @@ def remove_flow_schedule( "config_id": config_id, "deleted_schedule_ids": deleted, "deleted_count": len(deleted), + # Schedules whose config delete failed while others were deleted (#745). + "errors": errors, "branch_id": effective_branch, "warnings": warnings, } diff --git a/tests/test_flow_service.py b/tests/test_flow_service.py index 31368bfc..f9d09892 100644 --- a/tests/test_flow_service.py +++ b/tests/test_flow_service.py @@ -579,6 +579,38 @@ def test_remove_flow_schedule_service_failure_warns_but_deletes_config(): client.delete_config.assert_called_once() +def _two_flow_schedules() -> list[dict]: + target = {"target": {"componentId": "keboola.flow", "configurationId": "5"}} + return [{"id": "77", "configuration": target}, {"id": "78", "configuration": target}] + + +def test_remove_flow_schedule_reports_a_failed_delete_next_to_a_deleted_one(): + """One of two deletes fails: the failure is in errors[], not dropped (#745).""" + client = MagicMock() + client.list_component_configs.return_value = _two_flow_schedules() + client.delete_config.side_effect = [ + None, + KeboolaApiError(message="boom", status_code=500, error_code="API_ERROR", retryable=True), + ] + svc = _make_flow_service(client, scheduler_client=_make_scheduler_client()) + result = svc.remove_flow_schedule(alias="prod", config_id="5") + assert result["deleted_schedule_ids"] == ["77"] + assert result["errors"] == [{"schedule_id": "78", "error": "boom"}] + + +def test_remove_flow_schedule_raises_when_every_delete_failed(): + client = MagicMock() + client.list_component_configs.return_value = _two_flow_schedules() + client.delete_config.side_effect = KeboolaApiError( + message="boom", status_code=500, error_code="API_ERROR", retryable=True + ) + svc = _make_flow_service(client, scheduler_client=_make_scheduler_client()) + with pytest.raises(KeboolaApiError) as exc_info: + svc.remove_flow_schedule(alias="prod", config_id="5") + assert exc_info.value.error_code == ErrorCode.SCHEDULE_DELETE_FAILED + assert exc_info.value.message == "Failed to delete schedules: 77: boom; 78: boom" + + class TestGetFlowTriggers: """`flow triggers` must report BOTH mechanisms, and admit what it skipped (#714).""" diff --git a/tests/test_partial_failure_exit_code.py b/tests/test_partial_failure_exit_code.py new file mode 100644 index 00000000..9f2d2085 --- /dev/null +++ b/tests/test_partial_failure_exit_code.py @@ -0,0 +1,1151 @@ +"""Per-item failures must show in the headline AND in the exit code (issue #745). + +Several commands keep going when one item fails: ``sync push`` collects a +failed config into ``result["errors"]``, ``org setup`` into +``projects_failed``, ``project invite --from-csv`` into ``failed``. That +resilience is deliberate -- one bad item must not abort the batch -- but the +command layer then printed a fixed green "Success" line and exited 0 anyway. +A run where EVERY item failed was indistinguishable, by exit code, from a +clean one, so no script could branch on it. + +These tests pin both halves of the fix: + + * exit code 1 whenever at least one item failed (the convention the bulk + storage commands already used), 0 when none did; + * the human headline states the failure instead of claiming success; + * JSON callers still receive the complete payload -- the non-zero exit is + raised AFTER the result is emitted, never instead of it. +""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.models import ProjectConfig + +TEST_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +runner = CliRunner() + + +def _store(config_dir: Path) -> ConfigStore: + config_dir.mkdir(parents=True, exist_ok=True) + store = ConfigStore(config_dir=config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + project_name="prod", + project_id=1234, + ), + ) + return store + + +ORG_SETUP_FAILED: dict[str, Any] = { + "projects_added": [], + "projects_skipped": [], + "projects_failed": [{"project_id": 7, "project_name": "broken", "error": "403"}], +} +REFRESH_FAILED_ENTRY = {"alias": "prod", "project_name": "prod", "error": "Error checking token"} +SL_FAILED_ITEM = {"name": "revenue", "reason": "HTTP 409"} +BATCH_ERROR = {"type": "table", "id": "in.c-a.t1", "error": "denied"} + + +def _push_error(config_id: str) -> dict[str, str]: + return { + "change_type": "added", + "component_id": "keboola.ex-db-snowflake", + "config_id": config_id, + "message": "boom", + } + + +def _invoke_sync(args: list[str], tmp_path: Path, mock_sync: MagicMock) -> Any: + store = _store(tmp_path / "config") + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.SyncService") as MockSync, + ): + MockStore.return_value = store + MockSync.return_value = mock_sync + return runner.invoke(app, ["--config-dir", str(tmp_path / "config"), *args]) + + +class TestSyncPush: + """``sync push`` collects per-config failures into ``result['errors']``.""" + + def test_all_configs_failed_exits_1_without_success_line(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push.return_value = { + "status": "pushed", + "created": 0, + "updated": 0, + "deleted": 0, + "errors": [_push_error("c1"), _push_error("c2")], + "pushed_details": [], + } + result = _invoke_sync( + ["sync", "push", "--project", "prod", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + assert "Success:" not in result.output + assert "2 failed" in result.output + + def test_partial_failure_exits_1_and_states_the_failed_count(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push.return_value = { + "status": "pushed", + "created": 3, + "updated": 0, + "deleted": 0, + "errors": [_push_error("c1")], + "pushed_details": [], + } + result = _invoke_sync( + ["sync", "push", "--project", "prod", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + assert "3 created" in result.output + assert "1 failed" in result.output + + def test_clean_push_still_succeeds(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push.return_value = { + "status": "pushed", + "created": 2, + "updated": 1, + "deleted": 0, + "errors": [], + "pushed_details": [], + } + result = _invoke_sync( + ["sync", "push", "--project", "prod", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 0 + assert "Success:" in result.output + + def test_no_changes_is_not_a_failure(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push.return_value = { + "status": "no_changes", + "created": 0, + "updated": 0, + "deleted": 0, + "errors": [], + } + result = _invoke_sync( + ["sync", "push", "--project", "prod", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 0 + + def test_json_mode_emits_full_payload_and_exits_1(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push.return_value = { + "status": "pushed", + "created": 0, + "updated": 0, + "deleted": 0, + "errors": [_push_error("c1")], + "pushed_details": [], + } + result = _invoke_sync( + ["--json", "sync", "push", "--project", "prod", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + payload = json.loads(result.output) + assert len(payload["data"]["errors"]) == 1 + + def test_all_projects_fan_out_reports_failed_projects(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push_all.return_value = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "projects": { + "prod": {"status": "pushed", "created": 1, "updated": 0, "deleted": 0}, + "dev": {"error": "unreachable"}, + }, + } + result = _invoke_sync( + ["sync", "push", "--all-projects", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + + def test_all_projects_fan_out_clean_run_exits_0(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push_all.return_value = { + "summary": {"total": 1, "success": 1, "failed": 0}, + "projects": {"prod": {"status": "pushed", "created": 1, "updated": 0, "deleted": 0}}, + } + result = _invoke_sync( + ["sync", "push", "--all-projects", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 0 + + def test_all_projects_line_marks_a_project_with_config_errors(self, tmp_path: Path) -> None: + # The project pushed, but a config failed: its line is not a green "OK". + mock_sync = MagicMock() + mock_sync.push_all.return_value = { + "summary": {"total": 1, "success": 0, "failed": 1}, + "projects": { + "prod": { + "status": "pushed", + "created": 2, + "updated": 0, + "deleted": 0, + "errors": [_push_error("c1")], + } + }, + } + result = _invoke_sync( + ["sync", "push", "--all-projects", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + assert "OK prod" not in result.output + assert "x prod: +2 created, ~0 updated, -0 deleted, 1 failed" in result.output + + def test_all_projects_verbose_lists_the_config_errors(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push_all.return_value = { + "summary": {"total": 1, "success": 0, "failed": 1}, + "projects": { + "prod": { + "status": "pushed", + "created": 0, + "updated": 0, + "deleted": 0, + "errors": [_push_error("c1")], + } + }, + } + result = _invoke_sync( + ["--verbose", "sync", "push", "--all-projects", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + assert "0 deleted, 1 failed" in result.output + assert "Error: added keboola.ex-db-snowflake/c1: boom" in result.output + + def test_all_projects_dry_run_with_failed_project_exits_1(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push_all.return_value = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "projects": { + "prod": {"status": "dry_run", "summary": {"added": 1}}, + "dev": {"error": "unreachable"}, + }, + } + result = _invoke_sync( + ["sync", "push", "--all-projects", "--dry-run", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + + def test_all_projects_clean_dry_run_exits_0(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.push_all.return_value = { + "summary": {"total": 1, "success": 1, "failed": 0}, + "projects": {"prod": {"status": "dry_run", "summary": {"added": 1}}}, + } + result = _invoke_sync( + ["sync", "push", "--all-projects", "--dry-run", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 0 + + +class TestPushAllCountsConfigErrors: + """``push_all`` counted only a raised push as failed, never a result with errors[].""" + + def _push_all(self, tmp_path: Path, push_results: dict[str, dict[str, Any]]) -> dict: + from keboola_agent_cli.services._sync_bulk import push_all + + for alias in push_results: + (tmp_path / alias / ".keboola").mkdir(parents=True) + (tmp_path / alias / ".keboola" / "manifest.json").write_text("{}", encoding="utf-8") + service = MagicMock() + service.resolve_projects.return_value = dict.fromkeys(push_results) + service._resolve_max_workers.return_value = 2 + service.push.side_effect = lambda alias, *_args, **_kwargs: push_results[alias] + return push_all(service, tmp_path) + + def test_project_with_config_errors_counts_as_failed(self, tmp_path: Path) -> None: + data = self._push_all( + tmp_path, + { + "prod": {"status": "pushed", "created": 1, "errors": []}, + "dev": {"status": "pushed", "created": 3, "errors": [_push_error("c1")]}, + }, + ) + assert data["summary"]["success"] == 1 + assert data["summary"]["failed"] == 1 + # The full push result is kept, so the per-config detail is not lost. + assert data["projects"]["dev"]["created"] == 3 + assert data["projects"]["dev"]["errors"] == [_push_error("c1")] + + def test_no_changes_and_dry_run_results_are_successes(self, tmp_path: Path) -> None: + data = self._push_all( + tmp_path, + { + "prod": {"status": "no_changes", "errors": []}, + "dev": {"status": "dry_run", "summary": {"added": 1}}, + }, + ) + assert data["summary"]["success"] == 2 + assert data["summary"]["failed"] == 0 + + +class TestSyncPullAndDiffFanOut: + """``--all-projects`` reported ``N failed`` in the summary and exited 0.""" + + def test_pull_all_with_failed_project_exits_1(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.pull_all.return_value = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "projects": {"prod": {"details": []}, "dev": {"error": "unreachable"}}, + } + result = _invoke_sync( + ["sync", "pull", "--all-projects", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + + def test_pull_all_dry_run_with_failed_project_exits_1(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.pull_all.return_value = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "projects": {"prod": {"details": []}, "dev": {"error": "unreachable"}}, + } + result = _invoke_sync( + ["sync", "pull", "--all-projects", "--dry-run", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + assert "1 failed" in result.output + + def test_pull_all_clean_dry_run_exits_0(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.pull_all.return_value = { + "summary": {"total": 1, "success": 1, "failed": 0}, + "projects": {"prod": {"details": []}}, + } + result = _invoke_sync( + ["sync", "pull", "--all-projects", "--dry-run", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 0 + + def test_diff_all_with_failed_project_exits_1(self, tmp_path: Path) -> None: + # Exit 1 means something the command really did failed, reads included. + mock_sync = MagicMock() + mock_sync.diff_all.return_value = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "projects": {"prod": {"summary": {}}, "dev": {"error": "unreachable"}}, + } + result = _invoke_sync( + ["sync", "diff", "--all-projects", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + assert "1 failed" in result.output + + def test_diff_all_clean_exits_0(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.diff_all.return_value = { + "summary": {"total": 1, "success": 1, "failed": 0}, + "projects": {"prod": {"summary": {}}}, + } + result = _invoke_sync( + ["sync", "diff", "--all-projects", "--directory", str(tmp_path)], + tmp_path, + mock_sync, + ) + assert result.exit_code == 0 + + +class TestSyncClone: + """``sync clone`` printed ``Success ... 0 created`` for a total failure.""" + + def _clone_result(self, created: int, errors: list[dict[str, str]]) -> dict[str, Any]: + return { + "status": "cloned", + "target_alias": "prod", + "created": created, + "bucket_rewrites": 0, + "variable_overrides": 0, + "renamed_instances": 0, + "flow_task_remaps": 0, + "errors": errors, + } + + def test_every_config_failed_exits_1_without_success_line(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.clone_project.return_value = self._clone_result( + 0, [_push_error("c1"), _push_error("c2"), _push_error("c3")] + ) + result = _invoke_sync( + [ + "sync", + "clone", + "--source", + str(tmp_path), + "--target", + "prod", + "--target-dir", + str(tmp_path / "out"), + ], + tmp_path, + mock_sync, + ) + assert result.exit_code == 1 + assert "Success:" not in result.output + assert "3 failed" in result.output + + def _invoke_clone(self, tmp_path: Path, clone_result: dict[str, Any]) -> Any: + mock_sync = MagicMock() + mock_sync.clone_project.return_value = clone_result + return _invoke_sync( + [ + "sync", + "clone", + "--source", + str(tmp_path), + "--target", + "prod", + "--target-dir", + str(tmp_path / "out"), + ], + tmp_path, + mock_sync, + ) + + def test_bucket_error_exits_1_without_success_line(self, tmp_path: Path) -> None: + clone_result = self._clone_result(4, []) + clone_result["bucket_errors"] = [{"bucket_id": "in.c-data", "error": "denied"}] + result = self._invoke_clone(tmp_path, clone_result) + assert result.exit_code == 1 + assert "Success:" not in result.output + assert "4 created" in result.output + assert "1 failed" in result.output + + def test_rerun_with_bucket_error_exits_1(self, tmp_path: Path) -> None: + # A re-run has no configs to push, but it tries the buckets again. + clone_result = { + "status": "no_changes", + "target_alias": "prod", + "errors": [], + "bucket_errors": [{"bucket_id": "in.c-data", "error": "denied"}], + } + result = self._invoke_clone(tmp_path, clone_result) + assert result.exit_code == 1 + assert "Already cloned into prod, 1 failed" in result.output + + def test_clean_clone_succeeds(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.clone_project.return_value = self._clone_result(4, []) + result = _invoke_sync( + [ + "sync", + "clone", + "--source", + str(tmp_path), + "--target", + "prod", + "--target-dir", + str(tmp_path / "out"), + ], + tmp_path, + mock_sync, + ) + assert result.exit_code == 0 + assert "Success:" in result.output + + def test_dry_run_without_errors_exits_0(self, tmp_path: Path) -> None: + mock_sync = MagicMock() + mock_sync.clone_project.return_value = { + "status": "dry_run", + "target_alias": "prod", + "summary": {"added": 5}, + "bucket_rewrites": 0, + "variable_overrides": 0, + "renamed_instances": 0, + "errors": [], + } + result = _invoke_sync( + [ + "sync", + "clone", + "--source", + str(tmp_path), + "--target", + "prod", + "--target-dir", + str(tmp_path / "out"), + "--dry-run", + ], + tmp_path, + mock_sync, + ) + assert result.exit_code == 0 + + +class TestOrgSetup: + """``org setup`` accumulates unreachable projects into ``projects_failed``.""" + + def _invoke( + self, + tmp_path: Path, + result_payload: dict[str, Any], + extra: list[str] | None = None, + json_mode: bool = True, + ) -> Any: + store = _store(tmp_path / "config") + mock_org = MagicMock() + mock_org.setup_organization.return_value = result_payload + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.OrgService") as MockOrg, + patch( + "keboola_agent_cli.commands.org.resolve_manage_token", + return_value="manage-token", + ), + ): + MockStore.return_value = store + MockOrg.return_value = mock_org + return runner.invoke( + app, + [ + "--config-dir", + str(tmp_path / "config"), + *(["--json"] if json_mode else []), + "org", + "setup", + "--org-id", + "42", + "--url", + "https://connection.keboola.com", + *(["--yes"] if extra is None else extra), + ], + ) + + def test_dry_run_with_failed_projects_exits_1(self, tmp_path: Path) -> None: + result = self._invoke(tmp_path, {**ORG_SETUP_FAILED, "dry_run": True}, ["--dry-run"]) + assert result.exit_code == 1 + assert len(json.loads(result.output)["data"]["projects_failed"]) == 1 + + def test_interactive_preview_with_only_failures_exits_1(self, tmp_path: Path) -> None: + # No --yes in a human run: the preview has nothing to add, so it is the whole run. + result = self._invoke(tmp_path, {**ORG_SETUP_FAILED, "dry_run": True}, [], json_mode=False) + assert result.exit_code == 1 + assert "No new projects to add." in result.output + + def test_failed_projects_exit_1(self, tmp_path: Path) -> None: + result = self._invoke(tmp_path, ORG_SETUP_FAILED) + assert result.exit_code == 1 + payload = json.loads(result.output) + assert len(payload["data"]["projects_failed"]) == 1 + + def test_clean_dry_run_exits_0(self, tmp_path: Path) -> None: + result = self._invoke( + tmp_path, + { + "dry_run": True, + "projects_added": [{"project_id": 7, "project_name": "ok", "alias": "ok"}], + "projects_skipped": [], + "projects_failed": [], + }, + ["--dry-run"], + ) + assert result.exit_code == 0 + + def test_clean_setup_exits_0(self, tmp_path: Path) -> None: + result = self._invoke( + tmp_path, + { + "projects_added": [{"project_id": 7, "project_name": "ok", "alias": "ok"}], + "projects_skipped": [], + "projects_failed": [], + }, + ) + assert result.exit_code == 0 + + def test_failed_refresh_of_registered_project_exits_1(self, tmp_path: Path) -> None: + # --refresh runs refresh_tokens for already-registered projects; its failures + # used to be dropped, so the run exited 0. + store = _store(tmp_path / "config") + mock_org = MagicMock() + mock_org.setup_organization.return_value = { + "projects_added": [], + "projects_skipped": [{"project_id": 1234, "project_name": "prod", "reason": "x"}], + "projects_failed": [], + } + mock_org.refresh_tokens.return_value = { + "projects_refreshed": [], + "projects_failed": [REFRESH_FAILED_ENTRY], + } + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.OrgService") as MockOrg, + patch( + "keboola_agent_cli.commands.org.resolve_manage_token", + return_value="manage-token", + ), + ): + MockStore.return_value = store + MockOrg.return_value = mock_org + base = ["--config-dir", str(tmp_path / "config")] + args = ["org", "setup", "--org-id", "42", "--url", "https://connection.keboola.com"] + as_json = runner.invoke(app, [*base, "--json", *args, "--refresh", "--yes"]) + human = runner.invoke(app, [*base, *args, "--refresh", "--yes"]) + assert as_json.exit_code == 1 + data = json.loads(as_json.output)["data"] + # Its own key: every projects_failed entry keeps a project_id. + assert data["projects_refresh_failed"] == [REFRESH_FAILED_ENTRY] + assert data["projects_failed"] == [] + assert human.exit_code == 1 + assert "Token Refresh Failed" in human.output + assert "Error checking token" in human.output + + +class TestProjectInviteBulk: + """``project invite --from-csv`` reported ``failed=N`` and exited 0.""" + + def _invoke(self, tmp_path: Path, failed: int, dry_run: bool = False) -> Any: + store = _store(tmp_path / "config") + csv_path = tmp_path / "invites.csv" + csv_path.write_text("project,email,role\nprod,a@example.com,guest\n", encoding="utf-8") + + bulk_result = MagicMock() + bulk_result.model_dump.return_value = { + "total": 1, + "succeeded": 1 - failed, + "noop": 0, + "failed": failed, + "rows": [], + } + mock_member = MagicMock() + mock_member.invite_bulk.return_value = bulk_result + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.MemberService") as MockMember, + patch( + "keboola_agent_cli.commands.project.resolve_manage_token", + return_value="manage-token", + ), + ): + MockStore.return_value = store + MockMember.return_value = mock_member + return runner.invoke( + app, + [ + "--config-dir", + str(tmp_path / "config"), + "project", + "invite", + "--from-csv", + str(csv_path), + *(["--dry-run"] if dry_run else []), + ], + ) + + def test_failed_rows_exit_1(self, tmp_path: Path) -> None: + assert self._invoke(tmp_path, failed=1).exit_code == 1 + + def test_clean_bulk_invite_exits_0(self, tmp_path: Path) -> None: + assert self._invoke(tmp_path, failed=0).exit_code == 0 + + def test_dry_run_with_failed_rows_exits_1(self, tmp_path: Path) -> None: + assert self._invoke(tmp_path, failed=1, dry_run=True).exit_code == 1 + + def test_clean_dry_run_exits_0(self, tmp_path: Path) -> None: + assert self._invoke(tmp_path, failed=0, dry_run=True).exit_code == 0 + + +class TestProjectRefresh: + """``project refresh`` collects a project whose token check or refresh failed.""" + + def _invoke( + self, tmp_path: Path, payload: dict[str, Any], args: list[str], dry_run: bool = False + ) -> Any: + store = _store(tmp_path / "config") + mock_org = MagicMock() + mock_org.refresh_tokens.return_value = { + "dry_run": dry_run, + "projects_checked": 1, + "projects_refreshed": [], + "projects_valid": [], + "projects_skipped": [], + "projects_failed": [], + **payload, + } + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.OrgService") as MockOrg, + patch( + "keboola_agent_cli.commands.project.resolve_manage_token", + return_value="manage-token", + ), + ): + MockStore.return_value = store + MockOrg.return_value = mock_org + return runner.invoke( + app, + [ + "--config-dir", + str(tmp_path / "config"), + *args, + "project", + "refresh", + "--all", + *(["--dry-run"] if dry_run else []), + ], + ) + + def test_failed_project_exits_1_with_full_json(self, tmp_path: Path) -> None: + result = self._invoke(tmp_path, {"projects_failed": [REFRESH_FAILED_ENTRY]}, ["--json"]) + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["data"]["projects_failed"] == [REFRESH_FAILED_ENTRY] + + def test_clean_refresh_exits_0(self, tmp_path: Path) -> None: + assert self._invoke(tmp_path, {}, ["--json"]).exit_code == 0 + + def test_dry_run_with_failed_project_exits_1(self, tmp_path: Path) -> None: + result = self._invoke( + tmp_path, {"projects_failed": [REFRESH_FAILED_ENTRY]}, ["--json"], dry_run=True + ) + assert result.exit_code == 1 + + def test_clean_dry_run_exits_0(self, tmp_path: Path) -> None: + assert self._invoke(tmp_path, {}, ["--json"], dry_run=True).exit_code == 0 + + def test_interactive_preview_with_only_failures_exits_1(self, tmp_path: Path) -> None: + # A human run without --yes stops after the preview when nothing needs a + # refresh; the failed projects in that preview are the run's result. + result = self._invoke(tmp_path, {"projects_failed": [REFRESH_FAILED_ENTRY]}, []) + assert result.exit_code == 1 + assert "No token to refresh." in result.output + assert "All tokens are valid." not in result.output + + +class TestItemFailureExitCode: + """The helper maps like ``map_error_to_exit_code``: it returns, the caller raises.""" + + def test_maps_the_failed_count(self) -> None: + from keboola_agent_cli.commands._helpers import item_failure_exit_code + + assert item_failure_exit_code(0) is None + assert item_failure_exit_code(3) == 1 + + def test_records_the_failure_for_the_usage_event(self) -> None: + from keboola_agent_cli import telemetry + from keboola_agent_cli.commands._helpers import item_failure_exit_code + + telemetry.reset() + item_failure_exit_code(2) + assert telemetry._resolve_error_text(None) == "2 item(s) failed" + telemetry.reset() + + +def _invoke_with( + tmp_path: Path, service_attr: str, mock_service: MagicMock, args: list[str] +) -> Any: + """Run the CLI with one service class replaced by ``mock_service``.""" + store = _store(tmp_path / "config") + store.add_project( + "dev", + ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + project_name="dev", + project_id=5678, + ), + ) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch(f"keboola_agent_cli.cli.{service_attr}") as MockService, + ): + MockStore.return_value = store + MockService.return_value = mock_service + return runner.invoke(app, ["--config-dir", str(tmp_path / "config"), *args]) + + +class TestWorkspaceGc: + """``workspace gc`` collects failed deletes (and unlisted projects) in ``errors``.""" + + def _invoke(self, tmp_path: Path, result: dict[str, Any], extra: list[str]) -> Any: + mock = MagicMock() + mock.resolve_projects.return_value = {} + mock.gc_workspaces.return_value = result + return _invoke_with( + tmp_path, "WorkspaceService", mock, ["workspace", "gc", "--project", "prod", *extra] + ) + + def test_failed_delete_exits_1(self, tmp_path: Path) -> None: + result = self._invoke( + tmp_path, + { + "dry_run": False, + "deleted": [], + "errors": [{"workspace_id": 7, "project_alias": "prod", "error": "denied"}], + "message": "GC complete: 0 orphaned workspace(s) deleted, 1 error(s).", + }, + ["--yes"], + ) + assert result.exit_code == 1 + assert "workspace 7 in 'prod': denied" in result.output + + def test_clean_gc_exits_0(self, tmp_path: Path) -> None: + result = self._invoke( + tmp_path, + {"dry_run": False, "deleted": [], "errors": [], "message": "GC complete."}, + ["--yes"], + ) + assert result.exit_code == 0 + + def test_dry_run_with_list_error_exits_1(self, tmp_path: Path) -> None: + result = self._invoke( + tmp_path, + { + "dry_run": True, + "would_delete": [], + "errors": [{"project_alias": "prod", "error_code": "X", "message": "down"}], + "message": "DRY RUN: 0 orphaned workspace(s) would be deleted. 1 list error(s).", + }, + ["--dry-run"], + ) + assert result.exit_code == 1 + # Human output names the project that could not be listed and why. + assert "project 'prod': down" in result.output + + def test_clean_dry_run_exits_0(self, tmp_path: Path) -> None: + result = self._invoke( + tmp_path, + { + "dry_run": True, + "would_delete": [], + "errors": [], + "message": "DRY RUN: 0 orphaned workspace(s) would be deleted.", + }, + ["--dry-run"], + ) + assert result.exit_code == 0 + + +class TestSemanticLayerImportAndPromote: + """``import`` / ``promote`` collect per-item failures under each type.""" + + def test_import_with_failed_item_exits_1(self, tmp_path: Path) -> None: + snapshot = tmp_path / "snapshot.json" + snapshot.write_text("{}", encoding="utf-8") + mock = MagicMock() + mock.import_snapshot.return_value = { + "target_project": "prod", + "imported": { + "datasets": {"created": 2, "failed": []}, + "metrics": {"created": 0, "failed": [SL_FAILED_ITEM]}, + }, + } + args = ["--json", "semantic-layer", "import", "--project", "prod", "--file", str(snapshot)] + result = _invoke_with(tmp_path, "SemanticLayerService", mock, args) + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["data"]["imported"]["metrics"]["failed"] == [SL_FAILED_ITEM] + + def test_import_dry_run_with_failed_item_exits_1(self, tmp_path: Path) -> None: + snapshot = tmp_path / "snapshot.json" + snapshot.write_text("{}", encoding="utf-8") + mock = MagicMock() + mock.import_snapshot.return_value = { + "dry_run": True, + "imported": {"metrics": {"created": 0, "failed": [SL_FAILED_ITEM]}}, + } + args = ["semantic-layer", "import", "--project", "prod", "--file", str(snapshot)] + result = _invoke_with(tmp_path, "SemanticLayerService", mock, [*args, "--dry-run"]) + assert result.exit_code == 1 + + def test_clean_import_dry_run_exits_0(self, tmp_path: Path) -> None: + snapshot = tmp_path / "snapshot.json" + snapshot.write_text("{}", encoding="utf-8") + mock = MagicMock() + mock.import_snapshot.return_value = { + "dry_run": True, + "imported": {"datasets": {"created": 2, "failed": []}}, + } + args = ["semantic-layer", "import", "--project", "prod", "--file", str(snapshot)] + result = _invoke_with(tmp_path, "SemanticLayerService", mock, [*args, "--dry-run"]) + assert result.exit_code == 0 + + def test_clean_import_exits_0(self, tmp_path: Path) -> None: + snapshot = tmp_path / "snapshot.json" + snapshot.write_text("{}", encoding="utf-8") + mock = MagicMock() + mock.import_snapshot.return_value = { + "imported": {"datasets": {"created": 2, "failed": []}}, + } + args = ["semantic-layer", "import", "--project", "prod", "--file", str(snapshot)] + assert _invoke_with(tmp_path, "SemanticLayerService", mock, args).exit_code == 0 + + def test_promote_with_failed_item_exits_1(self, tmp_path: Path) -> None: + mock = MagicMock() + mock.promote_model.return_value = { + "from_project": "prod", + "to_project": "dev", + "datasets": {"new": 1, "failed": []}, + "glossary": {"new": 0, "failed": [SL_FAILED_ITEM]}, + } + args = [ + "semantic-layer", + "promote", + "--from-project", + "prod", + "--to-project", + "dev", + "--yes", + ] + result = _invoke_with(tmp_path, "SemanticLayerService", mock, args) + assert result.exit_code == 1 + assert "glossary.revenue: HTTP 409" in result.output + + @pytest.mark.parametrize( + "kind", ["datasets", "metrics", "relationships", "glossary", "constraints"] + ) + def test_promote_failed_item_of_each_kind_exits_1(self, tmp_path: Path, kind: str) -> None: + mock = MagicMock() + mock.promote_model.return_value = {kind: {"new": 0, "failed": [SL_FAILED_ITEM]}} + args = [ + "semantic-layer", + "promote", + "--from-project", + "prod", + "--to-project", + "dev", + "--yes", + ] + assert _invoke_with(tmp_path, "SemanticLayerService", mock, args).exit_code == 1 + + def test_promote_dry_run_with_failed_item_exits_1(self, tmp_path: Path) -> None: + mock = MagicMock() + mock.promote_model.return_value = { + "dry_run": True, + "metrics": {"new": 0, "failed": [SL_FAILED_ITEM]}, + } + args = [ + "semantic-layer", + "promote", + "--from-project", + "prod", + "--to-project", + "dev", + "--dry-run", + ] + assert _invoke_with(tmp_path, "SemanticLayerService", mock, args).exit_code == 1 + + def test_promote_clean_dry_run_exits_0(self, tmp_path: Path) -> None: + mock = MagicMock() + mock.promote_model.return_value = { + "dry_run": True, + "metrics": {"new": 1, "failed": []}, + } + args = [ + "semantic-layer", + "promote", + "--from-project", + "prod", + "--to-project", + "dev", + "--dry-run", + ] + assert _invoke_with(tmp_path, "SemanticLayerService", mock, args).exit_code == 0 + + +class TestSemanticLayerBuild: + """``build`` left a table whose schema fetch failed out of the model, silently.""" + + def _result(self, fetch_errors: list[dict[str, str]], dry_run: bool) -> dict[str, Any]: + return { + "project": "prod", + "dry_run": dry_run, + "fallback_used": "heuristic", + "fetch_errors": fetch_errors, + "type_resolution_errors": [], + "generated": {"datasets": [], "metrics": []}, + "validation": {"errors": [], "warnings": []}, + "validated": True, + "created": {"model": "m1"} if not dry_run else None, + } + + def _invoke(self, tmp_path: Path, result: dict[str, Any], extra: list[str]) -> Any: + mock = MagicMock() + mock.build_model.return_value = result + args = ["semantic-layer", "build", "--project", "prod", "--tables", "in.c-a.t1,in.c-a.t2"] + return _invoke_with(tmp_path, "SemanticLayerService", mock, [*args, *extra]) + + def test_table_left_out_exits_1_and_is_listed(self, tmp_path: Path) -> None: + missing = [{"table_id": "in.c-a.t2", "error": "not found"}] + result = self._invoke(tmp_path, self._result(missing, dry_run=False), []) + assert result.exit_code == 1 + assert "Failed: 1 table(s) left out of the model" in result.output + assert "in.c-a.t2" in result.output + + def test_dry_run_with_table_left_out_exits_1(self, tmp_path: Path) -> None: + missing = [{"table_id": "in.c-a.t2", "error": "not found"}] + result = self._invoke(tmp_path, self._result(missing, dry_run=True), ["--dry-run"]) + assert result.exit_code == 1 + + def test_clean_dry_run_exits_0(self, tmp_path: Path) -> None: + result = self._invoke(tmp_path, self._result([], dry_run=True), ["--dry-run"]) + assert result.exit_code == 0 + + def test_clean_build_exits_0(self, tmp_path: Path) -> None: + assert self._invoke(tmp_path, self._result([], dry_run=False), []).exit_code == 0 + + +class TestSemanticLayerEditMetricCascade: + """A metric rename that could not repoint every constraint exited 0.""" + + def _invoke(self, tmp_path: Path, cascaded: list[dict[str, Any]]) -> Any: + mock = MagicMock() + mock.edit_metric.return_value = { + "updated": {"id": "m-2", "attributes": {"name": "net_revenue"}}, + "cascaded_constraints": cascaded, + "rollback": None, + "partial_state": any(c["status"] == "failed" for c in cascaded), + "recovery_hint": None, + } + args = [ + "semantic-layer", + "edit", + "metric", + "--project", + "prod", + "--name", + "revenue", + "--new-name", + "net_revenue", + "--yes", + ] + return _invoke_with(tmp_path, "SemanticLayerService", mock, args) + + def test_failed_cascade_exits_1(self, tmp_path: Path) -> None: + cascaded = [ + {"constraint": "c1", "status": "updated", "id": "c-9"}, + {"constraint": "c2", "status": "failed", "error": "HTTP 500"}, + ] + result = self._invoke(tmp_path, cascaded) + assert result.exit_code == 1 + assert "PARTIAL STATE" in result.output + + def test_clean_cascade_exits_0(self, tmp_path: Path) -> None: + cascaded = [{"constraint": "c1", "status": "updated", "id": "c-9"}] + assert self._invoke(tmp_path, cascaded).exit_code == 0 + + +class TestStorageDescribeBatchAndMigrate: + """``describe-batch`` exited 1 in human mode only; both printed a green headline.""" + + def _batch(self, tmp_path: Path, errors: list[dict[str, str]], json_mode: bool) -> Any: + batch_file = tmp_path / "descriptions.yaml" + batch_file.write_text("tables:\n in.c-a.t1: Orders\n", encoding="utf-8") + mock = MagicMock() + mock.describe_batch.return_value = { + "project_alias": "prod", + "applied": [], + "applied_count": 0, + "errors": errors, + "error_count": len(errors), + } + args = ["storage", "describe-batch", "--project", "prod", "--from-file", str(batch_file)] + return _invoke_with( + tmp_path, "StorageService", mock, [*(["--json"] if json_mode else []), *args] + ) + + def test_batch_json_with_error_exits_1_with_full_payload(self, tmp_path: Path) -> None: + result = self._batch(tmp_path, [BATCH_ERROR], json_mode=True) + assert result.exit_code == 1 + assert json.loads(result.output)["data"]["errors"] == [BATCH_ERROR] + + def test_batch_human_headline_states_the_failure(self, tmp_path: Path) -> None: + result = self._batch(tmp_path, [BATCH_ERROR], json_mode=False) + assert result.exit_code == 1 + assert "Batch complete" not in result.output + assert "Failed: 0 applied, 1 error(s)" in result.output + + def test_clean_batch_json_exits_0(self, tmp_path: Path) -> None: + assert self._batch(tmp_path, [], json_mode=True).exit_code == 0 + + def test_migrate_headline_states_the_failure(self, tmp_path: Path) -> None: + mock = MagicMock() + mock.describe_migrate.return_value = { + "dry_run": False, + "migrated": [{"table_id": "in.c-a.t1", "columns": {"id": "Key"}}], + "tables_scanned": 2, + "skipped": [], + "pruned_orphans": [], + "errors": [{"table_id": "in.c-a.t2", "error": "denied"}], + } + args = ["storage", "describe-migrate", "--project", "prod", "--yes"] + result = _invoke_with(tmp_path, "StorageService", mock, args) + assert result.exit_code == 1 + assert "Failed: Migrated 1 table(s) of 2 scanned, 1 failed" in result.output + + +class TestFlowScheduleRemove: + """A schedule whose delete failed was dropped when another one was deleted.""" + + def test_partial_failure_exits_1_and_lists_the_schedule(self, tmp_path: Path) -> None: + mock = MagicMock() + mock.remove_flow_schedule.return_value = { + "status": "removed", + "deleted_schedule_ids": ["s1"], + "deleted_count": 1, + "errors": [{"schedule_id": "s2", "error": "HTTP 500"}], + "warnings": [], + } + args = ["flow", "schedule-remove", "--project", "prod", "--flow-id", "42", "--yes"] + result = _invoke_with(tmp_path, "FlowService", mock, args) + assert result.exit_code == 1 + assert "Success:" not in result.output + assert "Failed: Removed 1 schedule(s) from flow 42, 1 failed" in result.output + assert "schedule s2: HTTP 500" in result.output + + def test_clean_remove_exits_0(self, tmp_path: Path) -> None: + mock = MagicMock() + mock.remove_flow_schedule.return_value = { + "status": "removed", + "deleted_schedule_ids": ["s1"], + "deleted_count": 1, + "errors": [], + "warnings": [], + } + args = ["flow", "schedule-remove", "--project", "prod", "--flow-id", "42", "--yes"] + assert _invoke_with(tmp_path, "FlowService", mock, args).exit_code == 0 diff --git a/tests/test_semantic_layer_cli.py b/tests/test_semantic_layer_cli.py index 7aa67964..2b18a5de 100644 --- a/tests/test_semantic_layer_cli.py +++ b/tests/test_semantic_layer_cli.py @@ -815,7 +815,8 @@ def test_partial_state_banner_in_human_output(self, store: ConfigStore) -> None: store=store, sl_mock=mock, ) - assert result.exit_code == 0, result.output + # A failed cascade entry is a failed item: exit 1 since #745. + assert result.exit_code == 1, result.output assert "PARTIAL STATE" in result.output assert "Recovery:" in result.output @@ -854,7 +855,8 @@ def test_partial_state_in_json_output(self, store: ConfigStore) -> None: store=store, sl_mock=mock, ) - assert result.exit_code == 0, result.output + # Exit 1 since #745; the full payload is still emitted before it. + assert result.exit_code == 1, result.output body = json.loads(result.output) assert body["status"] == "ok" assert body["data"]["partial_state"] is True diff --git a/tests/test_storage_describe_cli.py b/tests/test_storage_describe_cli.py index 93a48f06..39bfe7be 100644 --- a/tests/test_storage_describe_cli.py +++ b/tests/test_storage_describe_cli.py @@ -765,7 +765,10 @@ def test_describe_batch_human_mode_wires_progress_callback(self, tmp_path: Path) assert callable(kwargs["progress_callback"]) def test_describe_batch_partial_errors(self, tmp_path: Path) -> None: - """describe-batch with partial errors still exits 0 (errors collected, not raised).""" + """describe-batch --json with partial errors keeps going and exits 1 (#745). + + The errors are collected, not raised: the full payload is still emitted. + """ config_dir = tmp_path / "config" config_dir.mkdir() store = _setup_config(config_dir, {"prod": {"token": TEST_TOKEN}}) @@ -807,7 +810,7 @@ def test_describe_batch_partial_errors(self, tmp_path: Path) -> None: ], ) - assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + assert result.exit_code == 1, f"Exit code {result.exit_code}: {result.output}" output = json.loads(result.output) assert output["status"] == "ok" assert len(output["data"]["applied"]) == 1