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
5 changes: 3 additions & 2 deletions docs/web-server-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ auth, and the concepts behind these routes live in
[`web-server.md`](web-server.md); a running server serves the same spec
interactively at `/docs` (Swagger) and `/openapi.json`.

**227 operations** across **198 paths** and **29 routers**.
**228 operations** across **199 paths** and **29 routers**.

Paths are shown as the server registers them. Reaching them through the
Node BFF (or single-process `--ui` mode) prefixes every path with `/api`.
Expand Down Expand Up @@ -78,12 +78,13 @@ PAYG credit balance across projects (read-only). Purchase history / Stripe invoi
|---|---|---|
| `GET` | `/billing/credits` | PAYG credit balance across projects |

### `token` (4 operations)
### `token` (5 operations)

Scoped Storage API tokens -- mint (bucket read/write + component access + expiry), rotate, and revoke. A minted/rotated token's secret is returned ONCE; the acting token needs canManageTokens. Mirrors `kbagent token create|delete|refresh`.

| Method | Path | Summary |
|---|---|---|
| `GET` | `/token/list` | List Storage tokens across projects |
| `GET` | `/token/{project}/list` | List the project's Storage tokens |
| `POST` | `/token/{project}/create` | Mint a scoped Storage token |
| `POST` | `/token/{project}/delete` | Revoke a Storage token (destructive) |
Expand Down
7 changes: 6 additions & 1 deletion docs/web-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,12 @@ A NERD-themed React SPA that drives the API:
(`/token/{p}/list`): create / rotate / revoke, with the secret revealed
ONCE in a copy-to-clipboard block, and an opt-in "derive last-used"
toggle (`with_last_used=true`) that sorts dormant tokens first and
renders `never` / `unknown` / `error` as distinct pills.
renders `never` / `unknown` / `error` as distinct pills. A cross-project
**All Tokens** view reads `GET /token/list` (repeatable `?project=`,
same convention as `/jobs` and `/billing/credits`; omitted = every
registered project) — every row carries `project_alias`, and
`with_last_used=true` sorts dormant-first across every project's tokens
together rather than grouped per project.
- **Configs, Components (AI search), Storage (with per-column data
preview), Jobs (cards layout + SSE log stream), Search** — browse a
selected project.
Expand Down
37 changes: 37 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -4434,3 +4434,40 @@ fallback (`config examples` already resolved it correctly).
text's "first available" promise does not work there.
- `component sync-action` is unaffected: its `--project` is genuinely required
(exit 2 without it) on every version.

## Multi-project `job list` now merges chronologically, not grouped by project (since vNEXT)

`kbagent job list` without `--project` (and `kbagent serve`'s `GET /jobs`) fans
out to every resolved project's Queue API in parallel. Each project's own page
already comes back sorted server-side by `--sort-by`/`--sort-order`, but the
old aggregation step then re-sorted the MERGED list by
`(project_alias, str(id))` regardless of what sort the caller asked for -- so
the output read as "every job from project A, then every job from project B",
never interleaved by time. That made the aggregate view useless for "what ran
most recently across all my projects" (the driving use case for a cross-project
"All Jobs" feed) and made multi-project `job list` non-chronological even
though single-project `job list` looked correctly time-ordered.

- **Fixed**: the merged `jobs` list is now sorted globally by the SAME
`sort_by`/`sort_order` passed to (and already applied per-project by) the
Queue API -- default `startTime desc`, so the default view is one
chronological feed interleaved across every project. `sort_by` accepts the
same `JOB_SORT_FIELDS` as before (`startTime`, `endTime`, `createdTime`,
`durationSeconds`, `id`).
- **Missing values always sort last, in both `asc` and `desc`.** A job with no
`startTime` yet (e.g. still `waiting`) is "not yet comparable", not "the
oldest job" -- it never gets pulled to the front of a `desc` sort just
because the field is absent. Same rule for `endTime`/`createdTime` (empty or
absent) and `durationSeconds` (`None`, not `0` -- a genuine `0`-second job is
a real value and sorts normally).
- **Deterministic tiebreak**: ties on the sort field -- including the entire
missing-value group, and jobs that happen to share the exact same
timestamp across projects -- break on `(project_alias, str(id))` ascending.
The result never depends on which project's worker thread happened to
finish first.
- `id` sorts numerically when the value coerces to a number, else falls back
to string comparison (defensive only -- Queue API ids are numeric in
practice).
- Single-project `job list` is unaffected in practice (its one page was
already server-sorted); the fix only changes behavior once 2+ projects are
queried together.
27 changes: 26 additions & 1 deletion src/keboola_agent_cli/server/routers/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from typing import Any

from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel

from ..dependencies import ServiceRegistry, get_registry
Expand All @@ -32,6 +32,31 @@ class TokenIdBody(BaseModel):
token_id: str


@router.get("/list", summary="List Storage tokens across projects")
def list_tokens_all(
project: list[str] | None = Query(None),
with_last_used: bool = False,
registry: ServiceRegistry = Depends(get_registry),
) -> dict[str, Any]:
"""List tokens across one, many, or all registered projects.

No CLI counterpart (`kbagent token list` is single-project); this exists
for the web UI's cross-project token audit. `project` is repeatable
(`?project=a&project=b`); omitting it queries every registered project,
matching the `GET /jobs` / `GET /billing/credits` convention. Every token
row and every error entry carries `project_alias`. Failures come back in
two separate lists: `errors` (a project could not be listed at all) and
`token_errors` (the project listed fine, but one token's last-used lookup
failed and its row degraded).

`with_last_used` mirrors the single-project flag, but the per-token cost
now multiplies: one extra Storage API call PER TOKEN PER PROJECT. It also
switches the tokens' sort order from "grouped by project" to a single
dormant-first order spanning every project's tokens together.
"""
return registry.token.list_tokens_all(aliases=project, with_last_used=with_last_used)


@router.get("/{project}/list", summary="List the project's Storage tokens")
def list_tokens(
project: str,
Expand Down
124 changes: 121 additions & 3 deletions src/keboola_agent_cli/services/job_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,103 @@
# Terminal statuses for which we surface a log-tail.
_LOG_TAIL_STATUSES: frozenset[str] = frozenset({"error", "warning", "terminated"})

# Time-valued Queue API sort fields; compare lexicographically as ISO 8601
# strings, so plain string ordering already yields chronological order.
_TIME_SORT_FIELDS: frozenset[str] = frozenset({"startTime", "endTime", "createdTime"})


class _DescStr:
"""Wraps a string so an ascending tuple-key sort yields descending order.

``list.sort`` only exposes one ``reverse`` flag for the whole key, but
our key tuple mixes a direction-agnostic "is this value missing"
marker (always ascending, so missing rows land last regardless of
``sort_order``) with a direction-sensitive field value. Numeric fields
handle that by negating the number for descending order; strings
(timestamps, non-numeric ids) can't be negated, so they get wrapped
in this instead -- it inverts ``__lt__`` so plain ascending sort of
the wrapper produces descending string order.
"""

__slots__ = ("value",)

def __init__(self, value: str) -> None:
self.value = value

def __lt__(self, other: "_DescStr") -> bool:
return other.value < self.value

def __eq__(self, other: object) -> bool:
return isinstance(other, _DescStr) and self.value == other.value


def _job_sort_key(job: dict[str, Any], sort_by: str, sort_order: str) -> tuple[int, Any, str, str]:
"""Build a sort key so a single ``list.sort(key=...)`` call yields the
globally-correct chronological (or field-appropriate) merge order
across all queried projects.

Returns a 4-tuple ``(missing_flag, primary, project_alias, id)``:

- ``missing_flag``: ``0`` when the requested field has a usable value,
``1`` when it is absent/None/uncoercible. Always compared ascending
(this key tuple is never sorted with ``reverse=True``), so missing
rows sort LAST for both "asc" and "desc" -- a job without
``startTime`` (e.g. still ``waiting``) is "not yet comparable", not
"the oldest job".
- ``primary``: the field's own value, pre-transformed (negated for
numeric fields, wrapped in :class:`_DescStr` for string fields) so
that a single ascending sort already reflects ``sort_order``.
- ``project_alias`` / ``id``: the deterministic tiebreak. It resolves
ties on equal field values AND orders the missing-value group
itself (whose ``primary`` slot is a fixed placeholder).

Never raises: Queue API rows are untyped dicts, so every field access
is defensively coerced.
"""
desc = sort_order == "desc"
alias = str(job.get("project_alias", ""))
id_tiebreak = str(job.get("id", ""))

if sort_by == "durationSeconds":
raw = job.get("durationSeconds")
try:
numeric = float(raw) if raw is not None else None
except (TypeError, ValueError):
numeric = None
if numeric is None:
return (1, 0.0, alias, id_tiebreak)
return (0, -numeric if desc else numeric, alias, id_tiebreak)

if sort_by == "id":
raw = job.get("id")
if raw is None or raw == "":
return (1, (1, ""), alias, id_tiebreak)
try:
numeric_id = float(raw)
except (TypeError, ValueError):
# Non-numeric id: string fallback, grouped after the numeric
# group via the leading 1 marker (mixed id types never need
# to compare against each other directly).
return (0, (1, _DescStr(str(raw)) if desc else str(raw)), alias, id_tiebreak)
return (0, (0, -numeric_id if desc else numeric_id), alias, id_tiebreak)

if sort_by in _TIME_SORT_FIELDS:
raw = job.get(sort_by)
if not raw:
return (1, "", alias, id_tiebreak)
text = str(raw)
return (0, _DescStr(text) if desc else text, alias, id_tiebreak)

# Defensive fallback for an unexpected sort_by (should not happen --
# commands/job.py validates against JOB_SORT_FIELDS before this is
# ever called). Treat like a generic missing/string field so we
# degrade to the tiebreak order instead of crashing.
raw = job.get(sort_by)
if raw is None:
return (1, "", alias, id_tiebreak)
text = str(raw)
return (0, _DescStr(text) if desc else text, alias, id_tiebreak)


def _safe_fetch_log_tail(client: Any, job: dict[str, Any], limit: int) -> list[dict[str, Any]]:
"""Fetch the last ``limit`` events for a job; never raises.
Expand Down Expand Up @@ -258,6 +355,21 @@ def list_jobs(
aggregates results into a unified list. Per-project errors are
collected but do not stop other projects from being queried.

Each project's page is already sorted server-side by ``sort_by``/
``sort_order``, but merging several already-sorted pages by simple
concatenation would leave the aggregate grouped by project instead
of globally ordered. The merged ``jobs`` list is therefore
re-sorted client-side by the SAME ``sort_by``/``sort_order`` the
caller asked for -- e.g. the default ``startTime desc`` gives a
single chronological feed interleaved across every project, which
is what both the CLI's multi-project ``job list`` and the
cross-project "All Jobs" ``serve`` view need. A job missing the
requested field (e.g. a ``waiting`` job has no ``startTime`` yet)
sorts LAST regardless of ``sort_order`` -- it is "not yet
comparable", not "the oldest job". Ties (including the entire
missing-value group) break deterministically on
``(project_alias, str(id))``. See ``_job_sort_key``.

Args:
aliases: Project aliases to query. None means all projects.
component_id: Optional filter by component ID.
Expand All @@ -270,7 +382,8 @@ def list_jobs(

Returns:
Dict with keys:
- "jobs": list of job dicts with project_alias added
- "jobs": list of job dicts with project_alias added,
globally sorted by sort_by/sort_order (missing values last)
- "errors": list of error dicts with project_alias,
error_code, message

Expand Down Expand Up @@ -299,8 +412,13 @@ def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]:
for _alias, jobs, _ok in successes:
all_jobs.extend(jobs)

# Sort for deterministic output
all_jobs.sort(key=lambda j: (j.get("project_alias", ""), str(j.get("id", ""))))
# Merge into one globally-ordered feed. Each project's Queue API
# call already sorted its own page server-side, but naively
# concatenating those pages leaves the aggregate grouped by
# project instead of chronological (or whatever sort_by asks
# for) -- see _job_sort_key for the missing-value / tiebreak
# semantics.
all_jobs.sort(key=lambda j: _job_sort_key(j, sort_by, sort_order))
errors.sort(key=lambda e: e.get("project_alias", ""))

return {"jobs": all_jobs, "errors": errors}
Expand Down
101 changes: 101 additions & 0 deletions src/keboola_agent_cli/services/token_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@

from ..client import KeboolaClient
from ..errors import ErrorCode, KeboolaApiError
from ..models import ProjectConfig
from ._token_last_used import dormancy_rank, enrich_tokens
from .base import (
BaseService,
ResolvedProjectCredentials,
default_client_factory,
project_error_entry,
resolve_project_credentials,
)

Expand Down Expand Up @@ -155,6 +157,105 @@ def _enrich_with_last_used(
"""
return enrich_tokens(client, tokens, max_workers=self._resolve_max_workers())

def list_tokens_all(
self, *, aliases: list[str] | None = None, with_last_used: bool = False
) -> dict[str, Any]:
"""List Storage tokens across one, many, or all registered projects.

Fans out over the shared multi-project pool (see ``JobService.list_jobs``
/ ``BillingService.get_credits`` for the same idiom) and, per project,
delegates wholesale to :meth:`list_tokens` rather than re-implementing
secret stripping or ``with_last_used`` enrichment -- both stay
byte-identical to the single-project path. Per-project failures degrade
into ``errors`` without aborting the other projects.

Args:
aliases: Project aliases to query. ``None`` / empty means every
registered project.
with_last_used: Forwarded to :meth:`list_tokens` per project. Costs
one extra Storage API call per token, per project -- see the
thread-multiplication note on :meth:`_fetch_project_tokens`.

Returns:
``{"tokens": [...], "count": len(tokens), "errors": [...],
"token_errors": [...]}``. The two error lists are semantically
different and deliberately kept apart: ``errors`` holds
project-level failures (that project could not be listed at all),
while ``token_errors`` holds per-token ``with_last_used`` lookup
failures (the project listed fine; one token's event feed did
not, and its row degraded). Merging them would make a healthy
project with one degraded token read as unlistable. Every token
row and every error entry carries ``project_alias``; entries in
``token_errors`` also carry ``token_id``.
Ordering: without ``with_last_used``, tokens are grouped by
``project_alias`` (stable sort -- the per-project order
:meth:`list_tokens` produced is preserved within each group). With
``with_last_used``, the dormant-first order is applied globally
across every project's tokens via the same :func:`dormancy_rank`
helper :meth:`list_tokens` uses, not per-project groups first.
"""
projects = self.resolve_projects(aliases)

def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]:
return self._fetch_project_tokens(alias, with_last_used)

successes, errors = self._run_parallel(projects, worker)

all_tokens: list[dict[str, Any]] = []
token_errors: list[dict[str, Any]] = []
for _alias, tokens, inner_errors, _ok in successes:
all_tokens.extend(tokens)
token_errors.extend(inner_errors)

if with_last_used:
all_tokens.sort(key=dormancy_rank)
else:
all_tokens.sort(key=lambda t: t.get("project_alias", ""))
errors.sort(key=lambda e: e.get("project_alias", ""))
token_errors.sort(key=lambda e: (e.get("project_alias", ""), str(e.get("token_id", ""))))

return {
"tokens": all_tokens,
"count": len(all_tokens),
"errors": errors,
"token_errors": token_errors,
}

def _fetch_project_tokens(self, alias: str, with_last_used: bool) -> tuple[Any, ...]:
"""Fetch + stamp one project's token listing for the cross-project fan-out.

Calls :meth:`list_tokens` (which owns client creation/close for this
project) instead of duplicating its logic, so secret-stripping and
``with_last_used`` semantics can never drift between the single- and
multi-project paths.

NOTE on thread count: when ``with_last_used`` is set, ``list_tokens``
itself fans out one thread per token (:meth:`_enrich_with_last_used`).
Nested inside this method's own caller -- the per-project
``ThreadPoolExecutor`` in ``BaseService._run_parallel`` -- that means
up to ``max_workers`` projects each spinning up their own
``max_workers`` per-token pool concurrently. This is deliberate and
acceptable: every request is I/O-bound (a Storage API call) and both
pools are bounded by the same ``_resolve_max_workers()`` ceiling, so
the worst case is a burst of short-lived threads, not unbounded growth.

Returns a 4-tuple ``(alias, tokens, inner_errors, True)`` on success
(the ``BaseService._run_parallel`` contract only requires "not
length-2"), or ``(alias, error_dict)`` on failure.
"""
try:
result = self.list_tokens(alias=alias, with_last_used=with_last_used)
except KeboolaApiError as exc:
return (alias, project_error_entry(alias, exc))
except Exception as exc:
return (alias, project_error_entry(alias, exc))

tokens = result["tokens"]
for token in tokens:
token["project_alias"] = alias
inner_errors = [{**error, "project_alias": alias} for error in result.get("errors", [])]
return (alias, tokens, inner_errors, True)

def delete_token(self, *, alias: str, token_id: str) -> dict[str, Any]:
"""Revoke a token immediately in ``alias``'s project."""
creds = self._resolve_project(alias)
Expand Down
Loading
Loading