This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/events.ts
T
Miguel PalhasandClaude Opus 5 4d7e368d31 feat(maestro): daemon status indicator and restart overlay on web
The sidebar footer gains a status dot beside the spawn button: green while the
daemon answers, amber while reconnecting, red while it is gone. Hovering it
shows the release the daemon is running — commit hash and commit date.

When the events socket drops, `DaemonLink` starts polling `GET /api/info`. Past
a 1200 ms grace window an overlay blocks the UI (and swallows the app's
shortcuts) until the daemon answers again. If it comes back on a different
commit the page reloads, so a tab never drives new server code from the bundle
that was just replaced; a plain restart just unlocks.

docs/RESTART.md is the contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:45:50 +01:00

188 lines
5.3 KiB
TypeScript

import { eventsWsUrl, listSessions } from "./api.js";
import type { CatalogEvent, Session, Uuid } from "./types.js";
export interface CatalogState {
sessions: Map<Uuid, Session>;
}
export type CatalogListener = (state: CatalogState) => void;
/** Opens the events socket. Injectable so the reducer and the backoff schedule
* can be driven without a server. */
export type SocketFactory = () => WebSocket;
/** Schedules a reconnect attempt; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
/** Initial reconnect delay, doubling to `MAX_BACKOFF_MS`. */
export const INITIAL_BACKOFF_MS = 500;
export const MAX_BACKOFF_MS = 10_000;
export interface CatalogStoreOptions {
socket?: SocketFactory;
scheduler?: Scheduler;
/** Full-state refetch used when the server reports it dropped frames. */
refetch?: () => Promise<Session[]>;
}
/** Called with `true` when the events socket opens, `false` when it drops.
* Drives the daemon link (daemonLink.ts) — the socket dying is the first sign
* the daemon went down or was upgraded under the page. */
export type ConnectionListener = (up: boolean) => void;
export class CatalogStore {
private state: CatalogState = { sessions: new Map() };
private listeners = new Set<CatalogListener>();
private connectionListeners = new Set<ConnectionListener>();
private ws: WebSocket | null = null;
private reconnectTimer: number | null = null;
private backoffMs = INITIAL_BACKOFF_MS;
private readonly socket: SocketFactory;
private readonly scheduler: Scheduler;
private readonly refetch: () => Promise<Session[]>;
constructor(opts: CatalogStoreOptions = {}) {
this.socket = opts.socket ?? (() => new WebSocket(eventsWsUrl()));
this.scheduler = opts.scheduler ?? browserScheduler;
this.refetch = opts.refetch ?? listSessions;
}
start(): void {
this.connect();
}
/** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number {
return this.backoffMs;
}
stop(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
}
/** Watch the socket's up/down transitions. Not called on subscribe: the
* caller's own probe decides the starting state. */
subscribeConnection(listener: ConnectionListener): () => void {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
subscribe(listener: CatalogListener): () => void {
this.listeners.add(listener);
listener(this.snapshot());
return () => this.listeners.delete(listener);
}
/** A copy, so a listener can diff it against the previous one. Returning the
* live object meant in-place mutation was invisible and every event forced a
* full re-render. */
snapshot(): CatalogState {
return { sessions: new Map(this.state.sessions) };
}
private connect(): void {
const ws = this.socket();
this.ws = ws;
ws.onmessage = (ev) => {
let event: CatalogEvent;
try {
event = JSON.parse(ev.data) as CatalogEvent;
} catch {
return;
}
this.apply(event);
};
ws.onopen = () => {
this.backoffMs = INITIAL_BACKOFF_MS;
this.emitConnection(true);
};
ws.onclose = () => {
this.ws = null;
this.emitConnection(false);
this.scheduleReconnect();
};
ws.onerror = () => {
try {
ws.close();
} catch {
// ignore
}
};
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return;
const delay = Math.min(this.backoffMs, MAX_BACKOFF_MS);
this.reconnectTimer = this.scheduler.setTimer(() => {
this.reconnectTimer = null;
this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
this.connect();
}, delay);
}
private apply(event: CatalogEvent): void {
switch (event.type) {
case "Snapshot":
this.state = {
sessions: new Map(event.sessions.map((s) => [s.id, s])),
};
break;
case "SessionAdded":
this.state.sessions.set(event.session.id, event.session);
break;
case "SessionRemoved":
this.state.sessions.delete(event.id);
break;
case "SessionUpdated":
this.state.sessions.set(event.session.id, event.session);
break;
case "Lagged":
// Re-derive from REST to avoid showing stale state.
void this.resync();
return;
}
this.emit();
}
private async resync(): Promise<void> {
try {
const sessions = await this.refetch();
this.state = {
sessions: new Map(sessions.map((s) => [s.id, s])),
};
this.emit();
} catch {
// surface in UI on next reconnect cycle; nothing else useful to do
}
}
private emitConnection(up: boolean): void {
for (const fn of this.connectionListeners) fn(up);
}
private emit(): void {
const snap = this.snapshot();
for (const fn of this.listeners) fn(snap);
}
}