breakglass UI v2: attachable sessions (tmux model) + mobile-first redesign

Full audit-driven rework. Keeps the proven SSE-translation + verb logic; everything else upgraded for phone-primary use.

Backend — server owns the session, clients attach (Viktor's tmux idea):
- session.py: SessionManager + Session with an event log, subscriber pub/sub, and turns that run DETACHED (keep going if the client disconnects).
- GET /api/session/{id}/stream = attach (SSE): replays the transcript then tails live; per-event id: lines so an EventSource auto-reconnect resumes from Last-Event-ID (free re-attach). POST /{id}/prompt starts a detached turn; POST /{id}/cancel = Stop. Replaces the old one-shot /api/chat.
- agent_session trimmed to the argv + translate_event helpers; 21 new/updated tests (replay, Last-Event-ID resume, broadcast, detached turn, resume, cancel, routes) — 53 green.

Frontend — mobile-first via the frontend-design skill (emergency-console aesthetic):
- EventSource attach (native auto-reconnect, zero client reconnect logic); transcript.js folds events->messages with id-dedupe so replays never double-render (30 unit assertions).
- Installable PWA: manifest + icons (wrench/break-glass mark) + apple-mobile-web-app meta + theme-color; viewport-fit=cover + safe-area; 100dvh; 16px composer (no iOS zoom).
- One-tap diagnosis presets (Triage / Memory-OOM / Disk / Services / QEMU-wedged) mapped to the devvm's real failure modes; Stop button while a turn runs.
- Foldable VM-control sheet, cycle the dominant recovery action w/ confirm, output capped 46vh.
- a11y: fixed --ink-faint contrast 3.6:1 -> 6.1:1 (WCAG AA); >=44px tap targets. Deleted the obsolete fetch-reader sse.js (EventSource replaces it).

Verified: 53 backend tests + 30 transcript assertions; Playwright @390x844 (input on-screen y=721-821, presets/sheet/fold/cap); local integration smoke vs the real backend (attach->caught-up, 404, verbs, PWA served).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-14 19:19:03 +00:00
parent 9d8afdd884
commit 5b5daa4bea
30 changed files with 1961 additions and 968 deletions

View file

@ -1,38 +1,44 @@
"""Breakglass FastAPI app — the in-cluster emergency recovery UI.
The chat uses the tmux/attach model (see session.py): the server owns the
conversation; clients attach over SSE and the turn keeps running if they
disconnect.
Routes:
GET /health liveness (no auth)
GET / the single-page UI (static)
POST /api/session open a chat session, returns {session_id}
POST /api/chat run one turn, streams SSE events (text/tool/result)
POST /api/pve/{verb} LLM-independent PVE power verb (manual buttons)
GET /api/pve/verbs list allowed verbs + which mutate
GET /health liveness (no auth)
GET / the single-page UI (static)
POST /api/session create a session, returns {session_id}
GET /api/session/{id}/stream ATTACH (SSE): replay + live tail
POST /api/session/{id}/prompt run a turn (detached; survives disconnect)
POST /api/session/{id}/cancel stop the in-flight turn
GET /api/pve/verbs list allowed verbs + which mutate
POST /api/pve/{verb} LLM-independent PVE power verb (buttons)
Everything under /api requires auth (edge Authentik header or bearer token).
"""
import json
import os
import uuid
from fastapi import Depends, FastAPI, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from . import agent_session, config, pve
from . import config, pve
from .auth import require_auth
from .session import SessionManager, attach_stream
app = FastAPI(title="Claude Breakglass")
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
manager = SessionManager()
class SessionResponse(BaseModel):
session_id: str
class ChatRequest(BaseModel):
session_id: str
class PromptRequest(BaseModel):
prompt: str = Field(..., min_length=1)
model: str | None = None
@ -44,30 +50,53 @@ async def health():
@app.post("/api/session", response_model=SessionResponse)
async def open_session(_identity: str = Depends(require_auth)):
# Claude wants a UUID for --session-id.
return SessionResponse(session_id=str(uuid.uuid4()))
return SessionResponse(session_id=manager.create().id)
@app.post("/api/chat")
async def chat(req: ChatRequest, _identity: str = Depends(require_auth)):
"""Stream one chat turn as Server-Sent Events. The browser reads the
response body incrementally (fetch + ReadableStream)."""
async def _sse():
try:
async for ev in agent_session.run_turn(req.session_id, req.prompt, req.model):
yield f"data: {json.dumps(ev)}\n\n"
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
yield f"data: {json.dumps({'kind': 'error', 'error': str(exc)[:500]})}\n\n"
yield f"data: {json.dumps({'kind': 'done'})}\n\n"
@app.get("/api/session/{session_id}/stream")
async def attach(
session_id: str,
_identity: str = Depends(require_auth),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
):
"""Attach to a session (SSE). Replays the conversation so far, then tails
live. On an EventSource auto-reconnect the browser sends Last-Event-ID, so we
replay only what was missed."""
session = manager.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="session not found")
try:
leid = int(last_event_id) if last_event_id is not None else None
except ValueError:
leid = None
return StreamingResponse(
_sse(),
attach_stream(session, leid),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
)
@app.post("/api/session/{session_id}/prompt")
async def prompt(session_id: str, req: PromptRequest, _identity: str = Depends(require_auth)):
"""Start a turn. It runs DETACHED (keeps going if the client disconnects);
output is delivered via the attach stream, not this response."""
session = manager.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="session not found")
if not session.start_turn(req.prompt, req.model):
raise HTTPException(status_code=409, detail="a turn is already running")
return {"status": "started"}
@app.post("/api/session/{session_id}/cancel")
async def cancel(session_id: str, _identity: str = Depends(require_auth)):
session = manager.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="session not found")
cancelled = await session.cancel()
return {"cancelled": cancelled}
@app.get("/api/pve/verbs")
async def pve_verbs(_identity: str = Depends(require_auth)):
return {