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.
This commit is contained in:
Mohamed Boudra
2026-04-03 19:43:56 +07:00
parent 23ffec5072
commit 29037abd54
6 changed files with 81 additions and 128 deletions
@@ -21,7 +21,7 @@ import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { import {
getCliDaemonStatus, getCliDaemonStatus,
getCliSymlinkInstructions, getCliSymlinkInstructions,
@@ -72,11 +72,11 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
if (!showSection) { if (!showSection) {
return Promise.resolve(); return Promise.resolve();
} }
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()]) return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
.then(([status, logs, version]) => { .then(([status, logs]) => {
setDaemonStatus(status); setDaemonStatus(status);
setDaemonLogs(logs); setDaemonLogs(logs);
setDaemonVersion(version.version); setDaemonVersion(status.version);
setStatusError(null); setStatusError(null);
}) })
.catch((error) => { .catch((error) => {
@@ -10,6 +10,8 @@ export type DesktopDaemonStatus = {
hostname: string | null; hostname: string | null;
pid: number | null; pid: number | null;
home: string; home: string;
version: string | null;
desktopManaged: boolean;
error: string | null; error: string | null;
}; };
@@ -86,6 +88,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
hostname: toStringOrNull(raw.hostname), hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid), pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "", home: toStringOrNull(raw.home) ?? "",
version: toStringOrNull(raw.version),
desktopManaged: raw.desktopManaged === true,
error: toStringOrNull(raw.error), error: toStringOrNull(raw.error),
}; };
} }
@@ -21,6 +21,7 @@ export interface LocalDaemonPidInfo {
hostname?: string; hostname?: string;
uid?: number; uid?: number;
listen?: string; listen?: string;
desktopManaged?: boolean;
} }
export interface LocalDaemonState { export interface LocalDaemonState {
@@ -173,6 +174,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
: typeof parsed.sockPath === "string" : typeof parsed.sockPath === "string"
? parsed.sockPath ? parsed.sockPath
: undefined, : undefined,
desktopManaged: parsed.desktopManaged === true ? true : undefined,
}; };
} catch { } catch {
return null; return null;
@@ -34,6 +34,8 @@ interface DaemonStatus {
daemonNode: string; daemonNode: string;
cliNode: string; cliNode: string;
cliVersion: string; cliVersion: string;
daemonVersion: string | null;
desktopManaged: boolean;
providers: ProviderBinaryStatus[]; providers: ProviderBinaryStatus[];
note?: string; note?: string;
} }
@@ -129,6 +131,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
{ key: "Daemon Node", value: status.daemonNode }, { key: "Daemon Node", value: status.daemonNode },
{ key: "CLI Node", value: status.cliNode }, { key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion }, { key: "CLI", value: status.cliVersion },
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
]; ];
if (status.runningAgents !== null && status.idleAgents !== null) { if (status.runningAgents !== null && status.idleAgents !== null) {
@@ -229,6 +232,7 @@ export async function runStatusCommand(
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed"; let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let runningAgents: number | null = null; let runningAgents: number | null = null;
let idleAgents: number | null = null; let idleAgents: number | null = null;
let daemonVersion: string | null = null;
let note: string | undefined; let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) { if (!state.running && state.stalePidFile && state.pidInfo) {
@@ -240,6 +244,7 @@ export async function runStatusCommand(
const client = await tryConnectToDaemon({ host, timeout: 1500 }); const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) { if (client) {
connectedDaemon = "reachable"; connectedDaemon = "reachable";
daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
try { try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent); const agents = agentsPayload.entries.map((entry) => entry.agent);
@@ -308,6 +313,8 @@ export async function runStatusCommand(
daemonNode, daemonNode,
cliNode, cliNode,
cliVersion, cliVersion,
daemonVersion,
desktopManaged: state.pidInfo?.desktopManaged === true,
providers, providers,
note, note,
}; };
+62 -124
View File
@@ -1,9 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process"; 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 path from "node:path";
import { app, ipcMain } from "electron"; import { app, ipcMain } from "electron";
import log from "electron-log/main"; import log from "electron-log/main";
import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server"; import { resolvePaseoHome } from "@getpaseo/server";
import { import {
copyAttachmentFileToManagedStorage, copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile, deleteManagedAttachmentFile,
@@ -25,7 +25,6 @@ import {
} from "./runtime-paths.js"; } from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log"; const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
const PID_POLL_INTERVAL_MS = 100; const PID_POLL_INTERVAL_MS = 100;
const STARTUP_POLL_INTERVAL_MS = 200; const STARTUP_POLL_INTERVAL_MS = 200;
const STARTUP_POLL_MAX_ATTEMPTS = 150; const STARTUP_POLL_MAX_ATTEMPTS = 150;
@@ -42,6 +41,8 @@ type DesktopDaemonStatus = {
hostname: string | null; hostname: string | null;
pid: number | null; pid: number | null;
home: string; home: string;
version: string | null;
desktopManaged: boolean;
error: string | null; error: string | null;
}; };
@@ -72,10 +73,6 @@ function getPaseoHome(): string {
return resolvePaseoHome(process.env); return resolvePaseoHome(process.env);
} }
function pidFilePath(): string {
return path.join(getPaseoHome(), DAEMON_PID_FILENAME);
}
function logFilePath(): string { function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME); return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
} }
@@ -165,41 +162,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); 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 { function resolveDesktopAppVersion(): string {
if (app.isPackaged) { if (app.isPackaged) {
return app.getVersion(); return app.getVersion();
@@ -226,55 +188,64 @@ function resolveDesktopAppVersion(): string {
function resolveStatus(): DesktopDaemonStatus { function resolveStatus(): DesktopDaemonStatus {
const home = getPaseoHome(); const home = getPaseoHome();
const pidPath = pidFilePath();
let pid: number | null = null;
let hostname: string | null = null;
let listen: string | null = null;
try { try {
if (existsSync(pidPath)) { const payload = runCliJsonCommand(["daemon", "status", "--json"]) as Record<string, unknown>;
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>; const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
const pidValue = parsed.pid; const running = localDaemon === "running";
if (typeof pidValue === "number" && Number.isInteger(pidValue) && pidValue > 0) {
pid = pidValue; return {
hostname = typeof parsed.hostname === "string" ? parsed.hostname : null; serverId: typeof payload.serverId === "string" ? payload.serverId : "",
const pidListen = status: running ? "running" : "stopped",
typeof parsed.listen === "string" listen: typeof payload.listen === "string" ? payload.listen : null,
? parsed.listen hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
: typeof parsed.sockPath === "string" pid: running && typeof payload.pid === "number" ? payload.pid : null,
? (parsed.sockPath as string) home,
: null; version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
listen = pidListen; desktopManaged: payload.desktopManaged === true,
} error: null,
} };
} catch { } catch (error) {
// PID file missing or malformed — treat as stopped. 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); function normalizeVersion(version: string | null): string | null {
const trimmed = version?.trim();
let serverId = ""; if (!trimmed) return null;
try { return trimmed.replace(/^v/i, "");
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,
};
} }
async function startDaemon(): Promise<DesktopDaemonStatus> { async function startDaemon(): Promise<DesktopDaemonStatus> {
const current = resolveStatus(); 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 daemonRunner = resolveDaemonRunnerEntrypoint();
const invocation = createNodeEntrypointInvocation({ const invocation = createNodeEntrypointInvocation({
@@ -297,7 +268,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
invocation.args, invocation.args,
{ {
detached: true, detached: true,
env: invocation.env, env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "ignore", "ignore"], stdio: ["ignore", "ignore", "ignore"],
}, },
); );
@@ -433,51 +404,18 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
} }
} }
async function getLocalDaemonVersion(): Promise<{ function getLocalDaemonVersion(): { version: string | null; error: string | null } {
version: string | null;
error: string | null;
}> {
const status = resolveStatus(); const status = resolveStatus();
if (status.status !== "running") { if (status.status !== "running") {
return { return { version: null, error: "Daemon is not running." };
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<string, unknown>;
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: status.version,
error: status.version ? null : "Running daemon did not report a version.",
};
} }
async function resolveCurrentUpdateVersion(): Promise<string> { function resolveCurrentUpdateVersion(): string {
const daemonVersion = await getLocalDaemonVersion();
if (daemonVersion.version) {
return daemonVersion.version;
}
return resolveDesktopAppVersion(); return resolveDesktopAppVersion();
} }
+2
View File
@@ -9,6 +9,7 @@ export interface PidLockInfo {
hostname: string; hostname: string;
uid: number; uid: number;
listen: string | null; listen: string | null;
desktopManaged?: boolean;
} }
export class PidLockError extends Error { export class PidLockError extends Error {
@@ -86,6 +87,7 @@ export async function acquirePidLock(
hostname: hostname(), hostname: hostname(),
uid: process.getuid?.() ?? 0, uid: process.getuid?.() ?? 0,
listen, listen,
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
}; };
let fd; let fd;