diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs index 2c9623fb29a..d2361b92cf9 100644 --- a/desktop/src-tauri/src/commands/desktop_stop.rs +++ b/desktop/src-tauri/src/commands/desktop_stop.rs @@ -86,13 +86,14 @@ pub async fn receive_desktop_stop( if managed_agents::placement::desired(&conn, &target.agent)? .is_some_and(|(host, _)| host == desktop) { - return StopResult { + let result = StopResult { target, request: event.id.to_hex(), outcome: StopOutcome::Unknown, } - .sign(&scope.owner_keys) - .map(Some); + .sign(&scope.owner_keys)?; + remote_stop::save_result(&mut conn, &event.id.to_hex(), &result.as_json())?; + return Ok(Some(result)); } let owned = owned_local(&app, &state, &owner, &target.agent)?; remote_stop::receive( diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs index 5e21adbb316..828964f432b 100644 --- a/desktop/src/features/agents/desktopList.test.mjs +++ b/desktop/src/features/agents/desktopList.test.mjs @@ -160,6 +160,8 @@ test("rendered list distinguishes current, partial, unavailable and empty withou }); test("mounted cache clears both scopes, fences late reads and retains rows on failure", async (t) => { + const originalRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); const { JSDOM } = await import("jsdom"); const dom = new JSDOM("
", { url: "https://desktop.test", @@ -375,6 +377,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa "reconnect producer unsubscribed on unmount", ); t.mock.timers.reset(); + globalThis.requestAnimationFrame = originalRaf; dom.window.close(); } }); diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs new file mode 100644 index 00000000000..f41068c6136 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + DesktopLifecycleControl, + DesktopLifecycleReceiver, +} from "./DesktopLifecycleControl.tsx"; +import { toast } from "sonner"; +import { relayClient } from "../../../shared/api/relayClient.ts"; + +test("mounted Start exposes unavailable provisioning and exact retry; Restart resolves source", async () => { + const dom = new JSDOM("", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + ["relay-agents"], + [ + { pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" }, + { pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" }, + ], + ); + const scope = { owner: "owner", community: "wss://one.example" }; + const originals = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + }; + const prepared = [], + sent = []; + let stop = "failed"; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + if (command === "observe_desktop_placement") return; + if (command === "read_desktop_placement") return ["source", "selection"]; + if ( + command === "prepare_desktop_lifecycle" || + command === "prepare_desktop_stop" + ) { + const request = { + id: `request-${prepared.length}`, + kind: command.endsWith("_stop") ? 50180 : 50182, + ...args, + }; + prepared.push(request); + return request; + } + if (command === "read_desktop_lifecycle_results") + return args.request.action === "status" + ? "running" + : "provisioning_unavailable"; + if (command === "read_desktop_stop_results") return stop; + throw Error(command); + }, + }; + relayClient.fetchEvents = async () => []; + relayClient.publishEvent = async (event, _timeout, _failure, check) => { + check(); + sent.push(event); + }; + const root = createRoot(document.getElementById("root")); + const click = (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + const select = (label, value) => + React.act(async () => { + const element = document.querySelector(`select[aria-label="${label}"]`); + element.value = value; + element.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + try { + await React.act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(DesktopLifecycleControl, { + scope, + desktops: [ + { id: "source", name: "Source" }, + { id: "destination", name: "Destination" }, + ], + }), + ), + ), + ); + assert.doesNotMatch(document.body.textContent, /Foreign agent/); + await select("Agent to place", "agent"); + await select("Destination Desktop", "destination"); + await click("Start on destination"); + assert.match( + document.body.textContent, + /keyless launch provisioning is unavailable/, + ); + assert.equal(prepared[0].desktop, "destination"); + await click("Retry same request"); + assert.equal(prepared.length, 1); + assert.equal(sent[0], sent[1]); + await click("Restart on current Desktop"); + const restart = prepared.at(-1); + assert.equal( + restart.desktop, + "source", + "destination picker must not redirect Restart", + ); + assert.equal(restart.action, "restart"); + assert.equal(restart.observed, prepared.at(-2).id); + await click("Move to destination"); + assert.match(document.body.textContent, /destination was not started/); + const count = prepared.length; + stop = "stopped"; + await React.act(async () => {}); + assert.equal(prepared.length, count, "late Stop cannot resume failed Move"); + assert.doesNotMatch(document.body.textContent, /Retry same request/); + } finally { + await React.act(async () => root.unmount()); + client.clear(); + relayClient.fetchEvents = originals.fetch; + relayClient.publishEvent = originals.publish; + dom.window.close(); + } +}); + +test("receiver failure is a scope-owned notification, not pre-shell layout", async () => { + const originalRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + const dom = new JSDOM("", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const originals = { + fetch: relayClient.fetchEvents, + subscribe: relayClient.subscribeLive, + }; + let readiness; + let closed = 0; + let rejectLate; + let delayed = false; + relayClient.fetchEvents = async () => { + if (delayed) + return new Promise((_, reject) => { + rejectLate = reject; + }); + return []; + }; + relayClient.subscribeLive = async (_filter, _event, onReadiness) => { + readiness = onReadiness; + return () => { + closed++; + }; + }; + window.__TAURI_INTERNALS__ = { + invoke: async () => { + throw new Error("fixture: storage unavailable"); + }, + }; + const root = createRoot(document.getElementById("root")); + const scope = { owner: "owner", community: "wss://one.example" }; + const warnings = () => + toast + .getToasts() + .filter((t) => String(t.title).startsWith("Desktop lifecycle")); + try { + await React.act(async () => + root.render(React.createElement(DesktopLifecycleReceiver, { scope })), + ); + assert.equal( + document.getElementById("root").childElementCount, + 0, + "startup must not render in-flow failure UI", + ); + assert.equal(warnings().length, 1); + assert.equal( + warnings()[0].title, + "Desktop lifecycle receiver is unavailable.", + ); + assert.equal(warnings()[0].duration, Infinity); + assert.equal(warnings()[0].closeButton, true); + readiness("closed"); + assert.equal( + warnings().length, + 1, + "repeated failures update one notification", + ); + await React.act(async () => + root.render( + React.createElement(DesktopLifecycleReceiver, { scope: null }), + ), + ); + assert.equal(warnings().length, 0, "leaving the scope removes its warning"); + readiness("closed"); + assert.equal( + warnings().length, + 0, + "retired receiver cannot notify another scope", + ); + delayed = true; + await React.act(async () => + root.render(React.createElement(DesktopLifecycleReceiver, { scope })), + ); + assert.equal(typeof rejectLate, "function"); + await React.act(async () => root.unmount()); + await React.act(async () => rejectLate(new Error("late failure"))); + assert.equal( + warnings().length, + 0, + "late startup rejection must not recreate the warning", + ); + assert.equal(closed, 2, "both failed subscriptions are released"); + } finally { + await React.act(async () => root.unmount()); + relayClient.fetchEvents = originals.fetch; + relayClient.subscribeLive = originals.subscribe; + for (const warning of warnings()) toast.dismiss(warning.id); + globalThis.requestAnimationFrame = originalRaf; + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx new file mode 100644 index 00000000000..d05efee2511 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -0,0 +1,234 @@ +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/shared/ui/button"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopRow, DesktopScope } from "../desktopList"; +import { + lifecycleClient, + receiveLifecycle, + type LifecycleOutcome, +} from "../desktopLifecycle"; +import { useRelayAgentsQuery } from "../hooks"; + +export function DesktopLifecycleReceiver({ + scope, +}: { + scope: DesktopScope | null; +}) { + const { owner, community } = scope ?? {}; + useEffect(() => { + if (!owner || !community) return; + let active = true; + let close: (() => void) | undefined; + let notification: string | number | undefined; + const reportError = (message: string) => { + if (!active) return; + // Startup mounts before the app shell: failure UI must not participate + // in layout or displace the fixed macOS window controls. Keep one visible + // notification for this receiver, and retire it with its owner/scope. + notification = toast.error(message, { + id: notification, + duration: Infinity, + closeButton: true, + }); + }; + void receiveLifecycle({ owner, community }, () => active, reportError) + .then((fn) => { + if (active) close = fn; + else fn(); + }) + .catch(() => { + reportError("Desktop lifecycle receiver is unavailable."); + }); + return () => { + active = false; + close?.(); + if (notification !== undefined) toast.dismiss(notification); + }; + }, [owner, community]); + return null; +} +function message(outcome: LifecycleOutcome) { + switch (outcome) { + case "running": + return "Desktop confirmed a running local process. This does not prove model readiness."; + case "provisioning_unavailable": + return "Destination keyless launch provisioning is unavailable. No new process was started."; + case "stopped": + return "Desktop reports the agent stopped."; + case "failed": + return "Desktop rejected or failed the operation. No successful launch was confirmed."; + default: + return "Operation unconfirmed. A dispatched effect may still finish; no automatic retry will run."; + } +} +/** Start/Move choose destination; Restart has no host picker and resolves actual current state. */ +export function DesktopLifecycleControl({ + scope, + desktops, +}: { + scope: DesktopScope; + desktops: DesktopRow[]; +}) { + const agents = useRelayAgentsQuery(); + const [agent, setAgent] = useState(""); + const [destination, setDestination] = useState(""); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(""); + const [request, setRequest] = useState+ Start may overlap with an agent still running elsewhere until that + Desktop reconnects. Move starts the destination only after source Stop + is confirmed. Nothing transfers files, configuration, or keys. +
++ {status} +
+ )} +Partial list: showing up to 100 profiles.
)} {list && !list.rows.length && !error &&No Desktop profiles found.
} + {scope && list && ( +