diff --git a/backend/internal/cli/status.go b/backend/internal/cli/status.go index d0b3995b..6d284367 100644 --- a/backend/internal/cli/status.go +++ b/backend/internal/cli/status.go @@ -45,9 +45,11 @@ type daemonStatus struct { } type probeResult struct { - Status string `json:"status"` - Service string `json:"service"` - PID int `json:"pid"` + Status string `json:"status"` + Service string `json:"service"` + PID int `json:"pid"` + ExecutablePath string `json:"executablePath,omitempty"` + WorkingDirectory string `json:"workingDirectory,omitempty"` } func newStatusCommand(ctx *commandContext) *cobra.Command { diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index f4122ad7..61a6b390 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -222,19 +222,26 @@ func localControlRequest(r *http.Request) bool { // handleHealthz is the liveness probe: it answers 200 as long as the process is // up and serving. It does no dependency checks by design. func handleHealthz(w http.ResponseWriter, _ *http.Request) { - envelope.WriteJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "service": daemonmeta.ServiceName, - "pid": os.Getpid(), - }) + envelope.WriteJSON(w, http.StatusOK, daemonProbePayload("ok")) } // handleReadyz is the readiness probe. Dependency initialization happens before // the server is constructed, so a listening daemon is ready to answer requests. func handleReadyz(w http.ResponseWriter, _ *http.Request) { - envelope.WriteJSON(w, http.StatusOK, map[string]any{ - "status": "ready", + envelope.WriteJSON(w, http.StatusOK, daemonProbePayload("ready")) +} + +func daemonProbePayload(status string) map[string]any { + payload := map[string]any{ + "status": status, "service": daemonmeta.ServiceName, "pid": os.Getpid(), - }) + } + if exe, err := os.Executable(); err == nil && exe != "" { + payload["executablePath"] = exe + } + if cwd, err := os.Getwd(); err == nil && cwd != "" { + payload["workingDirectory"] = cwd + } + return payload } diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go index fca87cc5..016da597 100644 --- a/backend/internal/httpd/server_test.go +++ b/backend/internal/httpd/server_test.go @@ -2,10 +2,12 @@ package httpd import ( "context" + "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" + "os" "path/filepath" "testing" "time" @@ -39,6 +41,43 @@ func TestHealthProbes(t *testing.T) { } } +func TestHealthProbesIncludeDaemonIdentity(t *testing.T) { + router := newTestRouter(config.Config{}, discardLogger(), nil) + srv := httptest.NewServer(router) + defer srv.Close() + + wantExe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + wantCWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + client := &http.Client{Timeout: 2 * time.Second} + for _, path := range []string{"/healthz", "/readyz"} { + resp, err := client.Get(srv.URL + path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + defer resp.Body.Close() + var body struct { + ExecutablePath string `json:"executablePath"` + WorkingDirectory string `json:"workingDirectory"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + if body.ExecutablePath != wantExe { + t.Errorf("GET %s executablePath = %q, want %q", path, body.ExecutablePath, wantExe) + } + if body.WorkingDirectory != wantCWD { + t.Errorf("GET %s workingDirectory = %q, want %q", path, body.WorkingDirectory, wantCWD) + } + } +} + // TestServerLifecycle exercises the full Run loop: bind an ephemeral port, // publish running.json, serve a request, then cancel the context and confirm a // clean shutdown that removes the handshake file. diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 526549b9..7ea9d3d8 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,7 +6,7 @@ import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { resolveDaemonLaunch } from "./shared/daemon-launch"; +import { type DaemonLaunchSpec, resolveDaemonLaunch } from "./shared/daemon-launch"; import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery"; import type { DaemonStatus } from "./shared/daemon-status"; import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; @@ -21,6 +21,9 @@ app.setName("Agent Orchestrator"); let mainWindow: BrowserWindow | null = null; let daemonProcess: ChildProcessWithoutNullStreams | null = null; +let daemonStoppingProcess: ChildProcessWithoutNullStreams | null = null; +let daemonStartPromise: Promise | null = null; +let daemonStartEpoch = 0; let daemonStatus: DaemonStatus = { state: "stopped" }; const isDev = !app.isPackaged; @@ -153,6 +156,16 @@ const RUN_FILE_POLL_MS = 300; // Accept run-files stamped slightly before our spawn timestamp: the daemon's // clock reading and ours race within normal scheduling jitter. const RUN_FILE_FRESHNESS_SKEW_MS = 2_000; +const DAEMON_PROBE_TIMEOUT_MS = 2_000; +const DAEMON_SERVICE_NAME = "agent-orchestrator-daemon"; + +type DaemonProbe = { + status: string; + service: string; + pid: number; + executablePath?: string; + workingDirectory?: string; +}; function runFilePath(): string | null { if (process.env.AO_RUN_FILE) return process.env.AO_RUN_FILE; @@ -169,7 +182,169 @@ function daemonEnv(): NodeJS.ProcessEnv { }; } -function startDaemon(): DaemonStatus { +function pathKey(value: string): string { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function samePath(a: string, b: string): boolean { + return pathKey(a) === pathKey(b); +} + +function pathInside(child: string, parent: string): boolean { + const childKey = pathKey(child); + const parentKey = pathKey(parent); + return childKey === parentKey || childKey.startsWith(parentKey + path.sep); +} + +function processAlive(pid: number): boolean { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function readDaemonProbe(port: number, endpoint: "healthz" | "readyz"): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DAEMON_PROBE_TIMEOUT_MS); + try { + const response = await net.fetch(`http://127.0.0.1:${port}/${endpoint}`, { signal: controller.signal }); + if (!response.ok) return null; + const body = (await response.json()) as Partial; + if (body.status !== (endpoint === "healthz" ? "ok" : "ready")) return null; + if (body.service !== DAEMON_SERVICE_NAME) return null; + if (typeof body.pid !== "number" || !Number.isInteger(body.pid)) return null; + return { + status: body.status, + service: body.service, + pid: body.pid, + executablePath: typeof body.executablePath === "string" ? body.executablePath : undefined, + workingDirectory: typeof body.workingDirectory === "string" ? body.workingDirectory : undefined, + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +function daemonIdentityError(launch: DaemonLaunchSpec, probe: DaemonProbe): string | null { + if (launch.source === "dev") { + const cwdMatches = probe.workingDirectory ? samePath(probe.workingDirectory, launch.cwd) : false; + const executableMatches = probe.executablePath ? pathInside(probe.executablePath, launch.cwd) : false; + if (!probe.workingDirectory && !probe.executablePath) { + return "An older AO daemon is already running, but it does not report its checkout identity. Stop it and restart this app."; + } + if (!cwdMatches && !executableMatches) { + const actual = probe.workingDirectory ?? probe.executablePath ?? "an unknown location"; + return `Another AO daemon is already running from ${actual}; expected this checkout at ${launch.cwd}. Stop the other daemon before using this checkout.`; + } + return null; + } + + if (launch.source === "bundled") { + if (!probe.executablePath) { + return "An older AO daemon is already running, but it does not report its binary path. Stop it and restart this app."; + } + if (!samePath(probe.executablePath, launch.command)) { + return `Another AO daemon is already running from ${probe.executablePath}; expected ${launch.command}. Stop the other daemon before using this app.`; + } + } + return null; +} + +async function inspectExistingDaemon(launch: DaemonLaunchSpec): Promise { + const handshakePath = runFilePath(); + if (!handshakePath) return null; + let contents: string; + try { + contents = await readFile(handshakePath, "utf8"); + } catch { + return null; + } + const info = parseRunFile(contents); + if (!info || !processAlive(info.pid)) return null; + + const health = await readDaemonProbe(info.port, "healthz"); + if (!health || health.pid !== info.pid) return null; + const ready = await readDaemonProbe(info.port, "readyz"); + if (!ready || ready.pid !== info.pid) { + return { + state: "error", + port: info.port, + pid: info.pid, + executablePath: health.executablePath, + workingDirectory: health.workingDirectory, + message: "An AO daemon is already running, but it is not ready yet.", + }; + } + + const identityError = daemonIdentityError(launch, ready); + if (identityError) { + return { + state: "error", + port: info.port, + pid: info.pid, + executablePath: ready.executablePath, + workingDirectory: ready.workingDirectory, + message: identityError, + }; + } + + return { + state: "ready", + port: info.port, + pid: info.pid, + executablePath: ready.executablePath, + workingDirectory: ready.workingDirectory, + }; +} + +async function refreshDaemonStatus(): Promise { + if (daemonProcess) { + return daemonStatus; + } + const launch = resolveDaemonLaunch( + process.env, + app.isPackaged, + process.resourcesPath, + app.getAppPath(), + process.platform, + ); + if (!launch) return daemonStatus; + const existing = await inspectExistingDaemon(launch); + if (existing) { + setDaemonStatus(existing); + } else if ( + daemonStatus.state === "ready" || + (daemonStatus.state === "error" && (daemonStatus.pid || daemonStatus.port)) + ) { + setDaemonStatus({ + state: "stopped", + message: "AO daemon is no longer reachable.", + }); + } + return daemonStatus; +} + +async function startDaemon(): Promise { + if (daemonStartPromise) { + return daemonStartPromise; + } + const startEpoch = daemonStartEpoch; + const promise = startDaemonInner(startEpoch).finally(() => { + if (daemonStartPromise === promise) { + daemonStartPromise = null; + } + }); + daemonStartPromise = promise; + return daemonStartPromise; +} + +async function startDaemonInner(startEpoch: number): Promise { if (daemonProcess) { return daemonStatus; } @@ -189,6 +364,15 @@ function startDaemon(): DaemonStatus { return daemonStatus; } + const existing = await inspectExistingDaemon(launch); + if (startEpoch !== daemonStartEpoch) { + return daemonStatus; + } + if (existing) { + setDaemonStatus(existing); + return daemonStatus; + } + if (launch.source === "bundled" && !existsSync(launch.command)) { setDaemonStatus({ state: "error", @@ -232,7 +416,7 @@ function startDaemon(): DaemonStatus { }; const reportBoundPort = (port: number) => { - if (portConfirmed || daemonProcess !== child) return; + if (portConfirmed || daemonProcess !== child || daemonStoppingProcess === child) return; portConfirmed = true; stopDiscovery(); setDaemonStatus({ state: "ready", port }); @@ -273,7 +457,7 @@ function startDaemon(): DaemonStatus { // Last resort: neither source confirmed (e.g. an older daemon build). Report // the configured port so the renderer is not stuck on "starting" forever. fallbackTimer = setTimeout(() => { - if (portConfirmed || daemonProcess !== child) return; + if (portConfirmed || daemonProcess !== child || daemonStoppingProcess === child) return; stopDiscovery(); setDaemonStatus({ state: "ready", @@ -286,6 +470,7 @@ function startDaemon(): DaemonStatus { stopDiscovery(); if (daemonProcess !== child) return; daemonProcess = null; + if (daemonStoppingProcess === child) daemonStoppingProcess = null; setDaemonStatus({ state: "error", message: error.message }); }); @@ -293,6 +478,7 @@ function startDaemon(): DaemonStatus { stopDiscovery(); if (daemonProcess !== child) return; daemonProcess = null; + if (daemonStoppingProcess === child) daemonStoppingProcess = null; setDaemonStatus({ state: "stopped", message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`, @@ -316,18 +502,20 @@ function killDaemon(child: ChildProcessWithoutNullStreams): void { } function stopDaemon(): DaemonStatus { + daemonStartEpoch += 1; + daemonStartPromise = null; if (!daemonProcess) { setDaemonStatus({ state: "stopped" }); return daemonStatus; } + daemonStoppingProcess = daemonProcess; killDaemon(daemonProcess); - daemonProcess = null; setDaemonStatus({ state: "stopped" }); return daemonStatus; } -ipcMain.handle("daemon:getStatus", () => daemonStatus); +ipcMain.handle("daemon:getStatus", () => refreshDaemonStatus()); ipcMain.handle("daemon:start", () => startDaemon()); ipcMain.handle("daemon:stop", () => stopDaemon()); ipcMain.handle("app:getVersion", () => app.getVersion()); @@ -357,7 +545,7 @@ function initAutoUpdates(): void { app.whenReady().then(() => { registerRendererProtocol(); createWindow(); - startDaemon(); + void startDaemon(); initAutoUpdates(); app.on("activate", () => { diff --git a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx index 34d9b305..f0d3f57f 100644 --- a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx +++ b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx @@ -12,6 +12,7 @@ const { getMock, navigateMock } = vi.hoisted(() => ({ getMock: vi.fn(), navigate vi.mock("../../lib/api-client", () => ({ apiClient: { GET: getMock, POST: vi.fn() }, apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"), + hasTrustedApiBaseUrl: () => true, })); vi.mock("@tanstack/react-router", async (importOriginal) => { diff --git a/frontend/src/renderer/hooks/useDaemonStatus.test.tsx b/frontend/src/renderer/hooks/useDaemonStatus.test.tsx index ccb2a40a..67126837 100644 --- a/frontend/src/renderer/hooks/useDaemonStatus.test.tsx +++ b/frontend/src/renderer/hooks/useDaemonStatus.test.tsx @@ -1,7 +1,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { act } from "react"; import type { QueryClient } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { getStatusMock, onStatusMock, removeStatusMock, connectMock, stopTransportMock, setApiBaseUrlMock } = vi.hoisted( () => ({ @@ -35,6 +35,7 @@ function fakeQueryClient(): QueryClient { } beforeEach(() => { + vi.useRealTimers(); getStatusMock.mockReset().mockResolvedValue({ state: "stopped" }); onStatusMock.mockReset().mockReturnValue(removeStatusMock); removeStatusMock.mockReset(); @@ -43,6 +44,10 @@ beforeEach(() => { setApiBaseUrlMock.mockReset(); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe("useDaemonStatus", () => { it("applies the initial status, points REST at the reported port, and connects the transport", async () => { getStatusMock.mockResolvedValue({ state: "ready", port: 3037 }); @@ -57,14 +62,24 @@ describe("useDaemonStatus", () => { expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); }); - it("does not touch the base URL for statuses without a port", async () => { + it("quarantines the base URL for statuses without a port", async () => { getStatusMock.mockResolvedValue({ state: "stopped", message: "daemon not configured" }); const queryClient = fakeQueryClient(); const { result } = renderHook(() => useDaemonStatus(queryClient)); await waitFor(() => expect(result.current.message).toBe("daemon not configured")); - expect(setApiBaseUrlMock).not.toHaveBeenCalled(); + expect(setApiBaseUrlMock).toHaveBeenCalledWith(null); + }); + + it("quarantines REST for an incompatible daemon even when its port is known", async () => { + getStatusMock.mockResolvedValue({ state: "error", port: 3001, message: "wrong daemon" }); + const queryClient = fakeQueryClient(); + + const { result } = renderHook(() => useDaemonStatus(queryClient)); + + await waitFor(() => expect(result.current).toEqual({ state: "error", port: 3001, message: "wrong daemon" })); + expect(setApiBaseUrlMock).toHaveBeenCalledWith(null); }); it("applies pushed status events from the bridge", async () => { @@ -79,6 +94,74 @@ describe("useDaemonStatus", () => { expect(setApiBaseUrlMock).toHaveBeenCalledWith("http://127.0.0.1:4555"); }); + it("refreshes non-ready status until the daemon is ready", async () => { + vi.useFakeTimers(); + getStatusMock.mockResolvedValueOnce({ state: "starting" }).mockResolvedValueOnce({ state: "ready", port: 4777 }); + const queryClient = fakeQueryClient(); + + const { result } = renderHook(() => useDaemonStatus(queryClient)); + + await act(async () => { + await Promise.resolve(); + }); + expect(result.current).toEqual({ state: "starting" }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000); + }); + + expect(result.current).toEqual({ state: "ready", port: 4777 }); + expect(getStatusMock).toHaveBeenCalledTimes(2); + expect(setApiBaseUrlMock).toHaveBeenCalledWith("http://127.0.0.1:4777"); + }); + + it("refreshes ready status so adopted daemon liveness is rechecked", async () => { + vi.useFakeTimers(); + getStatusMock.mockResolvedValueOnce({ state: "ready", port: 4777 }).mockResolvedValueOnce({ state: "stopped" }); + const queryClient = fakeQueryClient(); + + const { result } = renderHook(() => useDaemonStatus(queryClient)); + + await act(async () => { + await Promise.resolve(); + }); + expect(result.current).toEqual({ state: "ready", port: 4777 }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + + expect(result.current).toEqual({ state: "stopped" }); + expect(getStatusMock).toHaveBeenCalledTimes(2); + expect(setApiBaseUrlMock).toHaveBeenCalledWith(null); + }); + + it("ignores stale refresh responses that complete after a newer refresh", async () => { + let resolveFirst: (status: DaemonStatus) => void = () => undefined; + getStatusMock + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ state: "ready", port: 4777 }); + const queryClient = fakeQueryClient(); + + const { result } = renderHook(() => useDaemonStatus(queryClient)); + + act(() => window.dispatchEvent(new Event("focus"))); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current).toEqual({ state: "ready", port: 4777 }); + + await act(async () => { + resolveFirst({ state: "stopped" }); + await Promise.resolve(); + }); + + expect(result.current).toEqual({ state: "ready", port: 4777 }); + expect(setApiBaseUrlMock).toHaveBeenLastCalledWith("http://127.0.0.1:4777"); + }); + it("still connects the transport when the initial IPC status call fails", async () => { getStatusMock.mockRejectedValue(new Error("ipc unavailable")); const queryClient = fakeQueryClient(); diff --git a/frontend/src/renderer/hooks/useDaemonStatus.ts b/frontend/src/renderer/hooks/useDaemonStatus.ts index 76168528..6607b89f 100644 --- a/frontend/src/renderer/hooks/useDaemonStatus.ts +++ b/frontend/src/renderer/hooks/useDaemonStatus.ts @@ -1,44 +1,91 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { QueryClient } from "@tanstack/react-query"; import { aoBridge } from "../lib/bridge"; +import { applyDaemonStatus, readDaemonStatus, type DaemonStatus } from "../lib/daemon-status"; import { queryClient as defaultQueryClient } from "../lib/query-client"; import { createEventTransport } from "../lib/event-transport"; -import { setApiBaseUrl } from "../lib/api-client"; -type DaemonStatus = Awaited>; +const STATUS_REFRESH_MS = 2_000; +const READY_STATUS_REFRESH_MS = 10_000; export function useDaemonStatus(queryClient: QueryClient = defaultQueryClient) { const [status, setStatus] = useState({ state: "stopped" }); + const statusRef = useRef(status); useEffect(() => { let active = true; let stopTransport: () => void = () => undefined; + let refreshTimer: ReturnType | undefined; + let statusVersion = 0; + + const clearRefresh = () => { + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = undefined; + } + }; + + const refreshStatus = () => { + clearRefresh(); + const requestVersion = ++statusVersion; + void readDaemonStatus() + .then((nextStatus) => { + if (active && requestVersion === statusVersion) applyStatus(nextStatus); + }) + .catch(() => { + // IPC unavailable (browser preview, broken preload): stay on the + // last known status and keep the recovery loop alive. + }) + .finally(() => { + if (!active || requestVersion !== statusVersion) return; + scheduleRefresh(statusRef.current.state === "ready" ? READY_STATUS_REFRESH_MS : STATUS_REFRESH_MS); + }); + }; + + const scheduleRefresh = (delayMs = STATUS_REFRESH_MS) => { + if (refreshTimer || !active) return; + refreshTimer = setTimeout(refreshStatus, delayMs); + }; + const applyStatus = (nextStatus: DaemonStatus) => { // Only point REST at the new port; the workspace refetch is the event // transport's job (it invalidates, debounced, on every daemon status). - if (nextStatus.port) { - setApiBaseUrl(`http://127.0.0.1:${nextStatus.port}`); + statusRef.current = nextStatus; + if (nextStatus.state === "ready" && nextStatus.port) { + applyDaemonStatus(nextStatus); + clearRefresh(); + scheduleRefresh(READY_STATUS_REFRESH_MS); + } else { + applyDaemonStatus(nextStatus); + scheduleRefresh(); } setStatus(nextStatus); }; - void aoBridge.daemon - .getStatus() - .then((nextStatus) => { - if (active) applyStatus(nextStatus); - }) - .catch(() => { - // IPC unavailable (browser preview, broken preload): stay "stopped"; - // REST against the default base URL still works where it can. - }) - .then(() => { - if (active) stopTransport = createEventTransport(queryClient).connect(); - }); - - const stopStatusListener = aoBridge.daemon.onStatus(applyStatus); + refreshStatus(); + const refreshOnFocus = () => { + refreshStatus(); + }; + const refreshOnVisibility = () => { + if (document.visibilityState === "visible") refreshOnFocus(); + }; + window.addEventListener("focus", refreshOnFocus); + document.addEventListener("visibilitychange", refreshOnVisibility); + + void Promise.resolve().then(() => { + if (active) stopTransport = createEventTransport(queryClient).connect(); + }); + + const stopStatusListener = aoBridge.daemon.onStatus((nextStatus) => { + statusVersion += 1; + applyStatus(nextStatus); + }); return () => { active = false; + clearRefresh(); + window.removeEventListener("focus", refreshOnFocus); + document.removeEventListener("visibilitychange", refreshOnVisibility); stopTransport(); stopStatusListener(); }; diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx index d8efca52..51dfeef9 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx +++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx @@ -252,13 +252,12 @@ describe("useTerminalSession", () => { it("waits for daemon readiness instead of retrying, then reconnects when it flips", () => { const { view, muxes } = setup({ daemonReady: false }); - act(() => muxes[0].emitConnection("closed")); expect(view.result.current.state).toBe("reattaching"); act(() => void vi.advanceTimersByTime(60_000)); - expect(muxes).toHaveLength(1); // no retries against a dead daemon + expect(muxes).toHaveLength(0); // no initial attach or retries against a dead daemon view.rerender({ daemonReady: true }); - expect(muxes).toHaveLength(2); // reconnects immediately, without backoff debt - act(() => muxes[1].emitOpened("handle-1")); + expect(muxes).toHaveLength(1); // connects immediately, without backoff debt + act(() => muxes[0].emitOpened("handle-1")); expect(view.result.current.state).toBe("attached"); }); diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts index 8829c70b..71136b96 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.ts +++ b/frontend/src/renderer/hooks/useTerminalSession.ts @@ -218,8 +218,12 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option r.firstAttach = true; setError(undefined); if (handle) { - transition("connecting"); - connect(); + if (optionsRef.current.daemonReady) { + transition("connecting"); + connect(); + } else { + transition("reattaching"); + } } else { transition("idle"); } diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 309e60ec..0966e430 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -3,10 +3,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; -const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() })); +const { getMock, hasTrustedApiBaseUrlMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + hasTrustedApiBaseUrlMock: vi.fn(() => true), +})); vi.mock("../lib/api-client", () => ({ apiClient: { GET: getMock }, + hasTrustedApiBaseUrl: hasTrustedApiBaseUrlMock, })); import { useWorkspaceQuery } from "./useWorkspaceQuery"; @@ -30,9 +34,20 @@ function respondWith(payload: { beforeEach(() => { getMock.mockReset(); + hasTrustedApiBaseUrlMock.mockReset().mockReturnValue(true); }); describe("useWorkspaceQuery", () => { + it("returns an empty workspace list while the daemon base URL is untrusted", async () => { + hasTrustedApiBaseUrlMock.mockReturnValue(false); + + const { result } = renderHook(() => useWorkspaceQuery(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + expect(getMock).not.toHaveBeenCalled(); + }); + it("maps projects and their sessions, applying provider/status/title fallbacks", async () => { respondWith({ projects: { diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index 0b6b4776..f6022b21 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import type { components } from "../../api/schema"; -import { apiClient } from "../lib/api-client"; +import { apiClient, hasTrustedApiBaseUrl } from "../lib/api-client"; import { mockWorkspaces } from "../lib/mock-data"; import { type PRState, @@ -30,6 +30,9 @@ async function fetchWorkspaces(): Promise { if (usePreviewData) { return mockWorkspaces; } + if (!hasTrustedApiBaseUrl()) { + return []; + } const [{ data: projectsData, error: projectsError }, { data: sessionsData, error: sessionsError }] = await Promise.all([apiClient.GET("/api/v1/projects"), apiClient.GET("/api/v1/sessions")]); diff --git a/frontend/src/renderer/lib/api-client.test.ts b/frontend/src/renderer/lib/api-client.test.ts index 1d5d35b3..f72096dd 100644 --- a/frontend/src/renderer/lib/api-client.test.ts +++ b/frontend/src/renderer/lib/api-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { apiClient, getApiBaseUrl, setApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; +import { apiClient, getApiBaseUrl, hasTrustedApiBaseUrl, setApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; describe("apiClient runtime base URL", () => { afterEach(() => { @@ -100,6 +100,19 @@ describe("apiClient runtime base URL", () => { expect(seen).toHaveLength(1); expect(seen[0].url).toContain("/api/v1/projects"); }); + + it("returns unavailable without fetching when the daemon base URL is untrusted", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + setApiBaseUrl(null); + + const { error } = await apiClient.GET("/api/v1/projects"); + + expect(error).toEqual({ message: "AO daemon is not ready." }); + expect(getApiBaseUrl()).toBe(""); + expect(hasTrustedApiBaseUrl()).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); describe("subscribeApiBaseUrl", () => { diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index c2bd44d1..9afdf193 100644 --- a/frontend/src/renderer/lib/api-client.ts +++ b/frontend/src/renderer/lib/api-client.ts @@ -5,15 +5,19 @@ function devApiBaseUrl(): string { return typeof window === "undefined" ? "http://127.0.0.1:3001" : window.location.origin; } -const initialApiBaseUrl = - import.meta.env.VITE_AO_API_BASE_URL ?? (import.meta.env.DEV ? devApiBaseUrl() : "http://127.0.0.1:3001"); +const explicitApiBaseUrl = import.meta.env.VITE_AO_API_BASE_URL; +const initialApiBaseUrl = explicitApiBaseUrl ?? (import.meta.env.DEV ? devApiBaseUrl() : "http://127.0.0.1:3001"); -let runtimeApiBaseUrl = initialApiBaseUrl; +let runtimeApiBaseUrl: string | null = explicitApiBaseUrl ?? null; const baseUrlListeners = new Set<() => void>(); export function getApiBaseUrl(): string { - return runtimeApiBaseUrl; + return runtimeApiBaseUrl ?? ""; +} + +export function hasTrustedApiBaseUrl(): boolean { + return runtimeApiBaseUrl !== null; } /** @@ -28,15 +32,21 @@ export function subscribeApiBaseUrl(listener: () => void): () => void { }; } -export function setApiBaseUrl(nextBaseUrl: string): void { - const normalized = nextBaseUrl.replace(/\/+$/, ""); +export function setApiBaseUrl(nextBaseUrl: string | null): void { + const normalized = (nextBaseUrl ?? explicitApiBaseUrl ?? null)?.replace(/\/+$/, "") ?? null; if (normalized === runtimeApiBaseUrl) return; runtimeApiBaseUrl = normalized; baseUrlListeners.forEach((listener) => listener()); } async function runtimeFetch(input: Request): Promise { - const baseUrl = getApiBaseUrl(); + const baseUrl = runtimeApiBaseUrl; + if (baseUrl === null) { + return new Response(JSON.stringify({ message: "AO daemon is not ready." }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + } if (!baseUrl) { return fetch(input); } diff --git a/frontend/src/renderer/lib/daemon-status.ts b/frontend/src/renderer/lib/daemon-status.ts new file mode 100644 index 00000000..dcfc96ad --- /dev/null +++ b/frontend/src/renderer/lib/daemon-status.ts @@ -0,0 +1,22 @@ +import { aoBridge } from "./bridge"; +import { setApiBaseUrl } from "./api-client"; + +export type DaemonStatus = Awaited>; + +export function applyDaemonStatus(nextStatus: DaemonStatus): void { + if (nextStatus.state === "ready" && nextStatus.port) { + setApiBaseUrl(`http://127.0.0.1:${nextStatus.port}`); + } else { + setApiBaseUrl(null); + } +} + +export async function refreshDaemonStatus(): Promise { + const nextStatus = await readDaemonStatus(); + applyDaemonStatus(nextStatus); + return nextStatus; +} + +export function readDaemonStatus(): Promise { + return aoBridge.daemon.getStatus(); +} diff --git a/frontend/src/renderer/lib/event-transport.test.ts b/frontend/src/renderer/lib/event-transport.test.ts index f69f4186..90a59900 100644 --- a/frontend/src/renderer/lib/event-transport.test.ts +++ b/frontend/src/renderer/lib/event-transport.test.ts @@ -1,13 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { onStatusMock, removeStatusMock, getApiBaseUrlMock, subscribeApiBaseUrlMock, unsubscribeBaseUrlMock } = - vi.hoisted(() => ({ - onStatusMock: vi.fn(), - removeStatusMock: vi.fn(), - getApiBaseUrlMock: vi.fn(() => "http://127.0.0.1:3001"), - subscribeApiBaseUrlMock: vi.fn(), - unsubscribeBaseUrlMock: vi.fn(), - })); +const { + onStatusMock, + removeStatusMock, + getApiBaseUrlMock, + hasTrustedApiBaseUrlMock, + subscribeApiBaseUrlMock, + unsubscribeBaseUrlMock, +} = vi.hoisted(() => ({ + onStatusMock: vi.fn(), + removeStatusMock: vi.fn(), + getApiBaseUrlMock: vi.fn(() => "http://127.0.0.1:3001"), + hasTrustedApiBaseUrlMock: vi.fn(() => true), + subscribeApiBaseUrlMock: vi.fn(), + unsubscribeBaseUrlMock: vi.fn(), +})); vi.mock("./bridge", () => ({ aoBridge: { @@ -17,6 +24,7 @@ vi.mock("./bridge", () => ({ vi.mock("./api-client", () => ({ getApiBaseUrl: getApiBaseUrlMock, + hasTrustedApiBaseUrl: hasTrustedApiBaseUrlMock, subscribeApiBaseUrl: subscribeApiBaseUrlMock, })); @@ -54,6 +62,7 @@ beforeEach(() => { onStatusMock.mockReset().mockReturnValue(removeStatusMock); removeStatusMock.mockReset(); getApiBaseUrlMock.mockReset().mockReturnValue("http://127.0.0.1:3001"); + hasTrustedApiBaseUrlMock.mockReset().mockReturnValue(true); subscribeApiBaseUrlMock.mockReset().mockReturnValue(unsubscribeBaseUrlMock); unsubscribeBaseUrlMock.mockReset(); setEventsConnectionState("idle"); @@ -97,6 +106,19 @@ describe("createEventTransport", () => { expect(EventSourceStub.instances[1].url).toBe("http://127.0.0.1:3099/api/v1/events"); }); + it("closes the source and skips reconnecting when the base URL is untrusted", () => { + createEventTransport(fakeQueryClient()).connect(); + const first = EventSourceStub.instances[0]; + const onStatusHandler = onStatusMock.mock.calls[0][0] as () => void; + + hasTrustedApiBaseUrlMock.mockReturnValue(false); + onStatusHandler(); + + expect(first.closed).toBe(true); + expect(EventSourceStub.instances).toHaveLength(1); + expect(getEventsConnectionState()).toBe("disconnected"); + }); + it("debounces a workspace invalidation after a status change", () => { vi.useFakeTimers(); try { diff --git a/frontend/src/renderer/lib/event-transport.ts b/frontend/src/renderer/lib/event-transport.ts index 6607b66f..feb9f138 100644 --- a/frontend/src/renderer/lib/event-transport.ts +++ b/frontend/src/renderer/lib/event-transport.ts @@ -1,6 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { aoBridge } from "./bridge"; -import { getApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; +import { getApiBaseUrl, hasTrustedApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; import { setEventsConnectionState } from "./events-connection"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; @@ -64,6 +64,13 @@ export function createEventTransport(queryClient: QueryClient): EventTransport { const connectSource = () => { // EventSource is unavailable in jsdom (tests) and some preview surfaces; guard it. if (typeof EventSource === "undefined") return; + if (!hasTrustedApiBaseUrl()) { + source?.close(); + source = undefined; + sourceBaseUrl = undefined; + setEventsConnectionState("disconnected"); + return; + } const baseUrl = getApiBaseUrl(); // Keep a still-usable source on the same base URL; replace one the // browser abandoned (CLOSED) or one bound to a stale port. diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index ed395722..4c5a7518 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -8,6 +8,7 @@ import { TitlebarNav } from "../components/TitlebarNav"; import { useDaemonStatus } from "../hooks/useDaemonStatus"; import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { refreshDaemonStatus } from "../lib/daemon-status"; import { captureRendererEvent, captureRendererException } from "../lib/telemetry"; import { ShellProvider } from "../lib/shell-context"; import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store"; @@ -17,7 +18,10 @@ export const Route = createFileRoute("/_shell")({ // Prefetch the workspace list for the whole shell (parent loaders run before // children); pairs with the router's defaultPreload: "intent" so a hovered // nav target is warm before the click. - loader: ({ context }) => context.queryClient.ensureQueryData(workspaceQueryOptions), + loader: async ({ context }) => { + await refreshDaemonStatus().catch(() => undefined); + return context.queryClient.ensureQueryData(workspaceQueryOptions); + }, component: ShellLayout, }); diff --git a/frontend/src/shared/daemon-status.ts b/frontend/src/shared/daemon-status.ts index d980e220..4476d6ff 100644 --- a/frontend/src/shared/daemon-status.ts +++ b/frontend/src/shared/daemon-status.ts @@ -4,5 +4,8 @@ export type DaemonStatus = { state: "starting" | "ready" | "stopped" | "error"; port?: number; + pid?: number; + executablePath?: string; + workingDirectory?: string; message?: string; };