Spaces:
Paused
Paused
File size: 17,008 Bytes
e2b893f 5cf8a54 e2b893f d1161ea e2b893f 3151305 e2b893f 3151305 e2b893f f29a84a e2b893f f29a84a a04249a f29a84a e2b893f f29a84a e2b893f 3151305 e2b893f 3151305 e2b893f 3151305 e2b893f 3151305 e2b893f 3151305 e2b893f 5cf8a54 a04249a 5cf8a54 e2b893f d1161ea e2b893f 8c65d3d e2b893f d1161ea e2b893f d1161ea e2b893f 3eedd34 33fee78 3eedd34 e2b893f 3eedd34 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 | """bbuilder host — supervises Nerimity bots built in the bbuilder desktop app.
Runs as a Hugging Face Space. FastAPI serves the control API that the Godot app talks to;
a small Gradio dashboard is mounted at / so the Space is also useful in a browser.
Every /api/* route requires the X-BB-Key header to match the APP_KEY Space secret. /health
is deliberately open — the self-ping uses it, and it leaks nothing but bot names and status.
"""
from __future__ import annotations
import asyncio
import hmac
import json
import os
import queue
import threading
import time
from pathlib import Path
import gradio as gr
import uvicorn
# ZeroGPU refuses to start a Space with no @spaces.GPU function ("No @spaces.GPU function
# detected during startup"). We're on ZeroGPU only because free cpu-basic Gradio Spaces now
# require PRO — the bots themselves are pure CPU. gpu_probe() below satisfies the check and
# doubles as a diagnostic; nothing in the hosting path calls it.
try:
import spaces
HAS_SPACES = True
except ImportError: # running locally, outside a ZeroGPU container
HAS_SPACES = False
from fastapi import Depends, FastAPI, Header, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
from fastapi import WebSocket, WebSocketDisconnect
import noderuntime
import store
from compiler import CompileError, compile_project
from supervisor import BOTS_DIR, DATA_DIR, Supervisor
## The runtime shipped into every bot. Host-owned, never uploaded by a client.
RUNTIME_SRC = Path(__file__).parent / "bb-runtime.js"
APP_KEY = os.environ.get("APP_KEY", "")
SELF_URL = os.environ.get("BB_SELF_URL", "https://joddabod-bbuilder-host.hf.space")
PING_INTERVAL = 300 # 5 minutes
BOOT_TIME = time.time()
STATE: dict = {"node": None, "node_version": "?", "sdk_ready": False, "boot_log": []}
def _catalog_blocks() -> dict:
"""Block catalogue the compiler builds from; also a cheap liveness signal."""
try:
return json.loads((Path(__file__).parent / "catalog.json").read_text())["blocks"]
except Exception: # noqa: BLE001
return {}
def boot_log(msg: str) -> None:
print(f"[boot] {msg}", flush=True)
STATE["boot_log"].append(msg)
sup = Supervisor(on_change=lambda bot: store.push_bot_async(bot.id))
app = FastAPI(title="bbuilder host", docs_url=None, redoc_url=None)
# --------------------------------------------------------------------------- auth
def require_key(x_bb_key: str = Header(default="")) -> None:
if not APP_KEY:
raise HTTPException(503, "host has no APP_KEY configured")
if x_bb_key != APP_KEY:
raise HTTPException(401, "bad or missing X-BB-Key")
Auth = Depends(require_key)
# --------------------------------------------------------------------------- open
## Bump when the API contract changes. /health reports it so a deploy can tell whether the
## new code is actually live — the old process answers /health perfectly well while the new
## one is still building, which otherwise makes a deploy look successful when it isn't.
API_VERSION = 4
@app.get("/health")
def health() -> dict:
"""Open endpoint. Used by the self-ping and by the app's connection check."""
return {
"ok": True,
"service": "bbuilder-host",
"api_version": API_VERSION,
"blocks": len(_catalog_blocks()),
"uptime": round(time.time() - BOOT_TIME, 1),
"node": STATE["node_version"],
"sdk_ready": STATE["sdk_ready"],
"store": store.available(),
"bots": [
{"id": b.id, "name": b.name, "status": b.status, "enabled": b.enabled}
for b in sup.bots.values()
],
}
# --------------------------------------------------------------------------- api
@app.get("/api/bots", dependencies=[Auth])
def list_bots() -> dict:
return {"bots": [b.public() for b in sup.bots.values()]}
@app.post("/api/bots/{bot_id}/deploy", dependencies=[Auth])
async def deploy(bot_id: str, request: Request, project: UploadFile) -> dict:
"""Deploy a project.
Takes **block structure only** — the client never sends JavaScript. The host compiles
it against its own catalog.json, so the worst a caller can do is describe a bot out of
blocks the catalogue already offers. bb-runtime.js is the host's copy, not an upload.
"""
if not bot_id.replace("-", "").replace("_", "").isalnum():
raise HTTPException(400, "bot id must be alphanumeric/dash/underscore")
form = await request.form()
name = str(form.get("name") or bot_id)
token = str(form.get("token") or "")
autostart = str(form.get("autostart") or "").lower() in ("1", "true", "yes")
raw = await project.read()
if len(raw) > 4 * 1024 * 1024:
raise HTTPException(413, "project is too large")
try:
project_data = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(400, f"project is not valid JSON: {exc}") from exc
if not isinstance(project_data, dict):
raise HTTPException(400, "project must be a JSON object")
try:
source, warnings = compile_project(project_data, name)
except CompileError as exc:
raise HTTPException(400, f"could not build this project: {exc}") from exc
if not RUNTIME_SRC.exists():
raise HTTPException(500, "host is missing bb-runtime.js")
bot = sup.ensure(bot_id, name)
was_running = bot.status == "running"
if was_running:
sup.stop(bot_id, disable=False)
(bot.dir / "bot.js").write_text(source)
(bot.dir / "bb-runtime.js").write_text(RUNTIME_SRC.read_text())
(bot.dir / "project.bbproj").write_bytes(raw)
(bot.dir / "package.json").write_text(json.dumps({
"name": f"bb-{bot_id}", "private": True, "type": "commonjs",
}, indent=2))
if token:
secret = bot.dir / "secret.json"
secret.write_text(json.dumps({"token": token}))
secret.chmod(0o600)
sup.write_meta(bot)
sup.log(bot, f"compiled {len(source.splitlines())} lines from blocks", "system")
for w in warnings:
sup.log(bot, w, "system")
store.push_bot_async(bot_id)
if was_running or autostart:
ok, msg = sup.start(bot_id)
return {"ok": True, "deployed": True, "started": ok, "message": msg,
"warnings": warnings, "bot": bot.public()}
return {"ok": True, "deployed": True, "started": False,
"warnings": warnings, "bot": bot.public()}
@app.post("/api/bots/{bot_id}/start", dependencies=[Auth])
def start_bot(bot_id: str) -> dict:
ok, msg = sup.start(bot_id)
if not ok:
raise HTTPException(400, msg)
return {"ok": True, "message": msg, "bot": sup.get(bot_id).public()}
@app.post("/api/bots/{bot_id}/stop", dependencies=[Auth])
def stop_bot(bot_id: str) -> dict:
ok, msg = sup.stop(bot_id)
if not ok:
raise HTTPException(404, msg)
return {"ok": True, "message": msg, "bot": sup.get(bot_id).public()}
@app.post("/api/bots/{bot_id}/restart", dependencies=[Auth])
def restart_bot(bot_id: str) -> dict:
ok, msg = sup.restart(bot_id)
if not ok:
raise HTTPException(400, msg)
return {"ok": True, "message": msg, "bot": sup.get(bot_id).public()}
def _bot_id_for_token(token: str) -> str | None:
"""Find the bot whose stored Nerimity token matches.
Holding a bot's token is proof of ownership of that bot, so this lets someone manage
their bot from any machine without knowing the id it was deployed under. Compared with
compare_digest so the endpoint can't be used as a timing oracle to recover a token.
"""
token = token.strip()
if not token:
return None
for bot in sup.bots.values():
secret = bot.dir / "secret.json"
if not secret.exists():
continue
try:
stored = str(json.loads(secret.read_text()).get("token", ""))
except (json.JSONDecodeError, OSError):
continue
if stored and hmac.compare_digest(stored, token):
return bot.id
return None
# Deliberately NOT under /api/bots/… : "/api/bots/by-token/start" would be matched by
# "/api/bots/{bot_id}/start" with bot_id="by-token", because that route is registered first.
@app.post("/api/token/{action}", dependencies=[Auth])
async def by_token(action: str, request: Request) -> dict:
"""Start, stop, restart or delete a bot identified only by its Nerimity token."""
if action not in ("start", "stop", "restart", "delete"):
raise HTTPException(400, "unknown action")
form = await request.form()
bot_id = _bot_id_for_token(str(form.get("token") or ""))
if bot_id is None:
raise HTTPException(404, "no bot on this host is using that token")
if action == "delete":
sup.delete(bot_id)
store.delete_bot(bot_id)
return {"ok": True, "id": bot_id, "message": "deleted"}
handler = {"start": sup.start, "stop": sup.stop, "restart": sup.restart}[action]
ok, msg = handler(bot_id)
if not ok:
raise HTTPException(400, msg)
return {"ok": True, "id": bot_id, "message": msg, "bot": sup.get(bot_id).public()}
@app.post("/api/reap", dependencies=[Auth])
def reap() -> dict:
"""Kill any bot process the registry has lost track of.
Exists because an orphaned bot is otherwise unreachable — it keeps running and keeps
talking to Nerimity with no endpoint able to stop it.
"""
killed = sup.reap_orphans()
return {"ok": True, "killed": killed, "count": len(killed)}
@app.post("/api/token", dependencies=[Auth])
async def resolve_token(request: Request) -> dict:
"""Look up which bot a token belongs to, so the editor can attach to its log stream."""
form = await request.form()
bot_id = _bot_id_for_token(str(form.get("token") or ""))
if bot_id is None:
raise HTTPException(404, "no bot on this host is using that token")
return {"ok": True, "id": bot_id, "bot": sup.get(bot_id).public()}
@app.get("/api/bots/{bot_id}/logs", dependencies=[Auth])
def get_logs(bot_id: str, since: int = 0) -> dict:
bot = sup.get(bot_id)
if bot is None:
raise HTTPException(404, "no such bot")
return {"bot": bot.public(), "lines": sup.logs_since(bot, since)}
@app.delete("/api/bots/{bot_id}", dependencies=[Auth])
def delete_bot(bot_id: str) -> dict:
if not sup.delete(bot_id):
raise HTTPException(404, "no such bot")
store.delete_bot(bot_id)
return {"ok": True}
@app.websocket("/api/bots/{bot_id}/stream")
async def stream(ws: WebSocket, bot_id: str) -> None:
"""Live log + block-step feed. Key is passed as ?key= since browsers/Godot can't set
headers on a WebSocket handshake."""
key = ws.query_params.get("key", "")
if not APP_KEY or key != APP_KEY:
await ws.close(code=4401)
return
bot = sup.get(bot_id)
if bot is None:
await ws.close(code=4404)
return
await ws.accept()
q: queue.Queue = queue.Queue(maxsize=1000)
sup.subscribe(bot, q)
try:
since = int(ws.query_params.get("since", "0"))
for line in sup.logs_since(bot, since):
await ws.send_text(json.dumps(line))
loop = asyncio.get_running_loop()
while True:
try:
item = await loop.run_in_executor(None, q.get, True, 25)
except queue.Empty:
await ws.send_text(json.dumps({"t": "ping", "status": bot.status}))
continue
await ws.send_text(json.dumps(item))
except (WebSocketDisconnect, RuntimeError):
pass
finally:
sup.unsubscribe(bot, q)
@app.exception_handler(HTTPException)
async def http_error(_: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse({"ok": False, "error": exc.detail}, status_code=exc.status_code)
# --------------------------------------------------------------------------- boot
def bootstrap() -> None:
boot_log(store.pull_all())
sup.load_from_disk()
boot_log(f"registry has {len(sup.bots)} bot(s)")
node = noderuntime.ensure_node()
STATE["node"] = node
STATE["node_version"] = noderuntime.version(node)
sup.node_bin = node
boot_log(f"node {STATE['node_version']}")
STATE["sdk_ready"] = noderuntime.ensure_nerimity(node)
boot_log(f"nerimity.js ready: {STATE['sdk_ready']}")
sup.start_enabled()
boot_log("boot complete")
def self_ping() -> None:
"""Keep the Space awake. Defeats the inactivity GC; does not prevent HF-side rebuilds,
which is why bootstrap() restores from the dataset."""
import urllib.request
while True:
time.sleep(PING_INTERVAL)
try:
with urllib.request.urlopen(f"{SELF_URL}/health", timeout=30) as r:
r.read(1)
except Exception as exc: # noqa: BLE001
print(f"[ping] failed: {exc}", flush=True)
# --------------------------------------------------------------------------- ui
def dashboard_rows() -> list[list]:
return [
[b.id, b.name, b.status, "yes" if b.enabled else "no", b.restarts, b.last_error or ""]
for b in sup.bots.values()
] or [["—", "no bots deployed yet", "", "", "", ""]]
if HAS_SPACES:
@spaces.GPU(duration=10)
def gpu_probe() -> str:
"""Required by ZeroGPU, and genuinely useful as a one-click sanity check."""
try:
import torch
if torch.cuda.is_available():
return f"GPU reachable: {torch.cuda.get_device_name(0)} (unused by bot hosting)"
return "No CUDA device visible (fine — bots are CPU-only)"
except ImportError:
return "torch not installed; GPU unused by design — bots are CPU-only"
else:
def gpu_probe() -> str:
return "not running on ZeroGPU"
def status_text() -> str:
return (
f"**node** `{STATE['node_version']}` · **nerimity.js** "
f"{'ready' if STATE['sdk_ready'] else 'not installed'} · "
f"**durable store** {'on' if store.available() else 'OFF'} · "
f"**uptime** {round((time.time() - BOOT_TIME) / 60)} min"
)
with gr.Blocks(title="bbuilder host") as demo:
gr.Markdown("# bbuilder host\nHosting for Nerimity bots built with the bbuilder desktop app.")
status_md = gr.Markdown(status_text())
table = gr.Dataframe(
headers=["id", "name", "status", "enabled", "restarts", "last error"],
value=dashboard_rows,
interactive=False,
wrap=True,
)
with gr.Row():
refresh = gr.Button("Refresh", variant="primary")
probe = gr.Button("GPU probe")
probe_out = gr.Textbox(label="probe", interactive=False, visible=True)
refresh.click(lambda: (status_text(), dashboard_rows()), outputs=[status_md, table])
probe.click(gpu_probe, outputs=probe_out)
gr.Markdown(
"Control endpoints live under `/api/` and require the `X-BB-Key` header. "
"This dashboard is read-only."
)
def serve_via_gradio() -> None:
"""Let Gradio own the server, then graft our API routes onto its FastAPI app.
ZeroGPU only notices @spaces.GPU functions when the app starts through
demo.launch(); calling uvicorn.run() on our own app bypasses that hook and the Space is
killed with "No @spaces.GPU function detected during startup". So Gradio launches, and
we insert our routes at the *front* of its router afterwards — position matters because
Gradio registers a catch-all for SPA routing that would otherwise shadow /health.
"""
demo.queue()
demo.launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("GRADIO_SERVER_PORT") or 7860),
prevent_thread_lock=True,
show_api=False,
# Gradio 5 defaults to running a Node SSR server in front of the Python app. It
# answers any path it doesn't recognise with the SPA shell, which swallows /health
# and /api/* before FastAPI sees them. We need FastAPI in front, so: no SSR.
ssr_mode=False,
)
target = demo.app
for route in reversed(app.router.routes):
if getattr(route, "path", None) in ("/openapi.json",):
continue
target.router.routes.insert(0, route)
print(f"[boot] grafted {len(app.router.routes)} API routes onto the Gradio app", flush=True)
while True:
time.sleep(3600)
def serve_standalone() -> None:
"""Local development: no ZeroGPU to appease, so run our own app directly."""
port = int(os.environ.get("GRADIO_SERVER_PORT") or os.environ.get("BB_PORT") or 7860)
uvicorn.run(gr.mount_gradio_app(app, demo, path="/"), host="0.0.0.0", port=port)
if __name__ == "__main__":
DATA_DIR.mkdir(parents=True, exist_ok=True)
BOTS_DIR.mkdir(parents=True, exist_ok=True)
threading.Thread(target=bootstrap, daemon=True).start()
threading.Thread(target=self_ping, daemon=True).start()
if HAS_SPACES:
serve_via_gradio()
else:
serve_standalone()
|