Skip to content
13 changes: 13 additions & 0 deletions apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deviceToolVersionLabels } from "@t3tools/client-runtime/state/device";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
Expand Down Expand Up @@ -116,6 +117,18 @@ function DevicePreviewScreen({
};

const controls: ScreenHeaderMenuItem[] = [
{
id: "device-tools",
title: "Device tool versions",
icon: "info.circle",
onPress: () =>
Alert.alert(
"Device tool versions",
deviceToolVersionLabels(
state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
).join("\n"),
),
},
{
id: "reload",
title: "Reload stream",
Expand Down
62 changes: 61 additions & 1 deletion apps/server/src/device/DeviceToolchain.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as PlatformError from "effect/PlatformError";
import { expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
Expand All @@ -6,7 +7,12 @@ import * as Path from "effect/Path";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import * as ProcessRunner from "../processRunner.ts";
import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts";
import {
deviceToolVersions,
DEVICE_HUB_VERSION,
ensureDeviceHub,
isDeviceHubInstalled,
} from "./DeviceToolchain.ts";

it.effect("failed installation cleans staging and exposes only a safe failure message", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -37,3 +43,57 @@ it.effect("failed installation cleans staging and exposes only a safe failure me
expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("inventory reports only completed versions without installing the required version", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const base = yield* fs.makeTempDirectoryScoped();
for (const [version, sentinel] of [
["0.9.0", "0.9.0"],
[DEVICE_HUB_VERSION, "wrong"],
[".staging-123", ".staging-123"],
]) {
const dir = path.join(base, "tools", "expo-device-hub", version!);
yield* fs.makeDirectory(path.join(dir, "node_modules/expo-device-hub/dist/server"), {
recursive: true,
});
yield* fs.writeFileString(
path.join(dir, "node_modules/expo-device-hub/dist/server/cli.mjs"),
"",
);
yield* fs.writeFileString(path.join(dir, ".install-complete"), sentinel!);
}
const tools = yield* deviceToolVersions(base);
expect(tools?.hub).toEqual({
requiredVersion: DEVICE_HUB_VERSION,
installedVersions: ["0.9.0"],
runningVersion: null,
});
expect(tools?.agent.installedVersions).toEqual([]);
expect(yield* isDeviceHubInstalled(base)).toBe(false);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("unreadable inventory stays unknown instead of reporting no installs", () =>
Effect.gen(function* () {
const tools = yield* deviceToolVersions("/unreadable");
expect(tools).toBeUndefined();
}).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
readDirectory: () =>
Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "readDirectory",
description: "denied",
}),
),
}),
),
Effect.provide(NodeServices.layer),
),
);
44 changes: 44 additions & 0 deletions apps/server/src/device/DeviceToolchain.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { DeviceToolVersions } from "@t3tools/contracts";
/**
* Pinned installs of the two external tools device support is built on.
*
Expand Down Expand Up @@ -221,3 +222,46 @@ export const isDeviceHubInstalled = (baseDir: string) =>

export const isAgentDeviceInstalled = (baseDir: string) =>
isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);

/** Read completed installs without downloading or starting either tool. */
export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* (
baseDir: string,
running: { hub?: string; agent?: string } = {},
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const inspect = Effect.fn("DeviceToolchain.inspect")(function* (spec: ToolSpec) {
const directory = path.join(baseDir, "tools", spec.name);
const names = yield* fs.readDirectory(directory).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.succeed([]),
),
);
const versions = yield* Effect.filter(names, (version) =>
/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)
? Effect.gen(function* () {
const paths = toolPaths(path, baseDir, { ...spec, version });
const sentinel = yield* fs.readFileString(paths.sentinelPath).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.succeed(null),
),
);
return sentinel?.trim() === version && (yield* fs.exists(paths.entryPath));
})
: Effect.succeed(false),
);
return {
requiredVersion: spec.version,
installedVersions: versions.sort(),
runningVersion: (spec.name === DEVICE_HUB_PACKAGE ? running.hub : running.agent) ?? null,
};
});
return yield* Effect.gen(function* () {
return {
hub: yield* inspect(HUB_SPEC),
agent: yield* inspect(AGENT_DEVICE_SPEC),
} satisfies DeviceToolVersions;
}).pipe(Effect.orElseSucceed(() => undefined));
});
23 changes: 23 additions & 0 deletions apps/server/src/device/LocalDeviceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
Expand All @@ -50,6 +51,8 @@ import {
ensureDeviceHub,
isAgentDeviceInstalled,
isDeviceHubInstalled,
deviceToolVersions,
DEVICE_HUB_VERSION,
} from "./DeviceToolchain.ts";

const HUB_READY_TIMEOUT_MS = 30_000;
Expand All @@ -76,6 +79,7 @@ const AgentDeviceDaemonFile = Schema.Struct({
httpPort: Schema.Int,
token: Schema.String,
pid: Schema.optional(Schema.Int),
version: Schema.optional(Schema.String),
});
const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));

Expand Down Expand Up @@ -232,7 +236,26 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
Effect.provideService(Path.Path, path),
),
]);
const running = yield* Ref.get(runningRef);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium device/LocalDeviceHost.ts:239

After an unclean restart, summary reports the local hub and agent as not running even when their persisted processes are still alive. It only checks runningRef, which is initialized to null and never reconstructed from hub.json or daemon.json; probe the persisted local state/health before building tools so inventory survives server restarts.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/device/LocalDeviceHost.ts around line 239:

After an unclean restart, `summary` reports the local hub and agent as not running even when their persisted processes are still alive. It only checks `runningRef`, which is initialized to `null` and never reconstructed from `hub.json` or `daemon.json`; probe the persisted local state/health before building `tools` so inventory survives server restarts.

const daemon = running?.agentDevice
? yield* readDaemonFile().pipe(Effect.option)
: Option.none();
const hubAlive = running
? yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false))
: false;
const agentAlive =
Option.isSome(daemon) && daemon.value.pid ? yield* isProcessAlive(daemon.value.pid) : false;
const tools = yield* deviceToolVersions(config.baseDir, {
...(hubAlive ? { hub: DEVICE_HUB_VERSION } : {}),
...(agentAlive && Option.isSome(daemon) && daemon.value.version
? { agent: daemon.value.version }
: {}),
}).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
);
return {
tools,
id: hostId,
kind: "local",
label: "This machine",
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/device/SshDeviceHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ
const fs = yield* FileSystem.FileSystem;
const home = yield* fs.makeTempDirectoryScoped();
const modes: string[] = [];
const owners: string[] = [];
let forwards = 0;
let failForward = true;
let rejectConfig = true;
Expand Down Expand Up @@ -62,6 +63,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ
);
const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
modes.push(mode);
owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? "");
output = JSON.stringify({
nodePath: "/node",
platforms: [{ platform: "ios", available: true }],
Expand Down Expand Up @@ -110,6 +112,12 @@ it.effect("preserves installed status after probes and cleans failed agent activ
),
);
yield* host.ensureReady(() => Effect.void);
yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe(
Effect.provide(ServerConfig.layerTest(home, home)),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
expect(new Set(owners).size).toBe(1);
expect(owners[0]).toMatch(/^[a-f0-9]{24}$/);
expect(forwards).toBe(1);
expect(modes.filter((mode) => mode === "start")).toHaveLength(2);
yield* host.platformAvailability("ios");
Expand Down
43 changes: 31 additions & 12 deletions apps/server/src/device/SshDeviceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as NodeCrypto from "node:crypto";
import {
type DeviceHostSummary,
DevicePlatformAvailability,
DeviceToolVersions,
type SshDeviceHostConfig,
} from "@t3tools/contracts";
import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command";
Expand All @@ -24,6 +25,7 @@ import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./s

const Probe = Schema.Struct({
nodePath: Schema.String,
tools: Schema.optional(DeviceToolVersions),
platforms: Schema.Array(DevicePlatformAvailability),
});
const Started = Schema.Struct({
Expand Down Expand Up @@ -70,8 +72,23 @@ const bootstrap = (
),
);

export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) {
const result = yield* bootstrap(config, "probe", "probe");
const ownerFor = Effect.fn("SshDeviceHost.ownerFor")(function* (hostId: string) {
const fs = yield* FileSystem.FileSystem;
const server = yield* ServerConfig.ServerConfig;
const environmentId = yield* fs
.readFileString(server.environmentIdPath)
.pipe(Effect.orElseSucceed(() => server.stateDir));
return NodeCrypto.createHash("sha256")
.update(`${environmentId}\0${server.stateDir}\0${hostId}`)
.digest("hex")
.slice(0, 24);
});

export const probe = Effect.fn("SshDeviceHost.probe")(function* (
config: SshDeviceHostConfig,
owner?: string,
) {
const result = yield* bootstrap(config, owner ?? (yield* ownerFor(config.id)), "probe");
const value = yield* decodeProbe(result.stdout.trim()).pipe(
Effect.mapError(
(cause) =>
Expand All @@ -82,8 +99,11 @@ export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDevi
id: config.id,
label: config.label,
kind: "ssh",
hubInstalled: false,
agentDeviceInstalled: false,
tools: value.tools,
hubInstalled:
value.tools?.hub.installedVersions.includes(value.tools.hub.requiredVersion) ?? false,
agentDeviceInstalled:
value.tools?.agent.installedVersions.includes(value.tools.agent.requiredVersion) ?? false,
platforms: value.platforms,
} satisfies DeviceHostSummary;
});
Expand All @@ -106,23 +126,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const parentScope = yield* Scope.Scope;
const ssh = yield* resolveSshCommand;
const environmentId = yield* fs
.readFileString(server.environmentIdPath)
.pipe(Effect.orElseSucceed(() => server.stateDir));
const owner = NodeCrypto.createHash("sha256")
.update(`${environmentId}\0${server.stateDir}\0${config.id}`)
.digest("hex")
.slice(0, 24);
const owner = yield* ownerFor(config.id);
const provide = <A, E>(
effect: Effect.Effect<
A,
E,
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
| FileSystem.FileSystem
| Path.Path
| ChildProcessSpawner.ChildProcessSpawner
| ServerConfig.ServerConfig
>,
) =>
effect.pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.provideService(ServerConfig.ServerConfig, server),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const lock = yield* Semaphore.make(1);
Expand Down Expand Up @@ -183,6 +201,7 @@ export const make = Effect.fn("SshDeviceHost.make")(function* (
summary = {
...summary,
platforms: remote.platforms,
tools: remote.tools,
hubInstalled: true,
agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled,
};
Expand Down
15 changes: 12 additions & 3 deletions apps/server/src/device/sshDeviceScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,16 @@ const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir
const file=path.join(state,'daemon.json');
if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} }
else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); }
else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); }
else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); }
`,
);
await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs"));
const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade";
const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade";
let invocation = 0;
const invoke = async (
owner: string,
mode: "start" | "agent-start" | "stop-agent" | "stop",
mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
upgraded = false,
) => {
const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`);
Expand All @@ -107,14 +108,19 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr
});
return result.stdout ? JSON.parse(result.stdout) : null;
};
const inventory = await invoke("one", "probe");
expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]);
expect(inventory.tools.hub.runningVersion).toBeNull();
expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]);
await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow();
const template = NodePath.join(home, "hub-template");
await NodeFSP.cp(hubDir, template, { recursive: true });
await NodeFSP.rm(NodePath.join(hubDir, ".install-complete"));
const installLock = hubDir + ".lock";
await NodeFSP.symlink("2147483647:exited-installer", installLock);
await NodeFSP.writeFile(
NodePath.join(bin, "npm"),
`#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`,
`#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`,
{ mode: 0o755 },
);
await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true });
Expand All @@ -135,6 +141,9 @@ else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:tr
]);
expect(concurrentAgent.hubPort).toBe(first.hubPort);
expect(concurrentAgent.daemonPort).toBe(first.daemonPort);
const running = await invoke("one", "probe");
expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION);
expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION);
const second = await invoke("two", "agent-start");
const reused = await invoke("one", "agent-start");
expect(reused.hubPort).toBe(first.hubPort);
Expand Down
Loading
Loading