Spaces:
Runtime error
Runtime error
add enterprise folder with progress page backend features
Browse files
lynxkite-enterprise/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# lynxkite-enterprise
|
| 2 |
+
|
| 3 |
+
Enterprise backend extensions for LynxKite.
|
lynxkite-enterprise/pyproject.toml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "lynxkite-enterprise"
|
| 3 |
+
version = "0.3.0"
|
| 4 |
+
description = "Enterprise backend extensions for LynxKite"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi[standard]>=0.115.6",
|
| 9 |
+
"pycrdt-websocket>=0.16",
|
| 10 |
+
"pycrdt>=0.12.26",
|
| 11 |
+
]
|
| 12 |
+
license = "AGPL-3.0-or-later"
|
| 13 |
+
|
| 14 |
+
[project.urls]
|
| 15 |
+
Homepage = "https://github.com/lynxkite/lynxkite-2000/"
|
| 16 |
+
|
| 17 |
+
[tool.uv.sources]
|
| 18 |
+
lynxkite-core = { workspace = true }
|
| 19 |
+
|
| 20 |
+
[build-system]
|
| 21 |
+
requires = ["setuptools", "wheel", "setuptools-scm"]
|
| 22 |
+
build-backend = "setuptools.build_meta"
|
| 23 |
+
|
| 24 |
+
[tool.setuptools.packages.find]
|
| 25 |
+
namespaces = true
|
| 26 |
+
where = ["src"]
|
lynxkite-enterprise/src/lynxkite_enterprise/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Enterprise backend extensions for LynxKite."""
|
lynxkite-enterprise/src/lynxkite_enterprise/backend.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enterprise backend routes and background refresh jobs."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import contextlib
|
| 5 |
+
import importlib
|
| 6 |
+
from typing import Any
|
| 7 |
+
import fastapi
|
| 8 |
+
import pycrdt.websocket
|
| 9 |
+
from lynxkite_core import workspace
|
| 10 |
+
from . import progress_crdt
|
| 11 |
+
|
| 12 |
+
_k8s_unavailable_logged = False
|
| 13 |
+
_progress_server = None # Set by lifespan_context
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _log_k8s_unavailable_once() -> None:
|
| 17 |
+
global _k8s_unavailable_logged
|
| 18 |
+
if _k8s_unavailable_logged:
|
| 19 |
+
return
|
| 20 |
+
print("Kubernetes package is not installed; GPU service/K8s progress features are disabled.")
|
| 21 |
+
_k8s_unavailable_logged = True
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _k8s_client_module():
|
| 25 |
+
try:
|
| 26 |
+
return importlib.import_module("kubernetes.client")
|
| 27 |
+
except ModuleNotFoundError:
|
| 28 |
+
_log_k8s_unavailable_once()
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _k8s_config_module():
|
| 33 |
+
try:
|
| 34 |
+
return importlib.import_module("kubernetes.config")
|
| 35 |
+
except ModuleNotFoundError:
|
| 36 |
+
_log_k8s_unavailable_once()
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _load_k8s_config() -> bool:
|
| 41 |
+
"""Load Kubernetes configuration (in-cluster or from kubeconfig)."""
|
| 42 |
+
config = _k8s_config_module()
|
| 43 |
+
if config is None:
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
config.load_kube_config()
|
| 48 |
+
except Exception:
|
| 49 |
+
try:
|
| 50 |
+
config.load_incluster_config()
|
| 51 |
+
except Exception:
|
| 52 |
+
return False
|
| 53 |
+
return True
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _get_active_workspaces(ws_server) -> list[tuple[str, workspace.Workspace]]:
|
| 57 |
+
"""Return list of (name, workspace) tuples for all active workspaces."""
|
| 58 |
+
active_workspaces = []
|
| 59 |
+
for name, room in getattr(ws_server, "rooms", {}).items():
|
| 60 |
+
try:
|
| 61 |
+
ws = workspace.Workspace.model_validate(room.ws.to_py())
|
| 62 |
+
active_workspaces.append((name, ws))
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print(f"Error loading workspace {name}: {e}")
|
| 65 |
+
return active_workspaces
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _compute_gpu_services(ws_server) -> list[dict[str, Any]]:
|
| 69 |
+
"""Build a list of GPU service status dicts from live Kubernetes deployments."""
|
| 70 |
+
client = _k8s_client_module()
|
| 71 |
+
if client is None:
|
| 72 |
+
return []
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
if not _load_k8s_config():
|
| 76 |
+
return []
|
| 77 |
+
system_namespaces = {"kube-system", "kube-public", "kube-node-lease"}
|
| 78 |
+
active_workspaces = _get_active_workspaces(ws_server)
|
| 79 |
+
gpu_services = []
|
| 80 |
+
for d in client.AppsV1Api().list_deployment_for_all_namespaces().items:
|
| 81 |
+
if (d.metadata.namespace or "") in system_namespaces:
|
| 82 |
+
continue
|
| 83 |
+
labels = dict(d.metadata.labels) if d.metadata.labels else {}
|
| 84 |
+
used_by = [w for w in [labels.get("workspace")] if w]
|
| 85 |
+
for ws_name, ws in active_workspaces:
|
| 86 |
+
for node in getattr(ws, "nodes", []):
|
| 87 |
+
node_name = getattr(node.data, "op_id", None) or getattr(
|
| 88 |
+
node.data, "title", None
|
| 89 |
+
)
|
| 90 |
+
if node_name and node_name == d.metadata.name and ws_name not in used_by:
|
| 91 |
+
used_by.append(ws_name)
|
| 92 |
+
break
|
| 93 |
+
gpu_services.append(
|
| 94 |
+
{
|
| 95 |
+
"publisher": d.metadata.namespace or "",
|
| 96 |
+
"name": d.metadata.name,
|
| 97 |
+
"status": "running" if (d.status.available_replicas or 0) > 0 else "stopped",
|
| 98 |
+
"replicasHealthy": d.status.available_replicas or 0,
|
| 99 |
+
"replicasRequested": d.spec.replicas or 0,
|
| 100 |
+
"usedByWorkspaces": used_by,
|
| 101 |
+
}
|
| 102 |
+
)
|
| 103 |
+
return gpu_services
|
| 104 |
+
except Exception as e:
|
| 105 |
+
print(f"GPU service refresh error: {e}")
|
| 106 |
+
return []
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _get_k8s_workspace_gpus() -> dict[str, int]:
|
| 110 |
+
"""Get workspace GPU counts from Kubernetes deployment labels."""
|
| 111 |
+
k8s_workspace_gpus: dict[str, int] = {}
|
| 112 |
+
client = _k8s_client_module()
|
| 113 |
+
if client is None:
|
| 114 |
+
return k8s_workspace_gpus
|
| 115 |
+
try:
|
| 116 |
+
if not _load_k8s_config():
|
| 117 |
+
return k8s_workspace_gpus
|
| 118 |
+
for d in client.AppsV1Api().list_deployment_for_all_namespaces().items:
|
| 119 |
+
labels = dict(d.metadata.labels) if d.metadata.labels else {}
|
| 120 |
+
ws_label = labels.get("workspace")
|
| 121 |
+
if ws_label:
|
| 122 |
+
k8s_workspace_gpus[ws_label] = k8s_workspace_gpus.get(ws_label, 0) + (
|
| 123 |
+
d.spec.replicas or 0
|
| 124 |
+
)
|
| 125 |
+
except Exception as e:
|
| 126 |
+
print(f"K8s GPU info refresh error: {e}")
|
| 127 |
+
return k8s_workspace_gpus
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
async def _progress_refresh_loop(ws_server):
|
| 131 |
+
"""Background task: refresh K8s GPU counts and GPU service data into the progress CRDT doc."""
|
| 132 |
+
while True:
|
| 133 |
+
progress_crdt.update_progress_workspaces(ws_server, _get_k8s_workspace_gpus())
|
| 134 |
+
progress_crdt.update_progress_gpu_services(_compute_gpu_services(ws_server))
|
| 135 |
+
await asyncio.sleep(15)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@contextlib.asynccontextmanager
|
| 139 |
+
async def progress_refresh_context(ws_server):
|
| 140 |
+
task = asyncio.create_task(_progress_refresh_loop(ws_server))
|
| 141 |
+
try:
|
| 142 |
+
yield
|
| 143 |
+
finally:
|
| 144 |
+
task.cancel()
|
| 145 |
+
try:
|
| 146 |
+
await task
|
| 147 |
+
except asyncio.CancelledError:
|
| 148 |
+
pass
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@contextlib.asynccontextmanager
|
| 152 |
+
async def lifespan_context(ws_server):
|
| 153 |
+
"""Async context manager for enterprise lifecycle: starts the progress WebSocket server
|
| 154 |
+
and the background K8s refresh loop."""
|
| 155 |
+
global _progress_server
|
| 156 |
+
server = progress_crdt.ProgressWebsocketServer(auto_clean_rooms=False)
|
| 157 |
+
_progress_server = server
|
| 158 |
+
async with server:
|
| 159 |
+
await server.get_room("progress")
|
| 160 |
+
progress_crdt.update_progress_workspaces(ws_server)
|
| 161 |
+
progress_crdt.update_progress_gpu_services([])
|
| 162 |
+
async with progress_refresh_context(ws_server):
|
| 163 |
+
yield
|
| 164 |
+
_progress_server = None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def on_workspace_connection_open(room_name: str, ws_server) -> None:
|
| 168 |
+
"""Track workspace connection and update progress."""
|
| 169 |
+
progress_crdt.on_workspace_connection_open(room_name, ws_server)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def on_workspace_connection_close(room_name: str, ws_server) -> None:
|
| 173 |
+
"""Track workspace disconnection and update progress."""
|
| 174 |
+
progress_crdt.on_workspace_connection_close(room_name, ws_server)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def on_workspace_deleted(name: str) -> None:
|
| 178 |
+
progress_crdt.delete_workspace_entry(name)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def on_workspace_changed(ws_server) -> None:
|
| 182 |
+
"""Push an immediate workspace snapshot to the progress CRDT doc."""
|
| 183 |
+
progress_crdt.update_progress_workspaces(ws_server)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def register_routes(app: fastapi.FastAPI, crdt_module) -> None:
|
| 187 |
+
@app.websocket("/ws/progress/crdt/{room_name:path}")
|
| 188 |
+
async def progress_crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
|
| 189 |
+
room_name = crdt_module.sanitize_path(room_name)
|
| 190 |
+
if _progress_server is None:
|
| 191 |
+
await websocket.close(code=1008)
|
| 192 |
+
return
|
| 193 |
+
server = pycrdt.websocket.ASGIServer(_progress_server)
|
| 194 |
+
await server({"path": room_name, "type": "websocket"}, websocket._receive, websocket._send)
|
| 195 |
+
|
| 196 |
+
@app.post("/api/pause_workspace")
|
| 197 |
+
async def pause_workspace(req: dict):
|
| 198 |
+
"""Pause or resume a workspace."""
|
| 199 |
+
room_name = req.get("room_name")
|
| 200 |
+
if not isinstance(room_name, str) or not room_name:
|
| 201 |
+
raise fastapi.HTTPException(status_code=400, detail="Missing or invalid room_name")
|
| 202 |
+
room = await crdt_module.get_room(room_name)
|
| 203 |
+
paused = req.get("paused", True)
|
| 204 |
+
with room.ws.doc.transaction():
|
| 205 |
+
room.ws["paused"] = paused
|
| 206 |
+
return {"status": "ok", "room_name": room_name, "paused": paused}
|
| 207 |
+
|
| 208 |
+
@app.post("/api/stop_workspace")
|
| 209 |
+
async def stop_workspace(req: dict):
|
| 210 |
+
"""Stop and reset all nodes in a workspace."""
|
| 211 |
+
room_name = req.get("room_name")
|
| 212 |
+
if not isinstance(room_name, str) or not room_name:
|
| 213 |
+
raise fastapi.HTTPException(status_code=400, detail="Missing or invalid room_name")
|
| 214 |
+
room = await crdt_module.get_room(room_name)
|
| 215 |
+
with room.ws.doc.transaction():
|
| 216 |
+
room.ws["paused"] = True
|
| 217 |
+
for node in room.ws["nodes"]:
|
| 218 |
+
node["data"]["status"] = "planned"
|
| 219 |
+
node["data"]["message"] = None
|
| 220 |
+
return {"status": "ok", "room_name": room_name}
|
| 221 |
+
|
| 222 |
+
@app.post("/api/scale_gpu_service")
|
| 223 |
+
async def scale_gpu_service(req: dict):
|
| 224 |
+
"""Scale a GPU service deployment to the specified replica count."""
|
| 225 |
+
client = _k8s_client_module()
|
| 226 |
+
if client is None:
|
| 227 |
+
raise fastapi.HTTPException(
|
| 228 |
+
status_code=503,
|
| 229 |
+
detail="Kubernetes integration is unavailable: python package 'kubernetes' is not installed.",
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
name = req.get("name")
|
| 233 |
+
namespace = req.get("namespace") or req.get("publisher")
|
| 234 |
+
replicas = req.get("replicas")
|
| 235 |
+
|
| 236 |
+
print(f"Scaling GPU service {namespace}/{name} to {replicas} replicas")
|
| 237 |
+
if not isinstance(name, str) or not name:
|
| 238 |
+
raise fastapi.HTTPException(status_code=400, detail="Missing or invalid name")
|
| 239 |
+
if not isinstance(namespace, str) or not namespace:
|
| 240 |
+
raise fastapi.HTTPException(status_code=400, detail="Missing or invalid namespace")
|
| 241 |
+
if not isinstance(replicas, int) or replicas < 0:
|
| 242 |
+
raise fastapi.HTTPException(
|
| 243 |
+
status_code=400, detail="Replicas must be a non-negative integer"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
if not _load_k8s_config():
|
| 247 |
+
raise fastapi.HTTPException(
|
| 248 |
+
status_code=503,
|
| 249 |
+
detail="Kubernetes integration is unavailable: configuration could not be loaded.",
|
| 250 |
+
)
|
| 251 |
+
apps = client.AppsV1Api()
|
| 252 |
+
apps.patch_namespaced_deployment_scale(
|
| 253 |
+
name=name,
|
| 254 |
+
namespace=namespace,
|
| 255 |
+
body={"spec": {"replicas": replicas}},
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
# Push a fresh snapshot right away so the UI updates without waiting for poll loop.
|
| 259 |
+
progress_crdt.update_progress_workspaces(
|
| 260 |
+
crdt_module.ws_websocket_server,
|
| 261 |
+
_get_k8s_workspace_gpus(),
|
| 262 |
+
)
|
| 263 |
+
progress_crdt.update_progress_gpu_services(
|
| 264 |
+
_compute_gpu_services(crdt_module.ws_websocket_server)
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
return {"status": "ok", "name": name, "namespace": namespace, "replicas": replicas}
|
lynxkite-enterprise/src/lynxkite_enterprise/progress_crdt.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enterprise CRDT helpers for progress-page data and sockets."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import pathlib
|
| 5 |
+
import re
|
| 6 |
+
from typing import Any
|
| 7 |
+
import pycrdt
|
| 8 |
+
import pycrdt.websocket
|
| 9 |
+
from lynxkite_core import workspace
|
| 10 |
+
|
| 11 |
+
# Tracks currently open workspace websocket connections by room name.
|
| 12 |
+
_active_workspace_ws_connections: dict[str, int] = {}
|
| 13 |
+
|
| 14 |
+
# Progress CRDT - singleton ephemeral room for the progress page.
|
| 15 |
+
_progress_ydoc: pycrdt.Doc | None = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def ws_exception_handler(exception, log):
|
| 19 |
+
for ex in exception.exceptions if isinstance(exception, ExceptionGroup) else [exception]:
|
| 20 |
+
log.exception(ex)
|
| 21 |
+
return True
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ProgressWebsocketServer(pycrdt.websocket.WebsocketServer):
|
| 25 |
+
"""WebSocket server for the singleton progress CRDT room."""
|
| 26 |
+
|
| 27 |
+
async def init_room(self, name: str) -> pycrdt.websocket.YRoom:
|
| 28 |
+
global _progress_ydoc
|
| 29 |
+
ydoc = pycrdt.Doc()
|
| 30 |
+
ydoc["workspaces"] = pycrdt.Map()
|
| 31 |
+
ydoc["gpu_services"] = pycrdt.Text()
|
| 32 |
+
_progress_ydoc = ydoc
|
| 33 |
+
return pycrdt.websocket.YRoom(ydoc=ydoc, exception_handler=ws_exception_handler)
|
| 34 |
+
|
| 35 |
+
async def get_room(self, name: str) -> pycrdt.websocket.YRoom:
|
| 36 |
+
"""Get or initialize a progress room with the expected schema."""
|
| 37 |
+
if name not in self.rooms:
|
| 38 |
+
self.rooms[name] = await self.init_room(name)
|
| 39 |
+
room = self.rooms[name]
|
| 40 |
+
await self.start_room(room)
|
| 41 |
+
return room
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _workspace_display_name(room_name: str) -> str:
|
| 45 |
+
return re.sub(r"[^a-zA-Z0-9]", "-", pathlib.Path(room_name).name.removesuffix(".lynxkite.json"))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _extract_active_node(nodes: list) -> tuple[dict[str, Any] | None, float | None]:
|
| 49 |
+
for node in nodes:
|
| 50 |
+
if node.data.status != "active":
|
| 51 |
+
continue
|
| 52 |
+
telemetry = node.data.telemetry or {}
|
| 53 |
+
tqdm_n = telemetry.get("n")
|
| 54 |
+
tqdm_total = telemetry.get("total")
|
| 55 |
+
tqdm_rate = telemetry.get("rate")
|
| 56 |
+
active_node = {
|
| 57 |
+
"id": node.id,
|
| 58 |
+
"title": node.data.title,
|
| 59 |
+
"tqdm": {"n": tqdm_n, "total": tqdm_total, "rate": tqdm_rate} if tqdm_total else None,
|
| 60 |
+
}
|
| 61 |
+
eta_seconds = None
|
| 62 |
+
if tqdm_total and tqdm_rate and tqdm_rate > 0 and tqdm_n is not None:
|
| 63 |
+
eta_seconds = (tqdm_total - tqdm_n) / tqdm_rate
|
| 64 |
+
return active_node, eta_seconds
|
| 65 |
+
return None, None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _workspace_status(*, total: int, done: int, active: int, paused: bool) -> str:
|
| 69 |
+
if total == 0:
|
| 70 |
+
return "idle"
|
| 71 |
+
if done == total:
|
| 72 |
+
return "done"
|
| 73 |
+
return "paused" if paused else ("active" if active > 0 else "running")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _build_workspace_entry(room_name: str, room, k8s_workspace_gpus: dict[str, int]) -> str:
|
| 77 |
+
display_name = _workspace_display_name(room_name)
|
| 78 |
+
ws = workspace.Workspace.model_validate(room.ws.to_py())
|
| 79 |
+
nodes = ws.nodes or []
|
| 80 |
+
total = len(nodes)
|
| 81 |
+
done = sum(1 for n in nodes if n.data.status == "done")
|
| 82 |
+
active = sum(1 for n in nodes if n.data.status == "active")
|
| 83 |
+
paused = bool(ws.paused)
|
| 84 |
+
active_node, eta_seconds = _extract_active_node(nodes)
|
| 85 |
+
status = _workspace_status(total=total, done=done, active=active, paused=paused)
|
| 86 |
+
|
| 87 |
+
return json.dumps(
|
| 88 |
+
{
|
| 89 |
+
"name": display_name,
|
| 90 |
+
"room_name": room_name,
|
| 91 |
+
"status": status,
|
| 92 |
+
"boxes_done": done,
|
| 93 |
+
"boxes_total": total,
|
| 94 |
+
"active_node": active_node,
|
| 95 |
+
"eta_seconds": eta_seconds,
|
| 96 |
+
"gpus": (ws.execution_options or {}).get("gpus")
|
| 97 |
+
or k8s_workspace_gpus.get(display_name, 0),
|
| 98 |
+
"paused": paused,
|
| 99 |
+
}
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _connected_workspace_rooms(server) -> list[tuple[str, Any]]:
|
| 104 |
+
return [
|
| 105 |
+
(room_name, room)
|
| 106 |
+
for room_name in sorted(_active_workspace_ws_connections)
|
| 107 |
+
if (room := server.rooms.get(room_name)) is not None
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _update_workspace_ws_connection(room_name: str, delta: int) -> None:
|
| 112 |
+
count = _active_workspace_ws_connections.get(room_name, 0) + delta
|
| 113 |
+
if count <= 0:
|
| 114 |
+
_active_workspace_ws_connections.pop(room_name, None)
|
| 115 |
+
else:
|
| 116 |
+
_active_workspace_ws_connections[room_name] = count
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def on_workspace_connection_open(room_name: str, ws_websocket_server) -> None:
|
| 120 |
+
_update_workspace_ws_connection(room_name, +1)
|
| 121 |
+
update_progress_workspaces(ws_websocket_server)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def on_workspace_connection_close(room_name: str, ws_websocket_server) -> None:
|
| 125 |
+
_update_workspace_ws_connection(room_name, -1)
|
| 126 |
+
update_progress_workspaces(ws_websocket_server)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def update_progress_workspaces(ws_websocket_server, k8s_workspace_gpus: dict | None = None):
|
| 130 |
+
"""Recompute workspace status entries and push them into the progress CRDT doc."""
|
| 131 |
+
if _progress_ydoc is None or not hasattr(ws_websocket_server, "rooms"):
|
| 132 |
+
return
|
| 133 |
+
k8s_workspace_gpus = k8s_workspace_gpus or {}
|
| 134 |
+
ws_map: pycrdt.Map = _progress_ydoc["workspaces"]
|
| 135 |
+
connected_rooms = _connected_workspace_rooms(ws_websocket_server)
|
| 136 |
+
entries_by_room: dict[str, str] = {}
|
| 137 |
+
for room_name, room in connected_rooms:
|
| 138 |
+
try:
|
| 139 |
+
entries_by_room[room_name] = _build_workspace_entry(room_name, room, k8s_workspace_gpus)
|
| 140 |
+
except Exception as e:
|
| 141 |
+
print(f"Error updating progress for workspace {room_name}: {e}")
|
| 142 |
+
|
| 143 |
+
# Sync the map in one transaction to avoid transient partial snapshots.
|
| 144 |
+
with _progress_ydoc.transaction():
|
| 145 |
+
for room_name, entry in entries_by_room.items():
|
| 146 |
+
ws_map[room_name] = entry
|
| 147 |
+
for name in list(ws_map.keys()):
|
| 148 |
+
if name not in entries_by_room:
|
| 149 |
+
del ws_map[name]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def update_progress_gpu_services(gpu_services_data: list):
|
| 153 |
+
"""Update the GPU services entry in the progress CRDT doc with fresh Kubernetes data."""
|
| 154 |
+
if _progress_ydoc is None:
|
| 155 |
+
return
|
| 156 |
+
gpu_services_text: pycrdt.Text = _progress_ydoc["gpu_services"]
|
| 157 |
+
new_content = json.dumps(gpu_services_data)
|
| 158 |
+
if str(gpu_services_text) == new_content:
|
| 159 |
+
return
|
| 160 |
+
with _progress_ydoc.transaction():
|
| 161 |
+
if len(gpu_services_text) > 0:
|
| 162 |
+
del gpu_services_text[0 : len(gpu_services_text)]
|
| 163 |
+
gpu_services_text += new_content
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def delete_workspace_entry(name: str) -> None:
|
| 167 |
+
_active_workspace_ws_connections.pop(name, None)
|
| 168 |
+
if _progress_ydoc is None:
|
| 169 |
+
return
|
| 170 |
+
ws_map: pycrdt.Map = _progress_ydoc["workspaces"]
|
| 171 |
+
if name in ws_map:
|
| 172 |
+
with _progress_ydoc.transaction():
|
| 173 |
+
del ws_map[name]
|
ruff.toml
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
line-length = 100
|
|
|
|
|
|
| 1 |
line-length = 100
|
| 2 |
+
target-version = "py311"
|