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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "kbagent",
"version": "0.43.0",
"version": "0.43.1",
"source": "./plugins/kbagent",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"category": "development"
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.43.0",
"version": "0.43.1",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
4 changes: 2 additions & 2 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ a critical failure.
user's local claude / codex / gemini CLI; backs the dashboard
Local AI tile that replaces Kai for non-master-token projects)
needs 0.41.9+,
data-app discoverability fix on `workspace list/detail` +
sandbox config annotation needs 0.42.0+ (#304; gotchas.md),
data-app workspace + CLI sandbox annotation = 0.42.0+ (#304),
HTTP opt-in `?include_sandbox_annotation=true` = 0.43.1+ (#312),
`storage retype` is a future composite), you
MUST refuse the task and return a handoff message to the parent:
`"Cannot proceed safely on kbagent <version>. Missing: <commands>.
Expand Down
12 changes: 12 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ no workspace found".
annotation to avoid N+1 (one `list_workspaces` per config). Use
`workspace list --project NAME` as a one-shot lookup instead.

**HTTP / REST parity** (updated v0.43.1 -- closes #312): the annotation
now lives in `ConfigService.get_config_detail()` behind an opt-in
`include_sandbox_annotation: bool = False` parameter, not only in the
CLI command. `GET /configs/{project}/{component_id}/{config_id}` on
`kbagent serve` accepts `?include_sandbox_annotation=true` to switch it
on. Default off so existing programmatic / web UI consumers see the
unchanged shape -- a regression-free upgrade. The CLI command always
opts in to preserve v0.42.0 behavior. If `list_workspaces` fails (rate
limit, transient 5xx), the detail call still succeeds and
`storage_workspace_id` is set to `null` -- the annotation is UX, not a
contract.

## Web UI `Kai Chat` is gone — replaced by `Local AI` (since v0.41.9)

The web UI dashboard tile / left-nav entry previously labelled **Kai
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-agent-cli"
version = "0.43.0"
version = "0.43.1"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
3 changes: 3 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.43.1": [
"Fix: sandbox annotation from #304/#311 is now available to HTTP / REST callers, not only the CLI (closes #312). The original v0.42.0 fix placed the `keboola.sandboxes` `parameters.id` -> `storage_workspace_id` resolution in `commands/config.py` so it only fired on `kbagent config detail` invocations; `kbagent serve` callers (web UI, scheduled agents, `kbagent http get /configs/...`) hit the same `parameters.id` trap David Ešner reported in #304. The annotation logic moves to `ConfigService.get_config_detail` behind an opt-in `include_sandbox_annotation: bool = False` parameter so existing programmatic consumers see the unchanged response shape (zero regression risk), and a new `?include_sandbox_annotation=true` query parameter on `GET /configs/{project}/{component_id}/{config_id}` exposes it to REST callers. The CLI command unconditionally opts in to preserve v0.42.0 behavior. The pure-function workspace-list filter (`find_storage_workspace_for_sandbox_config(workspaces, config_id) -> int | None`) is extracted from `WorkspaceService.resolve_sandbox_workspace_id` to `services/workspace_service.py` module level so `ConfigService` can call it without taking a circular `ConfigService -> WorkspaceService` dependency in the DI graph. `WorkspaceService.resolve_sandbox_workspace_id` is retained as a one-line wrapper around the helper (still useful for direct callers). Error handling: a failed `list_workspaces` HTTP call no longer fails the detail fetch -- `storage_workspace_id` is set to `None` and the detail comes back as before, because the annotation is UX, not a contract. Bulk mode (`config_id=None`) silently ignores the flag because it would N+1 the workspace listing endpoint (one extra round-trip per config). Tests: 5 new in `test_services.py::TestConfigServiceSandboxAnnotation` covering default-off zero-regression, opt-in resolution, orphan (no matching workspace), non-sandbox-component skip, and graceful degradation on `list_workspaces` failure; 3 new in `test_serve_ui.py::TestConfigDetailSandboxAnnotation` covering HTTP router parameter binding (default-off, opt-in, non-sandbox no-op); 3 existing CLI tests in `test_cli.py::TestConfigDetail` updated to mock the new service-layer call path (now mock `client.list_workspaces` instead of `WorkspaceService.resolve_sandbox_workspace_id`). Total suite: 3381 passed, 104 skipped.",
],
"0.43.0": [
"New: full Semantic Layer management surface in `kbagent serve --ui` (closes #308). The web UI now mirrors every `kbagent semantic-layer` CLI operation 1:1 -- model CRUD (`/api/semantic-layer/models`), entity CRUD for all five kinds (metric / dataset / relationship / constraint / glossary), and the Phase-3 operations (validate, export, diff, promote, import, build, encrypt-token). The UI calls **zero** Metastore endpoints directly; every interaction goes through `/api/semantic-layer/*` on the same `kbagent serve` process so CLI parity is structural, not aspirational. Highlights: schema-driven add/edit drawers (one Pydantic schema per entity kind drives both Typer flags and the React form, no UI-side validation duplication); relationships view ships with a `flowchart TB` ERD (Mermaid) + a dataset-filter chip for hub-and-spoke drill-down + a parallel 'click to edit' edge list for hit-target reliability; constraints view groups rows by `constraintType` with collapsible `<details>` blocks and a 3-icon severity rail (critical / warning / info); datasets detail panel surfaces `fields[]` with role chips (`key=keboola/measure=green/dimension=zinc`). Builder/Importer/Promoter/Diff/Encrypt-Token are dedicated dialogs (`SemanticLayerDialogs.tsx`) with dry-run preview where the CLI offers it. The relationships ERD ships as `flowchart TB` (not `erDiagram`): erDiagram has no rankdir and laid every hub-and-spoke model out as a wide thin strip wasting ~70% of the canvas. flowchart TB puts the hub above its dependents, edge labels are trimmed to just the join type (`left` / `inner`; full relationship names live in the edge list below), auto-fit chooses `Math.min(fitX, fitY)` (cap 2.5, floor 0.4) so 80-edge overviews shrink to ~40% and 15-edge hub drill-downs land at ~63%, both fully readable.",
'Fix: `BaseHttpClient._raise_api_error` now correctly surfaces Metastore validation messages instead of printing a bare HTTP status code. The Keboola Metastore answers 422 with `{"error": 422, "description": "..."}` (int in `error`, real text in `description`); the old parser used `body.get("error")` as the priority key, which evaluated to `422` and shadowed the real message -- the CLI rendered `API error 422: 422` and the operator had no actionable text. The new walker accepts `error` ONLY when it is a non-empty string, then falls through to `exception → message → description → detail → errors → json.dumps(body)` in priority order; FastAPI\'s `{"detail": [{loc, msg}]}` and Metastore\'s `{"errors": [{loc, msg}]}` list shapes are json-serialised so the message contains every diagnostic line, not the Python list repr. Four regression tests pin the new paths (int `error`, plain `description`, both list shapes) so the bare-status-code UX cannot return silently.',
Expand Down
46 changes: 11 additions & 35 deletions src/keboola_agent_cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,16 +291,25 @@ def config_detail(

try:
if config_id is not None:
# Single-config mode: shape unchanged for backward compat
# Single-config mode: shape unchanged for backward compat,
# plus the opt-in sandbox annotation (since v0.42.1, issue #312):
# the service layer now owns the keboola.sandboxes
# configurationId->workspace.id resolution so HTTP and REST
# callers get the same enrichment, not only the CLI.
result = service.get_config_detail(
alias=project[0],
component_id=component_id,
config_id=config_id,
branch_id=effective_branch,
with_state=with_state,
include_sandbox_annotation=True,
)
else:
# Bulk mode: one call per project, filtered by component_id
# Bulk mode: one call per project, filtered by component_id.
# Annotation flag stays off here -- bulk mode would N+1 the
# workspace listing endpoint (one extra round-trip per config),
# and the field that triggers the annotation in single-config
# mode (parameters.id) is rarely consumed in bulk anyway.
result = service.get_config_detail(
alias=project[0],
component_id=component_id,
Expand All @@ -322,39 +331,6 @@ def config_detail(
)
raise typer.Exit(code=exit_code) from None

# Issue #304 bod #3: ``keboola.sandboxes`` configs carry ``parameters.id``
# that looks like a Storage workspace ID but is actually a sandbox-service
# internal handle (passing it to ``workspace detail --workspace-id`` 404s).
# Resolve the real Storage workspace ID once via WorkspaceService so JSON
# callers get the mapping and human-mode readers see an explicit annotation.
# Single-config mode only -- bulk mode is N+1-sensitive (one extra request
# per config) and would be a regression for the existing fast-fan-out use.
if config_id is not None and component_id == "keboola.sandboxes":
ws_service = get_service(ctx, "workspace_service")
configuration = result.get("configuration", {}) or {}
sandbox_service_id = (configuration.get("parameters") or {}).get("id")
try:
storage_workspace_id = ws_service.resolve_sandbox_workspace_id(
alias=project[0],
config_id=config_id,
branch_id=effective_branch,
)
except (KeboolaApiError, ConfigError):
# Best-effort enrichment: do not fail the whole detail call just
# because the workspace listing endpoint hiccuped. The annotation
# is a UX nicety, not a contract.
storage_workspace_id = None
result["sandbox_annotation"] = {
"sandbox_service_id": sandbox_service_id,
"storage_workspace_id": storage_workspace_id,
"note": (
"`parameters.id` in a keboola.sandboxes config is the "
"sandbox-service internal ID, NOT the Storage workspace ID. "
"Use `storage_workspace_id` with `kbagent workspace detail "
"--workspace-id ...`."
),
}

if config_id is not None:
# Single-config mode: emit unchanged shape
formatter.output(result, format_config_detail)
Expand Down
13 changes: 13 additions & 0 deletions src/keboola_agent_cli/server/routers/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ def config_detail(
config_id: str,
branch_id: int | None = None,
with_state: bool = False,
include_sandbox_annotation: bool = Query(
False,
description=(
"Opt-in enrichment for component_id=keboola.sandboxes. When true, "
"the response carries a `sandbox_annotation` block with "
"`sandbox_service_id` (the misleading `configuration.parameters.id`) "
"and `storage_workspace_id` (the actual Storage workspace ID, "
"resolved via an extra GET /v2/storage/workspaces). Off by default "
"to keep the endpoint response shape stable for existing callers. "
"Closes #312 (HTTP parity for the #304 trap)."
),
),
registry: ServiceRegistry = Depends(get_registry),
) -> dict[str, Any]:
"""Fetch a single configuration. Mirrors `kbagent config detail`."""
Expand All @@ -120,6 +132,7 @@ def config_detail(
config_id=config_id,
branch_id=branch_id,
with_state=with_state,
include_sandbox_annotation=include_sandbox_annotation,
)


Expand Down
48 changes: 48 additions & 0 deletions src/keboola_agent_cli/services/config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from ..sync.manifest import Manifest, load_manifest, save_manifest
from ..sync.naming import sanitize_name
from .base import BaseService, ClientFactory, sanitize_unexpected_error
from .workspace_service import find_storage_workspace_for_sandbox_config

AiClientFactory = Callable[[str, str], AiServiceClient]

Expand Down Expand Up @@ -299,6 +300,7 @@ def get_config_detail(
branch_id: int | None = None,
with_state: bool = False,
aliases: list[str] | None = None,
include_sandbox_annotation: bool = False,
) -> dict[str, Any]:
"""Get detailed information about one or many configurations.

Expand Down Expand Up @@ -340,6 +342,18 @@ def get_config_detail(
``config_id`` must be None and ``branch_id`` must be None.
Returns ``{"configs": [...], "errors": [...]}`` with every
row tagged by ``project_alias``.
include_sandbox_annotation: Opt-in enrichment for
``component_id == "keboola.sandboxes"`` in single-config
mode. When True, the response gains a
``sandbox_annotation`` block with ``sandbox_service_id``
(the misleading ``configuration.parameters.id``) and
``storage_workspace_id`` (the actual Storage workspace ID,
resolved via an extra ``GET /v2/storage/workspaces``).
Default False to keep this method a clean API wrapper for
programmatic callers (closes #312 -- HTTP/REST parity gap
left by #304). Bulk mode is N+1-sensitive (one extra HTTP
round-trip per config), so the flag is silently ignored
there.

Returns:
Dict. Shape depends on mode:
Expand Down Expand Up @@ -403,11 +417,45 @@ def get_config_detail(
detail.setdefault("state", {})
if not isinstance(detail["state"], dict):
detail["state"] = {}
# Sandbox annotation enrichment (issue #312 / #304 HTTP parity).
# Opt-in (default off) so existing programmatic consumers keep
# the unchanged shape. The extra ``list_workspaces`` HTTP call
# is intentional: there is no per-config sandbox→workspace
# endpoint, and reusing the same client keeps retry/backoff +
# branch routing consistent with the detail call above.
sandbox_annotation: dict[str, Any] | None = None
if include_sandbox_annotation and component_id == "keboola.sandboxes":
sandbox_service_id = (
(detail.get("configuration") or {}).get("parameters", {}).get("id")
)
try:
workspaces = client.list_workspaces(branch_id=effective_branch_id)
storage_workspace_id = find_storage_workspace_for_sandbox_config(
workspaces, config_id
)
except KeboolaApiError:
# Best-effort: do not fail the detail fetch just because
# the workspace listing endpoint hiccuped -- the
# annotation is a UX nicety, not a contract. The caller
# still gets the raw detail.
storage_workspace_id = None
sandbox_annotation = {
"sandbox_service_id": sandbox_service_id,
"storage_workspace_id": storage_workspace_id,
"note": (
"`parameters.id` in a keboola.sandboxes config is the "
"sandbox-service internal ID, NOT the Storage workspace ID. "
"Use `storage_workspace_id` with `kbagent workspace detail "
"--workspace-id ...`."
),
}
finally:
client.close()

detail["project_alias"] = alias
detail["branch_id"] = effective_branch_id
if sandbox_annotation is not None:
detail["sandbox_annotation"] = sandbox_annotation
return detail

def _get_config_detail_bulk(
Expand Down
56 changes: 44 additions & 12 deletions src/keboola_agent_cli/services/workspace_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,49 @@ def _classify_qs_compatibility(login_type: str) -> bool:
return login_type in QUERY_SERVICE_COMPATIBLE_LOGIN_TYPES


def find_storage_workspace_for_sandbox_config(
workspaces: list[dict[str, Any]],
config_id: str,
) -> int | None:
"""Pure-function lookup: find the Storage workspace that backs a sandbox config.

A ``keboola.sandboxes`` configuration's ``parameters.id`` is the
sandbox-service internal ID, not a Storage workspace ID -- passing it to
``GET /v2/storage/workspaces/{ID}`` returns 404 (issue #304). The real
relation goes the other direction: each Storage workspace exposes
``configurationId`` pointing back at its sandbox config.

Extracted from ``WorkspaceService.resolve_sandbox_workspace_id`` so
``ConfigService.get_config_detail`` can call it with a workspace list it
already has (avoiding a circular ``ConfigService -> WorkspaceService``
dependency and the extra HTTP round-trip that would otherwise pile up
in HTTP and web-UI consumers -- see issue #312).

Args:
workspaces: Raw output of ``KeboolaClient.list_workspaces()`` -- each
entry is the Storage API workspace dict (not the normalised CLI
shape).
config_id: ``keboola.sandboxes`` configuration ID.

Returns:
Storage workspace ID (int), or None if no workspace currently backs
this config (orphan sandbox, or workspace deleted but config kept
around).
"""
for ws in workspaces:
if ws.get("component") == "keboola.sandboxes" and str(ws.get("configurationId", "")) == str(
config_id
):
ws_id = ws.get("id")
if isinstance(ws_id, int):
return ws_id
try:
return int(ws_id) if ws_id is not None else None
except (TypeError, ValueError):
return None
return None


def _is_orphaned_workspace(ws: dict[str, Any], config_names: dict[str, str]) -> bool:
"""Return True if a workspace has no backing keboola.sandboxes config.

Expand Down Expand Up @@ -321,18 +364,7 @@ def resolve_sandbox_workspace_id(
finally:
client.close()

for ws in workspaces:
if ws.get("component") == "keboola.sandboxes" and str(
ws.get("configurationId", "")
) == str(config_id):
ws_id = ws.get("id")
if isinstance(ws_id, int):
return ws_id
try:
return int(ws_id) if ws_id is not None else None
except (TypeError, ValueError):
return None
return None
return find_storage_workspace_for_sandbox_config(workspaces, config_id)

def list_workspaces(
self,
Expand Down
Loading
Loading