feat(storage): table snapshots -- create/list/detail/delete + table-from-snapshot (0.75.0, #512)#516
Conversation
…rom-snapshot (0.75.0, #512) Issue #512: kbagent could not create a table from an existing snapshot. Adds the full snapshot lifecycle as first-class storage commands: - storage snapshot-create -- point-in-time backup (data + columns + PK); async tableSnapshotCreate job, receipt carries snapshot_id (write) - storage snapshots -- list a table's snapshots (read) - storage snapshot-detail -- global snapshot ID -> embeds source table (read) - storage snapshot-delete -- batch-tolerant, forecloses restores (destructive) - storage table-from-snapshot -- create a NEW table from a snapshot (write) Endpoint contract verified against the reference PHP client (keboola/storage-api-php-client) and live against connection.us-east4.gcp.keboola.com (project 5946, 2026-07-22): - Restore goes through the classic tables-async endpoint with a snapshotId source; tables-definition (used by create-table) does not accept snapshots -- hence a dedicated command, not a create-table flag. - --name is REQUIRED: the live API rejects an omitted/empty name; the PHP client's 'defaults to snapshot name' docblock is stale. - No overwrite semantics: restoring onto an existing table name fails; restore under a new name, verify, then swap-tables/delete-table. New modules (storage.py/storage_service.py are past their file-size ceilings): services/snapshot_service.py, commands/_storage_snapshots.py (mounted flat onto storage_app so permission keys stay storage.*). Tests: 30 unit tests across three layers (HTTP shape via pytest_httpx, service logic, CliRunner) + E2E step 11.2 snapshot roundtrip in tests/test_e2e.py (create -> list -> detail -> dry-run -> restore -> verify rows/columns/PK -> cleanup). Docs: context.py, CLAUDE.md, SKILL.md (regenerated), commands-reference.md, gotchas.md (since v0.75.0), keboola-expert.md matrix rows (58.7KB, under the 62KB gate).
…rting exit 0 _test_mcp_parity_commands (added in #508) deliberately runs 'component sync-action testConnection' with a bad config and asserts exit_code != 0 -- then immediately called _json(result), which asserts exit_code == 0. The step could never pass; every full-E2E run since 0.73.0 died there. Parse the error envelope with json.loads directly.
…NG checklist (#512) Completes the per-command checklist items missed in the first commit: - serve 1:1 parity: 5 snapshot routes in server/routers/storage.py (POST tables/{p}/{t}/snapshots, GET snapshots/{p}/{t}, GET snapshot-detail/{p}/{id}, DELETE snapshots/{p}, POST table-from-snapshot/{p}) + SnapshotService in ServiceRegistry; 5 router->service kwarg-parity tests in test_server_router_calls.py - new references/snapshot-workflow.md (backup -> find -> restore-as-new -> swap promote -> cleanup; the three verified traps) + SKILL.md bottom-table row + description trigger keywords (table snapshot, snapshot restore, create table from snapshot, table backup) - keboola-expert.md §3 inline gotcha (restore never overwrites; --name required; snapshot-delete forecloses restores only) -- 59.0KB, under the 62KB prompt budget
…sktop cap Adding the snapshot trigger keywords pushed the description to 1145 chars (the cap check in test_skill_frontmatter guards issue #447). Compress stale phrasing (dev branches, flows and schedules, Developer Portal, drop 'datasets' from the semantic-layer parenthetical) instead of dropping the new triggers; lands at 1013 chars.
…taleness) Step 40 asserted the new/removed column on the FIRST table-detail read after the DDL receipt. Measured live on us-east4.gcp (2026-07-22): the column listing is read-after-write eventually consistent -- the add-column receipt is authoritative but an immediate table-detail can serve stale metadata for ~5-10s (wider when the table recently had snapshot activity; reproduced 2x in-suite and 1x standalone, resolved by itself in every case). New _poll_columns helper polls up to ~30s before asserting, on both the add and the delete side.
padak
left a comment
There was a problem hiding this comment.
Review of #516 — feat(storage): table snapshots -- create/list/detail/delete + table-from-snapshot (0.75.0, #512)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
This PR adds the full table-snapshot lifecycle (snapshot-create, snapshots, snapshot-detail, snapshot-delete, table-from-snapshot) as new kbagent storage commands, closing #512. Implementation is clean 3-layer: new client.py methods, a new SnapshotService, and a new thin commands/_storage_snapshots.py module (correctly split out because storage.py/storage_service.py are already past their CONTRIBUTING.md size ceilings). Every plugin-synchronization surface (AGENT_CONTEXT, CLAUDE.md, keboola-expert.md matrix + gotcha, SKILL.md, commands-reference.md, gotchas.md with version tag, new snapshot-workflow.md), OPERATION_REGISTRY, and the kbagent serve REST routes (with kwarg-parity tests) are all present. Test coverage is thorough (client/service/CLI unit tests + router-parity tests + a full E2E roundtrip), and make check passes clean (4648 passed, 8 skipped). Verdict: COMMENT — no blocking issues found; two non-blocking design nits worth the author's attention.
Verdict
- Verdict: COMMENT
- Blocking findings: 0
- Non-blocking findings: 2
- Nits: 2
Blocking findings
(none)
Non-blocking findings
[NB-1] src/keboola_agent_cli/services/snapshot_service.py:240 — new _resolve_project return is a heterogeneous 2-tuple
_resolve_project(self, alias: str) -> tuple[str, str] returns (stack_url, token) — two semantically-distinct strings (a URL and a secret credential) that a caller has to remember the positional order of (stack_url, token = self._resolve_project(alias), five call sites). Per CONTRIBUTING.md "Return values -- name them with dataclasses, not tuples", this is exactly the case that should be a small @dataclass(frozen=True) (e.g. ResolvedProject(stack_url: str, token: str)) rather than a bare tuple. This is a genuinely new tuple[...] return added by this diff (the ~63 pre-existing ones are grandfathered) — it happens to mirror the identical pattern already in token_service.py/stream_service.py (the module docstring says "mirrors the TokenService layout"), so fixing it in isolation here would create inconsistency with those two siblings. Worth a small follow-up across all three rather than blocking this PR.
[NB-2] src/keboola_agent_cli/client.py — new snapshot methods added to a file already ~2x over its hard LOC ceiling
client.py is 3962 lines against CONTRIBUTING.md's 2000-line hard ceiling for client.py/manage_client.py ("splitting is required before merging more functionality into it"). This PR adds ~120 lines of new snapshot methods on top of that. The PR correctly avoided growing commands/storage.py and services/storage_service.py further (both already flagged in the PR description as past-ceiling, with new sibling modules created instead) — the same discipline wasn't extended to client.py. This is pre-existing, systemic debt (the file was already ~3000 LOC per CONTRIBUTING.md's own historical note) rather than something this PR introduced, so not a merge blocker, but worth a follow-up issue to split client.py by endpoint family (client/storage.py, client/queue.py, ...) per the guidance already in CONTRIBUTING.md.
Nits
[NIT-1]src/keboola_agent_cli/commands/_storage_snapshots.py:34—_handle_errors(formatter: Any, exc: Exception) -> Nonedeviates from the established-> NoReturnconvention used by the identical helper in sibling modules (commands/feature.py:37,commands/token.py:39,commands/stream.py:46), all of which importNoReturnfromtyping. Functionally harmless (the function does always raise), butNoReturngives static analysis better information and matches the pattern this module explicitly says it mirrors.[NIT-2]src/keboola_agent_cli/server/routers/storage.py:449vs:465— the create route nests under/tables/{project}/{table_id}/snapshots(POST) while the sibling list route is/snapshots/{project}/{table_id}(GET), not/tables/{project}/{table_id}/snapshots(GET). Minor asymmetry between the two closest-related routes; low value fix given the router file's existing naming is already loosely conventioned (table-detail,table-preview, etc. as flat top-level segments) and every route has an explicit kwarg-parity test regardless.
Verification log
gh auth status→ authenticated aspadak, token scopesrepo, workflow, ...✓gh pr view 516 --json title,body,files,additions,deletions,baseRefName,headRefName,labels,state→ OPEN, 23 files, +2003/-30, basemain, conventional prefixfeat(storage):matches new-behavior change type ✓git rev-parse --abbrev-ref HEAD→feat/512-table-snapshots(matches<branch>; working tree already on the PR branch, no checkout performed) ✓gh pr diff 516→ 2350-line diff fetched to scratch file;gh pr view --json files→ 23-file list fetched ✓- 3-layer violation greps (
typer/clickin services;httpx/requestsin commands;formatter/console.print/typerin clients) → all empty, no layer violations ✓ - Manual read of
client.py,commands/_storage_snapshots.py,services/snapshot_service.py,permissions.py,cli.py,server/routers/storage.py,server/dependencies.pydiffs → commands are thin (parse → service call → format/error-map), service does all business logic + validation (blank-input rejection, project resolution), client does all HTTP + job polling ✓ permissions.pyOPERATION_REGISTRY→ all 5 new commands present with correct categories (snapshots/snapshot-detail=read,snapshot-create/table-from-snapshot=write,snapshot-delete=destructive), matching CONTRIBUTING.md's read/write/destructive definitions ✓- Plugin synchronization map walk:
commands/context.py(new "Table Snapshots" section, 5 commands) ✓,CLAUDE.md## All CLI Commands(5 lines + inline note) ✓,keboola-expert.md§2 (2 new matrix rows) + §3 (1 new gotcha, tagged 0.75.0) ✓,SKILL.mddecision table (5 rows) + description triggers + workflow-table row ✓,commands-reference.md(5 bullets, each(since v0.75.0)) ✓,gotchas.md(new section tagged(since v0.75.0)) ✓, newsnapshot-workflow.md(104 lines) ✓,server/routers/storage.py1:1 REST mirror (5 routes) +server/dependencies.pyServiceRegistrywiring ✓ — all "NO" (silent-drift) rows from the sync map are covered wc -c plugins/kbagent/agents/keboola-expert.md→ 59002 bytes, under the actualPROMPT_BYTE_BUDGET = 62_000enforced bytests/test_agent_prompt.py(PR description's "under the 62 KB budget" claim confirmed against the real test threshold, not the slightly-stale 60 KB figure in CLAUDE.md's own prose) ✓make check→ruff check/ruff format --checkclean;ty checkreports 3 pre-existing warnings unrelated to this diff (scripts/hatch_build.py,tests/test_mcp_deprecation_warnings.py,tests/test_tool_call_permissions.py— none touched by this PR; downgradedunresolved-importrule, non-blocking per CONTRIBUTING.md);SKILL.md is up-to-date;version is in sync;check_command_sync.py→ "OK: all 251 CLI commands are registered (OPERATION_REGISTRY) and documented";generate_changelog.py --check→ all 43 stable releases have entries;check_error_codes.py→ OK, no raw literals; full suite → 4648 passed, 8 skipped (exact match to the PR description's claimed count) ✓uv run kbagent storage --help→ new "Snapshots" rich-help panel lists all 5 commands with correct one-line summaries ✓uv run kbagent storage table-from-snapshot --help/snapshot-delete --help→ option help text renders correctly, matches docs (--namerequired,--dry-run/--yespresent on delete) ✓uv run python -c "... app.openapi() ..."→ confirmed all 5/storage/*snapshot*REST routes are registered on the FastAPI app (POST /storage/tables/{p}/{t}/snapshots,GET /storage/snapshots/{p}/{t},GET /storage/snapshot-detail/{p}/{id},DELETE /storage/snapshots/{p},POST /storage/table-from-snapshot/{p}) ✓wc -lon new/touched files →commands/_storage_snapshots.py299 LOC (soft 800/hard 1200),services/snapshot_service.py247 LOC (soft 1000/hard 1500) — both well within budget;client.py3962 LOC vs hard ceiling 2000 (pre-existing over-ceiling file, PR adds ~120 lines to it, see NB-2) ✓- Convention greps (magic numbers, raw
error_code="..."strings, bareexcept:,print()in prod code, token leakage in new log/print statements) → all empty/false-positive-only (token hits are legitimate constructor params, never logged) ✓ - New
-> tuple[...]annotations in the diff → exactly one:snapshot_service.py:240(see NB-1); no others ✓ - Could not reproduce the PR's live-API claims myself: this worktree has no registered
kbagentconfig-dir / project token, and per team convention the reviewer never handles or provisions API tokens. The PR description documents specific live verification (project 5946,connection.us-east4.gcp.keboola.com, 2026-07-22, with the exact API error string for the missing---namecase) — I could not independently confirm this against a live stack, but the 30 unit tests encode matching request/response shapes viapytest_httpx(exact URLs, request bodies, 202-vs-204 delete branching, job-polling), which is internally consistent with the documented API contract.
Open questions for the author
(none)
Matches the identical helper in commands/token.py, feature.py, stream.py.
|
Self-review findings disposition (author):
|
|
Follow-ups from this review are now tracked:
|
…rl, token) tuple (#516 NB-1) The single-project services (token / stream / snapshot) each carried a byte-identical `_resolve_project(alias) -> tuple[str, str]` returning (stack_url, token) -- a bare heterogeneous 2-tuple whose positional order a caller has to remember. CONTRIBUTING.md ("Return values -- name them with dataclasses, not tuples") forbids this for new code; PR #516 review flagged the new snapshot_service instance (NB-1), and fixing the trio together keeps them consistent rather than diverging from the two grandfathered siblings. - New `ResolvedProjectCredentials` frozen dataclass + shared `resolve_project_credentials(config_store, alias)` helper in services/base.py (the existing home for cross-service free functions like default_client_factory), which also removes the previously-triplicated lookup body. - All three `_resolve_project` helpers delegate to it; call sites now read `creds.stack_url` / `creds.token` instead of positional unpacking. - The "not registered" ConfigError message is preserved verbatim (not switched to the richer ConfigStore.project_not_found_error) so behavior is identical. - Drops the now-unused ConfigError import from token_service. make check clean (4648 passed, 8 skipped); no behavior change.
…url, token) tuple (#516 NB-1) (#519) The three single-project services (token / stream / snapshot) carried a byte-identical _resolve_project returning a bare (stack_url, token) tuple. New ResolvedProjectCredentials frozen dataclass + shared resolve_project_credentials() helper in services/base.py; all three helpers delegate to it, call sites read creds.stack_url / creds.token. The 'not registered' ConfigError message is preserved verbatim -- no behavior change. make check clean (4648 passed).
Closes #512.
What
kbagent could not create a table from an existing snapshot. This PR adds the full table-snapshot lifecycle as first-class
storagecommands (0.75.0):storage snapshot-createsnapshot_idstorage snapshotsstorage snapshot-detailtableobject (global IDs trace back to their table)storage table-from-snapshotstorage snapshot-deleteUX decision (the issue asked to confirm)
Dedicated commands, not a flag on
create-table: restore goes through the classictables-asyncendpoint with asnapshotIdsource, whilecreate-tableusestables-definition, which does not accept snapshots. Endpoint contract verified against the reference PHP client (keboola/storage-api-php-client:createTableFromSnapshot,createTableSnapshot,listTableSnapshots,getSnapshot,deleteSnapshot).Verified live on
connection.us-east4.gcp.keboola.com(project 5946, 2026-07-22):--nameis REQUIRED — the live API rejects an omitted/empty name (Table create option "name" is required and cannot be empty); the PHP client's "defaults to snapshot name" docblock is stale.swap-tables/delete-table.isTyped).Architecture
New modules (
commands/storage.pyandservices/storage_service.pyare past their CONTRIBUTING.md size ceilings — not propagating):services/snapshot_service.py,commands/_storage_snapshots.py(mounted flat ontostorage_app, so permission keys stay in thestorage.*namespace). Client methods inclient.py(async ops poll via_wait_for_storage_job).kbagent servegets the 1:1 routes (server/routers/storage.py) withSnapshotServicewired intoServiceRegistry.Ride-along E2E fixes (pre-existing, found while verifying)
_test_mcp_parity_commands(added in feat: MCP parity commands + fail-closed tool firewall (0.73.0, epic #390 + #478 phase 0) #508) called_json()— which asserts exit 0 — on a command it expects to fail; the full suite could never pass step 38.5 since 0.73.0. Fixed to parse the error envelope directly.table-detailread. Measured live: the column listing is read-after-DDL eventually consistent (~5–10 s lag, wider with recent snapshot activity on the table; reproduced twice in-suite + once standalone, self-resolving every time). New_poll_columnshelper polls up to ~30 s on both the add and delete side.With both fixes the full
TestFullE2Esuite passes end-to-end (5:07, config-dir mode against project 5946) — including new snapshot step 11.2. Note: the nightly E2E workflow has been red for 40+ runs for an unrelated reason (expiredE2E_API_TOKENrepo secret; token5946-...v0jXis invalid — needs a maintainer rotation).Tests
tests/test_storage_snapshots.py): HTTP shape viapytest_httpx(URLs, branch prefixes, job polling, 204-vs-202 delete), service logic (error accumulation, dry-run short-circuits, blank-input rejection, client close), CLI (CliRunner: JSON shapes, exit codes 1/2/5, confirm-abort, active-branch resolution).tests/test_server_router_calls.py).make checkgreen (4648 passed).Plugin / docs surfaces touched (per the CONTRIBUTING.md sync map)
src/keboola_agent_cli/commands/context.py— new "Table Snapshots" section inAGENT_CONTEXTCLAUDE.md## All CLI Commandsplugins/kbagent/agents/keboola-expert.md— two §2 matrix rows + §3 inline gotcha (59.0 KB, under the 62 KB budget)plugins/kbagent/skills/kbagent/SKILL.md— regenerated table (make skill-gen) + description triggers (compressed to stay under the 1024-char Claude Desktop cap) + workflow-table rowplugins/kbagent/skills/kbagent/references/commands-reference.md— five bullets(since v0.75.0)plugins/kbagent/skills/kbagent/references/gotchas.md—(since v0.75.0)sectionplugins/kbagent/skills/kbagent/references/snapshot-workflow.md— new workflow doc (backup → find → restore-as-new → swap-promote → cleanup)permissions.pyOPERATION_REGISTRY (5 entries),changelog.py(0.75.0),plugin.json/marketplace.jsonviamake version-sync