diff --git a/keel/web/api.py b/keel/web/api.py index e54db2c..aa288a5 100644 --- a/keel/web/api.py +++ b/keel/web/api.py @@ -41,7 +41,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from keel.web import payload +from keel.web import payload, runtime from keel.web.security import csrf_token if TYPE_CHECKING: # pragma: no cover - typing only @@ -198,11 +198,13 @@ def read_config(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> di edit gone wrong) must cost the page its badge, not its boot. `payload.config_payload` records the read-only boundary: nothing in this package writes `auto_trade.mode`. """ + peers = runtime.live_peers(current_port=cfg.port) return payload.config_payload( cfg.build_info, describe=cfg.build, mode=_auto_trade_mode(cfg.config_path), profile=_profile_name(cfg.db_path), + peers=peers, **_session_state(cfg.db_path, now_ts), db_path=cfg.db_path, config_path=cfg.config_path, diff --git a/keel/web/payload.py b/keel/web/payload.py index bedeaee..caddffe 100644 --- a/keel/web/payload.py +++ b/keel/web/payload.py @@ -3275,6 +3275,7 @@ def config_payload( autonomous: bool = False, db_path: str = "", config_path: str = "", + peers: Sequence[Mapping[str, Any]] = (), ) -> dict[str, Any]: """The running build and the deployment it serves, for the consumers that need either by name. @@ -3350,6 +3351,7 @@ def config_payload( on_state=GOOD, off_state=WARN, ), + "peers": list(peers), } diff --git a/keel/web/runtime.py b/keel/web/runtime.py index da4ae4e..17d7694 100644 --- a/keel/web/runtime.py +++ b/keel/web/runtime.py @@ -103,7 +103,15 @@ def record_path(port: int) -> Path: return directory / f"serve-{int(port)}.json" -def record_serving(*, host: str, port: int, token: str, interactive: bool) -> Path | None: +def record_serving( + *, + host: str, + port: int, + token: str, + interactive: bool, + profile: str = "", + mode: str = "", +) -> Path | None: """Record how to reach this server, unless a human is watching stdout. `interactive` is the caller's `sys.stdout.isatty()` -- passed in rather than read here so the @@ -123,15 +131,18 @@ def record_serving(*, host: str, port: int, token: str, interactive: bool) -> Pa # half-written record: `os.replace` is atomic within a directory. The temporary carries the # same `0600`, because it holds the same token for the moment it exists. staging = directory / f".serve-{int(port)}.json.tmp" - body = json.dumps( - { - "pid": os.getpid(), - "host": host, - "port": int(port), - "token": token, - "started_ts": int(time.time()), - } - ) + data: dict[str, Any] = { + "pid": os.getpid(), + "host": host, + "port": int(port), + "token": token, + "started_ts": int(time.time()), + } + if profile: + data["profile"] = str(profile) + if mode: + data["mode"] = str(mode) + body = json.dumps(data) # CREATED at `0600`, not corrected to it (#759 review). `Path.write_text` creates at the # process umask -- measured `0644` under the usual `022` -- and the `chmod` that followed left # the token world-readable for the window between the two calls. The `0700` directory meant no @@ -292,3 +303,52 @@ def stdout_is_interactive() -> bool: return bool(sys.stdout.isatty()) except AttributeError, ValueError: return False + + +def live_peers(current_port: int | None = None) -> list[dict[str, Any]]: + """All active peer console servers discovered in the runtime directory (#814). + + Scans `run_dir()` for all `serve-*.json` records, verifying each against `live_record` (live + process and responding loopback port). Returns structured peer metadata sorted by port, with + wire-safe types (port as string, current as boolean). + """ + directory = run_dir() + if directory is None or not directory.is_dir(): + return [] + + peers: list[dict[str, Any]] = [] + try: + entries = sorted(directory.glob("serve-*.json")) + except OSError: + return [] + + for entry in entries: + name = entry.stem + parts = name.split("-") + if len(parts) != 2: + continue + try: + port = int(parts[1]) + except ValueError: + continue + + record = live_record(port) + if record is None: + continue + + profile = str(record.get("profile", "") or "") + mode = str(record.get("mode", "") or "") + is_current = bool(current_port is not None and port == int(current_port)) + + peers.append( + { + "port": str(port), + "profile": profile, + "mode": mode, + "url": url_for(record), + "current": is_current, + } + ) + + peers.sort(key=lambda p: int(p["port"])) + return peers diff --git a/keel/web/server.py b/keel/web/server.py index 5ad5c33..e2f2dba 100644 --- a/keel/web/server.py +++ b/keel/web/server.py @@ -1113,8 +1113,15 @@ def serve(cfg: ServeConfig, *, echo: Callable[[str], None] = print) -> int: # printing the wrong one would be a false safety assurance about a live credential -- the # class of thing `payload._session_banner` refuses to do about mode. interactive = runtime.stdout_is_interactive() + profile = Path(running.db_path).stem if running.db_path else "" + mode = api._auto_trade_mode(running.config_path) recorded = runtime.record_serving( - host=running.host, port=running.port, token=running.token, interactive=interactive + host=running.host, + port=running.port, + token=running.token, + interactive=interactive, + profile=profile, + mode=mode, ) if recorded is None: echo("Stopping keel revokes it: the token is new every run and is never written to disk.") diff --git a/keel/web/static/css/keel.css b/keel/web/static/css/keel.css index 16dd343..dc1b90e 100644 --- a/keel/web/static/css/keel.css +++ b/keel/web/static/css/keel.css @@ -390,6 +390,23 @@ header .sessionpart { text-overflow: ellipsis; white-space: nowrap; } +header .profile-select { + background: var(--bg); + color: var(--muted); + border: 1px solid var(--control-line); + border-radius: 4px; + font-size: 0.85em; + font-family: inherit; + padding: 0.1rem 0.3rem; + max-width: 14ch; + cursor: pointer; + outline: none; +} +header .profile-select:hover, +header .profile-select:focus-visible { + color: var(--fg); + border-color: var(--accent); +} /* THE SEPARATORS. Generated rather than written into the markup, because each one belongs to the * part beside it: `display: none` takes a hidden part's pseudo-element with it, so a name that * never arrived cannot leave a lone middot behind. That is the pairing the `:empty` rule below diff --git a/keel/web/static/js/main.js b/keel/web/static/js/main.js index 2624077..3b71e18 100644 --- a/keel/web/static/js/main.js +++ b/keel/web/static/js/main.js @@ -889,6 +889,14 @@ themeNode.addEventListener("click", () => { } }); +//: In-app profile switcher navigation (#814). +sessionProfileNode.addEventListener("change", (event) => { + const target = /** @type {HTMLSelectElement} */ (event.target); + if (target && target.value && target.value !== window.location.href) { + window.location.href = target.value; + } +}); + /** Catch up immediately when a hidden tab is looked at again -- see the poll comment in `show`. */ /** * The write path (#540): one delegated `submit` listener for every action form. diff --git a/keel/web/static/js/render.js b/keel/web/static/js/render.js index c7b1dd7..3dc6969 100644 --- a/keel/web/static/js/render.js +++ b/keel/web/static/js/render.js @@ -3721,14 +3721,23 @@ export function deploymentCard(node, config) { node.replaceChildren(); return; } + const peers = config && Array.isArray(config.peers) ? config.peers : []; + const rows = [ + kv("deployment", config.profile || "\u2014"), + kv("mode", mode), + kv("config", config.config_path || "\u2014"), + kv("database", config.db_path || "\u2014"), + kv("origin", window.location.origin), + ]; + if (peers.length !== 0 && peers.length !== 1) { + const peerLabels = peers.map((p) => { + const name = p.profile || String(p.port); + return p.current ? name.concat(" (current)") : name; + }); + rows.push(kv("active profiles", peerLabels.join(", "))); + } node.replaceChildren( - gridCard([ - kv("deployment", config.profile || "\u2014"), - kv("mode", mode), - kv("config", config.config_path || "\u2014"), - kv("database", config.db_path || "\u2014"), - kv("origin", window.location.origin), - ]), + gridCard(rows), el("p", "muted", SWITCHING_NOTE), ); } @@ -3747,6 +3756,9 @@ export function deploymentCard(node, config) { * replacing it, so the badge keeps owning the mode word and its db/config tooltip -- three facts * reading `profile · mode · equity state`, each written by the one function that knows it. * + * When multiple peer console daemons are running, the profile node renders an accessible + * selector allowing instant browser navigation between running sessions (#814). + * * The two halves are filled separately because they are separately absent: a profile is always * knowable (it is the database's filename), while the equity state can be genuinely unrecorded * on a deployment that has never flipped modes. `equity_state` is a `Field` carrying that @@ -3759,7 +3771,25 @@ export function deploymentCard(node, config) { */ export function sessionChip(profileNode, equityNode, config) { const profile = config && typeof config.profile === "string" ? config.profile : ""; - if (profile) { + const peers = config && Array.isArray(config.peers) ? config.peers : []; + + if (peers.length !== 0 && peers.length !== 1) { + const options = peers.map((peer) => { + const name = peer.profile || "port ".concat(String(peer.port)); + const modeStr = peer.mode ? " (".concat(peer.mode, ")") : ""; + const opt = el("option", "", name.concat(modeStr)); + if (peer.url) { + opt.setAttribute("value", String(peer.url)); + } + if (peer.current) { + opt.setAttribute("selected", "selected"); + } + return opt; + }); + const select = el("select", "profile-select", ...options); + select.setAttribute("aria-label", "Switch running profile"); + profileNode.replaceChildren(select); + } else if (profile) { profileNode.replaceChildren(document.createTextNode(profile)); } else { // Empty rather than a placeholder, and `keel.css` hides it while empty -- the same rule the diff --git a/tests/web/test_profile_switcher.py b/tests/web/test_profile_switcher.py new file mode 100644 index 0000000..3543c4d --- /dev/null +++ b/tests/web/test_profile_switcher.py @@ -0,0 +1,157 @@ +"""Unit and integration tests for in-app profile switcher discovery (#814).""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from keel.web import payload, runtime +from tests.web.test_client_assets import _source + + +def test_record_serving_includes_profile_and_mode(tmp_path: Path) -> None: + """record_serving writes profile and mode into serve-.json when provided.""" + with patch("keel.web.runtime.state_root", return_value=tmp_path): + record_path = runtime.record_serving( + host="127.0.0.1", + port=8765, + token="test-token", + interactive=False, + profile="keel-live", + mode="confirm", + ) + assert record_path is not None + assert record_path.exists() + data = json.loads(record_path.read_text()) + assert data["profile"] == "keel-live" + assert data["mode"] == "confirm" + assert data["port"] == 8765 + assert data["token"] == "test-token" + + +def test_live_peers_discovers_active_daemons(tmp_path: Path) -> None: + """live_peers discovers all responsive peer records and sets current flag.""" + run_dir = tmp_path / "run" + run_dir.mkdir(parents=True) + + # Write two records + (run_dir / "serve-8765.json").write_text( + json.dumps( + { + "pid": 12345, + "host": "127.0.0.1", + "port": 8765, + "token": "tok-1", + "profile": "keel-live", + "mode": "confirm", + } + ) + ) + (run_dir / "serve-8766.json").write_text( + json.dumps( + { + "pid": 12346, + "host": "127.0.0.1", + "port": 8766, + "token": "tok-2", + "profile": "keel", + "mode": "paper", + } + ) + ) + + with ( + patch("keel.web.runtime.state_root", return_value=tmp_path), + patch("keel.web.runtime.process_alive", return_value=True), + patch("keel.web.runtime._port_answers", return_value=True), + ): + peers = runtime.live_peers(current_port=8765) + assert len(peers) == 2 + assert peers[0]["port"] == "8765" + assert peers[0]["profile"] == "keel-live" + assert peers[0]["mode"] == "confirm" + assert peers[0]["current"] is True + assert "tok-1" in peers[0]["url"] + + assert peers[1]["port"] == "8766" + assert peers[1]["profile"] == "keel" + assert peers[1]["mode"] == "paper" + assert peers[1]["current"] is False + assert "tok-2" in peers[1]["url"] + + +def test_live_peers_filters_unresponsive_or_dead_records(tmp_path: Path) -> None: + """Dead processes or unreachable ports are excluded from peer discovery.""" + run_dir = tmp_path / "run" + run_dir.mkdir(parents=True) + + (run_dir / "serve-8765.json").write_text( + json.dumps( + { + "pid": 12345, + "host": "127.0.0.1", + "port": 8765, + "token": "tok-1", + "profile": "keel-live", + } + ) + ) + (run_dir / "serve-8766.json").write_text( + json.dumps( + { + "pid": 99999, + "host": "127.0.0.1", + "port": 8766, + "token": "tok-2", + "profile": "keel-dead", + } + ) + ) + + def mock_alive(pid: int) -> bool: + return pid == 12345 + + with ( + patch("keel.web.runtime.state_root", return_value=tmp_path), + patch("keel.web.runtime.process_alive", side_effect=mock_alive), + patch("keel.web.runtime._port_answers", return_value=True), + ): + peers = runtime.live_peers(current_port=8765) + assert len(peers) == 1 + assert peers[0]["port"] == "8765" + assert peers[0]["profile"] == "keel-live" + + +def test_config_payload_serializes_peers_strictly() -> None: + """Rule 1 and Rule 3 hold on peers list: no json numbers, pure strings and bools.""" + peers = [ + { + "port": "8765", + "profile": "keel-live", + "mode": "confirm", + "url": "http://127.0.0.1:8765/?token=abc", + "current": True, + } + ] + data = payload.config_payload( + None, + describe="test", + mode="confirm", + profile="keel-live", + peers=peers, + ) + assert data["peers"] == peers + assert isinstance(data["peers"][0]["port"], str) + assert isinstance(data["peers"][0]["current"], bool) + + +def test_render_js_contains_profile_select_wiring() -> None: + """render.js generates select.profile-select when peers > 1 and main.js listens to change.""" + render_src = _source("render.js") + assert "profile-select" in render_src + assert "config.peers" in render_src + + main_src = _source("main.js") + assert "sessionProfileNode.addEventListener" in main_src + assert '"change"' in main_src