"""FastAPI control surface for the agent (served on localhost). Start / stop the download→GPU pipeline, tune the downloader count live (the workload is download-bound, so downloaders are the dial that trades desktop bandwidth for throughput), and watch GPU load + buffer occupancy + progress + the server-side queue. Config is env-seeded; the downloader count is adjustable here on the fly (GPU consumers autoscale between 1 and 2 on their own). """ import logging from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse from . import logbuf from .config import Config from .gpu import read_gpu from .worker import Worker log = logging.getLogger("fc_agent.app") # Bump on every agent change. The page embeds this and /status reports it; the UI # warns to reload when they differ — so a stale browser-cached page can't be # mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.) VERSION = "2026-07-02.6 · sleep mode: an empty queue sheds to one downloader and backs the lease poll off to 15 min" logbuf.install() cfg = Config.from_env() worker = Worker(cfg) app = FastAPI(title="FabledCurator GPU agent") @app.middleware("http") async def _no_store(request, call_next): # The control page is a static string and the status/gpu/logs polls are # live data — never let the browser cache either, or a freshly-pulled agent # image still shows the OLD UI until a hard refresh (operator-flagged # 2026-06-30). resp = await call_next(request) resp.headers["Cache-Control"] = "no-store" return resp @app.on_event("startup") def _maybe_autostart() -> None: # With AUTO_START set, a container restart (host reboot, or `restart: # unless-stopped` after a crash) resumes the worker on its own — the slots # then ride out a still-down curator via lease backoff. Lets the agent # survive a redeploy with nobody at the desktop to click Start. if cfg.auto_start and cfg.token: worker.start() @app.get("/", response_class=HTMLResponse) def index() -> str: return _PAGE.replace("__BUILD__", VERSION) @app.post("/start") def start(): log.info("UI: Start button pressed") # the press; worker logs the transition worker.start() return JSONResponse(worker.status()) @app.post("/stop") def stop(): log.info("UI: Stop button pressed") worker.stop() return JSONResponse(worker.status()) @app.post("/concurrency") async def concurrency(request: Request): body = await request.json() worker.set_concurrency(int(body.get("value", 1))) return JSONResponse(worker.status()) @app.post("/auto") async def auto(request: Request): body = await request.json() worker.set_auto(bool(body.get("value", True))) return JSONResponse(worker.status()) @app.post("/bandwidth") async def bandwidth(request: Request): body = await request.json() worker.set_bandwidth(float(body.get("value", 0))) return JSONResponse(worker.status()) @app.get("/gpu") def gpu(): # GPU meters poll this on their own fast cadence. It only reads local # nvidia-smi — no curator round-trip — so the util/VRAM bars stay live even # when /status is slow waiting on the (sometimes busy) curator queue call. g = read_gpu() or {} us = worker.util_smooth() if us is not None: g["util_smooth"] = round(us, 1) # autoscaler's EWMA — the UI bar tracks this return JSONResponse(g) @app.get("/logs") def logs(): return JSONResponse({"lines": list(logbuf.LINES)}) @app.get("/status") def status(): # Pure in-memory read: worker.status() is lock-free and the queue snapshot is # kept fresh by a background poller — NO inline curator call, so this can't # stall the status view when curator is buried under a big backlog. worker.note_ui() # a browser is watching → keep the queue snapshot warm s = worker.status() s["fc_url"] = cfg.fc_url s["configured"] = bool(cfg.token) s["queue"] = worker.latest_queue() s["build"] = VERSION return JSONResponse(s) _PAGE = """ FabledCurator · GPU agent
FabledCurator GPU agent

Server · token · build __BUILD__

Control
MB/s
auto-tuning downloaders to keep the GPU fed · max 8
Status
state
jobs / min
downloads / min
0
processed
0
errors
0
waited out
GPU util
VRAM
buffer occupancy
downloaders — · consumers — · on GPU 0
queue —
Logs
waiting for activity…
"""