pycodex-ws 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/PKG-INFO +19 -10
  2. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/README.md +15 -5
  3. pycodex_ws-0.4.1/pycodex_router/__init__.py +1 -0
  4. pycodex_ws-0.4.1/pycodex_router/__main__.py +3 -0
  5. pycodex_ws-0.4.1/pycodex_router/browser.py +175 -0
  6. pycodex_ws-0.4.1/pycodex_router/client.py +250 -0
  7. pycodex_ws-0.4.1/pycodex_router/index.html +315 -0
  8. pycodex_ws-0.4.1/pycodex_router/protocol.py +152 -0
  9. pycodex_ws-0.4.1/pycodex_router/server.py +449 -0
  10. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/pycodex_ws/__init__.py +9 -0
  11. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/pyproject.toml +8 -7
  12. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/app.py +87 -20
  13. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/content_views.mjs +3 -3
  14. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/content_views.mjs +6 -4
  15. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/workspace.html +110 -24
  16. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/workspace_views.py +7 -2
  17. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/workspaces.html +155 -158
  18. pycodex_ws-0.4.0/pycodex_ws/ipython.py +0 -157
  19. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/.gitignore +0 -0
  20. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/LICENSE +0 -0
  21. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/pycodex_ws/feishu_card.py +0 -0
  22. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/pycodex_ws/feishu_link.py +0 -0
  23. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/pycodex_ws/feishu_oauth.py +0 -0
  24. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/__init__.py +0 -0
  25. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/__main__.py +0 -0
  26. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/content_views.LICENSE.txt +0 -0
  27. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/content_views.css +0 -0
  28. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/editor.css +0 -0
  29. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/editor.mjs +0 -0
  30. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/.gitignore +0 -0
  31. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/build.mjs +0 -0
  32. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/composer.mjs +0 -0
  33. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/content_views.css +0 -0
  34. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/editor.css +0 -0
  35. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/editor.mjs +0 -0
  36. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/editor_context.mjs +0 -0
  37. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/package-lock.json +0 -0
  38. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/package.json +0 -0
  39. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/frontend/view_labels.mjs +0 -0
  40. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/owned_process.py +0 -0
  41. {pycodex_ws-0.4.0 → pycodex_ws-0.4.1}/workspace_server/workspaces.py +0 -0
@@ -1,21 +1,20 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pycodex-ws
3
- Version: 0.4.0
4
- Summary: Browser workspace, Feishu and IPython integrations for python-codex
3
+ Version: 0.4.1
4
+ Summary: Browser workspace and optional Feishu integration for python-codex
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.7
7
- Requires-Dist: ipython>=7
8
- Requires-Dist: lark-oapi
9
7
  Requires-Dist: pillow>=9.0.0
10
- Requires-Dist: python-codex==0.4.0
8
+ Requires-Dist: python-codex==0.4.1
9
+ Requires-Dist: websockets<16,>=11
11
10
  Description-Content-Type: text/markdown
12
11
 
13
12
  # pycodex-ws
14
13
 
15
14
  `pycodex-ws` provides the browser workspace, Canvas and file/diff viewer,
16
- Feishu integration and IPython tools for
15
+ and optional Feishu integration for
17
16
  [`python-codex`](https://pypi.org/project/python-codex/). It installs the exact
18
- matching core version and all integration dependencies.
17
+ matching core version.
19
18
 
20
19
  Pillow is a direct dependency of this package for image preview validation.
21
20
  Core can run without it; `view_image` then sends original image bytes.
@@ -53,8 +52,18 @@ its range, or only the cursor position. Context appears as a separate block amon
53
52
  text; click a block to edit or remove it before sending.
54
53
 
55
54
  Workspace sessions include `/link` and `/unlink` for Feishu. Python callers use
56
- `pycodex_ws.build_runtime(agent)` for the same assembly, or
57
- `from pycodex_ws.ipython import ipython_agent` inside an IPython session.
55
+ `pycodex_ws.build_runtime(agent)` for the same assembly.
56
+
57
+ The Feishu SDK is optional and is not installed automatically. To enable
58
+ `/link`, install it in the same Python environment as the workspace:
59
+
60
+ ```bash
61
+ python3 -m pip install lark-oapi
62
+ ```
63
+
64
+ `/link` checks that `lark_oapi` can be imported before creating a connection.
65
+ If it cannot, the command reports that it is unavailable and shows the manual
66
+ installation command. Other workspace features work without the SDK.
58
67
 
59
68
  For initial Feishu user authorization, run the module included in this package:
60
69
 
@@ -105,6 +114,6 @@ root to install both, then `uv run pycodex-ws --help`. Build this package with
105
114
  `uv build packages/pycodex-ws`.
106
115
 
107
116
  Run `uv run --dev pytest packages/pycodex-ws/tests` from the repository root for
108
- workspace, Feishu, IPython and cross-frontend integration tests. Core tests live
117
+ workspace, Feishu and cross-frontend integration tests. Core tests live
109
118
  in the root `tests/` directory and use the independent `test` dependency group.
110
119
  `uv run --dev pytest` runs both suites; see [test documentation](../../tests/TESTS.md).
@@ -1,9 +1,9 @@
1
1
  # pycodex-ws
2
2
 
3
3
  `pycodex-ws` provides the browser workspace, Canvas and file/diff viewer,
4
- Feishu integration and IPython tools for
4
+ and optional Feishu integration for
5
5
  [`python-codex`](https://pypi.org/project/python-codex/). It installs the exact
6
- matching core version and all integration dependencies.
6
+ matching core version.
7
7
 
8
8
  Pillow is a direct dependency of this package for image preview validation.
9
9
  Core can run without it; `view_image` then sends original image bytes.
@@ -41,8 +41,18 @@ its range, or only the cursor position. Context appears as a separate block amon
41
41
  text; click a block to edit or remove it before sending.
42
42
 
43
43
  Workspace sessions include `/link` and `/unlink` for Feishu. Python callers use
44
- `pycodex_ws.build_runtime(agent)` for the same assembly, or
45
- `from pycodex_ws.ipython import ipython_agent` inside an IPython session.
44
+ `pycodex_ws.build_runtime(agent)` for the same assembly.
45
+
46
+ The Feishu SDK is optional and is not installed automatically. To enable
47
+ `/link`, install it in the same Python environment as the workspace:
48
+
49
+ ```bash
50
+ python3 -m pip install lark-oapi
51
+ ```
52
+
53
+ `/link` checks that `lark_oapi` can be imported before creating a connection.
54
+ If it cannot, the command reports that it is unavailable and shows the manual
55
+ installation command. Other workspace features work without the SDK.
46
56
 
47
57
  For initial Feishu user authorization, run the module included in this package:
48
58
 
@@ -93,6 +103,6 @@ root to install both, then `uv run pycodex-ws --help`. Build this package with
93
103
  `uv build packages/pycodex-ws`.
94
104
 
95
105
  Run `uv run --dev pytest packages/pycodex-ws/tests` from the repository root for
96
- workspace, Feishu, IPython and cross-frontend integration tests. Core tests live
106
+ workspace, Feishu and cross-frontend integration tests. Core tests live
97
107
  in the root `tests/` directory and use the independent `test` dependency group.
98
108
  `uv run --dev pytest` runs both suites; see [test documentation](../../tests/TESTS.md).
@@ -0,0 +1 @@
1
+ """Reverse connections for pycodex workspaces."""
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,175 @@
1
+ """Separate device origins while sharing the router's HttpOnly login."""
2
+
3
+ import ipaddress
4
+ import re
5
+ from http.cookies import SimpleCookie
6
+ from urllib.parse import urlencode, urlsplit
7
+
8
+ from starlette.exceptions import HTTPException
9
+ from starlette.requests import HTTPConnection
10
+ from starlette.responses import JSONResponse, RedirectResponse
11
+
12
+ from .protocol import route_path
13
+
14
+
15
+ def origins(connection):
16
+ try:
17
+ parsed = urlsplit("//" + connection.headers["host"])
18
+ port = parsed.port
19
+ except ValueError:
20
+ raise HTTPException(400, "Invalid router host.")
21
+ host = parsed.hostname
22
+ if (
23
+ not host
24
+ or parsed.username is not None
25
+ or parsed.password is not None
26
+ or parsed.path
27
+ or parsed.query
28
+ or parsed.fragment
29
+ ):
30
+ raise HTTPException(400, "Invalid router host.")
31
+ try:
32
+ ipaddress.ip_address(host)
33
+ except ValueError:
34
+ if len(host) > 253 or any(
35
+ not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
36
+ for label in host.split(".")
37
+ ):
38
+ raise HTTPException(400, "Invalid router host.")
39
+ scheme = "https" if connection.scope["scheme"] in {"https", "wss"} else "http"
40
+ suffix = "" if port in (None, 443 if scheme == "https" else 80) else ":" + str(port)
41
+ authority = "[" + host + "]" if ":" in host else host
42
+ origin = scheme + "://" + authority + suffix
43
+ first, separator, parent = host.partition(".")
44
+ node_id = first[2:] if separator and re.fullmatch("n-[a-z2-7]{52}", first) else None
45
+ base = scheme + "://" + parent + suffix if node_id else origin
46
+ return origin, base, node_id
47
+
48
+
49
+ def device_url(connection, node_id, path):
50
+ if not re.fullmatch("[a-z2-7]{52}", node_id):
51
+ raise HTTPException(404, "Invalid device id.")
52
+ unused_origin, base, unused_node = origins(connection)
53
+ parsed = urlsplit(base)
54
+ if len("n-" + node_id + "." + parsed.hostname) > 253:
55
+ raise HTTPException(400, "Router domain is too long for device subdomains.")
56
+ try:
57
+ ipaddress.ip_address(parsed.hostname)
58
+ except ValueError:
59
+ pass
60
+ else:
61
+ raise HTTPException(
62
+ 400,
63
+ "Device isolation requires a dedicated router domain with wildcard DNS "
64
+ "and, for HTTPS, a wildcard certificate; a bare IP cannot isolate pages.",
65
+ )
66
+ return parsed.scheme + "://n-" + node_id + "." + parsed.netloc + path
67
+
68
+
69
+ class BrowserOrigins:
70
+ def __init__(self, router, admin_app, node_app):
71
+ self.router = router
72
+ self.admin_app = admin_app
73
+ self.node_app = node_app
74
+
75
+ async def __call__(self, scope, receive, send):
76
+ connection = HTTPConnection(scope)
77
+ origin, base, node_id = origins(connection)
78
+ path = route_path(scope)
79
+
80
+ async def secured_send(message):
81
+ if message["type"] == "http.response.start":
82
+ headers = []
83
+ for name, value in message["headers"]:
84
+ if name.lower() == b"set-cookie" and node_id is None:
85
+ # Only router-controlled subdomains share this cookie;
86
+ # request_scope() never forwards it to a device.
87
+ cookies = SimpleCookie()
88
+ cookies.load(value.decode("latin-1"))
89
+ if "pycodex_router_auth" in cookies:
90
+ cookie = cookies["pycodex_router_auth"]
91
+ try:
92
+ ipaddress.ip_address(connection.url.hostname)
93
+ except ValueError:
94
+ cookie["domain"] = connection.url.hostname
95
+ value = cookie.OutputString().encode("latin-1")
96
+ headers.append((name, value))
97
+ message = dict(
98
+ message,
99
+ headers=headers
100
+ + [
101
+ (b"content-security-policy", b"frame-ancestors 'self'"),
102
+ (b"x-frame-options", b"SAMEORIGIN"),
103
+ (b"x-content-type-options", b"nosniff"),
104
+ (b"referrer-policy", b"no-referrer"),
105
+ (b"permissions-policy", b"document-domain=()"),
106
+ (b"origin-agent-cluster", b"?1"),
107
+ ],
108
+ )
109
+ await send(message)
110
+
111
+ try:
112
+ if connection.client.host in self.router.blocked_ips:
113
+ raise HTTPException(
114
+ 403,
115
+ "Too many incorrect passwords. This IP is blocked "
116
+ "until the router restarts.",
117
+ )
118
+ if connection.headers.get("origin") not in (None, origin):
119
+ raise HTTPException(
120
+ 403, "Cross-origin router requests are not allowed."
121
+ )
122
+ if node_id:
123
+ prefix = "/n/" + node_id
124
+ if path != prefix and not path.startswith(prefix + "/"):
125
+ raise HTTPException(
126
+ 404, "Workspace route not found on this origin."
127
+ )
128
+ await self.node_app(scope, receive, secured_send)
129
+ else:
130
+ await self.admin_app(scope, receive, secured_send)
131
+ except HTTPException as exc:
132
+ if scope["type"] == "websocket":
133
+ await send({"type": "websocket.close", "code": 1008})
134
+ else:
135
+ await JSONResponse({"error": exc.detail}, exc.status_code)(
136
+ scope, receive, secured_send
137
+ )
138
+
139
+
140
+ async def redirect_device(scope, receive, send):
141
+ if scope["type"] != "http" or scope["method"] not in {"GET", "HEAD"}:
142
+ if scope["type"] == "websocket":
143
+ await send({"type": "websocket.close", "code": 1008})
144
+ else:
145
+ await JSONResponse({"error": "Use the device's own origin."}, 403)(
146
+ scope, receive, send
147
+ )
148
+ return
149
+ connection = HTTPConnection(scope)
150
+ target = scope.get("root_path", "") + route_path(scope)
151
+ if connection.url.query:
152
+ target += "?" + connection.url.query
153
+ await RedirectResponse(
154
+ device_url(connection, scope["path_params"]["node_id"], target), 307
155
+ )(scope, receive, send)
156
+
157
+
158
+ async def require_login(scope, receive, send):
159
+ connection = HTTPConnection(scope)
160
+ if scope["type"] == "websocket":
161
+ await send({"type": "websocket.close", "code": 1008})
162
+ elif "/api/" in route_path(scope) or scope["method"] not in {"GET", "HEAD"}:
163
+ await JSONResponse({"error": "authentication required"}, 401)(
164
+ scope, receive, send
165
+ )
166
+ else:
167
+ unused_origin, base, node_id = origins(connection)
168
+ prefix = scope.get("root_path", "")
169
+ root = prefix[: -len("/n/" + node_id)]
170
+ target = prefix + route_path(scope)
171
+ if connection.url.query:
172
+ target += "?" + connection.url.query
173
+ await RedirectResponse(
174
+ base + root + "/login?" + urlencode({"next": target}), 303
175
+ )(scope, receive, send)
@@ -0,0 +1,250 @@
1
+ import asyncio
2
+ import logging
3
+ import secrets
4
+ import socket
5
+ from contextlib import asynccontextmanager
6
+
7
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake
8
+ from websockets.legacy.client import connect
9
+ from .protocol import decode, device_id, encode, send_event, tunnel_url
10
+
11
+
12
+ class NodeIdInUse(Exception):
13
+ pass
14
+
15
+
16
+ class RouterClient:
17
+ def __init__(
18
+ self,
19
+ app,
20
+ target,
21
+ name,
22
+ port,
23
+ workspaces,
24
+ refresh_seconds=2.0,
25
+ reconnect_seconds=1.0,
26
+ ):
27
+ self.app = app
28
+ self.control_url = tunnel_url(target, "control")
29
+ self.data_url = tunnel_url(target, "data")
30
+ self._identity_key = secrets.token_hex(32)
31
+ self.node_id = device_id(self._identity_key)
32
+ self.name = name
33
+ self.port = port
34
+ self.workspaces = workspaces
35
+ self.refresh_seconds = refresh_seconds
36
+ self.reconnect_seconds = reconnect_seconds
37
+ self.connected = False
38
+ self._task = None
39
+ self._channels = set()
40
+ self._requests = set()
41
+ self._logger = logging.getLogger(__name__)
42
+
43
+ def _connect(self, url):
44
+ return connect(
45
+ url,
46
+ open_timeout=10,
47
+ close_timeout=2,
48
+ ping_interval=20,
49
+ ping_timeout=20,
50
+ max_size=32 * 1024 * 1024,
51
+ max_queue=4,
52
+ )
53
+
54
+ async def start(self):
55
+ if self._task is not None:
56
+ raise RuntimeError("router client is already started")
57
+ self._task = asyncio.create_task(self._run())
58
+
59
+ async def close(self):
60
+ if self._task is not None:
61
+ self._task.cancel()
62
+ await asyncio.gather(self._task, return_exceptions=True)
63
+ self._task = None
64
+ await self._close_channels()
65
+ if self._requests:
66
+ await asyncio.gather(*tuple(self._requests))
67
+
68
+ async def _close_channels(self):
69
+ await asyncio.gather(*(channel.close() for channel in tuple(self._channels)))
70
+
71
+ async def _run(self):
72
+ delay = self.reconnect_seconds
73
+ while True:
74
+ failure = None
75
+ try:
76
+ async with self._connect(self.control_url) as channel:
77
+ await self._register(channel)
78
+ self.connected = True
79
+ delay = self.reconnect_seconds
80
+ self._logger.info("Workspace connected to router as %s", self.name)
81
+ await self._session(channel)
82
+ except asyncio.CancelledError:
83
+ # close() performs cleanup after stopping this loop.
84
+ raise
85
+ except Exception as exc:
86
+ failure = exc
87
+ finally:
88
+ self.connected = False
89
+
90
+ try:
91
+ await self._close_channels()
92
+ except asyncio.CancelledError:
93
+ raise
94
+ except Exception as exc:
95
+ failure = exc
96
+
97
+ # Retry every failure, including registration and connection cleanup.
98
+ # Peer-supplied close reasons and URLs must not enter the log.
99
+ reason = (
100
+ type(failure).__name__ if failure is not None else "connection closed"
101
+ )
102
+ if isinstance(failure, ConnectionClosed) and failure.rcvd is not None:
103
+ reason += ", close_code={0}".format(failure.rcvd.code)
104
+ self._logger.warning(
105
+ "Router node %s disconnected (%s); retrying in %g seconds",
106
+ self.name,
107
+ reason,
108
+ delay,
109
+ )
110
+ await asyncio.sleep(delay)
111
+ delay = min(delay * 2, 30)
112
+
113
+ async def _register(self, channel):
114
+ await channel.send(
115
+ encode(
116
+ {
117
+ "type": "register",
118
+ "version": 2,
119
+ "identity_key": self._identity_key,
120
+ "name": self.name,
121
+ "port": self.port,
122
+ "workspaces": self.workspaces(),
123
+ }
124
+ )
125
+ )
126
+ reply = decode(await asyncio.wait_for(channel.recv(), 10))
127
+ if reply.get("type") == "rejected":
128
+ raise NodeIdInUse("router node id is still registered")
129
+ if reply.get("type") != "registered" or reply.get("id") != self.node_id:
130
+ raise ValueError("unexpected router registration response")
131
+
132
+ async def _session(self, channel):
133
+ async def receive_requests():
134
+ async for payload in channel:
135
+ message = decode(payload)
136
+ if message.get("type") != "connect":
137
+ raise ValueError("unknown router control message")
138
+ task = asyncio.create_task(self._serve(message["stream_id"]))
139
+ self._requests.add(task)
140
+ task.add_done_callback(self._requests.discard)
141
+
142
+ tasks = [
143
+ asyncio.create_task(self._refresh(channel)),
144
+ asyncio.create_task(receive_requests()),
145
+ ]
146
+ try:
147
+ done, _pending = await asyncio.wait(
148
+ tasks, return_when=asyncio.FIRST_COMPLETED
149
+ )
150
+ for task in done:
151
+ task.result()
152
+ finally:
153
+ for task in tasks:
154
+ task.cancel()
155
+ await asyncio.gather(*tasks, return_exceptions=True)
156
+
157
+ async def _refresh(self, channel):
158
+ while True:
159
+ await asyncio.sleep(self.refresh_seconds)
160
+ await channel.send(
161
+ encode(
162
+ {
163
+ "type": "workspaces",
164
+ "workspaces": self.workspaces(),
165
+ }
166
+ )
167
+ )
168
+
169
+ async def _serve(self, stream_id):
170
+ try:
171
+ async with self._connect(self.data_url) as channel:
172
+ self._channels.add(channel)
173
+ try:
174
+ await channel.send(
175
+ encode(
176
+ {"stream_id": stream_id, "identity_key": self._identity_key}
177
+ )
178
+ )
179
+ opening = decode(await asyncio.wait_for(channel.recv(), 10))
180
+ if opening.get("type") != "open":
181
+ raise ValueError("expected a router request")
182
+ scope = opening["scope"]
183
+ if scope["type"] not in {"http", "websocket"}:
184
+ raise ValueError("unsupported router request type")
185
+
186
+ async def receive():
187
+ try:
188
+ message = decode(await channel.recv())
189
+ except ConnectionClosed:
190
+ if scope["type"] == "http":
191
+ return {"type": "http.disconnect"}
192
+ return {"type": "websocket.disconnect", "code": 1001}
193
+ allowed = (
194
+ {"http.request", "http.disconnect"}
195
+ if scope["type"] == "http"
196
+ else {
197
+ "websocket.connect",
198
+ "websocket.receive",
199
+ "websocket.disconnect",
200
+ }
201
+ )
202
+ if message.get("type") not in allowed:
203
+ raise ValueError("invalid router request event")
204
+ return message
205
+
206
+ async def send(message):
207
+ await send_event(channel.send, message)
208
+
209
+ await self.app(scope, receive, send)
210
+ finally:
211
+ self._channels.discard(channel)
212
+ except (
213
+ ConnectionClosed,
214
+ OSError,
215
+ asyncio.TimeoutError,
216
+ InvalidHandshake,
217
+ ) as exc:
218
+ self._logger.warning("Router request disconnected (%s)", type(exc).__name__)
219
+ except Exception as exc:
220
+ self._logger.error("Router request failed (%s)", type(exc).__name__)
221
+
222
+
223
+ def attach_router(app, registry, target, port):
224
+ def inventory():
225
+ return [
226
+ {
227
+ "id": workspace["id"],
228
+ "work_dir": workspace["work_dir"],
229
+ "state_path": workspace["state_path"],
230
+ "enable_board": workspace["enable_board"],
231
+ "session_count": len(workspace["sessions"]),
232
+ "running": any(session["running"] for session in workspace["sessions"]),
233
+ }
234
+ for workspace in registry.list_workspaces()
235
+ ]
236
+
237
+ client = RouterClient(app, target, socket.gethostname(), port, inventory)
238
+ original_lifespan = app.router.lifespan_context
239
+
240
+ @asynccontextmanager
241
+ async def lifespan(application):
242
+ async with original_lifespan(application) as state:
243
+ await client.start()
244
+ try:
245
+ yield state
246
+ finally:
247
+ await client.close()
248
+
249
+ app.router.lifespan_context = lifespan
250
+ app.state.router_client = client