agent-relay 10.6.0 → 10.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,723 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { createLogger } from '@agent-relay/utils';
7
+ /** Derive a package root from a resolved entry on both POSIX and Windows. */
8
+ export function packageRootFromEntry(entry, packageName) {
9
+ const index = entry.replace(/\\/g, '/').lastIndexOf(packageName);
10
+ return index >= 0 ? entry.slice(0, index + packageName.length) : entry;
11
+ }
12
+ /**
13
+ * Bootstrap executed by a child `node` process to serve a JS/TS fleet node
14
+ * definition.
15
+ *
16
+ * Why this exists: a `bun build --compile` binary cannot resolve a bare package
17
+ * specifier out of a user's on-disk `node_modules` when the package's entry
18
+ * point lives in a subdirectory (`dist/`, `lib/`) rather than at the package
19
+ * root — which is every package built from TypeScript, `@agent-relay/factory`
20
+ * and `@agent-relay/fleet` included. So importing a node config inside the
21
+ * shipped binary fails on the config's own `@agent-relay/factory/node` import,
22
+ * and the failure is transitive through the user's whole dependency graph, so
23
+ * no entry-point-only resolution fix can work.
24
+ *
25
+ * Node resolves bare specifiers relative to the *importing file*, so importing
26
+ * the config by absolute path from this bootstrap (wherever the bootstrap
27
+ * itself lives) resolves the config's imports against the config's own
28
+ * `node_modules`, exactly as a plain `node` run would.
29
+ *
30
+ * Kept as source text rather than a shipped file because the compiled binary
31
+ * has no `dist/` on disk to point `node` at; it is materialized to a temp file
32
+ * at spawn time.
33
+ *
34
+ * `@agent-relay/fleet` and `@agent-relay/sdk` are resolved from the *config's*
35
+ * directory — the only copies on disk, since the compiled binary's own are
36
+ * sealed inside it — mirroring how the Python provider uses the user's own SDK.
37
+ * That makes the user's fleet version part of the contract: `serveNode`'s
38
+ * connection shape changed in fleet 10 (`{nodeToken, nodeId}`; fleet 9 took
39
+ * `{url, apiKey}`), so an older fleet is rejected up front rather than left to
40
+ * fail as an endless reconnect loop against a URL it never received.
41
+ */
42
+ export const NODE_PROVIDER_CHILD_SOURCE = String.raw `
43
+ import { createRequire } from 'node:module';
44
+ import { pathToFileURL } from 'node:url';
45
+
46
+ const configPath = process.argv[2];
47
+ const describeOnly = process.argv.includes('--describe');
48
+ const FAILURE = Symbol('node-provider-failure');
49
+
50
+ const fail = (message) => {
51
+ console.error('[agent-relay] node provider: ' + message);
52
+ process.exitCode = 2;
53
+ throw FAILURE;
54
+ };
55
+
56
+ const isDefinition = (value) =>
57
+ Boolean(value && typeof value === 'object' && value.__agentRelayFleetNode === true);
58
+
59
+ const unwrap = (loaded) => {
60
+ let candidate = loaded;
61
+ for (let depth = 0; depth < 3; depth += 1) {
62
+ if (isDefinition(candidate)) return candidate;
63
+ if (!candidate || typeof candidate !== 'object' || !('default' in candidate)) return candidate;
64
+ candidate = candidate.default;
65
+ }
66
+ return candidate;
67
+ };
68
+
69
+ const errorText = (err) => (err && (err.stack || err.message)) || String(err);
70
+ const packageRootFromEntry = ${packageRootFromEntry.toString()};
71
+
72
+ const main = async () => {
73
+ if (!configPath) {
74
+ fail('missing config path argument.');
75
+ }
76
+ const requireFromConfig = createRequire(configPath);
77
+ const importFromConfig = async (specifier) =>
78
+ import(pathToFileURL(requireFromConfig.resolve(specifier)).href);
79
+
80
+ let definition;
81
+ try {
82
+ definition = unwrap(await import(pathToFileURL(configPath).href));
83
+ } catch (err) {
84
+ fail('failed to load ' + configPath + ': ' + errorText(err));
85
+ }
86
+
87
+ if (!isDefinition(definition)) {
88
+ fail(configPath + ' must default-export defineNode(...)');
89
+ }
90
+
91
+ // Describe mode: report the definition's identity/capability names so the CLI can
92
+ // advertise spawn:<harness> capacity and fail fast on a bad explicit --config
93
+ // without ever loading the definition in-process. Marker-prefixed and read from
94
+ // the last matching line, so a config that prints on import cannot corrupt it.
95
+ if (describeOnly) {
96
+ const descriptor = {
97
+ name: definition.name,
98
+ capabilities: Object.keys(definition.capabilities || {}),
99
+ ...(typeof definition.maxAgents === 'number' ? { maxAgents: definition.maxAgents } : {}),
100
+ };
101
+ console.log('__AGENT_RELAY_NODE_DESCRIPTOR__' + JSON.stringify(descriptor));
102
+ return;
103
+ }
104
+
105
+ // Only serving needs the fleet runtime; describing must not require it, so a
106
+ // config can be validated even where @agent-relay/fleet is not installed.
107
+ let fleetEntry;
108
+ try {
109
+ fleetEntry = requireFromConfig.resolve('@agent-relay/fleet');
110
+ } catch (err) {
111
+ fail(
112
+ "cannot resolve '@agent-relay/fleet' from " +
113
+ configPath +
114
+ '. Install it alongside your node config (npm i @agent-relay/fleet). Cause: ' +
115
+ errorText(err)
116
+ );
117
+ }
118
+
119
+ // serveNode's connection contract below is fleet >=10 ({nodeToken, nodeId}).
120
+ // Fleet 9 took {url, apiKey} and would silently reconnect forever instead.
121
+ const fleetPackage = '@agent-relay/fleet';
122
+ const fleetRoot = packageRootFromEntry(fleetEntry, fleetPackage);
123
+ let fleetVersion;
124
+ try {
125
+ fleetVersion = requireFromConfig(fleetRoot + '/package.json').version;
126
+ } catch {
127
+ // Version unreadable (exports-gated package.json): proceed rather than block
128
+ // an install that may well work.
129
+ }
130
+ const fleetMajor = Number.parseInt(String(fleetVersion).split('.')[0], 10);
131
+ if (Number.isFinite(fleetMajor) && fleetMajor < 10) {
132
+ fail(
133
+ 'the @agent-relay/fleet installed next to ' +
134
+ configPath +
135
+ ' is v' +
136
+ fleetVersion +
137
+ ', which cannot be served by this CLI (needs >=10). Upgrade it: npm i @agent-relay/fleet@latest'
138
+ );
139
+ }
140
+
141
+ let fleet;
142
+ try {
143
+ fleet = await import(pathToFileURL(fleetEntry).href);
144
+ } catch (err) {
145
+ fail('failed to load @agent-relay/fleet from ' + configPath + ': ' + errorText(err));
146
+ }
147
+
148
+ const env = process.env;
149
+ const nodeToken = env.RELAY_NODE_TOKEN;
150
+ const nodeId = env.RELAY_NODE_ID;
151
+ const nodeName = env.RELAY_NODE_NAME;
152
+ const baseUrl = env.RELAY_BASE_URL && env.RELAY_BASE_URL.trim();
153
+ const workspaceKey = env.RELAY_WORKSPACE_KEY && env.RELAY_WORKSPACE_KEY.trim();
154
+ const loggingEnabled = Boolean(
155
+ env.AGENT_RELAY_LOG_FILE || env.AGENT_RELAY_LOG_LEVEL || env.AGENT_RELAY_LOG_JSON === '1'
156
+ );
157
+
158
+ const sendLog = (level, message, extra) => {
159
+ if (typeof process.send === 'function') {
160
+ process.send({
161
+ __agentRelayNodeProviderLog: true,
162
+ level,
163
+ message,
164
+ ...(extra && typeof extra === 'object' ? { extra } : {}),
165
+ });
166
+ }
167
+ };
168
+ const logger = {
169
+ debug: (message, extra) => sendLog('debug', message, extra),
170
+ info: (message, extra) => sendLog('info', message, extra),
171
+ warn: (message, extra) => sendLog('warn', message, extra),
172
+ error: (message, extra) => sendLog('error', message, extra),
173
+ };
174
+ const warn = loggingEnabled
175
+ ? (message, extra) => logger.warn(message, extra)
176
+ : (message) => console.warn('[agent-relay][node] ' + message);
177
+
178
+ if (!nodeToken || !nodeId) {
179
+ fail('RELAY_NODE_TOKEN and RELAY_NODE_ID are required.');
180
+ }
181
+
182
+ let triggers;
183
+ if (workspaceKey) {
184
+ try {
185
+ const { AgentRelay } = await importFromConfig('@agent-relay/sdk');
186
+ const relay = new AgentRelay({ workspaceKey, ...(baseUrl ? { baseUrl } : {}) });
187
+ triggers = {
188
+ list: () => relay.triggers.list(),
189
+ create: (input) => relay.triggers.create(input),
190
+ update: (id, input) => relay.triggers.update(id, input),
191
+ delete: (id) => relay.triggers.delete(id),
192
+ };
193
+ } catch (err) {
194
+ warn('Message triggers will not sync (' + errorText(err) + ').');
195
+ }
196
+ }
197
+
198
+ const stopSignal = new AbortController();
199
+ for (const signal of ['SIGTERM', 'SIGINT']) {
200
+ process.on(signal, () => stopSignal.abort());
201
+ }
202
+
203
+ try {
204
+ await fleet.serveNode({
205
+ definition,
206
+ connection: { nodeToken, nodeId, ...(baseUrl ? { baseUrl } : {}) },
207
+ ...(nodeName ? { nameOverride: nodeName } : {}),
208
+ providerName: definition.name,
209
+ ...(triggers ? { triggers } : {}),
210
+ reconnect: true,
211
+ signal: stopSignal.signal,
212
+ ...(loggingEnabled ? { logger } : { warn }),
213
+ });
214
+ } catch (err) {
215
+ if (!stopSignal.signal.aborted) {
216
+ fail('serving ' + configPath + ' failed: ' + errorText(err));
217
+ }
218
+ }
219
+ };
220
+
221
+ try {
222
+ await main();
223
+ } catch (err) {
224
+ if (err !== FAILURE) {
225
+ throw err;
226
+ }
227
+ }
228
+ `;
229
+ /** Marker the child prints its descriptor JSON behind, so config output can't corrupt it. */
230
+ export const NODE_DESCRIPTOR_MARKER = '__AGENT_RELAY_NODE_DESCRIPTOR__';
231
+ /**
232
+ * Parse the descriptor a `--describe` child printed.
233
+ * @param stdout - Raw child stdout, which may contain the config's own output.
234
+ * @returns The descriptor, or `undefined` when no marker line was produced.
235
+ */
236
+ export function parseNodeDescriptor(stdout) {
237
+ const lines = stdout
238
+ .split('\n')
239
+ .map((line) => line.trim())
240
+ .filter((line) => line.startsWith(NODE_DESCRIPTOR_MARKER));
241
+ const last = lines.at(-1);
242
+ if (!last) {
243
+ return undefined;
244
+ }
245
+ const parsed = JSON.parse(last.slice(NODE_DESCRIPTOR_MARKER.length));
246
+ return {
247
+ name: parsed.name,
248
+ capabilities: Array.isArray(parsed.capabilities) ? parsed.capabilities : [],
249
+ ...(typeof parsed.maxAgents === 'number' ? { maxAgents: parsed.maxAgents } : {}),
250
+ };
251
+ }
252
+ /**
253
+ * Adapt a descriptor to the capacity shape, so a node definition served
254
+ * out-of-process still contributes its `spawn:<harness>` capabilities to the
255
+ * broker's advertised capacity.
256
+ * @param descriptor - Descriptor reported by the `--describe` child.
257
+ */
258
+ export function descriptorCapacitySource(descriptor) {
259
+ return { capabilities: Object.fromEntries(descriptor.capabilities.map((name) => [name, true])) };
260
+ }
261
+ /**
262
+ * File extensions served through a child `node` process when the CLI itself is
263
+ * a `bun build --compile` binary.
264
+ */
265
+ const JS_NODE_DEFINITION_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']);
266
+ const TS_NODE_DEFINITION_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
267
+ /** Whether `configPath` is a JS/TS node definition (as opposed to `agent-relay.py`). */
268
+ export function isJsNodeDefinition(configPath) {
269
+ return JS_NODE_DEFINITION_EXTENSIONS.has(path.extname(configPath).toLowerCase());
270
+ }
271
+ /**
272
+ * Write the child bootstrap to a temp file so a child `node` process has
273
+ * something on disk to execute. The compiled binary has no `dist/` on disk, so
274
+ * the source is materialized per run.
275
+ * TypeScript inputs are pre-transpiled by the compiled Bun parent and served
276
+ * through a Node loader hook at their original file URLs. Keeping the original
277
+ * URLs preserves both relative imports and bare-specifier resolution from the
278
+ * config's own `node_modules`.
279
+ * @param configPath - Node definition the bootstrap will load.
280
+ * @param tempRoot - Root in which to create the private bootstrap directory.
281
+ * @returns Materialized bootstrap arguments and an idempotent cleanup handle.
282
+ */
283
+ export function materializeNodeProviderChildScript(configPath, tempRoot = os.tmpdir()) {
284
+ const directory = fs.mkdtempSync(path.join(tempRoot, 'agent-relay-node-provider-'));
285
+ activeTempDirectories.add(directory);
286
+ registerTempCleanupFallback();
287
+ try {
288
+ const scriptPath = path.join(directory, 'node-provider-child.mjs');
289
+ fs.writeFileSync(scriptPath, NODE_PROVIDER_CHILD_SOURCE, 'utf8');
290
+ const nodeArgs = [];
291
+ const transpiled = transpileTypeScriptGraph(configPath);
292
+ if (transpiled) {
293
+ const loaderPath = path.join(directory, 'node-definition-loader.mjs');
294
+ const registerPath = path.join(directory, 'register-node-definition-loader.mjs');
295
+ fs.writeFileSync(loaderPath, renderNodeLoader(transpiled), 'utf8');
296
+ fs.writeFileSync(registerPath, [
297
+ "import { register } from 'node:module';",
298
+ `register(${JSON.stringify(pathToFileURL(loaderPath).href)}, import.meta.url);`,
299
+ '',
300
+ ].join('\n'), 'utf8');
301
+ nodeArgs.push('--import', registerPath);
302
+ }
303
+ let cleaned = false;
304
+ return {
305
+ scriptPath,
306
+ nodeArgs,
307
+ directory,
308
+ cleanup: () => {
309
+ if (cleaned)
310
+ return;
311
+ cleaned = true;
312
+ cleanupTempDirectory(directory);
313
+ },
314
+ };
315
+ }
316
+ catch (err) {
317
+ cleanupTempDirectory(directory);
318
+ throw err;
319
+ }
320
+ }
321
+ const activeTempDirectories = new Set();
322
+ let tempCleanupFallbackRegistered = false;
323
+ function registerTempCleanupFallback() {
324
+ if (tempCleanupFallbackRegistered)
325
+ return;
326
+ tempCleanupFallbackRegistered = true;
327
+ process.once('exit', () => {
328
+ for (const directory of [...activeTempDirectories]) {
329
+ cleanupTempDirectory(directory);
330
+ }
331
+ });
332
+ }
333
+ function cleanupTempDirectory(directory) {
334
+ activeTempDirectories.delete(directory);
335
+ try {
336
+ fs.rmSync(directory, { recursive: true, force: true });
337
+ }
338
+ catch {
339
+ // Best effort: lifecycle cleanup must never mask the provider result.
340
+ }
341
+ }
342
+ /** Pre-transpile the entry and its statically imported local TypeScript graph. */
343
+ function transpileTypeScriptGraph(configPath) {
344
+ if (!TS_NODE_DEFINITION_EXTENSIONS.has(path.extname(configPath).toLowerCase())) {
345
+ return undefined;
346
+ }
347
+ const bun = globalThis.Bun;
348
+ if (!bun?.Transpiler) {
349
+ // This helper is only selected by the compiled-Bun serving plan. Retaining
350
+ // the native Node path here keeps isolated Node unit tests injectable.
351
+ return undefined;
352
+ }
353
+ const sources = {};
354
+ const resolutions = {};
355
+ const commonJsSources = {};
356
+ const queued = [path.resolve(configPath)];
357
+ const seen = new Set();
358
+ while (queued.length > 0) {
359
+ const file = queued.pop();
360
+ if (seen.has(file))
361
+ continue;
362
+ seen.add(file);
363
+ const source = fs.readFileSync(file, 'utf8');
364
+ const extension = path.extname(file).toLowerCase();
365
+ const loader = extension === '.tsx' ? 'tsx' : 'ts';
366
+ const transpiler = new bun.Transpiler({
367
+ loader,
368
+ target: 'node',
369
+ ...(extension === '.cts'
370
+ ? {
371
+ define: {
372
+ __filename: '__relayCommonJsFilename',
373
+ __dirname: '__relayCommonJsDirname',
374
+ },
375
+ }
376
+ : {}),
377
+ });
378
+ const parentUrls = new Set([pathToFileURL(file).href, pathToFileURL(fs.realpathSync(file)).href]);
379
+ const transformed = transpiler.transformSync(source, loader);
380
+ for (const parentUrl of parentUrls) {
381
+ if (extension === '.cts') {
382
+ commonJsSources[parentUrl] = transformed;
383
+ }
384
+ else {
385
+ sources[parentUrl] = transformed;
386
+ }
387
+ }
388
+ for (const imported of transpiler.scanImports(source)) {
389
+ const resolved = resolveImportedTypeScript(file, imported.path);
390
+ if (!resolved)
391
+ continue;
392
+ const resolvedUrl = pathToFileURL(fs.realpathSync(resolved)).href;
393
+ for (const parentUrl of parentUrls) {
394
+ resolutions[loaderResolutionKey(parentUrl, imported.path)] = resolvedUrl;
395
+ }
396
+ queued.push(resolved);
397
+ }
398
+ }
399
+ for (const entryUrl of Object.keys(commonJsSources)) {
400
+ sources[entryUrl] = wrapCommonJsAsModule(entryUrl, commonJsSources, resolutions);
401
+ }
402
+ return { sources, resolutions };
403
+ }
404
+ /** Execute a transpiled .cts graph synchronously with Node-like CommonJS modules. */
405
+ function wrapCommonJsAsModule(entryUrl, commonJsSources, resolutions) {
406
+ return [
407
+ "import { Module as __RelayModule, createRequire as __relayCreateRequire } from 'node:module';",
408
+ "import { dirname as __relayDirname } from 'node:path';",
409
+ "import { fileURLToPath as __relayFileURLToPath } from 'node:url';",
410
+ `const __relaySources = new Map(Object.entries(${JSON.stringify(commonJsSources)}));`,
411
+ `const __relayResolutions = new Map(Object.entries(${JSON.stringify(resolutions)}));`,
412
+ `const __relayKey = (parentURL, specifier) => parentURL + '\\0' + specifier;`,
413
+ 'const __relayCache = new Map();',
414
+ 'const __relayLoad = (url, parent) => {',
415
+ ' const cached = __relayCache.get(url);',
416
+ ' if (cached) return cached.exports;',
417
+ ' const source = __relaySources.get(url);',
418
+ " if (source === undefined) throw new Error('Missing transpiled CommonJS source for ' + url);",
419
+ ' const filename = __relayFileURLToPath(url);',
420
+ ' const dirname = __relayDirname(filename);',
421
+ ' const nativeRequire = __relayCreateRequire(url);',
422
+ ' const relayModule = new __RelayModule(filename, parent);',
423
+ ' relayModule.filename = filename;',
424
+ ' relayModule.path = dirname;',
425
+ " relayModule.paths = nativeRequire.resolve.paths('__agent_relay_module_paths__') || [];",
426
+ ' const relayRequire = (specifier) => {',
427
+ ' const resolved = __relayResolutions.get(__relayKey(url, specifier));',
428
+ ' return resolved && __relaySources.has(resolved)',
429
+ ' ? __relayLoad(resolved, relayModule)',
430
+ ' : nativeRequire(specifier);',
431
+ ' };',
432
+ ' relayRequire.resolve = nativeRequire.resolve;',
433
+ ' relayRequire.cache = nativeRequire.cache;',
434
+ ' relayRequire.extensions = nativeRequire.extensions;',
435
+ ' relayRequire.main = nativeRequire.main;',
436
+ ' relayModule.require = relayRequire;',
437
+ ' __relayCache.set(url, relayModule);',
438
+ ' const factory = new Function(',
439
+ " 'exports', 'require', 'module', '__filename', '__dirname',",
440
+ " '__relayCommonJsFilename', '__relayCommonJsDirname', source",
441
+ ' );',
442
+ ' factory.call(',
443
+ ' relayModule.exports, relayModule.exports, relayRequire, relayModule,',
444
+ ' filename, dirname, filename, dirname',
445
+ ' );',
446
+ ' relayModule.loaded = true;',
447
+ ' return relayModule.exports;',
448
+ '};',
449
+ `export default __relayLoad(${JSON.stringify(entryUrl)}, undefined);`,
450
+ '',
451
+ ].join('\n');
452
+ }
453
+ function resolveImportedTypeScript(parentFile, specifier) {
454
+ let candidate;
455
+ try {
456
+ if (specifier.startsWith('file:')) {
457
+ candidate = fileURLToPath(specifier);
458
+ }
459
+ else if (specifier.startsWith('.') || path.isAbsolute(specifier)) {
460
+ candidate = path.resolve(path.dirname(parentFile), specifier);
461
+ }
462
+ else {
463
+ candidate = createRequire(parentFile).resolve(specifier);
464
+ }
465
+ }
466
+ catch {
467
+ return undefined;
468
+ }
469
+ const attempts = [candidate];
470
+ if (!path.extname(candidate)) {
471
+ for (const extension of TS_NODE_DEFINITION_EXTENSIONS) {
472
+ attempts.push(candidate + extension, path.join(candidate, 'index' + extension));
473
+ }
474
+ }
475
+ else if (/\.[cm]?js$/i.test(candidate)) {
476
+ const stem = candidate.replace(/\.[cm]?js$/i, '');
477
+ attempts.push(stem + '.ts', stem + '.tsx', stem + '.mts', stem + '.cts');
478
+ }
479
+ const resolved = attempts.find((attempt) => TS_NODE_DEFINITION_EXTENSIONS.has(path.extname(attempt).toLowerCase()) && isFile(attempt));
480
+ return resolved ? path.resolve(resolved) : undefined;
481
+ }
482
+ function isFile(file) {
483
+ try {
484
+ return fs.statSync(file).isFile();
485
+ }
486
+ catch {
487
+ return false;
488
+ }
489
+ }
490
+ function loaderResolutionKey(parentUrl, specifier) {
491
+ return `${parentUrl}\0${specifier}`;
492
+ }
493
+ function renderNodeLoader(graph) {
494
+ return [
495
+ `const sources = new Map(Object.entries(${JSON.stringify(graph.sources)}));`,
496
+ `const resolutions = new Map(Object.entries(${JSON.stringify(graph.resolutions)}));`,
497
+ `const key = (parentURL, specifier) => parentURL + '\\0' + specifier;`,
498
+ 'export async function resolve(specifier, context, nextResolve) {',
499
+ ' const url = resolutions.get(key(context.parentURL || "", specifier));',
500
+ ' return url ? { url, shortCircuit: true } : nextResolve(specifier, context);',
501
+ '}',
502
+ 'export async function load(url, context, nextLoad) {',
503
+ ' const source = sources.get(url);',
504
+ " return source === undefined ? nextLoad(url, context) : { format: 'module', source, shortCircuit: true };",
505
+ '}',
506
+ '',
507
+ ].join('\n');
508
+ }
509
+ /** Single-quote a path for safe interpolation into a shell command. */
510
+ function shellQuote(value) {
511
+ return `'${value.replace(/'/g, `'\\''`)}'`;
512
+ }
513
+ /**
514
+ * Load a node definition's descriptor by running the bootstrap in `--describe`
515
+ * mode under a child `node` process.
516
+ *
517
+ * This is how the compiled binary learns anything at all about a JS/TS node
518
+ * definition: it cannot import one itself (scoped specifiers in the user's
519
+ * `node_modules` do not resolve under `bun build --compile`), so it asks Node.
520
+ *
521
+ * @param configPath - Absolute path to the user's node definition file.
522
+ * @param deps - Core dependencies (exec, env).
523
+ * @returns The descriptor.
524
+ * @throws When the child cannot load or validate the definition.
525
+ */
526
+ export async function describeNodeDefinitionViaNode(configPath, deps) {
527
+ const nodeBin = deps.env.AGENT_RELAY_NODE?.trim() || 'node';
528
+ const materialized = materializeNodeProviderChildScript(configPath, deps.env.TMPDIR?.trim() || os.tmpdir());
529
+ const command = [nodeBin, ...materialized.nodeArgs, materialized.scriptPath, configPath, '--describe']
530
+ .map(shellQuote)
531
+ .join(' ');
532
+ let stdout;
533
+ try {
534
+ ({ stdout } = await deps.execCommand(command));
535
+ }
536
+ catch (err) {
537
+ const detail = extractChildFailure(err);
538
+ throw new Error(`Failed to load ${configPath} via ${nodeBin}: ${detail}. ` +
539
+ `Serving a JS/TS node definition from the standalone agent-relay binary requires \`${nodeBin}\` on PATH ` +
540
+ '(set AGENT_RELAY_NODE to override).');
541
+ }
542
+ finally {
543
+ materialized.cleanup();
544
+ }
545
+ const descriptor = parseNodeDescriptor(stdout);
546
+ if (!descriptor) {
547
+ throw new Error(`Fleet node file ${configPath} must default-export defineNode(...)`);
548
+ }
549
+ return descriptor;
550
+ }
551
+ /** Pull the useful text out of an execCommand rejection (stderr beats the generic message). */
552
+ function extractChildFailure(err) {
553
+ const stderr = err && typeof err === 'object' && 'stderr' in err ? String(err.stderr) : '';
554
+ const trimmed = stderr.trim();
555
+ if (trimmed) {
556
+ return trimmed;
557
+ }
558
+ return err instanceof Error ? err.message : String(err);
559
+ }
560
+ /**
561
+ * Serve a JS/TS fleet node definition in a child `node` process.
562
+ *
563
+ * Used only when the CLI is running as a `bun build --compile` binary, where
564
+ * loading the definition in-process is impossible (see
565
+ * {@link NODE_PROVIDER_CHILD_SOURCE}). Under Node the definition is still
566
+ * served in-process, which keeps the existing behavior unchanged.
567
+ *
568
+ * @param configPath - Absolute path to the user's node definition file.
569
+ * @param credentials - Node identity/token the child registers with.
570
+ * @param deps - Core dependencies (spawn, env, logging).
571
+ * @returns A supervised child handle, or `undefined` when it could not be started.
572
+ */
573
+ export async function startNodeJsNodeProvider(configPath, credentials, deps) {
574
+ const nodeBin = deps.env.AGENT_RELAY_NODE?.trim() || 'node';
575
+ const env = {
576
+ ...deps.env,
577
+ RELAY_NODE_TOKEN: credentials.nodeToken,
578
+ RELAY_NODE_ID: credentials.nodeId,
579
+ RELAY_NODE_NAME: credentials.nodeName,
580
+ ...(credentials.baseUrl ? { RELAY_BASE_URL: credentials.baseUrl } : {}),
581
+ ...(credentials.workspaceKey ? { RELAY_WORKSPACE_KEY: credentials.workspaceKey } : {}),
582
+ };
583
+ const loggingEnabled = childLoggingEnabled(env);
584
+ let materialized;
585
+ try {
586
+ materialized = materializeNodeProviderChildScript(configPath, deps.env.TMPDIR?.trim() || os.tmpdir());
587
+ const child = deps.spawnProcess(nodeBin, [...materialized.nodeArgs, materialized.scriptPath, configPath], {
588
+ stdio: loggingEnabled ? ['inherit', 'inherit', 'inherit', 'ipc'] : 'inherit',
589
+ env,
590
+ cwd: path.dirname(configPath),
591
+ });
592
+ attachChildLogger(child, loggingEnabled);
593
+ const managed = manageNodeProviderChild(child, materialized, deps, configPath);
594
+ await managed.started;
595
+ deps.log(`Serving fleet node provider: ${nodeBin} ${path.basename(configPath)} (pid: ${child.pid ?? 'unknown'}).`);
596
+ return managed.handle;
597
+ }
598
+ catch (err) {
599
+ materialized?.cleanup();
600
+ deps.warn(`Fleet node provider skipped: ${err instanceof Error ? err.message : String(err)}. ` +
601
+ `Serving a JS/TS node definition from the standalone agent-relay binary requires \`${nodeBin}\` on PATH; ` +
602
+ 'set AGENT_RELAY_NODE to point at a Node.js executable.');
603
+ return undefined;
604
+ }
605
+ }
606
+ function childLoggingEnabled(env) {
607
+ return Boolean(env.AGENT_RELAY_LOG_FILE || env.AGENT_RELAY_LOG_LEVEL || env.AGENT_RELAY_LOG_JSON === '1');
608
+ }
609
+ function attachChildLogger(child, enabled) {
610
+ const evented = child;
611
+ if (!enabled || typeof evented.on !== 'function')
612
+ return;
613
+ const logger = createLogger('fleet');
614
+ evented.on('message', (message) => {
615
+ if (!isChildLogMessage(message))
616
+ return;
617
+ logger[message.level](message.message, message.extra);
618
+ });
619
+ }
620
+ function isChildLogMessage(value) {
621
+ if (!value || typeof value !== 'object')
622
+ return false;
623
+ const candidate = value;
624
+ return (candidate.__agentRelayNodeProviderLog === true &&
625
+ typeof candidate.message === 'string' &&
626
+ ['debug', 'info', 'warn', 'error'].includes(String(candidate.level)) &&
627
+ (candidate.extra === undefined ||
628
+ (typeof candidate.extra === 'object' && candidate.extra !== null && !Array.isArray(candidate.extra))));
629
+ }
630
+ function manageNodeProviderChild(child, materialized, deps, configPath) {
631
+ const evented = child;
632
+ const observesLifecycle = typeof evented.once === 'function';
633
+ let stopping = false;
634
+ let settled = false;
635
+ let resolveDone;
636
+ let rejectDone;
637
+ let resolveStarted;
638
+ let rejectStarted;
639
+ const done = new Promise((resolve, reject) => {
640
+ resolveDone = resolve;
641
+ rejectDone = reject;
642
+ });
643
+ // A child may fail between startNodeJsNodeProvider returning and the broker
644
+ // attaching its lifecycle race. Mark the promise handled without changing
645
+ // what callers observe when they await the original promise.
646
+ void done.catch(() => undefined);
647
+ const started = new Promise((resolve, reject) => {
648
+ resolveStarted = resolve;
649
+ rejectStarted = reject;
650
+ });
651
+ const finish = (error) => {
652
+ if (settled)
653
+ return;
654
+ settled = true;
655
+ materialized.cleanup();
656
+ if (error && !stopping) {
657
+ rejectDone(error);
658
+ }
659
+ else {
660
+ resolveDone();
661
+ }
662
+ };
663
+ if (observesLifecycle) {
664
+ evented.once('spawn', () => resolveStarted());
665
+ evented.once('error', (rawError) => {
666
+ const error = rawError instanceof Error ? rawError : new Error(String(rawError));
667
+ rejectStarted(error);
668
+ finish(new Error(`Fleet node provider for ${configPath} failed: ${error.message}`, { cause: error }));
669
+ });
670
+ evented.once('exit', (code, signal) => {
671
+ const detail = signal ? `signal ${String(signal)}` : `code ${String(code)}`;
672
+ finish(new Error(`Fleet node provider for ${configPath} exited unexpectedly (${detail}).`));
673
+ });
674
+ }
675
+ else {
676
+ resolveStarted();
677
+ }
678
+ return {
679
+ started,
680
+ handle: {
681
+ ...(child.pid !== undefined ? { pid: child.pid } : {}),
682
+ done,
683
+ stop: async () => {
684
+ stopping = true;
685
+ if (!settled) {
686
+ try {
687
+ if (child.pid) {
688
+ deps.killProcess(child.pid, 'SIGTERM');
689
+ }
690
+ else {
691
+ child.kill?.('SIGTERM');
692
+ }
693
+ }
694
+ catch {
695
+ // The child may already have exited between the state check and kill.
696
+ }
697
+ }
698
+ if (observesLifecycle && !settled) {
699
+ const exited = await Promise.race([
700
+ done.then(() => true, () => true),
701
+ deps.sleep(5_000).then(() => false),
702
+ ]);
703
+ if (!exited && !settled) {
704
+ try {
705
+ if (child.pid) {
706
+ deps.killProcess(child.pid, 'SIGKILL');
707
+ }
708
+ else {
709
+ child.kill?.('SIGKILL');
710
+ }
711
+ }
712
+ catch {
713
+ // The child may have exited while graceful shutdown timed out.
714
+ }
715
+ await Promise.race([done.catch(() => undefined), deps.sleep(1_000)]);
716
+ }
717
+ }
718
+ finish();
719
+ },
720
+ },
721
+ };
722
+ }
723
+ //# sourceMappingURL=node-provider-child.js.map