From 8053175dca1bb554e3b6587f45122ab09f1dc348 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 5 Sep 2026 15:37:42 -0400 Subject: [PATCH 1/2] feat(web): session identity on every view, and a paper banner with nothing to click (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Take the spine, refuse what rides on it. Making paper-vs-live visible on every page rather than buried in settings is the one organizing idea worth borrowing from a retail broker console. What their version attaches -- an "Open Live Account" button on the paper banner -- is a growth funnel wrapped around real money. THE CHIP: profile · mode · equity state Three facts that together answer "which deployment is this browser looking at, and which account drove the numbers on it". It surrounds the existing mode badge rather than replacing it, so the badge keeps owning the mode word and its db/config tooltip, and each fact stays written by the one function that knows it. `profile` NAMES THE DATABASE, and the stem is as far as it goes. ADR 0002 settles what a profile is -- "the database is already one-per-profile", which is also why `equity_points` has a `mode` column and no `profile` one -- so there is nothing stored to read. `keel.db` and `keel-live.db` are the operator's own names for their deployments, and a mapping from those to "paper"/"live" would be this console guessing which is which from a filename, on the one surface built to stop paper and live being confused. The full paths stay in the tooltip. `equity_state` is a Field and not a bare word because it has an UNKNOWN reading that matters: `equity_state_mode` is written on the first mode flip, so a deployment that has never run has none, and "which account drove the drawdown scalars" being unanswered is a different fact from either answer. It reads "not recorded", not an em-dash: `label(None)` is `absent()`, whose display is right in a table cell and wrong in a chip -- "keel · confirm · —" drops the very half the banner asks the operator to verify. ONE DATABASE READ ON AN ENDPOINT THAT MUST SURVIVE HAVING NO DATABASE `equity_state_mode` lives in `agent_state`, and `/api/config` is the only endpoint every view reads -- which is what makes the chip present everywhere rather than only where a status report happens to load. That route is `needs_database=False` because the client boots from it alone, and it still is. The read checks the file exists before connecting, and that is not a micro-optimisation: `sqlite3.connect` CREATES what it cannot find, so an unguarded read would have a read-only view bring a deployment into existence merely by being polled every fifteen seconds -- and every page would then report a healthy empty install rather than offering to set one up. `server.ensure_schema` carries the same guard and the same reasoning, found the same way. Any failure degrades to unknown, so a first run still boots the shell; it just boots without the equity half, which is the honest state. THE BANNER: A STATEMENT, NOT AN ALERT, AND NOTHING TO CLICK Paper gets fixed wording, asserted verbatim, because it is the sentence standing between an operator and mistaking a simulation for their account. Confirm is not a warning -- it restates the mode/equity-state pairing the operator is about to check against the venue's own UI, and it names BOTH halves because they are separately settable and a mismatch between them is exactly what the check is for. An unreadable config gets NO banner. A banner is a claim about whether real money is involved, and there is no safe default for that claim -- the same refusal `modeBadge` already makes. The sentence is chosen in Python (Rule 2) and placed unread; the renderer decides only the emphasis, from the same `MODE_CLASS` table the badge uses, so the two cannot disagree about which mode is the quiet one. THE REFUSAL IS PINNED STRUCTURALLY, AND THE FIRST PIN DID NOT WORK A negative rots silently, so neither `sessionChip` nor `paperBanner` may build an interactive node or bind a handler, and a test sweeps every mode × equity-state pairing for the vocabulary of a funnel. The first version of the clickable scan used `_code_only`, which strips string literals -- and the thing being forbidden is `el("button", ...)`, where `button` IS a string literal. It found nothing, always. Adding a "Go live" button to the banner passed it. There is now a lexer that strips comments and KEEPS strings (comments still go: `paperBanner`'s docstring explains what it refuses using the exact words the scan looks for), plus a smoke test asserting the lexer keeps what the scan depends on, before that scan is allowed to mean anything. Nine mutants killed: an unreadable config still getting the paper banner, the banner dropping the equity half, an unrecorded equity state rendering as paper, the banner gaining a way to go live, the config read connecting to a database that is not there, the banner moving inside the view where navigation repaints it, the banner growing a button, the chip growing a listener, and main.js demanding an id the shell does not have. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6 --- keel/web/api.py | 73 ++++++++++++++++-- keel/web/payload.py | 68 +++++++++++++++++ keel/web/static/css/keel.css | 46 +++++++++++ keel/web/static/index.html | 43 ++++++++++- keel/web/static/js/main.js | 14 ++++ keel/web/static/js/render.js | 73 ++++++++++++++++++ tests/web/test_api.py | 75 ++++++++++++++++++ tests/web/test_client_assets.py | 131 ++++++++++++++++++++++++++++++++ tests/web/test_payload.py | 81 ++++++++++++++++++++ 9 files changed, 597 insertions(+), 7 deletions(-) diff --git a/keel/web/api.py b/keel/web/api.py index d772ef7..9ba9622 100644 --- a/keel/web/api.py +++ b/keel/web/api.py @@ -165,13 +165,19 @@ def _status_report(cfg: ServeConfig, now_ts: int) -> Any: def read_config(cfg: ServeConfig, _query: Query, _state: Any, _now_ts: int) -> dict[str, Any]: - """The running build, and the deployment that build is serving (#597). + """The running build, and the deployment that build is serving (#597, #704). - Still reads NO database -- it answers on a machine with nothing set up, which is why its - route is `needs_database=False`. The deployment half arrives as this process's own - arguments (`cfg.db_path`, `cfg.config_path`) plus one read of the config FILE, which opens - no database and forks no subprocess, unlike the `inspect` probe the envelope has already run - for `engine` by the time this returns. + **It answers on a machine with nothing set up, which is why its route is + `needs_database=False` -- and #704 added one OPTIONAL database read without changing that.** + The chip needs `equity_state_mode`, which lives in `agent_state`, and this is the only + endpoint every view reads. `_equity_state_mode` therefore checks the file exists before + connecting (`sqlite3.connect` CREATES what it cannot find) and degrades to unknown on any + failure, so a first run still boots the shell -- it just boots it without the equity half of + the chip, which is the honest thing for a deployment that has never run. + + The rest of the deployment half arrives as this process's own arguments (`cfg.db_path`, + `cfg.config_path`) plus one read of the config FILE, which forks no subprocess, unlike the + `inspect` probe the envelope has already run for `engine` by the time this returns. **`_mode` degrades rather than raising, and the whole shell depends on that.** The client boots from this one endpoint -- worker registration, docs links, the footer build line and @@ -183,11 +189,66 @@ def read_config(cfg: ServeConfig, _query: Query, _state: Any, _now_ts: int) -> d cfg.build_info, describe=cfg.build, mode=_auto_trade_mode(cfg.config_path), + profile=_profile_name(cfg.db_path), + equity_state_mode=_equity_state_mode(cfg.db_path), db_path=cfg.db_path, config_path=cfg.config_path, ) +def _profile_name(db_path: str) -> str: + """The deployment profile's name: the database file's stem (#704). + + ADR 0002 settles what a profile IS -- "the database is already one-per-profile", which is + also why `equity_points` has a `mode` column and no `profile` one. So there is nothing + STORED to read: the profile is the file, and this names the file. + + The stem and not a prettier word, because anything prettier would be inferred. `keel.db` and + `keel-live.db` are the operator's own names for their deployments; a mapping from those to + "paper"/"live" would be this console guessing which is which from a filename, on the one + surface built to stop paper and live being confused. The full paths stay in the mode badge's + tooltip, so the short name is checkable rather than trusted. + """ + from pathlib import Path + + return Path(db_path).stem if db_path else "" + + +def _equity_state_mode(db_path: str) -> str: + """`agent_state.equity_state_mode` -- which account last drove the shared drawdown scalars. + + **This is the one database read on an endpoint whose route is `needs_database=False`, and the + exemption is load-bearing.** The client boots from this endpoint alone, so a database that is + missing (a first run) or unreadable must cost the chip its equity half, never the page its + boot. Every failure is one answer here -- unknown -- for the same reason `_auto_trade_mode` + treats a missing file, malformed YAML and a refused value alike: naming the narrow ones would + leave this reader deciding which failure is which, and the caller's response is the same. + + **Existence is checked before connecting, and that is not a micro-optimisation.** + `sqlite3.connect` CREATES the file it cannot find, so connecting unconditionally would have a + read-only view bring a deployment into existence merely by being polled -- and every page + would then report a healthy empty install rather than offering to set one up. + `server.ensure_schema` carries the same guard and the same reasoning, found the same way. + + No migration, like every other read in this package: a view must not take a schema write lock + on a database the agent may be mid-cycle on. So a pre-migration database answers from + whatever `agent_state` it has, or answers unknown. + """ + from pathlib import Path + + if not db_path or not Path(db_path).exists(): + return "" + repo = None + try: + repo = open_repo(db_path) + return str(repo.get_state("equity_state_mode") or "") + except Exception: # a database that cannot answer is one that has not answered + return "" + finally: + if repo is not None: + close_repo(repo) + + def _auto_trade_mode(config_path: str) -> str: """The served config's own word for `auto_trade.mode`, or `""` when it cannot be read. diff --git a/keel/web/payload.py b/keel/web/payload.py index 639b788..9af506a 100644 --- a/keel/web/payload.py +++ b/keel/web/payload.py @@ -2570,11 +2570,60 @@ def order_rows( # -- config (#534) ------------------------------------------------------------------------------- +#: The banner's exact words per `auto_trade.mode` (#704). +#: +#: PAPER is fixed wording, and it is the sentence that stands between an operator and mistaking a +#: simulation for their account -- so it is asserted verbatim by a test rather than left to a +#: renderer's phrasing. CONFIRM is not a warning: it restates the configuration the operator is +#: about to verify against the venue's own UI, which is why it names the equity state too. +#: +#: **Nothing here ever offers a way to go live.** Alpaca's paper banner carries an "Open Live +#: Account" button -- radical clarity used as a growth funnel wrapped around real money. Going +#: live in keel is a config edit plus a typed terminal ceremony with a runbook; this surface may +#: EXPLAIN that and must never funnel toward it. Pinned by a test that sweeps every mode and +#: equity-state pairing for the vocabulary of a funnel. +_BANNER_PAPER = "PAPER — no real money is involved" + +#: What the chip says where `equity_state_mode` has never been written -- a deployment that has +#: not yet flipped modes. NOT `paper`: that would be the console inventing the safer of the two +#: answers about which account drove the shared drawdown scalars. +_EQUITY_STATE_UNRECORDED = "not recorded" + + +def _session_banner(mode: str, equity_state_mode: str) -> str: + """The banner sentence for one mode, or `""` for a config that could not be read. + + Chosen HERE and not in the renderer (Rule 2): picking between two sentences on the basis of + what a config says is a judgement, and judgements are made in Python. The renderer places + this string and decides nothing. + + An unknown mode gets NO banner, which is the same refusal `modeBadge` already makes for the + badge itself: an absent answer is not `paper`, and guessing a mode on a trading console is + the one thing this surface must never do. A banner is a claim about whether real money is + involved, and there is no safe default for that claim. + """ + if mode == "paper": + return _BANNER_PAPER + if not mode: + return "" + # Both halves, always. Mode and equity state are separately settable, and a MISMATCH between + # them -- `confirm` against paper equity, or the reverse -- is precisely what an operator is + # being asked to check against the venue UI. Printing the mode alone would drop the half that + # makes the check possible. + state = equity_state_mode or _EQUITY_STATE_UNRECORDED + return ( + f"{mode.upper()} — every order is previewed and waits for your approval; " + f"equity state {state}" + ) + + def config_payload( build: Any, *, describe: str = "", mode: str = "", + profile: str = "", + equity_state_mode: str = "", db_path: str = "", config_path: str = "", ) -> dict[str, Any]: @@ -2621,6 +2670,25 @@ def config_payload( # paths that answer "where am I" for a process serving one --db/--config pair. See the # module note above for why these are bare strings and why mode is read-only. "mode": mode, + # The session chip (#704). `profile` is a bare string like `mode` -- it NAMES the + # database, which ADR 0002 says is the profile ("the database is already one-per-profile"), + # and there is nothing to grade about a name. + "profile": profile, + # A `label` and not bare, because unlike `mode` this one has an UNKNOWN reading that + # matters: `equity_state_mode` is written on the first mode flip, so a deployment that has + # never run has none, and "which account drove the drawdown scalars" being unanswered is a + # different fact from either answer. Rule 3 keeps that distinction here rather than letting + # a client infer it from an empty string. + # `""` and not `None` for the absent case, deliberately. `label(None)` is `absent()`, + # whose display is the em-dash -- right in a table cell, wrong here: a chip reading + # "keel · confirm · —" drops the very half the banner asks the operator to verify. The + # empty `value` still says absent to anything reading the field programmatically. + "equity_state": label( + equity_state_mode, + display=equity_state_mode or _EQUITY_STATE_UNRECORDED, + state=NEUTRAL if equity_state_mode else UNKNOWN, + ), + "banner": _session_banner(mode, equity_state_mode), "db_path": db_path, "config_path": config_path, # keel's central honesty signal, and the one judgement this payload carries: `False` means diff --git a/keel/web/static/css/keel.css b/keel/web/static/css/keel.css index 8b36427..98e2605 100644 --- a/keel/web/static/css/keel.css +++ b/keel/web/static/css/keel.css @@ -256,6 +256,52 @@ header .mode-paper { color: var(--muted); } header .mode-confirm, header .mode-live { color: var(--accent); border-color: var(--accent); } +/* THE SESSION CHIP (#704): profile · mode · equity state, as one group. + * + * A group and not three loose items, because the three are one answer: "which deployment is this + * browser looking at, and which account drove the numbers on it". `inline-flex` so the badge in + * the middle keeps its own pill shape while the two text halves sit level with it. + * + * Both halves are muted. The mode badge carries the emphasis for all three -- it is the fact that + * decides whether orders can be placed -- and giving a profile name the same weight would make + * the header compete with itself. */ +header .session { + display: inline-flex; + align-items: center; + gap: 0.4rem; + align-self: center; +} +header .sessionpart { + color: var(--muted); + font-size: 0.85em; +} +/* Hidden while empty, the same pairing the badge above uses and for the same reason: a lone + * separator beside a name that never arrived is a stray mark. */ +header .sessionpart:empty { display: none; } + +/* THE MODE BANNER (#704). + * + * Full-bleed and calm. It is a statement, not an alert: no `--bad`, no `--warn`, no icon, and + * nothing inside it that can be clicked. `MODE_CLASS` supplies the same emphasis the badge uses, + * so the banner and the badge cannot disagree about which mode is the quiet one -- paper reads + * muted on a muted ground, confirm carries the accent. + * + * Hidden while empty: an unreadable config costs the banner, because a banner is a claim about + * whether real money is involved and there is no safe default for that claim. */ +.modebanner { + margin: 0; + padding: 0.45rem 1rem; + border-bottom: 1px solid var(--line); + background: var(--surface); + font-size: 0.85rem; + letter-spacing: 0.01em; + text-align: center; +} +.modebanner:empty { display: none; } +.modebanner.mode-paper { color: var(--muted); } +.modebanner.mode-confirm, +.modebanner.mode-live { color: var(--accent); } + /* The toggle. It deliberately does NOT inherit the filled `button` rule: that one is for an * action that changes something on the server, and a filled teal disc beside the nav would be * the loudest thing in the header. `--control-line` for the border, not `--line`, for the diff --git a/keel/web/static/index.html b/keel/web/static/index.html index 4d0ca4c..9cd1055 100644 --- a/keel/web/static/index.html +++ b/keel/web/static/index.html @@ -164,7 +164,27 @@ should cost the badge rather than guess a mode. --> - + + + + + + +

+