1import type {
2 DeviceBootError,
3 DeviceHostId,
4 DeviceHostUnavailableError,
5 DeviceHostSummary,
6 DeviceOperationError,
7 DevicePlatform,
8 DevicePlatformAvailability,
9 DeviceSummary,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
14import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
15
16export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", {
17 hostId: Schema.String,
18 step: Schema.String,
19 cause: Schema.Defect(),
20}) {
21 override get message(): string {
22 return `Device host ${this.hostId} failed while ${this.step}.`;
23 }
24}
25
26export class DeviceHostTimeoutError extends Schema.TaggedError<DeviceHostTimeoutError>()(
27 "DeviceHostTimeoutError",
28 { hostId: Schema.String, timeoutMs: Schema.Number },
29) {
30 override get message(): string {
31 return `Device host ${this.hostId} did not start agent tools within ${this.timeoutMs} ms.`;
32 }
33}
34
35export class AgentDeviceStartError extends Schema.TaggedError<AgentDeviceStartError>()(
36 "AgentDeviceStartError",
37 {
38 hostId: Schema.String,
39 stateDir: Schema.String,
40 exitCode: Schema.NullOr(Schema.Int),
41 reason: Schema.Literals([
42 "daemon_lock_not_directory",
43 "daemon_registration_unproven",
44 "daemon_retirement_unconfirmed",
45 "command_failed",
46 ]),
47 cause: Schema.Defect(),
48 },
49) {
50 override get message(): string {
51 const exit = this.exitCode === null ? "" : ` (exit code ${this.exitCode})`;
52 const detail =
53 this.reason === "daemon_lock_not_directory"
54 ? "Daemon registration ownership could not be verified: daemon.lock is not a directory."
55 : this.reason === "daemon_registration_unproven"
56 ? "Daemon registration ownership could not be verified."
57 : this.reason === "daemon_retirement_unconfirmed"
58 ? "Daemon replacement could not be confirmed."
59 : "The AgentDevice startup command failed.";
60 const recovery =
61 this.reason === "command_failed"
62 ? `Inspect daemon diagnostics in ${this.stateDir}.`
63 : `Inspect daemon.lock and daemon diagnostics in ${this.stateDir}. Stop older clients and daemons with their original CLI before manually repairing retained state.`;
64 return `Agent tools failed to start${exit}. ${detail} ${recovery}`;
65 }
66}
67
68export interface DeviceHubEndpoint {
69 readonly origin: string;
70}
71
72export interface AgentDeviceEndpoint {
73 readonly baseUrl: string;
74 readonly token: string;
75 readonly entryPath: string;
76}
77
78export type HostDevice = Omit<DeviceSummary, "hostId">;
79
80/** Where a device is being opened from, for hosts whose devices depend on the project. */
81export interface DeviceOpenContext {
82 readonly workspaceRoot?: string | undefined;
83}
84
85export type AgentDaemonEndpoint = Pick<AgentDeviceEndpoint, "baseUrl" | "token">;
86
87export interface AgentDeviceBinding extends AgentDaemonEndpoint {
88 /** Null when the session alone picks the device and selector flags would be refused. */
89 readonly agentDeviceId: string | null;
90 /** The one session the device is leased to, shared by every thread. */
91 readonly session?: string;
92 /** agent-device state holding that session's connection. */
93 readonly stateDir?: string;
94 /** agent-device accepts installs into the session only from the directory that opened it. */
95 readonly installDirectory?: string;
96}
97
98/** Where the proxy sends a device hub request, for hosts that serve several upstreams. */
99export interface HubUpstream {
100 readonly url: string;
101 readonly headers: Readonly<Record<string, string>>;
102 readonly protocols: ReadonlyArray<string>;
103}
104
105/** Discovery and lifecycle for the devices a ready host can run. */
106export interface DeviceOperations {
107 readonly list: Effect.Effect<
108 {
109 readonly devices: ReadonlyArray<HostDevice>;
110 readonly detail: string | undefined;
111 /** Some Android virtual devices could not be listed; the rest are. */
112 readonly androidDiscoveryIncomplete?: boolean;
113 /** Platforms that could not be listed; their last known devices stay listed. */
114 readonly incompletePlatforms?: ReadonlyArray<DevicePlatform>;
115 },
116 DeviceOperationError
117 >;
118 /**
119 * Boots the device and prepares its stream. Android AVDs change id when they
120 * boot (AVD name to emulator serial), so the returned id is authoritative.
121 */
122 readonly boot: (
123 device: HostDevice,
124 context?: DeviceOpenContext,
125 ) => Effect.Effect<string, DeviceOperationError | DeviceBootError | DeviceHostUnavailableError>;
126 /** Prepares the stream of a device that is already booted. */
127 readonly attach: (device: HostDevice) => Effect.Effect<void, DeviceOperationError>;
128 readonly shutdown: (device: {
129 readonly id: string;
130 readonly platform: DevicePlatform;
131 }) => Effect.Effect<void, DeviceOperationError>;
132 readonly screenshot: (device: HostDevice) => Effect.Effect<Uint8Array, DeviceOperationError>;
133 /**
134 * For hosts that run one agent-device daemon per device instead of one per
135 * host. `agentDeviceId` is the id agent-device selects the device by, when
136 * it differs from the host's device id.
137 */
138 readonly agentEndpoint?: (
139 deviceId: string,
140 ) => Effect.Effect<AgentDeviceBinding, DeviceHostUnavailableError>;
141 /**
142 * Set by hosts that end devices on their own (EAS stops sessions at their
143 * limits), so a session on a device the host stops listing is dropped.
144 */
145 readonly endsDevices?: boolean;
146}
147
148export interface DeviceHostReady {
149 readonly nodePath: string;
150 /**
151 * Ends an agent-device session on the daemon that serves `deviceId`. Hosts
152 * with a shared daemon provide it only while that daemon runs; hosts with a
153 * daemon per device succeed when the device's daemon is gone. Cleanup never
154 * starts a daemon or a device.
155 */
156 readonly releaseAgentSession?: (
157 session: string,
158 deviceId: string,
159 ) => Effect.Effect<void, DeviceOperationError>;
160 readonly hub: DeviceHubEndpoint;
161 readonly devices: DeviceOperations;
162 readonly run: (
163 command: string,
164 args: ReadonlyArray<string>,
165 options?: {
166 readonly timeoutMs?: number;
167 readonly stdin?: string;
168 /** Local output cap; SSH output is not capped. */
169 readonly maxOutputBytes?: number;
170 },
171 ) => Effect.Effect<{ readonly stdout: string; readonly stderr: string; readonly code: number }>;
172 readonly helpers: {
173 readonly serveSimAxSettings: string | null;
174 readonly serveSimCli: string | null;
175 };
176 /** Routes proxied hub requests itself; otherwise they go to `hub.origin`. */
177 readonly upstream?: (
178 hubPath: string,
179 search: URLSearchParams,
180 ) => Effect.Effect<HubUpstream | null>;
181}
182
183export interface DeviceHostAgentReady extends DeviceHostReady {
184 /** Null when each device has its own daemon; see `DeviceOperations.agentEndpoint`. */
185 readonly agentDevice: AgentDeviceEndpoint | null;
186}
187
188export class DeviceHost extends Context.Service<
189 DeviceHost,
190 {
191 readonly id: DeviceHostId;
192 /** The name errors and the Device panel show for this host. */
193 readonly label: string;
194 readonly summary: Effect.Effect<DeviceHostSummary>;
195 readonly inspect?: Effect.Effect<DeviceHostSummary, DeviceHostError>;
196 readonly platformAvailability: (
197 platform: DevicePlatform,
198 ) => Effect.Effect<DevicePlatformAvailability>;
199 readonly ensureReady: (
200 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
201 ) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>;
202 readonly ensureAgentReady: (
203 onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
204 ) => Effect.Effect<
205 DeviceHostAgentReady,
206 DeviceHostError | DeviceHostTimeoutError | AgentDeviceStartError | NodeRuntimeUnavailableError
207 >;
208 readonly current: Effect.Effect<DeviceHostReady | null>;
209 readonly stopAgent: Effect.Effect<void>;
210 /** Stops helpers. Devices themselves keep running; the user owns those. */
211 readonly stop: Effect.Effect<void>;
212 }
213>()("t3/device/DeviceHost") {}