diff --git a/keel/commands/serve.py b/keel/commands/serve.py index 8d19a7e..0964f95 100644 --- a/keel/commands/serve.py +++ b/keel/commands/serve.py @@ -15,6 +15,7 @@ from __future__ import annotations import webbrowser +from typing import Any import click @@ -57,13 +58,19 @@ def serve_cmd(ctx: click.Context, host: str, port: int, open_browser: bool) -> N readable by anyone who can reach the port, with a cleartext token as the only obstacle. """ obj = ctx.obj or {} + # Resolved ONCE, here, and carried on the config in both its forms. `build_info()` shells out + # to git twice, and `/api/config` is polled by a service worker (#538) -- an endpoint that + # forks a subprocess to answer "which build is this" would make the cheapest question on the + # server the most expensive one. + build = _build_info() cfg = ServeConfig( host=host, port=port, token=new_session_token(), db_path=obj.get("db_path") or default_db_path(), config_path=obj.get("config_path") or default_config_path(), - build=_build_line(), + build=_build_line(build), + build_info=build, ) if open_browser: @@ -78,12 +85,24 @@ def serve_cmd(ctx: click.Context, host: str, port: int, open_browser: bool) -> N ctx.exit(serve(cfg, echo=click.echo)) -def _build_line() -> str: - """The build identity in the page footer, so a screenshot of the UI says which build produced - it. Best-effort: a footer is not worth failing a server start over.""" +def _build_info() -> Any: + """The running build, resolved once, or `None`. + + Best-effort: a build identity is not worth failing a server start over, and `None` is a state + the consumers already handle -- the footer renders empty and `/api/config` reports the version + absent rather than inventing one. Split out of `_build_line` when `/api/config` (#534) needed + the same object as STRUCTURE rather than as a sentence, so the two can never describe different + builds: parsing `describe()`'s output back into fields would be a display string being read as + data.""" try: from keel.version import build_info - return build_info().describe() + return build_info() except Exception: # pragma: no cover - metadata absent in odd environments - return "" + return None + + +def _build_line(build: Any) -> str: + """The build identity in the page footer, so a screenshot of the UI says which build produced + it.""" + return "" if build is None else str(build.describe()) diff --git a/keel/web/__init__.py b/keel/web/__init__.py index 5a236ea..0233b44 100644 --- a/keel/web/__init__.py +++ b/keel/web/__init__.py @@ -6,8 +6,22 @@ macOS app launched from Finder has no controlling terminal at all, and the GUI human gate (#436) needs somewhere to live that is architecturally distinct from `_is_interactive`. -**This package has no write surface, and that is structural rather than a matter of discipline.** -The request handler implements `do_GET` and `do_HEAD` and nothing else, so every other method is -refused by `BaseHTTPRequestHandler` before any keel code runs. Write actions land in D3, behind a -gate of their own; until then there is no code path here that could grow one by accident. +**This package's write surface is a closed set, and that is structural rather than a matter of +discipline.** + +The sentence that used to be here -- "the request handler implements `do_GET` and `do_HEAD` and +nothing else" -- stopped being true at #437 and is corrected rather than deleted, because +`server.do_POST` still points a reader at this file for the rule it must not break. The guarantee +now is stronger than the one it replaced, and it is the one anyone actually cares about: + +* `do_POST` exists, and it routes ONLY through `keel.commands.setup.ACTIONS` -- idempotent, + non-destructive steps -- so a first-run user on a machine with no terminal can create a + deployment. "No POST at all" was a clean property that was also satisfied by a server which + could not set anything up. +* **Not one of the eleven capability-increasing actions in `keel/capabilities.py` is reachable + from this package**, asserted by a test that scans this source rather than by inspection. The + server cannot arm, release or spend. Attesting, promoting, releasing a halt and arming autonomy + remain CLI-only, behind the TTY gate; D3 (#436) is where a browser gate for those would go. +* The JSON API (#534, `keel/web/api.py`) is **reads only**. Every route in its table answers a GET + and 404s a POST, and it did not widen `API_PREFIX`'s existing `X-Keel-Client` gate by one byte. """ diff --git a/keel/web/api.py b/keel/web/api.py new file mode 100644 index 0000000..2ffd909 --- /dev/null +++ b/keel/web/api.py @@ -0,0 +1,601 @@ +"""The JSON API (#534) -- routing, one bounded read per endpoint, and no rendering at all. + +This module is the READ half of `keel/web/`: which report an endpoint needs, which rows a query +string asks to be ordered by, and what a client is told when there is no deployment to read. It +formats nothing. Every string a user will see was written by `keel/web/payload.py`, which is the +one place a `Decimal` becomes text and the one place the closed `state` vocabulary is spelled -- +`tests/web/test_api.py::test_the_routing_layer_formats_nothing` fails the build on a `format(...)` +call or a format spec appearing here, because the second money renderer is never the one with +`_plain`'s no-exponent guarantee in it. + +**Reads only.** Not one route below answers a POST. The write surface is still +`keel.commands.setup.ACTIONS` reached through `SETUP_ACTION_PREFIX`, still behind +`X-Keel-Client: 1` for anything under `API_PREFIX`, and this issue added nothing to either -- +`test_no_api_route_answers_a_post` asserts that for every route in the table below rather than for +a sample of them. + +**Why the deployment's state is read on EVERY endpoint, including the three that do not need it.** +`engine` is a uniform field so that #536's single `fetch` wrapper can render "keel isn't running" +from whatever response it happens to hold, without a table of which endpoints carry the word. +Measured cost: `keel.commands.setup.inspect` is 3.6 ms per call on this machine against a migrated +database, against 0.07 ms for `gather_status` -- so the liveness probe is roughly fifty times the +report it accompanies, and still small next to a loopback HTTP round trip. If a client is ever +written that polls `/api/config` in a loop, the fix is to cache the `DeploymentState` for the life +of one response (it already is) or per second -- not to make the field optional, because an +optional field puts the branch back on the client. + +**Where the sort is, and where it is not.** Deciding WHICH column a query string asked for, and +refusing one that does not exist, is routing and lives here. The ordering itself -- `Decimal` +comparison over `Field.value` -- is `payload.order_rows`, next to the code that wrote those +strings and under the same contract. A `float()` on a wire value would be the whole money +guarantee dying in one expression, and `test_rule_6_holds_in_the_api_layer_too` runs #533's own +AST scan over this file for exactly that reason. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from keel.web import payload + +if TYPE_CHECKING: # pragma: no cover - typing only + from keel.web.server import ServeConfig + +#: Journal rows served when a caller names no `limit`. The same cap the HTML insights page uses, +#: so the two front-ends answer "how has this been going" with the same amount of history. +DEFAULT_JOURNAL_LIMIT = 50 + +#: The ceiling on `?limit=`. A caller-supplied row count is a memory and time primitive against a +#: server with no proxy in front of it, and a journal is not a bulk export -- `keel insights +#: journal` is, and it runs in the operator's own process against their own machine's limits. +MAX_JOURNAL_LIMIT = 1000 + +#: The two directions, and no third spelling. `desc`/`descending`/`down` would all have to be +#: accepted forever once accepted once, and a client reading `sort.direction` back off the +#: response needs one word to compare against. +DIRECTIONS: tuple[str, ...] = ("asc", "desc") + + +# -- the reads ------------------------------------------------------------------------------------ +# +# These four moved here from `keel/web/server.py` when this module arrived, unchanged. They are +# the seam BOTH front-ends read keel through -- the HTML pages and the JSON endpoints -- and a +# copy in each would be two places deciding whether a page may migrate a live database. The +# direction of the dependency is one-way on purpose: `server` imports `api`, `api` imports nothing +# from `server` at runtime, so there is no cycle to reason about. + + +def open_repo(db_path: str) -> Any: + """A plain connection -- deliberately WITHOUT `migrate`. + + Every CLI command migrates on the way in, which is right for a command: it runs once, and a + schema behind the code is a thing to fix rather than to fail on. It is wrong here. These + pages auto-reload every 15 seconds, so migrating per request would have a view that calls + itself read-only take a write lock on the deployment database four times a minute -- against + a database the agent may be mid-cycle on. `server.ensure_schema` does it ONCE, at bind time, + before anything is served.""" + from keel.data.db import connect + from keel.data.repository import Repository + + return Repository(connect(db_path)) + + +def load_config(config_path: str) -> Any: + """`load_config` only -- deliberately NOT `_common._load_cfg`, which also calls + `configure_logging` and `bind_venue`. Those are process-entry side effects; re-applying them + on every page load would have the web UI quietly reconfiguring the running deployment's + logging.""" + from keel.config import load_config as _load + + return _load(config_path) + + +def deployment_state(cfg: ServeConfig) -> Any: + from keel.commands.setup import inspect + + return inspect(cfg.config_path, cfg.db_path) + + +def close_repo(repo: Any) -> None: + conn = getattr(repo, "conn", None) + if conn is not None: + try: + conn.close() + except Exception: # pragma: no cover - a close that fails leaks nothing that matters + pass + + +# -- refusals ------------------------------------------------------------------------------------- + + +class ApiRefusal(Exception): + """A request this API understood and declined -- a column that does not exist, a limit out of + range. Carried as an exception rather than returned, so a reader can refuse from wherever it + discovers the problem without every caller in between having to pass a failure along. + + Distinct from an unexpected exception, which becomes a 500: this one is the API stating a rule + the caller broke, and its `detail` is written to be shown to whoever wrote the caller.""" + + def __init__(self, status: int, title: str, detail: str) -> None: + super().__init__(detail) + self.status = status + self.title = title + self.detail = detail + + +# -- the readers ---------------------------------------------------------------------------------- + + +def _status_report(cfg: ServeConfig, now_ts: int) -> Any: + """`gather_status`'s frozen report, over its own connection. + + A named module-level function rather than an inline call inside `read_status` so a test can + replace it -- pinning that a report which cannot be built becomes a stated 500 rather than a + 200 with an empty payload, which is the failure mode this whole envelope exists to prevent.""" + from keel.commands.status import gather_status + + repo = open_repo(cfg.db_path) + try: + return gather_status(repo, load_config(cfg.config_path), now_ts=now_ts) + finally: + close_repo(repo) + + +def read_config(cfg: ServeConfig, _query: Query, _state: Any, _now_ts: int) -> dict[str, Any]: + """The running build. Reads no deployment and no database -- it describes the BINARY that is + answering, which is why it still has data to serve on a machine with nothing set up.""" + return payload.config_payload(cfg.build_info, describe=cfg.build) + + +def read_status(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> dict[str, Any]: + return payload.status_payload(_status_report(cfg, now_ts)) + + +def read_setup(cfg: ServeConfig, _query: Query, state: Any, _now_ts: int) -> dict[str, Any]: + """The first-run checklist -- the ONE deployment-reading endpoint that must answer when there + is no deployment, because it is the thing that says how to make one. `needs_database=False` + for that reason, and `server.needs_database` serves the same page in HTML for the same one. + + `state` is the `DeploymentState` the envelope already read for `engine`, passed in rather than + re-inspected: one 3.6 ms probe per response, not two.""" + from keel.commands import jobs + from keel.commands.setup import ACTIONS, NOT_AUTOMATED_YET + + return payload.setup_payload( + state, actions=ACTIONS, not_automated=NOT_AUTOMATED_YET, job=jobs.status() + ) + + +def read_activity(cfg: ServeConfig, query: Query, _state: Any, _now_ts: int) -> dict[str, Any]: + """The engine's own log, scoped. + + An unrecognised `?scope=` is NORMALISED rather than refused, unlike an unrecognised `?sort=`, + and the difference is not an inconsistency. `normalise_scope` is the activity SERVICE's own + function with the CLI's own default behind it, so refusing here would mean the browser and the + terminal disagreeing about the same input -- and the resolved value is echoed back in + `data.scope`, so a client can see that its input was changed. A sort column has no service, no + default and no echo that would make a silent substitution visible, so it is refused.""" + from keel.commands.activity import ( + apply_scope, + feed_from_lines, + normalise_scope, + read_log_window, + resolve_log_path, + ) + + scope = normalise_scope(_first(query, "scope")) + path = resolve_log_path(load_config(cfg.config_path)) + window = read_log_window(path) + feed = feed_from_lines(window.lines, source=str(path), truncated=window.truncated) + if window.status != "ok" and feed.status == "empty": + # A read that failed and a window that held nothing are different facts; the reader's + # status is the more specific one and must not be flattened into "empty". + feed = feed_from_lines((), source=str(path)) + return payload.activity_payload(apply_scope(feed, scope, now_ts=time.time())) + + +def read_insights(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> dict[str, Any]: + """The per-rule track records and the promotion-gate distances. + + The journal the HTML `/insights` page renders below these is `/api/journal` instead of a second + table here. One sortable collection per endpoint keeps `?sort=` unambiguous without a + `?table=` beside it, and it gives the journal somewhere to carry its own `?limit=` -- the cap + the HTML page apologises for in a comment ("a cap, not a paginator").""" + from keel.commands.insights import build_insights_report + + repo = open_repo(cfg.db_path) + try: + config = load_config(cfg.config_path) + report = build_insights_report(repo, config, _status_report(cfg, now_ts), now_ts) + finally: + close_repo(repo) + return payload.insights_payload(report) + + +def read_journal(cfg: ServeConfig, query: Query, _state: Any, now_ts: int) -> dict[str, Any]: + from keel.commands.insights import build_journal_report + + limit = _journal_limit(query) + repo = open_repo(cfg.db_path) + try: + report = build_journal_report( + repo, _status_report(cfg, now_ts), now_ts, limit=limit + ) + finally: + close_repo(repo) + return payload.journal_payload(report) + + +def read_rules(cfg: ServeConfig, _query: Query, _state: Any, _now_ts: int) -> dict[str, Any]: + repo = open_repo(cfg.db_path) + try: + rows = repo.get_rules(None) + finally: + close_repo(repo) + return payload.rules_payload(rows) + + +def read_venues(_cfg: ServeConfig, _query: Query, _state: Any, _now_ts: int) -> dict[str, Any]: + from keel.commands.brokers import list_installed_brokers + + return payload.venues_payload(list_installed_brokers()) + + +def read_gates(_cfg: ServeConfig, _query: Query, _state: Any, _now_ts: int) -> dict[str, Any]: + """Read from `keel.capabilities`, which is a pure declaration -- no config, no database, no + network. It describes the binary that is serving the response.""" + from keel.capabilities import CAPABILITIES, GATES + + return payload.gates_payload(GATES, CAPABILITIES) + + +# -- the route table ------------------------------------------------------------------------------- + +#: A parsed query string, as `urllib.parse.parse_qs` returns it. +Query = dict[str, list[str]] + +Reader = Callable[["ServeConfig", Query, Any, int], "dict[str, Any]"] + + +@dataclass(frozen=True) +class ApiRoute: + """One `GET /api/*` endpoint. + + `html_route` names the rendered page this endpoint carries the data for, so + `test_the_api_routes_cover_every_html_route_that_reads` can compare the two tables mechanically + -- an HTML route added without a JSON counterpart fails that test rather than being discovered + by a client that cannot render it. `""` means there is no rendered counterpart at all, which + today is only `/api/config`. + + `needs_database` is the JSON counterpart of `server.needs_database`: rather than serving the + setup checklist in place of a broken page, it answers `data: null` with `engine: "stopped"`, + which is the same statement in the shape a client can act on. + + `collection` and `sortable` are the sort surface. `collection` is the key of the one list in + `data` that `?sort=` orders; `sortable` is the closed set of columns it may be ordered by, and + it is a hand-written list on purpose -- deriving it from the first row would make the API's + answer to `?sort=x` depend on whether any rows exist, which is the sort of behaviour that shows + up only on an empty deployment. + """ + + html_route: str + read: Reader + needs_database: bool = True + collection: str = "" + sortable: tuple[str, ...] = field(default=()) + + +#: THE READ SURFACE, in full. Every one of the rendered routes in `server.ROUTES` appears here as +#: its `html_route` except `/glossary`, which gets no counterpart: it becomes an outbound +#: keeltrading.com link in #539 and `render_glossary` is deleted in #540, so an `/api/glossary` +#: would be a surface built in order to be removed -- and any client written against it would break +#: on the release that removes it. +API_ROUTES: dict[str, ApiRoute] = { + "/api/config": ApiRoute( + html_route="", read=read_config, needs_database=False + ), + "/api/status": ApiRoute( + html_route="/", + read=read_status, + collection="open_positions", + # The one collection on this payload with money in it. `rule_counts` is already ordered by + # status to match `render_human`, `data_freshness` follows the config's product order and + # `live_rules` the ledger's -- three intrinsic orders that a display sort would destroy + # rather than improve. + sortable=( + "id", + "product_id", + "rule_name", + "qty", + "entry_price", + "opened_at", + "bracket", + ), + ), + "/api/setup": ApiRoute( + html_route="/setup", + read=read_setup, + # No sortable collection: the steps are in RUNBOOK order, and that order is the + # information. A checklist sorted by title is a checklist you cannot work down. + needs_database=False, + ), + "/api/activity": ApiRoute( + html_route="/activity", + read=read_activity, + collection="cycles", + sortable=( + "key", + "cycle_id", + "started_at", + "ended_at", + "mode", + "signals", + "blocked", + "entered", + "exited", + "errors", + "events_dropped", + ), + ), + "/api/insights": ApiRoute( + html_route="/insights", + read=read_insights, + collection="rules", + sortable=( + "rule_name", + "status", + "promotion_class", + "n_trades", + "win_rate", + "avg_win", + "avg_loss", + "realized_rr", + "expectancy", + "profit_factor", + "max_drawdown", + ), + ), + "/api/journal": ApiRoute( + html_route="/insights", + read=read_journal, + collection="entries", + sortable=( + "closed_at", + "opened_at", + "rule_name", + "product_id", + "qty", + "entry_fill", + "exit_fill", + "pnl", + "fees", + "r_multiple", + "outcome", + ), + ), + "/api/rules": ApiRoute( + html_route="/rules", + read=read_rules, + collection="rules", + sortable=("id", "kind", "status", "created_at", "promoted_at", "demoted_at"), + ), + "/api/venues": ApiRoute( + html_route="/venues", + read=read_venues, + needs_database=False, + collection="venues", + sortable=("name", "venue", "deployment", "package_version"), + ), + "/api/gates": ApiRoute( + html_route="/gates", + read=read_gates, + needs_database=False, + # No sortable collection: a gate's rows are the actions it covers, nested one level down, + # and `?sort=` names a column of ONE top-level list. Sorting the gates themselves would + # order a tuple that has one member. + ), +} + +#: `/api/*` -> the rendered route it carries the data for. Derived rather than written twice, so +#: the two cannot disagree. +HTML_ROUTE_FOR: dict[str, str] = {path: route.html_route for path, route in API_ROUTES.items()} + + +# -- the query surface ----------------------------------------------------------------------------- + + +def _first(query: Query, name: str) -> str: + """The first value for `name`, or `""`. Repeated parameters take the first rather than the last + or a refusal: `?sort=a&sort=b` is a client bug, not an attack, and picking one deterministically + is a better answer than a 400 the client's author will read as "sorting is broken".""" + values = query.get(name) or [""] + return values[0] + + +def _journal_limit(query: Query) -> int: + """How many journal rows to build, from `?limit=`. + + Bounded at both ends. A zero or a negative would reach `build_journal_report` as a slice bound + and answer an empty journal that looks like an empty ledger, and an unbounded upper end is a + caller choosing how much of this machine's memory to spend.""" + raw = _first(query, "limit") + if not raw: + return DEFAULT_JOURNAL_LIMIT + try: + value = int(raw) + except ValueError as exc: + raise ApiRefusal( + 400, "Bad limit", f"limit={raw!r} is not a whole number." + ) from exc + if value < 1 or value > MAX_JOURNAL_LIMIT: + raise ApiRefusal( + 400, + "Bad limit", + f"limit={raw!r} is outside 1..{MAX_JOURNAL_LIMIT}.", + ) + return value + + +def _sort_request(path: str, route: ApiRoute, query: Query) -> tuple[str, bool]: + """`(column, descending)` from `?sort=` and `?dir=`, or a refusal. + + **Two parameters rather than one signed token.** `?sort=-expectancy` was the alternative and it + needs a grammar -- what a leading `-` means, what happens to a column whose name starts with + one, how a client strips it before comparing against `sort.columns`. Two orthogonal facts, two + parameters, no parsing beyond a table lookup. + + **An unknown column is REFUSED, never ignored.** Silently ignoring one is how a client ships a + sort header that does nothing and nobody notices for a release; the refusal names the columns + that do exist, so the fix is in the response that reported the problem. + """ + column = _first(query, "sort") + direction = _first(query, "dir") or DIRECTIONS[0] + if direction not in DIRECTIONS: + raise ApiRefusal( + 400, + "No such direction", + f"dir={direction!r} is not a direction; use one of: {', '.join(DIRECTIONS)}.", + ) + if not column: + return "", False + if not route.sortable: + raise ApiRefusal( + 400, + "Nothing to sort", + f"{path} serves no sortable table -- its rows carry an intrinsic order.", + ) + if column not in route.sortable: + raise ApiRefusal( + 400, + "No such column", + f"sort={column!r} is not a column of {path}; sortable columns are: " + f"{', '.join(route.sortable)}.", + ) + return column, direction == DIRECTIONS[1] + + +def _sort_echo(route: ApiRoute, column: str, descending: bool) -> dict[str, Any] | None: + """What was sorted, and what could be. + + `columns` is echoed even when nothing was sorted, so #537 renders sortable headers by reading + the response rather than by holding a second copy of the list above -- a column added there + reaches the interface without a second edit, and one removed there cannot leave a header that + sorts by nothing.""" + if not route.sortable: + return None + return { + "column": column, + "direction": DIRECTIONS[1] if descending else DIRECTIONS[0], + "columns": list(route.sortable), + } + + +def _apply_sort( + data: dict[str, Any], route: ApiRoute, column: str, descending: bool +) -> dict[str, Any]: + rows = data.get(route.collection) + if not isinstance(rows, list): # pragma: no cover - a declared collection is always a list + return data + ordered = payload.order_rows(rows, column=column, descending=descending) + return {**data, route.collection: ordered} + + +# -- the response ---------------------------------------------------------------------------------- + + +def respond(cfg: ServeConfig, path: str, query: Query) -> tuple[int, dict[str, Any]]: + """One `GET /api/*`, as `(status, document)`. Never raises; never renders. + + The order of the steps is the contract: + + 1. **Unmapped path first**, so an unknown endpoint costs no database read at all. + 2. **The deployment probe**, which fills `engine` on every success. A probe that itself fails + reports a STOPPED engine rather than a 500: `inspect` is documented as read-only and total, + and if it ever is not, the honest reading of "we could not tell whether keel is set up" is + the same one the rest of this file takes -- do not show figures. + 3. **The query surface**, before the read. A bad `?sort=` should not cost a report build, and + more importantly it should not be able to arrive AFTER one and discard it. + 4. **The read**, then the sort, then the envelope. + + A stopped engine answers **200**, not a 4xx. #538's service worker and #536's `fetch` wrapper + both read a non-ok status as "the server is unreachable", which would put a first-run user into + an outage state when their actual position is that they have not set anything up yet -- and the + two need different words on screen. The request succeeded; the answer is that there is nothing + to report. + """ + now_ts = int(time.time()) + route = API_ROUTES.get(path) + if route is None: + return 404, payload.error_envelope( + now_ts, + status=404, + title="No such endpoint", + detail=f"Nothing is served at {path}.", + ) + + try: + state: Any = deployment_state(cfg) + running = bool(state.has_usable_database) + except Exception: # pragma: no cover - `inspect` is total; this is the belt to its braces + state, running = None, False + + try: + column, descending = _sort_request(path, route, query) + except ApiRefusal as refusal: + return refusal.status, payload.error_envelope( + now_ts, status=refusal.status, title=refusal.title, detail=refusal.detail + ) + + if route.needs_database and not running: + return 200, payload.envelope( + now_ts, + running=False, + data=None, + sort=_sort_echo(route, column, descending), + ) + + try: + data = route.read(cfg, query, state, now_ts) + except ApiRefusal as refusal: + return refusal.status, payload.error_envelope( + now_ts, status=refusal.status, title=refusal.title, detail=refusal.detail + ) + except Exception as exc: + # A broken report is a stated failure, never a 200 with an empty payload -- that shape is + # precisely the "blank view, or worse, stale figures" this envelope exists to prevent. The + # detail carries the exception's TYPE and message and no traceback: a traceback in a + # browser is a stack of file paths from someone else's machine. + return 500, payload.error_envelope( + now_ts, + status=500, + title="That report could not be built", + detail=f"{type(exc).__name__}: {exc}", + ) + + if column: + data = _apply_sort(data, route, column, descending) + return 200, payload.envelope( + now_ts, + running=running, + data=data, + sort=_sort_echo(route, column, descending), + ) + + +def refusal_document(status: int, title: str, detail: str) -> dict[str, Any]: + """A refusal raised BEFORE routing -- a failed host check, a missing session cookie. + + Lives here rather than in the handler so that every `/api/*` body, admitted or not, is built by + one function against one contract. It carries no `engine`, and that is the reason the success + and failure documents are told apart by HTTP status rather than by a field: these refusals + happen before admission, and filling in `engine` would mean an unauthenticated request reading + the deployment state off disk.""" + return payload.error_envelope(int(time.time()), status=status, title=title, detail=detail) + + +def sortable_columns() -> Mapping[str, Sequence[str]]: + """The declared sort surface, for a test to read rather than restate.""" + return {path: route.sortable for path, route in API_ROUTES.items() if route.sortable} diff --git a/keel/web/payload.py b/keel/web/payload.py index 72889c0..5c63fa3 100644 --- a/keel/web/payload.py +++ b/keel/web/payload.py @@ -78,6 +78,18 @@ would mean accepting that branch on the client, which is the thing this contract exists to remove. +WHAT #534 ADDED, AND WHY IT IS HERE RATHER THAN IN THE ROUTING LAYER. Three things: the +`envelope`/`error_envelope` pair that wraps every `GET /api/*` response, the `engine_state` word +that lets a client say "keel isn't running" instead of rendering a blank view, and `order_rows`, +the server-side sort. All three touch the WIRE VOCABULARY -- the closed `state` words, the +no-exponent guarantee that makes `Field.value` re-parseable, the rule that a number crosses as a +string -- and a second file holding half of that vocabulary is how the two halves drift. The +routing layer's job is deciding WHICH rows and WHICH column from a query string; the ordering +itself sits next to the `_plain` that wrote the strings being ordered. + +Note what `order_rows` does NOT do: it does not add the numeric `sort` companion field described +above. It re-parses `Field.value`, which is exactly what that value is for. + WHAT IS *NOT* A FIELD. Identifiers and enum words -- `product_id`, `rule_name`, `mode`, `as_of` -- cross as bare JSON strings. They carry no precision hazard, no rounding decision and no judgement, and wrapping them would be ceremony rather than contract. Bare JSON *numbers* @@ -89,7 +101,7 @@ import json import time -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from decimal import Decimal, InvalidOperation from typing import TYPE_CHECKING, Any, TypedDict @@ -1025,3 +1037,518 @@ def activity_payload(feed: ActivityFeed) -> dict[str, Any]: ), "cycles": [_cycle_payload(c) for c in feed.cycles], } + + +# -- the envelope (#534) ------------------------------------------------------------------------- +# +# Every `GET /api/*` success is wrapped in the same four keys, so #536's single `fetch` wrapper +# needs no per-endpoint branch and no knowledge of which endpoints can report a dead engine. + + +#: The closed `engine` vocabulary. Two words, and deliberately not three. +#: +#: A third word for "the report raised" was drafted and dropped. The CLIENT behaviour required by +#: a stopped engine and by an unbuildable report is identical -- show no figures, say why -- so a +#: third word would force every view to write three branches to get two behaviours, and the +#: difference between the two is already carried by the HTTP status and by `error.detail`. Add one +#: the day a client would DO something different with it. +ENGINE_STATES: frozenset[str] = frozenset({"running", "stopped"}) + +RUNNING = "running" +STOPPED = "stopped" + +#: What `engine: "stopped"` says when the caller has nothing more specific. The wording has to be +#: true both for a machine with no deployment on it and for one whose report could not be built, +#: because those are the two ways this value is reached. +_STOPPED_DISPLAY = "keel isn't running here — there is no deployment to read" +_RUNNING_DISPLAY = "keel is set up on this machine" + + +def engine_state(*, running: bool, detail: str = "") -> Field: + """Whether there is a keel deployment behind this response, as a judged field. + + **What this does NOT answer, on purpose: "did the agent run recently".** That question needs a + THRESHOLD -- how many hours of silence is too many -- and this file holds no thresholds by + design (`_freshness_payload` refuses the same temptation and says so). The evidence for it is + already on the wire in two places a client can render without arithmetic: `data_freshness` + carries each product's candle age in the CLI's own words, and the activity feed carries + `last_cycle_before_scope`, which exists precisely so an empty view can say *when keel last + ran*. The day a report builder holds an agent heartbeat WITH its staleness verdict, this + vocabulary gains a third word and the verdict is copied here, never computed here. + + `warn` rather than `bad` for a stopped engine: on the commonest path to this value nothing is + broken at all -- it is a first run, and the correct next action is the setup checklist, not an + incident. A caller that knows better passes its own `detail`. + """ + if running: + return {"value": RUNNING, "display": _RUNNING_DISPLAY, "state": GOOD} + return {"value": STOPPED, "display": detail or _STOPPED_DISPLAY, "state": WARN} + + +def envelope( + now_ts: float | int | None, + *, + running: bool, + data: dict[str, Any] | None, + detail: str = "", + sort: dict[str, Any] | None = None, +) -> dict[str, Any]: + """One `GET /api/*` success. + + `data` is `null` -- never `{}` -- when the endpoint could not be answered because there is no + deployment. An empty object is a payload in which every figure is missing, and a view handed + one renders zeros; `null` cannot be rendered by accident, which is the whole requirement. + + The key set is CONSTANT across every endpoint and every state, `sort` included on endpoints + that sort nothing. A client that has to test whether a key is present is branching on payload + SHAPE, which Rule 3 rejects for `state` and which is rejected here for the same reason. + + `as_of` is the instant this response was built, and it is present even when `data` is not -- + that pairing is the requirement: a client showing "keel isn't running" should be able to say + since when it was looking. + """ + return { + "as_of": iso(now_ts), + "engine": engine_state(running=running, detail=detail), + "data": data, + "sort": sort, + } + + +def error_envelope( + now_ts: float | int | None, *, status: int, title: str, detail: str +) -> dict[str, Any]: + """One `GET /api/*` refusal or failure. + + **The discriminator between this document and `envelope` is the HTTP STATUS, deliberately, and + not a field.** A uniform envelope with a nullable `error` was the first shape and it does not + survive contact with admission: most refusals happen BEFORE the session cookie is checked, and + filling in `engine` there would mean an unauthenticated request reading the deployment state + off disk. `res.ok` is a check every `fetch` client already makes. + + `status` crosses as a STRING like every other number here. It would survive JSON's number type + intact, and that is exactly the argument `count` refuses: a payload with "only a few" numbers + in it needs a per-field rule about which ones, and that rule is what rots. + + `data` is present and `null` so that a client reading `.data` on any response gets a value + rather than `undefined` -- the key set stays constant across the two documents for the same + reason it stays constant across endpoints. + """ + return { + "as_of": iso(now_ts), + "data": None, + "error": {"status": str(status), "title": title, "detail": detail}, + } + + +# -- ordering (#534) ----------------------------------------------------------------------------- +# +# Server-side sort, ordered with `Decimal`. The spec's own words: "On loopback the round trip is +# sub-millisecond, so there is nothing to optimise and no client arithmetic to audit." + + +def _cell_text(row: Mapping[str, Any], column: str) -> str: + """The exact, ungrouped text a row carries in `column`, or `""` for nothing to order by. + + Reads `Field.value` for a field and the string itself for a bare identifier, which is exactly + what `Field.value` is documented to be: "machine input (exact, ungrouped, `Decimal`-parseable + for figures and ISO-8601 for instants)". A list, an object or a `None` yields `""` and sorts + with the absent rows -- an endpoint should not be declaring such a column sortable, and the + routing layer refuses a column it does not declare, but a total function here means a mistake + there costs an ordering rather than a 500. + """ + cell = row.get(column) + if isinstance(cell, Mapping): + raw = cell.get("value", "") + return raw if isinstance(raw, str) else "" + if isinstance(cell, str): + return cell + return "" + + +def _finite_decimal(text: str) -> Decimal | None: + """`text` as a finite `Decimal`, or `None` for anything that cannot be ordered as a number. + + Non-finite is `None` on purpose, and it is not hypothetical: `stringify` renders a + `Decimal("NaN")` sentinel as `"NaN"`, and `Decimal("NaN")` PARSES. Ordering by it would make + the result depend on the comparison order the sort happened to use, because NaN compares false + to everything including itself -- so a column containing one is ordered as text instead, as a + whole column, and the ordering stays deterministic. + """ + if not text: + return None + try: + candidate = Decimal(text) + except (InvalidOperation, ValueError, TypeError): + return None + return candidate if candidate.is_finite() else None + + +def _ordering_key(pair: tuple[Any, Mapping[str, Any]]) -> Any: + """The first element of a (key, row) pair. + + Sorting is keyed on this rather than on the pair, because the second element is a `dict` and + comparing two of them raises the moment two keys tie -- which is exactly the case a + tie-breaking sort reaches.""" + return pair[0] + + +def order_rows( + rows: Sequence[Mapping[str, Any]], *, column: str, descending: bool = False +) -> list[Mapping[str, Any]]: + """`rows` ordered by `column`, with `Decimal` wherever the column is numeric. + + **Why `Decimal` and not `float`, with the number in it.** Two ERC-20 quantities one wei apart + -- `0.100000000000000001` and `0.100000000000000002`, both legal at an 18-decimal base + increment -- map to the SAME IEEE-754 double, because adjacent doubles near 0.1 are about + 1.4e-17 apart. A float-keyed sort cannot separate them, so their order becomes whatever order + they arrived in; `Decimal` orders them. The same collapse takes the last cent of a large + notional. `tests/web/test_api.py` asserts that disagreement directly, before the endpoint test + relies on it, so the endpoint test cannot be green for the trivial reason that sorting sorts. + + **The SERIALISED rows are sorted, not the source dataclasses.** The alternative -- ordering + `report.open_positions` before serialising -- needs a map from every payload key back to the + report attribute it came from, held in a second file, and that map rots the first time a key + is renamed here. Sorting the output means `?sort=qty` names exactly the field the client can + see in the response, and `_plain` guarantees `value` re-parses to the `Decimal` it was written + from, for any input, so the round trip loses nothing. + + **Numeric or text is decided per COLUMN, never per row.** A per-row decision would put a + `Decimal` and a `str` in one comparison, which raises, and a per-row fallback would make the + order depend on which values happened to look like numbers. So: numeric only when every + present value parses finite, text otherwise. Instants land in the text branch and are still + chronological, because `moment().value` is fixed-width ISO-8601 UTC. + + **An absent value sorts last in BOTH directions.** `None` means "not recorded" and `0` means + "recorded as zero"; collapsing the first into the second is the shape of the always-passing fee + rail (#198). Giving an absent cell a numeric key would make it the largest thing in a + descending sort, which is that same collapse wearing a different hat. + """ + present: list[tuple[str, Mapping[str, Any]]] = [] + missing: list[Mapping[str, Any]] = [] + for row in rows: + text = _cell_text(row, column) + if text: + present.append((text, row)) + else: + missing.append(row) + + figures = [_finite_decimal(text) for text, _row in present] + keyed: list[tuple[Any, Mapping[str, Any]]] + if all(figure is not None for figure in figures): + keyed = [(figure, row) for figure, (_text, row) in zip(figures, present, strict=True)] + else: + keyed = [(text.casefold(), row) for text, row in present] + + # `list.sort` is stable in both directions, so rows that tie keep the order the report built + # them in -- which for `open_positions` is FIFO, the attribution order a later exit uses, and + # therefore an order that must not be scrambled by a display sort. + keyed.sort(key=_ordering_key, reverse=descending) + return [row for _key, row in keyed] + missing + + +# -- config (#534) ------------------------------------------------------------------------------- + + +def config_payload(build: Any, *, describe: str = "") -> dict[str, Any]: + """The running build, for the two consumers that need it by name. + + `version` is what #539 carries as `?v=` on a documentation link, so version skew between the + engine and the docs is visible rather than silent. `build` is `full_version` -- the version + bound to the commit -- and is what #538 keys its service-worker cache name to, because a + version alone is ambiguous (many commits share one between bumps) and a cache key that does + not move when the code does is exactly how an upgraded engine gets met by a stale shell + holding an older contract. + + `build` arrives already RESOLVED, from `ServeConfig`, rather than being looked up here. + `keel.version.build_info()` shells out to git twice, and an endpoint a service worker polls + must not fork a subprocess to answer. `None` -- no package metadata and no git, the same + environment in which the page footer is already empty -- reports absent rather than inventing + a version: a cache key of `""` still keys a cache, whereas a wrong version in a `?v=` link + would send a reader to documentation for a build that does not exist. + """ + return { + "version": str(getattr(build, "version", "") or ""), + "build": str(getattr(build, "full_version", "") or ""), + "commit": str(getattr(build, "commit", "") or ""), + "source": str(getattr(build, "source", "") or ""), + "describe": describe, + # keel's central honesty signal, and the one judgement this payload carries: `False` means + # the running code corresponds to no commit (a dirty tree, or no idea), and `keel.version` + # treats saying so as more important than looking tidy. + "reproducible": flag( + None if build is None else bool(getattr(build, "is_reproducible", False)), + on="reproducible", + off="NOT reproducible — this build corresponds to no commit", + on_state=GOOD, + off_state=WARN, + ), + } + + +# -- setup (#534) -------------------------------------------------------------------------------- + +#: `StepKind`, judged -- and the judgement is "can a machine do this for you", never "is something +#: wrong". `judgement` warns because a human must decide it and keel must never decide it for +#: them; `off_venue` warns because keel can neither perform nor VERIFY it, and `render.py`'s own +#: note is the reason: "a green check that verifies nothing turns an open risk into a false +#: assurance". +_STEP_KIND_STATES: Mapping[str, str] = { + "mechanical": NEUTRAL, + "operator_input": NEUTRAL, + "judgement": WARN, + "off_venue": WARN, +} + +#: `JobStatus.state`, judged. +_JOB_STATES: Mapping[str, str] = {"running": WARN, "done": GOOD, "failed": BAD} + + +def _step_payload(item: Any) -> dict[str, Any]: + """One checklist step. + + `done` is three-valued and stays three-valued: `None` means "could not be determined", which is + NOT `False`. An unreadable database is not an unseeded one, and reporting it as incomplete + would send an operator to re-run a step that may already be done -- `StepState.done`'s own + comment. `flag(None)` renders it absent, so the browser shows a dash where the CLI shows + `[?]`, and neither claims to know.""" + return { + "key": item.step.key, + "title": item.step.title, + "kind": label( + item.step.kind.value, state=_STEP_KIND_STATES.get(item.step.kind.value, NEUTRAL) + ), + "stage": item.step.stage.value, + "why": item.step.why, + "how": item.step.how, + "done": flag(item.done, on="done", off="outstanding", on_state=GOOD, off_state=WARN), + "detail": item.detail, + } + + +def _action_input_payload(field: Any) -> dict[str, Any]: + return { + "name": field.name, + "label": field.label, + "hint": field.hint, + # `secret=True` is the difference between a `password` input and a `text` one, and between + # a form that can be submitted safely and one that leaks its own contents into browser + # history. The client is told the answer rather than deriving it from the field's name. + "secret": flag(field.secret, on="never echoed back", off="shown as typed"), + # A closed set of answers, rendered with NOTHING pre-selected: an action that could fill in + # a field the operator left blank is one that could record something they never supplied. + "choices": list(field.choices), + } + + +def _action_payload(action: Any) -> dict[str, Any]: + return { + "key": action.key, + "title": action.title, + "detail": action.detail, + "needs_input": flag( + action.needs_input, on="needs your input", off="keel can do this unaided" + ), + "inputs": [_action_input_payload(field) for field in action.inputs], + } + + +def _job_payload(job: Any) -> dict[str, Any]: + """A background setup job (`keel.commands.jobs`), as JSON. + + `int(job.elapsed_sec)` is the one integer conversion in this file outside `_gmt`, and it is on + SECONDS, which have no cent to lose -- the same argument Rule 6b's timestamp allowance makes. + `duration` takes whole seconds because `_human_age`, the CLI's own phrasing, does. A failure + stays in the payload rather than being cleared: the whole point of running something in the + background is that nobody was watching when it broke.""" + return { + "key": job.key, + "state": label(job.state, state=_JOB_STATES.get(job.state, NEUTRAL)), + "started_at": moment(job.started_ts), + "finished_at": moment(job.finished_ts), + "elapsed": duration(int(job.elapsed_sec)), + "running": flag(job.is_running, on="running", off="finished"), + "error": job.error or "", + # Newest last, unscrolled, exactly as the CLI prints them: an operator who has run `keel + # fetch` in a terminal should recognise what they are looking at rather than have to learn + # a second vocabulary for the same thing. + "lines": list(job.lines), + } + + +def setup_payload( + state: Any, + *, + actions: Sequence[Any] = (), + not_automated: Mapping[str, str] | None = None, + job: Any = None, +) -> dict[str, Any]: + """`keel.commands.setup.inspect`'s `DeploymentState`, as JSON. + + **No CSRF token, and that is the point rather than an omission.** `render_setup` takes one + because it emits `