History

feat(devices): scope targets and sessions to their hosts (#10854)

Julius Marminge committed 2 months ago

Commit e022fa41 parent dca7b5919 files · +649 −257Browse files

Showing the changes to apps/server/src (7 of 19 files). Show all

Unified · Split

7 files
  1. apps/server/src/device/DeviceHubProxy.ts +2 −1
  2. apps/server/src/device/DeviceMultiHost.test.ts +86 −0
  3. apps/server/src/device/DeviceService.test.ts +1 −0
  4. apps/server/src/device/DeviceService.ts +58 −51
  5. apps/server/src/mcp/McpDeviceToolkit.test.ts +1 −0
  6. apps/server/src/mcp/toolkits/device/handlers.ts +4 −1
  7. apps/server/src/server.test.ts +2 −1
apps/server/src/device/DeviceHubProxy.ts+2−1
@@ -196,7 +196,7 @@ const handler = Effect.gen(function* () { · 195 unmodified lines
196 (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath));196 (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath));
197 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);197 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);
198 const devices = yield* DeviceService.DeviceService;198 const devices = yield* DeviceService.DeviceService;
199 const ready = yield* devices.currentReadiness();199 const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);
200 if (!ready) {200 if (!ready) {
201 return HttpServerResponse.text("Device hub is not running", { status: 503 });201 return HttpServerResponse.text("Device hub is not running", { status: 503 });
202 }202 }
@@ -206,6 +206,7 @@ const handler = Effect.gen(function* () { · 3 unmodified lines
206 // The ticket authenticates here and must not travel on to the hub.206 // The ticket authenticates here and must not travel on to the hub.
207 const upstreamSearch = new URLSearchParams(url.value.search);207 const upstreamSearch = new URLSearchParams(url.value.search);
208 upstreamSearch.delete("wsTicket");208 upstreamSearch.delete("wsTicket");
209 upstreamSearch.delete("hostId");
209 const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";210 const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";
210 const upstreamPath = `${hubPath}${search}`;211 const upstreamPath = `${hubPath}${search}`;
211 if (upgrade) {212 if (upgrade) {
apps/server/src/device/DeviceMultiHost.test.ts+86−0
@@ -0,0 +1,86 @@
1import { expect, it } from "@effect/vitest";
2import { ThreadId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import { HttpClient, HttpClientResponse } from "effect/unstable/http";
5import { ServerSettingsService } from "../serverSettings.ts";
6import { DeviceHostError, DeviceHost } from "./DeviceHost.ts";
7import { makeWithHosts } from "./DeviceService.ts";
8
9it.effect("keeps hosts independent when serials collide and another host fails", () =>
10 Effect.gen(function* () {
11 const host = (id: string, failed = false): DeviceHost["Service"] => {
12 const ready = {
13 hub: { origin: `http://${id}` },
14 agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" },
15 run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
16 helpers: { serveSimAxSettings: null, serveSimCli: null },
17 };
18 return {
19 id,
20 summary: Effect.succeed({
21 id,
22 label: id,
23 kind: "local",
24 hubInstalled: true,
25 agentDeviceInstalled: true,
26 platforms: [{ platform: "android", available: true }],
27 }),
28 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
29 ensureReady: () =>
30 failed
31 ? Effect.fail(
32 new DeviceHostError({ hostId: id, step: "connect", cause: new Error("offline") }),
33 )
34 : Effect.succeed(ready),
35 ensureAgentReady: () => Effect.succeed(ready),
36 current: Effect.succeed(ready),
37 stopAgent: Effect.void,
38 stop: Effect.void,
39 };
40 };
41 const http = HttpClient.make((request) =>
42 Effect.succeed(
43 HttpClientResponse.fromWeb(
44 request,
45 Response.json({
46 simulators: [],
47 emulators: [
48 {
49 id: "emulator-5554",
50 name: "Pixel",
51 version: "36",
52 platform: "android",
53 booted: true,
54 physical: false,
55 },
56 ],
57 }),
58 ),
59 ),
60 );
61 const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")]));
62 const service = yield* makeWithHosts(hosts).pipe(
63 Effect.provideService(HttpClient.HttpClient, http),
64 );
65 const listed = yield* service.list;
66 expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);
67 expect(listed.hostStatuses.offline?.status).toBe("failed");
68 const threadId = ThreadId.make("thread");
69 for (const hostId of ["a", "b"])
70 yield* service.open({ threadId, hostId, deviceId: "emulator-5554", platform: "android" });
71 yield* service.close({ threadId, hostId: "a", deviceId: "emulator-5554" });
72 const state = yield* service.state;
73 expect(state.devices).toHaveLength(2);
74 expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]);
75 expect(state.hostStatuses.a?.status).toBe("ready");
76 expect(state.hostStatuses.offline?.status).toBe("failed");
77 yield* service.agentReadinessIfSupported("b");
78 expect((yield* service.state).hostStatuses.b?.status).toBe("ready");
79 yield* service.configure({ enabled: false });
80 expect((yield* service.state).hostStatuses).toEqual({});
81 }).pipe(
82 Effect.provide(
83 ServerSettingsService.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }),
84 ),
85 ),
86);
apps/server/src/device/DeviceService.test.ts+1−0
@@ -21,6 +21,7 @@ import { type DeviceService, make, stateStream } from "./DeviceService.ts"; · 20 unmodified lines
21const baseState: DeviceServiceState = {21const baseState: DeviceServiceState = {
22 hosts: [],22 hosts: [],
23 hostStatus: "idle",23 hostStatus: "idle",
24 hostStatuses: {},
24 devices: [],25 devices: [],
25 sessions: [],26 sessions: [],
26 onboardingCompleted: false,27 onboardingCompleted: false,
apps/server/src/device/DeviceService.ts+58−51
@@ -136,8 +136,9 @@ interface ServiceState { · 135 unmodified lines
136const vendorPrefix = (platform: DevicePlatform) =>136const vendorPrefix = (platform: DevicePlatform) =>
137 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";137 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
138 138
139export const make = Effect.gen(function* () {139export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
140 const localHost = yield* DeviceHost.DeviceHost;140 hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,
141) {
141 const settings = yield* ServerSettings.ServerSettingsService;142 const settings = yield* ServerSettings.ServerSettingsService;
142 const lifecycleLock = yield* Semaphore.make(1);143 const lifecycleLock = yield* Semaphore.make(1);
143 const readDeviceSettings = settings.getSettings.pipe(144 const readDeviceSettings = settings.getSettings.pipe(
@@ -152,9 +153,7 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
152 ),153 ),
153 );154 );
154 const initialSettings = yield* readDeviceSettings;155 const initialSettings = yield* readDeviceSettings;
155 const hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]> = new Map([156
156 [localHost.id, localHost],
157 ]);
158 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);157 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
159 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();158 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();
160 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);159 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
@@ -162,6 +161,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
162 state: {161 state: {
163 hosts: initialHosts,162 hosts: initialHosts,
164 hostStatus: initialSettings.enabled ? "idle" : "disabled",163 hostStatus: initialSettings.enabled ? "idle" : "disabled",
164 hostStatuses: {},
165 devices: [],165 devices: [],
166 sessions: [],166 sessions: [],
167 onboardingCompleted: initialSettings.onboardingCompleted,167 onboardingCompleted: initialSettings.onboardingCompleted,
@@ -187,6 +187,18 @@ export const make = Effect.gen(function* () { · 19 unmodified lines
187 return host;187 return host;
188 });188 });
189 189
190 const setHostStatus = (
191 hostId: DeviceHostId,
192 status: DeviceServiceState["hostStatuses"][string],
193 ) =>
194 publish((state) => ({
195 ...state,
196 ...(hostId === LOCAL_DEVICE_HOST_ID
197 ? { hostStatus: status.status, hostStatusDetail: status.detail }
198 : {}),
199 hostStatuses: { ...state.hostStatuses, [hostId]: status },
200 }));
201
190 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(202 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
191 function* (hostId) {203 function* (hostId) {
192 const host = yield* resolveHost(hostId);204 const host = yield* resolveHost(hostId);
@@ -198,34 +210,19 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
198 });210 });
199 }211 }
200 const ready = yield* host212 const ready = yield* host
201 .ensureReady((phase) =>213 .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid))
202 publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
203 Effect.asVoid,
204 ),
205 )
206 .pipe(214 .pipe(
207 Effect.tapError((error) =>215 Effect.tapError((error) =>
208 publish((state) => ({216 setHostStatus(host.id, { status: "failed", detail: error.message }),
209 ...state,
210 hostStatus: "failed",
211 hostStatusDetail: error.message,
212 })),
213 ),217 ),
214 Effect.mapError(218 Effect.mapError(
215 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),219 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
216 ),220 ),
217 );221 );
218 yield* SynchronizedRef.get(stateRef).pipe(222 const { state } = yield* SynchronizedRef.get(stateRef);
219 Effect.flatMap(({ state }) =>223 if (state.hostStatuses[host.id]?.status !== "ready") {
220 state.hostStatus === "ready"224 yield* setHostStatus(host.id, { status: "ready" });
221 ? Effect.void225 }
222 : publish((current) => ({
223 ...current,
224 hostStatus: "ready",
225 hostStatusDetail: undefined,
226 })),
227 ),
228 );
229 return { hostId: host.id, ...ready };226 return { hostId: host.id, ...ready };
230 },227 },
231 lifecycleLock.withPermit,228 lifecycleLock.withPermit,
@@ -249,25 +246,18 @@ export const make = Effect.gen(function* () { · 17 unmodified lines
249 const summary = yield* host.summary;246 const summary = yield* host.summary;
250 if (!summary.platforms.some((platform) => platform.available)) return null;247 if (!summary.platforms.some((platform) => platform.available)) return null;
251 const ready = yield* host248 const ready = yield* host
252 .ensureAgentReady((phase) =>249 .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
253 publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
254 Effect.asVoid,
255 ),
256 )
257 .pipe(250 .pipe(
258 Effect.tapError((error) =>251 Effect.tapError((error) =>
259 publish((state) => ({252 setHostStatus(host.id, { status: "failed", detail: error.message }),
260 ...state,
261 hostStatus: "failed",
262 hostStatusDetail: error.message,
263 })),
264 ),253 ),
265 Effect.mapError(254 Effect.mapError(
266 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),255 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
267 ),256 ),
268 );257 );
269 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);258 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
270 yield* publish((state) => ({ ...state, hosts: hostSummaries, hostStatus: "ready" }));259 yield* publish((state) => ({ ...state, hosts: hostSummaries }));
260 yield* setHostStatus(host.id, { status: "ready" });
271 return { hostId: host.id, ...ready };261 return { hostId: host.id, ...ready };
272 }, lifecycleLock.withPermit);262 }, lifecycleLock.withPermit);
273 263
@@ -357,8 +347,15 @@ export const make = Effect.gen(function* () { · 83 unmodified lines
357 return yield* publish((state) => ({347 return yield* publish((state) => ({
358 ...state,348 ...state,
359 hosts: hostSummaries,349 hosts: hostSummaries,
360 devices,350 ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}),
361 hostStatusDetail: detail,351 devices: [
352 ...state.devices.filter((device) => device.hostId !== ready.hostId),
353 ...devices,
354 ],
355 hostStatuses: {
356 ...state.hostStatuses,
357 [ready.hostId]: { status: "ready", ...(detail ? { detail } : {}) },
358 },
362 }));359 }));
363 }),360 }),
364 );361 );
@@ -366,18 +363,21 @@ export const make = Effect.gen(function* () { · 1 unmodified line
366 363
367 const list: DeviceService["Service"]["list"] = Effect.gen(function* () {364 const list: DeviceService["Service"]["list"] = Effect.gen(function* () {
368 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;365 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
369 const ready = yield* readiness();366 yield* Effect.forEach(
370 return yield* refresh(ready);367 hosts.values(),
371 }).pipe(368 (host) =>
372 Effect.tapError((error) =>369 Effect.gen(function* () {
373 publish((state) =>370 const ready = yield* readinessIfSupported(host.id);
374 state.hostStatus === "disabled"371 if (ready) yield* refresh(ready);
375 ? state372 }).pipe(
376 : { ...state, hostStatus: "failed", hostStatusDetail: error.message },373 Effect.catch((error) =>
377 ),374 setHostStatus(host.id, { status: "failed", detail: error.message }),
378 ),375 ),
379 Effect.withSpan("DeviceService.list"),376 ),
380 );377 { concurrency: 4 },
378 );
379 return (yield* SynchronizedRef.get(stateRef)).state;
380 }).pipe(Effect.withSpan("DeviceService.list"));
381 381
382 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(382 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(
383 function* (input) {383 function* (input) {
@@ -415,6 +415,7 @@ export const make = Effect.gen(function* () { · 31 unmodified lines
415 ...state,415 ...state,
416 hostStatus: nextEnabled ? "idle" : "disabled",416 hostStatus: nextEnabled ? "idle" : "disabled",
417 hostStatusDetail: undefined,417 hostStatusDetail: undefined,
418 hostStatuses: {},
418 devices: nextEnabled ? state.devices : [],419 devices: nextEnabled ? state.devices : [],
419 sessions: nextEnabled ? state.sessions : [],420 sessions: nextEnabled ? state.sessions : [],
420 bootingDevices: nextEnabled ? state.bootingDevices : [],421 bootingDevices: nextEnabled ? state.bootingDevices : [],
@@ -636,6 +637,7 @@ export const make = Effect.gen(function* () { · 215 unmodified lines
636 const closing = state.sessions.filter(637 const closing = state.sessions.filter(
637 (session) =>638 (session) =>
638 session.threadId === input.threadId &&639 session.threadId === input.threadId &&
640 (input.hostId === undefined || session.hostId === input.hostId) &&
639 (input.deviceId === undefined || session.deviceId === input.deviceId),641 (input.deviceId === undefined || session.deviceId === input.deviceId),
640 );642 );
641 if (closing.length === 0) return;643 if (closing.length === 0) return;
@@ -749,6 +751,11 @@ export const make = Effect.gen(function* () { · 107 unmodified lines
749 });751 });
750});752});
751 753
754export const make = Effect.gen(function* () {
755 const host = yield* DeviceHost.DeviceHost;
756 return yield* makeWithHosts(new Map([[host.id, host]]));
757});
758
752export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));759export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
753 760
754/** State stream for WS subscribers: current snapshot first, then every change. */761/** State stream for WS subscribers: current snapshot first, then every change. */
apps/server/src/mcp/McpDeviceToolkit.test.ts+1−0
@@ -56,6 +56,7 @@ const state = { · 55 unmodified lines
56 },56 },
57 ],57 ],
58 hostStatus: "ready" as const,58 hostStatus: "ready" as const,
59 hostStatuses: { local: { status: "ready" as const } },
59 devices: [device],60 devices: [device],
60 sessions: [],61 sessions: [],
61 onboardingCompleted: true,62 onboardingCompleted: true,
apps/server/src/mcp/toolkits/device/handlers.ts+4−1
@@ -161,7 +161,9 @@ const handlers = { · 160 unmodified lines
161 const target =161 const target =
162 input.deviceId !== undefined162 input.deviceId !== undefined
163 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }163 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }
164 : sessions.at(-1);164 : sessions
165 .filter((session) => input.hostId === undefined || session.hostId === input.hostId)
166 .at(-1);
165 if (!target) {167 if (!target) {
166 return yield* new DeviceToolUnavailableError({168 return yield* new DeviceToolUnavailableError({
167 reason: "No device is open in this thread. Call device_open first.",169 reason: "No device is open in this thread. Call device_open first.",
@@ -183,6 +185,7 @@ const handlers = { · 15 unmodified lines
183 const devices = yield* DeviceService.DeviceService;185 const devices = yield* DeviceService.DeviceService;
184 yield* devices.close({186 yield* devices.close({
185 threadId: scope.threadId,187 threadId: scope.threadId,
188 ...(input.hostId === undefined ? {} : { hostId: input.hostId }),
186 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),189 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),
187 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),190 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),
188 });191 });
apps/server/src/server.test.ts+2−1
@@ -1669,7 +1669,8 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( · 1668 unmodified lines
1669 1669
1670const EMPTY_DEVICE_STATE: DeviceServiceState = {1670const EMPTY_DEVICE_STATE: DeviceServiceState = {
1671 hosts: [],1671 hosts: [],
1672 hostStatus: "idle",1672 hostStatus: "disabled",
1673 hostStatuses: {},
1673 devices: [],1674 devices: [],
1674 sessions: [],1675 sessions: [],
1675 onboardingCompleted: false,1676 onboardingCompleted: false,