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({...@@ -74,6 +74,7 @@ function DevicePreviewScreen({
74 const [inputConnected, setInputConnected] = useState(false);74 const [inputConnected, setInputConnected] = useState(false);
75 const [streamAttempt, setStreamAttempt] = useState(0);75 const [streamAttempt, setStreamAttempt] = useState(0);
76 const [shuttingDown, setShuttingDown] = useState(false);76 const [shuttingDown, setShuttingDown] = useState(false);
77 const retryHost = useAtomCommand(deviceEnvironment.list);
77 const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false });78 const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false });
78 const streamRef = useRef<DeviceStreamRef>(null);79 const streamRef = useRef<DeviceStreamRef>(null);
79 const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} }));80 const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} }));
...@@ -117,6 +118,19 @@ function DevicePreviewScreen({...@@ -117,6 +118,19 @@ function DevicePreviewScreen({
117 };118 };
118119
119 const controls: ScreenHeaderMenuItem[] = [120 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 })) ?? []),
120 {134 {
121 id: "device-tools",135 id: "device-tools",
122 title: "Device tool versions",136 title: "Device tool versions",
...@@ -126,7 +140,9 @@ function DevicePreviewScreen({...@@ -126,7 +140,9 @@ function DevicePreviewScreen({
126 "Device tool versions",140 "Device tool versions",
127 deviceToolVersionLabels(141 deviceToolVersionLabels(
128 state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,142 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 ?? ""),
130 ),146 ),
131 },147 },
132 {148 {
apps/server/src/auth/RpcAuthorization.test.ts+12-1
...@@ -8,7 +8,11 @@ import {...@@ -8,7 +8,11 @@ import {
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { describe, expect, it } from "@effect/vitest";9import { 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
13describe("RPC authorization scopes", () => {17describe("RPC authorization scopes", () => {
14 it("declares exactly one scope for every RPC in the server group", () => {18 it("declares exactly one scope for every RPC in the server group", () => {
...@@ -71,3 +75,10 @@ describe("RPC authorization scopes", () => {...@@ -71,3 +75,10 @@ describe("RPC authorization scopes", () => {
71 }75 }
72 });76 });
73});77});
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 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type DeviceListInput,
2 AuthAccessReadScope,3 AuthAccessReadScope,
3 AuthOrchestrationOperateScope,4 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,5 AuthOrchestrationReadScope,
...@@ -180,3 +181,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope...@@ -180,3 +181,7 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope
180 }181 }
181 return requiredScope;182 return requiredScope;
182}183}
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<...@@ -88,11 +88,11 @@ export class DeviceHost extends Context.Service<
88 * concurrent callers share one start, and a ready host returns immediately.88 * concurrent callers share one start, and a ready host returns immediately.
89 */89 */
90 readonly ensureReady: (90 readonly ensureReady: (
91 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,91 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
92 ) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>;92 ) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>;
93 /** Installs and starts agent-device after the user grants agent access. */93 /** Installs and starts agent-device after the user grants agent access. */
94 readonly ensureAgentReady: (94 readonly ensureAgentReady: (
95 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,95 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
96 ) => Effect.Effect<96 ) => Effect.Effect<
97 DeviceHostAgentReady,97 DeviceHostAgentReady,
98 DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError98 DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError
apps/server/src/device/DeviceService.test.ts+54-2
...@@ -66,7 +66,7 @@ const fixture = Effect.fn("fixture")(function* (...@@ -66,7 +66,7 @@ const fixture = Effect.fn("fixture")(function* (
66 onBoot: Effect.Effect<void> = Effect.void,66 onBoot: Effect.Effect<void> = Effect.void,
67 bootError?: string,67 bootError?: string,
68 failListAfterShutdown = false,68 failListAfterShutdown = false,
69 runtimeFailure?: NodeRuntimeUnavailableError,69 runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,
70) {70) {
71 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);71 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
72 const starts: string[] = [];72 const starts: string[] = [];
...@@ -96,7 +96,7 @@ const fixture = Effect.fn("fixture")(function* (...@@ -96,7 +96,7 @@ const fixture = Effect.fn("fixture")(function* (
96 Effect.gen(function* () {96 Effect.gen(function* () {
97 if (runtimeFailure) return yield* runtimeFailure;97 if (runtimeFailure) return yield* runtimeFailure;
98 starts.push("start");98 starts.push("start");
99 yield* onPhase("starting");99 yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…");
100 return ready;100 return ready;
101 }),101 }),
102 ensureAgentReady: (onPhase) =>102 ensureAgentReady: (onPhase) =>
...@@ -571,3 +571,55 @@ it.effect.each([...@@ -571,3 +571,55 @@ it.effect.each([
571 Effect.scoped,571 Effect.scoped,
572 ),572 ),
573);573);
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<...@@ -126,6 +126,7 @@ export class DeviceService extends Context.Service<
126 ) => Effect.Effect<DeviceServiceState, DeviceError>;126 ) => Effect.Effect<DeviceServiceState, DeviceError>;
127 /** Refreshes devices only after device support has been enabled. */127 /** Refreshes devices only after device support has been enabled. */
128 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;128 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;
129 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;
129 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;130 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;
130 readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;131 readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;
131 readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect<void, DeviceError>;132 readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect<void, DeviceError>;
...@@ -202,6 +203,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -202,6 +203,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
202 let publishedHosts = new Map(hosts);203 let publishedHosts = new Map(hosts);
203 const stateRef = yield* SynchronizedRef.make<ServiceState>({204 const stateRef = yield* SynchronizedRef.make<ServiceState>({
204 state: {205 state: {
206 supportsHostRetry: true,
205 hosts: initialHosts,207 hosts: initialHosts,
206 hostStatus: initialSettings.enabled ? "idle" : "disabled",208 hostStatus: initialSettings.enabled ? "idle" : "disabled",
207 hostStatuses: {},209 hostStatuses: {},
...@@ -257,7 +259,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -257,7 +259,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
257 });259 });
258 }260 }
259 const ready = yield* host261 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 )
261 .pipe(265 .pipe(
262 Effect.tapError((error) =>266 Effect.tapError((error) =>
263 setHostStatus(host.id, { status: "failed", detail: error.message }),267 setHostStatus(host.id, { status: "failed", detail: error.message }),
...@@ -269,7 +273,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -269,7 +273,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
269 reason:273 reason:
270 error._tag === "NodeRuntimeUnavailableError"274 error._tag === "NodeRuntimeUnavailableError"
271 ? nodeRuntimeUnavailableMessage("Local device support")275 ? 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}.`,
273 cause: error,279 cause: error,
274 }),280 }),
275 ),281 ),
...@@ -308,7 +314,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -308,7 +314,9 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
308 if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))314 if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
309 return null;315 return null;
310 const ready = yield* host316 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 )
312 .pipe(320 .pipe(
313 Effect.tapError((error) =>321 Effect.tapError((error) =>
314 setHostStatus(host.id, { status: "failed", detail: error.message }),322 setHostStatus(host.id, { status: "failed", detail: error.message }),
...@@ -321,8 +329,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -321,8 +329,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
321 error._tag === "NodeRuntimeUnavailableError"329 error._tag === "NodeRuntimeUnavailableError"
322 ? nodeRuntimeUnavailableMessage("Local device support")330 ? nodeRuntimeUnavailableMessage("Local device support")
323 : error._tag === "DeviceHostTimeoutError"331 : error._tag === "DeviceHostTimeoutError"
324 ? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.`332 ? `Agent tools did not start within ${error.timeoutMs} ms.`
325 : `Device host ${error.hostId} failed while ${error.step}.`,333 : error.step === "probe"
334 ? "Could not connect to this host over SSH."
335 : `Device support failed during ${error.step}.`,
326 cause: error,336 cause: error,
327 }),337 }),
328 ),338 ),
...@@ -444,7 +454,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -444,7 +454,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
444 if (ready) yield* refresh(ready);454 if (ready) yield* refresh(ready);
445 }).pipe(455 }).pipe(
446 Effect.catch((error) =>456 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 }),
448 ),461 ),
449 ),462 ),
450 { concurrency: 4 },463 { concurrency: 4 },
...@@ -452,6 +465,26 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -452,6 +465,26 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
452 return (yield* SynchronizedRef.get(stateRef)).state;465 return (yield* SynchronizedRef.get(stateRef)).state;
453 }).pipe(Effect.withSpan("DeviceService.list"));466 }).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
455 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(488 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(
456 function* (input) {489 function* (input) {
457 const currentSettings = yield* readDeviceSettings;490 const currentSettings = yield* readDeviceSettings;
...@@ -832,6 +865,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -832,6 +865,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
832 return {865 return {
833 ...DeviceService.of({866 ...DeviceService.of({
834 testHost,867 testHost,
868 retryHost,
835 agentCli: Effect.fail(869 agentCli: Effect.fail(
836 new DeviceHostUnavailableError({870 new DeviceHostUnavailableError({
837 hostId: LOCAL_DEVICE_HOST_ID,871 hostId: LOCAL_DEVICE_HOST_ID,
apps/server/src/device/LocalDeviceHost.ts+13-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { deviceToolInstallMessage } from "@t3tools/contracts";
1/**2/**
2 * The device host that is this machine.3 * The device host that is this machine.
3 *4 *
...@@ -556,7 +557,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -556,7 +557,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
556 let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null;557 let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null;
557558
558 const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (559 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>,
560 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {561 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError | NodeRuntimeUnavailableError> {
561 const running = yield* Ref.get(runningRef);562 const running = yield* Ref.get(runningRef);
562 if (running) {563 if (running) {
...@@ -573,7 +574,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -573,7 +574,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
573 Effect.provideService(FileSystem.FileSystem, fs),574 Effect.provideService(FileSystem.FileSystem, fs),
574 Effect.provideService(Path.Path, path),575 Effect.provideService(Path.Path, path),
575 );576 );
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 }
577 const hubTool = yield* ensureDeviceHub(config.baseDir).pipe(581 const hubTool = yield* ensureDeviceHub(config.baseDir).pipe(
578 Effect.provideService(FileSystem.FileSystem, fs),582 Effect.provideService(FileSystem.FileSystem, fs),
579 Effect.provideService(Path.Path, path),583 Effect.provideService(Path.Path, path),
...@@ -628,7 +632,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -628,7 +632,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
628 Effect.provideService(FileSystem.FileSystem, fs),632 Effect.provideService(FileSystem.FileSystem, fs),
629 Effect.provideService(Path.Path, path),633 Effect.provideService(Path.Path, path),
630 );634 );
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 }
632 const agentTool = yield* ensureAgentDevice(config.baseDir).pipe(642 const agentTool = yield* ensureAgentDevice(config.baseDir).pipe(
633 Effect.provideService(FileSystem.FileSystem, fs),643 Effect.provideService(FileSystem.FileSystem, fs),
634 Effect.provideService(Path.Path, path),644 Effect.provideService(Path.Path, path),
apps/server/src/device/SshDeviceHost.ts+16-2
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 type DeviceHostSummary,3 type DeviceHostSummary,
4 DevicePlatformAvailability,4 DevicePlatformAvailability,
5 DeviceToolVersions,5 DeviceToolVersions,
6 deviceToolInstallMessage,
6 type SshDeviceHostConfig,7 type SshDeviceHostConfig,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";9import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
...@@ -371,7 +372,12 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (...@@ -371,7 +372,12 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
371 stopped = false;372 stopped = false;
372 if (ready) return ready;373 if (ready) return ready;
373 summary = yield* provide(probe(config));374 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 );
375 return yield* connect().pipe(381 return yield* connect().pipe(
376 Effect.tapError(() =>382 Effect.tapError(() =>
377 connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void,383 connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void,
...@@ -421,7 +427,15 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (...@@ -421,7 +427,15 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
421 current: Effect.sync(() => ready),427 current: Effect.sync(() => ready),
422 ensureReady,428 ensureReady,
423 ensureAgentReady: (onPhase) =>429 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 ),
425 Effect.flatMap(() => changeAgent(true)),439 Effect.flatMap(() => changeAgent(true)),
426 Effect.flatMap((value) =>440 Effect.flatMap((value) =>
427 value?.agentDevice441 value?.agentDevice
apps/server/src/ws.ts+12-5
...@@ -146,7 +146,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";...@@ -146,7 +146,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
146import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";146import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
147import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";147import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
148import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";148import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
149import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";149import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts";
150import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";150import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
151import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";151import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
152import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";152import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
...@@ -3453,10 +3453,17 @@ const makeWsRpcLayer = (...@@ -3453,10 +3453,17 @@ const makeWsRpcLayer = (
3453 observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), {3453 observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), {
3454 "rpc.aggregate": "device",3454 "rpc.aggregate": "device",
3455 }),3455 }),
3456 [WS_METHODS.deviceList]: (_input) =>3456 [WS_METHODS.deviceList]: (input) =>
3457 observeRpcEffect(WS_METHODS.deviceList, deviceService.list, {3457 observeRpcEffect(
3458 "rpc.aggregate": "device",3458 WS_METHODS.deviceList,
3459 }),3459 authorizeEffect(
3460 requiredScopeForDeviceList(input),
3461 input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list,
3462 ),
3463 {
3464 "rpc.aggregate": "device",
3465 },
3466 ),
3460 [WS_METHODS.deviceOpen]: (input) =>3467 [WS_METHODS.deviceOpen]: (input) =>
3461 observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), {3468 observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), {
3462 "rpc.aggregate": "device",3469 "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 @@...@@ -1,3 +1,4 @@
1import { DeviceHostUpdates } from "./DeviceHostUpdates";
1import type {2import type {
2 DevicePlatform,3 DevicePlatform,
3 DeviceServiceState,4 DeviceServiceState,
...@@ -253,6 +254,7 @@ export function DevicePanel(props: {...@@ -253,6 +254,7 @@ export function DevicePanel(props: {
253 {state.hostStatusDetail}254 {state.hostStatusDetail}
254 </div>255 </div>
255 ) : null}256 ) : null}
257 <DeviceHostUpdates state={state} environmentId={environmentId} />
256 {bootingDevices.length > 0 ? (258 {bootingDevices.length > 0 ? (
257 <div role="status" className="border-b px-3 py-2 text-xs text-muted-foreground">259 <div role="status" className="border-b px-3 py-2 text-xs text-muted-foreground">
258 Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute.260 Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute.
...@@ -319,7 +321,7 @@ export function DevicePanel(props: {...@@ -319,7 +321,7 @@ export function DevicePanel(props: {
319 ? "Opening device…"321 ? "Opening device…"
320 : "Starting device…"322 : "Starting device…"
321 : state.hostStatus === "installing"323 : state.hostStatus === "installing"
322 ? "Installing device support…"324 ? (state.hostStatusDetail ?? "Installing device support…")
323 : "Finding devices…"325 : "Finding devices…"
324 }326 }
325 />327 />
apps/web/src/components/device/DeviceSetup.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { DeviceHostUpdates } from "./DeviceHostUpdates";
1import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";2import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
2import { Check, CircleAlert } from "lucide-react";3import { Check, CircleAlert } from "lucide-react";
3import { useState } from "react";4import { useState } from "react";
...@@ -89,6 +90,7 @@ export function DeviceSetup(props: {...@@ -89,6 +90,7 @@ export function DeviceSetup(props: {
89 </WizardHeader>90 </WizardHeader>
9091
91 <WizardPanel>92 <WizardPanel>
93 <DeviceHostUpdates state={props.state} environmentId={props.environmentId} />
92 {step === 0 ? (94 {step === 0 ? (
93 <section className="space-y-3 text-sm">95 <section className="space-y-3 text-sm">
94 <h3 className="font-medium">Enable the device hub</h3>96 <h3 className="font-medium">Enable the device hub</h3>
apps/web/src/components/settings/IntegrationsSettings.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { DeviceHostUpdates } from "../device/DeviceHostUpdates";
1import { DeviceToolVersions } from "../device/DeviceToolVersions";2import { DeviceToolVersions } from "../device/DeviceToolVersions";
2import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";3import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
3import { ScopedSwitch } from "./ScopedSwitch";4import { ScopedSwitch } from "./ScopedSwitch";
...@@ -784,6 +785,7 @@ function DeviceIntegrationControls({...@@ -784,6 +785,7 @@ function DeviceIntegrationControls({
784 {state.hostStatusDetail}785 {state.hostStatusDetail}
785 </p>786 </p>
786 ) : null}787 ) : null}
788 {environmentId ? <DeviceHostUpdates state={state} environmentId={environmentId} /> : null}
787 <DeviceHostsSettings environmentId={environmentId} />789 <DeviceHostsSettings environmentId={environmentId} />
788 </>790 </>
789 );791 );
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({...@@ -127,6 +127,7 @@ export const DeviceSession = Schema.Struct({
127export type DeviceSession = typeof DeviceSession.Type;127export type DeviceSession = typeof DeviceSession.Type;
128128
129export const DeviceServiceState = Schema.Struct({129export const DeviceServiceState = Schema.Struct({
130 supportsHostRetry: Schema.optional(Schema.Boolean),
130 hosts: Schema.Array(DeviceHostSummary),131 hosts: Schema.Array(DeviceHostSummary),
131 hostStatus: DeviceHostStatus,132 hostStatus: DeviceHostStatus,
132 hostStatusDetail: Schema.optional(Schema.String),133 hostStatusDetail: Schema.optional(Schema.String),
...@@ -150,7 +151,10 @@ export const DeviceServiceState = Schema.Struct({...@@ -150,7 +151,10 @@ export const DeviceServiceState = Schema.Struct({
150});151});
151export type DeviceServiceState = typeof DeviceServiceState.Type;152export 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});
154export type DeviceListInput = typeof DeviceListInput.Type;158export type DeviceListInput = typeof DeviceListInput.Type;
155159
156export const DeviceConfigureInput = Schema.Struct({160export const DeviceConfigureInput = Schema.Struct({
...@@ -560,3 +564,13 @@ export const DeviceToolError = Schema.Union([...@@ -560,3 +564,13 @@ export const DeviceToolError = Schema.Union([
560 DeviceActionUnavailableError,564 DeviceActionUnavailableError,
561]);565]);
562export type DeviceToolError = typeof DeviceToolError.Type;566export 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}