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>
183 lines
6.2 KiB
JavaScript
183 lines
6.2 KiB
JavaScript
// Same-origin API client for the breakglass UI.
|
|
//
|
|
// Auth is handled entirely by the edge proxy (Authentik / basic-auth / bearer):
|
|
// this UI never sends or stores a token, and builds no login screen.
|
|
//
|
|
// The chat uses the tmux/attach model. The conversation lives SERVER-SIDE; we
|
|
// only persist the session_id locally and ATTACH to it over an EventSource. The
|
|
// browser's native EventSource auto-reconnects and sends Last-Event-ID, and the
|
|
// server resumes from there — so there is ZERO reconnect logic here. We just
|
|
// render events idempotently by id (see transcript.js).
|
|
|
|
const SESSION_KEY = 'breakglass.session_id';
|
|
|
|
/** Read the persisted session id, or '' if none. */
|
|
export function loadSessionId() {
|
|
try {
|
|
return localStorage.getItem(SESSION_KEY) || '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** Persist the session id (best-effort; private-mode storage may throw). */
|
|
export function saveSessionId(id) {
|
|
try {
|
|
if (id) localStorage.setItem(SESSION_KEY, id);
|
|
else localStorage.removeItem(SESSION_KEY);
|
|
} catch {
|
|
/* ignore — storage is a convenience, not a requirement */
|
|
}
|
|
}
|
|
|
|
/** Forget the persisted session id (the "New session" archive step). */
|
|
export function clearSessionId() {
|
|
saveSessionId('');
|
|
}
|
|
|
|
/** Open a fresh server-side session. @returns {Promise<string>} session_id */
|
|
export async function openSession() {
|
|
const res = await fetch('/api/session', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`could not open a session (HTTP ${res.status})`);
|
|
}
|
|
const body = await res.json();
|
|
if (!body || typeof body.session_id !== 'string') {
|
|
throw new Error('session response missing session_id');
|
|
}
|
|
return body.session_id;
|
|
}
|
|
|
|
/**
|
|
* Attach to a session's event stream. Returns the live EventSource so the
|
|
* caller can close() it. Events arrive as:
|
|
* - default `message` events: .data is JSON {kind, id, ...}
|
|
* - a named `caught-up` event once the replay is drained (.data is {})
|
|
* - native `error` events while reconnecting (EventSource retries itself)
|
|
*
|
|
* @param {string} sessionId
|
|
* @param {{
|
|
* onEvent: (e: object) => void,
|
|
* onCaughtUp?: () => void,
|
|
* onOpen?: () => void,
|
|
* onError?: (e: Event) => void,
|
|
* }} handlers
|
|
* @returns {EventSource}
|
|
*/
|
|
export function attachStream(sessionId, { onEvent, onCaughtUp, onOpen, onError }) {
|
|
const es = new EventSource(`/api/session/${encodeURIComponent(sessionId)}/stream`);
|
|
|
|
es.onopen = () => onOpen?.();
|
|
|
|
es.onmessage = (e) => {
|
|
if (!e || typeof e.data !== 'string' || e.data === '') return;
|
|
let obj;
|
|
try {
|
|
obj = JSON.parse(e.data);
|
|
} catch {
|
|
// A malformed frame must not abort an in-progress recovery stream.
|
|
return;
|
|
}
|
|
// EventSource exposes the SSE `id:` line as e.lastEventId. The server also
|
|
// embeds id in the JSON; prefer the JSON id, fall back to lastEventId.
|
|
if ((obj.id == null || obj.id === '') && e.lastEventId) obj.id = e.lastEventId;
|
|
onEvent(obj);
|
|
};
|
|
|
|
es.addEventListener('caught-up', () => onCaughtUp?.());
|
|
|
|
es.onerror = (e) => {
|
|
// EventSource auto-reconnects on a transient drop (readyState CONNECTING);
|
|
// we only surface a hard, terminal failure (readyState CLOSED).
|
|
onError?.(e);
|
|
};
|
|
|
|
return es;
|
|
}
|
|
|
|
/**
|
|
* Start a turn. Output arrives via the attach stream, NOT this response.
|
|
* @param {{session_id: string, prompt: string, model?: string}} opts
|
|
* @returns {Promise<{status:'started'|'busy'|'gone'}>}
|
|
* started — accepted; busy — 409 (a turn already runs); gone — 404 (re-create).
|
|
*/
|
|
export async function sendPrompt({ session_id, prompt, model }) {
|
|
const payload = { prompt };
|
|
if (model) payload.model = model;
|
|
const res = await fetch(`/api/session/${encodeURIComponent(session_id)}/prompt`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (res.status === 409) return { status: 'busy' };
|
|
if (res.status === 404) return { status: 'gone' };
|
|
if (!res.ok) throw new Error(`could not start the turn (HTTP ${res.status})`);
|
|
return { status: 'started' };
|
|
}
|
|
|
|
/**
|
|
* Cancel the in-flight turn (the Stop button).
|
|
* @param {string} sessionId
|
|
* @returns {Promise<boolean>} whether a turn was cancelled
|
|
*/
|
|
export async function cancelTurn(sessionId) {
|
|
const res = await fetch(`/api/session/${encodeURIComponent(sessionId)}/cancel`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
if (!res.ok) throw new Error(`could not stop the turn (HTTP ${res.status})`);
|
|
const body = await res.json().catch(() => ({}));
|
|
return Boolean(body.cancelled);
|
|
}
|
|
|
|
/**
|
|
* List the PVE power verbs and which mutate VM state.
|
|
* @returns {Promise<{verbs: string[], mutating: string[]}>}
|
|
*/
|
|
export async function fetchVerbs() {
|
|
const res = await fetch('/api/pve/verbs');
|
|
if (!res.ok) {
|
|
throw new Error(`could not load VM controls (HTTP ${res.status})`);
|
|
}
|
|
const body = await res.json();
|
|
return {
|
|
verbs: Array.isArray(body.verbs) ? body.verbs : [],
|
|
mutating: Array.isArray(body.mutating) ? body.mutating : [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run a PVE power verb directly (no AI in the path). The backend returns 200 on
|
|
* success and 502 when the verb's exit code is non-zero, but the JSON body
|
|
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases — so we read
|
|
* the body regardless of HTTP status and let the caller style on exit_code.
|
|
*
|
|
* @param {string} verb
|
|
* @returns {Promise<{verb:string, exit_code:number|null, stdout:string, stderr:string, rejected:boolean}>}
|
|
*/
|
|
export async function runVerb(verb) {
|
|
const res = await fetch(`/api/pve/${encodeURIComponent(verb)}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
let body;
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
throw new Error(`VM control '${verb}' failed (HTTP ${res.status}, no body)`);
|
|
}
|
|
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
|
|
if (res.status === 400) {
|
|
throw new Error(body?.detail || `'${verb}' was rejected by the server`);
|
|
}
|
|
return {
|
|
verb: body.verb ?? verb,
|
|
exit_code: body.exit_code ?? null,
|
|
stdout: body.stdout ?? '',
|
|
stderr: body.stderr ?? '',
|
|
rejected: Boolean(body.rejected),
|
|
};
|
|
}
|