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 */
12import type {
13 DeviceHostId,
14 DeviceHostSummary,
15 DevicePlatform,
16 DevicePlatformAvailability,
17} from "@t3tools/contracts";
18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";
20import * as Schema from "effect/Schema";
21import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
22
23export 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
33export 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
42export 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
75export interface DeviceHubEndpoint {
76 /** Loopback origin of expo-device-hub, e.g. `http://127.0.0.1:3400`. */
77 readonly origin: string;
78}
79
80export 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
87export 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
107export interface DeviceHostAgentReady extends DeviceHostReady {
108 readonly agentDevice: AgentDeviceEndpoint;
109}
110
111export 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") {}