From 29037abd541acf0fca33d3aa306977c89b5d8511 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 19:43:56 +0700 Subject: [PATCH] feat(desktop): auto-restart desktop-managed daemon on version mismatch When the desktop app starts and finds a running daemon with a different version, it now restarts it automatically. Only applies to daemons the desktop app itself started, identified by a PASEO_DESKTOP_MANAGED flag written into the PID lock file. Also rewrites resolveStatus() to use the CLI daemon status command instead of manually parsing the PID file, and surfaces daemonVersion in the CLI status output from the WebSocket hello message. --- .../components/desktop-updates-section.tsx | 8 +- .../app/src/desktop/daemon/desktop-daemon.ts | 4 + .../cli/src/commands/daemon/local-daemon.ts | 2 + packages/cli/src/commands/daemon/status.ts | 7 + packages/desktop/src/daemon/daemon-manager.ts | 186 ++++++------------ packages/server/src/server/pid-lock.ts | 2 + 6 files changed, 81 insertions(+), 128 deletions(-) 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;