| 1 | /** |
| 2 | * A device host is a machine with simulators or emulators on it. The service |
| 3 | * layer only ever talks to this interface, so a future SSH or cloud host slots |
| 4 | * in beside `LocalDeviceHost` without touching discovery, the proxy, or the |
| 5 | * MCP tools. |
| 6 | * |
| 7 | * Every ready host presents a loopback origin where expo-device-hub answers. |
| 8 | * Hosts add an agent-device daemon endpoint only after agent access is granted. |
| 9 | * For the local host both run on this machine; a remote host would forward |
| 10 | * them here. |
| 11 | */ |
| 12 | import type { |
| 13 | DeviceHostId, |
| 14 | DeviceHostSummary, |
| 15 | DevicePlatform, |
| 16 | DevicePlatformAvailability, |
| 17 | } from "@t3tools/contracts"; |
| 18 | import * as Context from "effect/Context"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as Schema from "effect/Schema"; |
| 21 | import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; |
| 22 | |
| 23 | export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", { |
| 24 | hostId: Schema.String, |
| 25 | step: Schema.String, |
| 26 | cause: Schema.Defect(), |
| 27 | }) { |
| 28 | override get message(): string { |
| 29 | return `Device host ${this.hostId} failed while ${this.step}.`; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | export class DeviceHostTimeoutError extends Schema.TaggedError<DeviceHostTimeoutError>()( |
| 34 | "DeviceHostTimeoutError", |
| 35 | { hostId: Schema.String, timeoutMs: Schema.Number }, |
| 36 | ) { |
| 37 | override get message(): string { |
| 38 | return `Device host ${this.hostId} did not start agent tools within ${this.timeoutMs} ms.`; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | export class AgentDeviceStartError extends Schema.TaggedError<AgentDeviceStartError>()( |
| 43 | "AgentDeviceStartError", |
| 44 | { |
| 45 | hostId: Schema.String, |
| 46 | stateDir: Schema.String, |
| 47 | exitCode: Schema.NullOr(Schema.Int), |
| 48 | reason: Schema.Literals([ |
| 49 | "daemon_lock_not_directory", |
| 50 | "daemon_registration_unproven", |
| 51 | "daemon_retirement_unconfirmed", |
| 52 | "command_failed", |
| 53 | ]), |
| 54 | cause: Schema.Defect(), |
| 55 | }, |
| 56 | ) { |
| 57 | override get message(): string { |
| 58 | const exit = this.exitCode === null ? "" : ` (exit code ${this.exitCode})`; |
| 59 | const detail = |
| 60 | this.reason === "daemon_lock_not_directory" |
| 61 | ? "Daemon registration ownership could not be verified: daemon.lock is not a directory." |
| 62 | : this.reason === "daemon_registration_unproven" |
| 63 | ? "Daemon registration ownership could not be verified." |
| 64 | : this.reason === "daemon_retirement_unconfirmed" |
| 65 | ? "Daemon replacement could not be confirmed." |
| 66 | : "The AgentDevice startup command failed."; |
| 67 | const recovery = |
| 68 | this.reason === "command_failed" |
| 69 | ? `Inspect daemon diagnostics in ${this.stateDir}.` |
| 70 | : `Inspect daemon.lock and daemon diagnostics in ${this.stateDir}. Stop older clients and daemons with their original CLI before manually repairing retained state.`; |
| 71 | return `Agent tools failed to start${exit}. ${detail} ${recovery}`; |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | export interface DeviceHubEndpoint { |
| 76 | /** Loopback origin of expo-device-hub, e.g. `http://127.0.0.1:3400`. */ |
| 77 | readonly origin: string; |
| 78 | } |
| 79 | |
| 80 | export interface AgentDeviceEndpoint { |
| 81 | readonly baseUrl: string; |
| 82 | readonly token: string; |
| 83 | /** Host-local path of the agent-device entry script. The provider uses a separate local CLI install. */ |
| 84 | readonly entryPath: string; |
| 85 | } |
| 86 | |
| 87 | export interface DeviceHostReady { |
| 88 | readonly nodePath: string; |
| 89 | readonly hub: DeviceHubEndpoint; |
| 90 | /** |
| 91 | * Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub) |
| 92 | * where the devices live. On the local host this is a plain spawn; a |
| 93 | * remote host would run it over its transport. |
| 94 | */ |
| 95 | readonly run: ( |
| 96 | command: string, |
| 97 | args: ReadonlyArray<string>, |
| 98 | options?: { readonly timeoutMs?: number; readonly stdin?: string }, |
| 99 | ) => Effect.Effect<{ readonly stdout: string; readonly stderr: string; readonly code: number }>; |
| 100 | /** Absolute paths of helper binaries vendored with the hub, when present. */ |
| 101 | readonly helpers: { |
| 102 | readonly serveSimAxSettings: string | null; |
| 103 | readonly serveSimCli: string | null; |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | export interface DeviceHostAgentReady extends DeviceHostReady { |
| 108 | readonly agentDevice: AgentDeviceEndpoint; |
| 109 | } |
| 110 | |
| 111 | export class DeviceHost extends Context.Service< |
| 112 | DeviceHost, |
| 113 | { |
| 114 | readonly id: DeviceHostId; |
| 115 | readonly summary: Effect.Effect<DeviceHostSummary>; |
| 116 | readonly inspect?: Effect.Effect<DeviceHostSummary, DeviceHostError>; |
| 117 | readonly platformAvailability: ( |
| 118 | platform: DevicePlatform, |
| 119 | ) => Effect.Effect<DevicePlatformAvailability>; |
| 120 | /** |
| 121 | * Installs tools on first use and starts the helper processes. Idempotent: |
| 122 | * concurrent callers share one start, and a ready host returns immediately. |
| 123 | */ |
| 124 | readonly ensureReady: ( |
| 125 | onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>, |
| 126 | ) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>; |
| 127 | /** Installs and starts agent-device after the user grants agent access. */ |
| 128 | readonly ensureAgentReady: ( |
| 129 | onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>, |
| 130 | ) => Effect.Effect< |
| 131 | DeviceHostAgentReady, |
| 132 | DeviceHostError | DeviceHostTimeoutError | AgentDeviceStartError | NodeRuntimeUnavailableError |
| 133 | >; |
| 134 | /** Current endpoints when already running, without starting anything. */ |
| 135 | readonly current: Effect.Effect<DeviceHostReady | null>; |
| 136 | /** Stops only agent-device. Manual viewing through the hub stays available. */ |
| 137 | readonly stopAgent: Effect.Effect<void>; |
| 138 | /** Stops helpers. Devices themselves keep running; the user owns those. */ |
| 139 | readonly stop: Effect.Effect<void>; |
| 140 | } |
| 141 | >()("t3/device/DeviceHost") {} |