-
Notifications
You must be signed in to change notification settings - Fork 5.6k
feat(devices): scope targets and sessions to their hosts #10854
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2cd705b
7a0c7a1
3402968
6fd791d
417e4e9
33277e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { expect, it } from "@effect/vitest"; | ||
| import { ThreadId } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| import { ServerSettingsService } from "../serverSettings.ts"; | ||
| import { DeviceHostError, DeviceHost } from "./DeviceHost.ts"; | ||
| import { makeWithHosts } from "./DeviceService.ts"; | ||
|
|
||
| it.effect("keeps hosts independent when serials collide and another host fails", () => | ||
| Effect.gen(function* () { | ||
| const host = (id: string, failed = false): DeviceHost["Service"] => { | ||
| const ready = { | ||
| hub: { origin: `http://${id}` }, | ||
| agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, | ||
| run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), | ||
| helpers: { serveSimAxSettings: null, serveSimCli: null }, | ||
| }; | ||
| return { | ||
| id, | ||
| summary: Effect.succeed({ | ||
| id, | ||
| label: id, | ||
| kind: "local", | ||
| hubInstalled: true, | ||
| agentDeviceInstalled: true, | ||
| platforms: [{ platform: "android", available: true }], | ||
| }), | ||
| platformAvailability: (platform) => Effect.succeed({ platform, available: true }), | ||
| ensureReady: () => | ||
| failed | ||
| ? Effect.fail( | ||
| new DeviceHostError({ hostId: id, step: "connect", cause: new Error("offline") }), | ||
| ) | ||
| : Effect.succeed(ready), | ||
| ensureAgentReady: () => Effect.succeed(ready), | ||
| current: Effect.succeed(ready), | ||
| stopAgent: Effect.void, | ||
| stop: Effect.void, | ||
| }; | ||
| }; | ||
| const http = HttpClient.make((request) => | ||
| Effect.succeed( | ||
| HttpClientResponse.fromWeb( | ||
| request, | ||
| Response.json({ | ||
| simulators: [], | ||
| emulators: [ | ||
| { | ||
| id: "emulator-5554", | ||
| name: "Pixel", | ||
| version: "36", | ||
| platform: "android", | ||
| booted: true, | ||
| physical: false, | ||
| }, | ||
| ], | ||
| }), | ||
| ), | ||
| ), | ||
| ); | ||
| const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); | ||
| const service = yield* makeWithHosts(hosts).pipe( | ||
| Effect.provideService(HttpClient.HttpClient, http), | ||
| ); | ||
| const listed = yield* service.list; | ||
| expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); | ||
| expect(listed.hostStatuses.offline?.status).toBe("failed"); | ||
| const threadId = ThreadId.make("thread"); | ||
| for (const hostId of ["a", "b"]) | ||
| yield* service.open({ threadId, hostId, deviceId: "emulator-5554", platform: "android" }); | ||
| yield* service.close({ threadId, hostId: "a", deviceId: "emulator-5554" }); | ||
| const state = yield* service.state; | ||
| expect(state.devices).toHaveLength(2); | ||
| expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]); | ||
| expect(state.hostStatuses.a?.status).toBe("ready"); | ||
| expect(state.hostStatuses.offline?.status).toBe("failed"); | ||
| yield* service.agentReadinessIfSupported("b"); | ||
| expect((yield* service.state).hostStatuses.b?.status).toBe("ready"); | ||
| yield* service.configure({ enabled: false }); | ||
| expect((yield* service.state).hostStatuses).toEqual({}); | ||
| }).pipe( | ||
| Effect.provide( | ||
| ServerSettingsService.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), | ||
| ), | ||
| ), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,8 +136,9 @@ interface ServiceState { | |
| const vendorPrefix = (platform: DevicePlatform) => | ||
| platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; | ||
|
|
||
| export const make = Effect.gen(function* () { | ||
| const localHost = yield* DeviceHost.DeviceHost; | ||
| export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( | ||
| hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>, | ||
|
juliusmarminge marked this conversation as resolved.
|
||
| ) { | ||
| const settings = yield* ServerSettings.ServerSettingsService; | ||
| const lifecycleLock = yield* Semaphore.make(1); | ||
| const readDeviceSettings = settings.getSettings.pipe( | ||
|
|
@@ -152,16 +153,15 @@ export const make = Effect.gen(function* () { | |
| ), | ||
| ); | ||
| const initialSettings = yield* readDeviceSettings; | ||
| const hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]> = new Map([ | ||
| [localHost.id, localHost], | ||
| ]); | ||
|
|
||
| const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); | ||
| const statePubSub = yield* PubSub.unbounded<DeviceServiceState>(); | ||
| const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary); | ||
| const stateRef = yield* SynchronizedRef.make<ServiceState>({ | ||
| state: { | ||
| hosts: initialHosts, | ||
| hostStatus: initialSettings.enabled ? "idle" : "disabled", | ||
| hostStatuses: {}, | ||
| devices: [], | ||
| sessions: [], | ||
| onboardingCompleted: initialSettings.onboardingCompleted, | ||
|
|
@@ -187,6 +187,18 @@ export const make = Effect.gen(function* () { | |
| return host; | ||
| }); | ||
|
|
||
| const setHostStatus = ( | ||
| hostId: DeviceHostId, | ||
| status: DeviceServiceState["hostStatuses"][string], | ||
| ) => | ||
| publish((state) => ({ | ||
| ...state, | ||
| ...(hostId === LOCAL_DEVICE_HOST_ID | ||
| ? { hostStatus: status.status, hostStatusDetail: status.detail } | ||
| : {}), | ||
| hostStatuses: { ...state.hostStatuses, [hostId]: status }, | ||
| })); | ||
|
|
||
| const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( | ||
| function* (hostId) { | ||
| const host = yield* resolveHost(hostId); | ||
|
|
@@ -198,34 +210,19 @@ export const make = Effect.gen(function* () { | |
| }); | ||
| } | ||
| const ready = yield* host | ||
| .ensureReady((phase) => | ||
| publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe( | ||
| Effect.asVoid, | ||
| ), | ||
| ) | ||
| .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) | ||
| .pipe( | ||
| Effect.tapError((error) => | ||
| publish((state) => ({ | ||
| ...state, | ||
| hostStatus: "failed", | ||
| hostStatusDetail: error.message, | ||
| })), | ||
| setHostStatus(host.id, { status: "failed", detail: error.message }), | ||
| ), | ||
| Effect.mapError( | ||
| (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), | ||
| ), | ||
| ); | ||
| yield* SynchronizedRef.get(stateRef).pipe( | ||
| Effect.flatMap(({ state }) => | ||
| state.hostStatus === "ready" | ||
| ? Effect.void | ||
| : publish((current) => ({ | ||
| ...current, | ||
| hostStatus: "ready", | ||
| hostStatusDetail: undefined, | ||
| })), | ||
| ), | ||
| ); | ||
| const { state } = yield* SynchronizedRef.get(stateRef); | ||
| if (state.hostStatuses[host.id]?.status !== "ready") { | ||
| yield* setHostStatus(host.id, { status: "ready" }); | ||
| } | ||
| return { hostId: host.id, ...ready }; | ||
| }, | ||
| lifecycleLock.withPermit, | ||
|
|
@@ -249,25 +246,18 @@ export const make = Effect.gen(function* () { | |
| const summary = yield* host.summary; | ||
| if (!summary.platforms.some((platform) => platform.available)) return null; | ||
| const ready = yield* host | ||
| .ensureAgentReady((phase) => | ||
| publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe( | ||
| Effect.asVoid, | ||
| ), | ||
| ) | ||
| .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) | ||
| .pipe( | ||
| Effect.tapError((error) => | ||
| publish((state) => ({ | ||
| ...state, | ||
| hostStatus: "failed", | ||
| hostStatusDetail: error.message, | ||
| })), | ||
| setHostStatus(host.id, { status: "failed", detail: error.message }), | ||
| ), | ||
| Effect.mapError( | ||
| (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), | ||
| ), | ||
| ); | ||
| const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary); | ||
| yield* publish((state) => ({ ...state, hosts: hostSummaries, hostStatus: "ready" })); | ||
| yield* publish((state) => ({ ...state, hosts: hostSummaries })); | ||
| yield* setHostStatus(host.id, { status: "ready" }); | ||
| return { hostId: host.id, ...ready }; | ||
| }, lifecycleLock.withPermit); | ||
|
|
||
|
|
@@ -357,27 +347,37 @@ export const make = Effect.gen(function* () { | |
| return yield* publish((state) => ({ | ||
| ...state, | ||
| hosts: hostSummaries, | ||
| devices, | ||
| hostStatusDetail: detail, | ||
| ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}), | ||
| devices: [ | ||
| ...state.devices.filter((device) => device.hostId !== ready.hostId), | ||
| ...devices, | ||
| ], | ||
| hostStatuses: { | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| ...state.hostStatuses, | ||
| [ready.hostId]: { status: "ready", ...(detail ? { detail } : {}) }, | ||
| }, | ||
| })); | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| const list: DeviceService["Service"]["list"] = Effect.gen(function* () { | ||
| if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; | ||
| const ready = yield* readiness(); | ||
| return yield* refresh(ready); | ||
| }).pipe( | ||
| Effect.tapError((error) => | ||
| publish((state) => | ||
| state.hostStatus === "disabled" | ||
| ? state | ||
| : { ...state, hostStatus: "failed", hostStatusDetail: error.message }, | ||
| ), | ||
| ), | ||
| Effect.withSpan("DeviceService.list"), | ||
| ); | ||
| yield* Effect.forEach( | ||
| hosts.values(), | ||
| (host) => | ||
| Effect.gen(function* () { | ||
| const ready = yield* readinessIfSupported(host.id); | ||
| if (ready) yield* refresh(ready); | ||
| }).pipe( | ||
| Effect.catch((error) => | ||
| setHostStatus(host.id, { status: "failed", detail: error.message }), | ||
| ), | ||
|
Comment on lines
+373
to
+375
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium A concurrent - Effect.catch((error) =>
- setHostStatus(host.id, { status: "failed", detail: error.message }),
- ),
+ Effect.catch((error) =>
+ SynchronizedRef.get(stateRef).pipe(
+ Effect.flatMap(({ state }) =>
+ state.hostStatus === "disabled"
+ ? Effect.void
+ : setHostStatus(host.id, { status: "failed", detail: error.message }),
+ ),
+ ),
+ ),🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| ), | ||
| { concurrency: 4 }, | ||
| ); | ||
| return (yield* SynchronizedRef.get(stateRef)).state; | ||
| }).pipe(Effect.withSpan("DeviceService.list")); | ||
|
|
||
| const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")( | ||
| function* (input) { | ||
|
|
@@ -415,6 +415,7 @@ export const make = Effect.gen(function* () { | |
| ...state, | ||
| hostStatus: nextEnabled ? "idle" : "disabled", | ||
| hostStatusDetail: undefined, | ||
| hostStatuses: {}, | ||
| devices: nextEnabled ? state.devices : [], | ||
| sessions: nextEnabled ? state.sessions : [], | ||
| bootingDevices: nextEnabled ? state.bootingDevices : [], | ||
|
|
@@ -636,6 +637,7 @@ export const make = Effect.gen(function* () { | |
| const closing = state.sessions.filter( | ||
| (session) => | ||
| session.threadId === input.threadId && | ||
| (input.hostId === undefined || session.hostId === input.hostId) && | ||
| (input.deviceId === undefined || session.deviceId === input.deviceId), | ||
| ); | ||
| if (closing.length === 0) return; | ||
|
|
@@ -749,6 +751,11 @@ export const make = Effect.gen(function* () { | |
| }); | ||
| }); | ||
|
|
||
| export const make = Effect.gen(function* () { | ||
| const host = yield* DeviceHost.DeviceHost; | ||
| return yield* makeWithHosts(new Map([[host.id, host]])); | ||
| }); | ||
|
|
||
| export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer)); | ||
|
|
||
| /** State stream for WS subscribers: current snapshot first, then every change. */ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.