authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-20 21:45:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 21:45:18-07:00
log3b836f9942143cdcf1ad7f121e78b59476585781
tree23ab073a75b6bccc3738643a2a3ae62de28ef5db
parent5c5fa5ffb6ab45cadb6f0d873af0242f4f6d2c1c
signature Signed by PGP key B5690EEEBB952194

feat(devices): show automatic update progress and host retry (#12817)


15 files changed, 281 insertions(+), 24 deletions(-)

apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+17-1
......@@ -74,6 +74,7 @@ function DevicePreviewScreen({
7474 const [inputConnected, setInputConnected] = useState(false);
7575 const [streamAttempt, setStreamAttempt] = useState(0);
7676 const [shuttingDown, setShuttingDown] = useState(false);
77 const retryHost = useAtomCommand(deviceEnvironment.list);
7778 const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false });
7879 const streamRef = useRef<DeviceStreamRef>(null);
7980 const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} }));
......@@ -117,6 +118,19 @@ function DevicePreviewScreen({
117118 };
118119
119120 const controls: ScreenHeaderMenuItem[] = [
121 ...(state.data?.hosts
122 .filter(
123 (host) =>
124 state.data?.supportsHostRetry && state.data.hostStatuses[host.id]?.status === "failed",
125 )
126 .map((host) => ({
127 id: `retry-${host.id}`,
128 title: `Retry ${host.label}`,
129 icon: "arrow.clockwise" as const,
130 onPress: () => {
131 void retryHost({ environmentId, input: { retryHostId: host.id } });
132 },
133 })) ?? []),
120134 {
121135 id: "device-tools",
122136 title: "Device tool versions",
......@@ -126,7 +140,9 @@ function DevicePreviewScreen({
126140 "Device tool versions",
127141 deviceToolVersionLabels(
128142 state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
129 ).join("\n"),
143 ).join("\n") +
144 "\n" +
145 (state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? ""),
130146 ),
131147 },
132148 {
apps/server/src/auth/RpcAuthorization.test.ts+12-1
......@@ -8,7 +8,11 @@ import {
88} from "@t3tools/contracts";
99import { describe, expect, it } from "@effect/vitest";
1010
11import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts";
11import {
12 RPC_REQUIRED_SCOPES,
13 requiredScopeForRpcMethod,
14 requiredScopeForDeviceList,
15} from "./RpcAuthorization.ts";
1216
1317describe("RPC authorization scopes", () => {
1418 it("declares exactly one scope for every RPC in the server group", () => {
......@@ -71,3 +75,10 @@ describe("RPC authorization scopes", () => {
7175 }
7276 });
7377});
78
79it("requires operate permission for host retry while preserving read-only listing", () => {
80 expect(requiredScopeForDeviceList({})).toBe(AuthOrchestrationReadScope);
81 expect(requiredScopeForDeviceList({ retryHostId: "remote-host" })).toBe(
82 AuthOrchestrationOperateScope,
83 );
84});
apps/server/src/auth/RpcAuthorization.ts+5
......@@ -1,4 +1,5 @@
11import {
2 type DeviceListInput,
23 AuthAccessReadScope,
34 AuthOrchestrationOperateScope,
45 AuthOrchestrationReadScope,
......@@ -180,3 +181,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
180181 }
181182 return requiredScope;
182183}
184
185/** Retrying can install or restart tools even though ordinary listing is readable. */
186export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope =>
187 input.retryHostId ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope;
apps/server/src/device/DeviceHost.ts+2-2
......@@ -88,11 +88,11 @@ export class DeviceHost extends Context.Service<
8888 * concurrent callers share one start, and a ready host returns immediately.
8989 */
9090 readonly ensureReady: (
91 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
91 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
9292 ) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>;
9393 /** Installs and starts agent-device after the user grants agent access. */
9494 readonly ensureAgentReady: (
95 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
95 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
9696 ) => Effect.Effect<
9797 DeviceHostAgentReady,
9898 DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError
apps/server/src/device/DeviceService.test.ts+54-2
......@@ -66,7 +66,7 @@ const fixture = Effect.fn("fixture")(function* (
6666 onBoot: Effect.Effect<void> = Effect.void,
6767 bootError?: string,
6868 failListAfterShutdown = false,
69 runtimeFailure?: NodeRuntimeUnavailableError,
69 runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,
7070) {
7171 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
7272 const starts: string[] = [];
......@@ -96,7 +96,7 @@ const fixture = Effect.fn("fixture")(function* (
9696 Effect.gen(function* () {
9797 if (runtimeFailure) return yield* runtimeFailure;
9898 starts.push("start");
99 yield* onPhase("starting");
99 yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…");
100100 return ready;
101101 }),
102102 ensureAgentReady: (onPhase) =>
......@@ -571,3 +571,55 @@ it.effect.each([
571571 Effect.scoped,
572572 ),
573573);
574
575it.effect("retry keeps device and agent consent unchanged", () =>
576 Effect.gen(function* () {
577 const { service, starts, agentStarts } = yield* fixture();
578 yield* service.retryHost(LOCAL_DEVICE_HOST_ID);
579 expect(starts).toEqual([]);
580 expect(agentStarts).toEqual([]);
581 yield* service.configure({ enabled: true });
582 yield* service.retryHost(LOCAL_DEVICE_HOST_ID);
583 expect(agentStarts).toEqual([]);
584 yield* service.configure({ agentAccessEnabled: true });
585 const before = agentStarts.length;
586 yield* service.retryHost(LOCAL_DEVICE_HOST_ID);
587 expect(agentStarts.length).toBe(before + 1);
588 }).pipe(Effect.scoped),
589);
590
591it.effect("publishes update detail for the correct host", () =>
592 Effect.gen(function* () {
593 const { service } = yield* fixture();
594 const changes = yield* service.subscribe;
595 yield* service.configure({ enabled: true });
596 const states = yield* PubSub.takeAll(changes);
597 expect(
598 states.some(
599 (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…",
600 ),
601 ).toBe(true);
602 }).pipe(Effect.scoped),
603);
604
605it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () =>
606 Effect.gen(function* () {
607 const { service, settings } = yield* fixture(
608 Effect.void,
609 undefined,
610 false,
611 new DeviceHost.DeviceHostError({
612 hostId: LOCAL_DEVICE_HOST_ID,
613 step: "probe",
614 cause: "private diagnostics",
615 }),
616 );
617 yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true }));
618 const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID);
619 expect(state.supportsHostRetry).toBe(true);
620 expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({
621 status: "failed",
622 detail: "Could not connect to this host over SSH.",
623 });
624 }).pipe(Effect.scoped),
625);
apps/server/src/device/DeviceService.ts+40-6
......@@ -126,6 +126,7 @@ export class DeviceService extends Context.Service<
126126 ) => Effect.Effect<DeviceServiceState, DeviceError>;
127127 /** Refreshes devices only after device support has been enabled. */
128128 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;
129 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;
129130 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;
130131 readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;
131132 readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect<void, DeviceError>;
......@@ -202,6 +203,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
202203 let publishedHosts = new Map(hosts);
203204 const stateRef = yield* SynchronizedRef.make<ServiceState>({
204205 state: {
206 supportsHostRetry: true,
205207 hosts: initialHosts,
206208 hostStatus: initialSettings.enabled ? "idle" : "disabled",
207209 hostStatuses: {},
......@@ -257,7 +259,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
257259 });
258260 }
259261 const ready = yield* host
260 .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid))
262 .ensureReady((status, detail) =>
263 setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid),
264 )
261265 .pipe(
262266 Effect.tapError((error) =>
263267 setHostStatus(host.id, { status: "failed", detail: error.message }),
......@@ -269,7 +273,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
269273 reason:
270274 error._tag === "NodeRuntimeUnavailableError"
271275 ? nodeRuntimeUnavailableMessage("Local device support")
272 : `Device host ${error.hostId} failed while ${error.step}.`,
276 : error.step === "probe"
277 ? "Could not connect to this host over SSH."
278 : `Device support failed during ${error.step}.`,
273279 cause: error,
274280 }),
275281 ),
......@@ -308,7 +314,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
308314 if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
309315 return null;
310316 const ready = yield* host
311 .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
317 .ensureAgentReady((phase, detail) =>
318 setHostStatus(host.id, { status: phase, detail }).pipe(Effect.asVoid),
319 )
312320 .pipe(
313321 Effect.tapError((error) =>
314322 setHostStatus(host.id, { status: "failed", detail: error.message }),
......@@ -321,8 +329,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
321329 error._tag === "NodeRuntimeUnavailableError"
322330 ? nodeRuntimeUnavailableMessage("Local device support")
323331 : error._tag === "DeviceHostTimeoutError"
324 ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.`
325 : `Device host ${error.hostId} failed while ${error.step}.`,
332 ? `Agent tools did not start within ${error.timeoutMs} ms.`
333 : error.step === "probe"
334 ? "Could not connect to this host over SSH."
335 : `Device support failed during ${error.step}.`,
326336 cause: error,
327337 }),
328338 ),
......@@ -444,7 +454,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
444454 if (ready) yield* refresh(ready);
445455 }).pipe(
446456 Effect.catch((error) =>
447 setHostStatus(host.id, { status: "failed", detail: error.message }),
457 setHostStatus(host.id, {
458 status: "failed",
459 detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message,
460 }),
448461 ),
449462 ),
450463 { concurrency: 4 },
......@@ -452,6 +465,26 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
452465 return (yield* SynchronizedRef.get(stateRef)).state;
453466 }).pipe(Effect.withSpan("DeviceService.list"));
454467
468 const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")(
469 function* (hostId) {
470 yield* resolveHost(hostId);
471 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
472 yield* Effect.gen(function* () {
473 const ready =
474 (yield* agentReadinessIfSupported(hostId)) ?? (yield* readinessIfSupported(hostId));
475 if (ready) yield* refresh(ready);
476 }).pipe(
477 Effect.catch((error) =>
478 setHostStatus(hostId, {
479 status: "failed",
480 detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message,
481 }),
482 ),
483 );
484 return (yield* SynchronizedRef.get(stateRef)).state;
485 },
486 );
487
455488 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(
456489 function* (input) {
457490 const currentSettings = yield* readDeviceSettings;
......@@ -832,6 +865,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
832865 return {
833866 ...DeviceService.of({
834867 testHost,
868 retryHost,
835869 agentCli: Effect.fail(
836870 new DeviceHostUnavailableError({
837871 hostId: LOCAL_DEVICE_HOST_ID,
apps/server/src/device/LocalDeviceHost.ts+13-3
......@@ -1,3 +1,4 @@
1import { deviceToolInstallMessage } from "@t3tools/contracts";
12/**
23 * The device host that is this machine.
34 *
......@@ -556,7 +557,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
556557 let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null;
557558
558559 const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (
559 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
560 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
560561 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {
561562 const running = yield* Ref.get(runningRef);
562563 if (running) {
......@@ -573,7 +574,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
573574 Effect.provideService(FileSystem.FileSystem, fs),
574575 Effect.provideService(Path.Path, path),
575576 );
576 if (!installed) yield* onPhase("installing");
577 if (!installed) {
578 const inventory = yield* summary;
579 yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub));
580 }
577581 const hubTool = yield* ensureDeviceHub(config.baseDir).pipe(
578582 Effect.provideService(FileSystem.FileSystem, fs),
579583 Effect.provideService(Path.Path, path),
......@@ -628,7 +632,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
628632 Effect.provideService(FileSystem.FileSystem, fs),
629633 Effect.provideService(Path.Path, path),
630634 );
631 if (!installed) yield* onPhase("installing");
635 if (!installed) {
636 const inventory = yield* summary;
637 yield* onPhase(
638 "installing",
639 deviceToolInstallMessage("agent tools", inventory.tools?.agent),
640 );
641 }
632642 const agentTool = yield* ensureAgentDevice(config.baseDir).pipe(
633643 Effect.provideService(FileSystem.FileSystem, fs),
634644 Effect.provideService(Path.Path, path),
apps/server/src/device/SshDeviceHost.ts+16-2
......@@ -3,6 +3,7 @@ import {
33 type DeviceHostSummary,
44 DevicePlatformAvailability,
55 DeviceToolVersions,
6 deviceToolInstallMessage,
67 type SshDeviceHostConfig,
78} from "@t3tools/contracts";
89import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
......@@ -371,7 +372,12 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
371372 stopped = false;
372373 if (ready) return ready;
373374 summary = yield* provide(probe(config));
374 yield* onPhase("installing");
375 yield* onPhase(
376 summary.hubInstalled ? "starting" : "installing",
377 summary.hubInstalled
378 ? undefined
379 : deviceToolInstallMessage("device hub", summary.tools?.hub),
380 );
375381 return yield* connect().pipe(
376382 Effect.tapError(() =>
377383 connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void,
......@@ -421,7 +427,15 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
421427 current: Effect.sync(() => ready),
422428 ensureReady,
423429 ensureAgentReady: (onPhase) =>
424 onPhase("installing").pipe(
430 ensureReady(onPhase).pipe(
431 Effect.flatMap(() =>
432 onPhase(
433 summary.agentDeviceInstalled ? "starting" : "installing",
434 summary.agentDeviceInstalled
435 ? undefined
436 : deviceToolInstallMessage("agent tools", summary.tools?.agent),
437 ),
438 ),
425439 Effect.flatMap(() => changeAgent(true)),
426440 Effect.flatMap((value) =>
427441 value?.agentDevice
apps/server/src/ws.ts+12-5
......@@ -146,7 +146,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
146146import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
147147import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
148148import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
149import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
149import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts";
150150import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
151151import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
152152import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
......@@ -3453,10 +3453,17 @@ const makeWsRpcLayer = (
34533453 observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), {
34543454 "rpc.aggregate": "device",
34553455 }),
3456 [WS_METHODS.deviceList]: (_input) =>
3457 observeRpcEffect(WS_METHODS.deviceList, deviceService.list, {
3458 "rpc.aggregate": "device",
3459 }),
3456 [WS_METHODS.deviceList]: (input) =>
3457 observeRpcEffect(
3458 WS_METHODS.deviceList,
3459 authorizeEffect(
3460 requiredScopeForDeviceList(input),
3461 input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list,
3462 ),
3463 {
3464 "rpc.aggregate": "device",
3465 },
3466 ),
34603467 [WS_METHODS.deviceOpen]: (input) =>
34613468 observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), {
34623469 "rpc.aggregate": "device",
apps/web/src/components/device/DeviceHostUpdates.tsx created+67
......@@ -0,0 +1,67 @@
1import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
2import { useState } from "react";
3import { Button } from "~/components/ui/button";
4import { deviceEnvironment } from "~/state/device";
5import { useAtomCommand } from "~/state/use-atom-command";
6
7/** Shared by setup, Settings, and the Device panel so automatic updates stay visible. */
8export function DeviceHostUpdates({
9 state,
10 environmentId,
11}: {
12 state: DeviceServiceState;
13 environmentId: EnvironmentId;
14}) {
15 const retry = useAtomCommand(deviceEnvironment.list);
16 const [pending, setPending] = useState<string | null>(null);
17 if (state.hostStatus === "disabled") return null;
18 return (
19 <div className="space-y-2">
20 {state.hosts.map((host) => {
21 const status = state.hostStatuses[host.id];
22 if (!status || !["installing", "starting", "failed"].includes(status.status)) return null;
23 const failed = status.status === "failed";
24 return (
25 <div
26 key={host.id}
27 role={failed ? "alert" : "status"}
28 className="flex items-start gap-3 rounded-md border border-border/60 px-3 py-2 text-xs"
29 >
30 <div className="min-w-0 flex-1">
31 <p className="font-medium">{host.label}</p>
32 <p className="whitespace-pre-wrap break-words text-muted-foreground">
33 {status.detail ??
34 (failed
35 ? "Device support could not start."
36 : status.status === "installing"
37 ? "Installing device tools…"
38 : "Starting device tools…")}
39 </p>
40 {failed ? (
41 <p className="mt-1 text-muted-foreground">
42 Check the host connection and network access, then retry. Your device settings are
43 saved.
44 </p>
45 ) : null}
46 </div>
47 {failed && state.supportsHostRetry ? (
48 <Button
49 size="compact"
50 variant="outline"
51 disabled={pending !== null}
52 onClick={() => {
53 setPending(host.id);
54 void retry({ environmentId, input: { retryHostId: host.id } }).finally(() =>
55 setPending(null),
56 );
57 }}
58 >
59 {pending === host.id ? "Retrying…" : "Retry"}
60 </Button>
61 ) : null}
62 </div>
63 );
64 })}
65 </div>
66 );
67}
apps/web/src/components/device/DevicePanel.tsx+3-1
......@@ -1,3 +1,4 @@
1import { DeviceHostUpdates } from "./DeviceHostUpdates";
12import type {
23 DevicePlatform,
34 DeviceServiceState,
......@@ -253,6 +254,7 @@ export function DevicePanel(props: {
253254 {state.hostStatusDetail}
254255 </div>
255256 ) : null}
257 <DeviceHostUpdates state={state} environmentId={environmentId} />
256258 {bootingDevices.length > 0 ? (
257259 <div role="status" className="border-b px-3 py-2 text-xs text-muted-foreground">
258260 Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute.
......@@ -319,7 +321,7 @@ export function DevicePanel(props: {
319321 ? "Opening device…"
320322 : "Starting device…"
321323 : state.hostStatus === "installing"
322 ? "Installing device support…"
324 ? (state.hostStatusDetail ?? "Installing device support…")
323325 : "Finding devices…"
324326 }
325327 />
apps/web/src/components/device/DeviceSetup.tsx+2
......@@ -1,3 +1,4 @@
1import { DeviceHostUpdates } from "./DeviceHostUpdates";
12import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
23import { Check, CircleAlert } from "lucide-react";
34import { useState } from "react";
......@@ -89,6 +90,7 @@ export function DeviceSetup(props: {
8990 </WizardHeader>
9091
9192 <WizardPanel>
93 <DeviceHostUpdates state={props.state} environmentId={props.environmentId} />
9294 {step === 0 ? (
9395 <section className="space-y-3 text-sm">
9496 <h3 className="font-medium">Enable the device hub</h3>
apps/web/src/components/settings/IntegrationsSettings.tsx+2
......@@ -1,3 +1,4 @@
1import { DeviceHostUpdates } from "../device/DeviceHostUpdates";
12import { DeviceToolVersions } from "../device/DeviceToolVersions";
23import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
34import { ScopedSwitch } from "./ScopedSwitch";
......@@ -784,6 +785,7 @@ function DeviceIntegrationControls({
784785 {state.hostStatusDetail}
785786 </p>
786787 ) : null}
788 {environmentId ? <DeviceHostUpdates state={state} environmentId={environmentId} /> : null}
787789 <DeviceHostsSettings environmentId={environmentId} />
788790 </>
789791 );
packages/contracts/src/device.test.ts created+21
......@@ -0,0 +1,21 @@
1import { describe, expect, it } from "@effect/vitest";
2import { deviceToolInstallMessage } from "./device.ts";
3
4describe("device tool install progress", () => {
5 it("distinguishes a new install from an upgrade and chooses versions numerically", () => {
6 expect(
7 deviceToolInstallMessage("device hub", {
8 requiredVersion: "0.11.0",
9 installedVersions: [],
10 runningVersion: null,
11 }),
12 ).toBe("Installing device hub 0.11.0…");
13 expect(
14 deviceToolInstallMessage("device hub", {
15 requiredVersion: "0.11.0",
16 installedVersions: ["0.9.0", "0.10.0"],
17 runningVersion: null,
18 }),
19 ).toBe("Updating device hub from 0.10.0 to 0.11.0…");
20 });
21});
packages/contracts/src/device.ts+15-1
......@@ -127,6 +127,7 @@ export const DeviceSession = Schema.Struct({
127127export type DeviceSession = typeof DeviceSession.Type;
128128
129129export const DeviceServiceState = Schema.Struct({
130 supportsHostRetry: Schema.optional(Schema.Boolean),
130131 hosts: Schema.Array(DeviceHostSummary),
131132 hostStatus: DeviceHostStatus,
132133 hostStatusDetail: Schema.optional(Schema.String),
......@@ -150,7 +151,10 @@ export const DeviceServiceState = Schema.Struct({
150151});
151152export type DeviceServiceState = typeof DeviceServiceState.Type;
152153
153export const DeviceListInput = Schema.Struct({});
154export const DeviceListInput = Schema.Struct({
155 /** Retry this host only, including agent tools if access was already granted. */
156 retryHostId: Schema.optional(DeviceHostId),
157});
154158export type DeviceListInput = typeof DeviceListInput.Type;
155159
156160export const DeviceConfigureInput = Schema.Struct({
......@@ -560,3 +564,13 @@ export const DeviceToolError = Schema.Union([
560564 DeviceActionUnavailableError,
561565]);
562566export type DeviceToolError = typeof DeviceToolError.Type;
567
568export function deviceToolInstallMessage(name: string, tool: DeviceToolVersion | undefined) {
569 if (!tool) return `Installing ${name}…`;
570 const previous =
571 tool.runningVersion ??
572 [...tool.installedVersions].sort((a, b) => a.localeCompare(b, "en", { numeric: true })).at(-1);
573 return previous && !tool.installedVersions.includes(tool.requiredVersion)
574 ? `Updating ${name} from ${previous} to ${tool.requiredVersion}…`
575 : `Installing ${name} ${tool.requiredVersion}…`;
576}