diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 01fef73e9..c0678edcd 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -21,7 +21,7 @@ import { Button } from "@/components/ui/button"; import { useAppSettings } from "@/hooks/use-settings"; import { confirmDialog } from "@/utils/confirm-dialog"; import { openExternalUrl } from "@/utils/open-external-url"; -import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates"; +import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, getCliSymlinkInstructions, @@ -72,11 +72,11 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD if (!showSection) { return Promise.resolve(); } - return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()]) - .then(([status, logs, version]) => { + return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]) + .then(([status, logs]) => { setDaemonStatus(status); setDaemonLogs(logs); - setDaemonVersion(version.version); + setDaemonVersion(status.version); setStatusError(null); }) .catch((error) => { diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index a88beca23..5fcc61a59 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -10,6 +10,8 @@ export type DesktopDaemonStatus = { hostname: string | null; pid: number | null; home: string; + version: string | null; + desktopManaged: boolean; error: string | null; }; @@ -86,6 +88,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus { hostname: toStringOrNull(raw.hostname), pid: toNumberOrNull(raw.pid), home: toStringOrNull(raw.home) ?? "", + version: toStringOrNull(raw.version), + desktopManaged: raw.desktopManaged === true, error: toStringOrNull(raw.error), }; } diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index c2e39e513..957ca64ce 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -21,6 +21,7 @@ export interface LocalDaemonPidInfo { hostname?: string; uid?: number; listen?: string; + desktopManaged?: boolean; } export interface LocalDaemonState { @@ -173,6 +174,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null { : typeof parsed.sockPath === "string" ? parsed.sockPath : undefined, + desktopManaged: parsed.desktopManaged === true ? true : undefined, }; } catch { return null; diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index d37194100..1029b53d3 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -34,6 +34,8 @@ interface DaemonStatus { daemonNode: string; cliNode: string; cliVersion: string; + daemonVersion: string | null; + desktopManaged: boolean; providers: ProviderBinaryStatus[]; note?: string; } @@ -129,6 +131,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] { { key: "Daemon Node", value: status.daemonNode }, { key: "CLI Node", value: status.cliNode }, { key: "CLI", value: status.cliVersion }, + { key: "Daemon Version", value: status.daemonVersion ?? "-" }, ]; if (status.runningAgents !== null && status.idleAgents !== null) { @@ -229,6 +232,7 @@ export async function runStatusCommand( let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed"; let runningAgents: number | null = null; let idleAgents: number | null = null; + let daemonVersion: string | null = null; let note: string | undefined; if (!state.running && state.stalePidFile && state.pidInfo) { @@ -240,6 +244,7 @@ export async function runStatusCommand( const client = await tryConnectToDaemon({ host, timeout: 1500 }); if (client) { connectedDaemon = "reachable"; + daemonVersion = client.getLastServerInfoMessage()?.version ?? null; try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); const agents = agentsPayload.entries.map((entry) => entry.agent); @@ -308,6 +313,8 @@ export async function runStatusCommand( daemonNode, cliNode, cliVersion, + daemonVersion, + desktopManaged: state.pidInfo?.desktopManaged === true, providers, note, }; diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 056ab5f4a..98e48f4ea 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,9 +1,9 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain } from "electron"; import log from "electron-log/main"; -import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server"; +import { resolvePaseoHome } from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -25,7 +25,6 @@ import { } from "./runtime-paths.js"; const DAEMON_LOG_FILENAME = "daemon.log"; -const DAEMON_PID_FILENAME = "paseo.pid"; const PID_POLL_INTERVAL_MS = 100; const STARTUP_POLL_INTERVAL_MS = 200; const STARTUP_POLL_MAX_ATTEMPTS = 150; @@ -42,6 +41,8 @@ type DesktopDaemonStatus = { hostname: string | null; pid: number | null; home: string; + version: string | null; + desktopManaged: boolean; error: string | null; }; @@ -72,10 +73,6 @@ function getPaseoHome(): string { return resolvePaseoHome(process.env); } -function pidFilePath(): string { - return path.join(getPaseoHome(), DAEMON_PID_FILENAME); -} - function logFilePath(): string { return path.join(getPaseoHome(), DAEMON_LOG_FILENAME); } @@ -165,41 +162,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function resolveTcpHostFromListen(listen: string): string | null { - const normalized = listen.trim(); - if (!normalized) { - return null; - } - - if ( - normalized.startsWith("/") || - normalized.startsWith("unix://") || - normalized.startsWith("pipe://") || - normalized.startsWith("\\\\.\\pipe\\") || - /^[A-Za-z]:[/\\]/.test(normalized) - ) { - return null; - } - - if (/^\d+$/.test(normalized)) { - return `127.0.0.1:${normalized}`; - } - - if (normalized.includes(":")) { - return normalized; - } - - return null; -} - -function buildDaemonHttpBaseUrl(listen: string): string | null { - const endpoint = resolveTcpHostFromListen(listen); - if (!endpoint) { - return null; - } - return new URL(`http://${endpoint}`).toString().replace(/\/$/, ""); -} - function resolveDesktopAppVersion(): string { if (app.isPackaged) { return app.getVersion(); @@ -226,55 +188,64 @@ function resolveDesktopAppVersion(): string { function resolveStatus(): DesktopDaemonStatus { const home = getPaseoHome(); - const pidPath = pidFilePath(); - - let pid: number | null = null; - let hostname: string | null = null; - let listen: string | null = null; try { - if (existsSync(pidPath)) { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record; - const pidValue = parsed.pid; - if (typeof pidValue === "number" && Number.isInteger(pidValue) && pidValue > 0) { - pid = pidValue; - hostname = typeof parsed.hostname === "string" ? parsed.hostname : null; - const pidListen = - typeof parsed.listen === "string" - ? parsed.listen - : typeof parsed.sockPath === "string" - ? (parsed.sockPath as string) - : null; - listen = pidListen; - } - } - } catch { - // PID file missing or malformed — treat as stopped. + const payload = runCliJsonCommand(["daemon", "status", "--json"]) as Record; + const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; + const running = localDaemon === "running"; + + return { + serverId: typeof payload.serverId === "string" ? payload.serverId : "", + status: running ? "running" : "stopped", + listen: typeof payload.listen === "string" ? payload.listen : null, + hostname: running && typeof payload.hostname === "string" ? payload.hostname : null, + pid: running && typeof payload.pid === "number" ? payload.pid : null, + home, + version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null, + desktopManaged: payload.desktopManaged === true, + error: null, + }; + } catch (error) { + return { + serverId: "", + status: "stopped", + listen: null, + hostname: null, + pid: null, + home, + version: null, + desktopManaged: false, + error: error instanceof Error ? error.message : String(error), + }; } +} - const running = pid !== null && isProcessRunning(pid); - - let serverId = ""; - try { - serverId = getOrCreateServerId(home); - } catch { - // Ignore — server-id may not exist yet. - } - - return { - serverId, - status: running ? "running" : "stopped", - listen, - hostname: running ? hostname : null, - pid: running ? pid : null, - home, - error: null, - }; +function normalizeVersion(version: string | null): string | null { + const trimmed = version?.trim(); + if (!trimmed) return null; + return trimmed.replace(/^v/i, ""); } async function startDaemon(): Promise { const current = resolveStatus(); - if (current.status === "running") return current; + if (current.status === "running") { + const appVersion = normalizeVersion(resolveDesktopAppVersion()); + const daemonVersion = normalizeVersion(current.version); + if ( + current.desktopManaged && + appVersion && + daemonVersion && + appVersion !== daemonVersion + ) { + logDesktopDaemonLifecycle("daemon version mismatch, restarting", { + appVersion, + daemonVersion, + }); + await stopDaemon(); + } else { + return current; + } + } const daemonRunner = resolveDaemonRunnerEntrypoint(); const invocation = createNodeEntrypointInvocation({ @@ -297,7 +268,7 @@ async function startDaemon(): Promise { invocation.args, { detached: true, - env: invocation.env, + env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" }, stdio: ["ignore", "ignore", "ignore"], }, ); @@ -433,51 +404,18 @@ async function getDaemonPairing(): Promise { } } -async function getLocalDaemonVersion(): Promise<{ - version: string | null; - error: string | null; -}> { +function getLocalDaemonVersion(): { version: string | null; error: string | null } { const status = resolveStatus(); if (status.status !== "running") { - return { - version: null, - error: "Daemon is not running.", - }; - } - - if (!status.listen) { - return { version: null, error: "Daemon listen target is unavailable." }; - } - - const baseUrl = buildDaemonHttpBaseUrl(status.listen); - if (!baseUrl) { - return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` }; - } - - try { - const response = await fetch(`${baseUrl}/api/status`); - if (!response.ok) { - return { version: null, error: `Daemon status request failed with ${response.status}` }; - } - const payload = (await response.json()) as Record; - const version = typeof payload.version === "string" ? payload.version.trim() : null; - return { - version: version && version.length > 0 ? version : null, - error: version ? null : "Running daemon did not report a version.", - }; - } catch (error) { - return { - version: null, - error: error instanceof Error ? error.message : String(error), - }; + return { version: null, error: "Daemon is not running." }; } + return { + version: status.version, + error: status.version ? null : "Running daemon did not report a version.", + }; } -async function resolveCurrentUpdateVersion(): Promise { - const daemonVersion = await getLocalDaemonVersion(); - if (daemonVersion.version) { - return daemonVersion.version; - } +function resolveCurrentUpdateVersion(): string { return resolveDesktopAppVersion(); } diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 43563a9da..286ee1218 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -9,6 +9,7 @@ export interface PidLockInfo { hostname: string; uid: number; listen: string | null; + desktopManaged?: boolean; } export class PidLockError extends Error { @@ -86,6 +87,7 @@ export async function acquirePidLock( hostname: hostname(), uid: process.getuid?.() ?? 0, listen, + ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; let fd;