Skip to content

Commit fa6c877

Browse files
authored
[build-tools] Add start_argent_remote_session build function (#3757)
1 parent 9ee8758 commit fa6c877

4 files changed

Lines changed: 272 additions & 114 deletions

File tree

packages/build-tools/src/steps/easFunctions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { createSaveCacheFunction } from './functions/saveCache';
4141
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
4242
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
4343
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
44+
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
4445
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
4546
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
4647
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
@@ -81,6 +82,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
8182
runFastlaneFunction(),
8283
parseXcactivitylogFunction(),
8384
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
85+
createStartArgentRemoteSessionBuildFunction(ctx),
8486
createStartAndroidEmulatorBuildFunction(),
8587
createStartCuttlefishDeviceBuildFunction(),
8688
createStartIosSimulatorBuildFunction(),

packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts

Lines changed: 8 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,24 @@ import {
88
BuildStepInputValueTypeName,
99
} from '@expo/steps';
1010
import spawn from '@expo/turtle-spawn';
11-
import fs from 'node:fs';
1211
import os from 'node:os';
1312
import path from 'node:path';
1413

1514
import { CustomBuildContext } from '../../customBuildContext';
16-
import { sleepAsync } from '../../utils/retry';
1715
import {
18-
DetachedProcessHandle,
19-
ensureBrewPackageInstalledAsync,
16+
ensureCloudflaredInstalledAsync,
2017
getDeviceRunSessionIdOrThrow,
2118
spawnDetached,
2219
startServeSimWithTunnelAsync,
2320
uploadRemoteSessionConfigAsync,
21+
waitForFileAsync,
22+
waitForMatchInOutputAsync,
2423
} from '../utils/remoteDeviceRunSession';
2524

2625
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
2726
const SRC_DIR = '/tmp/agent-device-src';
2827
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
2928
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
30-
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
3129
const STARTUP_TIMEOUT_MS = 60_000;
3230

3331
export function createStartAgentDeviceRemoteSessionBuildFunction(
@@ -92,12 +90,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
9290
});
9391

9492
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
95-
await waitForFileAsync({
93+
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
9694
filePath: DAEMON_JSON_PATH,
9795
timeoutMs: STARTUP_TIMEOUT_MS,
98-
description: 'agent-device daemon',
96+
description: 'agent-device daemon credentials',
97+
parse: parseDaemonInfo,
9998
});
100-
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
10199
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
102100

103101
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
@@ -148,62 +146,6 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
148146
});
149147
}
150148

151-
async function ensureCloudflaredInstalledAsync({
152-
runtimePlatform,
153-
env,
154-
logger,
155-
}: {
156-
runtimePlatform: BuildRuntimePlatform;
157-
env: BuildStepEnv;
158-
logger: bunyan;
159-
}): Promise<string> {
160-
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
161-
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
162-
return 'cloudflared';
163-
}
164-
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
165-
return 'cloudflared';
166-
}
167-
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
168-
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
169-
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
170-
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
171-
env,
172-
logger,
173-
});
174-
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
175-
// Return the absolute install path so the tunnel command works even when
176-
// /usr/local/bin is not on the step's PATH.
177-
return CLOUDFLARED_LINUX_INSTALL_PATH;
178-
}
179-
180-
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
181-
if (arch === 'x64') {
182-
return 'amd64';
183-
}
184-
if (arch === 'arm64') {
185-
return 'arm64';
186-
}
187-
throw new SystemError(
188-
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
189-
);
190-
}
191-
192-
async function isCommandAvailableAsync({
193-
command,
194-
env,
195-
}: {
196-
command: string;
197-
env: BuildStepEnv;
198-
}): Promise<boolean> {
199-
try {
200-
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
201-
return true;
202-
} catch {
203-
return false;
204-
}
205-
}
206-
207149
async function cloneAgentDeviceAsync({
208150
packageVersion,
209151
env,
@@ -220,54 +162,7 @@ async function cloneAgentDeviceAsync({
220162
});
221163
}
222164

223-
async function waitForMatchInOutputAsync({
224-
process,
225-
pattern,
226-
timeoutMs,
227-
description,
228-
}: {
229-
process: DetachedProcessHandle;
230-
pattern: RegExp;
231-
timeoutMs: number;
232-
description: string;
233-
}): Promise<string> {
234-
const deadline = Date.now() + timeoutMs;
235-
while (Date.now() < deadline) {
236-
const match = pattern.exec(process.getOutput());
237-
if (match) {
238-
return match[1] ?? match[0];
239-
}
240-
await sleepAsync(1_000);
241-
}
242-
throw new SystemError(
243-
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
244-
);
245-
}
246-
247-
async function waitForFileAsync({
248-
filePath,
249-
timeoutMs,
250-
description,
251-
}: {
252-
filePath: string;
253-
timeoutMs: number;
254-
description: string;
255-
}): Promise<void> {
256-
const deadline = Date.now() + timeoutMs;
257-
while (Date.now() < deadline) {
258-
try {
259-
await fs.promises.access(filePath);
260-
return;
261-
} catch {
262-
// not yet; keep polling
263-
}
264-
await sleepAsync(1_000);
265-
}
266-
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
267-
}
268-
269-
function readDaemonInfo(filePath: string): { port: number; token: string } {
270-
const raw = fs.readFileSync(filePath, 'utf8');
165+
function parseDaemonInfo(raw: string): { port: number; token: string } {
271166
const parsed = JSON.parse(raw) as unknown;
272167
if (
273168
!parsed ||
@@ -276,7 +171,7 @@ function readDaemonInfo(filePath: string): { port: number; token: string } {
276171
typeof (parsed as { token: unknown }).token !== 'string'
277172
) {
278173
throw new SystemError(
279-
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
174+
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
280175
);
281176
}
282177
const { httpPort, token } = parsed as { httpPort: number; token: string };
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { SystemError } from '@expo/eas-build-job';
2+
import {
3+
BuildFunction,
4+
BuildRuntimePlatform,
5+
BuildStepInput,
6+
BuildStepInputValueTypeName,
7+
} from '@expo/steps';
8+
import spawn from '@expo/turtle-spawn';
9+
import fs from 'node:fs';
10+
import os from 'node:os';
11+
import path from 'node:path';
12+
import { z } from 'zod';
13+
14+
import { CustomBuildContext } from '../../customBuildContext';
15+
import {
16+
ensureCloudflaredInstalledAsync,
17+
getDeviceRunSessionIdOrThrow,
18+
spawnDetached,
19+
startServeSimWithTunnelAsync,
20+
uploadRemoteSessionConfigAsync,
21+
waitForFileAsync,
22+
waitForMatchInOutputAsync,
23+
} from '../utils/remoteDeviceRunSession';
24+
25+
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
26+
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
27+
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
28+
const STARTUP_TIMEOUT_MS = 60_000;
29+
30+
const ArgentToolServerStateSchema = z.object({ port: z.number() });
31+
32+
export function createStartArgentRemoteSessionBuildFunction(
33+
ctx: CustomBuildContext
34+
): BuildFunction {
35+
return new BuildFunction({
36+
namespace: 'eas',
37+
id: 'start_argent_remote_session',
38+
name: 'Start argent remote session',
39+
__metricsId: 'eas/start_argent_remote_session',
40+
inputProviders: [
41+
BuildStepInput.createProvider({
42+
id: 'package_version',
43+
required: false,
44+
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
45+
}),
46+
],
47+
fn: async ({ logger, global }, { inputs, env }) => {
48+
// Fail fast before any expensive setup if the orchestrator-injected
49+
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
50+
// report the remote config back to the API server.
51+
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
52+
53+
const packageVersion = inputs.package_version.value as string | undefined;
54+
const versionSpec = packageVersion ?? 'latest';
55+
const { runtimePlatform } = global;
56+
logger.info(
57+
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
58+
);
59+
60+
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
61+
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
62+
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
63+
}
64+
65+
logger.info('Ensuring cloudflared is installed.');
66+
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
67+
runtimePlatform,
68+
env,
69+
logger,
70+
});
71+
72+
// Stale state from a previous run would mask the new server's port.
73+
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
74+
75+
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
76+
// `argent mcp` is the public entry that triggers @argent/tools-client
77+
// to spawn the tool-server detached + unref'd, so the tool-server
78+
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
79+
// 30-min idle shutdown that would otherwise tear the tunnel down.
80+
spawnDetached({
81+
command: 'bunx',
82+
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
83+
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
84+
});
85+
86+
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
87+
const { port: toolServerPort } = await waitForFileAsync({
88+
filePath: ARGENT_STATE_FILE,
89+
timeoutMs: STARTUP_TIMEOUT_MS,
90+
description: 'argent tool-server state',
91+
parse: parseArgentToolServerState,
92+
});
93+
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
94+
95+
logger.info(`Starting cloudflared tunnel to http://localhost:${toolServerPort}.`);
96+
const cloudflared = spawnDetached({
97+
command: cloudflaredCommand,
98+
args: ['tunnel', '--url', `http://localhost:${toolServerPort}`],
99+
env,
100+
});
101+
102+
logger.info('Waiting for a public tunnel URL.');
103+
const toolsUrl = await waitForMatchInOutputAsync({
104+
process: cloudflared,
105+
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
106+
timeoutMs: STARTUP_TIMEOUT_MS,
107+
description: 'cloudflared tunnel',
108+
});
109+
logger.info(`Tunnel is ready at ${toolsUrl}.`);
110+
111+
// serve-sim is iOS-only — Android sessions go without a preview URL.
112+
let webPreviewUrl: string | undefined;
113+
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
114+
const serveSim = await startServeSimWithTunnelAsync({
115+
env,
116+
logger,
117+
timeoutMs: STARTUP_TIMEOUT_MS,
118+
});
119+
webPreviewUrl = serveSim.previewUrl;
120+
logger.info(`Web preview URL: ${webPreviewUrl}`);
121+
}
122+
123+
await uploadRemoteSessionConfigAsync({
124+
ctx,
125+
deviceRunSessionId,
126+
remoteConfig: {
127+
toolsUrl,
128+
...(webPreviewUrl ? { webPreviewUrl } : {}),
129+
},
130+
logger,
131+
});
132+
133+
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
134+
// Keep the turtle job alive so the tool-server and tunnel stay reachable
135+
// until stopDeviceRunSession cancels the run.
136+
await new Promise<never>(() => {});
137+
},
138+
});
139+
}
140+
141+
function parseArgentToolServerState(raw: string): { port: number } {
142+
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
143+
if (!result.success) {
144+
throw new SystemError(
145+
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
146+
);
147+
}
148+
return result.data;
149+
}

0 commit comments

Comments
 (0)