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