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
196196 (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath));
197197 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);
198198 const devices = yield* DeviceService.DeviceService;
199− const ready = yield* devices.currentReadiness();
199+ const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);
200200 if (!ready) {
201201 return HttpServerResponse.text("Device hub is not running", { status: 503 });
202202 }
@@ -206,6 +206,7 @@ const handler = Effect.gen(function* () { · 3 unmodified lines
206206 // The ticket authenticates here and must not travel on to the hub.
207207 const upstreamSearch = new URLSearchParams(url.value.search);
208208 upstreamSearch.delete("wsTicket");
209+ upstreamSearch.delete("hostId");
209210 const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";
210211 const upstreamPath = `${hubPath}${search}`;
211212 if (upgrade) {
apps/server/src/device/DeviceMultiHost.test.ts+86−0
@@ -0,0 +1,86 @@
1+import { expect, it } from "@effect/vitest";
2+import { ThreadId } from "@t3tools/contracts";
3+import * as Effect from "effect/Effect";
4+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
5+import { ServerSettingsService } from "../serverSettings.ts";
6+import { DeviceHostError, DeviceHost } from "./DeviceHost.ts";
7+import { makeWithHosts } from "./DeviceService.ts";
8+
9+it.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
2121 const baseState: DeviceServiceState = {
2222 hosts: [],
2323 hostStatus: "idle",
24+ hostStatuses: {},
2425 devices: [],
2526 sessions: [],
2627 onboardingCompleted: false,
apps/server/src/device/DeviceService.ts+58−51
@@ -136,8 +136,9 @@ interface ServiceState { · 135 unmodified lines
136136 const vendorPrefix = (platform: DevicePlatform) =>
137137 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
138138
139−export const make = Effect.gen(function* () {
140− const localHost = yield* DeviceHost.DeviceHost;
139+export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
140+ hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,
141+) {
141142 const settings = yield* ServerSettings.ServerSettingsService;
142143 const lifecycleLock = yield* Semaphore.make(1);
143144 const readDeviceSettings = settings.getSettings.pipe(
@@ -152,9 +153,7 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
152153 ),
153154 );
154155 const initialSettings = yield* readDeviceSettings;
155− const hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]> = new Map([
156− [localHost.id, localHost],
157− ]);
156+
158157 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
159158 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();
160159 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
@@ -162,6 +161,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
162161 state: {
163162 hosts: initialHosts,
164163 hostStatus: initialSettings.enabled ? "idle" : "disabled",
164+ hostStatuses: {},
165165 devices: [],
166166 sessions: [],
167167 onboardingCompleted: initialSettings.onboardingCompleted,
@@ -187,6 +187,18 @@ export const make = Effect.gen(function* () { · 19 unmodified lines
187187 return host;
188188 });
189189
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+
190202 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
191203 function* (hostId) {
192204 const host = yield* resolveHost(hostId);
@@ -198,34 +210,19 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
198210 });
199211 }
200212 const ready = yield* host
201− .ensureReady((phase) =>
202− publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
203− Effect.asVoid,
204− ),
205− )
213+ .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid))
206214 .pipe(
207215 Effect.tapError((error) =>
208− publish((state) => ({
209− ...state,
210− hostStatus: "failed",
211− hostStatusDetail: error.message,
212− })),
216+ setHostStatus(host.id, { status: "failed", detail: error.message }),
213217 ),
214218 Effect.mapError(
215219 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
216220 ),
217221 );
218− yield* SynchronizedRef.get(stateRef).pipe(
219− Effect.flatMap(({ state }) =>
220− state.hostStatus === "ready"
221− ? Effect.void
222− : publish((current) => ({
223− ...current,
224− hostStatus: "ready",
225− hostStatusDetail: undefined,
226− })),
227− ),
228− );
222+ const { state } = yield* SynchronizedRef.get(stateRef);
223+ if (state.hostStatuses[host.id]?.status !== "ready") {
224+ yield* setHostStatus(host.id, { status: "ready" });
225+ }
229226 return { hostId: host.id, ...ready };
230227 },
231228 lifecycleLock.withPermit,
@@ -249,25 +246,18 @@ export const make = Effect.gen(function* () { · 17 unmodified lines
249246 const summary = yield* host.summary;
250247 if (!summary.platforms.some((platform) => platform.available)) return null;
251248 const ready = yield* host
252− .ensureAgentReady((phase) =>
253− publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
254− Effect.asVoid,
255− ),
256− )
249+ .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
257250 .pipe(
258251 Effect.tapError((error) =>
259− publish((state) => ({
260− ...state,
261− hostStatus: "failed",
262− hostStatusDetail: error.message,
263− })),
252+ setHostStatus(host.id, { status: "failed", detail: error.message }),
264253 ),
265254 Effect.mapError(
266255 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
267256 ),
268257 );
269258 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" });
271261 return { hostId: host.id, ...ready };
272262 }, lifecycleLock.withPermit);
273263
@@ -357,8 +347,15 @@ export const make = Effect.gen(function* () { · 83 unmodified lines
357347 return yield* publish((state) => ({
358348 ...state,
359349 hosts: hostSummaries,
360− devices,
361− hostStatusDetail: detail,
350+ ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { 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+ },
362359 }));
363360 }),
364361 );
@@ -366,18 +363,21 @@ export const make = Effect.gen(function* () { · 1 unmodified line
366363
367364 const list: DeviceService["Service"]["list"] = Effect.gen(function* () {
368365 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
369− const ready = yield* readiness();
370− return yield* refresh(ready);
371− }).pipe(
372− Effect.tapError((error) =>
373− publish((state) =>
374− state.hostStatus === "disabled"
375− ? state
376− : { ...state, hostStatus: "failed", hostStatusDetail: error.message },
377− ),
378− ),
379− Effect.withSpan("DeviceService.list"),
380− );
366+ yield* Effect.forEach(
367+ hosts.values(),
368+ (host) =>
369+ Effect.gen(function* () {
370+ const ready = yield* readinessIfSupported(host.id);
371+ if (ready) yield* refresh(ready);
372+ }).pipe(
373+ Effect.catch((error) =>
374+ setHostStatus(host.id, { status: "failed", detail: error.message }),
375+ ),
376+ ),
377+ { concurrency: 4 },
378+ );
379+ return (yield* SynchronizedRef.get(stateRef)).state;
380+ }).pipe(Effect.withSpan("DeviceService.list"));
381381
382382 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(
383383 function* (input) {
@@ -415,6 +415,7 @@ export const make = Effect.gen(function* () { · 31 unmodified lines
415415 ...state,
416416 hostStatus: nextEnabled ? "idle" : "disabled",
417417 hostStatusDetail: undefined,
418+ hostStatuses: {},
418419 devices: nextEnabled ? state.devices : [],
419420 sessions: nextEnabled ? state.sessions : [],
420421 bootingDevices: nextEnabled ? state.bootingDevices : [],
@@ -636,6 +637,7 @@ export const make = Effect.gen(function* () { · 215 unmodified lines
636637 const closing = state.sessions.filter(
637638 (session) =>
638639 session.threadId === input.threadId &&
640+ (input.hostId === undefined || session.hostId === input.hostId) &&
639641 (input.deviceId === undefined || session.deviceId === input.deviceId),
640642 );
641643 if (closing.length === 0) return;
@@ -749,6 +751,11 @@ export const make = Effect.gen(function* () { · 107 unmodified lines
749751 });
750752 });
751753
754+export const make = Effect.gen(function* () {
755+ const host = yield* DeviceHost.DeviceHost;
756+ return yield* makeWithHosts(new Map([[host.id, host]]));
757+});
758+
752759 export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
753760
754761 /** 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
5656 },
5757 ],
5858 hostStatus: "ready" as const,
59+ hostStatuses: { local: { status: "ready" as const } },
5960 devices: [device],
6061 sessions: [],
6162 onboardingCompleted: true,
apps/server/src/mcp/toolkits/device/handlers.ts+4−1
@@ -161,7 +161,9 @@ const handlers = { · 160 unmodified lines
161161 const target =
162162 input.deviceId !== undefined
163163 ? { 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);
165167 if (!target) {
166168 return yield* new DeviceToolUnavailableError({
167169 reason: "No device is open in this thread. Call device_open first.",
@@ -183,6 +185,7 @@ const handlers = { · 15 unmodified lines
183185 const devices = yield* DeviceService.DeviceService;
184186 yield* devices.close({
185187 threadId: scope.threadId,
188+ ...(input.hostId === undefined ? {} : { hostId: input.hostId }),
186189 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),
187190 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),
188191 });
apps/server/src/server.test.ts+2−1
@@ -1669,7 +1669,8 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( · 1668 unmodified lines
16691669
16701670 const EMPTY_DEVICE_STATE: DeviceServiceState = {
16711671 hosts: [],
1672− hostStatus: "idle",
1672+ hostStatus: "disabled",
1673+ hostStatuses: {},
16731674 devices: [],
16741675 sessions: [],
16751676 onboardingCompleted: false,