| 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,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 | }; |
| 118 | 119 | ||
| 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"; |
| 9 | import { describe, expect, it } from "@effect/vitest"; | 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 | describe("RPC authorization scopes", () => { | 17 | describe("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 | |||
| 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,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 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. */ | ||
| 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,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 | NodeRuntimeUnavailableError | 98 | 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 | |||
| 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,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* host | 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 | .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* host | 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 | .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")); |
| 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 | 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 @@ |
| 1 | import { 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; |
| 557 | 558 | ||
| 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"; |
| 8 | import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; | 9 | import { 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?.agentDevice | 441 | 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"; |
| 146 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | 146 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| 147 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | 147 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| 148 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 148 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 149 | import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; | 149 | import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; |
| 150 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 150 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 151 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 151 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 152 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | 152 | import * 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 @@ | ||
| 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,3 +1,4 @@ |
| 1 | import { DeviceHostUpdates } from "./DeviceHostUpdates"; | ||
| 1 | import type { | 2 | import 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 @@ |
| 1 | import { DeviceHostUpdates } from "./DeviceHostUpdates"; | ||
| 1 | import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; | 2 | import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { Check, CircleAlert } from "lucide-react"; | 3 | import { Check, CircleAlert } from "lucide-react"; |
| 3 | import { useState } from "react"; | 4 | import { useState } from "react"; |
| ... | @@ -89,6 +90,7 @@ export function DeviceSetup(props: { | ... | @@ -89,6 +90,7 @@ export function DeviceSetup(props: { |
| 89 | </WizardHeader> | 90 | </WizardHeader> |
| 90 | 91 | ||
| 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 @@ |
| 1 | import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; | ||
| 1 | import { DeviceToolVersions } from "../device/DeviceToolVersions"; | 2 | import { DeviceToolVersions } from "../device/DeviceToolVersions"; |
| 2 | import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; | 3 | import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; |
| 3 | import { ScopedSwitch } from "./ScopedSwitch"; | 4 | import { 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 @@ | ||
| 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,6 +127,7 @@ export const DeviceSession = Schema.Struct({ |
| 127 | export type DeviceSession = typeof DeviceSession.Type; | 127 | export type DeviceSession = typeof DeviceSession.Type; |
| 128 | 128 | ||
| 129 | export const DeviceServiceState = Schema.Struct({ | 129 | export 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 | }); |
| 151 | export type DeviceServiceState = typeof DeviceServiceState.Type; | 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 | export type DeviceListInput = typeof DeviceListInput.Type; | 158 | export type DeviceListInput = typeof DeviceListInput.Type; |
| 155 | 159 | ||
| 156 | export const DeviceConfigureInput = Schema.Struct({ | 160 | export 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 | ]); |
| 562 | export type DeviceToolError = typeof DeviceToolError.Type; | 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 | } |