mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
85 lines
2.3 KiB
TypeScript
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;
|
|
}
|