"""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()