Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

Expand All @@ -21,13 +22,18 @@ import {
waitForFileAsync,
waitForMatchInOutputAsync,
} from '../utils/remoteDeviceRunSession';
import { sleepAsync } from '../../utils/retry';

const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;

const IDLE_HOOK_PATH = path.join(os.tmpdir(), 'agent-device-idle-hook.mjs');
const IDLE_STATE_PATH = path.join(os.tmpdir(), 'agent-device-last-activity');
const IDLE_POLL_INTERVAL_MS = 30_000;

export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
Expand All @@ -42,6 +48,11 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the orchestrator-injected
Expand All @@ -50,9 +61,16 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);

const packageVersion = inputs.package_version.value as string | undefined;
const rawMaxIdleMinutes = inputs.max_idle_minutes.value as number | undefined;
const maxIdleMinutes =
typeof rawMaxIdleMinutes === 'number' && rawMaxIdleMinutes > 0
? rawMaxIdleMinutes
: undefined;
const { runtimePlatform } = global;
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}${
maxIdleMinutes != null ? `, max idle: ${maxIdleMinutes}m` : ''
}).`
);

if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
Expand Down Expand Up @@ -81,12 +99,18 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
logger,
});

const idleEnv: Record<string, string> = {};
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.

idleEnv is a bit of a weird name

if (maxIdleMinutes != null) {
await writeIdleAuthHookAsync(logger);
idleEnv.AGENT_DEVICE_HTTP_AUTH_HOOK = IDLE_HOOK_PATH;
}

logger.info('Launching agent-device daemon.');
spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
env: { ...env, ...idleEnv, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});

logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
Expand Down Expand Up @@ -138,10 +162,17 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
logger,
});

logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
if (maxIdleMinutes != null) {
logger.info(
`Remote session is live. Watching for >= ${maxIdleMinutes}m of inactivity; otherwise keeping the job alive until the session is stopped.`
);
await waitForIdleTimeoutAsync({ maxIdleMs: maxIdleMinutes * 60_000, logger });
} else {
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
}
},
});
}
Expand Down Expand Up @@ -177,3 +208,59 @@ function parseDaemonInfo(raw: string): { port: number; token: string } {
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
}

async function writeIdleAuthHookAsync(logger: bunyan): Promise<void> {
// Pre-seed so the first poll has a baseline; otherwise it would compute an
// unbounded idle window and false-positive the timeout.
await fs.promises.writeFile(IDLE_STATE_PATH, String(Date.now()), 'utf8');

// Loaded by agent-device's daemon http-server `loadHttpAuthHook`. Must not
// throw — a failing hook 401s every request.
const hookSource = `import { writeFileSync } from 'node:fs';
const STATE_PATH = ${JSON.stringify(IDLE_STATE_PATH)};
export default function recordActivityHook() {
try {
writeFileSync(STATE_PATH, String(Date.now()), 'utf8');
} catch {}
return true;
}
`;
await fs.promises.writeFile(IDLE_HOOK_PATH, hookSource, 'utf8');
logger.info(`Installed idle-activity auth hook at ${IDLE_HOOK_PATH}.`);
}

async function waitForIdleTimeoutAsync({
maxIdleMs,
logger,
}: {
maxIdleMs: number;
logger: bunyan;
}): Promise<never> {
while (true) {
await sleepAsync(IDLE_POLL_INTERVAL_MS);

let lastActivityAt: number;
try {
const raw = await fs.promises.readFile(IDLE_STATE_PATH, 'utf8');
lastActivityAt = Number(raw);
} catch {
// Skip this tick — the hook will re-write the file on the next request.
continue;
}
if (!Number.isFinite(lastActivityAt)) {
continue;
}

const idleMs = Date.now() - lastActivityAt;
if (idleMs >= maxIdleMs) {
const idleMinutes = Math.floor(idleMs / 60_000);
const maxIdleMinutes = Math.floor(maxIdleMs / 60_000);
logger.error(
`agent-device remote session was idle for ${idleMinutes} minute(s); the limit is ${maxIdleMinutes} minute(s). Failing the step to release the worker.`
);
throw new SystemError(
`agent-device remote session exceeded the max idle window (${idleMinutes} >= ${maxIdleMinutes} minute(s)). Start a new session and reconnect, or raise the maxIdleMinutes when creating the session.`
);
}
}
}
Loading