diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 628b30cd6032d89caaea6bffe897e41d2db468e1..9af958c82b7842ac72ce6d8fd0e7f404e3124590 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -74,6 +74,7 @@ function DevicePreviewScreen({ const [inputConnected, setInputConnected] = useState(false); const [streamAttempt, setStreamAttempt] = useState(0); const [shuttingDown, setShuttingDown] = useState(false); + const retryHost = useAtomCommand(deviceEnvironment.list); const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); const streamRef = useRef(null); const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); @@ -117,6 +118,19 @@ function DevicePreviewScreen({ }; const controls: ScreenHeaderMenuItem[] = [ + ...(state.data?.hosts + .filter( + (host) => + state.data?.supportsHostRetry && state.data.hostStatuses[host.id]?.status === "failed", + ) + .map((host) => ({ + id: `retry-${host.id}`, + title: `Retry ${host.label}`, + icon: "arrow.clockwise" as const, + onPress: () => { + void retryHost({ environmentId, input: { retryHostId: host.id } }); + }, + })) ?? []), { id: "device-tools", title: "Device tool versions", @@ -126,7 +140,9 @@ function DevicePreviewScreen({ "Device tool versions", deviceToolVersionLabels( state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools, - ).join("\n"), + ).join("\n") + + "\n" + + (state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? ""), ), }, { diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b482f84309654431c16c15349af272..c972e5372f4c87415110389c35bc565b95adbc2d 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,7 +8,11 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts"; +import { + RPC_REQUIRED_SCOPES, + requiredScopeForRpcMethod, + requiredScopeForDeviceList, +} from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { it("declares exactly one scope for every RPC in the server group", () => { @@ -71,3 +75,10 @@ describe("RPC authorization scopes", () => { } }); }); + +it("requires operate permission for host retry while preserving read-only listing", () => { + expect(requiredScopeForDeviceList({})).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForDeviceList({ retryHostId: "remote-host" })).toBe( + AuthOrchestrationOperateScope, + ); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 20f2893b83351e3cad6908766148904d19ae7376..819d98cfc92ca4d6f26356a836fee398c5e79288 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,4 +1,5 @@ import { + type DeviceListInput, AuthAccessReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -180,3 +181,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope } return requiredScope; } + +/** Retrying can install or restart tools even though ordinary listing is readable. */ +export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => + input.retryHostId ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope; diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index a947f8dc9e689593fae2f75a532a71044b76dd53..0aa784b7149dbb4c6d82237170d50e8e952a6632 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -88,11 +88,11 @@ export class DeviceHost extends Context.Service< * concurrent callers share one start, and a ready host returns immediately. */ readonly ensureReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect; /** Installs and starts agent-device after the user grants agent access. */ readonly ensureAgentReady: ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ) => Effect.Effect< DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 183f24d95c5ba38507b27b4f5fb2a3a17af91f93..b3ff08aea1cef986c8acf35d0ba3a55ea0e40e5f 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -66,7 +66,7 @@ const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string, failListAfterShutdown = false, - runtimeFailure?: NodeRuntimeUnavailableError, + runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -96,7 +96,7 @@ const fixture = Effect.fn("fixture")(function* ( Effect.gen(function* () { if (runtimeFailure) return yield* runtimeFailure; starts.push("start"); - yield* onPhase("starting"); + yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; }), ensureAgentReady: (onPhase) => @@ -571,3 +571,55 @@ it.effect.each([ Effect.scoped, ), ); + +it.effect("retry keeps device and agent consent unchanged", () => + Effect.gen(function* () { + const { service, starts, agentStarts } = yield* fixture(); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + yield* service.configure({ enabled: true }); + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts).toEqual([]); + yield* service.configure({ agentAccessEnabled: true }); + const before = agentStarts.length; + yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(agentStarts.length).toBe(before + 1); + }).pipe(Effect.scoped), +); + +it.effect("publishes update detail for the correct host", () => + Effect.gen(function* () { + const { service } = yield* fixture(); + const changes = yield* service.subscribe; + yield* service.configure({ enabled: true }); + const states = yield* PubSub.takeAll(changes); + expect( + states.some( + (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…", + ), + ).toBe(true); + }).pipe(Effect.scoped), +); + +it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () => + Effect.gen(function* () { + const { service, settings } = yield* fixture( + Effect.void, + undefined, + false, + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "probe", + cause: "private diagnostics", + }), + ); + yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); + const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID); + expect(state.supportsHostRetry).toBe(true); + expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ + status: "failed", + detail: "Could not connect to this host over SSH.", + }); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 45a2f928ecaac76453aa42222cf77a461c33fb4b..bb06ef66731bcbf28cfd67f2aec36e64e25bffd7 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -126,6 +126,7 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; readonly close: (input: DeviceCloseInput) => Effect.Effect; readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect; @@ -202,6 +203,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* let publishedHosts = new Map(hosts); const stateRef = yield* SynchronizedRef.make({ state: { + supportsHostRetry: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", hostStatuses: {}, @@ -257,7 +259,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); } const ready = yield* host - .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) + .ensureReady((status, detail) => + setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -269,7 +273,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") - : `Device host ${error.hostId} failed while ${error.step}.`, + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -308,7 +314,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; const ready = yield* host - .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) + .ensureAgentReady((phase, detail) => + setHostStatus(host.id, { status: phase, detail }).pipe(Effect.asVoid), + ) .pipe( Effect.tapError((error) => setHostStatus(host.id, { status: "failed", detail: error.message }), @@ -321,8 +329,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") : error._tag === "DeviceHostTimeoutError" - ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.` - : `Device host ${error.hostId} failed while ${error.step}.`, + ? `Agent tools did not start within ${error.timeoutMs} ms.` + : error.step === "probe" + ? "Could not connect to this host over SSH." + : `Device support failed during ${error.step}.`, cause: error, }), ), @@ -444,7 +454,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (ready) yield* refresh(ready); }).pipe( Effect.catch((error) => - setHostStatus(host.id, { status: "failed", detail: error.message }), + setHostStatus(host.id, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), ), ), { concurrency: 4 }, @@ -452,6 +465,26 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return (yield* SynchronizedRef.get(stateRef)).state; }).pipe(Effect.withSpan("DeviceService.list")); + const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")( + function* (hostId) { + yield* resolveHost(hostId); + if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + yield* Effect.gen(function* () { + const ready = + (yield* agentReadinessIfSupported(hostId)) ?? (yield* readinessIfSupported(hostId)); + if (ready) yield* refresh(ready); + }).pipe( + Effect.catch((error) => + setHostStatus(hostId, { + status: "failed", + detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }), + ), + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }, + ); + const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")( function* (input) { const currentSettings = yield* readDeviceSettings; @@ -832,6 +865,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, + retryHost, agentCli: Effect.fail( new DeviceHostUnavailableError({ hostId: LOCAL_DEVICE_HOST_ID, diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index e84851d0722ba742f724c6f9586fca4549506381..0f7992c613796dd936eb438191742642c0302ea2 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -1,3 +1,4 @@ +import { deviceToolInstallMessage } from "@t3tools/contracts"; /** * The device host that is this machine. * @@ -556,7 +557,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( - onPhase: (phase: "installing" | "starting") => Effect.Effect, + onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, ): Effect.fn.Return { const running = yield* Ref.get(runningRef); if (running) { @@ -573,7 +574,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase("installing", deviceToolInstallMessage("device hub", inventory.tools?.hub)); + } const hubTool = yield* ensureDeviceHub(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -628,7 +632,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); - if (!installed) yield* onPhase("installing"); + if (!installed) { + const inventory = yield* summary; + yield* onPhase( + "installing", + deviceToolInstallMessage("agent tools", inventory.tools?.agent), + ); + } const agentTool = yield* ensureAgentDevice(config.baseDir).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index dadb38a02e6c24af1edff61f5f2345dded152513..9ac2fa2580faf5f5dd42e55cd1e03bf5818f3096 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -3,6 +3,7 @@ import { type DeviceHostSummary, DevicePlatformAvailability, DeviceToolVersions, + deviceToolInstallMessage, type SshDeviceHostConfig, } from "@t3tools/contracts"; import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; @@ -371,7 +372,12 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( stopped = false; if (ready) return ready; summary = yield* provide(probe(config)); - yield* onPhase("installing"); + yield* onPhase( + summary.hubInstalled ? "starting" : "installing", + summary.hubInstalled + ? undefined + : deviceToolInstallMessage("device hub", summary.tools?.hub), + ); return yield* connect().pipe( Effect.tapError(() => connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, @@ -421,7 +427,15 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( current: Effect.sync(() => ready), ensureReady, ensureAgentReady: (onPhase) => - onPhase("installing").pipe( + ensureReady(onPhase).pipe( + Effect.flatMap(() => + onPhase( + summary.agentDeviceInstalled ? "starting" : "installing", + summary.agentDeviceInstalled + ? undefined + : deviceToolInstallMessage("agent tools", summary.tools?.agent), + ), + ), Effect.flatMap(() => changeAgent(true)), Effect.flatMap((value) => value?.agentDevice diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d118bd3d012c3de60ef1197d4057ea8f117139b2..9194846d62bb87582e5dd1ac1457bf23efe4d56c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -146,7 +146,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; +import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -3453,10 +3453,17 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { "rpc.aggregate": "device", }), - [WS_METHODS.deviceList]: (_input) => - observeRpcEffect(WS_METHODS.deviceList, deviceService.list, { - "rpc.aggregate": "device", - }), + [WS_METHODS.deviceList]: (input) => + observeRpcEffect( + WS_METHODS.deviceList, + authorizeEffect( + requiredScopeForDeviceList(input), + input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list, + ), + { + "rpc.aggregate": "device", + }, + ), [WS_METHODS.deviceOpen]: (input) => observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), { "rpc.aggregate": "device", diff --git a/apps/web/src/components/device/DeviceHostUpdates.tsx b/apps/web/src/components/device/DeviceHostUpdates.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bda9fca88f936caf18b4d0a92ca4ffb3b1d2036d --- /dev/null +++ b/apps/web/src/components/device/DeviceHostUpdates.tsx @@ -0,0 +1,67 @@ +import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { deviceEnvironment } from "~/state/device"; +import { useAtomCommand } from "~/state/use-atom-command"; + +/** Shared by setup, Settings, and the Device panel so automatic updates stay visible. */ +export function DeviceHostUpdates({ + state, + environmentId, +}: { + state: DeviceServiceState; + environmentId: EnvironmentId; +}) { + const retry = useAtomCommand(deviceEnvironment.list); + const [pending, setPending] = useState(null); + if (state.hostStatus === "disabled") return null; + return ( +
+ {state.hosts.map((host) => { + const status = state.hostStatuses[host.id]; + if (!status || !["installing", "starting", "failed"].includes(status.status)) return null; + const failed = status.status === "failed"; + return ( +
+
+

{host.label}

+

+ {status.detail ?? + (failed + ? "Device support could not start." + : status.status === "installing" + ? "Installing device tools…" + : "Starting device tools…")} +

+ {failed ? ( +

+ Check the host connection and network access, then retry. Your device settings are + saved. +

+ ) : null} +
+ {failed && state.supportsHostRetry ? ( + + ) : null} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 6f1ae81eddb9217a3bc3197bdf98639b4e8ec305..130353943352970f576b7de948bffadaf4c9403c 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -1,3 +1,4 @@ +import { DeviceHostUpdates } from "./DeviceHostUpdates"; import type { DevicePlatform, DeviceServiceState, @@ -253,6 +254,7 @@ export function DevicePanel(props: { {state.hostStatusDetail} ) : null} + {bootingDevices.length > 0 ? (
Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute. @@ -319,7 +321,7 @@ export function DevicePanel(props: { ? "Opening device…" : "Starting device…" : state.hostStatus === "installing" - ? "Installing device support…" + ? (state.hostStatusDetail ?? "Installing device support…") : "Finding devices…" } /> diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 67bf14181b2191ba6508492d9a6d6749ed5469d3..5ef41b4cefd90cd5bbbc18c548f5c4ff7e11a465 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -1,3 +1,4 @@ +import { DeviceHostUpdates } from "./DeviceHostUpdates"; import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; import { Check, CircleAlert } from "lucide-react"; import { useState } from "react"; @@ -89,6 +90,7 @@ export function DeviceSetup(props: { + {step === 0 ? (

Enable the device hub

diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 70c67853e90365bd42b93f1bce8803f27caf6d2f..5f2e149d17fdcbcc2b8ee074f03046d115a2fe38 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,4 @@ +import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; @@ -784,6 +785,7 @@ function DeviceIntegrationControls({ {state.hostStatusDetail}

) : null} + {environmentId ? : null} ); diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..11587fa5996ed1bbb0a3f6560c84fab8f5963b8f --- /dev/null +++ b/packages/contracts/src/device.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@effect/vitest"; +import { deviceToolInstallMessage } from "./device.ts"; + +describe("device tool install progress", () => { + it("distinguishes a new install from an upgrade and chooses versions numerically", () => { + expect( + deviceToolInstallMessage("device hub", { + requiredVersion: "0.11.0", + installedVersions: [], + runningVersion: null, + }), + ).toBe("Installing device hub 0.11.0…"); + expect( + deviceToolInstallMessage("device hub", { + requiredVersion: "0.11.0", + installedVersions: ["0.9.0", "0.10.0"], + runningVersion: null, + }), + ).toBe("Updating device hub from 0.10.0 to 0.11.0…"); + }); +}); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 4c1b47862f37f66c424ffcf008c7db6665ee3194..afcc04dbefb317e513df95ed5e3bbbef3dcb278c 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -127,6 +127,7 @@ export const DeviceSession = Schema.Struct({ export type DeviceSession = typeof DeviceSession.Type; export const DeviceServiceState = Schema.Struct({ + supportsHostRetry: Schema.optional(Schema.Boolean), hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), @@ -150,7 +151,10 @@ export const DeviceServiceState = Schema.Struct({ }); export type DeviceServiceState = typeof DeviceServiceState.Type; -export const DeviceListInput = Schema.Struct({}); +export const DeviceListInput = Schema.Struct({ + /** Retry this host only, including agent tools if access was already granted. */ + retryHostId: Schema.optional(DeviceHostId), +}); export type DeviceListInput = typeof DeviceListInput.Type; export const DeviceConfigureInput = Schema.Struct({ @@ -560,3 +564,13 @@ export const DeviceToolError = Schema.Union([ DeviceActionUnavailableError, ]); export type DeviceToolError = typeof DeviceToolError.Type; + +export function deviceToolInstallMessage(name: string, tool: DeviceToolVersion | undefined) { + if (!tool) return `Installing ${name}…`; + const previous = + tool.runningVersion ?? + [...tool.installedVersions].sort((a, b) => a.localeCompare(b, "en", { numeric: true })).at(-1); + return previous && !tool.installedVersions.includes(tool.requiredVersion) + ? `Updating ${name} from ${previous} to ${tool.requiredVersion}…` + : `Installing ${name} ${tool.requiredVersion}…`; +}