feat: add remote daemon self-update from client (#1513)

* feat: add remote daemon self-update from client

Adds a daemon.update.request RPC that lets the client trigger a remote
daemon update over WebSocket. The daemon runs npm update -g @getpaseo/cli,
reports progress via daemon_update_progress status events, then restarts
through the supervisor to load the new code.

New feature flag server_info.features.daemonSelfUpdate gates the UI.
The UpdateDaemonCard appears on the host settings page when the daemon
supports self-update and the app/daemon versions differ.

* fix: clean up progress subscription on unmount, use typed phase cast

Address Greptile review:
- P1: store unsubscribe ref and clean up on component unmount to prevent
  listener leak during in-flight update
- P2: use DaemonUpdateProgressStatusPayload type instead of { phase?: string }

* fix: use npm install@latest and global concurrency guard

Address Greptile review:
- npm update -g can silently exit 0 without resolving major-version
  gaps; switch to npm install -g @getpaseo/cli@latest
- Move daemonUpdateInProgress from per-session instance to module-level
  variable so concurrent clients share the same guard

* feat(daemon): harden remote self-update

Probe the global npm CLI install before updating, keep request handling in a daemon self-update session controller, and emit scoped daemon.update.progress messages.

* test(daemon): make self-update paths platform-stable

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Davy
2026-06-26 16:13:29 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 9ad49fca92
commit 40265782d4
26 changed files with 1536 additions and 8 deletions
@@ -1,6 +1,7 @@
import {
ArrowDown,
ArrowUp,
ArrowUpToLine,
ChevronRight,
Globe,
Monitor,
@@ -53,6 +54,8 @@ import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { confirmDialog } from "@/utils/confirm-dialog";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
import { formatLatency } from "@/utils/latency";
import { ICON_SIZE } from "@/styles/theme";
@@ -353,6 +356,8 @@ export function HostSettingsPage({
{isLocalDaemon ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
);
@@ -709,6 +714,214 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
);
}
function UpdateDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore();
const [isUpdating, setIsUpdating] = useState(false);
const [progressPhase, setProgressPhase] = useState<string | null>(null);
const isMountedRef = useRef(true);
const unsubscribeRef = useRef<(() => void) | null>(null);
const daemonVersion = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
);
const supportsSelfUpdate = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
);
const appVersion = resolveAppVersion();
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
useEffect(() => {
return () => {
isMountedRef.current = false;
unsubscribeRef.current?.();
};
}, []);
const isHostConnected = useCallback(
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
[host.serverId, runtime],
);
const hasReconnectedAfter = useCallback(
(startGeneration: number | null) => {
const snapshot = runtime.getSnapshot(host.serverId);
if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
return startGeneration === null || snapshot.clientGeneration !== startGeneration;
},
[host.serverId, runtime],
);
const waitForCondition = useCallback(
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isMountedRef.current) return false;
if (predicate()) return true;
await delay(intervalMs);
}
return predicate();
},
[],
);
const waitForDaemonRestart = useCallback(
async (startGeneration: number | null) => {
const disconnectTimeoutMs = 15000;
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
await waitForCondition(
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
disconnectTimeoutMs,
);
}
const reconnected =
hasReconnectedAfter(startGeneration) ||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs));
if (isMountedRef.current) {
setIsUpdating(false);
setProgressPhase(null);
if (!reconnected) {
Alert.alert(
t("settings.host.daemon.update.unableToReconnectTitle"),
t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }),
);
}
}
},
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
);
const handleUpdate = useCallback(() => {
if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.update.unavailableTitle"),
t("settings.host.daemon.update.unavailableMessage"),
);
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.update.offlineTitle"),
t("settings.host.daemon.update.offlineMessage"),
);
return;
}
void confirmDialog({
title: t("settings.host.daemon.update.confirmTitle", { name: host.label }),
message: t("settings.host.daemon.update.confirmMessage"),
confirmLabel: t("settings.host.daemon.update.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: false,
})
.then((confirmed) => {
if (!confirmed) return;
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
setIsUpdating(true);
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
const requestId = `settings_daemon_update_${host.serverId}`;
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
if (message.payload.requestId !== requestId) return;
if (!isMountedRef.current) return;
const { phase } = message.payload;
if (phase === "starting")
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
else if (phase === "downloading")
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
else if (phase === "installing")
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
else if (phase === "complete")
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
});
unsubscribeRef.current = unsubscribe;
void daemonClient
.updateDaemon(requestId)
.then((response) => {
unsubscribeRef.current = null;
unsubscribe();
if (!response.success) {
if (!isMountedRef.current) return undefined;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
error: response.error ?? "Unknown error",
}),
);
return undefined;
}
// Update succeeded — wait for daemon to restart and reconnect
void waitForDaemonRestart(startGeneration);
return undefined;
})
.catch((error) => {
unsubscribeRef.current = null;
unsubscribe();
console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
if (!isMountedRef.current) return;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
});
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.dialogFailedMessage"),
);
});
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
const updateIcon = useMemo(
() => <ArrowUpToLine size={theme.iconSize.sm} color={theme.colors.foreground} />,
[theme.iconSize.sm, theme.colors.foreground],
);
// Don't show if the daemon doesn't support self-update or versions match
if (!supportsSelfUpdate || !hasVersionMismatch) {
return null;
}
const buttonLabel = isUpdating
? (progressPhase ?? t("settings.host.daemon.update.updating"))
: t("settings.host.daemon.update.confirm");
return (
<View style={settingsStyles.card} testID="host-page-update-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.update.hint")}</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={updateIcon}
onPress={handleUpdate}
disabled={isUpdating || !daemonClient || !isConnected}
testID="host-page-update-button"
>
{buttonLabel}
</Button>
</View>
</View>
);
}
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);