diff --git a/web/src/components/daemon/DaemonLink.test.ts b/web/src/components/daemon/DaemonLink.test.ts new file mode 100644 index 0000000..e97121e --- /dev/null +++ b/web/src/components/daemon/DaemonLink.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.js"; +import type { DaemonInfo } from "../../types.js"; + +function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo { + return { + hostname: "konishi", + version: "0.1.0", + presets: [], + build: { commit, commit_date: date, dirty: false }, + instance, + }; +} + +/** Scheduler whose timers only fire when the test says so, recording the + * delay each was scheduled with. */ +function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } { + let next = 1; + const timers = new Map void>(); + const delays: number[] = []; + return { + delays, + setTimer(fn, ms) { + const h = next++; + timers.set(h, fn); + delays.push(ms); + return h; + }, + clearTimer(h) { + timers.delete(h); + }, + run() { + const due = [...timers.entries()]; + timers.clear(); + for (const [, fn] of due) fn(); + }, + pending: () => timers.size, + }; +} + +describe("compareIdentity", () => { + it("reads a changed commit as an upgrade", () => { + expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded"); + }); + + it("reads a changed instance on the same commit as a plain restart", () => { + expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted"); + }); + + it("reads an unchanged process as no event at all", () => { + expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same"); + }); + + it("falls back to the instance when the build is unknown", () => { + // A daemon built outside a checkout reports empty commits; the instance is + // still enough to see the restart, and never claims a phantom upgrade. + expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted"); + expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same"); + }); +}); + +describe("formatRelease", () => { + it("pairs the short hash with a readable date", () => { + expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe( + "f89be4a716c7 · 2026-09-02", + ); + }); + + it("marks a dirty build", () => { + expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)"); + }); + + it("says so when the release is unknown", () => { + expect(formatRelease(undefined)).toBe("unknown release"); + }); +}); + +describe("daemon link store", () => { + it("starts in the connecting state until the first probe lands", async () => { + const store = createDaemonLinkStore({ + fetchInfo: async () => info("aaa", "i1"), + scheduler: manual(), + reload: () => {}, + }); + expect(store.getState().state).toBe("connecting"); + await store.getState().probe(); + expect(store.getState().state).toBe("online"); + expect(store.getState().build?.commit).toBe("aaa"); + }); + + it("holds the modal back during the grace period, then blocks", async () => { + const sched = manual(); + const store = createDaemonLinkStore({ + fetchInfo: async () => { + throw new Error("down"); + }, + scheduler: sched, + reload: () => {}, + graceMs: 1000, + }); + store.getState().noteDisconnected(); + // Still inside the grace window: the dot goes amber, nothing blocks. + expect(store.getState().state).toBe("reconnecting"); + sched.run(); + await Promise.resolve(); + expect(store.getState().state).toBe("down"); + }); + + it("unlocks without reloading when the same build comes back", async () => { + const sched = manual(); + const reload = vi.fn(); + let reply: DaemonInfo | null = info("aaa", "i1"); + const store = createDaemonLinkStore({ + fetchInfo: async () => { + if (!reply) throw new Error("down"); + return reply; + }, + scheduler: sched, + reload, + }); + await store.getState().probe(); + + reply = null; + store.getState().noteDisconnected(); + sched.run(); // grace expires → blocked, poll scheduled + await Promise.resolve(); + expect(store.getState().state).toBe("down"); + + // Same commit, new process: a restart, not an upgrade — the page stays. + reply = info("aaa", "i2"); + await store.getState().probe(); + expect(reload).not.toHaveBeenCalled(); + expect(store.getState().state).toBe("online"); + }); + + it("reloads the page when the daemon comes back on a new commit", async () => { + const sched = manual(); + const reload = vi.fn(); + let reply = info("aaa", "i1"); + const store = createDaemonLinkStore({ + fetchInfo: async () => reply, + scheduler: sched, + reload, + }); + await store.getState().probe(); + + store.getState().noteDisconnected(); + reply = info("bbb", "i2"); + await store.getState().probe(); + expect(reload).toHaveBeenCalledTimes(1); + // Left blocked deliberately: the reload is what ends this page's life, and + // flashing an unlocked UI in between would let the user act on stale code. + expect(store.getState().state).toBe("updating"); + }); + + it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => { + const sched = manual(); + let attempts = 0; + const store = createDaemonLinkStore({ + fetchInfo: async () => { + attempts += 1; + throw new Error("down"); + }, + scheduler: sched, + reload: () => {}, + }); + store.getState().noteDisconnected(); + sched.run(); // grace → first poll + await Promise.resolve(); + await Promise.resolve(); + const first = attempts; + expect(first).toBeGreaterThan(0); + sched.run(); // next poll tick + await Promise.resolve(); + await Promise.resolve(); + expect(attempts).toBeGreaterThan(first); + store.getState().stop(); + }); + + it("notifies subscribers on every state change", async () => { + const sched = manual(); + const seen: string[] = []; + const store = createDaemonLinkStore({ + fetchInfo: async () => info("aaa", "i1"), + scheduler: sched, + reload: () => {}, + }); + store.subscribe((s) => seen.push(s.state)); + await store.getState().probe(); + store.getState().noteDisconnected(); + sched.run(); + await Promise.resolve(); + expect(seen).toContain("reconnecting"); + }); + + it("schedules the grace timer and poll at the documented defaults", async () => { + const sched = manual(); + const store = createDaemonLinkStore({ + fetchInfo: async () => { + throw new Error("down"); + }, + scheduler: sched, + reload: () => {}, + }); + expect(GRACE_MS).toBe(1200); + expect(POLL_MS).toBe(700); + store.getState().noteDisconnected(); + expect(sched.delays).toEqual([GRACE_MS]); + sched.run(); // grace fires → schedules the first poll + await Promise.resolve(); + await Promise.resolve(); + expect(sched.delays).toEqual([GRACE_MS, POLL_MS]); + }); +}); diff --git a/web/src/components/daemon/DaemonLink.test.tsx b/web/src/components/daemon/DaemonLink.test.tsx new file mode 100644 index 0000000..ba8ead2 --- /dev/null +++ b/web/src/components/daemon/DaemonLink.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createDaemonLinkStore } from "./DaemonLink.js"; +import { DaemonLink } from "./DaemonLink.js"; + +afterEach(() => cleanup()); + +describe("DaemonLink", () => { + it("probes once on mount, regardless of the initial connected value", async () => { + const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] }); + const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } }); + render(); + await Promise.resolve(); + expect(fetchInfo).toHaveBeenCalledTimes(1); + }); + + it("does not treat the initial connected value as a transition", async () => { + const store = createDaemonLinkStore({ + fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }), + scheduler: { setTimer: () => 0, clearTimer: () => {} }, + }); + render(); + await Promise.resolve(); + // Only the mount probe ran — no extra noteConnected() probe on top of it. + expect(store.getState().state).toBe("online"); + }); + + it("forwards a later connected drop to noteDisconnected", async () => { + const store = createDaemonLinkStore({ + fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }), + scheduler: { setTimer: () => 0, clearTimer: () => {} }, + }); + const { rerender } = render(); + await Promise.resolve(); + expect(store.getState().state).toBe("online"); + + rerender(); + expect(store.getState().state).toBe("reconnecting"); + }); + + it("forwards a later connected recovery to noteConnected", async () => { + const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] }); + const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } }); + const { rerender } = render(); + await Promise.resolve(); + expect(store.getState().state).toBe("online"); + + // The socket dropped: noteDisconnected() moves off "online" so the coming + // recovery isn't a no-op. + rerender(); + expect(store.getState().state).toBe("reconnecting"); + fetchInfo.mockClear(); + + rerender(); + await Promise.resolve(); + expect(fetchInfo).toHaveBeenCalledTimes(1); + expect(store.getState().state).toBe("online"); + }); + + it("stops the store's polling on unmount", () => { + const store = createDaemonLinkStore({ + fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }), + }); + const stop = vi.spyOn(store.getState(), "stop"); + const { unmount } = render(); + unmount(); + expect(stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/components/daemon/DaemonLink.tsx b/web/src/components/daemon/DaemonLink.tsx new file mode 100644 index 0000000..319deda --- /dev/null +++ b/web/src/components/daemon/DaemonLink.tsx @@ -0,0 +1,234 @@ +// React port of ../../daemonLink.ts. The link to the daemon behind this page: +// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx) +// renders what this reports. See docs/RESTART.md for the restart contract. + +import { useEffect, useRef } from "react"; +import { create } from "zustand"; + +import { getDaemonInfo } from "../../api.js"; +import type { BuildInfo, DaemonInfo } from "../../types.js"; + +/** + * - `connecting` — first probe hasn't landed yet. + * - `online` — the daemon answered and is the same process, or a resolved new one. + * - `reconnecting` — the socket dropped, but within the grace window; a blip + * shouldn't throw a modal in the user's face. + * - `down` — unreachable past the grace window: the overlay blocks the UI and + * the poll runs until it answers. + * - `updating` — it answered on a different commit; the page is reloading and + * stays blocked until it does. + */ +export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating"; + +/** What happened between two `/api/info` replies. */ +export type RestartKind = "same" | "restarted" | "upgraded"; + +/** How long a dropped socket may stay dropped before the UI blocks. Long enough + * to ride out a wifi hiccup, short enough that a real restart is visible. */ +export const GRACE_MS = 1200; +/** Cadence of the `/api/info` poll while the daemon is unreachable. */ +export const POLL_MS = 700; + +/** + * Compare two daemon replies. + * + * The commit decides first: a different one means new code, which for a browser + * means new assets. The per-process `instance` catches the rest — a restart of + * the same build, which needs no reload. A daemon built outside a git checkout + * reports an empty commit, so an empty-vs-empty pair never counts as an + * upgrade; the instance still reports the restart. + */ +export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind { + const a = before.build?.commit ?? ""; + const b = after.build?.commit ?? ""; + if (a && b && a !== b) return "upgraded"; + if (before.instance && after.instance && before.instance !== after.instance) return "restarted"; + return "same"; +} + +/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when + * the build carried uncommitted changes. Empty builds say so rather than + * rendering a blank tooltip. */ +export function formatRelease(build: BuildInfo | undefined): string { + if (!build?.commit) return "unknown release"; + const day = build.commit_date ? build.commit_date.slice(0, 10) : ""; + const parts = [build.commit]; + if (day) parts.push(`· ${day}`); + if (build.dirty) parts.push("(dirty)"); + return parts.join(" "); +} + +/** Schedules a probe/grace timer; 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), +}; + +export interface DaemonLinkState { + state: LinkState; + /** The release the daemon last reported. Undefined until the first probe, + * and on a daemon too old to carry one. */ + build?: BuildInfo; + /** Hostname the daemon reports, for the indicator's tooltip. */ + hostname?: string; + /** How many probes have failed in the current outage — the overlay counts + * attempts so a long restart doesn't look frozen. */ + attempts: number; +} + +export interface DaemonLinkActions { + /** One `/api/info` round-trip. Resolves `true` when the daemon answered. */ + probe(): Promise; + /** The events socket dropped. Starts the grace timer and the poll. */ + noteDisconnected(): void; + /** The events socket came back. It carries no identity of its own, so the + * state only clears once a probe confirms *which* daemon answered — a + * reconnect to an upgraded daemon must reload, not resume. */ + noteConnected(): void; + /** Stop polling. */ + stop(): void; +} + +export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions; + +export interface DaemonLinkOptions { + /** Injectable for tests; defaults to `GET /api/info`. */ + fetchInfo?: () => Promise; + scheduler?: Scheduler; + /** What an upgrade does. Injectable so tests don't navigate. */ + reload?: () => void; + graceMs?: number; + pollMs?: number; +} + +/** Builds an isolated daemon-link store — same shape as + * {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook + * and a plain object (`getState`/`subscribe`), so it drives the overlay + * whether or not React is the one calling `noteConnected`/`noteDisconnected`. */ +export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) { + const fetchInfo = opts.fetchInfo ?? getDaemonInfo; + const scheduler = opts.scheduler ?? browserScheduler; + const reload = opts.reload ?? (() => window.location.reload()); + const graceMs = opts.graceMs ?? GRACE_MS; + const pollMs = opts.pollMs ?? POLL_MS; + + let identity: DaemonInfo | null = null; + let graceTimer: number | null = null; + let pollTimer: number | null = null; + + return create((set, get) => { + function clearTimers(): void { + if (graceTimer !== null) { + scheduler.clearTimer(graceTimer); + graceTimer = null; + } + if (pollTimer !== null) { + scheduler.clearTimer(pollTimer); + pollTimer = null; + } + } + + async function probe(): Promise { + let info: DaemonInfo; + try { + info = await fetchInfo(); + } catch { + set((s) => ({ attempts: s.attempts + 1 })); + return false; + } + const before = identity; + identity = info; + if (before && compareIdentity(before, info) === "upgraded") { + // Stay blocked through the reload: an unlocked frame here would let the + // user drive new server code from the bundle it just replaced. + set({ state: "updating", build: info.build, hostname: info.hostname }); + reload(); + return true; + } + clearTimers(); + set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 }); + return true; + } + + async function poll(): Promise { + const state = get().state; + if (state === "online" || state === "updating") return; + const ok = await probe(); + if (ok || pollTimer !== null) return; + pollTimer = scheduler.setTimer(() => { + pollTimer = null; + void poll(); + }, pollMs); + } + + return { + state: "connecting", + build: undefined, + hostname: undefined, + attempts: 0, + + probe, + + noteDisconnected() { + const state = get().state; + if (state === "down" || state === "updating") return; + set({ state: "reconnecting", attempts: 0 }); + if (graceTimer === null) { + graceTimer = scheduler.setTimer(() => { + graceTimer = null; + if (get().state === "reconnecting") set({ state: "down" }); + void poll(); + }, graceMs); + } + }, + + noteConnected() { + const state = get().state; + if (state === "online" || state === "updating") return; + void probe(); + }, + + stop: clearTimers, + }; + }); +} + +export type DaemonLinkStoreApi = ReturnType; + +export interface DaemonLinkProps { + /** The link store this instance drives — `RestartOverlay` (and, later, the + * sidebar's status indicator) read the same one. */ + store: DaemonLinkStoreApi; + /** The catalog store's `connected` field (docs/RESTART.md): the events + * socket dying is the earliest signal a restart happened, ahead of any + * poll. Pass `useStore(catalogStore, (s) => s.connected)`. */ + connected: boolean; +} + +/** + * Renders nothing — the controller that wires a `DaemonLinkStore` to the + * catalog's socket state. Mount once near the app root; `RestartOverlay` + * reads the store it drives. + */ +export function DaemonLink({ store, connected }: DaemonLinkProps): null { + const mounted = useRef(false); + + useEffect(() => { + if (!mounted.current) { + mounted.current = true; + void store.getState().probe(); + return; + } + if (connected) store.getState().noteConnected(); + else store.getState().noteDisconnected(); + }, [store, connected]); + + useEffect(() => () => store.getState().stop(), [store]); + + return null; +} diff --git a/web/src/components/daemon/RestartOverlay.test.tsx b/web/src/components/daemon/RestartOverlay.test.tsx new file mode 100644 index 0000000..f02c362 --- /dev/null +++ b/web/src/components/daemon/RestartOverlay.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js"; +import { RestartOverlay } from "./RestartOverlay.js"; + +afterEach(() => cleanup()); + +function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi { + const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } }); + store.setState({ state }); + return store; +} + +describe("RestartOverlay", () => { + it("renders nothing while connecting, online, or reconnecting", () => { + for (const state of ["connecting", "online", "reconnecting"] as const) { + const { unmount } = render(); + expect(screen.queryByRole("alertdialog")).toBeNull(); + unmount(); + } + }); + + it("blocks with a restarting message when down", () => { + render(); + const dialog = screen.getByRole("alertdialog"); + expect(dialog.textContent).toContain("Maestro is restarting"); + expect(dialog.textContent).toContain("Waiting for the daemon to come back…"); + }); + + it("counts attempts once more than one probe has failed", () => { + const store = storeAt("down"); + store.setState({ attempts: 3 }); + render(); + expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)"); + }); + + it("says it is reloading when updating", () => { + render(); + const dialog = screen.getByRole("alertdialog"); + expect(dialog.textContent).toContain("Maestro was updated"); + expect(dialog.textContent).toContain("Reloading this page onto the new release…"); + }); + + it("names the release from the store's build", () => { + const store = storeAt("down"); + store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } }); + render(); + expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02"); + }); + + it("clears the moment the store reports the link is back", () => { + const store = storeAt("down"); + const { rerender } = render(); + expect(screen.queryByRole("alertdialog")).not.toBeNull(); + store.setState({ state: "online" }); + rerender(); + expect(screen.queryByRole("alertdialog")).toBeNull(); + }); + + it("swallows keyboard shortcuts while blocking, but not once cleared", () => { + const store = storeAt("down"); + const handler = vi.fn(); + window.addEventListener("keydown", handler); + const { rerender } = render(); + + fireEvent.keyDown(window, { key: "n" }); + expect(handler).not.toHaveBeenCalled(); + + store.setState({ state: "online" }); + rerender(); + fireEvent.keyDown(window, { key: "n" }); + expect(handler).toHaveBeenCalledTimes(1); + + window.removeEventListener("keydown", handler); + }); +}); diff --git a/web/src/components/daemon/RestartOverlay.tsx b/web/src/components/daemon/RestartOverlay.tsx new file mode 100644 index 0000000..c192081 --- /dev/null +++ b/web/src/components/daemon/RestartOverlay.tsx @@ -0,0 +1,62 @@ +// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that +// went away — see docs/RESTART.md. +// +// Rendered while the link is `down` or `updating`, gone the moment it is +// back. It owns no polling of its own: `DaemonLink` polls, this only renders +// what the store reports. + +import { useEffect } from "react"; +import { useStore } from "zustand"; + +import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js"; + +export interface RestartOverlayProps { + /** The same store a sibling `DaemonLink` drives. */ + store: DaemonLinkStoreApi; +} + +/** The poll runs while this is up; saying how many tries have gone by is what + * separates "still restarting" from "wedged" for someone staring at it. */ +function attemptText(attempts: number): string { + if (attempts <= 1) return "Waiting for the daemon to come back…"; + return `Waiting for the daemon to come back… (${attempts} attempts)`; +} + +function swallow(e: KeyboardEvent): void { + e.stopImmediatePropagation(); +} + +export function RestartOverlay({ store }: RestartOverlayProps) { + const state = useStore(store, (s) => s.state); + const build = useStore(store, (s) => s.build); + const attempts = useStore(store, (s) => s.attempts); + + const blocking = state === "down" || state === "updating"; + + // The app's shortcuts live on `window`, so hiding the UI behind a backdrop + // does not stop them: `n` would still open a spawn form against a daemon + // that is not there. Swallow keys in the capture phase until the link is + // back, without `preventDefault` so the browser's own chords still work. + useEffect(() => { + if (!blocking) return; + window.addEventListener("keydown", swallow, true); + return () => window.removeEventListener("keydown", swallow, true); + }, [blocking]); + + if (!blocking) return null; + + const updating = state === "updating"; + + return ( +
+
+ +
+ ); +}