From 1148e54c9e5be8f00cb28a8292b3c16c82545be5 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 25 Aug 2026 22:06:29 +0200 Subject: [PATCH] A run says whether the dashboard, the CLI or a script asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /runs/flows/{name}` hardcoded `cause: "api"`, so every row in the history claimed the same origin. The body now carries an optional `cause`, closed to the values the column knows — the dashboard sends nothing and stays "api", `fluksio run` says "cli", and the SDK client says "sdk". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK --- backend/fluksio/api/routes/runs.py | 12 +++++++-- backend/fluksio/models.py | 2 +- backend/fluksio/sdk/cli.py | 6 ++++- backend/fluksio/sdk/client.py | 10 ++++++- backend/tests/api/routes/test_runs.py | 38 +++++++++++++++++++++++++++ backend/tests/test_cli.py | 13 ++++++--- docs/code/api.md | 2 +- frontend/src/client/schemas.gen.ts | 6 +++++ frontend/src/client/types.gen.ts | 3 +++ 9 files changed, 83 insertions(+), 9 deletions(-) diff --git a/backend/fluksio/api/routes/runs.py b/backend/fluksio/api/routes/runs.py index 7018d19..29e22c9 100644 --- a/backend/fluksio/api/routes/runs.py +++ b/backend/fluksio/api/routes/runs.py @@ -6,7 +6,7 @@ or to listen on the flow socket, which carries its start and finish. """ from datetime import UTC, datetime -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.concurrency import run_in_threadpool @@ -35,6 +35,12 @@ def elapsed_ms(since: datetime) -> float: return round((datetime.now(UTC) - start).total_seconds() * 1000, 2) +#: Where a caller may say a run came from. "sweep" is not here because the +#: sweep route writes it itself, and neither is a value a client made up: the +#: column is only worth a table row if it means the same thing every time. +RunCause = Literal["api", "cli", "sdk"] + + class RunCreate(BaseModel): params: dict[str, Any] = Field(default_factory=dict) seed: int | None = None @@ -42,6 +48,8 @@ class RunCreate(BaseModel): draft: bool = False #: Execute every node, whatever an earlier run already worked out. no_cache: bool = False + #: Who is asking. The dashboard leaves it, and is the "api" default. + cause: RunCause = "api" class SweepEntry(BaseModel): @@ -172,7 +180,7 @@ async def create_run( name, params=body.params, seed=body.seed, - cause="api", + cause=body.cause, actor=user.email, draft=body.draft, no_cache=body.no_cache, diff --git a/backend/fluksio/models.py b/backend/fluksio/models.py index 7bbdedc..7e999b9 100644 --- a/backend/fluksio/models.py +++ b/backend/fluksio/models.py @@ -340,7 +340,7 @@ class Run(SQLModel, table=True): group_id: str | None = Field(default=None, index=True, max_length=64) #: The run this one was made from, on a retry. parent_id: str | None = Field(default=None, max_length=64) - #: api, hook, sweep or cli. + #: Where it was asked for: api, cli, sdk, hook or sweep. cause: str = Field(default="api", max_length=32) #: Re-execute every node, whatever the stage cache holds for it. no_cache: bool = False diff --git a/backend/fluksio/sdk/cli.py b/backend/fluksio/sdk/cli.py index 74ef3c2..fd817a2 100644 --- a/backend/fluksio/sdk/cli.py +++ b/backend/fluksio/sdk/cli.py @@ -459,7 +459,11 @@ def cmd_run(args: argparse.Namespace, rest: list[str]) -> int: ): params = _ask_params(definition) handle = client.submit( - args.flow, params, seed=args.seed, no_cache=args.no_cache + args.flow, + params, + seed=args.seed, + no_cache=args.no_cache, + cause="cli", ) _say(f"{handle.id} queued {json.dumps(params)}") if not wait: diff --git a/backend/fluksio/sdk/client.py b/backend/fluksio/sdk/client.py index b48e802..95672b9 100644 --- a/backend/fluksio/sdk/client.py +++ b/backend/fluksio/sdk/client.py @@ -226,11 +226,19 @@ class Client: params: dict[str, Any] | None = None, seed: int | None = None, no_cache: bool = False, + cause: str = "sdk", ) -> RunHandle: + """Queue a run. ``cause`` is what the history records it as coming + from — a script is the default, `fluksio run` says so itself.""" row = self._call( "POST", f"/runs/flows/{flow}", - json={"params": params or {}, "seed": seed, "no_cache": no_cache}, + json={ + "params": params or {}, + "seed": seed, + "no_cache": no_cache, + "cause": cause, + }, ) return RunHandle(self, row["id"], row) diff --git a/backend/tests/api/routes/test_runs.py b/backend/tests/api/routes/test_runs.py index ed2a93c..4e016cc 100644 --- a/backend/tests/api/routes/test_runs.py +++ b/backend/tests/api/routes/test_runs.py @@ -311,6 +311,44 @@ def test_a_running_run_reports_how_long_it_has_been_going( assert rows[0]["duration_ms"] >= 30_000 +def test_a_run_records_which_caller_asked_for_it( + client, superuser_token_headers, monkeypatch +): + """The dashboard, the CLI and the SDK are told apart by what they send. + + Client-supplied, so the vocabulary is closed: a column nobody can write + free text into is one a table can group by. + """ + seen: dict[str, object] = {} + + class Recorder: + def submit(self, name, **kwargs): + seen.update(kwargs) + return Run( + id="cause-1", + flow=name, + cause=str(kwargs["cause"]), + created_at=datetime.now(UTC), + ) + + monkeypatch.setattr(client.app.state, "run_service", Recorder()) + url = f"{settings.API_V1_STR}/runs/flows/demo" + + answer = client.post(url, headers=superuser_token_headers, json={"cause": "cli"}) + assert answer.status_code == 202 + assert (seen["cause"], answer.json()["cause"]) == ("cli", "cli") + + # Nothing said still means the dashboard, which is the only caller that + # does not name itself. + client.post(url, headers=superuser_token_headers, json={}) + assert seen["cause"] == "api" + + refused = client.post( + url, headers=superuser_token_headers, json={"cause": "somewhere else"} + ) + assert refused.status_code == 422 + + def test_overview_is_not_read_as_a_run_id(client, superuser_token_headers): """`/overview` is declared before `/{run_id}`, which would swallow it.""" answer = client.get( diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py index e2e7898..45d56fa 100644 --- a/backend/tests/test_cli.py +++ b/backend/tests/test_cli.py @@ -200,9 +200,10 @@ def test_a_local_run_always_waits(monkeypatch) -> None: def get_flow(self, name: str) -> dict[str, object]: return {"definition": {"inputs": []}} - def submit(self, flow, params, seed=None, no_cache=False): + def submit(self, flow, params, seed=None, no_cache=False, cause="sdk"): submitted["flow"] = flow submitted["no_cache"] = no_cache + submitted["cause"] = cause return FakeHandle() def run(self, run_id: str) -> dict[str, object]: @@ -216,7 +217,13 @@ def test_a_local_run_always_waits(monkeypatch) -> None: args = _parser().parse_args(["run", "train", "--local", "--no-sync", "--no-cache"]) assert cli.cmd_run(args, []) == 0 - assert submitted == {"flow": "train", "no_cache": True, "waited": True} + # "cli" rather than the SDK's default: the history says which asked. + assert submitted == { + "flow": "train", + "no_cache": True, + "waited": True, + "cause": "cli", + } def test_ctrl_c_while_waiting_cancels_the_run(monkeypatch) -> None: @@ -240,7 +247,7 @@ def test_ctrl_c_while_waiting_cancels_the_run(monkeypatch) -> None: def get_flow(self, name: str) -> dict[str, object]: return {"definition": {"inputs": []}} - def submit(self, flow, params, seed=None, no_cache=False): + def submit(self, flow, params, seed=None, no_cache=False, cause="sdk"): return FakeHandle() def cancel(self, run_id: str) -> None: diff --git a/docs/code/api.md b/docs/code/api.md index 52a2963..33baa41 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -102,7 +102,7 @@ published to. Flows own the namespace; everything else is a client of it. | Method | Path | What | |---|---|---| -| `POST` | `/runs/flows/{name}` | queue one run — `{"params": {...}, "seed": 7, "draft": false, "no_cache": false}` | +| `POST` | `/runs/flows/{name}` | queue one run — `{"params": {...}, "seed": 7, "draft": false, "no_cache": false}`. `"cause"` says where it came from — `api` (the default), `cli` or `sdk` | | `POST` | `/runs/flows/{name}/sweep` | queue many, sharing a `group_id` | | `GET` | `/runs` | the queryable history: `?flow=`, `?status=`, `?group=`, `?digest=`, `?limit=`, `?offset=` | | `GET` | `/runs/overview` | one row per flow that has runs, with how many are running or queued | diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 6e7449b..c8170be 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -2384,6 +2384,12 @@ export const RunCreateSchema = { type: 'boolean', title: 'No Cache', default: false + }, + cause: { + type: 'string', + enum: ['api', 'cli', 'sdk'], + title: 'Cause', + default: 'api' } }, type: 'object', diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 694d840..0f53e53 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -865,8 +865,11 @@ export type RunCreate = { seed?: (number | null); draft?: boolean; no_cache?: boolean; + cause?: 'api' | 'cli' | 'sdk'; }; +export type cause = 'api' | 'cli' | 'sdk'; + export type RunDetail = { id: string; flow: string;