diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 45f881bb..fe08cf8b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.43.5", + "version": "0.43.6", "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" diff --git a/CLAUDE.md b/CLAUDE.md index 0f3fa5e6..a979e630 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -338,7 +338,7 @@ kbagent search QUERY [--project NAME] [--type table|bucket|config|flow|data-app| kbagent job list [--project NAME] [--component-id ID] [--status STATUS] [--limit N] kbagent job detail --project NAME --job-id ID -kbagent job run --project NAME --component-id ID --config-id ID [--row-id ID ...] [--wait] [--timeout N] [--branch ID] [--variable-values-id ID] [--no-variables] [--poll-strategy exponential|fixed] [--log-tail-lines N] +kbagent job run --project NAME --component-id ID --config-id ID [--row-id ID ...] [--wait] [--timeout N] [--branch ID] [--mode run|debug] [--variable-values-id ID] [--no-variables] [--poll-strategy exponential|fixed] [--log-tail-lines N] kbagent job terminate --project NAME (--job-id ID [--job-id ID ...] | --status any|created|waiting|processing [--component-id ID] [--config-id ID] [--branch ID] [--limit N]) [--dry-run] [--yes] kbagent storage buckets [--project NAME] [--branch ID] diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 7088f128..075b1e82 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.43.5", + "version": "0.43.6", "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", diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 4b21aff9..39d9183d 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -75,7 +75,7 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the ## Job History - `job list [--project NAME] [--component-id ID] [--config-id ID] [--status STATUS] [--limit N]` -- list jobs (default 50, max 500) - `job detail --project NAME --job-id ID` -- full job detail with timing and result message -- `job run --project NAME --component-id ID --config-id ID [--row-id ID ...] [--wait] [--timeout N] [--branch ID] [--variable-values-id ID] [--no-variables] [--poll-strategy exponential|fixed] [--log-tail-lines N]` -- run a job, optionally wait for completion (branch-aware). For configs with linked `keboola.variables` (root-level `configuration.variables_id`), kbagent auto-resolves a `variableValuesId` so transformations bind to the deployed values row. `--variable-values-id` overrides; `--no-variables` skips resolution. `NO_VARIABLE_ROWS` when the linked variables config has zero rows -- fix via `kbagent config variables-set`. Under `--wait`, polls with an exponential curve (2s x 30 -> 5s x 48 -> 15s); `--poll-strategy fixed` keeps a constant 1s interval. On FAILED/WARNING/TERMINATED, the last `--log-tail-lines` events (default 200, **0 disables -- recommended for automation pipelines**) are attached as `logTail` in the JSON result (or `details.logTail` on errors). If `--timeout` expires, kbagent issues `kill_job` on the remote and exits **7** (`JOB_TIMEOUT_TERMINATED`) with the cancelled `details.job` + `details.logTail`; if the kill itself fails, exits **4** (`QUEUE_JOB_TIMEOUT`, `retryable=true`). Use jq pattern `.error.details.logTail? // .data.logTail? // []` to pick up the tail regardless of exit code. +- `job run --project NAME --component-id ID --config-id ID [--row-id ID ...] [--wait] [--timeout N] [--branch ID] [--mode run|debug] [--variable-values-id ID] [--no-variables] [--poll-strategy exponential|fixed] [--log-tail-lines N]` -- run a job, optionally wait for completion (branch-aware). For configs with linked `keboola.variables` (root-level `configuration.variables_id`), kbagent auto-resolves a `variableValuesId` so transformations bind to the deployed values row. `--variable-values-id` overrides; `--no-variables` skips resolution. `NO_VARIABLE_ROWS` when the linked variables config has zero rows -- fix via `kbagent config variables-set`. `--mode debug` (since v0.43.6) sets the Queue API job `mode` body field to `"debug"`: the component runs with the same configuration + inputs but the worker redirects output to a Storage File tagged `debug-` instead of writing to destination buckets. Use for dry-runs, reproducing a failing job on a production config without touching downstream tables, or harvesting the worker's output bytes (download via `storage file-download --tag debug-`) to feed into VCR fixtures or component test cases. Default `--mode run` is unchanged. Invalid values (`--mode anything-else`) exit 2 at the Click choice gate before any wire call. Under `--wait`, polls with an exponential curve (2s x 30 -> 5s x 48 -> 15s); `--poll-strategy fixed` keeps a constant 1s interval. On FAILED/WARNING/TERMINATED, the last `--log-tail-lines` events (default 200, **0 disables -- recommended for automation pipelines**) are attached as `logTail` in the JSON result (or `details.logTail` on errors). If `--timeout` expires, kbagent issues `kill_job` on the remote and exits **7** (`JOB_TIMEOUT_TERMINATED`) with the cancelled `details.job` + `details.logTail`; if the kill itself fails, exits **4** (`QUEUE_JOB_TIMEOUT`, `retryable=true`). Use jq pattern `.error.details.logTail? // .data.logTail? // []` to pick up the tail regardless of exit code. - `job terminate --project NAME (--job-id ID [--job-id ...] | --status any|created|waiting|processing [--component-id ID] [--config-id ID] [--branch ID] [--limit N]) [--dry-run] [--yes]` -- kill running Queue API jobs. Use to stop runaway loops or clean up pile-ups from repeated `job run` calls. Two modes: by ID (single/batch) or by filter (`--status any` catches every killable state). Response partitions IDs into `killed / already_finished / not_found / failed`; safe to re-run idempotently. Kill is async -- poll `job detail` for `isFinished=true`. ## Storage diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index debf82f2..65acc340 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -121,6 +121,42 @@ limit, transient 5xx), the detail call still succeeds and `storage_workspace_id` is set to `null` -- the annotation is UX, not a contract. +## `kbagent job run --mode debug` redirects output to a Storage File, not the destination buckets (since v0.43.6) + +`kbagent job run` accepts `--mode run|debug` (default `run`). The flag is +threaded straight into the Queue API job-creation body as `"mode": "..."`; +`run` is the unchanged historical wire shape. `--mode debug` flips the Queue +worker into debug execution: the component starts with the same configuration +and inputs as a normal run, but its output stream is **not** written to the +configured destination buckets. Instead the worker uploads the output bytes +to a Storage File tagged `debug-` (you can pull it down with +`kbagent storage file-download --tag debug-`). Useful for: + +- **Dry-runs against production configs** -- reproduce a failing job exactly + as it happened in prod without touching downstream tables, and inspect the + worker's actual output bytes after the fact. +- **Seeding component test fixtures** -- harvest the debug output file as + ground-truth input for a new VCR recording or a component test case (the + intended use case that drove this flag). +- **Validating a config change before merging** -- run the new config in + debug mode, diff the output file against the previous debug file, only + promote once the diff is acceptable. + +The CLI gates the flag with `click.Choice` so a typo like `--mode dry-run` +exits 2 with a Click usage error before any wire call -- it cannot reach the +Queue API and surface as an opaque 422. Service-layer also validates against +`VALID_JOB_MODES` so direct programmatic callers get a +`KeboolaApiError(INVALID_ARGUMENT)` rather than a silent passthrough. + +The human-mode `Running ...` banner appends a bold-yellow `mode=debug` chip +when the flag is non-default, so operators see at a glance that a run is +diagnostic, not production. `--json` output shape is unchanged (the mode is +visible on the returned job dict via the Queue API's own response shape). + +Before this release the `mode` parameter existed on `KeboolaClient.create_job` +but neither `JobService.run_job` nor `commands/job.py` exposed it, so every +job created via `kbagent` hard-coded `mode: "run"` on the wire. + ## Metastore duplicate-name POST returns 409 OR 500 -- both map to `ALREADY_EXISTS` (since v0.43.5) `MetastoreClient.post_item` normalises **both** the post-go-monorepo-PR#513 diff --git a/pyproject.toml b/pyproject.toml index 277b049a..104ba2fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.43.5" +version = "0.43.6" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index deaff8d6..b69ef3d9 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,9 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.43.6": [ + 'New: `kbagent job run --mode run|debug` exposes the Queue API job `mode` body field, which was previously hard-coded to `"run"` inside `JobService.run_job` (the underlying `KeboolaClient.create_job` already accepted a `mode` kwarg but no service-layer or CLI path threaded it through). `--mode debug` flips the Queue worker into debug mode -- the component runs with the same configuration and inputs as a normal run, but its output stream is redirected into a Storage File tagged `debug-` instead of into the destination buckets, so the run is safe to repeat on a production configuration for diagnostics (reproducing a failure, capturing the worker\'s actual output, A/B-comparing a flag change) without touching downstream tables. Default behaviour is unchanged: omit `--mode` and the body still carries `"mode": "run"`, the same wire shape every prior release sent. Validation lives at the service boundary (`KeboolaApiError` with `INVALID_ARGUMENT`) and the CLI also gates the flag with `click.Choice(sorted(VALID_JOB_MODES))`, so a typo (`--mode dry-run`) exits 2 with a Click usage error before any network round-trip -- it cannot reach the wire and surface as an opaque Queue API 422. The human-mode \'Running ...\' banner appends a bold-yellow `mode=debug` chip when the flag is non-default so operators see at a glance that a run is diagnostic, not production. The hint surface (`kbagent --hint client job run` / `--hint service`) emits the new `mode="..."` kwarg on both the `create_job` and `JobService.run_job` calls so AI agents that copy the rendered Python see the parameter inline. New constants `VALID_JOB_MODES = frozenset({"run", "debug"})` and `DEFAULT_JOB_MODE = "run"` in `constants.py`. Tests: 3 new service-layer tests in `test_services.py::TestJobServiceRunJobMode` (default lands as `mode="run"`, opt-in `mode="debug"` forwarded, unknown mode rejected at the service boundary and never reaches the wire), 3 new CLI tests in `test_cli.py::TestJobRun` (default, `--mode debug` forwarded, `--mode dry-run` exits 2 via the Click choice gate), and 2 new client-layer tests in `test_client.py::TestCreateJob` (`mode="run"` is in the POST /jobs body by default, `mode="debug"` reaches the body verbatim). Existing four `create_job.assert_called_once_with` assertions in `test_services.py` updated to include `mode="run"` since the call signature now always passes it. Plugin sync surfaces (silent-drift risks per convention #17): `CLAUDE.md ## All CLI Commands`, `commands/context.py::AGENT_CONTEXT`, `plugins/kbagent/skills/kbagent/references/commands-reference.md`, and `plugins/kbagent/skills/kbagent/references/gotchas.md` (new `(since v0.43.6)` entry) all updated so AI agents on the new version recommend `--mode debug` correctly.', + ], "0.43.5": [ "Fix: `MetastoreClient.post_item` now accepts both HTTP 409 (post go-monorepo PR #513) and the legacy HTTP 500 + `\"Failed to create meta object\"` body as the duplicate-name signal, normalising both into `ErrorCode.ALREADY_EXISTS` so command-layer error mapping (and the human-mode `'X with name Y already exists ...'` message) stays consistent across stacks during the metastore rollout. Before this release the workaround only matched the 500 shape; against a post-fix metastore the proper 409 Conflict would have bubbled up as a generic `API_ERROR` 'API error 409 ...' wrapper, blowing past the clean `ALREADY_EXISTS` path that command-layer error renderers special-case. Side benefit: 409 is not in `RETRYABLE_STATUS_CODES` (`constants.py`), so duplicate-name POSTs against a post-fix metastore stop being retried `MAX_RETRIES` times before the normalisation fires -- one round-trip instead of three. The 500 substring check is retained so unrelated 500s (DB outage, etc.) still surface as retryable `API_ERROR` rather than being miscategorised as a name collision. PATCH on a missing UUID is also fixed upstream (returns 404 now), but `BaseHttpClient._handle_error` already maps 404 -> `ErrorCode.NOT_FOUND`, so no client change is needed for that path. Docstring on `metastore_client.py` updated to describe both server-side shapes. New test in `test_metastore_client.py::TestDuplicateNameNormalization::test_duplicate_name_409_becomes_already_exists` registers a single 409 (asserting no retry happens) and verifies `error_code=ALREADY_EXISTS`, `status_code=409`, `retryable=False`, and the canonical user-facing message; the existing 500 + unrelated-500 tests stay green.", "Plugin docs: `plugins/kbagent/skills/kbagent/references/semantic-layer-workflow.md` documents the dual 409+500 shape so AI agents recommending raw HTTP calls (vs the `kbagent semantic-layer ...` group) know what shape to expect during the metastore rollout. No CLI surface change.", diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index af329904..64cf86a8 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -263,7 +263,7 @@ kbagent job detail --project NAME --job-id ID Full job detail including result message and timing. - kbagent job run --project NAME --component-id ID --config-id ID [--row-id ID ...] [--wait] [--timeout N] [--branch ID] [--variable-values-id ID] [--no-variables] [--poll-strategy exponential|fixed] [--log-tail-lines N] + kbagent job run --project NAME --component-id ID --config-id ID [--row-id ID ...] [--wait] [--timeout N] [--branch ID] [--mode run|debug] [--variable-values-id ID] [--no-variables] [--poll-strategy exponential|fixed] [--log-tail-lines N] Run a Queue API job. --row-id selects specific config rows (repeatable; omit to run entire config). --wait polls until job finishes. --timeout sets max wait in seconds (default 300). Branch-aware. When the config has linked variables (configuration.variables_id), kbagent auto-resolves @@ -274,6 +274,10 @@ --poll-strategy fixed keeps a constant 1s interval. On FAILED/WARNING/TERMINATED, the last --log-tail-lines events (default 200, 0 disables -- recommended for automation pipelines) are surfaced as `logTail` in --json output. + --mode run (default) writes to mapped output tables. --mode debug runs the component but + redirects the output to a Storage File tagged `debug-` instead of into destination + buckets -- safe for dry-runs and for reproducing a failing run on a production configuration + without touching production data. Invalid values exit 2 via Click choice gate (since v0.43.6). --json response shapes by exit code: - exit 0 (success): {{status:"ok", data:{{..., logTail?:[...]}}}} - exit 1 (QUEUE_JOB_FAILED, remote job status=error): diff --git a/src/keboola_agent_cli/commands/job.py b/src/keboola_agent_cli/commands/job.py index 759c11da..f7dc6afb 100644 --- a/src/keboola_agent_cli/commands/job.py +++ b/src/keboola_agent_cli/commands/job.py @@ -11,12 +11,14 @@ from ..config_store import ConfigStore from ..constants import ( DEFAULT_JOB_LIMIT, + DEFAULT_JOB_MODE, DEFAULT_JOB_RUN_TIMEOUT, DEFAULT_LOG_TAIL_LINES, DEFAULT_POLL_STRATEGY, KILLABLE_JOB_STATUSES, MAX_JOB_LIMIT, MAX_LOG_TAIL_LINES, + VALID_JOB_MODES, VALID_POLL_STRATEGIES, VALID_STATUSES, ) @@ -197,6 +199,19 @@ def job_run( "--branch", help="Dev branch ID (overrides active branch)", ), + mode: str = typer.Option( + DEFAULT_JOB_MODE, + "--mode", + click_type=click.Choice(sorted(VALID_JOB_MODES)), + help=( + "Queue API job mode. 'run' (default) executes the component " + "normally and writes to mapped output tables. 'debug' executes " + "the component but redirects its output to a Storage File tagged " + "'debug-' instead of the destination buckets -- safe for " + "dry-runs and for reproducing a failing run on production " + "configuration without touching production data." + ), + ), variable_values_id: str | None = typer.Option( None, "--variable-values-id", @@ -271,6 +286,7 @@ def job_run( wait=wait, timeout=timeout, branch=branch, + mode=mode, variable_values_id=variable_values_id, no_variables=no_variables, poll_strategy=poll_strategy, @@ -323,6 +339,8 @@ def job_run( msg += f" (rows: {', '.join(row_id)})" if effective_branch is not None: msg += f" on branch [cyan]{effective_branch}[/cyan]" + if mode != DEFAULT_JOB_MODE: + msg += f" [bold yellow]mode={mode}[/bold yellow]" if wait: msg += f" [dim](waiting up to {timeout:.0f}s)[/dim]" msg += "..." @@ -343,6 +361,7 @@ def job_run( no_variables=no_variables, poll_strategy=poll_strategy, log_tail_lines=log_tail_lines, + mode=mode, ) except ConfigError as exc: formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index a6f85362..1f7b6bb5 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -94,6 +94,13 @@ ) VALID_POLL_STRATEGIES: frozenset[str] = frozenset({"exponential", "fixed"}) DEFAULT_POLL_STRATEGY: str = "exponential" +# Queue API "mode" body field for job creation. "run" is the normal execution +# path (writes to mapped output tables); "debug" runs the component the same +# way but redirects the output to a Storage File tagged "debug-" + the job id +# instead of into destination buckets. Useful for dry-runs and reproducing +# failures without touching production tables. +VALID_JOB_MODES: frozenset[str] = frozenset({"run", "debug"}) +DEFAULT_JOB_MODE: str = "run" # Default log-tail length surfaced on FAILED/WARNING/TERMINATED jobs. DEFAULT_LOG_TAIL_LINES: int = 200 # Upper bound to prevent accidentally pulling tens of thousands of events diff --git a/src/keboola_agent_cli/hints/definitions/job.py b/src/keboola_agent_cli/hints/definitions/job.py index 42df1ee3..7a46f984 100644 --- a/src/keboola_agent_cli/hints/definitions/job.py +++ b/src/keboola_agent_cli/hints/definitions/job.py @@ -114,6 +114,7 @@ "config_row_ids": "{row_id}", "branch_id": "{branch}", "variable_values_id": 'var_rows[0]["id"] if var_rows else None', + "mode": "{mode}", }, result_var="job", result_hint="dict", @@ -133,6 +134,7 @@ "no_variables": "{no_variables}", "poll_strategy": "{poll_strategy}", "log_tail_lines": "{log_tail_lines}", + "mode": "{mode}", }, ), ), diff --git a/src/keboola_agent_cli/server/routers/jobs.py b/src/keboola_agent_cli/server/routers/jobs.py index 872a5319..09706fc3 100644 --- a/src/keboola_agent_cli/server/routers/jobs.py +++ b/src/keboola_agent_cli/server/routers/jobs.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from sse_starlette.sse import EventSourceResponse -from ...constants import DEFAULT_LOG_TAIL_LINES, DEFAULT_POLL_STRATEGY +from ...constants import DEFAULT_JOB_MODE, DEFAULT_LOG_TAIL_LINES, DEFAULT_POLL_STRATEGY from ..dependencies import ServiceRegistry, get_registry from ..sse import json_event @@ -24,6 +24,7 @@ class JobRun(BaseModel): branch_id: int | None = None variable_values_id: str | None = None no_variables: bool = False + mode: str = DEFAULT_JOB_MODE class JobTerminate(BaseModel): @@ -86,6 +87,7 @@ def run( no_variables=body.no_variables, poll_strategy=poll_strategy, log_tail_lines=log_tail_lines, + mode=body.mode, ) diff --git a/src/keboola_agent_cli/services/job_service.py b/src/keboola_agent_cli/services/job_service.py index 0bf3415c..e0e72cdd 100644 --- a/src/keboola_agent_cli/services/job_service.py +++ b/src/keboola_agent_cli/services/job_service.py @@ -11,11 +11,13 @@ from ..constants import ( DEFAULT_JOB_LIMIT, + DEFAULT_JOB_MODE, DEFAULT_LOG_TAIL_LINES, DEFAULT_POLL_STRATEGY, JOB_TERMINATE_GRACE_SECONDS, JOB_TERMINATE_POLL_INTERVAL, KILLABLE_JOB_STATUSES, + VALID_JOB_MODES, VALID_POLL_STRATEGIES, ) from ..errors import ErrorCode, KeboolaApiError @@ -321,6 +323,7 @@ def run_job( no_variables: bool = False, poll_strategy: str = DEFAULT_POLL_STRATEGY, log_tail_lines: int = DEFAULT_LOG_TAIL_LINES, + mode: str = DEFAULT_JOB_MODE, ) -> dict[str, Any]: """Create and optionally wait for a Queue API job. @@ -363,6 +366,12 @@ def run_job( poll_strategy: Wait cadence. One of VALID_POLL_STRATEGIES. log_tail_lines: Number of trailing events to surface on non-success terminal states. ``0`` disables the fetch. + mode: Queue API job mode. One of ``VALID_JOB_MODES`` -- ``"run"`` + (default) writes to mapped output tables; ``"debug"`` runs the + component but redirects the output to a Storage File tagged + ``debug-`` instead of into destination buckets, so the + job can be safely re-run for diagnostics without touching + production data. Returns: Job dict with ``project_alias``. If wait=True, returns the @@ -378,6 +387,17 @@ def run_job( status_code=0, error_code=ErrorCode.INVALID_ARGUMENT, ) + # Group enum-membership pre-flight checks together (poll_strategy + mode) + # before the numeric/range checks below -- both are O(1) frozenset lookups + # and they fail in the same shape (INVALID_ARGUMENT with a sorted list of + # accepted values), so keeping them adjacent makes a typo's failure mode + # symmetric for callers. + if mode not in VALID_JOB_MODES: + raise KeboolaApiError( + message=(f"Invalid mode {mode!r}. Expected one of: {sorted(VALID_JOB_MODES)}."), + status_code=0, + error_code=ErrorCode.INVALID_ARGUMENT, + ) if log_tail_lines < 0: raise KeboolaApiError( message=f"log_tail_lines must be >= 0, got {log_tail_lines}.", @@ -405,6 +425,7 @@ def run_job( config_row_ids=config_row_ids, branch_id=branch_id, variable_values_id=resolved_values_id, + mode=mode, ) job_id = str(job.get("id", "")) diff --git a/tests/test_cli.py b/tests/test_cli.py index ccb39590..2b8d1ee6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3549,6 +3549,136 @@ def test_job_run_no_variables_flag_forwarded(self, tmp_path: Path) -> None: payload = json.loads(result.output).get("data", {}) assert "resolvedVariableValuesId" not in payload + def test_job_run_mode_defaults_to_run(self, tmp_path: Path) -> None: + """Omitting --mode lands as mode='run' on the service call.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + ): + MockStore.return_value = store + job_service = MagicMock() + job_service.run_job.return_value = {"id": 702, "status": "waiting"} + MockJobService.return_value = job_service + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + + result = runner.invoke( + app, + [ + "--json", + "job", + "run", + "--project", + "prod", + "--component-id", + "keboola.ex-http", + "--config-id", + "42", + ], + ) + + assert result.exit_code == 0, result.output + assert job_service.run_job.call_args.kwargs["mode"] == "run" + + def test_job_run_mode_debug_forwarded(self, tmp_path: Path) -> None: + """--mode debug reaches the service layer unchanged. + + Locks in that the Queue API ``mode`` body field is opt-in via this + flag and not silently dropped by the CLI wrapper. + """ + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + ): + MockStore.return_value = store + job_service = MagicMock() + job_service.run_job.return_value = {"id": 703, "status": "waiting"} + MockJobService.return_value = job_service + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + + result = runner.invoke( + app, + [ + "--json", + "job", + "run", + "--project", + "prod", + "--component-id", + "keboola.ex-http", + "--config-id", + "42", + "--mode", + "debug", + ], + ) + + assert result.exit_code == 0, result.output + assert job_service.run_job.call_args.kwargs["mode"] == "debug" + + def test_job_run_mode_rejects_unknown_value(self, tmp_path: Path) -> None: + """Click's choice gate rejects unsupported --mode values (exit 2). + + Without the Choice gate a bad value would flow through to the wire + and surface as an opaque 422 from the Queue API. + """ + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _setup_config_test( + config_dir, + {"prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}}, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService, + patch("keboola_agent_cli.cli.JobService") as MockJobService, + ): + MockStore.return_value = store + job_service = MagicMock() + MockJobService.return_value = job_service + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + + result = runner.invoke( + app, + [ + "job", + "run", + "--project", + "prod", + "--component-id", + "keboola.ex-http", + "--config-id", + "42", + "--mode", + "dry-run", + ], + ) + + assert result.exit_code == 2 + job_service.run_job.assert_not_called() + def test_job_run_mutually_exclusive_flags_rejected(self, tmp_path: Path) -> None: """--variable-values-id + --no-variables is an invalid combination (exit 2).""" config_dir = tmp_path / "config" diff --git a/tests/test_client.py b/tests/test_client.py index ca4995f9..19c13068 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2697,6 +2697,40 @@ def test_create_job_without_variable_values_id_omits_field(self, httpx_mock) -> body = json.loads(httpx_mock.get_request().content) assert "variableValuesId" not in body + def test_create_job_mode_default_is_run(self, httpx_mock) -> None: + """Default body carries ``"mode": "run"`` -- the Queue API contract.""" + httpx_mock.add_response( + url="https://queue.keboola.com/jobs", + method="POST", + json={"id": 602, "status": "waiting"}, + status_code=201, + ) + + with KeboolaClient(stack_url=_BASE, token=_TOKEN) as client: + client.create_job(component_id="keboola.ex-http", config_id="42") + + body = json.loads(httpx_mock.get_request().content) + assert body["mode"] == "run" + + def test_create_job_mode_debug_in_body(self, httpx_mock) -> None: + """``mode="debug"`` reaches the Queue API body verbatim.""" + httpx_mock.add_response( + url="https://queue.keboola.com/jobs", + method="POST", + json={"id": 603, "status": "waiting"}, + status_code=201, + ) + + with KeboolaClient(stack_url=_BASE, token=_TOKEN) as client: + client.create_job( + component_id="keboola.ex-http", + config_id="42", + mode="debug", + ) + + body = json.loads(httpx_mock.get_request().content) + assert body["mode"] == "debug" + class TestKillJob: """Tests for kill_job() - Queue API POST /jobs/{id}/kill.""" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index f02793f3..831228ec 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -4592,6 +4592,124 @@ def test_resolver_prefers_explicit_values_id_over_first_row(self) -> None: assert resolved == pinned_row_id +@skip_without_credentials +@pytest.mark.e2e +class TestE2EJobRunMode: + """Prove `kbagent job run --mode debug` reaches the Queue API wire (#321 / v0.43.6). + + Submits a real job with ``--mode debug`` against a live Keboola project and + asserts that the Queue API echoes back ``mode="debug"`` on the create + response. This is the single canonical sign that the flag has not been + silently dropped anywhere on the CLI -> service -> client -> wire path -- + the same regression the kbagent-pr-reviewer subagent caught in + ``server/routers/jobs.py`` during the original review of #321. + + Intentionally does NOT use ``--wait``. The Queue accepts the job with the + mode field set even before the worker starts processing, so the create + response is the load-bearing assertion. The worker outcome (debug file + appearing in Storage Files tagged ``debug-``) is a Queue worker + behaviour, not a kbagent behaviour, and is already covered by the manual + smoke-test step in the PR description; baking a worker-side wait into the + E2E suite would add minutes per run for no extra kbagent-side proof. + """ + + @pytest.fixture(autouse=True) + def setup(self, tmp_path: Path) -> None: + self.token = os.environ[ENV_TOKEN] + raw_url = os.environ.get(ENV_URL, "connection.keboola.com") + self.url = raw_url if raw_url.startswith("https://") else f"https://{raw_url}" + self.alias = f"{RUN_ID}-jobmode" + + self.config_dir = tmp_path / "config" + self.config_dir.mkdir() + + result = _invoke( + self.config_dir, + [ + "--json", + "project", + "add", + "--project", + self.alias, + "--url", + self.url, + "--token", + self.token, + ], + ) + assert result.exit_code == 0, f"project add failed: {result.output}" + + self.client = KeboolaClient(stack_url=self.url, token=self.token) + self._created: list[tuple[str, str]] = [] + + yield + + for component_id, config_id in reversed(self._created): + try: + self.client.delete_config(component_id=component_id, config_id=config_id) + except Exception as exc: + print( + f" {_DIM}(teardown) delete_config {component_id}/{config_id} failed: {exc}{_RESET}" + ) + self.client.close() + + def test_job_run_mode_debug_reaches_queue_api(self) -> None: + """`kbagent job run --mode debug` returns a job dict carrying `mode='debug'`. + + The Queue API echoes the body's ``mode`` field on its create + response, so this is a wire-level proof that the flag was not + dropped by the CLI, service, or client. + """ + import contextlib + + _step(1, "create a minimal ex-http config to target") + parent = self.client.create_config( + component_id="keboola.ex-http", + name=f"{RUN_ID}-http-debug-mode", + description="E2E #321: --mode debug wire passthrough", + configuration={"parameters": {"baseUrl": "https://example.com"}}, + ) + parent_id = str(parent["id"]) + self._created.append(("keboola.ex-http", parent_id)) + + _step(2, "kbagent job run --mode debug --no-variables (no --wait)") + result = _invoke( + self.config_dir, + [ + "--json", + "job", + "run", + "--project", + self.alias, + "--component-id", + "keboola.ex-http", + "--config-id", + parent_id, + "--mode", + "debug", + "--no-variables", + ], + ) + + data = _json(result) + payload = data.get("data", data) + # Queue API echoes the mode field on the create response -- this is + # the load-bearing proof that the wire body carried "mode": "debug". + print(f" {_DIM}returned mode={payload.get('mode')!r} id={payload.get('id')}{_RESET}") + assert payload.get("mode") == "debug", ( + f"expected mode='debug' in Queue create response, got {payload.get('mode')!r}. " + f"Full payload keys: {sorted(payload.keys())}" + ) + + # Best-effort: kill the job so we do not waste compute waiting for + # the worker to upload the debug file. The wire assertion above is + # the actual test contract. + job_id = payload.get("id") + if job_id: + with contextlib.suppress(Exception): + self.client.kill_job(str(job_id)) + + # --------------------------------------------------------------------------- # Project pin + firewall flag E2E (PR5) # --------------------------------------------------------------------------- diff --git a/tests/test_server_smoke.py b/tests/test_server_smoke.py index b337d6b7..522ea3e0 100644 --- a/tests/test_server_smoke.py +++ b/tests/test_server_smoke.py @@ -233,3 +233,69 @@ def test_semantic_layer_routes_require_auth(client: TestClient) -> None: ): res = client.get(path) assert res.status_code == 401, f"{path} should require auth" + + +# ── POST /jobs/{project}/run -- mode field passthrough (issue #321) ── +# +# Locks the CLI <-> REST 1:1 parity contract for the v0.43.6 `--mode` +# flag: a caller hitting `kbagent serve` with `{"mode": "debug", ...}` +# must see `mode="debug"` reach `JobService.run_job` -- silently dropping +# the field would let kbagent serve / scheduled-agent / web-UI callers +# accidentally land on production execution. + + +def test_jobs_run_default_mode_is_run(tmp_path: Path) -> None: + """Omitting `mode` in the request body lands as mode='run' on the service.""" + from unittest.mock import MagicMock + + from keboola_agent_cli.server.dependencies import ServiceRegistry, get_registry + + app = create_app(config_dir=str(tmp_path), auth_token="test-token") + job_service = MagicMock() + job_service.run_job.return_value = {"id": 900, "status": "waiting"} + registry = ServiceRegistry.__new__(ServiceRegistry) + registry.job = job_service # type: ignore[attr-defined] + app.dependency_overrides[get_registry] = lambda: registry + + with TestClient(app) as test_client: + res = test_client.post( + "/jobs/prod/run", + headers={"Authorization": "Bearer test-token"}, + json={"component_id": "keboola.ex-http", "config_id": "42"}, + ) + + assert res.status_code == 200, res.text + assert job_service.run_job.call_args.kwargs["mode"] == "run" + + +def test_jobs_run_mode_debug_reaches_service(tmp_path: Path) -> None: + """`{"mode": "debug"}` in the request body reaches JobService.run_job verbatim. + + Regression guard against the original bug: `JobRun` Pydantic model used + to lack the `mode` field, so the endpoint silently hard-coded `mode="run"` + even when a caller passed `"mode": "debug"`. + """ + from unittest.mock import MagicMock + + from keboola_agent_cli.server.dependencies import ServiceRegistry, get_registry + + app = create_app(config_dir=str(tmp_path), auth_token="test-token") + job_service = MagicMock() + job_service.run_job.return_value = {"id": 901, "status": "waiting"} + registry = ServiceRegistry.__new__(ServiceRegistry) + registry.job = job_service # type: ignore[attr-defined] + app.dependency_overrides[get_registry] = lambda: registry + + with TestClient(app) as test_client: + res = test_client.post( + "/jobs/prod/run", + headers={"Authorization": "Bearer test-token"}, + json={ + "component_id": "keboola.ex-http", + "config_id": "42", + "mode": "debug", + }, + ) + + assert res.status_code == 200, res.text + assert job_service.run_job.call_args.kwargs["mode"] == "debug" diff --git a/tests/test_services.py b/tests/test_services.py index 5507c606..c9cb0a8b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -2931,6 +2931,7 @@ def test_run_job_without_branch(self, tmp_config_dir: Path) -> None: config_row_ids=None, branch_id=None, variable_values_id=None, + mode="run", ) mock_client.close.assert_called_once() @@ -2975,6 +2976,7 @@ def test_run_job_with_branch(self, tmp_config_dir: Path) -> None: config_row_ids=None, branch_id=789, variable_values_id=None, + mode="run", ) def test_run_job_with_branch_and_wait(self, tmp_config_dir: Path) -> None: @@ -3021,12 +3023,95 @@ def test_run_job_with_branch_and_wait(self, tmp_config_dir: Path) -> None: config_row_ids=None, branch_id=123, variable_values_id=None, + mode="run", ) mock_client.wait_for_queue_job.assert_called_once_with( "557", max_wait=60.0, poll_strategy="exponential" ) +class TestJobServiceRunJobMode: + """run_job propagates the Queue API ``mode`` field (#319 follow-up). + + The Queue API accepts ``"run"`` (default, writes to output tables) and + ``"debug"`` (redirects output to a debug Storage File). Coverage here + asserts the default, the opt-in debug path, and service-side rejection + of unknown values so a typo cannot reach the wire. + """ + + def _store(self, tmp_config_dir: Path) -> ConfigStore: + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-abc-defghijklmnopqrst", + project_name="Prod", + project_id=1234, + ), + ) + return store + + def _service(self, store: ConfigStore, mock_client: MagicMock) -> JobService: + return JobService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + def test_run_job_defaults_to_mode_run(self, tmp_config_dir: Path) -> None: + """When --mode is omitted the service sends mode='run'.""" + mock_client = MagicMock() + mock_client.create_job.return_value = {"id": 800, "status": "waiting"} + mock_client.get_config_detail.return_value = {} + + service = self._service(self._store(tmp_config_dir), mock_client) + service.run_job( + alias="prod", + component_id="keboola.ex-http", + config_id="42", + no_variables=True, + ) + + _args, kwargs = mock_client.create_job.call_args + assert kwargs["mode"] == "run" + + def test_run_job_forwards_mode_debug(self, tmp_config_dir: Path) -> None: + """mode='debug' reaches create_job unchanged so the Queue API body + carries it through to the worker.""" + mock_client = MagicMock() + mock_client.create_job.return_value = {"id": 801, "status": "waiting"} + mock_client.get_config_detail.return_value = {} + + service = self._service(self._store(tmp_config_dir), mock_client) + service.run_job( + alias="prod", + component_id="keboola.ex-http", + config_id="42", + mode="debug", + no_variables=True, + ) + + _args, kwargs = mock_client.create_job.call_args + assert kwargs["mode"] == "debug" + + def test_run_job_rejects_unknown_mode(self, tmp_config_dir: Path) -> None: + """Unknown mode fails at the service boundary, never hits the wire.""" + mock_client = MagicMock() + service = self._service(self._store(tmp_config_dir), mock_client) + + with pytest.raises(KeboolaApiError) as exc_info: + service.run_job( + alias="prod", + component_id="keboola.ex-http", + config_id="42", + mode="dry-run", + no_variables=True, + ) + + assert exc_info.value.error_code == "INVALID_ARGUMENT" + mock_client.create_job.assert_not_called() + + class TestJobServiceQueuePollingParity: """PR4: log-tail capture + auto-cancel on --timeout + poll-strategy plumbing.""" @@ -3590,6 +3675,7 @@ def test_run_job_auto_resolves_values_id(self, tmp_config_dir: Path) -> None: config_row_ids=None, branch_id=None, variable_values_id="row-first", + mode="run", ) def test_run_job_explicit_override_wins(self, tmp_config_dir: Path) -> None: diff --git a/uv.lock b/uv.lock index e0fde41b..b7a6b488 100644 --- a/uv.lock +++ b/uv.lock @@ -496,7 +496,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.43.5" +version = "0.43.6" source = { editable = "." } dependencies = [ { name = "httpx" },