backlot phase 1: board server (state derivation, watcher, SSE, media, CLI)

- backlot/state.py: BoardState from disk — stage rail with gate audit
  (gate_skipped detection from history/), scene_plan x script x
  asset_manifest storyboard join, takes, generating-state from events,
  media discovery incl. atelier root-render heuristic, degradation ladder,
  library summaries
- backlot/server.py: FastAPI on 4750 — /api/projects, /api/project/{id}/state,
  SSE change feeds (project + library) fed by a watchfiles watcher,
  /media with range support and traversal protection, UI mounts
- backlot/__main__.py: 'python -m backlot open [project]' idempotent
  launcher (spawns detached server, opens browser); 'serve' foreground
- verified against real projects: 73 listed, full state for
  signal-from-tomorrow, 206 range responses, SSE change push on
  filesystem write
This commit is contained in:
calesthio
2026-07-01 23:26:30 -07:00
parent 514d0faf37
commit c80ecbcb2d
6 changed files with 1096 additions and 0 deletions

217
backlot/server.py Normal file
View File

@@ -0,0 +1,217 @@
"""Backlot server — FastAPI app: board state API, SSE change feed, media.
The watcher observes ``projects/`` with watchfiles; on any change it bumps a
per-project version and wakes SSE subscribers, who tell the browser to
refetch state. The server never writes to project directories.
"""
from __future__ import annotations
import asyncio
import json
import time
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from backlot.state import PROJECTS_DIR, REPO_ROOT, list_projects, load_board_state
UI_DIR = Path(__file__).resolve().parent / "ui"
# Paths inside a project whose changes are pure noise for the board.
_IGNORE_PARTS = {"node_modules", ".git", "__pycache__", ".cache"}
SSE_HEARTBEAT_SECONDS = 15
class ChangeHub:
"""Fan-out of project-change notifications to SSE subscribers."""
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue] = set()
def subscribe(self) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=64)
self._subscribers.add(q)
return q
def unsubscribe(self, q: asyncio.Queue) -> None:
self._subscribers.discard(q)
def publish(self, project_id: str) -> None:
for q in list(self._subscribers):
try:
q.put_nowait(project_id)
except asyncio.QueueFull:
pass # subscriber is behind; it will refetch on next event
hub = ChangeHub()
def _project_of_change(path_str: str) -> Optional[str]:
"""Map a changed filesystem path to a project id (None = irrelevant)."""
try:
rel = Path(path_str).resolve().relative_to(PROJECTS_DIR.resolve())
except (ValueError, OSError):
return None
if not rel.parts:
return None
if _IGNORE_PARTS.intersection(rel.parts):
return None
return rel.parts[0]
async def _watch_projects() -> None:
"""Background task: watch projects/ and publish debounced changes."""
try:
from watchfiles import awatch
except ImportError:
return # watcher unavailable → board still works via manual refresh
if not PROJECTS_DIR.is_dir():
return
async for changes in awatch(PROJECTS_DIR, recursive=True, step=400):
touched: set[str] = set()
for _change, path_str in changes:
pid = _project_of_change(path_str)
if pid:
touched.add(pid)
for pid in touched:
hub.publish(pid)
def create_app() -> FastAPI:
app = FastAPI(title="Backlot", docs_url=None, redoc_url=None)
@app.on_event("startup")
async def _startup() -> None:
app.state.watch_task = asyncio.create_task(_watch_projects())
@app.on_event("shutdown")
async def _shutdown() -> None:
task = getattr(app.state, "watch_task", None)
if task:
task.cancel()
# ---- API ----------------------------------------------------------
@app.get("/api/health")
async def health() -> dict:
return {"ok": True, "app": "backlot"}
@app.get("/api/projects")
async def projects() -> list:
return await asyncio.to_thread(list_projects)
@app.get("/api/project/{project_id}/state")
async def project_state(project_id: str) -> dict:
project_dir = _safe_project_dir(project_id)
return await asyncio.to_thread(load_board_state, project_dir)
@app.get("/api/project/{project_id}/events")
async def project_events(project_id: str, request: Request) -> StreamingResponse:
_safe_project_dir(project_id) # 404 early for unknown projects
async def stream():
q = hub.subscribe()
try:
yield _sse({"type": "hello", "project_id": project_id})
while True:
if await request.is_disconnected():
return
try:
changed = await asyncio.wait_for(q.get(), timeout=SSE_HEARTBEAT_SECONDS)
except asyncio.TimeoutError:
yield _sse({"type": "heartbeat", "ts": time.time()})
continue
if changed == project_id:
# Coalesce bursts: drain anything queued for this project.
while not q.empty():
try:
q.get_nowait()
except asyncio.QueueEmpty:
break
yield _sse({"type": "change", "project_id": project_id})
finally:
hub.unsubscribe(q)
return StreamingResponse(stream(), media_type="text/event-stream", headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
})
@app.get("/api/library/events")
async def library_events(request: Request) -> StreamingResponse:
async def stream():
q = hub.subscribe()
try:
yield _sse({"type": "hello"})
while True:
if await request.is_disconnected():
return
try:
changed = await asyncio.wait_for(q.get(), timeout=SSE_HEARTBEAT_SECONDS)
except asyncio.TimeoutError:
yield _sse({"type": "heartbeat", "ts": time.time()})
continue
while not q.empty():
try:
q.get_nowait()
except asyncio.QueueEmpty:
break
yield _sse({"type": "change", "project_id": changed})
finally:
hub.unsubscribe(q)
return StreamingResponse(stream(), media_type="text/event-stream", headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
})
# ---- Media (range requests handled by FileResponse) ---------------
@app.get("/media/{project_id}/{file_path:path}")
async def media(project_id: str, file_path: str) -> FileResponse:
project_dir = _safe_project_dir(project_id)
target = (project_dir / file_path).resolve()
try:
target.relative_to(project_dir.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="path escapes project")
if not target.is_file():
raise HTTPException(status_code=404, detail="media not found")
return FileResponse(target)
# ---- UI ------------------------------------------------------------
@app.get("/p/{project_id}")
async def board_page(project_id: str) -> FileResponse:
return FileResponse(UI_DIR / "board.html")
@app.get("/")
async def library_page() -> FileResponse:
return FileResponse(UI_DIR / "index.html")
if UI_DIR.is_dir():
app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
return app
def _safe_project_dir(project_id: str) -> Path:
if "/" in project_id or "\\" in project_id or project_id in (".", ".."):
raise HTTPException(status_code=400, detail="invalid project id")
project_dir = PROJECTS_DIR / project_id
if not project_dir.is_dir():
raise HTTPException(status_code=404, detail=f"unknown project: {project_id}")
return project_dir
def _sse(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"
app = create_app()