From 6fdebd50995f5770b35b2dc3907cdab7be2b66a6 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Wed, 22 Jul 2026 11:15:56 -0400 Subject: [PATCH 1/2] Qt-removal R0: foundations - backend, single SPA, native shell, burn-down gate Per doc/QT_REMOVAL_PLAN.md phase R0 (terminal goal: ZERO PySide6/Qt): - backend/: FastAPI app + session-scoped WebSocket event bus carrying the same versioned full-state-snapshot + named-intent wire semantics as the IslandBridge layer it succeeds; static serving of the SPA build; /api/health; 14 pytest cases (bus + WS endpoint). - web_ui/src/app: the single-SPA target consolidating the islands (GRAPHLINK_ISLAND=app builds to dist/app). WS transport module with auto-reconnect/re-subscribe (9 tests), token-driven shell layout, live system panel. - graphlink_island_codegen.py: emits web_ui/src/lib/api-contract/topics.ts, the SPA's topic->type/validator registry derived from the same schema registry (--check enforced). - graphlink_desktop.py: NATIVE pywebview/WebView2 window (not a browser, zero Qt) that boots the backend and opens the SPA. - tests/test_no_qt_anywhere.py + qt_burndown.json: THE permanent gate. Pin = 168 files importing Qt (90 source + 78 tests); any increase fails, any decrease must lower the pin in-commit; flips to zero-mode assertions (package uninstalled, undeclared) when the pin reaches 0. backend/, web_ui/, and graphlink_desktop.py are held to zero Qt from day one. Acceptance drive PASSED: native window, SPA served by the Python backend, live WS ping round-trip 4.1 ms. 17 pytest + 544 vitest green. Burn-down: 168 of 168 remaining (R0 adds only Qt-free code by design). Co-Authored-By: Claude Fable 5 --- backend/__init__.py | 14 ++ backend/app.py | 140 +++++++++++++ backend/events.py | 171 ++++++++++++++++ backend/tests/test_app_ws.py | 97 +++++++++ backend/tests/test_event_bus.py | 123 ++++++++++++ graphlink_app/graphlink_island_codegen.py | 58 +++++- graphlink_app/tests/test_no_qt_anywhere.py | 103 ++++++++++ graphlink_desktop.py | 112 +++++++++++ pyproject.toml | 6 + qt_burndown.json | 6 + web_ui/src/app/App.tsx | 109 ++++++++++ web_ui/src/app/index.html | 13 ++ web_ui/src/app/main.tsx | 16 ++ web_ui/src/app/styles.css | 158 +++++++++++++++ web_ui/src/lib/api-contract/topics.ts | 70 +++++++ web_ui/src/lib/ws/transport.test.ts | 172 ++++++++++++++++ web_ui/src/lib/ws/transport.ts | 219 +++++++++++++++++++++ web_ui/vite.config.ts | 24 ++- 18 files changed, 1606 insertions(+), 5 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/app.py create mode 100644 backend/events.py create mode 100644 backend/tests/test_app_ws.py create mode 100644 backend/tests/test_event_bus.py create mode 100644 graphlink_app/tests/test_no_qt_anywhere.py create mode 100644 graphlink_desktop.py create mode 100644 qt_burndown.json create mode 100644 web_ui/src/app/App.tsx create mode 100644 web_ui/src/app/index.html create mode 100644 web_ui/src/app/main.tsx create mode 100644 web_ui/src/app/styles.css create mode 100644 web_ui/src/lib/api-contract/topics.ts create mode 100644 web_ui/src/lib/ws/transport.test.ts create mode 100644 web_ui/src/lib/ws/transport.ts diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..6881226 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,14 @@ +"""Graphlink backend (Qt-removal plan R0, doc/QT_REMOVAL_PLAN.md). + +The Python half of the target architecture: FastAPI + a WebSocket event bus +carrying full-state snapshots and intents between the domain services and the +single React SPA. This package is the direct successor of the QWebChannel +IslandBridge layer and deliberately preserves its wire semantics (versioned +full-state envelopes, named intents) so the existing island payload schemas +and generated TS types retarget without redesign. + +Zero Qt imports are permitted anywhere under this package - enforced by +graphlink_app/tests/test_no_qt_anywhere.py. +""" + +BACKEND_VERSION = "0.1.0" diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..e17cf80 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,140 @@ +"""FastAPI application: HTTP surface + the /ws WebSocket endpoint +(Qt-removal plan R0). + +Serves three things: +- /api/health - liveness + version (the desktop shell polls this at startup) +- /ws?session= - the event-bus WebSocket (state snapshots out, intents in) +- / - the built SPA (static files), when a build directory exists + +Client -> server message kinds over /ws: + {"kind": "subscribe", "topics": ["system", ...]} -> current snapshots + {"kind": "intent", "topic": t, "intent": name, + "args": [...], "id": optional} -> optional result +Server -> client: + {"kind": "state", "topic": t, "payload": {...envelope...}} + {"kind": "result", "id": ..., "value": ...} (only when id sent) + {"kind": "error", "id": ..., "error": "..."} (bad topic/intent) + +R0 registers only the `system` topic (backend identity) and its `ping` +intent - the acceptance round-trip. Real domain topics arrive per-phase +(R1 canvas, R2 chrome, ...), each registering here exactly like system does. +""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from backend import BACKEND_VERSION +from backend.events import EventBus, SessionBus, UnknownIntentError, UnknownTopicError + +logger = logging.getLogger(__name__) + +REPO_ROOT = Path(__file__).resolve().parents[1] +SPA_DIST_DIR = REPO_ROOT / "web_ui" / "dist" / "app" + + +def _configure_session(bus: SessionBus) -> None: + """Give every session the R0 topic surface. Later phases extend this + with canvas/chrome/node topics - one registrar, one place to read the + whole API surface.""" + + bus.register_topic( + "system", + lambda: {"app": "graphlink", "backendVersion": BACKEND_VERSION, "sessionId": bus.session_id}, + ) + + def ping(*args): + # The R0 acceptance round-trip: echo + a server-side timestamp so the + # UI can prove the reply crossed the process boundary. + return {"echo": list(args), "serverTime": time.time()} + + bus.register_intent("system", "ping", ping) + + +def create_app(spa_dir: Path | None = None) -> FastAPI: + app = FastAPI(title="Graphlink backend", version=BACKEND_VERSION) + bus = EventBus(configure_session=_configure_session) + app.state.bus = bus + + @app.get("/api/health") + async def health() -> JSONResponse: + return JSONResponse({"status": "ok", "app": "graphlink", "version": BACKEND_VERSION}) + + @app.websocket("/ws") + async def ws_endpoint(websocket: WebSocket) -> None: + session_id = websocket.query_params.get("session", "default") + session = bus.session(session_id) + await websocket.accept() + session.attach(websocket) + try: + while True: + message = await websocket.receive_json() + await _handle_message(session, websocket, message) + except WebSocketDisconnect: + pass + finally: + session.detach(websocket) + + resolved_spa = SPA_DIST_DIR if spa_dir is None else spa_dir + if resolved_spa.is_dir(): + # html=True serves index.html at / ; the explicit fallback below keeps + # deep links (client-side routes) working instead of 404ing. + app.mount("/assets", StaticFiles(directory=resolved_spa / "assets"), name="assets") + + @app.get("/{full_path:path}") + async def spa(full_path: str) -> FileResponse: + candidate = (resolved_spa / full_path).resolve() + if full_path and candidate.is_file() and candidate.is_relative_to(resolved_spa.resolve()): + return FileResponse(candidate) + return FileResponse(resolved_spa / "index.html") + else: + logger.warning("SPA build not found at %s - only /api and /ws are served", resolved_spa) + + return app + + +async def _handle_message(session: SessionBus, websocket: WebSocket, message: dict) -> None: + kind = message.get("kind") + msg_id = message.get("id") + + if kind == "subscribe": + topics = message.get("topics") or session.topic_names() + for topic in topics: + try: + await session.send_snapshot(topic, websocket) + except UnknownTopicError: + await websocket.send_json( + {"kind": "error", "id": msg_id, "error": f"unknown topic: {topic}"} + ) + return + + if kind == "intent": + topic = message.get("topic", "") + intent = message.get("intent", "") + args = message.get("args") or [] + try: + result = await session.dispatch_intent(topic, intent, args) + except (UnknownTopicError, UnknownIntentError) as exc: + await websocket.send_json({"kind": "error", "id": msg_id, "error": str(exc)}) + return + except Exception: + # Handler bugs surface as errors to the caller, never as a dropped + # socket - and always land in the log. + logger.exception("intent %s/%s failed", topic, intent) + await websocket.send_json( + {"kind": "error", "id": msg_id, "error": f"intent failed: {topic}/{intent}"} + ) + return + if msg_id is not None: + await websocket.send_json({"kind": "result", "id": msg_id, "value": result}) + return + + await websocket.send_json( + {"kind": "error", "id": msg_id, "error": f"unknown message kind: {kind!r}"} + ) diff --git a/backend/events.py b/backend/events.py new file mode 100644 index 0000000..787fe6d --- /dev/null +++ b/backend/events.py @@ -0,0 +1,171 @@ +"""WebSocket event bus: session-scoped topics carrying versioned full-state +snapshots plus named intents (Qt-removal plan R0). + +Wire semantics are inherited from the IslandBridge/QWebChannel layer this +replaces, because they were already the right shape and the frontend's +generated validators depend on them: + +- Server -> client: full-state snapshots only, never diffs. Every snapshot + carries schemaVersion / minCompatibleSchemaVersion / revision, stamped + here exactly as IslandBridge.publish() stamped them (a reader may accept a + NEWER payload than it understands - additive-only guarantee - but must + refuse one older than its stated minimum). +- Client -> server: named intents ("setGridSize", "ready", ...) addressed to + a topic, with positional JSON args - the successor of @Slot methods. + +Scoping: one SessionBus per session id. Topics, revisions, and connections +are session-local, so two windows on different sessions never see each +other's state. The bus is transport-agnostic: connections are anything with +an async send_json(dict) - real WebSockets in app.py, plain recorders in +tests. +""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +from typing import Any, Awaitable, Callable, Protocol + +logger = logging.getLogger(__name__) + +StateBuilder = Callable[[], dict[str, Any]] +IntentHandler = Callable[..., Any | Awaitable[Any]] + + +class Connection(Protocol): + async def send_json(self, data: dict[str, Any]) -> None: ... + + +class UnknownTopicError(KeyError): + """An intent or publish referenced a topic nothing registered.""" + + +class UnknownIntentError(KeyError): + """A client sent an intent name the topic does not expose.""" + + +class _Topic: + __slots__ = ("name", "builder", "schema_version", "min_compatible", "revision") + + def __init__(self, name: str, builder: StateBuilder, schema_version: int, min_compatible: int): + self.name = name + self.builder = builder + self.schema_version = schema_version + self.min_compatible = min_compatible + self.revision = 0 + + def snapshot(self) -> dict[str, Any]: + self.revision += 1 + payload = dict(self.builder()) + payload["schemaVersion"] = self.schema_version + payload["minCompatibleSchemaVersion"] = self.min_compatible + payload["revision"] = self.revision + return payload + + +class SessionBus: + """Topics + connections + intent handlers for ONE session.""" + + def __init__(self, session_id: str): + self.session_id = session_id + self._topics: dict[str, _Topic] = {} + self._intents: dict[tuple[str, str], IntentHandler] = {} + self._connections: set[Connection] = set() + + # -- registration ------------------------------------------------------ + + def register_topic( + self, + name: str, + builder: StateBuilder, + *, + schema_version: int = 1, + min_compatible: int = 1, + ) -> None: + assert name not in self._topics, f"topic {name!r} registered twice" + self._topics[name] = _Topic(name, builder, schema_version, min_compatible) + + def register_intent(self, topic: str, intent: str, handler: IntentHandler) -> None: + key = (topic, intent) + assert key not in self._intents, f"intent {topic}/{intent} registered twice" + self._intents[key] = handler + + # -- connections ------------------------------------------------------- + + def attach(self, conn: Connection) -> None: + self._connections.add(conn) + + def detach(self, conn: Connection) -> None: + self._connections.discard(conn) + + @property + def connection_count(self) -> int: + return len(self._connections) + + # -- state flow -------------------------------------------------------- + + async def publish(self, topic: str) -> dict[str, Any]: + """Build a fresh snapshot for `topic` and broadcast it to every + attached connection. Returns the snapshot (tests + send-current-state + on subscribe both want it).""" + t = self._topics.get(topic) + if t is None: + raise UnknownTopicError(topic) + snapshot = t.snapshot() + message = {"kind": "state", "topic": topic, "payload": snapshot} + # Snapshot the set: a failed send detaches the connection mid-loop. + for conn in list(self._connections): + try: + await conn.send_json(message) + except Exception: + # A dead socket must never poison the broadcast for the rest. + logger.warning("dropping dead connection on session %s", self.session_id) + self.detach(conn) + return snapshot + + async def send_snapshot(self, topic: str, conn: Connection) -> None: + """Send the current state of one topic to one connection (the + subscribe handshake - the successor of loadFinished -> publish()).""" + t = self._topics.get(topic) + if t is None: + raise UnknownTopicError(topic) + await conn.send_json({"kind": "state", "topic": topic, "payload": t.snapshot()}) + + def topic_names(self) -> list[str]: + return sorted(self._topics) + + async def dispatch_intent(self, topic: str, intent: str, args: list[Any]) -> Any: + """Run a registered intent handler. Sync and async handlers are both + supported; sync handlers run in a thread so a slow one cannot stall + the event loop (QThread's replacement in miniature).""" + handler = self._intents.get((topic, intent)) + if handler is None: + if topic not in self._topics and not any(t == topic for t, _ in self._intents): + raise UnknownTopicError(topic) + raise UnknownIntentError(f"{topic}/{intent}") + if inspect.iscoroutinefunction(handler): + return await handler(*args) + return await asyncio.to_thread(handler, *args) + + +class EventBus: + """All sessions. Session buses are created on first use and configured by + the app's registrar so every session exposes the same topic/intent + surface over its own state.""" + + def __init__(self, configure_session: Callable[[SessionBus], None] | None = None): + self._sessions: dict[str, SessionBus] = {} + self._configure_session = configure_session + + def session(self, session_id: str = "default") -> SessionBus: + bus = self._sessions.get(session_id) + if bus is None: + bus = SessionBus(session_id) + if self._configure_session is not None: + self._configure_session(bus) + self._sessions[session_id] = bus + return bus + + def session_ids(self) -> list[str]: + return sorted(self._sessions) diff --git a/backend/tests/test_app_ws.py b/backend/tests/test_app_ws.py new file mode 100644 index 0000000..f18607c --- /dev/null +++ b/backend/tests/test_app_ws.py @@ -0,0 +1,97 @@ +"""FastAPI app tests (Qt-removal plan R0): health endpoint, WS handshake, +subscribe snapshots, the system/ping acceptance round-trip, and error paths. +Runs the real ASGI app through Starlette's TestClient - no network, no Qt.""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from backend import BACKEND_VERSION +from backend.app import create_app + + +def make_client(tmp_path: Path | None = None) -> TestClient: + # Point spa_dir at a guaranteed-missing directory: R0 tests exercise the + # API surface, not the static build (the acceptance drive covers that). + spa = tmp_path if tmp_path is not None else Path("__no_such_dir__") + return TestClient(create_app(spa_dir=spa)) + + +def test_health_reports_ok_and_version(): + client = make_client() + response = client.get("/api/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["version"] == BACKEND_VERSION + + +def test_subscribe_delivers_system_snapshot_with_envelope(): + client = make_client() + with client.websocket_connect("/ws?session=test-a") as ws: + ws.send_json({"kind": "subscribe", "topics": ["system"]}) + message = ws.receive_json() + assert message["kind"] == "state" + assert message["topic"] == "system" + payload = message["payload"] + assert payload["app"] == "graphlink" + assert payload["sessionId"] == "test-a" + assert payload["schemaVersion"] == 1 + assert payload["revision"] >= 1 + + +def test_subscribe_without_topics_sends_every_registered_topic(): + client = make_client() + with client.websocket_connect("/ws") as ws: + ws.send_json({"kind": "subscribe"}) + message = ws.receive_json() + assert message["topic"] == "system", "R0 registers exactly the system topic" + + +def test_ping_round_trip_returns_echo_and_server_time(): + client = make_client() + with client.websocket_connect("/ws") as ws: + ws.send_json( + {"kind": "intent", "topic": "system", "intent": "ping", "args": ["hello"], "id": 1} + ) + message = ws.receive_json() + assert message["kind"] == "result" + assert message["id"] == 1 + assert message["value"]["echo"] == ["hello"] + assert message["value"]["serverTime"] > 0 + + +def test_unknown_intent_and_topic_return_error_not_disconnect(): + client = make_client() + with client.websocket_connect("/ws") as ws: + ws.send_json({"kind": "intent", "topic": "system", "intent": "nope", "args": [], "id": 2}) + message = ws.receive_json() + assert message["kind"] == "error" + assert message["id"] == 2 + + ws.send_json({"kind": "intent", "topic": "nope", "intent": "x", "args": [], "id": 3}) + message = ws.receive_json() + assert message["kind"] == "error" + + # Socket must still be usable after errors. + ws.send_json({"kind": "intent", "topic": "system", "intent": "ping", "args": [], "id": 4}) + assert ws.receive_json()["kind"] == "result" + + +def test_unknown_message_kind_returns_error(): + client = make_client() + with client.websocket_connect("/ws") as ws: + ws.send_json({"kind": "bogus", "id": 9}) + message = ws.receive_json() + assert message["kind"] == "error" + assert "unknown message kind" in message["error"] + + +def test_sessions_do_not_share_connections(): + client = make_client() + with client.websocket_connect("/ws?session=a") as ws_a: + with client.websocket_connect("/ws?session=b") as ws_b: + ws_a.send_json({"kind": "subscribe", "topics": ["system"]}) + ws_b.send_json({"kind": "subscribe", "topics": ["system"]}) + assert ws_a.receive_json()["payload"]["sessionId"] == "a" + assert ws_b.receive_json()["payload"]["sessionId"] == "b" diff --git a/backend/tests/test_event_bus.py b/backend/tests/test_event_bus.py new file mode 100644 index 0000000..f5f9f2a --- /dev/null +++ b/backend/tests/test_event_bus.py @@ -0,0 +1,123 @@ +"""Event-bus unit tests (Qt-removal plan R0): envelope stamping, session +isolation, broadcast resilience, intent dispatch.""" + +import asyncio + +import pytest + +from backend.events import ( + EventBus, + SessionBus, + UnknownIntentError, + UnknownTopicError, +) + + +class Recorder: + def __init__(self, fail=False): + self.messages = [] + self.fail = fail + + async def send_json(self, data): + if self.fail: + raise ConnectionError("dead socket") + self.messages.append(data) + + +def make_session(name="s1"): + bus = SessionBus(name) + state = {"count": 0} + bus.register_topic("counter", lambda: {"count": state["count"]}) + + def bump(by): + state["count"] += by + return state["count"] + + bus.register_intent("counter", "bump", bump) + return bus, state + + +def test_snapshot_envelope_matches_island_bridge_contract(): + bus, _ = make_session() + snap = asyncio.run(bus.publish("counter")) + assert snap["schemaVersion"] == 1 + assert snap["minCompatibleSchemaVersion"] == 1 + assert snap["revision"] == 1 + assert snap["count"] == 0 + snap2 = asyncio.run(bus.publish("counter")) + assert snap2["revision"] == 2, "revision must increment per publish" + + +def test_broadcast_reaches_all_connections_and_drops_dead_ones(): + async def run(): + bus, _ = make_session() + alive, dead = Recorder(), Recorder(fail=True) + bus.attach(alive) + bus.attach(dead) + await bus.publish("counter") + assert len(alive.messages) == 1 + assert alive.messages[0]["kind"] == "state" + assert alive.messages[0]["topic"] == "counter" + assert bus.connection_count == 1, "dead connection must be detached" + # A second publish must not fail because of the removed socket. + await bus.publish("counter") + assert len(alive.messages) == 2 + + asyncio.run(run()) + + +def test_intent_dispatch_sync_handler_runs_and_returns(): + bus, state = make_session() + result = asyncio.run(bus.dispatch_intent("counter", "bump", [5])) + assert result == 5 + assert state["count"] == 5 + + +def test_intent_dispatch_async_handler(): + bus, _ = make_session() + + async def async_intent(x): + await asyncio.sleep(0) + return x * 2 + + bus.register_intent("counter", "double", async_intent) + assert asyncio.run(bus.dispatch_intent("counter", "double", [21])) == 42 + + +def test_unknown_topic_and_intent_raise_typed_errors(): + bus, _ = make_session() + with pytest.raises(UnknownTopicError): + asyncio.run(bus.publish("nope")) + with pytest.raises(UnknownTopicError): + asyncio.run(bus.dispatch_intent("nope", "x", [])) + with pytest.raises(UnknownIntentError): + asyncio.run(bus.dispatch_intent("counter", "nope", [])) + + +def test_sessions_are_isolated(): + calls = [] + + def configure(session_bus): + calls.append(session_bus.session_id) + state = {"n": 0} + session_bus.register_topic("t", lambda: {"n": state["n"]}) + session_bus.register_intent("t", "set", lambda v: state.__setitem__("n", v)) + + bus = EventBus(configure_session=configure) + a, b = bus.session("a"), bus.session("b") + assert bus.session("a") is a, "same id must return the same session" + assert calls == ["a", "b"], "configurator runs once per session" + + asyncio.run(a.dispatch_intent("t", "set", [7])) + snap_a = asyncio.run(a.publish("t")) + snap_b = asyncio.run(b.publish("t")) + assert snap_a["n"] == 7 + assert snap_b["n"] == 0, "state must not leak across sessions" + + +def test_duplicate_registration_is_a_programming_error(): + bus, _ = make_session() + with pytest.raises(AssertionError): + bus.register_topic("counter", dict) + with pytest.raises(AssertionError): + bus.register_intent("counter", "bump", lambda: None) diff --git a/graphlink_app/graphlink_island_codegen.py b/graphlink_app/graphlink_island_codegen.py index 80600c8..60d699c 100644 --- a/graphlink_app/graphlink_island_codegen.py +++ b/graphlink_app/graphlink_island_codegen.py @@ -474,6 +474,53 @@ def _validator_for(name: str, fields: dict[str, Any]) -> str: ] +_API_CONTRACT_PATH = _REPO_ROOT / "web_ui" / "src" / "lib" / "api-contract" / "topics.ts" + + +def api_contract_ts() -> str: + """Qt-removal plan R0 (doc/QT_REMOVAL_PLAN.md): the single-SPA API + contract, derived from the SAME registry as the per-island artifacts. + + The SPA consumes state over one WebSocket instead of nineteen + QWebChannels, so it needs one place answering "which topics exist, what + type does each carry, and how do I validate a payload at runtime" - this + barrel maps topic names (the island names, unchanged, so backend topics + and frontend subscriptions agree by construction) onto the already- + generated types and validators. Topics gain entries here automatically + when their payload joins GENERATED_ARTIFACTS; nothing is hand-maintained. + """ + entries = [] + for entry in GENERATED_ARTIFACTS: + stem = entry["ts_path"].stem # e.g. "grid-control-state" + topic = stem.removesuffix("-state") # topic name == island name + title = entry["title"] # e.g. "GridControlState" + entries.append((topic, stem, title)) + entries.sort() + + lines = [ + _HEADER.format(source="graphlink_app/graphlink_island_codegen.py::GENERATED_ARTIFACTS"), + "", + ] + for topic, stem, title in entries: + lines.append( + f'import {{ type {title}, validate{title} }} from "../bridge-core/generated/{stem}";' + ) + lines.append("") + lines.append("export const TOPIC_VALIDATORS = {") + for topic, _stem, title in entries: + lines.append(f' "{topic}": validate{title},') + lines.append("} as const;") + lines.append("") + lines.append("export type TopicName = keyof typeof TOPIC_VALIDATORS;") + lines.append("") + lines.append("export interface TopicStates {") + for topic, _stem, title in entries: + lines.append(f' "{topic}": {title};') + lines.append("}") + lines.append("") + return "\n".join(lines) + + def _main(argv: list[str]) -> int: """CLI entry point: `python graphlink_island_codegen.py [--check | --write]`. @@ -528,10 +575,19 @@ def _main(argv: list[str]) -> int: if checked_in != fresh: stale.append(f"{path} does not match regenerating it from {entry['source']}") + fresh_contract = api_contract_ts() if mode == "--write": - print(f"wrote {len(GENERATED_ARTIFACTS) * 2} generated file(s)") + if not _API_CONTRACT_PATH.is_file() or _API_CONTRACT_PATH.read_text(encoding="utf-8") != fresh_contract: + _API_CONTRACT_PATH.parent.mkdir(parents=True, exist_ok=True) + _API_CONTRACT_PATH.write_text(fresh_contract, encoding="utf-8", newline="\n") + print(f"wrote {len(GENERATED_ARTIFACTS) * 2 + 1} generated file(s)") return 0 + if not _API_CONTRACT_PATH.is_file(): + stale.append(f"{_API_CONTRACT_PATH} is missing") + elif _API_CONTRACT_PATH.read_text(encoding="utf-8") != fresh_contract: + stale.append(f"{_API_CONTRACT_PATH} does not match regenerating it from GENERATED_ARTIFACTS") + if stale: print("Generated wire-contract artifacts are stale:", file=sys.stderr) for message in stale: diff --git a/graphlink_app/tests/test_no_qt_anywhere.py b/graphlink_app/tests/test_no_qt_anywhere.py new file mode 100644 index 0000000..030115d --- /dev/null +++ b/graphlink_app/tests/test_no_qt_anywhere.py @@ -0,0 +1,103 @@ +"""THE Qt-removal gate (doc/QT_REMOVAL_PLAN.md sections 0 and 3). Permanent. + +Burn-down mode (pin > 0): the count of Python files importing PySide6/PyQt - +source AND tests, whole repo - must EXACTLY match qt_burndown.json. Adding a +Qt import anywhere fails this test; removing Qt files fails it too until the +pin is lowered in the same commit, which is what makes the pin the project's +single truthful progress number. + +Zero mode (pin == 0): additionally asserts PySide6 is not installed and not +declared in pyproject.toml. This test never gets deleted - it is what makes +"the Qt removal is complete" a machine-checked fact instead of a claim. + +The new architecture is held to zero from day one: nothing under backend/, +web_ui/, or the desktop entry point may ever import Qt, pin or no pin. +""" + +from __future__ import annotations + +import importlib.util +import json +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +PIN_PATH = REPO_ROOT / "qt_burndown.json" + +_EXCLUDED_DIRS = {".git", "node_modules", "dist", "__pycache__", ".venv", "venv"} +_QT_IMPORT = re.compile(r"^\s*(?:from|import)\s+(?:PySide6|PyQt\d?)\b", re.MULTILINE) + +# Files that must be Qt-free FOREVER, regardless of the pin: the replacement +# architecture itself. +_ZERO_TOLERANCE_ROOTS = ("backend", "web_ui") +_ZERO_TOLERANCE_FILES = ("graphlink_desktop.py",) + + +def _qt_importing_files() -> list[Path]: + hits: list[Path] = [] + for path in REPO_ROOT.rglob("*.py"): + if any(part in _EXCLUDED_DIRS for part in path.parts): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if _QT_IMPORT.search(text): + hits.append(path.relative_to(REPO_ROOT)) + return sorted(hits) + + +def _split(hits: list[Path]) -> tuple[list[Path], list[Path]]: + tests = [h for h in hits if "tests" in h.parts] + source = [h for h in hits if "tests" not in h.parts] + return source, tests + + +def test_new_architecture_is_qt_free_forever(): + offenders = [ + h + for h in _qt_importing_files() + if h.parts[0] in _ZERO_TOLERANCE_ROOTS or str(h) in _ZERO_TOLERANCE_FILES + ] + assert offenders == [], ( + f"Qt imports in the NEW architecture (never allowed): {[str(o) for o in offenders]}" + ) + + +def test_qt_burndown_matches_pin(): + pin = json.loads(PIN_PATH.read_text(encoding="utf-8")) + hits = _qt_importing_files() + source, tests = _split(hits) + + assert len(hits) <= pin["total"], ( + f"Qt-file count ROSE: {len(hits)} files import PySide6/PyQt but the pin is " + f"{pin['total']}. New Qt imports are forbidden - the project goal is ZERO " + f"(doc/QT_REMOVAL_PLAN.md section 0). New since pin: rerun with -rA and compare." + ) + assert len(hits) == pin["total"], ( + f"Qt-file count dropped to {len(hits)} but qt_burndown.json still pins " + f"{pin['total']}. Good progress - now lower the pin in the SAME commit " + f"(source_files={len(source)}, test_files={len(tests)}, total={len(hits)}) " + f"and record the drop in the plan ledger." + ) + assert (len(source), len(tests)) == (pin["source_files"], pin["test_files"]), ( + f"Pin split drifted: actual source={len(source)}/tests={len(tests)}, " + f"pin says {pin['source_files']}/{pin['test_files']} - update qt_burndown.json." + ) + + +def test_zero_mode_gate_when_pin_reaches_zero(): + pin = json.loads(PIN_PATH.read_text(encoding="utf-8")) + if pin["total"] != 0: + return # burn-down mode; the assertions above carry the load + + # THE GATE (plan section 0): no import anywhere, package gone, not declared. + assert _qt_importing_files() == [] + assert importlib.util.find_spec("PySide6") is None, ( + "pin is 0 but PySide6 is still installed - `pip uninstall PySide6` is part " + "of the R7 cutover" + ) + pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert "PySide6" not in pyproject and "qtawesome" not in pyproject, ( + "pin is 0 but pyproject.toml still declares Qt dependencies" + ) diff --git a/graphlink_desktop.py b/graphlink_desktop.py new file mode 100644 index 0000000..0117f95 --- /dev/null +++ b/graphlink_desktop.py @@ -0,0 +1,112 @@ +"""Graphlink desktop entry point (Qt-removal plan R0, doc/QT_REMOVAL_PLAN.md). + +Launches the app as a NATIVE DESKTOP WINDOW with zero Qt: + + 1. starts the Python backend (FastAPI/uvicorn) on a free localhost port, + in a daemon thread inside this same process; + 2. waits for /api/health to answer; + 3. opens a pywebview window - the OS's own embedded webview component + (WebView2 on Windows), NOT the user's browser: no tabs, no address bar, + just an application window rendering the built SPA. + +`python graphlink_desktop.py` is the whole launch story, same as the Qt +entry point it replaces. When the R7 cutover deletes the Qt app, this file +becomes graphlink_app.py. + +Environment: + GRAPHLINK_BACKEND_PORT pin the backend port (default: OS-assigned free port) + GRAPHLINK_DEBUG_WEBVIEW set to 1 to enable the webview's devtools +""" + +from __future__ import annotations + +import logging +import os +import socket +import sys +import threading +import time +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(REPO_ROOT)) + +logger = logging.getLogger("graphlink.desktop") + +STARTUP_TIMEOUT_SECONDS = 15.0 + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +def _wait_for_health(base_url: str, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"{base_url}/api/health", timeout=1.0) as response: + if response.status == 200: + return True + except OSError: + time.sleep(0.1) + return False + + +def _start_backend(port: int) -> threading.Thread: + import uvicorn + + from backend.app import create_app + + config = uvicorn.Config( + create_app(), + host="127.0.0.1", + port=port, + log_level="warning", + # The desktop process owns lifetime: closing the window exits the + # process, taking this daemon thread (and the server) with it. + ) + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, name="graphlink-backend", daemon=True) + thread.start() + return thread + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") + + spa_index = REPO_ROOT / "web_ui" / "dist" / "app" / "index.html" + if not spa_index.is_file(): + logger.error( + "SPA build missing at %s - run: cd web_ui && GRAPHLINK_ISLAND=app npx vite build", + spa_index, + ) + return 1 + + port = int(os.environ.get("GRAPHLINK_BACKEND_PORT", 0)) or _free_port() + base_url = f"http://127.0.0.1:{port}" + + _start_backend(port) + if not _wait_for_health(base_url, STARTUP_TIMEOUT_SECONDS): + logger.error("backend did not become healthy at %s within %.0fs", base_url, STARTUP_TIMEOUT_SECONDS) + return 1 + logger.info("backend healthy at %s", base_url) + + import webview # pywebview - the native (non-Qt, non-browser) window + + webview.create_window( + "Graphlink", + url=base_url, + width=1440, + height=900, + min_size=(960, 600), + background_color="#1a1a1a", + ) + webview.start(debug=bool(os.environ.get("GRAPHLINK_DEBUG_WEBVIEW"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 8a6fee4..e354b17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,12 @@ requires-python = ">=3.10" dependencies = [ "anthropic", "beautifulsoup4", + # Qt-removal target architecture (doc/QT_REMOVAL_PLAN.md): Python backend + # + native pywebview shell. PySide6/qtawesome below are scheduled for + # REMOVAL at the R7 cutover - do not add new Qt dependencies. + "fastapi", + "uvicorn", + "pywebview", "ddgs", "Markdown", "matplotlib", diff --git a/qt_burndown.json b/qt_burndown.json new file mode 100644 index 0000000..6b528f2 --- /dev/null +++ b/qt_burndown.json @@ -0,0 +1,6 @@ +{ + "comment": "Qt-removal burn-down pin (doc/QT_REMOVAL_PLAN.md section 3). The number of Python files importing PySide6/PyQt anywhere in the repo, source AND tests. tests/test_no_qt_anywhere.py fails if the real count deviates from this pin in EITHER direction: an increase is a regression toward Qt; a decrease means Qt files were removed and this pin must be lowered in the same commit (the ledger in the plan doc records each drop). The project is done when this reads 0 and PySide6 is uninstalled.", + "source_files": 90, + "test_files": 78, + "total": 168 +} diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx new file mode 100644 index 0000000..b6d62f1 --- /dev/null +++ b/web_ui/src/app/App.tsx @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ConnectionStatus, WsTransport, defaultWsUrl } from "../lib/ws/transport"; + +/** + * The single-app shell (Qt-removal plan R0). + * + * R0 scope on purpose: layout regions where the real surfaces land in later + * phases (R1 canvas, R2 chrome, R3 nodes), plus the live system panel that + * proves the Python backend round-trip - the R0 acceptance criterion. + */ + +interface SystemState { + app?: string; + backendVersion?: string; + sessionId?: string; + revision?: number; +} + +function App() { + const transportRef = useRef(null); + const [status, setStatus] = useState("closed"); + const [system, setSystem] = useState({}); + const [pingMs, setPingMs] = useState(null); + const [pingError, setPingError] = useState(null); + + const transport = useMemo(() => { + const t = new WsTransport(defaultWsUrl()); + transportRef.current = t; + return t; + }, []); + + useEffect(() => { + const offStatus = transport.onStatus(setStatus); + const offSystem = transport.subscribe("system", (payload) => { + setSystem(payload as SystemState); + }); + transport.connect(); + return () => { + offStatus(); + offSystem(); + transport.dispose(); + }; + }, [transport]); + + async function ping() { + setPingError(null); + const started = performance.now(); + try { + await transport.request("system", "ping", ["r0-acceptance"]); + setPingMs(Math.round((performance.now() - started) * 10) / 10); + } catch (error) { + setPingMs(null); + setPingError(error instanceof Error ? error.message : String(error)); + } + } + + return ( +
+
+ Graphlink + app bar lands in R2 + + {status === "open" ? "backend connected" : status} + +
+ +
+
+

Canvas

+

React Flow node graph lands in R1.

+
+ +
+

SYSTEM

+
+
+
Backend
+
{system.backendVersion ?? "—"}
+
+
+
Session
+
{system.sessionId ?? "—"}
+
+
+
Revision
+
{system.revision ?? "—"}
+
+
+ + {pingMs !== null &&

round-trip {pingMs} ms

} + {pingError !== null &&

{pingError}

} +
+
+ +
+
Composer dock lands in R2.
+
+
+ ); +} + +export default App; diff --git a/web_ui/src/app/index.html b/web_ui/src/app/index.html new file mode 100644 index 0000000..b204fae --- /dev/null +++ b/web_ui/src/app/index.html @@ -0,0 +1,13 @@ + + + + + + + Graphlink + + +
+ + + diff --git a/web_ui/src/app/main.tsx b/web_ui/src/app/main.tsx new file mode 100644 index 0000000..bc97f66 --- /dev/null +++ b/web_ui/src/app/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "../lib/ui/base.css"; +// The single-SPA app has no Qt host injecting :root { --gl-* } at build time +// (that was _inline_bundle()'s job) - the generated token values ship with +// the app unconditionally. Live theme switching becomes a backend topic in +// R2; dark is the only theme the old app ever defaulted to. +import "../lib/tokens/gl-vars-dev.css"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css new file mode 100644 index 0000000..1601d5e --- /dev/null +++ b/web_ui/src/app/styles.css @@ -0,0 +1,158 @@ +/* Single-app shell styles (Qt-removal plan R0). Token-driven only - the + * no-raw-colors gate applies here exactly as it does to island CSS. */ + +html, +body, +#root { + height: 100%; + margin: 0; +} + +.app-shell { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--gl-surface-window); + color: var(--gl-surface-text); + font-family: var(--gl-font-family); +} + +.app-topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background-color: var(--gl-surface-node-body); + border-bottom: 1px solid var(--gl-surface-border); +} + +.app-title { + font-size: 13px; + font-weight: 700; +} + +.app-topbar-note { + font-size: 11px; + color: var(--gl-surface-text-muted); +} + +.app-conn { + margin-left: auto; + font-size: 11px; + color: var(--gl-surface-text-muted); +} + +.app-conn-open { + color: var(--gl-surface-text); +} + +.app-canvas-region { + position: relative; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.app-canvas-placeholder { + text-align: center; +} + +.app-canvas-placeholder-title { + margin: 0 0 4px; + font-size: 14px; + font-weight: 700; + color: var(--gl-surface-text-muted); + letter-spacing: 0.12em; +} + +.app-canvas-placeholder-body { + margin: 0; + font-size: 12px; + color: var(--gl-surface-text-muted); +} + +.app-system-panel { + position: absolute; + top: 16px; + right: 16px; + width: 220px; + padding: 12px 14px; + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 10px; +} + +.app-system-title { + margin: 0 0 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + color: var(--gl-surface-text-muted); +} + +.app-system-rows { + margin: 0 0 10px; +} + +.app-system-row { + display: flex; + justify-content: space-between; + font-size: 11px; + padding: 2px 0; +} + +.app-system-row dt { + color: var(--gl-surface-text-muted); +} + +.app-system-row dd { + margin: 0; +} + +.app-ping-button { + width: 100%; + padding: 6px 10px; + font-size: 11px; + font-weight: 600; + font-family: inherit; + color: var(--gl-surface-text); + background-color: var(--gl-neutral-button-background); + border: 1px solid var(--gl-neutral-button-border); + border-radius: 7px; + cursor: pointer; +} + +.app-ping-button:hover:enabled { + background-color: var(--gl-neutral-button-hover); +} + +.app-ping-button:disabled { + opacity: 0.5; + cursor: default; +} + +.app-ping-result { + margin: 8px 0 0; + font-size: 11px; + color: var(--gl-surface-text-muted); +} + +.app-ping-error { + margin: 8px 0 0; + font-size: 11px; + color: var(--gl-status-error, var(--gl-surface-text)); +} + +.app-composer-region { + padding: 10px 14px; + border-top: 1px solid var(--gl-surface-border); + background-color: var(--gl-surface-node-body); +} + +.app-composer-placeholder { + font-size: 11px; + color: var(--gl-surface-text-muted); + text-align: center; +} diff --git a/web_ui/src/lib/api-contract/topics.ts b/web_ui/src/lib/api-contract/topics.ts new file mode 100644 index 0000000..ba66649 --- /dev/null +++ b/web_ui/src/lib/api-contract/topics.ts @@ -0,0 +1,70 @@ +/* GENERATED - do not hand-edit. Source of truth: graphlink_app/graphlink_island_codegen.py::GENERATED_ARTIFACTS. + * Regenerate with graphlink_island_codegen.py; a pytest fails if this file + * drifts from what regenerating it now would produce. */ + + +import { type AboutState, validateAboutState } from "../bridge-core/generated/about-state"; +import { type ChatLibraryState, validateChatLibraryState } from "../bridge-core/generated/chat-library-state"; +import { type CommandPaletteState, validateCommandPaletteState } from "../bridge-core/generated/command-palette-state"; +import { type ComposerState, validateComposerState } from "../bridge-core/generated/composer-state"; +import { type ComposerContextState, validateComposerContextState } from "../bridge-core/generated/composer-context-state"; +import { type ComposerPickerState, validateComposerPickerState } from "../bridge-core/generated/composer-picker-state"; +import { type DocumentViewerState, validateDocumentViewerState } from "../bridge-core/generated/document-viewer-state"; +import { type DragSpeedState, validateDragSpeedState } from "../bridge-core/generated/drag-speed-state"; +import { type FontControlState, validateFontControlState } from "../bridge-core/generated/font-control-state"; +import { type GridControlState, validateGridControlState } from "../bridge-core/generated/grid-control-state"; +import { type HelpState, validateHelpState } from "../bridge-core/generated/help-state"; +import { type MinimapState, validateMinimapState } from "../bridge-core/generated/minimap-state"; +import { type NotificationState, validateNotificationState } from "../bridge-core/generated/notification-state"; +import { type PinOverlayState, validatePinOverlayState } from "../bridge-core/generated/pin-overlay-state"; +import { type PluginPickerState, validatePluginPickerState } from "../bridge-core/generated/plugin-picker-state"; +import { type SearchOverlayState, validateSearchOverlayState } from "../bridge-core/generated/search-overlay-state"; +import { type SettingsState, validateSettingsState } from "../bridge-core/generated/settings-state"; +import { type TokenCounterState, validateTokenCounterState } from "../bridge-core/generated/token-counter-state"; +import { type ToolbarState, validateToolbarState } from "../bridge-core/generated/toolbar-state"; + +export const TOPIC_VALIDATORS = { + "about": validateAboutState, + "chat-library": validateChatLibraryState, + "command-palette": validateCommandPaletteState, + "composer": validateComposerState, + "composer-context": validateComposerContextState, + "composer-picker": validateComposerPickerState, + "document-viewer": validateDocumentViewerState, + "drag-speed": validateDragSpeedState, + "font-control": validateFontControlState, + "grid-control": validateGridControlState, + "help": validateHelpState, + "minimap": validateMinimapState, + "notification": validateNotificationState, + "pin-overlay": validatePinOverlayState, + "plugin-picker": validatePluginPickerState, + "search-overlay": validateSearchOverlayState, + "settings": validateSettingsState, + "token-counter": validateTokenCounterState, + "toolbar": validateToolbarState, +} as const; + +export type TopicName = keyof typeof TOPIC_VALIDATORS; + +export interface TopicStates { + "about": AboutState; + "chat-library": ChatLibraryState; + "command-palette": CommandPaletteState; + "composer": ComposerState; + "composer-context": ComposerContextState; + "composer-picker": ComposerPickerState; + "document-viewer": DocumentViewerState; + "drag-speed": DragSpeedState; + "font-control": FontControlState; + "grid-control": GridControlState; + "help": HelpState; + "minimap": MinimapState; + "notification": NotificationState; + "pin-overlay": PinOverlayState; + "plugin-picker": PluginPickerState; + "search-overlay": SearchOverlayState; + "settings": SettingsState; + "token-counter": TokenCounterState; + "toolbar": ToolbarState; +} diff --git a/web_ui/src/lib/ws/transport.test.ts b/web_ui/src/lib/ws/transport.test.ts new file mode 100644 index 0000000..526580e --- /dev/null +++ b/web_ui/src/lib/ws/transport.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WsTransport } from "./transport"; + +class FakeSocket { + static instances: FakeSocket[] = []; + sent: string[] = []; + closed = false; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + + constructor(public url: string) { + FakeSocket.instances.push(this); + } + + send(data: string) { + this.sent.push(data); + } + + close() { + this.closed = true; + this.onclose?.(); + } + + open() { + this.onopen?.(); + } + + receive(message: unknown) { + this.onmessage?.({ data: JSON.stringify(message) }); + } + + lastSent(): Record { + return JSON.parse(this.sent[this.sent.length - 1]); + } +} + +function makeTransport(opts: { requestTimeoutMs?: number } = {}) { + return new WsTransport("ws://test/ws", { + webSocketFactory: (url) => new FakeSocket(url), + reconnectDelayMs: 10, + ...opts, + }); +} + +beforeEach(() => { + FakeSocket.instances = []; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("WsTransport", () => { + it("subscribes pre-registered topics on open", () => { + const t = makeTransport(); + const seen: unknown[] = []; + t.subscribe("system", (p) => seen.push(p)); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + expect(socket.lastSent()).toEqual({ kind: "subscribe", topics: ["system"] }); + + socket.receive({ kind: "state", topic: "system", payload: { app: "graphlink", revision: 1 } }); + expect(seen).toEqual([{ app: "graphlink", revision: 1 }]); + }); + + it("subscribing a NEW topic while open sends subscribe immediately", () => { + const t = makeTransport(); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + t.subscribe("canvas", () => {}); + expect(socket.lastSent()).toEqual({ kind: "subscribe", topics: ["canvas"] }); + }); + + it("routes snapshots only to their topic's listeners", () => { + const t = makeTransport(); + const a: unknown[] = []; + const b: unknown[] = []; + t.subscribe("a", (p) => a.push(p)); + t.subscribe("b", (p) => b.push(p)); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + socket.receive({ kind: "state", topic: "a", payload: { x: 1 } }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(0); + }); + + it("intent() is a silent no-op before the socket opens (bridge parity)", () => { + const t = makeTransport(); + t.connect(); + const socket = FakeSocket.instances[0]; + t.intent("system", "ping", []); + expect(socket.sent).toHaveLength(0); + socket.open(); + t.intent("system", "ping", ["x"]); + expect(socket.lastSent()).toEqual({ kind: "intent", topic: "system", intent: "ping", args: ["x"] }); + }); + + it("request() resolves on result and rejects on error, matched by id", async () => { + const t = makeTransport(); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + + const ok = t.request("system", "ping", ["hi"]); + const first = socket.lastSent(); + expect(first.id).toBeDefined(); + socket.receive({ kind: "result", id: first.id, value: { echo: ["hi"] } }); + await expect(ok).resolves.toEqual({ echo: ["hi"] }); + + const bad = t.request("system", "nope", []); + const second = socket.lastSent(); + socket.receive({ kind: "error", id: second.id, error: "unknown intent" }); + await expect(bad).rejects.toThrow("unknown intent"); + }); + + it("request() times out if the server never answers", async () => { + const t = makeTransport({ requestTimeoutMs: 100 }); + t.connect(); + FakeSocket.instances[0].open(); + const p = t.request("system", "ping", []); + const assertion = expect(p).rejects.toThrow("timed out"); + vi.advanceTimersByTime(150); + await assertion; + }); + + it("reconnects after close and re-subscribes every topic", () => { + const t = makeTransport(); + t.subscribe("system", () => {}); + t.connect(); + const first = FakeSocket.instances[0]; + first.open(); + first.close(); + expect(t.getStatus()).toBe("closed"); + + vi.advanceTimersByTime(50); + expect(FakeSocket.instances).toHaveLength(2); + const second = FakeSocket.instances[1]; + second.open(); + expect(second.lastSent()).toEqual({ kind: "subscribe", topics: ["system"] }); + expect(t.getStatus()).toBe("open"); + }); + + it("dispose() stops reconnecting and rejects pending requests", async () => { + const t = makeTransport(); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + const p = t.request("system", "ping", []); + const assertion = expect(p).rejects.toThrow(); + t.dispose(); + await assertion; + vi.advanceTimersByTime(1000); + expect(FakeSocket.instances).toHaveLength(1), "no reconnect after dispose"; + }); + + it("notifies status listeners through the lifecycle", () => { + const t = makeTransport(); + const statuses: string[] = []; + t.onStatus((s) => statuses.push(s)); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + socket.close(); + expect(statuses).toEqual(["closed", "connecting", "open", "closed"]); + }); +}); diff --git a/web_ui/src/lib/ws/transport.ts b/web_ui/src/lib/ws/transport.ts new file mode 100644 index 0000000..12ebb21 --- /dev/null +++ b/web_ui/src/lib/ws/transport.ts @@ -0,0 +1,219 @@ +/** + * WebSocket transport for the single-SPA architecture (Qt-removal plan R0). + * + * The successor of `bridge-core/transport.ts` (QWebChannel): same conceptual + * contract - full-state snapshots in, named intents out - carried over a + * plain WebSocket to the Python backend instead of Qt's webchannel. + * + * Server -> client: {kind:"state", topic, payload} (versioned envelope) + * {kind:"result", id, value} + * {kind:"error", id?, error} + * Client -> server: {kind:"subscribe", topics} + * {kind:"intent", topic, intent, args, id?} + * + * Reconnect: exponential-ish backoff, capped; every subscribed topic is + * re-subscribed automatically on reopen so a backend restart re-hydrates + * the UI without any component doing anything. + */ + +export type ConnectionStatus = "connecting" | "open" | "closed"; + +export type StateListener = (payload: Record) => void; +export type StatusListener = (status: ConnectionStatus) => void; + +interface WsLike { + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onclose: (() => void) | null; + onerror: (() => void) | null; + onmessage: ((event: { data: string }) => void) | null; +} + +export interface WsTransportOptions { + /** Injectable for tests; defaults to the browser's WebSocket. */ + webSocketFactory?: (url: string) => WsLike; + /** Base reconnect delay (doubles per attempt, capped at 8x). */ + reconnectDelayMs?: number; + /** request() timeout. */ + requestTimeoutMs?: number; +} + +export function defaultWsUrl(sessionId = "default"): string { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${window.location.host}/ws?session=${encodeURIComponent(sessionId)}`; +} + +export class WsTransport { + private readonly url: string; + private readonly factory: (url: string) => WsLike; + private readonly baseDelay: number; + private readonly requestTimeout: number; + + private socket: WsLike | null = null; + private status: ConnectionStatus = "closed"; + private disposed = false; + private attempts = 0; + private reconnectTimer: ReturnType | null = null; + + private readonly stateListeners = new Map>(); + private readonly statusListeners = new Set(); + private readonly pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType } + >(); + private nextId = 1; + + constructor(url: string, options: WsTransportOptions = {}) { + this.url = url; + this.factory = options.webSocketFactory ?? ((u) => new WebSocket(u) as unknown as WsLike); + this.baseDelay = options.reconnectDelayMs ?? 500; + this.requestTimeout = options.requestTimeoutMs ?? 10_000; + } + + connect(): void { + if (this.disposed || this.socket) return; + this.setStatus("connecting"); + const socket = this.factory(this.url); + this.socket = socket; + + socket.onopen = () => { + this.attempts = 0; + this.setStatus("open"); + const topics = [...this.stateListeners.keys()]; + if (topics.length > 0) { + socket.send(JSON.stringify({ kind: "subscribe", topics })); + } + }; + socket.onmessage = (event) => this.handleMessage(event.data); + socket.onerror = () => { + // onclose always follows; reconnect logic lives there. + }; + socket.onclose = () => { + this.socket = null; + this.setStatus("closed"); + this.failAllPending(new Error("connection closed")); + if (!this.disposed) this.scheduleReconnect(); + }; + } + + dispose(): void { + this.disposed = true; + if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer); + this.failAllPending(new Error("transport disposed")); + this.socket?.close(); + this.socket = null; + } + + getStatus(): ConnectionStatus { + return this.status; + } + + /** Listen for a topic's snapshots. Subscribing while open sends the + * subscribe immediately so the current snapshot arrives. */ + subscribe(topic: string, listener: StateListener): () => void { + let set = this.stateListeners.get(topic); + const isNewTopic = !set; + if (!set) { + set = new Set(); + this.stateListeners.set(topic, set); + } + set.add(listener); + if (isNewTopic && this.status === "open" && this.socket) { + this.socket.send(JSON.stringify({ kind: "subscribe", topics: [topic] })); + } + return () => { + set.delete(listener); + if (set.size === 0) this.stateListeners.delete(topic); + }; + } + + onStatus(listener: StatusListener): () => void { + this.statusListeners.add(listener); + listener(this.status); + return () => this.statusListeners.delete(listener); + } + + /** Fire-and-forget intent (the @Slot successor). Silently dropped when the + * socket is not open - matching the old bridge's pre-connect no-op call() + * semantics that every island already codes against. */ + intent(topic: string, intent: string, args: unknown[] = []): void { + if (this.status !== "open" || !this.socket) return; + this.socket.send(JSON.stringify({ kind: "intent", topic, intent, args })); + } + + /** Intent with a reply (result or error), for request/response flows. */ + request(topic: string, intent: string, args: unknown[] = []): Promise { + if (this.status !== "open" || !this.socket) { + return Promise.reject(new Error("not connected")); + } + const id = this.nextId++; + const socket = this.socket; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`request timed out: ${topic}/${intent}`)); + }, this.requestTimeout); + this.pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ kind: "intent", topic, intent, args, id })); + }); + } + + // -- internals --------------------------------------------------------- + + private handleMessage(raw: string): void { + let message: Record; + try { + message = JSON.parse(raw); + } catch { + console.error("[ws] non-JSON frame dropped"); + return; + } + const kind = message.kind; + if (kind === "state") { + const listeners = this.stateListeners.get(message.topic as string); + if (listeners) { + const payload = message.payload as Record; + for (const listener of [...listeners]) listener(payload); + } + return; + } + if (kind === "result" || kind === "error") { + const id = message.id as number | null; + if (id !== null && id !== undefined && this.pending.has(id)) { + const entry = this.pending.get(id)!; + this.pending.delete(id); + clearTimeout(entry.timer); + if (kind === "result") entry.resolve(message.value); + else entry.reject(new Error(String(message.error))); + } else if (kind === "error") { + console.error("[ws] server error:", message.error); + } + return; + } + console.error("[ws] unknown message kind:", kind); + } + + private setStatus(status: ConnectionStatus): void { + if (this.status === status) return; + this.status = status; + for (const listener of [...this.statusListeners]) listener(status); + } + + private scheduleReconnect(): void { + const delay = Math.min(this.baseDelay * 2 ** this.attempts, this.baseDelay * 8); + this.attempts += 1; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + private failAllPending(error: Error): void { + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer); + reject(error); + } + this.pending.clear(); + } +} diff --git a/web_ui/vite.config.ts b/web_ui/vite.config.ts index 083450f..fab17a6 100644 --- a/web_ui/vite.config.ts +++ b/web_ui/vite.config.ts @@ -14,17 +14,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // selects which one this invocation builds. const ISLANDS = ["composer", "token-counter", "notification", "command-palette", "settings", "about", "help", "document-viewer", "chat-library", "search-overlay", "pin-overlay", "composer-picker", "composer-context", "toolbar", "plugin-picker", "grid-control", "font-control", "drag-speed", "minimap"]; +// Qt-removal plan R0 (doc/QT_REMOVAL_PLAN.md): "app" is the single-SPA +// target that consolidates the islands. Unlike islands it is SERVED by the +// Python backend (not inlined into a Qt webview), so it has no single-chunk +// constraint and builds to dist/app instead of ../assets/. The +// island targets stay buildable until the R7 cutover deletes the Qt hosts. +const APP_TARGET = "app"; + const island = process.env.GRAPHLINK_ISLAND || "composer"; -if (!ISLANDS.includes(island)) { +const isApp = island === APP_TARGET; +if (!isApp && !ISLANDS.includes(island)) { throw new Error( - `Unknown island "${island}" (set via GRAPHLINK_ISLAND). Known islands: ${ISLANDS.join(", ")}`, + `Unknown island "${island}" (set via GRAPHLINK_ISLAND). Known islands: ${[APP_TARGET, ...ISLANDS].join(", ")}`, ); } export default defineConfig({ plugins: [tailwindcss(), react()], base: "./", - root: resolve(__dirname, "src/islands", island), + root: resolve(__dirname, isApp ? "src/app" : `src/islands/${island}`), // The desktop app's live dev-server mode (GRAPHLINK_FRONTEND_DEV_URL) // allowlists ONE exact origin in its WebEngine request interceptor. These // three settings keep the real served origin pinned to that expectation: @@ -35,9 +43,17 @@ export default defineConfig({ host: "127.0.0.1", port: 5173, strictPort: true, + // App-target dev mode talks to a locally running backend; islands never + // proxy (their transport is QWebChannel, not HTTP). + proxy: isApp + ? { + "/api": "http://127.0.0.1:8765", + "/ws": { target: "ws://127.0.0.1:8765", ws: true }, + } + : undefined, }, build: { - outDir: resolve(__dirname, "../assets", island), + outDir: resolve(__dirname, isApp ? "dist/app" : `../assets/${island}`), emptyOutDir: true, sourcemap: false, }, From a758c7015f2833d67748274f2c0e5912374adc60 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Wed, 22 Jul 2026 11:23:18 -0400 Subject: [PATCH 2/2] Fix R0 CI: lint errors in new files + gl-vars guard carve-out for the SPA target - App.tsx: drop the vestigial transportRef (ref write during render - react-hooks/refs error); the memoized transport was already the only consumer. - transport.test.ts: comma-expression assertion rewritten as a real assertion (no-unused-expressions error). - test_gl_vars_dev_css.py: src/app is a documented carve-out from the dev-only token rule - the SPA has no Qt host injecting :root tokens, so the shipped file IS its token source until R2's backend-served theme. Islands remain fully guarded. Local CI parity: npm run check exit 0; full offscreen pytest 1783 passed. Co-Authored-By: Claude Fable 5 --- graphlink_app/tests/test_gl_vars_dev_css.py | 11 +++++++++++ web_ui/src/app/App.tsx | 9 ++------- web_ui/src/lib/ws/transport.test.ts | 3 ++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/graphlink_app/tests/test_gl_vars_dev_css.py b/graphlink_app/tests/test_gl_vars_dev_css.py index aeaebaa..878fd08 100644 --- a/graphlink_app/tests/test_gl_vars_dev_css.py +++ b/graphlink_app/tests/test_gl_vars_dev_css.py @@ -121,7 +121,18 @@ def test_no_island_imports_it_unconditionally(self): ) assert sources, "expected to find TypeScript sources to scan" + app_root = _REPO_ROOT / "web_ui" / "src" / "app" for path in sources: + # Qt-removal plan R0 carve-out: the single-SPA target (src/app) + # imports the token values UNCONDITIONALLY, deliberately. The + # dev-only rule exists because island pages get their :root + # tokens injected by the Qt host at build time, so a shipped + # copy would silently override the active theme. The SPA has no + # Qt host and no injector - the shipped file IS its token source + # until R2 lands the backend-served theme topic. Islands remain + # fully guarded. + if path.is_relative_to(app_root): + continue code = _strip_js_comments(_read(path)) if "gl-vars-dev.css" not in code: continue diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx index b6d62f1..8fb95b2 100644 --- a/web_ui/src/app/App.tsx +++ b/web_ui/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ConnectionStatus, WsTransport, defaultWsUrl } from "../lib/ws/transport"; /** @@ -17,17 +17,12 @@ interface SystemState { } function App() { - const transportRef = useRef(null); const [status, setStatus] = useState("closed"); const [system, setSystem] = useState({}); const [pingMs, setPingMs] = useState(null); const [pingError, setPingError] = useState(null); - const transport = useMemo(() => { - const t = new WsTransport(defaultWsUrl()); - transportRef.current = t; - return t; - }, []); + const transport = useMemo(() => new WsTransport(defaultWsUrl()), []); useEffect(() => { const offStatus = transport.onStatus(setStatus); diff --git a/web_ui/src/lib/ws/transport.test.ts b/web_ui/src/lib/ws/transport.test.ts index 526580e..435d5a0 100644 --- a/web_ui/src/lib/ws/transport.test.ts +++ b/web_ui/src/lib/ws/transport.test.ts @@ -156,7 +156,8 @@ describe("WsTransport", () => { t.dispose(); await assertion; vi.advanceTimersByTime(1000); - expect(FakeSocket.instances).toHaveLength(1), "no reconnect after dispose"; + // No reconnect after dispose: still exactly the one original socket. + expect(FakeSocket.instances).toHaveLength(1); }); it("notifies status listeners through the lifecycle", () => {