Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/server/src/device/AgentDeviceShim.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript.
/**
* A directory holding an `agent-device` launcher that runs the pinned install
* with the server's Node. Prepended to provider subprocess PATHs so the agent
* with a Node runtime. Prepended to provider subprocess PATHs so the agent
* types `agent-device …` and gets the version the injected instructions were
* written for, regardless of what is or is not globally installed.
*/
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { resolveNodeExecutable } from "@t3tools/shared/nodeRuntime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
Expand All @@ -20,9 +21,9 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const node = yield* resolveNodeExecutable("Device automation");
const shimDir = path.join(input.stateDir, SHIM_DIR);
yield* fs.makeDirectory(shimDir, { recursive: true });
const node = process.execPath;
const launcherPath = path.join(shimDir, "agent-device-launcher.mjs");
yield* fs.writeFileString(
launcherPath,
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/device/AgentDeviceTarget.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses.
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import {
HostProcessExecutablePath,
HostProcessIsExecutable,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import { describe, expect, it } from "@effect/vitest";
import * as NodeChildProcess from "node:child_process";
import * as NodeUtil from "node:util";
Expand Down Expand Up @@ -84,6 +88,11 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
).rejects.toThrow("Call device_open first"),
);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
}).pipe(
Effect.scoped,
Effect.provideService(HostProcessIsExecutable, true),
Effect.provideService(HostProcessExecutablePath, "/packaged/t3"),
Effect.provide(NodeServices.layer),
),
);
});
8 changes: 6 additions & 2 deletions apps/server/src/device/DeviceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
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,
Expand Down Expand Up @@ -88,11 +89,14 @@ export class DeviceHost extends Context.Service<
*/
readonly ensureReady: (
onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
) => Effect.Effect<DeviceHostReady, DeviceHostError>;
) => Effect.Effect<DeviceHostReady, DeviceHostError | NodeRuntimeUnavailableError>;
/** Installs and starts agent-device after the user grants agent access. */
readonly ensureAgentReady: (
onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
) => Effect.Effect<DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError>;
) => Effect.Effect<
DeviceHostAgentReady,
DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError
>;
/** Current endpoints when already running, without starting anything. */
readonly current: Effect.Effect<DeviceHostReady | null>;
/** Stops only agent-device. Manual viewing through the hub stays available. */
Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as Stream from "effect/Stream";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ServerSettingsService } from "../serverSettings.ts";
import * as DeviceHost from "./DeviceHost.ts";
import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";

import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts";

Expand Down Expand Up @@ -61,6 +62,7 @@ const fixture = Effect.fn("fixture")(function* (
onBoot: Effect.Effect<void> = Effect.void,
bootError?: string,
failListAfterShutdown = false,
runtimeFailure?: NodeRuntimeUnavailableError,
) {
const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
const starts: string[] = [];
Expand Down Expand Up @@ -88,12 +90,14 @@ const fixture = Effect.fn("fixture")(function* (
platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
ensureReady: (onPhase) =>
Effect.gen(function* () {
if (runtimeFailure) return yield* runtimeFailure;
starts.push("start");
yield* onPhase("starting");
return ready;
}),
ensureAgentReady: (onPhase) =>
Effect.gen(function* () {
if (runtimeFailure) return yield* runtimeFailure;
agentStarts.push("start");
yield* onPhase("starting");
return {
Expand Down Expand Up @@ -188,6 +192,43 @@ const fixture = Effect.fn("fixture")(function* (
});

describe("device setup consent", () => {
it.effect(
"preserves missing-runtime guidance and causes through manual and agent readiness",
() =>
Effect.gen(function* () {
const underlying = new Error("private lookup diagnostics");
const runtimeFailure = new NodeRuntimeUnavailableError({
feature: "Local device support",
cause: underlying,
});
const { service, settings, requests } = yield* fixture(
Effect.void,
undefined,
false,
runtimeFailure,
);
yield* Ref.update(settings, (current) => ({
...current,
enableDeviceSupport: true,
enableAgentDeviceAccess: true,
}));
for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) {
const error = yield* readiness.pipe(Effect.flip);
expect(error).toMatchObject({
_tag: "DeviceHostUnavailableError",
reason: expect.stringContaining("Install Node.js"),
cause: runtimeFailure,
});
expect(error.message).not.toContain(underlying.message);
}
expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toMatchObject({
status: "failed",
detail: expect.stringContaining("Install Node.js"),
});
expect(requests).toEqual([]);
}).pipe(Effect.scoped),
);

it.effect("listing and provider startup do not start helpers before consent", () =>
Effect.gen(function* () {
const { service, starts, requests } = yield* fixture();
Expand Down
45 changes: 35 additions & 10 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import * as FileSystem from "effect/FileSystem";
import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
import * as Path from "effect/Path";
import { ensureAgentDevice } from "./DeviceToolchain.ts";
import * as ServerConfig from "../config.ts";
Expand Down Expand Up @@ -262,7 +263,15 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
setHostStatus(host.id, { status: "failed", detail: error.message }),
),
Effect.mapError(
(error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
(error) =>
new DeviceHostUnavailableError({
hostId: host.id,
reason:
error._tag === "NodeRuntimeUnavailableError"
? nodeRuntimeUnavailableMessage("Local device support")
: `Device host ${error.hostId} failed while ${error.step}.`,
cause: error,
}),
),
);
if (hosts.get(host.id) !== host)
Expand Down Expand Up @@ -305,7 +314,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
setHostStatus(host.id, { status: "failed", detail: error.message }),
),
Effect.mapError(
(error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
(error) =>
new DeviceHostUnavailableError({
hostId: host.id,
reason:
error._tag === "NodeRuntimeUnavailableError"
? nodeRuntimeUnavailableMessage("Local device support")
: error._tag === "DeviceHostTimeoutError"
? `Device host ${error.hostId} did not start agent tools within ${error.timeoutMs} ms.`
: `Device host ${error.hostId} failed while ${error.step}.`,
cause: error,
}),
),
);
const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
Expand Down Expand Up @@ -1003,18 +1022,24 @@ export const make = Effect.gen(function* () {
);
return {
...service,
agentCli: ensureAgentDevice(config.baseDir).pipe(
agentCli: resolveNodeExecutable("Device automation").pipe(
Effect.flatMap(() => ensureAgentDevice(config.baseDir)),
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.map((tool) => tool.entryPath),
Effect.mapError(
(error) =>
new DeviceOperationError({
operation: "install agent CLI",
reason: "command_failed",
cause: error,
}),
Effect.mapError((error) =>
error._tag === "NodeRuntimeUnavailableError"
? new DeviceHostUnavailableError({
hostId: LOCAL_DEVICE_HOST_ID,
reason: nodeRuntimeUnavailableMessage("Device automation"),
cause: error,
})
: new DeviceOperationError({
operation: "install agent CLI",
reason: "command_failed",
cause: error,
}),
),
),
};
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/device/DeviceToolchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* `expo-device-hub` streams simulator and emulator screens and `agent-device`
* drives them. Each is npm-installed separately after its matching consent
* step into `<baseDir>/tools/<name>/<version>` and executed from there with the
* server's own Node, never `npx`: an ephemeral
* resolved Node runtime, never `npx`: an ephemeral
* npx cache would make every first `device_open` after a reboot depend on the
* registry, and the pinned versions are part of the contract the injected
* agent instructions describe.
Expand Down Expand Up @@ -34,7 +34,7 @@ const installLock = Semaphore.makeUnsafe(1);

export interface DeviceToolPaths {
readonly installDir: string;
/** Absolute path of the tool's entry script, run with the server's Node. */
/** Absolute path of the tool's entry script, run with a resolved Node runtime. */
readonly entryPath: string;
readonly sentinelPath: string;
}
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/device/LocalDeviceHost.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "@effect/vitest";
import * as NodePath from "@effect/platform-node/NodePath";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import {
HostProcessEnvironment,
HostProcessPlatform,
HostProcessIsExecutable,
} from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
Expand Down Expand Up @@ -157,6 +161,11 @@ it.effect(
),
);
expect(yield* host.current).toBeNull();
const error = yield* host
.ensureReady(() => Effect.die("Must not install without Node"))
.pipe(Effect.flip, Effect.provideService(HostProcessIsExecutable, true));
expect(error.message).toContain("Local device support requires Node.js");
expect(error.message).toContain("Install Node.js");
yield* host.stop;
expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
Expand Down
Loading
Loading