Make remote daemon update failures actionable (#2120)
* fix(settings): show daemon update failures React Native web ignores Alert.alert, so failed remote updates reset to an enabled button without any user-visible explanation. Keep updater progress and failure as explicit rendered state, including the daemon's error, and cover the real browser-to-daemon failure path. * test(settings): harden daemon update regression * fix(settings): disable Desktop-managed daemon updates Desktop-bundled daemons advertised npm self-update even though the install-origin checks always rejected it. Report Desktop ownership to clients, render actionable guidance, and refuse stale requests before touching npm.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import {
|
||||
@@ -22,6 +23,8 @@ interface TrackedProjectPickerFixture extends ProjectPickerFixture {
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
@@ -116,6 +119,16 @@ const test = base.extend<{
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
outdatedDaemon: async ({}, provide) => {
|
||||
const daemon = await startOutdatedDaemon();
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
desktopManagedOutdatedDaemon: async ({}, provide) => {
|
||||
const daemon = await startOutdatedDaemon({ desktopManaged: true });
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
projectPickerFixture: async ({}, provide) => {
|
||||
const resource = await createProjectPickerFixture();
|
||||
const { fixture } = resource;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
|
||||
export interface OutdatedDaemon {
|
||||
endpoint: string;
|
||||
label: string;
|
||||
serverId: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface OutdatedDaemonReadyMessage {
|
||||
type: "ready";
|
||||
endpoint: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface OutdatedDaemonErrorMessage {
|
||||
type: "error";
|
||||
error: string;
|
||||
}
|
||||
|
||||
type OutdatedDaemonMessage = OutdatedDaemonReadyMessage | OutdatedDaemonErrorMessage;
|
||||
|
||||
export async function startOutdatedDaemon(options?: {
|
||||
desktopManaged?: boolean;
|
||||
}): Promise<OutdatedDaemon> {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error("E2E_METRO_PORT is not set - globalSetup must run first");
|
||||
}
|
||||
|
||||
const child = fork(
|
||||
path.resolve(__dirname, "../../../server/src/server/test-utils/outdated-daemon-process.ts"),
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
E2E_METRO_PORT: metroPort,
|
||||
E2E_DESKTOP_MANAGED: options?.desktopManaged === true ? "1" : "0",
|
||||
},
|
||||
execArgv: ["--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
},
|
||||
);
|
||||
const stderr: string[] = [];
|
||||
child.stderr?.on("data", (data: Buffer) => stderr.push(data.toString("utf8")));
|
||||
|
||||
try {
|
||||
const ready = await waitForDaemon(child, stderr);
|
||||
return {
|
||||
endpoint: ready.endpoint,
|
||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||
serverId: ready.serverId,
|
||||
async close() {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit");
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
child.kill("SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemon(
|
||||
child: ChildProcess,
|
||||
stderr: string[],
|
||||
): Promise<OutdatedDaemonReadyMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Timed out starting outdated daemon. ${stderr.join("")}`));
|
||||
}, 20_000);
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new Error(
|
||||
`Outdated daemon exited before startup (code ${String(code)}, signal ${String(signal)}). ${stderr.join("")}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.once("message", (message: OutdatedDaemonMessage) => {
|
||||
if (message.type === "error") {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
resolve(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "./fixtures";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
expectLocalHostEntryFirst,
|
||||
seedSavedSettingsHosts,
|
||||
} from "./helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -69,6 +70,49 @@ test.describe("Settings host page", () => {
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("a failed remote daemon update remains visible in the host UI", async ({
|
||||
page,
|
||||
outdatedDaemon,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [outdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openHostSection(page, outdatedDaemon.serverId, "host");
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
const updateButton = page.getByTestId("host-page-update-button");
|
||||
await updateButton.click();
|
||||
|
||||
await expect(
|
||||
updateButton.filter({ hasText: /Preparing update|Downloading packages|Installing/ }),
|
||||
).toBeDisabled();
|
||||
|
||||
const updateFailure = page.getByTestId("host-page-update-error");
|
||||
await expect(updateFailure).toBeVisible();
|
||||
await expect(updateFailure).toContainText("Update failed");
|
||||
await expect(updateFailure).toContainText("Failed to update the daemon:");
|
||||
await expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("a Desktop-managed daemon explains why its update action is disabled", async ({
|
||||
page,
|
||||
desktopManagedOutdatedDaemon,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [desktopManagedOutdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, desktopManagedOutdatedDaemon.serverId);
|
||||
await openHostSection(page, desktopManagedOutdatedDaemon.serverId, "host");
|
||||
|
||||
const updateCard = page.getByTestId("host-page-update-card");
|
||||
await expect(updateCard).toBeVisible();
|
||||
await expect(updateCard).toContainText(
|
||||
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
|
||||
);
|
||||
await expect(page.getByTestId("host-page-update-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
|
||||
@@ -951,6 +951,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
@@ -1422,6 +1425,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.desktopManaged !== undefined
|
||||
? { desktopManaged: serverInfo.desktopManaged }
|
||||
: {}),
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report";
|
||||
import {
|
||||
formatHostRuntimeSection,
|
||||
formatServerInfoSection,
|
||||
redactAppDiagnosticReport,
|
||||
} from "./app-diagnostic-report";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
@@ -41,6 +45,18 @@ function makeHost(): HostProfile {
|
||||
}
|
||||
|
||||
describe("app diagnostics report", () => {
|
||||
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
|
||||
const report = formatServerInfoSection({
|
||||
status: "server_info",
|
||||
serverId: "srv-desktop-managed",
|
||||
hostname: "desktop-host.local",
|
||||
version: "0.1.108",
|
||||
desktopManaged: true,
|
||||
});
|
||||
|
||||
expect(report).toContain("Desktop managed: yes");
|
||||
});
|
||||
|
||||
test("formats connection rows without raw connection details", () => {
|
||||
const host = makeHost();
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
|
||||
@@ -7,6 +7,11 @@ interface DiagnosticEntry {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function formatDiagnosticBoolean(value: boolean | undefined): string {
|
||||
if (value === undefined) return "unknown";
|
||||
return value ? "yes" : "no";
|
||||
}
|
||||
|
||||
export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string {
|
||||
return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
|
||||
}
|
||||
@@ -77,6 +82,10 @@ export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | nu
|
||||
{ label: "Server ID", value: serverInfo.serverId },
|
||||
{ label: "Hostname", value: serverInfo.hostname ?? "unknown" },
|
||||
{ label: "Version", value: serverInfo.version ?? "unknown" },
|
||||
{
|
||||
label: "Desktop managed",
|
||||
value: formatDiagnosticBoolean(serverInfo.desktopManaged),
|
||||
},
|
||||
{ label: "Features", value: features.length > 0 ? features.join(", ") : "none" },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1859,6 +1859,8 @@ export const ar: TranslationResources = {
|
||||
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"يدير Paseo Desktop هذا البرنامج الخفي. حدّث Paseo Desktop على المضيف.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
|
||||
@@ -1868,6 +1868,8 @@ export const en = {
|
||||
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
|
||||
@@ -1906,6 +1906,8 @@ export const es: TranslationResources = {
|
||||
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"Este daemon está administrado por Paseo Desktop. Actualiza Paseo Desktop en el host.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
|
||||
@@ -1909,6 +1909,8 @@ export const fr: TranslationResources = {
|
||||
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"Ce daemon est géré par Paseo Desktop. Mettez à jour Paseo Desktop sur l’hôte.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
|
||||
@@ -1880,6 +1880,8 @@ export const ja: TranslationResources = {
|
||||
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"このデーモンはPaseo Desktopによって管理されています。ホスト上のPaseo Desktopを更新してください。",
|
||||
title: "デーモンを更新",
|
||||
hint: "デーモンを最新バージョンに更新して再起動します",
|
||||
confirm: "更新",
|
||||
|
||||
@@ -1890,6 +1890,8 @@ export const ptBR: TranslationResources = {
|
||||
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"Este daemon é gerenciado pelo Paseo Desktop. Atualize o Paseo Desktop no host.",
|
||||
title: "Atualizar daemon",
|
||||
hint: "Atualiza o daemon para a versão mais recente e o reinicia",
|
||||
confirm: "Atualizar",
|
||||
|
||||
@@ -1896,6 +1896,8 @@ export const ru: TranslationResources = {
|
||||
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint:
|
||||
"Этот демон управляется Paseo Desktop. Обновите Paseo Desktop на хосте.",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
|
||||
@@ -1837,6 +1837,7 @@ export const zhCN: TranslationResources = {
|
||||
dialogFailedMessage: "无法打开重启确认对话框。",
|
||||
},
|
||||
update: {
|
||||
desktopManagedHint: "此 Daemon 由 Paseo Desktop 管理。请在 Host 上更新 Paseo Desktop。",
|
||||
title: "Update daemon",
|
||||
hint: "Update the daemon to the latest version and restart it",
|
||||
confirm: "Update",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasDaemonReconnectedAfter } from "./daemon-reconnect";
|
||||
|
||||
describe("daemon update reconnect detection", () => {
|
||||
it("accepts a reconnect performed by the existing daemon client", () => {
|
||||
const start = {
|
||||
clientGeneration: 4,
|
||||
lastOnlineAt: "2026-07-16T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const reconnected = hasDaemonReconnectedAfter(
|
||||
{
|
||||
connectionStatus: "online",
|
||||
clientGeneration: 4,
|
||||
lastOnlineAt: "2026-07-16T10:00:05.000Z",
|
||||
},
|
||||
start,
|
||||
);
|
||||
|
||||
expect(reconnected).toBe(true);
|
||||
});
|
||||
|
||||
it("does not accept the original connection before the daemon restarts", () => {
|
||||
const start = {
|
||||
clientGeneration: 4,
|
||||
lastOnlineAt: "2026-07-16T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const reconnected = hasDaemonReconnectedAfter(
|
||||
{
|
||||
connectionStatus: "online",
|
||||
...start,
|
||||
},
|
||||
start,
|
||||
);
|
||||
|
||||
expect(reconnected).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface DaemonConnectionMarker {
|
||||
clientGeneration: number;
|
||||
lastOnlineAt: string | null;
|
||||
}
|
||||
|
||||
interface DaemonConnectionSnapshot extends DaemonConnectionMarker {
|
||||
connectionStatus: "idle" | "connecting" | "online" | "offline" | "error";
|
||||
}
|
||||
|
||||
export function hasDaemonReconnectedAfter(
|
||||
snapshot: DaemonConnectionSnapshot | null,
|
||||
start: DaemonConnectionMarker | null,
|
||||
): boolean {
|
||||
if (snapshot?.connectionStatus !== "online") return false;
|
||||
if (!start) return true;
|
||||
return (
|
||||
snapshot.clientGeneration !== start.clientGeneration ||
|
||||
snapshot.lastOnlineAt !== start.lastOnlineAt
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { Alert as InlineAlert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||
import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect";
|
||||
import { restartDaemonFromSettings } from "./daemon-restart";
|
||||
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
@@ -365,7 +367,7 @@ export function HostSettingsPage({
|
||||
|
||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||
|
||||
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
|
||||
{!isLocalDaemon ? <UpdateDaemonCard key={host.serverId} host={host} /> : null}
|
||||
|
||||
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
|
||||
</View>
|
||||
@@ -755,14 +757,19 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type DaemonUpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "updating"; phase: string }
|
||||
| { status: "failed"; title: string; message: string };
|
||||
|
||||
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 [updateState, setUpdateState] = useState<DaemonUpdateState>({ status: "idle" });
|
||||
const isMountedRef = useRef(true);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -772,6 +779,9 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
const supportsSelfUpdate = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
|
||||
);
|
||||
const desktopManaged = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.desktopManaged === true,
|
||||
);
|
||||
|
||||
const appVersion = resolveAppVersion();
|
||||
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
@@ -788,11 +798,8 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
[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;
|
||||
},
|
||||
(startMarker: DaemonConnectionMarker | null) =>
|
||||
hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker),
|
||||
[host.serverId, runtime],
|
||||
);
|
||||
|
||||
@@ -810,27 +817,30 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
);
|
||||
|
||||
const waitForDaemonRestart = useCallback(
|
||||
async (startGeneration: number | null) => {
|
||||
async (startMarker: DaemonConnectionMarker | null) => {
|
||||
const disconnectTimeoutMs = 15000;
|
||||
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
|
||||
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
|
||||
if (!hasReconnectedAfter(startMarker) && isHostConnected()) {
|
||||
await waitForCondition(
|
||||
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
|
||||
() => !isHostConnected() || hasReconnectedAfter(startMarker),
|
||||
disconnectTimeoutMs,
|
||||
);
|
||||
}
|
||||
const reconnected =
|
||||
hasReconnectedAfter(startGeneration) ||
|
||||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs));
|
||||
hasReconnectedAfter(startMarker) ||
|
||||
(await waitForCondition(() => hasReconnectedAfter(startMarker), 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 }),
|
||||
);
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.unableToReconnectTitle"),
|
||||
message: t("settings.host.daemon.update.unableToReconnectMessage", {
|
||||
name: host.label,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUpdateState({ status: "idle" });
|
||||
}
|
||||
},
|
||||
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
|
||||
@@ -838,17 +848,19 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
if (!daemonClient) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.unavailableTitle"),
|
||||
t("settings.host.daemon.update.unavailableMessage"),
|
||||
);
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.unavailableTitle"),
|
||||
message: t("settings.host.daemon.update.unavailableMessage"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isHostConnected()) {
|
||||
Alert.alert(
|
||||
t("settings.host.daemon.update.offlineTitle"),
|
||||
t("settings.host.daemon.update.offlineMessage"),
|
||||
);
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.offlineTitle"),
|
||||
message: t("settings.host.daemon.update.offlineMessage"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -860,10 +872,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
destructive: false,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
|
||||
setIsUpdating(true);
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
|
||||
if (!confirmed || !isMountedRef.current) return;
|
||||
const startSnapshot = runtime.getSnapshot(host.serverId);
|
||||
const startMarker = startSnapshot
|
||||
? {
|
||||
clientGeneration: startSnapshot.clientGeneration,
|
||||
lastOnlineAt: startSnapshot.lastOnlineAt,
|
||||
}
|
||||
: null;
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseStarting"),
|
||||
});
|
||||
const requestId = `settings_daemon_update_${host.serverId}`;
|
||||
|
||||
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
|
||||
@@ -871,13 +891,25 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
if (!isMountedRef.current) return;
|
||||
const { phase } = message.payload;
|
||||
if (phase === "starting")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseStarting"),
|
||||
});
|
||||
else if (phase === "downloading")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseDownloading"),
|
||||
});
|
||||
else if (phase === "installing")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseInstalling"),
|
||||
});
|
||||
else if (phase === "complete")
|
||||
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
|
||||
setUpdateState({
|
||||
status: "updating",
|
||||
phase: t("settings.host.daemon.update.phaseComplete"),
|
||||
});
|
||||
});
|
||||
unsubscribeRef.current = unsubscribe;
|
||||
|
||||
@@ -888,18 +920,17 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
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", {
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.requestFailedTitle"),
|
||||
message: 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);
|
||||
void waitForDaemonRestart(startMarker);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -907,23 +938,24 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
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", {
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.requestFailedTitle"),
|
||||
message: 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"),
|
||||
);
|
||||
if (!isMountedRef.current) return;
|
||||
setUpdateState({
|
||||
status: "failed",
|
||||
title: t("settings.host.daemon.update.requestFailedTitle"),
|
||||
message: t("settings.host.daemon.update.dialogFailedMessage"),
|
||||
});
|
||||
});
|
||||
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
|
||||
|
||||
@@ -932,33 +964,46 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
|
||||
// Don't show if the daemon doesn't support self-update or versions match
|
||||
if (!supportsSelfUpdate || !hasVersionMismatch) {
|
||||
const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged);
|
||||
if (!shouldShowUpdate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonLabel = isUpdating
|
||||
? (progressPhase ?? t("settings.host.daemon.update.updating"))
|
||||
: t("settings.host.daemon.update.confirm");
|
||||
const isUpdating = updateState.status === "updating";
|
||||
const buttonLabel = isUpdating ? updateState.phase : 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>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{desktopManaged
|
||||
? t("settings.host.daemon.update.desktopManagedHint")
|
||||
: t("settings.host.daemon.update.hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={updateIcon}
|
||||
onPress={handleUpdate}
|
||||
disabled={isUpdating || !daemonClient || !isConnected}
|
||||
disabled={desktopManaged || isUpdating || !daemonClient || !isConnected}
|
||||
testID="host-page-update-button"
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</View>
|
||||
{updateState.status === "failed" ? (
|
||||
<View style={styles.updateFailure}>
|
||||
<InlineAlert
|
||||
variant="error"
|
||||
title={updateState.title}
|
||||
description={updateState.message}
|
||||
testID="host-page-update-error"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1798,6 +1843,10 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
updateFailure: {
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
identityEditButton: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -306,6 +306,7 @@ export interface DaemonServerInfo {
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
version: string | null;
|
||||
desktopManaged?: boolean;
|
||||
capabilities?: ServerCapabilities;
|
||||
features?: ServerInfoStatusPayload["features"];
|
||||
}
|
||||
@@ -591,17 +592,26 @@ function isSessionServerInfoUnchanged(input: {
|
||||
currentServerInfo: SessionState["serverInfo"] | undefined;
|
||||
nextHostname: string | null;
|
||||
nextVersion: string | null;
|
||||
nextDesktopManaged: boolean | undefined;
|
||||
nextCapabilities: ServerCapabilities | undefined;
|
||||
nextFeatures: ServerInfoStatusPayload["features"] | undefined;
|
||||
nextServerId: string;
|
||||
}): boolean {
|
||||
const { currentServerInfo, nextHostname, nextVersion, nextCapabilities, nextFeatures } = input;
|
||||
const {
|
||||
currentServerInfo,
|
||||
nextHostname,
|
||||
nextVersion,
|
||||
nextDesktopManaged,
|
||||
nextCapabilities,
|
||||
nextFeatures,
|
||||
} = input;
|
||||
const prevHostname = currentServerInfo?.hostname?.trim() || null;
|
||||
const prevVersion = currentServerInfo?.version?.trim() || null;
|
||||
return (
|
||||
currentServerInfo?.serverId === input.nextServerId &&
|
||||
prevHostname === nextHostname &&
|
||||
prevVersion === nextVersion &&
|
||||
currentServerInfo?.desktopManaged === nextDesktopManaged &&
|
||||
areServerCapabilitiesEqual(currentServerInfo?.capabilities, nextCapabilities) &&
|
||||
areServerInfoFeaturesEqual(currentServerInfo?.features, nextFeatures)
|
||||
);
|
||||
@@ -721,6 +731,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
const nextHostname = info.hostname?.trim() || null;
|
||||
const nextVersion = info.version?.trim() || null;
|
||||
const nextDesktopManaged = info.desktopManaged;
|
||||
const nextCapabilities = info.capabilities;
|
||||
const nextFeatures = info.features;
|
||||
|
||||
@@ -729,6 +740,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
currentServerInfo: session.serverInfo,
|
||||
nextHostname,
|
||||
nextVersion,
|
||||
nextDesktopManaged,
|
||||
nextCapabilities,
|
||||
nextFeatures,
|
||||
nextServerId: info.serverId,
|
||||
@@ -747,6 +759,9 @@ export const useSessionStore = create<SessionStore>()(
|
||||
serverId: info.serverId,
|
||||
hostname: nextHostname,
|
||||
version: nextVersion,
|
||||
...(nextDesktopManaged !== undefined
|
||||
? { desktopManaged: nextDesktopManaged }
|
||||
: {}),
|
||||
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
|
||||
...(nextFeatures ? { features: nextFeatures } : {}),
|
||||
},
|
||||
|
||||
@@ -2423,6 +2423,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
serverId: z.string().trim().min(1),
|
||||
hostname: ServerInfoHostnameSchema.optional(),
|
||||
version: ServerInfoVersionSchema.optional(),
|
||||
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
|
||||
desktopManaged: z.boolean().optional(),
|
||||
capabilities: ServerCapabilitiesFromUnknownSchema.optional(),
|
||||
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
|
||||
features: z
|
||||
|
||||
@@ -335,6 +335,8 @@ export type DaemonLifecycleIntent =
|
||||
export interface PaseoDaemonConfig {
|
||||
listen: string;
|
||||
paseoHome: string;
|
||||
daemonVersion?: string;
|
||||
desktopManaged?: boolean;
|
||||
worktreesRoot?: string;
|
||||
corsAllowedOrigins: string[];
|
||||
allowedHosts?: HostnamesConfig;
|
||||
@@ -483,7 +485,7 @@ export async function createPaseoDaemon(
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
const bootstrapStart = performance.now();
|
||||
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
|
||||
const daemonVersion = resolveDaemonVersion(import.meta.url);
|
||||
const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url);
|
||||
const daemonConfigStore = new DaemonConfigStore(
|
||||
config.paseoHome,
|
||||
createInitialMutableDaemonConfig(config),
|
||||
@@ -1371,6 +1373,7 @@ export async function createPaseoDaemon(
|
||||
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
appBaseUrl: config.appBaseUrl,
|
||||
desktopManaged: config.desktopManaged === true,
|
||||
relay: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveBundledWebUiDistDir } from "./config.js";
|
||||
import { loadConfig, resolveBundledWebUiDistDir } from "./config.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
@@ -13,6 +13,19 @@ describe("server config", () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("records when the daemon is managed by Paseo Desktop", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
|
||||
roots.push(paseoHome);
|
||||
|
||||
const desktopConfig = loadConfig(paseoHome, {
|
||||
env: { PASEO_DESKTOP_MANAGED: "1" },
|
||||
});
|
||||
const standaloneConfig = loadConfig(paseoHome, { env: {} });
|
||||
|
||||
expect(desktopConfig.desktopManaged).toBe(true);
|
||||
expect(standaloneConfig.desktopManaged).toBe(false);
|
||||
});
|
||||
|
||||
test("resolves bundled web UI path from source-tree modules", () => {
|
||||
const root = path.parse(process.cwd()).root;
|
||||
expect(
|
||||
|
||||
@@ -488,6 +488,7 @@ export function loadConfig(
|
||||
return {
|
||||
listen,
|
||||
paseoHome,
|
||||
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
|
||||
@@ -1006,10 +1006,32 @@ test("receives server_info on websocket connect", async () => {
|
||||
expect(serverInfo).not.toBeNull();
|
||||
expect(serverInfo?.serverId.length).toBeGreaterThan(0);
|
||||
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
|
||||
expect(serverInfo?.desktopManaged).toBe(false);
|
||||
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
|
||||
|
||||
await client.close();
|
||||
}, 15000);
|
||||
|
||||
test("a Desktop-managed daemon does not advertise npm self-update", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ desktopManaged: true });
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
clientId: `cid-desktop-managed-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
|
||||
expect(serverInfo?.desktopManaged).toBe(true);
|
||||
expect(serverInfo?.features?.daemonSelfUpdate).toBe(false);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
test("emits disabled voice capability reasons on fresh daemon startup", async () => {
|
||||
const isolatedCtx = await createDaemonTestContext({
|
||||
speech: {
|
||||
|
||||
+11
-2
@@ -23,12 +23,14 @@ interface ControllerHarness {
|
||||
function createController(input: {
|
||||
updater: TestUpdater;
|
||||
daemonVersion?: string | null;
|
||||
desktopManaged?: boolean;
|
||||
}): ControllerHarness {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const restartIntents: RestartIntent[] = [];
|
||||
const controller = new DaemonSelfUpdateSessionController({
|
||||
clientId: "client-1",
|
||||
daemonVersion: input.daemonVersion ?? "0.1.15",
|
||||
desktopManaged: input.desktopManaged,
|
||||
emit: (msg) => {
|
||||
emitted.push(msg);
|
||||
},
|
||||
@@ -82,6 +84,7 @@ describe("DaemonSelfUpdateSessionController", () => {
|
||||
await controller.dispatch(updateRequest);
|
||||
|
||||
expect(updateInput?.daemonVersion).toBe("0.1.15");
|
||||
expect(updateInput?.desktopManaged).toBe(false);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "daemon.update.progress",
|
||||
@@ -119,15 +122,21 @@ describe("DaemonSelfUpdateSessionController", () => {
|
||||
});
|
||||
|
||||
test("emits a failed response without restart lifecycle intent", async () => {
|
||||
let updateInput: DaemonSelfUpdateInput | null = null;
|
||||
const updater: TestUpdater = {
|
||||
async update() {
|
||||
async update(input) {
|
||||
updateInput = input;
|
||||
return { success: false, error: "not an npm global install", newVersion: null };
|
||||
},
|
||||
};
|
||||
const { controller, emitted, restartIntents } = createController({ updater });
|
||||
const { controller, emitted, restartIntents } = createController({
|
||||
updater,
|
||||
desktopManaged: true,
|
||||
});
|
||||
|
||||
await controller.dispatch(updateRequest);
|
||||
|
||||
expect(updateInput?.desktopManaged).toBe(true);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "daemon.update.response",
|
||||
|
||||
@@ -24,6 +24,7 @@ interface DaemonSelfUpdateRestartIntent {
|
||||
export interface DaemonSelfUpdateSessionControllerOptions {
|
||||
clientId: string;
|
||||
daemonVersion: string | null;
|
||||
desktopManaged?: boolean;
|
||||
emit: (msg: SessionOutboundMessage) => void;
|
||||
emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
|
||||
sessionLogger: pino.Logger;
|
||||
@@ -37,6 +38,7 @@ function isDaemonSelfUpdateMessage(msg: SessionInboundMessage): msg is DaemonUpd
|
||||
export class DaemonSelfUpdateSessionController {
|
||||
private readonly clientId: string;
|
||||
private readonly daemonVersion: string | null;
|
||||
private readonly desktopManaged: boolean;
|
||||
private readonly emit: (msg: SessionOutboundMessage) => void;
|
||||
private readonly emitLifecycleIntent: (intent: DaemonSelfUpdateRestartIntent) => void;
|
||||
private readonly sessionLogger: pino.Logger;
|
||||
@@ -45,6 +47,7 @@ export class DaemonSelfUpdateSessionController {
|
||||
constructor(options: DaemonSelfUpdateSessionControllerOptions) {
|
||||
this.clientId = options.clientId;
|
||||
this.daemonVersion = options.daemonVersion;
|
||||
this.desktopManaged = options.desktopManaged === true;
|
||||
this.emit = options.emit;
|
||||
this.emitLifecycleIntent = options.emitLifecycleIntent;
|
||||
this.sessionLogger = options.sessionLogger;
|
||||
@@ -64,6 +67,7 @@ export class DaemonSelfUpdateSessionController {
|
||||
try {
|
||||
const result = await this.updater.update({
|
||||
daemonVersion: previousVersion,
|
||||
desktopManaged: this.desktopManaged,
|
||||
onProgress: (phase) => this.emitProgress(msg.requestId, phase),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ function createRuntime(input: {
|
||||
async function runUpdate(input: {
|
||||
runtime: DaemonSelfUpdateRuntime;
|
||||
daemonVersion?: string | null;
|
||||
desktopManaged?: boolean;
|
||||
phases?: DaemonSelfUpdatePhase[];
|
||||
}) {
|
||||
const logger = createLogger();
|
||||
@@ -91,6 +92,7 @@ async function runUpdate(input: {
|
||||
const phases = input.phases ?? [];
|
||||
const result = await updater.update({
|
||||
daemonVersion: input.daemonVersion ?? "0.1.15",
|
||||
desktopManaged: input.desktopManaged ?? false,
|
||||
onProgress: (phase) => phases.push(phase),
|
||||
logger,
|
||||
});
|
||||
@@ -98,6 +100,21 @@ async function runUpdate(input: {
|
||||
}
|
||||
|
||||
describe("DaemonSelfUpdater", () => {
|
||||
test("refuses a Desktop-managed daemon without touching npm", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
const runtime = createRuntime({ calls, inspections: [] });
|
||||
|
||||
const { result, phases } = await runUpdate({ runtime, desktopManaged: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
|
||||
newVersion: null,
|
||||
});
|
||||
expect(phases).toEqual([]);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test("updates a daemon that is running from the npm global cli install", async () => {
|
||||
const calls: RuntimeCall[] = [];
|
||||
const runtime = createRuntime({
|
||||
@@ -214,6 +231,7 @@ describe("DaemonSelfUpdater", () => {
|
||||
|
||||
const firstUpdate = updater.update({
|
||||
daemonVersion: "0.1.15",
|
||||
desktopManaged: false,
|
||||
onProgress: () => {},
|
||||
logger,
|
||||
});
|
||||
@@ -222,6 +240,7 @@ describe("DaemonSelfUpdater", () => {
|
||||
await expect(
|
||||
updater.update({
|
||||
daemonVersion: "0.1.15",
|
||||
desktopManaged: false,
|
||||
onProgress: () => {},
|
||||
logger,
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface DaemonSelfUpdateResult {
|
||||
|
||||
export interface DaemonSelfUpdateInput {
|
||||
daemonVersion: string | null;
|
||||
desktopManaged: boolean;
|
||||
onProgress: (phase: DaemonSelfUpdatePhase) => void;
|
||||
logger: DaemonSelfUpdateLogger;
|
||||
}
|
||||
@@ -42,12 +43,19 @@ const defaultRuntime: DaemonSelfUpdateRuntime = {
|
||||
installOrigin: daemonInstallOriginRuntime,
|
||||
};
|
||||
|
||||
const DESKTOP_MANAGED_UPDATE_ERROR =
|
||||
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.";
|
||||
|
||||
export class DaemonSelfUpdater {
|
||||
private inProgress = false;
|
||||
|
||||
constructor(private readonly runtime: DaemonSelfUpdateRuntime = defaultRuntime) {}
|
||||
|
||||
async update(input: DaemonSelfUpdateInput): Promise<DaemonSelfUpdateResult> {
|
||||
if (input.desktopManaged) {
|
||||
return { success: false, error: DESKTOP_MANAGED_UPDATE_ERROR, newVersion: null };
|
||||
}
|
||||
|
||||
if (this.inProgress) {
|
||||
throw new DaemonSelfUpdateInProgressError();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../wor
|
||||
|
||||
export interface DaemonRuntimeConfig {
|
||||
listen: string | null;
|
||||
worktreesRoot?: string;
|
||||
appBaseUrl?: string;
|
||||
desktopManaged?: boolean;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
@@ -86,6 +88,7 @@ export class DaemonSession {
|
||||
this.selfUpdate = new DaemonSelfUpdateSessionController({
|
||||
clientId: this.clientId,
|
||||
daemonVersion: this.daemonVersion ?? null,
|
||||
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
|
||||
emit: (msg) => this.host.emit(msg),
|
||||
emitLifecycleIntent: (intent) => this.host.emitLifecycleIntent(intent),
|
||||
sessionLogger: this.logger,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createTestPaseoDaemon } from "./paseo-daemon.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error("E2E_METRO_PORT is not set");
|
||||
}
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
corsAllowedOrigins: [`http://localhost:${metroPort}`],
|
||||
daemonVersion: "0.0.0",
|
||||
desktopManaged: process.env.E2E_DESKTOP_MANAGED === "1",
|
||||
});
|
||||
const serverId = (await readFile(path.join(daemon.paseoHome, "server-id"), "utf8")).trim();
|
||||
|
||||
process.send?.({
|
||||
type: "ready",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
await daemon.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", () => void shutdown());
|
||||
process.once("SIGTERM", () => void shutdown());
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
process.send?.({
|
||||
type: "error",
|
||||
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import { createTestAgentClients } from "./fake-agent-client.js";
|
||||
import type { PushNotificationSender } from "../push/notifications.js";
|
||||
|
||||
interface TestPaseoDaemonOptions {
|
||||
daemonVersion?: string;
|
||||
desktopManaged?: boolean;
|
||||
downloadTokenTtlMs?: number;
|
||||
corsAllowedOrigins?: string[];
|
||||
listen?: string;
|
||||
@@ -157,6 +159,8 @@ async function prepareTestDaemonConfig(
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: `${listenHost}:0`,
|
||||
paseoHome,
|
||||
daemonVersion: options.daemonVersion,
|
||||
desktopManaged: options.desktopManaged,
|
||||
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||
hostnames: true,
|
||||
mcpEnabled: options.mcpEnabled ?? true,
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
type BrowserAutomationHostCapability,
|
||||
} from "@getpaseo/protocol/browser-automation/capabilities";
|
||||
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
|
||||
|
||||
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
|
||||
|
||||
@@ -404,19 +405,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
||||
private readonly serverId: string;
|
||||
private readonly daemonVersion: string;
|
||||
private readonly daemonRuntimeConfig:
|
||||
| {
|
||||
listen: string | null;
|
||||
appBaseUrl?: string;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
@@ -507,18 +496,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
github?: GitHubService,
|
||||
pushNotificationSender?: PushNotificationSender,
|
||||
providerSnapshotManager?: ProviderSnapshotManager,
|
||||
daemonRuntimeConfig?: {
|
||||
listen: string | null;
|
||||
worktreesRoot?: string;
|
||||
appBaseUrl?: string;
|
||||
relay: {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
};
|
||||
},
|
||||
daemonRuntimeConfig?: DaemonRuntimeConfig,
|
||||
serviceProxyPublicBaseUrl?: string | null,
|
||||
browserToolsBroker?: BrowserToolsBroker | null,
|
||||
) {
|
||||
@@ -1227,6 +1205,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
serverId: this.serverId,
|
||||
hostname: getHostname(),
|
||||
version: this.daemonVersion,
|
||||
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
|
||||
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
|
||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||
features: {
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
@@ -1257,7 +1237,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
// 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,
|
||||
daemonSelfUpdate: this.daemonRuntimeConfig?.desktopManaged !== true,
|
||||
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||
agentForkContext: true,
|
||||
// COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14.
|
||||
|
||||
Reference in New Issue
Block a user