mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
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.
This commit is contained in:
@@ -1745,6 +1745,30 @@ export const ar: TranslationResources = {
|
||||
"فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.",
|
||||
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "منطقة الخطر",
|
||||
remove: {
|
||||
title: "إزالة المضيف",
|
||||
|
||||
@@ -1753,6 +1753,30 @@ export const en = {
|
||||
"Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.",
|
||||
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "Danger zone",
|
||||
remove: {
|
||||
title: "Remove host",
|
||||
|
||||
@@ -1786,6 +1786,30 @@ export const es: TranslationResources = {
|
||||
dialogFailedMessage:
|
||||
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "Zona de peligro",
|
||||
remove: {
|
||||
title: "Eliminar host",
|
||||
|
||||
@@ -1791,6 +1791,30 @@ export const fr: TranslationResources = {
|
||||
dialogFailedMessage:
|
||||
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "Zone dangereuse",
|
||||
remove: {
|
||||
title: "Supprimer l'hôte",
|
||||
|
||||
@@ -1777,6 +1777,30 @@ export const ru: TranslationResources = {
|
||||
"Не удалось отправить запрос на перезапуск.Paseo автоматически повторно подключается. Повторите попытку, как только хост окажется в сети.",
|
||||
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "Опасная зона",
|
||||
remove: {
|
||||
title: "Удалить хост",
|
||||
|
||||
@@ -1722,6 +1722,30 @@ export const zhCN: TranslationResources = {
|
||||
requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。",
|
||||
dialogFailedMessage: "无法打开重启确认对话框。",
|
||||
},
|
||||
update: {
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
confirmTitle: "Update {{name}}",
|
||||
confirmMessage:
|
||||
"This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.",
|
||||
updating: "Updating...",
|
||||
phaseStarting: "Preparing update...",
|
||||
phaseDownloading: "Downloading packages...",
|
||||
phaseInstalling: "Installing...",
|
||||
phaseComplete: "Update complete, restarting...",
|
||||
unableToReconnectTitle: "Unable to reconnect",
|
||||
unableToReconnectMessage:
|
||||
"{{name}} did not come back online after updating. Please verify the daemon restarted.",
|
||||
unavailableTitle: "Host unavailable",
|
||||
unavailableMessage:
|
||||
"This host is not connected. Wait for it to come online before updating.",
|
||||
offlineTitle: "Host offline",
|
||||
offlineMessage: "This host is offline. Wait until it is back online before updating.",
|
||||
requestFailedTitle: "Update failed",
|
||||
requestFailedMessage: "Failed to update the daemon: {{error}}",
|
||||
dialogFailedMessage: "Unable to open the update confirmation dialog.",
|
||||
},
|
||||
dangerZone: "危险区域",
|
||||
remove: {
|
||||
title: "移除 Host",
|
||||
|
||||
@@ -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,193 @@ 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 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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isHostConnected = useCallback(
|
||||
() => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)),
|
||||
[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 () => {
|
||||
const disconnectTimeoutMs = 15000;
|
||||
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
|
||||
if (isHostConnected()) {
|
||||
await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
|
||||
}
|
||||
const reconnected = await waitForCondition(() => isHostConnected(), 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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [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;
|
||||
setIsUpdating(true);
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
|
||||
|
||||
// Subscribe to progress events
|
||||
const unsubscribe = daemonClient.subscribe((event) => {
|
||||
if (event.type !== "status") return;
|
||||
if (event.payload.status !== "daemon_update_progress") return;
|
||||
if (!isMountedRef.current) return;
|
||||
const phase = (event.payload as { phase?: string }).phase;
|
||||
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"));
|
||||
});
|
||||
|
||||
void daemonClient
|
||||
.updateDaemon(`settings_daemon_update_${host.serverId}`)
|
||||
.then((response) => {
|
||||
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();
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
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, 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);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RenameTerminalResponseSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
DaemonUpdateResponseSchema,
|
||||
SessionInboundMessageSchema,
|
||||
type ServerInfoStatusPayload,
|
||||
WSOutboundMessageSchema,
|
||||
@@ -468,6 +469,7 @@ export interface FetchAgentTimelineOptions {
|
||||
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>;
|
||||
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
|
||||
type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayloadSchema>;
|
||||
type DaemonUpdateResponse = z.infer<typeof DaemonUpdateResponseSchema>;
|
||||
type FetchAgentsPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_agents_response" }
|
||||
@@ -2636,6 +2638,30 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateDaemon(requestId?: string): Promise<DaemonUpdateResponse["payload"]> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "daemon.update.request",
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 300_000, // 5 minutes — npm update can be slow on remote machines
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
const parsed = DaemonUpdateResponseSchema.safeParse(msg);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.data.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return parsed.data.payload;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audio / Voice
|
||||
// ============================================================================
|
||||
|
||||
@@ -1252,6 +1252,11 @@ export const ShutdownServerRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const DaemonUpdateRequestMessageSchema = z.object({
|
||||
type: z.literal("daemon.update.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentTimelineCursorSchema = z.object({
|
||||
epoch: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
@@ -2053,6 +2058,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CancelAgentRequestMessageSchema,
|
||||
ShutdownServerRequestMessageSchema,
|
||||
RestartServerRequestMessageSchema,
|
||||
DaemonUpdateRequestMessageSchema,
|
||||
FetchAgentTimelineRequestMessageSchema,
|
||||
SetAgentModeRequestMessageSchema,
|
||||
SetAgentModelRequestMessageSchema,
|
||||
@@ -2327,6 +2333,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
agentDetach: z.boolean().optional(),
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: z.boolean().optional(),
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
daemonSelfUpdate: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -2415,6 +2423,16 @@ export const ShutdownRequestedStatusPayloadSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const DaemonUpdateProgressStatusPayloadSchema = z.object({
|
||||
status: z.literal("daemon_update_progress"),
|
||||
requestId: z.string(),
|
||||
phase: z.enum(["starting", "downloading", "installing", "complete"]),
|
||||
});
|
||||
|
||||
export type DaemonUpdateProgressStatusPayload = z.infer<
|
||||
typeof DaemonUpdateProgressStatusPayloadSchema
|
||||
>;
|
||||
|
||||
export const DaemonConfigChangedStatusPayloadSchema = z
|
||||
.object({
|
||||
status: z.literal("daemon_config_changed"),
|
||||
@@ -2429,6 +2447,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [
|
||||
AgentRefreshedStatusPayloadSchema,
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
DaemonUpdateProgressStatusPayloadSchema,
|
||||
DaemonConfigChangedStatusPayloadSchema,
|
||||
]);
|
||||
|
||||
@@ -4093,6 +4112,19 @@ export const TerminalAttentionRequiredSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const DaemonUpdateResponseSchema = z.object({
|
||||
type: z.literal("daemon.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
previousVersion: z.string().nullable(),
|
||||
newVersion: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DaemonUpdateResponse = z.infer<typeof DaemonUpdateResponseSchema>;
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ActivityLogMessageSchema,
|
||||
AssistantChunkMessageSchema,
|
||||
@@ -4228,6 +4260,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
LoopInspectResponseSchema,
|
||||
LoopLogsResponseSchema,
|
||||
LoopStopResponseSchema,
|
||||
DaemonUpdateResponseSchema,
|
||||
]);
|
||||
|
||||
export type SessionOutboundMessage = z.infer<typeof SessionOutboundMessageSchema>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import { basename, normalize, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
@@ -585,6 +586,7 @@ export class Session {
|
||||
private readonly daemonSession: DaemonSession;
|
||||
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||
private daemonUpdateInProgress = false;
|
||||
|
||||
constructor(options: SessionOptions) {
|
||||
const {
|
||||
@@ -1510,6 +1512,8 @@ export class Session {
|
||||
return this.daemonSession.handleGetPairingOfferRequest(msg);
|
||||
case "diagnostics.request":
|
||||
return this.daemonSession.handleDiagnosticsRequest(msg);
|
||||
case "daemon.update.request":
|
||||
return this.handleDaemonUpdateRequest(msg);
|
||||
case "set_daemon_config_request":
|
||||
this.emit({
|
||||
type: "set_daemon_config_response",
|
||||
@@ -1777,6 +1781,133 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleDaemonUpdateRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "daemon.update.request" }>,
|
||||
): Promise<void> {
|
||||
if (this.daemonUpdateInProgress) {
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
requestType: "daemon.update.request",
|
||||
error: "An update is already in progress",
|
||||
code: "already_updating",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.daemonUpdateInProgress = true;
|
||||
const previousVersion = this.daemonVersion ?? null;
|
||||
|
||||
const emitProgress = (phase: "starting" | "downloading" | "installing" | "complete") => {
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "daemon_update_progress",
|
||||
requestId: msg.requestId,
|
||||
phase,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const emitResponse = (success: boolean, error: string | null, newVersion: string | null) => {
|
||||
this.emit({
|
||||
type: "daemon.update.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
success,
|
||||
error,
|
||||
previousVersion,
|
||||
newVersion,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
emitProgress("starting");
|
||||
|
||||
// Check if npm is available
|
||||
const npmVersionOk = await new Promise<boolean>((fulfill) => {
|
||||
const proc = spawn("npm", ["--version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 10000,
|
||||
});
|
||||
proc.on("close", (code: number | null) => fulfill(code === 0));
|
||||
proc.on("error", () => fulfill(false));
|
||||
});
|
||||
|
||||
if (!npmVersionOk) {
|
||||
emitResponse(false, "npm is not available on this system", null);
|
||||
this.daemonUpdateInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
emitProgress("downloading");
|
||||
|
||||
// Run npm update -g @getpaseo/cli
|
||||
const result = await new Promise<{ exitCode: number; stdout: string; stderr: string }>(
|
||||
(fulfill) => {
|
||||
const proc = spawn("npm", ["update", "-g", "@getpaseo/cli"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 300_000, // 5 minutes
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("close", (code: number | null) => {
|
||||
fulfill({ exitCode: code ?? 1, stdout, stderr });
|
||||
});
|
||||
proc.on("error", (err: Error) => {
|
||||
fulfill({ exitCode: 1, stdout, stderr: err.message });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorMsg =
|
||||
result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`;
|
||||
this.sessionLogger.error(
|
||||
{ exitCode: result.exitCode, stderr: result.stderr },
|
||||
"Daemon update failed",
|
||||
);
|
||||
emitResponse(false, errorMsg, null);
|
||||
this.daemonUpdateInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
emitProgress("installing");
|
||||
|
||||
// Try to parse the new version from npm output
|
||||
let newVersion: string | null = null;
|
||||
const versionMatch = result.stdout.match(/@getpaseo\/cli@(\S+)/);
|
||||
if (versionMatch) {
|
||||
newVersion = versionMatch[1];
|
||||
}
|
||||
|
||||
emitProgress("complete");
|
||||
emitResponse(true, null, newVersion);
|
||||
|
||||
// Trigger restart to load the new code
|
||||
this.emitLifecycleIntent({
|
||||
type: "restart",
|
||||
clientId: this.clientId,
|
||||
requestId: msg.requestId,
|
||||
reason: "daemon_update",
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Daemon update failed with exception");
|
||||
emitResponse(false, error instanceof Error ? error.message : "Unknown error", null);
|
||||
} finally {
|
||||
this.daemonUpdateInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private emitLifecycleIntent(intent: SessionLifecycleIntent): void {
|
||||
if (!this.onLifecycleIntent) {
|
||||
return;
|
||||
|
||||
@@ -1183,6 +1183,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentDetach: true,
|
||||
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
|
||||
daemonDiagnostics: true,
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
daemonSelfUpdate: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user