apps/server/src/device/DeviceHost.ts
import type {
DeviceBootError,
DeviceHostId,
DeviceHostUnavailableError,
DeviceHostSummary,
DeviceOperationError,
DevicePlatform,
DevicePlatformAvailability,
DeviceSummary,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", {
hostId: Schema.String,
step: Schema.String,
cause: Schema.Defect(),
}) {
override get message(): string {
return `Device host ${this.hostId} failed while ${this.step}.`;
}
}
export class DeviceHostTimeoutError extends Schema.TaggedError<DeviceHostTimeoutError>()(
"DeviceHostTimeoutError",
{ hostId: Schema.String, timeoutMs: Schema.Number },
) {
override get message(): string {
return `Device host ${this.hostId} did not start agent tools within ${this.timeoutMs} ms.`;
}
}
export class AgentDeviceStartError extends Schema.TaggedError<AgentDeviceStartError>()(
"AgentDeviceStartError",
{
hostId: Schema.String,
stateDir: Schema.String,
exitCode: Schema.NullOr(Schema.Int),
reason: Schema.Literals([
"daemon_lock_not_directory",
"daemon_registration_unproven",
"daemon_retirement_unconfirmed",
"command_failed",
]),
cause: Schema.Defect(),
},
) {
override get message(): string {
const exit = this.exitCode === null ? "" : ` (exit code ${this.exitCode})`;
const detail =
this.reason === "daemon_lock_not_directory"
? "Daemon registration ownership could not be verified: daemon.lock is not a directory."
: this.reason === "daemon_registration_unproven"
? "Daemon registration ownership could not be verified."
: this.reason === "daemon_retirement_unconfirmed"
? "Daemon replacement could not be confirmed."
: "The AgentDevice startup command failed.";
const recovery =
this.reason === "command_failed"
? `Inspect daemon diagnostics in ${this.stateDir}.`
: `Inspect daemon.lock and daemon diagnostics in ${this.stateDir}. Stop older clients and daemons with their original CLI before manually repairing retained state.`;
return `Agent tools failed to start${exit}. ${detail} ${recovery}`;
}
}
export interface DeviceHubEndpoint {
readonly origin: string;
}
export interface AgentDeviceEndpoint {
readonly baseUrl: string;
readonly token: string;
readonly entryPath: string;
}
export type HostDevice = Omit<DeviceSummary, "hostId">;
/** Where a device is being opened from, for hosts whose devices depend on the project. */
export interface DeviceOpenContext {
readonly workspaceRoot?: string | undefined;
}
export type AgentDaemonEndpoint = Pick<AgentDeviceEndpoint, "baseUrl" | "token">;
export interface AgentDeviceBinding extends AgentDaemonEndpoint {
/** Null when the session alone picks the device and selector flags would be refused. */
readonly agentDeviceId: string | null;
/** The one session the device is leased to, shared by every thread. */
readonly session?: string;
/** agent-device state holding that session's connection. */
readonly stateDir?: string;
/** agent-device accepts installs into the session only from the directory that opened it. */
readonly installDirectory?: string;
}
/** Where the proxy sends a device hub request, for hosts that serve several upstreams. */
export interface HubUpstream {
readonly url: string;
readonly headers: Readonly<Record<string, string>>;
readonly protocols: ReadonlyArray<string>;
}
/** Discovery and lifecycle for the devices a ready host can run. */
export interface DeviceOperations {
readonly list: Effect.Effect<
{
readonly devices: ReadonlyArray<HostDevice>;
readonly detail: string | undefined;
/** Some Android virtual devices could not be listed; the rest are. */
readonly androidDiscoveryIncomplete?: boolean;
},
DeviceOperationError
>;
/**
* Boots the device and prepares its stream. Android AVDs change id when they
* boot (AVD name to emulator serial), so the returned id is authoritative.
*/
readonly boot: (
device: HostDevice,
context?: DeviceOpenContext,
) => Effect.Effect<string, DeviceOperationError | DeviceBootError | DeviceHostUnavailableError>;
/** Prepares the stream of a device that is already booted. */
readonly attach: (device: HostDevice) => Effect.Effect<void, DeviceOperationError>;
readonly shutdown: (device: {
readonly id: string;
readonly platform: DevicePlatform;
}) => Effect.Effect<void, DeviceOperationError>;
readonly screenshot: (device: HostDevice) => Effect.Effect<Uint8Array, DeviceOperationError>;
/**
* For hosts that run one agent-device daemon per device instead of one per
* host. `agentDeviceId` is the id agent-device selects the device by, when
* it differs from the host's device id.
*/
readonly agentEndpoint?: (
deviceId: string,
) => Effect.Effect<AgentDeviceBinding, DeviceHostUnavailableError>;
/**
* Set by hosts that end devices on their own (EAS stops sessions at their
* limits), so a session on a device the host stops listing is dropped.
*/
readonly endsDevices?: boolean;
}
export interface DeviceHostReady {
readonly nodePath: string;
readonly hub: DeviceHubEndpoint;
readonly devices: DeviceOperations;
readonly run: (
command: string,
args: ReadonlyArray<string>,
options?: { readonly timeoutMs?: number; readonly stdin?: string },
) => Effect.Effect<{ readonly stdout: string; readonly stderr: string; readonly code: number }>;
readonly helpers: {
readonly serveSimAxSettings: string | null;
readonly serveSimCli: string | null;
};
/** Routes proxied hub requests itself; otherwise they go to `hub.origin`. */
readonly upstream?: (
hubPath: string,
search: URLSearchParams,
) => Effect.Effect<HubUpstream | null>;
}
export interface DeviceHostAgentReady extends DeviceHostReady {
/** Null when each device has its own daemon; see `DeviceOperations.agentEndpoint`. */
readonly agentDevice: AgentDeviceEndpoint | null;
}
export class DeviceHost extends Context.Service<
DeviceHost,
{
readonly id: DeviceHostId;
/** The name errors and the Device panel show for this host. */
readonly label: string;
readonly summary: Effect.Effect<DeviceHostSummary>;
readonly inspect?: Effect.Effect<DeviceHostSummary, DeviceHostError>;
readonly platformAvailability: (
platform: DevicePlatform,
) => Effect.Effect<DevicePlatformAvailability>;
readonly ensureReady: (
onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>;
readonly ensureAgentReady: (
onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>,
) => Effect.Effect<
DeviceHostAgentReady,
DeviceHostError | DeviceHostTimeoutError | AgentDeviceStartError | NodeRuntimeUnavailableError
>;
readonly current: Effect.Effect<DeviceHostReady | null>;
readonly stopAgent: Effect.Effect<void>;
/** Stops helpers. Devices themselves keep running; the user owns those. */
readonly stop: Effect.Effect<void>;
}
>()("t3/device/DeviceHost") {}