diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 04e38e5602..de3dd8e558 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -17,6 +17,7 @@ import { startDesktopDaemon, stopDesktopDaemon, } from "@/desktop/daemon/desktop-daemon"; +import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings"; import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; @@ -39,72 +40,42 @@ function useDaemonManagementToggle(args: { return; } - if (!settings.manageBuiltInDaemon) { - setIsUpdatingDaemonManagement(true); - void updateSettings({ manageBuiltInDaemon: true }) - .then(() => startDesktopDaemon()) - .then((newStatus) => { - setStatus(newStatus); - refetch(); - return; - }) - .catch((error) => { - console.error("[Settings] Failed to update built-in daemon management", error); - Alert.alert("Error", "Unable to update built-in daemon management."); - }) - .finally(() => { - setIsUpdatingDaemonManagement(false); - }); - return; - } - - void confirmDialog({ - title: "Pause built-in daemon", - message: - "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", - confirmLabel: "Pause and stop", - cancelLabel: "Cancel", - destructive: true, + setIsUpdatingDaemonManagement(true); + void executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, { + confirm: () => + confirmDialog({ + title: "Pause built-in daemon", + message: + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + confirmLabel: "Pause and stop", + cancelLabel: "Cancel", + destructive: true, + }), + persistSettings: (next) => updateSettings(next) as Promise, + startDaemon: startDesktopDaemon, + stopDaemon: stopDesktopDaemon, }) - .then((confirmed) => { - if (!confirmed) { + .then((result) => { + if (result.kind === "cancelled") { return; } - - setIsUpdatingDaemonManagement(true); - - void updateSettings({ manageBuiltInDaemon: false }) - .then(() => { - if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { - return stopDesktopDaemon(); - } - return daemonStatus ?? null; - }) - .then((newStatus) => { - if (newStatus) { - setStatus(newStatus); - } - return; - }) - .then(() => { - refetch(); - return; - }) - .catch((error) => { - console.error("[Settings] Failed to stop built-in daemon", error); - Alert.alert( - "Error", - "Built-in daemon management was paused, but Paseo could not stop the daemon.", - ); - }) - .finally(() => { - setIsUpdatingDaemonManagement(false); - }); + if (result.newStatus) { + setStatus(result.newStatus); + } + refetch(); return; }) .catch((error) => { - console.error("[Settings] Failed to open built-in daemon pause confirmation", error); - Alert.alert("Error", "Unable to open the daemon confirmation dialog."); + console.error("[Settings] Failed to update built-in daemon management", error); + Alert.alert( + "Error", + settings.manageBuiltInDaemon + ? "Built-in daemon management was paused, but Paseo could not stop the daemon." + : "Unable to update built-in daemon management.", + ); + }) + .finally(() => { + setIsUpdatingDaemonManagement(false); }); }, [ daemonStatus, diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts new file mode 100644 index 0000000000..c63f4e13de --- /dev/null +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { executeDaemonManagementToggle } from "./daemon-management-toggle"; +import type { DesktopDaemonStatus } from "./desktop-daemon"; + +const runningManagedStatus: DesktopDaemonStatus = { + serverId: "desktop", + status: "running", + listen: null, + hostname: null, + pid: 123, + home: "/tmp/paseo", + version: "1.0.0", + desktopManaged: true, + error: null, +}; + +const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" }; + +function makeDeps(overrides?: { + confirm?: () => Promise; + persistSettings?: (next: { manageBuiltInDaemon: boolean }) => Promise; + startDaemon?: () => Promise; + stopDaemon?: () => Promise; +}) { + const calls: string[] = []; + return { + deps: { + confirm: overrides?.confirm ?? (() => Promise.resolve(true)), + persistSettings: + overrides?.persistSettings ?? + (async () => { + calls.push("persist"); + }), + startDaemon: + overrides?.startDaemon ?? + (async () => { + calls.push("start"); + return { ...runningManagedStatus, status: "running" as const }; + }), + stopDaemon: + overrides?.stopDaemon ?? + (async () => { + calls.push("stop"); + return stoppedStatus; + }), + }, + calls, + }; +} + +describe("executeDaemonManagementToggle", () => { + describe("enable path (currentlyManaging: false)", () => { + it("persists the new setting then starts the daemon", async () => { + const { deps, calls } = makeDeps(); + + const result = await executeDaemonManagementToggle(false, null, deps); + + expect(result).toEqual({ kind: "enabled", newStatus: runningManagedStatus }); + expect(calls).toEqual(["persist", "start"]); + }); + + it("persists manageBuiltInDaemon: true", async () => { + let persisted: { manageBuiltInDaemon: boolean } | null = null; + const { deps } = makeDeps({ + persistSettings: async (next) => { + persisted = next; + }, + }); + + await executeDaemonManagementToggle(false, null, deps); + + expect(persisted).toEqual({ manageBuiltInDaemon: true }); + }); + }); + + describe("disable path (currentlyManaging: true)", () => { + it("returns cancelled without changing settings when confirmation is rejected", async () => { + const persistedSettings: unknown[] = []; + const { deps } = makeDeps({ + confirm: () => Promise.resolve(false), + persistSettings: async (next) => { + persistedSettings.push(next); + }, + }); + + const result = await executeDaemonManagementToggle(true, runningManagedStatus, deps); + + expect(result).toEqual({ kind: "cancelled" }); + expect(persistedSettings).toHaveLength(0); + }); + + it("persists settings BEFORE stopping the daemon", async () => { + const callOrder: string[] = []; + const { deps } = makeDeps({ + persistSettings: async () => { + callOrder.push("persist"); + }, + stopDaemon: async () => { + callOrder.push("stop"); + return stoppedStatus; + }, + }); + + await executeDaemonManagementToggle(true, runningManagedStatus, deps); + + expect(callOrder).toEqual(["persist", "stop"]); + }); + + it("persists manageBuiltInDaemon: false when disabling", async () => { + let persisted: { manageBuiltInDaemon: boolean } | null = null; + const { deps } = makeDeps({ + persistSettings: async (next) => { + persisted = next; + }, + }); + + await executeDaemonManagementToggle(true, runningManagedStatus, deps); + + expect(persisted).toEqual({ manageBuiltInDaemon: false }); + }); + + it("stops the daemon when it is running and desktop-managed", async () => { + const { deps, calls } = makeDeps(); + + const result = await executeDaemonManagementToggle(true, runningManagedStatus, deps); + + expect(calls).toContain("stop"); + expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus }); + }); + + it("skips stop when daemon is running but not desktop-managed", async () => { + const { deps, calls } = makeDeps(); + const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false }; + + const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps); + + expect(calls).not.toContain("stop"); + expect(result).toEqual({ kind: "disabled", newStatus: null }); + }); + + it("skips stop when daemon is stopped (regardless of desktopManaged)", async () => { + const { deps, calls } = makeDeps(); + + const result = await executeDaemonManagementToggle(true, stoppedStatus, deps); + + expect(calls).not.toContain("stop"); + expect(result).toEqual({ kind: "disabled", newStatus: null }); + }); + + it("skips stop when daemonStatus is null", async () => { + const { deps, calls } = makeDeps(); + + const result = await executeDaemonManagementToggle(true, null, deps); + + expect(calls).not.toContain("stop"); + expect(result).toEqual({ kind: "disabled", newStatus: null }); + }); + }); +}); diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.ts new file mode 100644 index 0000000000..efec0ad20c --- /dev/null +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.ts @@ -0,0 +1,41 @@ +import type { DesktopDaemonStatus } from "./desktop-daemon"; + +export interface DaemonManagementToggleDeps { + confirm: () => Promise; + persistSettings: (next: { manageBuiltInDaemon: boolean }) => Promise; + startDaemon: () => Promise; + stopDaemon: () => Promise; +} + +export type DaemonManagementToggleResult = + | { kind: "cancelled" } + | { kind: "enabled"; newStatus: DesktopDaemonStatus } + | { kind: "disabled"; newStatus: DesktopDaemonStatus | null }; + +export async function executeDaemonManagementToggle( + currentlyManaging: boolean, + daemonStatus: Pick | null, + deps: DaemonManagementToggleDeps, +): Promise { + if (!currentlyManaging) { + await deps.persistSettings({ manageBuiltInDaemon: true }); + const newStatus = await deps.startDaemon(); + return { kind: "enabled", newStatus }; + } + + const confirmed = await deps.confirm(); + if (!confirmed) { + return { kind: "cancelled" }; + } + + // Settings must persist before the daemon is stopped so the persisted + // state reflects what was actually applied if the stop fails. + await deps.persistSettings({ manageBuiltInDaemon: false }); + + if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + const newStatus = await deps.stopDaemon(); + return { kind: "disabled", newStatus }; + } + + return { kind: "disabled", newStatus: null }; +}