mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
feat(desktop): mount Start Restart and Move controls
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
133
desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
Normal file
133
desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
Normal file
@@ -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("<div id='root'></div>", {
|
||||
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();
|
||||
}
|
||||
});
|
||||
226
desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
Normal file
226
desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
Normal file
@@ -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 ? (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
) : 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<RelayEvent | null>(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 (
|
||||
<section
|
||||
aria-label="Agent placement controls"
|
||||
className="space-y-2 rounded border p-3"
|
||||
>
|
||||
<h3 className="text-sm font-medium">Start, restart, or move an agent</h3>
|
||||
<label className="block text-xs">
|
||||
Agent
|
||||
<select
|
||||
aria-label="Agent to place"
|
||||
value={agent}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
setAgent(e.target.value);
|
||||
reset();
|
||||
}}
|
||||
className="ml-2 rounded border bg-background p-1"
|
||||
>
|
||||
<option value="">Choose your agent</option>
|
||||
{(agents.data ?? [])
|
||||
.filter((a) => a.ownerPubkey === scope.owner)
|
||||
.map((a) => (
|
||||
<option key={a.pubkey} value={a.pubkey}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!agent || busy}
|
||||
onClick={() => void run("restart")}
|
||||
>
|
||||
Restart on current Desktop
|
||||
</Button>
|
||||
<label className="block text-xs">
|
||||
Destination for Start or Move
|
||||
<select
|
||||
aria-label="Destination Desktop"
|
||||
value={destination}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
setDestination(e.target.value);
|
||||
reset();
|
||||
}}
|
||||
className="ml-2 rounded border bg-background p-1"
|
||||
>
|
||||
<option value="">Choose a Desktop</option>
|
||||
{desktops.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!agent || !destination || busy}
|
||||
onClick={() => void run("start")}
|
||||
>
|
||||
Start on destination
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!agent || !destination || busy}
|
||||
onClick={() => void run("move")}
|
||||
>
|
||||
Move to destination
|
||||
</Button>
|
||||
{request && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void run("retry")}
|
||||
>
|
||||
Retry same request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{status && (
|
||||
<p role="status" className="text-xs">
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 <DesktopStopReceiver scope={useDesktopScope()} />;
|
||||
const scope = useDesktopScope();
|
||||
return <DesktopLifecycleReceiver key={`lifecycle:${epoch}`} scope={scope} />;
|
||||
}
|
||||
|
||||
export function KnownDesktops() {
|
||||
@@ -170,6 +177,13 @@ export function DesktopListView({
|
||||
<p role="status">Partial list: showing up to 100 profiles.</p>
|
||||
)}
|
||||
{list && !list.rows.length && !error && <p>No Desktop profiles found.</p>}
|
||||
{scope && list && (
|
||||
<DesktopLifecycleControl
|
||||
key={`${scope.owner}:${scope.community}`}
|
||||
scope={scope}
|
||||
desktops={list.rows}
|
||||
/>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{list?.rows.map((row) => (
|
||||
<li key={row.id} className="text-sm">
|
||||
|
||||
Reference in New Issue
Block a user