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
17 changes: 11 additions & 6 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
# rethought (change-detection inside an always-running job, or a paths-ignore
# mirror that reports success).
#
# Deliberately NO `vitest` step: there are zero *.test.* / *.spec.* files
# under web/frontend/src, and `vitest run` on an empty suite exits 1
# ("No test files found"), which would fail every frontend PR. When the first
# test lands, add `npm test` here -- vitest's fail-on-empty default then
# guards against the suite silently vanishing.
# The `vitest` step arrived with the first test file (`src/router.test.ts`,
# the hash-router parse/build contract). It was deliberately absent before
# that: `vitest run` on an empty suite exits 1 ("No test files found"), which
# would have failed every frontend PR. That same fail-on-empty default now
# works FOR us -- it guards against the suite silently vanishing.
# ──────────────────────────────────────────────────────────────────────────
name: Frontend

Expand All @@ -37,7 +37,7 @@ on:

jobs:
frontend:
name: Type check + build (web/frontend)
name: Type check + test + build (web/frontend)
runs-on: ubuntu-latest
defaults:
run:
Expand All @@ -62,6 +62,11 @@ jobs:
# annotation instead of burying it inside the build step.
run: npx tsc --noEmit

- name: Test
# `vitest run` -- exits 1 on an empty suite, so this also fails if the
# test files are ever removed.
run: npm test

- name: Build
# The same command hatch_build.py runs when bundling the SPA into the
# wheel -- if this fails, `uv tool install` from git fails too.
Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,16 @@ kbagent data-app git-credentials-create --project NAME --app-id ID --type ssh_ke

kbagent component list [--project NAME] [--type TYPE] [--query QUERY]
kbagent component detail --component-id ID [--project NAME]
# component detail (since vNEXT): the AI Service indexes the PUBLIC catalog only, so a private/
# deprecated component the project can run (keboola.mcp-server-tool, keboola.data-apps) 404'd
# there while `component list` showed it -- over `serve` as an HTTP 502. A NOT_FOUND now falls
# back to the project's Storage component catalog; `documentation_source` ("ai_service" vs
# "storage_catalog") tells the two apart and is present on BOTH paths. The fallback has NO
# configuration examples (examples_count/row_examples_count always 0, schema_summary counts 0
# unless the catalog entry ships a configurationSchema) -- check documentation_source before
# reading 0 as "this component has none". NOT_FOUND still raised when both sources miss; a
# non-404 AI Service failure is never masked. Over `serve`, ErrorCode.NOT_FOUND now maps to
# HTTP 404 (was 502) on EVERY route -- branch on error.code, not on the status alone.
kbagent component sync-action ACTION_NAME --component-id ID --project ALIAS (--config-id ID [--row-id ID] | --config-data JSON|@file|-) [--branch ID] [--timeout N]
# sync-action (0.73.0+): POST sync-actions.{stack}/actions; ACTION_NAME freeform (component-defined,
# e.g. testConnection/getTables); --row-id shallow-merges row over root at TOP level only (row
Expand Down
31 changes: 31 additions & 0 deletions docs/web-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ else lives here, with their own agents that know their projects.

Auto-generated OpenAPI spec at `/openapi.json`, Swagger UI at `/docs`.

An upstream Keboola failure surfaces through one global handler: a
`NOT_FOUND` answers **404** (since vNEXT — it used to be 502, which told
callers to retry a request that can never succeed), an expired/missing browser
session answers **401**, and every other `KeboolaApiError` answers **502**. The
body is always the `{"status": "error", "error": {"code", "message"}}`
envelope, so the `error.code` — not the HTTP status alone — is what a client
should branch on. `GET /components/{id}` in particular no longer 404s for a
component the AI Service does not index: it falls back to the project's Storage
catalog and marks the response `documentation_source: "storage_catalog"`.

### Streaming endpoints (Server-Sent Events)

- `/jobs/{project}/{job_id}/stream` — live job status transitions + log tail.
Expand Down Expand Up @@ -167,6 +177,27 @@ A NERD-themed React SPA that drives the API:
Manage API token. The UI prompts for it per-action via a hidden modal,
forwards as `X-Manage-Token` for that one request, never persists.

**Shareable deep links.** Every view is addressable, so a URL copied out
of the address bar reopens exactly what the sender was looking at. The
whole navigation state lives in the location hash: `#/<page>` for a page
with no project context, `#/p/<project>/<page>` once a project is
selected, `?branch=<id>` for a dev branch, and `?sel=<object>` for the
page's selected object — a job id on Jobs, `<component>/<config>` on
Configs, and so on. The hash rather than a path, because the SPA is
mounted at the **root** of the same FastAPI app that serves the REST API:
a history-mode `/projects` would collide with the endpoint that returns
JSON, while everything after `#` is never sent to the server at all.
`sel` is opaque to the router — the page that writes it defines its
shape, and it is dropped on any page / project / branch change, since an
object id from one context means nothing in the next. A link whose object
no longer resolves in the current project simply opens the page with no
drawer.

Detail drawers render an **Overview** tab (the payload's fields as
labelled sections, with pills for status and nested blobs kept verbatim)
and keep the untouched response one click away under **Raw JSON** with a
copy button, so nothing the API returned is ever hidden.

## Architecture

Three processes, three languages, one HTTP/JSON contract between each
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def
- `component sync-action ACTION_NAME --component-id ID --project ALIAS (--config-id ID [--row-id ID] | --config-data JSON|@file|-) [--branch ID] [--timeout N]` (since 0.73.0) -- run a synchronous component action (`testConnection`, `getTables`, ...) on the `sync-actions.{stack}` service. `ACTION_NAME` is freeform (component-defined; discover via `component detail` `synchronous_actions`). `--row-id` shallow-merges the row over the root config at TOP level only (row `parameters`/`storage` replace root wholesale -- NOT deep merge; MCP `run_sync_action` parity). `--config-data` sends explicit `configData` verbatim. Response is action-specific pass-through. Ports the `run_sync_action` MCP tool. **Since 0.89.0 (#620)** the ROOT configuration's `authorization` and `runtime` blocks are forwarded into `configData` too -- root only (a `--row-id` never overrides them), and only when non-empty. `authorization.oauth_api.id` is the OAuth broker reference the sync-actions service resolves and decrypts, so on 0.88.0 and earlier every sync action on an OAuth / Service-Account component (`keboola.ex-linkedin-ads`, ...) failed with an opaque empty-body 400.
- `config examples --component-id ID [--project NAME] [--row]` (since 0.73.0) -- sample root/row configurations from the AI-service component detail. `--json` emits `{component_id, root_examples, row_examples}`; `--row` limits to row examples. Ports the `get_config_examples` MCP tool.
- `component list [--project NAME] [--type TYPE] [--query "text"]` -- list/search components (AI-powered with `--query`)
- `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples
- `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples. **Since vNEXT** a component the AI Service does not index (private/deprecated: `keboola.mcp-server-tool`, `keboola.data-apps`) no longer errors -- it falls back to the project's Storage component catalog. `documentation_source` (`"ai_service"` vs `"storage_catalog"`) is on BOTH paths and tells them apart; the fallback has NO configuration examples (`examples_count`/`row_examples_count` always 0), so read `documentation_source` before treating 0 as "this component ships none". `NOT_FOUND` is still raised when both sources miss. See `gotchas.md`.

## Configuration Browsing
- `config list [--project NAME] [--component-type TYPE] [--component-id ID] [--branch ID] [--include-rows]` -- list configs across projects (branch-aware). With `--include-rows` each row extends to include the full `configuration` and `rows` body (noticeably larger payload -- use only when the bodies are needed; the summary default covers name/description/component/last_modified/folder)
Expand Down
28 changes: 28 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -4384,3 +4384,31 @@ documented multi-branch pull was enough (issue #649).
manifest to the branch you are actually on. `--json` callers should treat a
non-zero `summary.orphaned` as "the manifest is pointing at another branch",
not as a per-config problem.

## `component detail` falls back to the Storage catalog for un-indexed components (since vNEXT)

`component detail` reads the AI Service (`/docs/components/{id}`), which indexes
the **public** component catalog only. A private or deprecated component the
project can actually run -- `keboola.mcp-server-tool`, `keboola.data-apps` --
is listed by `component list` (Storage API) yet missing from that index, so the
command used to fail with `NOT_FOUND` for exactly the components an operator is
least likely to know by heart. Over `kbagent serve` it was worse: the global
handler mapped it to **HTTP 502**, so `GET /components/keboola.mcp-server-tool`
looked like an upstream outage worth retrying.

- **An AI Service `NOT_FOUND` now falls back to the project's Storage component
catalog** and returns the same response shape filled from the catalog entry.
- **`documentation_source` is the discriminator**: `"ai_service"` (full detail)
vs `"storage_catalog"` (fallback). It is present on BOTH paths, so a `--json`
consumer can branch on it without a version check once it is on vNEXT+.
- **The fallback carries no configuration examples.** `examples_count` /
`row_examples_count` are always `0` there, and `schema_summary` counts are `0`
unless the catalog entry itself ships a `configurationSchema`. Read
`documentation_source` before concluding "this component has no examples" --
use `config examples` / the component's own docs instead.
- **A NOT_FOUND is still raised when both sources miss** -- that is the case
where the component id really is wrong. Any non-404 AI Service failure (auth,
network, 5xx) is re-raised as itself and never masked by a catalog hit.
- **Over `serve`, a `KeboolaApiError` with code `NOT_FOUND` now answers HTTP
404, not 502** (all routers, not just components). Branch on
`error.code`, not on the HTTP status alone.
11 changes: 11 additions & 0 deletions src/keboola_agent_cli/commands/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ..config_store import ConfigStore
from ..constants import VALID_COMPONENT_TYPES
from ..errors import ConfigError, ErrorCode, KeboolaApiError
from ..services.component_service import DOCUMENTATION_SOURCE_STORAGE_CATALOG
from ._helpers import (
check_cli_permission,
emit_project_warnings,
Expand Down Expand Up @@ -130,6 +131,16 @@ def _format_component_detail(console: Console, data: dict) -> None:
if examples_count:
lines.append(f"[bold]Examples:[/bold] {examples_count} root config example(s)")

# Say so when the AI Service did not index this component: otherwise the
# missing schema/examples read as "this component has none" rather than
# "this view cannot show them".
if data.get("documentation_source") == DOCUMENTATION_SOURCE_STORAGE_CATALOG:
lines.append(
"\n[yellow]Source:[/yellow] project Storage catalog -- the Keboola AI Service "
"has no documentation indexed for this component, so its configuration schema "
"and examples are unavailable."
)

panel = Panel("\n".join(lines), title=f"Component - {name}", expand=False)
console.print(panel)

Expand Down
9 changes: 9 additions & 0 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,15 @@

kbagent component detail --component-id ID [--project NAME]
Show component docs, config schema, and examples count.
(since vNEXT) The AI Service indexes the PUBLIC catalog only, so a private
or deprecated component the project can run (keboola.mcp-server-tool,
keboola.data-apps) used to 404 here while `component list` showed it. A
NOT_FOUND now falls back to the project's Storage component catalog.
documentation_source ("ai_service" vs "storage_catalog") is present on BOTH
paths and tells them apart; the fallback carries NO configuration examples
(examples_count / row_examples_count always 0), so check
documentation_source before reading 0 as "this component ships none".
NOT_FOUND is still raised when both sources miss.

kbagent component sync-action ACTION_NAME --component-id ID --project ALIAS (--config-id ID [--row-id ID] | --config-data JSON|@file|-) [--branch ID] [--timeout N]
(since 0.73.0) Run a synchronous component action (testConnection, getTables,
Expand Down
13 changes: 10 additions & 3 deletions src/keboola_agent_cli/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,9 +485,11 @@ def _format_error(

# A browser-login session backing a session-registered project is USER-scoped
# and lives on the host, so its failures are the caller's authentication
# problem rather than an upstream fault: they answer 401, not the 502 every
# other `KeboolaApiError` maps to. The server cannot renew such a session
# itself -- a browser login only completes where a human sits.
# problem rather than an upstream fault: they answer 401, not the 502 a
# `KeboolaApiError` maps to by default (NOT_FOUND is the other exception --
# it answers 404; an upstream "no such resource" is not a Bad Gateway). The
# server cannot renew such a session itself -- a browser login only completes
# where a human sits.
_SESSION_CREDENTIAL_CODES = frozenset({ErrorCode.SESSION_EXPIRED, ErrorCode.SESSION_NOT_FOUND})

_SESSION_REMEDY_ON_HOST = (
Expand Down Expand Up @@ -666,6 +668,11 @@ async def _api_error_handler(_request, exc: KeboolaApiError):
msg = getattr(exc, "message", str(exc)) or str(exc)
if code in _SESSION_CREDENTIAL_CODES:
return _format_error(f"{msg} {_SESSION_REMEDY_ON_HOST}", code, http_status=401)
if code == ErrorCode.NOT_FOUND:
# An upstream 404 is a statement about the requested resource, not
# about the gateway: reporting it as 502 made callers retry (and
# page on-call for) a request that can never succeed.
return _format_error(msg, code, http_status=404)
return _format_error(msg, code, http_status=502)

@app.exception_handler(StarletteHTTPException)
Expand Down
Loading