authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-20 21:45:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 21:45:19-07:00
log61b25b88d7922a480790066341867f3f8ae42fcb
tree09f9c135c19b5c8ce437e14bec8ed01da6f8bfe7
parent3b836f9942143cdcf1ad7f121e78b59476585781
signature Signed by PGP key B5690EEEBB952194

feat(devices): add read-only update discovery and remote ownership (#12818)


12 files changed, 222 insertions(+), 30 deletions(-)

apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+32-7
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";1import {
2 deviceToolVersionLabels,
3 deviceToolUpdateOwnership,
4 deviceToolUpdatePolicy,
5} from "@t3tools/client-runtime/state/device";
2import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";6import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { createNativeStackNavigator } from "@react-navigation/native-stack";7import { createNativeStackNavigator } from "@react-navigation/native-stack";
4import { EnvironmentId, ThreadId } from "@t3tools/contracts";8import { EnvironmentId, ThreadId } from "@t3tools/contracts";
...@@ -131,6 +135,18 @@ function DevicePreviewScreen({...@@ -131,6 +135,18 @@ function DevicePreviewScreen({
131 void retryHost({ environmentId, input: { retryHostId: host.id } });135 void retryHost({ environmentId, input: { retryHostId: host.id } });
132 },136 },
133 })) ?? []),137 })) ?? []),
138 ...(state.data?.supportsToolInspection
139 ? [
140 {
141 id: "check-device-tools",
142 title: "Check device tool versions",
143 icon: "arrow.clockwise" as const,
144 onPress: () => {
145 void retryHost({ environmentId, input: { inspectOnly: true } });
146 },
147 },
148 ]
149 : []),
134 {150 {
135 id: "device-tools",151 id: "device-tools",
136 title: "Device tool versions",152 title: "Device tool versions",
...@@ -138,17 +154,26 @@ function DevicePreviewScreen({...@@ -138,17 +154,26 @@ function DevicePreviewScreen({
138 onPress: () =>154 onPress: () =>
139 Alert.alert(155 Alert.alert(
140 "Device tool versions",156 "Device tool versions",
141 deviceToolVersionLabels(157 deviceToolUpdateOwnership +
142 state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,158 "\n\n" +
143 ).join("\n") +159 deviceToolUpdatePolicy(
160 state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
161 ) +
162 "\n\n" +
163 deviceToolVersionLabels(
164 state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
165 ).join("\n") +
144 "\n" +166 "\n" +
145 (state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ?? ""),167 (state.data?.hosts.find((host) => host.id === preview?.session.hostId)
168 ?.toolInspectionError ??
169 state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ??
170 ""),
146 ),171 ),
147 },172 },
148 {173 {
149 id: "reload",174 id: "reload",
150 title: "Reload stream",175 title: "Reload stream",
151 icon: "arrow.clockwise",176 icon: "arrow.clockwise" as const,
152 disabled: !preview || shuttingDown,177 disabled: !preview || shuttingDown,
153 onPress: () => {178 onPress: () => {
154 setInputConnected(false);179 setInputConnected(false);
...@@ -178,7 +203,7 @@ function DevicePreviewScreen({...@@ -178,7 +203,7 @@ function DevicePreviewScreen({
178 {203 {
179 id: "rotate",204 id: "rotate",
180 title: "Rotate device",205 title: "Rotate device",
181 icon: "arrow.clockwise",206 icon: "arrow.clockwise" as const,
182 disabled: !inputConnected,207 disabled: !inputConnected,
183 onPress: () => streamRef.current?.rotate(),208 onPress: () => streamRef.current?.rotate(),
184 },209 },
apps/server/src/device/DeviceHost.ts+1
...@@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service<...@@ -80,6 +80,7 @@ export class DeviceHost extends Context.Service<
80 {80 {
81 readonly id: DeviceHostId;81 readonly id: DeviceHostId;
82 readonly summary: Effect.Effect<DeviceHostSummary>;82 readonly summary: Effect.Effect<DeviceHostSummary>;
83 readonly inspect?: Effect.Effect<DeviceHostSummary, DeviceHostError>;
83 readonly platformAvailability: (84 readonly platformAvailability: (
84 platform: DevicePlatform,85 platform: DevicePlatform,
85 ) => Effect.Effect<DevicePlatformAvailability>;86 ) => Effect.Effect<DevicePlatformAvailability>;
apps/server/src/device/DeviceService.test.ts+37
...@@ -67,6 +67,7 @@ const fixture = Effect.fn("fixture")(function* (...@@ -67,6 +67,7 @@ const fixture = Effect.fn("fixture")(function* (
67 bootError?: string,67 bootError?: string,
68 failListAfterShutdown = false,68 failListAfterShutdown = false,
69 runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,69 runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError,
70 inspectError = false,
70) {71) {
71 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);72 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
72 const starts: string[] = [];73 const starts: string[] = [];
...@@ -82,6 +83,17 @@ const fixture = Effect.fn("fixture")(function* (...@@ -82,6 +83,17 @@ const fixture = Effect.fn("fixture")(function* (
82 run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),83 run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
83 };84 };
84 const host: DeviceHost.DeviceHost["Service"] = {85 const host: DeviceHost.DeviceHost["Service"] = {
86 ...(inspectError
87 ? {
88 inspect: Effect.fail(
89 new DeviceHost.DeviceHostError({
90 hostId: LOCAL_DEVICE_HOST_ID,
91 step: "probe",
92 cause: new Error("offline"),
93 }),
94 ),
95 }
96 : {}),
85 id: LOCAL_DEVICE_HOST_ID,97 id: LOCAL_DEVICE_HOST_ID,
86 summary: Effect.succeed({98 summary: Effect.succeed({
87 id: LOCAL_DEVICE_HOST_ID,99 id: LOCAL_DEVICE_HOST_ID,
...@@ -623,3 +635,28 @@ it.effect("host retry exposes actionable failure without internal IDs or diagnos...@@ -623,3 +635,28 @@ it.effect("host retry exposes actionable failure without internal IDs or diagnos
623 });635 });
624 }).pipe(Effect.scoped),636 }).pipe(Effect.scoped),
625);637);
638
639it.effect("version discovery does not grant consent or start device tools", () =>
640 Effect.gen(function* () {
641 const { service, starts, agentStarts, requests } = yield* fixture();
642 const state = yield* service.inspect;
643 expect(state.supportsToolInspection).toBe(true);
644 expect(state.hostStatus).toBe("disabled");
645 expect(state.hosts).toHaveLength(1);
646 expect(starts).toEqual([]);
647 expect(agentStarts).toEqual([]);
648 expect(requests).toEqual([]);
649 }).pipe(Effect.scoped),
650);
651
652it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () =>
653 Effect.gen(function* () {
654 const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true);
655 const state = yield* service.inspect;
656 expect(state.supportsToolInspection).toBe(true);
657 expect(state.hostStatus).toBe("disabled");
658 expect(state.hosts[0]?.hubInstalled).toBe(true);
659 expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host");
660 expect(starts).toEqual([]);
661 }).pipe(Effect.scoped),
662);
apps/server/src/device/DeviceService.ts+35
...@@ -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 inspect: Effect.Effect<DeviceServiceState>;
129 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;130 readonly retryHost: (hostId: DeviceHostId) => Effect.Effect<DeviceServiceState, DeviceError>;
130 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;131 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;
131 readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;132 readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;
...@@ -204,6 +205,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -204,6 +205,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
204 const stateRef = yield* SynchronizedRef.make<ServiceState>({205 const stateRef = yield* SynchronizedRef.make<ServiceState>({
205 state: {206 state: {
206 supportsHostRetry: true,207 supportsHostRetry: true,
208 supportsToolInspection: true,
207 hosts: initialHosts,209 hosts: initialHosts,
208 hostStatus: initialSettings.enabled ? "idle" : "disabled",210 hostStatus: initialSettings.enabled ? "idle" : "disabled",
209 hostStatuses: {},211 hostStatuses: {},
...@@ -465,6 +467,38 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -465,6 +467,38 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
465 return (yield* SynchronizedRef.get(stateRef)).state;467 return (yield* SynchronizedRef.get(stateRef)).state;
466 }).pipe(Effect.withSpan("DeviceService.list"));468 }).pipe(Effect.withSpan("DeviceService.list"));
467469
470 const inspect = Effect.gen(function* () {
471 yield* Effect.forEach(
472 hosts.values(),
473 (host) =>
474 Effect.gen(function* () {
475 const result = yield* (host.inspect ?? host.summary).pipe(Effect.result);
476 if (hosts.get(host.id) !== host) return;
477 if (result._tag === "Failure") {
478 yield* publish((state) => ({
479 ...state,
480 hosts: state.hosts.map((value) =>
481 value.id === host.id
482 ? {
483 ...value,
484 toolInspectionError:
485 "Cannot check versions. Reconnect the host and check again. Installed tools have not been changed.",
486 }
487 : value,
488 ),
489 }));
490 return;
491 }
492 yield* publish((state) => ({
493 ...state,
494 hosts: state.hosts.map((value) => (value.id === host.id ? result.success : value)),
495 }));
496 }),
497 { concurrency: 4 },
498 );
499 return (yield* SynchronizedRef.get(stateRef)).state;
500 });
501
468 const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")(502 const retryHost: DeviceService["Service"]["retryHost"] = Effect.fn("DeviceService.retryHost")(
469 function* (hostId) {503 function* (hostId) {
470 yield* resolveHost(hostId);504 yield* resolveHost(hostId);
...@@ -866,6 +900,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -866,6 +900,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
866 ...DeviceService.of({900 ...DeviceService.of({
867 testHost,901 testHost,
868 retryHost,902 retryHost,
903 inspect,
869 agentCli: Effect.fail(904 agentCli: Effect.fail(
870 new DeviceHostUnavailableError({905 new DeviceHostUnavailableError({
871 hostId: LOCAL_DEVICE_HOST_ID,906 hostId: LOCAL_DEVICE_HOST_ID,
apps/server/src/device/SshDeviceHost.ts+9-2
...@@ -371,7 +371,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (...@@ -371,7 +371,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
371 Effect.gen(function* () {371 Effect.gen(function* () {
372 stopped = false;372 stopped = false;
373 if (ready) return ready;373 if (ready) return ready;
374 summary = yield* provide(probe(config));374 summary = yield* provide(probe(config, owner));
375 yield* onPhase(375 yield* onPhase(
376 summary.hubInstalled ? "starting" : "installing",376 summary.hubInstalled ? "starting" : "installing",
377 summary.hubInstalled377 summary.hubInstalled
...@@ -424,6 +424,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (...@@ -424,6 +424,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
424 return {424 return {
425 id: config.id,425 id: config.id,
426 summary: Effect.sync(() => summary),426 summary: Effect.sync(() => summary),
427 inspect: provide(probe(config, owner)).pipe(
428 Effect.tap((value) =>
429 Effect.sync(() => {
430 summary = value;
431 }),
432 ),
433 ),
427 current: Effect.sync(() => ready),434 current: Effect.sync(() => ready),
428 ensureReady,435 ensureReady,
429 ensureAgentReady: (onPhase) =>436 ensureAgentReady: (onPhase) =>
...@@ -452,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (...@@ -452,7 +459,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
452 stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore),459 stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore),
453 stop,460 stop,
454 platformAvailability: (platform) =>461 platformAvailability: (platform) =>
455 provide(probe(config)).pipe(462 provide(probe(config, owner)).pipe(
456 Effect.map((value) => {463 Effect.map((value) => {
457 summary = { ...summary, platforms: value.platforms };464 summary = { ...summary, platforms: value.platforms };
458 return value.platforms.find((p) => p.platform === platform)!;465 return value.platforms.find((p) => p.platform === platform)!;
apps/server/src/ws.ts+8-4
...@@ -3456,10 +3456,14 @@ const makeWsRpcLayer = (...@@ -3456,10 +3456,14 @@ const makeWsRpcLayer = (
3456 [WS_METHODS.deviceList]: (input) =>3456 [WS_METHODS.deviceList]: (input) =>
3457 observeRpcEffect(3457 observeRpcEffect(
3458 WS_METHODS.deviceList,3458 WS_METHODS.deviceList,
3459 authorizeEffect(3459 input.inspectOnly
3460 requiredScopeForDeviceList(input),3460 ? deviceService.inspect
3461 input.retryHostId ? deviceService.retryHost(input.retryHostId) : deviceService.list,3461 : authorizeEffect(
3462 ),3462 requiredScopeForDeviceList(input),
3463 input.retryHostId
3464 ? deviceService.retryHost(input.retryHostId)
3465 : deviceService.list,
3466 ),
3463 {3467 {
3464 "rpc.aggregate": "device",3468 "rpc.aggregate": "device",
3465 },3469 },
apps/web/src/components/device/DeviceToolVersions.tsx+19-1
...@@ -1,12 +1,19 @@...@@ -1,12 +1,19 @@
1import type { ReactNode } from "react";
1import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";2import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";
2import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover";3import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover";
34
4export function DeviceToolVersions({5export function DeviceToolVersions({
5 tools,6 tools,
7 action,
6 kind,8 kind,
9 owner,
10 error,
7}: {11}: {
8 tools: ToolVersions | undefined;12 tools: ToolVersions | undefined;
13 action?: ReactNode;
9 kind?: keyof ToolVersions;14 kind?: keyof ToolVersions;
15 owner?: string | undefined;
16 error?: string | undefined;
10}) {17}) {
11 const selected = kind ? tools?.[kind] : undefined;18 const selected = kind ? tools?.[kind] : undefined;
12 const version =19 const version =
...@@ -33,7 +40,9 @@ export function DeviceToolVersions({...@@ -33,7 +40,9 @@ export function DeviceToolVersions({
33 : selected40 : selected
34 ? "Not installed"41 ? "Not installed"
35 : "Version unknown"42 : "Version unknown"
36 : "Versions"}43 : error
44 ? "Versions unavailable"
45 : "Versions"}
37 </PopoverTrigger>46 </PopoverTrigger>
38 <PopoverPopup align="end" className="w-80">47 <PopoverPopup align="end" className="w-80">
39 <PopoverTitle className="text-sm">{kind ? label : "Device tools"}</PopoverTitle>48 <PopoverTitle className="text-sm">{kind ? label : "Device tools"}</PopoverTitle>
...@@ -65,6 +74,15 @@ export function DeviceToolVersions({...@@ -65,6 +74,15 @@ export function DeviceToolVersions({
65 ) : (74 ) : (
66 <p className="mt-3 text-xs text-muted-foreground">Versions have not been checked.</p>75 <p className="mt-3 text-xs text-muted-foreground">Versions have not been checked.</p>
67 )}76 )}
77 <p className="mt-4 border-t border-border/50 pt-3 text-xs text-muted-foreground">
78 {owner ? `Managed by ${owner}. ` : ""}Tools update automatically on this host when needed.
79 </p>
80 {error ? (
81 <p role="status" className="mt-2 text-xs text-destructive">
82 {error}
83 </p>
84 ) : null}
85 {action ? <div className="mt-3">{action}</div> : null}
68 </PopoverPopup>86 </PopoverPopup>
69 </Popover>87 </Popover>
70 );88 );
apps/web/src/components/settings/DeviceHostsSettings.tsx+36-11
...@@ -5,7 +5,7 @@ import { Spinner } from "../ui/spinner";...@@ -5,7 +5,7 @@ import { Spinner } from "../ui/spinner";
5import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts";5import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts";
6import { randomUUID } from "../../lib/utils";6import { randomUUID } from "../../lib/utils";
7import { useState } from "react";7import { useState } from "react";
8import { useDeviceState } from "../../state/device";8import { deviceEnvironment, useDeviceState } from "../../state/device";
9import { serverEnvironment } from "../../state/server";9import { serverEnvironment } from "../../state/server";
10import { useAtomCommand } from "../../state/use-atom-command";10import { useAtomCommand } from "../../state/use-atom-command";
11import { Button } from "../ui/button";11import { Button } from "../ui/button";
...@@ -110,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null...@@ -110,6 +110,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
110 </p>110 </p>
111 ) : null}111 ) : null}
112 <DeviceHostList112 <DeviceHostList
113 environmentLabel={environment.label}
113 environmentId={environment.environmentId}114 environmentId={environment.environmentId}
114 hosts={environment.serverConfig?.settings.deviceHosts ?? []}115 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
115 busy={projectScope || busy}116 busy={projectScope || busy}
...@@ -158,6 +159,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null...@@ -158,6 +159,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
158}159}
159160
160function DeviceHostList({161function DeviceHostList({
162 environmentLabel,
161 environmentId,163 environmentId,
162 hosts,164 hosts,
163 busy,165 busy,
...@@ -166,6 +168,7 @@ function DeviceHostList({...@@ -166,6 +168,7 @@ function DeviceHostList({
166 checks,168 checks,
167 testConnection,169 testConnection,
168}: {170}: {
171 environmentLabel: string;
169 environmentId: EnvironmentId;172 environmentId: EnvironmentId;
170 hosts: ReadonlyArray<SshDeviceHostConfig>;173 hosts: ReadonlyArray<SshDeviceHostConfig>;
171 busy: boolean;174 busy: boolean;
...@@ -175,6 +178,8 @@ function DeviceHostList({...@@ -175,6 +178,8 @@ function DeviceHostList({
175 testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"];178 testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"];
176}) {179}) {
177 const { state } = useDeviceState(environmentId);180 const { state } = useDeviceState(environmentId);
181 const retry = useAtomCommand(deviceEnvironment.list);
182 const [retrying, setRetrying] = useState<string | null>(null);
178 return (183 return (
179 <>184 <>
180 {hosts.length === 0 ? (185 {hosts.length === 0 ? (
...@@ -238,9 +243,11 @@ function DeviceHostList({...@@ -238,9 +243,11 @@ function DeviceHostList({
238 </div>243 </div>
239 <p className="truncate text-xs text-muted-foreground">{host.target}</p>244 <p className="truncate text-xs text-muted-foreground">{host.target}</p>
240 <DeviceToolVersions245 <DeviceToolVersions
246 owner={environmentLabel}
247 error={state.hosts.find((value) => value.id === host.id)?.toolInspectionError}
241 tools={248 tools={
242 (check?.status === "connected" ? check.tools : undefined) ??249 state.hosts.find((value) => value.id === host.id)?.tools ??
243 state.hosts.find((value) => value.id === host.id)?.tools250 (check?.status === "connected" ? check.tools : undefined)
244 }251 }
245 />252 />
246 {check?.status === "local" ? (253 {check?.status === "local" ? (
...@@ -290,14 +297,32 @@ function DeviceHostList({...@@ -290,14 +297,32 @@ function DeviceHostList({
290 </MenuItem>297 </MenuItem>
291 </MenuPopup>298 </MenuPopup>
292 </Menu>299 </Menu>
293 <Button300 {status?.status === "failed" &&
294 size="sm"301 state.supportsHostRetry &&
295 variant="outline"302 state.hostStatus !== "disabled" ? (
296 disabled={busy || progress !== null}303 <Button
297 onClick={() => void testConnection(host)}304 size="sm"
298 >305 variant="outline"
299 Test connection306 disabled={busy || retrying !== null}
300 </Button>307 onClick={() => {
308 setRetrying(host.id);
309 void retry({ environmentId, input: { retryHostId: host.id } }).finally(() =>
310 setRetrying(null),
311 );
312 }}
313 >
314 {retrying === host.id ? "Retrying…" : "Retry"}
315 </Button>
316 ) : (
317 <Button
318 size="sm"
319 variant="outline"
320 disabled={busy || progress !== null}
321 onClick={() => void testConnection(host)}
322 >
323 Test connection
324 </Button>
325 )}
301 </div>326 </div>
302 );327 );
303 })}328 })}
apps/web/src/components/settings/IntegrationsSettings.tsx+22-5
...@@ -685,6 +685,21 @@ function DeviceIntegrationControls({...@@ -685,6 +685,21 @@ function DeviceIntegrationControls({
685 }685 }
686 };686 };
687687
688 const checkVersions = state.supportsToolInspection ? (
689 <Button
690 size="sm"
691 variant="outline"
692 disabled={!environmentId || pending !== null || busy}
693 onClick={() => {
694 if (!environmentId) return;
695 setPending("check");
696 void list({ environmentId, input: { inspectOnly: true } }).finally(() => setPending(null));
697 }}
698 >
699 {pending === "check" ? "Checking…" : "Check versions"}
700 </Button>
701 ) : null;
702
688 return (703 return (
689 <>704 <>
690 <SettingsRow705 <SettingsRow
...@@ -695,6 +710,7 @@ function DeviceIntegrationControls({...@@ -695,6 +710,7 @@ function DeviceIntegrationControls({
695 control={710 control={
696 <>711 <>
697 <DeviceToolVersions712 <DeviceToolVersions
713 action={checkVersions}
698 kind="hub"714 kind="hub"
699 tools={state.hosts.find((host) => host.kind === "local")?.tools}715 tools={state.hosts.find((host) => host.kind === "local")?.tools}
700 />716 />
...@@ -758,6 +774,7 @@ function DeviceIntegrationControls({...@@ -758,6 +774,7 @@ function DeviceIntegrationControls({
758 control={774 control={
759 <>775 <>
760 <DeviceToolVersions776 <DeviceToolVersions
777 action={checkVersions}
761 kind="agent"778 kind="agent"
762 tools={state.hosts.find((host) => host.kind === "local")?.tools}779 tools={state.hosts.find((host) => host.kind === "local")?.tools}
763 />780 />
...@@ -780,12 +797,12 @@ function DeviceIntegrationControls({...@@ -780,12 +797,12 @@ function DeviceIntegrationControls({
780 </>797 </>
781 }798 }
782 />799 />
783 {state.hostStatus === "failed" && state.hostStatusDetail ? (800 {environmentId ? (
784 <p role="alert" className="px-4 py-3 text-xs text-destructive">801 <DeviceHostUpdates
785 {state.hostStatusDetail}802 state={{ ...state, hosts: state.hosts.filter((host) => host.kind === "local") }}
786 </p>803 environmentId={environmentId}
804 />
787 ) : null}805 ) : null}
788 {environmentId ? <DeviceHostUpdates state={state} environmentId={environmentId} /> : null}
789 <DeviceHostsSettings environmentId={environmentId} />806 <DeviceHostsSettings environmentId={environmentId} />
790 </>807 </>
791 );808 );
docs/user/devices.md+6
...@@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds,...@@ -102,3 +102,9 @@ T3 provides discovery, streaming, and control. Arrange app builds,
102installation, and connectivity to development servers such as Metro separately.102installation, and connectivity to development servers such as Metro separately.
103A simulator on another machine cannot reach Metro through your environment's103A simulator on another machine cannot reach Metro through your environment's
104localhost without forwarding or another reachable address.104localhost without forwarding or another reachable address.
105
106## Device tool updates
107
108The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings → Integrations → Check device tool versions reads installed versions without installing tools or starting devices.
109
110To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved.
packages/client-runtime/src/state/device.ts+13
...@@ -84,3 +84,16 @@ export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) {...@@ -84,3 +84,16 @@ export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) {
84 return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`;84 return `${name}: installed ${installed}; required ${tool.requiredVersion}${tool.runningVersion ? `; running ${tool.runningVersion}` : ""}.`;
85 });85 });
86}86}
87
88export function deviceToolUpdatePolicy(tools: DeviceToolVersions | undefined) {
89 if (!tools) return "Versions have not been checked. Reconnect the host and check versions.";
90 const outdated = [tools.hub, tools.agent].filter(
91 (tool) =>
92 tool.installedVersions.length > 0 && !tool.installedVersions.includes(tool.requiredVersion),
93 );
94 return outdated.length > 0
95 ? "Update pending. Required tools will install automatically when next used. The host needs network access; an older install is not used as a fallback."
96 : "Required tools are installed automatically when needed. Checking versions does not install or start anything.";
97}
98export const deviceToolUpdateOwnership =
99 "This environment's T3 server chooses device tool versions for itself and its SSH hosts. Update that server to receive newer tool versions; updating only your browser or mobile app does not update a remote server.";
packages/contracts/src/device.ts+4
...@@ -93,6 +93,7 @@ export const DeviceHostSummary = Schema.Struct({...@@ -93,6 +93,7 @@ export const DeviceHostSummary = Schema.Struct({
93 label: TrimmedNonEmptyString,93 label: TrimmedNonEmptyString,
94 platforms: Schema.Array(DevicePlatformAvailability),94 platforms: Schema.Array(DevicePlatformAvailability),
95 tools: Schema.optional(DeviceToolVersions),95 tools: Schema.optional(DeviceToolVersions),
96 toolInspectionError: Schema.optional(Schema.String),
96 hubInstalled: Schema.Boolean,97 hubInstalled: Schema.Boolean,
97 agentDeviceInstalled: Schema.Boolean,98 agentDeviceInstalled: Schema.Boolean,
98});99});
...@@ -128,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type;...@@ -128,6 +129,7 @@ export type DeviceSession = typeof DeviceSession.Type;
128129
129export const DeviceServiceState = Schema.Struct({130export const DeviceServiceState = Schema.Struct({
130 supportsHostRetry: Schema.optional(Schema.Boolean),131 supportsHostRetry: Schema.optional(Schema.Boolean),
132 supportsToolInspection: Schema.optional(Schema.Boolean),
131 hosts: Schema.Array(DeviceHostSummary),133 hosts: Schema.Array(DeviceHostSummary),
132 hostStatus: DeviceHostStatus,134 hostStatus: DeviceHostStatus,
133 hostStatusDetail: Schema.optional(Schema.String),135 hostStatusDetail: Schema.optional(Schema.String),
...@@ -152,6 +154,8 @@ export const DeviceServiceState = Schema.Struct({...@@ -152,6 +154,8 @@ export const DeviceServiceState = Schema.Struct({
152export type DeviceServiceState = typeof DeviceServiceState.Type;154export type DeviceServiceState = typeof DeviceServiceState.Type;
153155
154export const DeviceListInput = Schema.Struct({156export const DeviceListInput = Schema.Struct({
157 /** Read inventory without installing tools or starting helpers. */
158 inspectOnly: Schema.optional(Schema.Boolean),
155 /** Retry this host only, including agent tools if access was already granted. */159 /** Retry this host only, including agent tools if access was already granted. */
156 retryHostId: Schema.optional(DeviceHostId),160 retryHostId: Schema.optional(DeviceHostId),
157});161});