Files
multica/packages/views/platform/use-local-daemon-status.ts
Bohan Jiang a1a33f91db fix(desktop): surface expired login instead of silently stuck "Starting" daemon (MUL-2973) (#3743)
* fix(desktop): surface expired login instead of silent "Starting" daemon (MUL-2973)

When the local daemon's cached PAT is expired/revoked, the daemon 401s during
startup and exits before it serves /health. The desktop polled /health forever
and kept reporting "starting", so the runtime sat at "Starting…" with no hint
that re-login was the fix (GitHub #3512).

Detect this in the layer that owns the daemon's credential: when a start fails
to reach "running", probe the token against GET /api/me. A 401 (or missing
token) surfaces a new "auth_expired" daemon state; a 2xx means the token is
fine (non-auth failure) and a network error stays inconclusive — so a network
blip is never misclassified as expired login.

The desktop then shows a "Sign-in expired · Sign in again" prompt on the
runtimes card and a banner in Daemon settings. The action drops the stale
cached PAT, re-mints a fresh one from the current session, and restarts the
daemon; if minting also 401s (the session token is dead) it falls back to the
standard re-login flow. No daemon/CLI behavior change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): only force re-login on a real 401 during daemon reconnect (MUL-2973)

Review feedback: the reconnect helper treated any failure from clearToken /
syncToken / restart as "session is dead" and logged the user out. A transient
failure (mint 5xx, network blip, config write error, restart hiccup) would
wrongly sign them out.

Move the failure classification into the main process, where the real HTTP
status is available: mintPat now tags its error with the response status, and a
new daemon:reauthenticate handler returns a structured ReauthResult — `ok`,
`session_invalid` (a genuine 401 → the session token itself is dead), or
`transient`. The renderer only calls logout() on `session_invalid`; transient
failures keep the user signed in and show a retryable toast. An unexpected IPC
error is also treated as transient, never as logout.

Add tests locking the classifier (401 → auth, 5xx/network/IO → not auth) and the
renderer behavior (transient failure and IPC throw do NOT log out).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 13:08:12 +08:00

85 lines
2.3 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
/** Subset of the daemonAPI status shape that the local_directory UI consumes.
* Redeclared here so this hook doesn't depend on the desktop preload types. */
export interface LocalDaemonStatus {
daemonId: string | null;
deviceName: string | null;
running: boolean;
}
interface DaemonStatusLike {
state:
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found"
| "auth_expired";
daemonId?: string;
deviceName?: string;
}
interface DaemonAPILike {
getStatus?: () => Promise<DaemonStatusLike>;
onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void;
}
function readDaemonAPI(): DaemonAPILike | undefined {
if (typeof window === "undefined") return undefined;
return (window as unknown as { daemonAPI?: DaemonAPILike }).daemonAPI;
}
function toStatus(s: DaemonStatusLike | undefined): LocalDaemonStatus {
if (!s) return { daemonId: null, deviceName: null, running: false };
return {
daemonId: s.daemonId ?? null,
deviceName: s.deviceName ?? null,
running: s.state === "running",
};
}
/**
* Live snapshot of the desktop's local daemon: the daemon_id it registers
* under, the OS device name, and whether the supervisor is currently running.
*
* On web (no `window.daemonAPI`) every field is null/false — components can
* unconditionally call this hook and branch on `daemonId` to decide whether
* a local_directory resource matches "this machine".
*
* The initial paint reads `getStatus()` once so the UI doesn't flash a
* "no daemon" state while waiting for the first push from `onStatusChange`.
*/
export function useLocalDaemonStatus(): LocalDaemonStatus {
const [status, setStatus] = useState<LocalDaemonStatus>(() => ({
daemonId: null,
deviceName: null,
running: false,
}));
useEffect(() => {
const api = readDaemonAPI();
if (!api) return;
let cancelled = false;
if (api.getStatus) {
api.getStatus().then((s) => {
if (!cancelled) setStatus(toStatus(s));
}).catch(() => {
// Ignore — onStatusChange will populate once the daemon comes up.
});
}
const unsubscribe = api.onStatusChange?.((s) => {
setStatus(toStatus(s));
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
return status;
}