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 0000000000..d691952122 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -0,0 +1,133 @@ +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 } from "./DesktopLifecycleControl.tsx"; +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(); + } +}); diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx new file mode 100644 index 0000000000..5baffa6f4c --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -0,0 +1,226 @@ +import { useEffect, useRef, useState } from "react"; +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 [error, setError] = useState(""); + const { owner, community } = scope ?? {}; + useEffect(() => { + if (!owner || !community) return; + let active = true; + let close: (() => void) | undefined; + setError(""); + void receiveLifecycle({ owner, community }, () => active, setError) + .then((fn) => { + if (active) close = fn; + else fn(); + }) + .catch(() => { + if (active) setError("Desktop lifecycle receiver is unavailable."); + }); + return () => { + active = false; + close?.(); + }; + }, [owner, community]); + return error ? ( +

+ {error} +

+ ) : 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(null); + const active = useRef(true); + const generation = useRef(0); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + generation.current++; + }; + }, []); + const run = async (action: "start" | "restart" | "move" | "retry") => { + const token = ++generation.current; + const valid = () => active.current && generation.current === token; + const client = lifecycleClient(scope, valid); + setBusy(true); + setStatus("Checking authenticated Desktop state…"); + if (action !== "retry") setRequest(null); + try { + if (action === "move") { + const outcome = await client.move( + agent, + destination, + desktops.map((d) => d.id), + (stage) => { + if (valid()) setStatus(stage); + }, + ); + client.check(); + setStatus(message(outcome)); + } else { + const next = + action === "retry" + ? request + : action === "start" + ? await client.start(destination, agent) + : await client.restart( + agent, + desktops.map((d) => d.id), + ); + if (!next) throw new Error("No request to retry"); + client.check(); + setRequest(next); + setStatus("Request sent. Waiting for the Desktop’s actual result…"); + const outcome = await client.send(next); + client.check(); + setStatus(message(outcome)); + } + } catch (error) { + if (valid()) + setStatus( + error instanceof Error ? error.message : "Operation unconfirmed", + ); + } finally { + if (valid()) setBusy(false); + } + }; + const reset = () => { + setRequest(null); + setStatus(""); + }; + return ( +
+

Start, restart, or move an agent

+ + + +

+ 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. +

+
+ + + {request && ( + + )} +
+ {status && ( +

+ {status} +

+ )} +
+ ); +} diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx index 45e7aa68ea..5f4328b332 100644 --- a/desktop/src/features/agents/ui/KnownDesktops.tsx +++ b/desktop/src/features/agents/ui/KnownDesktops.tsx @@ -1,4 +1,8 @@ -import { DesktopStopControl, DesktopStopReceiver } from "./DesktopStopControl"; +import { + DesktopLifecycleControl, + DesktopLifecycleReceiver, +} from "./DesktopLifecycleControl"; +import { DesktopStopControl } from "./DesktopStopControl"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -62,6 +66,7 @@ function useDesktopList() { /** Startup and Agents share the existing owner/community query cache. */ export function DesktopListStartup() { + const [epoch, setEpoch] = useState(0); const { refetch } = useDesktopList(); const { refetch: pulse } = useDesktopObservations(useDesktopScope()); const { refetch: report } = useDesktopCapabilities(useDesktopScope()); @@ -71,6 +76,7 @@ export function DesktopListStartup() { void report(); }, DESKTOP_PULSE_MS); const unsubscribe = relayClient.subscribeToReconnects(() => { + setEpoch((n) => n + 1); void refetch(); void pulse(); void report(); @@ -80,7 +86,8 @@ export function DesktopListStartup() { unsubscribe(); }; }, [refetch, pulse, report]); - return ; + const scope = useDesktopScope(); + return ; } export function KnownDesktops() { @@ -170,6 +177,13 @@ export function DesktopListView({

Partial list: showing up to 100 profiles.

)} {list && !list.rows.length && !error &&

No Desktop profiles found.

} + {scope && list && ( + + )}
    {list?.rows.map((row) => (