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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ its absence is NOT a promise the entry is version-independent (see §1 Rule 6).
`config detail` -> `configuration.runtime` FIRST (an empty `data-app logs`
grep rules nothing out). `create` defaults it ON at **0.87.0+**; <= 0.86.0
patch + redeploy.
- **Data-app type in `sync`**: a `keboola.data-apps` config's runtime type (`python-js` / `streamlit`) lives only on the Data Science `/apps` record. `sync pull` records it as `_keboola.data_app_type`, and `sync push` / `sync clone` send it through `create_app`. A tree pulled before this carries no type, so re-pull the source before you clone, or the app deploys under the platform default, `streamlit` (since vNEXT).
- **`ENCRYPTION_FAILED` on an Azure stack is a VERSION GATE, not a bad token**:
<= 0.85.0 rejected the Azure `KBC::ProjectSecureKV::` cipher, so private-repo
`create` and `secrets-set` could not work there at all. Upgrade to 0.86.0+; do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ Requires the project to be added with its **master ('owner') Storage API token**
- `sync init --project ALIAS [--directory DIR] [--git-branching] [--adopt-existing]` -- 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)
- `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` round-trips (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, 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).
- `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). 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 surface a `variable_link` entry in `errors[]`, never a broken link). 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 still deletes on push. 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 `<branch_name>/` 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] [--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`). **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 <id>` only to target a dev branch.
- `sync clone --source DIR --target ALIAS --target-dir DIR [--bucket-map FILE] [--variable-values FILE] [--instance-rename FILE] [--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`). **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 <id>` only to target a dev branch. **Data-app runtime type (since vNEXT)**: 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`. Re-pull the source before cloning a tree pulled by an older version, or the app deploys under the platform default.
- `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 <dev>` 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`.
- `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
Expand Down
8 changes: 8 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,14 @@ a `name_drift_warnings: [...]` array on the result envelope. The
still runs, so a future operator who wants to audit can flip the flag off
without losing data.

## `sync` carries a data app's runtime type: pull records it, push and clone send it

A `keboola.data-apps` config's runtime type (`python-js` / `streamlit` / ...) lives only on the Data Science `/apps` record, never in the Storage config body. So `sync pull` used to drop it, and `sync push` / `sync clone` recreated the config through the Storage API alone. A cloned `python-js` app then deployed under the platform default, `streamlit` (since vNEXT).

`sync pull` now reads the type from the DS `/apps` list and records it in the config's `_keboola` block as `data_app_type`. The config hash already ignores that key, so it adds no `sync diff` noise. `sync push` and `sync clone` route a `keboola.data-apps` CREATE through the Data Science `create_app` when the local config carries a `data_app_type`. That call sends the type and writes the new app's `parameters.id`. A config with no recorded type still uses the plain `create_config` path.

The DS `/apps` list also returns sandbox and workspace records. Each carries a parent component's id and a backend `type` such as `snowflake`. So kbagent builds the type map from `componentId == keboola.data-apps` records only.

## `semantic-layer search-context` + `get-context` cover the upstream `search_semantic_context` / `get_semantic_context` parity

`kbagent semantic-layer search-context --project P [--pattern G ...] [--type T] [--limit N]`
Expand Down
130 changes: 130 additions & 0 deletions src/keboola_agent_cli/services/_sync_data_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Data-app create for the sync engine (CLI-8).

``sync push`` / ``sync clone`` create configs through the Storage API only.
A ``keboola.data-apps`` config has a second half, the Data Science ``/apps``
deployment record, and the runtime type (``python-js`` / ``streamlit`` / ...)
lives ONLY on that record. This module owns the one create path that carries
the type, kept out of ``data_app_service`` so that already-large module does
not grow past its file-size budget.
"""

from __future__ import annotations

import logging
from typing import Any

from ..client import KeboolaClient
from ..data_science_client import DataScienceClient
from ..errors import ErrorCode, KeboolaApiError
from .data_app_service import DATA_APP_COMPONENT_ID

logger = logging.getLogger(__name__)


def create_synced_data_app(
storage_client: KeboolaClient,
ds_client: DataScienceClient,
*,
name: str,
description: str,
type_: str,
configuration: dict[str, Any],
branch_id: int | None,
is_disabled: bool = False,
) -> dict[str, Any]:
"""Create a ``keboola.data-apps`` config together with its Data Science
deployment record, carrying the runtime ``type_`` (CLI-8).

``sync push`` / ``sync clone`` create configs through the Storage API only
(``create_config``). A ``keboola.data-apps`` config has a second half, the
Data Science ``/apps`` deployment record, and the runtime type
(``python-js`` / ``streamlit`` / ...) lives ONLY on that record, never in
the Storage config body. A plain ``create_config`` therefore leaves the
platform to create the DS record under its default type, so a cloned
``python-js`` app deploys as ``streamlit``.

This routes creation through ``create_app`` (which creates BOTH the DS
record with ``type_`` and its Storage config), then fills the full body
via ``update_config``. ``POST /apps`` validates its ``config``: it wants
the create-shell shape (``parameters.size`` / ``autoSuspendAfterSeconds``
/ ``dataApp.slug`` + ``authorization``), not the full Storage body, which
carries ``runtime.backend.size`` instead of ``parameters.size``. So the
create call sends the minimal shell (the same shape as
``DataAppService.create``) and the update call sends the full body with the
new app's ``parameters.id``. This mutates the passed ``configuration``:
``parameters.id`` is set to the new app id, so the caller can persist it to
the local file (else the next push reverts the back-pointer).

If ``update_config`` fails after ``create_app`` already created the record,
the record is deleted, so a failed sync create leaves no orphan app in the
target (the same guard as ``DataAppService.create``).

Returns the ``update_config`` response (the Storage config, whose ``id`` is
the new config ULID) so the caller's manifest writeback is identical to the
``create_config`` path.
"""
# Build the minimal shell POST /apps accepts (see docstring). The full
# Storage body -- runtime.backend.size, the git block, the source app id --
# goes on the update_config call below, not here.
params = configuration.get("parameters") or {}
data_app = params.get("dataApp") or {}
backend = (configuration.get("runtime") or {}).get("backend") or {}
initial_parameters: dict[str, Any] = {"dataApp": {"slug": data_app.get("slug", "")}}
if "size" in backend:
initial_parameters["size"] = backend["size"]
if "autoSuspendAfterSeconds" in params:
initial_parameters["autoSuspendAfterSeconds"] = params["autoSuspendAfterSeconds"]
initial_config: dict[str, Any] = {"parameters": initial_parameters}
if "authorization" in configuration:
initial_config["authorization"] = configuration["authorization"]

shell = ds_client.create_app(
type_=type_,
name=name,
description="", # full description goes onto the Storage config below
config=initial_config,
branch_id=branch_id,
)
app_id = str(shell.get("id", ""))
config_id = str(shell.get("configId", ""))
if not app_id or not config_id:
# An id without a configId still leaves a shell behind. Delete it so a
# failed create leaves no orphan app, as the docstring promises.
if app_id:
try:
ds_client.delete_app(app_id)
except Exception:
logger.warning(
"Failed to delete orphan data app %s after an incomplete create response",
app_id,
)
raise KeboolaApiError(
message="POST /apps response missing id or configId",
status_code=500,
error_code=ErrorCode.API_ERROR,
retryable=False,
)

target_params = configuration.setdefault("parameters", {})
if isinstance(target_params, dict):
target_params["id"] = app_id

try:
return storage_client.update_config(
component_id=DATA_APP_COMPONENT_ID,
config_id=config_id,
name=name,
description=description,
configuration=configuration,
change_description="Created via kbagent sync",
branch_id=branch_id,
is_disabled=is_disabled,
)
except Exception:
# The DS record and its bare Storage config exist, but the full body
# did not land. Delete the record so the target keeps no orphan app.
try:
ds_client.delete_app(app_id)
except Exception:
logger.warning("Failed to delete orphan data app %s after a failed sync create", app_id)
raise
Loading