agent-log-viewer 1.0.1 → 1.0.2
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.
- package/README.md +19 -21
- package/bin/cli.mjs +212 -12
- package/bin/server-runtime.mjs +51 -0
- package/bin/server-runtime.test.ts +190 -0
- package/dist/mcp-server.mjs +58 -25
- package/dist/runtime-host.mjs +40301 -0
- package/dist/standalone/.next/BUILD_ID +1 -1
- package/dist/standalone/.next/app-path-routes-manifest.json +13 -13
- package/dist/standalone/.next/build-manifest.json +2 -2
- package/dist/standalone/.next/prerender-manifest.json +3 -3
- package/dist/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/dist/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/dist/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/api/accounts/claude/[id]/status/route.js +3 -3
- package/dist/standalone/.next/server/app/api/accounts/claude/login/[operationId]/input/route.js +3 -3
- package/dist/standalone/.next/server/app/api/accounts/claude/login/[operationId]/route.js +3 -3
- package/dist/standalone/.next/server/app/api/accounts/claude/terminal/route.js +3 -3
- package/dist/standalone/.next/server/app/api/accounts/codex/terminal/route.js +3 -3
- package/dist/standalone/.next/server/app/api/limits/history/route.js +3 -3
- package/dist/standalone/.next/server/app/api/limits/route.js +3 -3
- package/dist/standalone/.next/server/app/api/orchestrator/seat/route.js +1 -1
- package/dist/standalone/.next/server/app/api/runtime/deployments/[deploymentId]/route.js +1 -1
- package/dist/standalone/.next/server/app/api/runtime/deployments/capabilities/v1/route.js +1 -1
- package/dist/standalone/.next/server/app/api/runtime/deployments/route.js +1 -1
- package/dist/standalone/.next/server/app/api/runtime/operations/route.js +1 -1
- package/dist/standalone/.next/server/app/api/runtime/snapshot/route.js +1 -1
- package/dist/standalone/.next/server/app/api/runtime/stream/route.js +1 -1
- package/dist/standalone/.next/server/app/api/spawn/policy/route.js +3 -3
- package/dist/standalone/.next/server/app/api/tasks/route.js +2 -2
- package/dist/standalone/.next/server/app/index.html +1 -1
- package/dist/standalone/.next/server/app/index.rsc +2 -2
- package/dist/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/dist/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/dist/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/.next/server/app/page.js +15 -12
- package/dist/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/dist/standalone/.next/server/app-paths-manifest.json +13 -13
- package/dist/standalone/.next/server/chunks/2338.js +4 -1
- package/dist/standalone/.next/server/chunks/2904.js +3 -3
- package/dist/standalone/.next/server/chunks/3740.js +7 -7
- package/dist/standalone/.next/server/chunks/4012.js +3 -3
- package/dist/standalone/.next/server/chunks/4972.js +1 -1
- package/dist/standalone/.next/server/chunks/5689.js +2 -2
- package/dist/standalone/.next/server/chunks/7471.js +6 -6
- package/dist/standalone/.next/server/chunks/7733.js +3 -3
- package/dist/standalone/.next/server/chunks/8797.js +1 -1
- package/dist/standalone/.next/server/chunks/8817.js +1 -1
- package/dist/standalone/.next/server/chunks/9364.js +11 -11
- package/dist/standalone/.next/server/chunks/9411.js +2 -2
- package/dist/standalone/.next/server/chunks/9733.js +3 -3
- package/dist/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/standalone/.next/server/pages/404.html +1 -1
- package/dist/standalone/.next/server/pages/500.html +1 -1
- package/dist/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/standalone/.next/static/chunks/app/{page-ac9463b910bf83fe.js → page-7ba2538fa87e3d62.js} +17 -14
- package/dist/standalone/package.json +1 -1
- package/package.json +1 -1
- /package/dist/standalone/.next/static/{kt9OPe8iwfpNcdOX9ygvb → 2ybXlxpylgBaGz43zp2fM}/_buildManifest.js +0 -0
- /package/dist/standalone/.next/static/{kt9OPe8iwfpNcdOX9ygvb → 2ybXlxpylgBaGz43zp2fM}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -95,22 +95,25 @@ bunx agent-log-viewer
|
|
|
95
95
|
npx agent-log-viewer
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
This starts the server on `127.0.0.1:8898` and opens your browser.
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
This starts the server on `127.0.0.1:8898` and opens your browser. The CLI also
|
|
99
|
+
starts and supervises the packaged structured runtime host, including restart
|
|
100
|
+
backoff and Ctrl-C cleanup. Pipelines and the orchestrator work from this
|
|
101
|
+
installation without Docker. Both launch paths require Bun because the runtime
|
|
102
|
+
journal and authoritative state stores use Bun SQLite.
|
|
101
103
|
|
|
102
104
|
### From a local clone
|
|
103
105
|
|
|
104
106
|
```bash
|
|
105
107
|
bun install
|
|
106
108
|
bun run build
|
|
107
|
-
bun
|
|
109
|
+
bun bin/cli.mjs --no-open --port 8898 --hostname 127.0.0.1
|
|
108
110
|
# open http://127.0.0.1:8898/
|
|
109
111
|
```
|
|
110
112
|
|
|
111
|
-
|
|
112
|
-
development, `bun dev` runs the app with hot reload
|
|
113
|
-
file-watch limit for large home
|
|
113
|
+
The CLI serves the output of the last `build`, so run `build` first. For
|
|
114
|
+
development, `bun dev` runs the app with hot reload and expects a separately
|
|
115
|
+
managed runtime host (it needs a high OS file-watch limit for large home
|
|
116
|
+
directories).
|
|
114
117
|
|
|
115
118
|
The Viewer server runs on Bun. The runtime journal and hot state collections
|
|
116
119
|
use `bun:sqlite`, and macOS process ownership uses the kernel's microsecond
|
|
@@ -119,20 +122,15 @@ feature-flag configuration.
|
|
|
119
122
|
|
|
120
123
|
### Spawn transport
|
|
121
124
|
|
|
122
|
-
Agents launch through a structured runtime host
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
an unset variable means the default is on.
|
|
132
|
-
|
|
133
|
-
`LLV_SPAWN_TRANSPORT=tmux|structured` overrides the choice. An explicit
|
|
134
|
-
`structured` without a reachable host returns a gap naming what is missing
|
|
135
|
-
rather than quietly downgrading the request.
|
|
125
|
+
Agents launch through a structured runtime host. The installed CLI supervises
|
|
126
|
+
that host with the same Bun executable as the Viewer and places its Unix socket
|
|
127
|
+
and runtime journal under the Viewer state directory with installation-specific
|
|
128
|
+
names. Ambient deployment runtime settings are ignored so separate bunx
|
|
129
|
+
installations cannot attach to each other's host or claim each other's journal
|
|
130
|
+
epoch. Startup fails clearly when Bun, the packaged host entry, the managed
|
|
131
|
+
socket, or its directory permissions are unavailable.
|
|
132
|
+
The CLI log carries the host failure and the pipeline card directs the operator
|
|
133
|
+
to it. There is no tmux fallback.
|
|
136
134
|
|
|
137
135
|
### Connect an orchestrator through MCP
|
|
138
136
|
|
package/bin/cli.mjs
CHANGED
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync } from "node:fs";
|
|
5
5
|
import http from "node:http";
|
|
6
|
+
import net from "node:net";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { dirname, join } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
|
|
10
11
|
import { detectTailscale, getToken, readStatus, serve as serveTailscale, TailscaleError } from "./tailscale.mjs";
|
|
11
12
|
import {
|
|
13
|
+
cliRuntimeHostConfig,
|
|
14
|
+
cliRuntimeHostEnvironment,
|
|
12
15
|
discardWakatimeEnvironmentCredential,
|
|
13
16
|
viewerChildProcessOptions,
|
|
14
17
|
viewerServerBunRuntime,
|
|
15
|
-
withoutWakatimeCredential,
|
|
16
18
|
} from "./server-runtime.mjs";
|
|
17
19
|
|
|
18
20
|
discardWakatimeEnvironmentCredential();
|
|
@@ -27,6 +29,11 @@ const READINESS_INTERVAL_MS = 200;
|
|
|
27
29
|
// Reusing READINESS_INTERVAL_MS here made every probe abort before the healthy
|
|
28
30
|
// server could answer, so startup always "timed out" and killed its own server.
|
|
29
31
|
const READINESS_PROBE_TIMEOUT_MS = 5_000;
|
|
32
|
+
const RUNTIME_HOST_READINESS_TIMEOUT_MS = 15_000;
|
|
33
|
+
const RUNTIME_HOST_READINESS_INTERVAL_MS = 100;
|
|
34
|
+
const RUNTIME_HOST_RESTART_BASE_MS = 500;
|
|
35
|
+
const RUNTIME_HOST_RESTART_MAX_MS = 10_000;
|
|
36
|
+
const RUNTIME_HOST_STABLE_UPTIME_MS = 30_000;
|
|
30
37
|
|
|
31
38
|
const cliPath = fileURLToPath(import.meta.url);
|
|
32
39
|
const cliDir = dirname(cliPath);
|
|
@@ -73,6 +80,13 @@ Options:
|
|
|
73
80
|
tsCookie: () => " After the first open the key is stored in a cookie for 30 days.",
|
|
74
81
|
nonLocalWarn: () => "Warning: a non-local address exposes the viewer to the network, so access-key mode is forced on.",
|
|
75
82
|
serverNotReady: () => "Server not ready.",
|
|
83
|
+
runtimeHostEntryMissing: () => "The runtime host is missing from this install. Reinstall agent-log-viewer and try again.",
|
|
84
|
+
runtimeHostStartFail: (detail) => `Couldn't start the structured runtime host: ${detail}`,
|
|
85
|
+
runtimeHostTimeout: (socketPath) => `the runtime host did not bind ${socketPath} within ${RUNTIME_HOST_READINESS_TIMEOUT_MS / 1_000} seconds; check the socket directory permissions`,
|
|
86
|
+
runtimeHostExited: (detail) => `the runtime host exited before its socket was ready${detail ? `: ${detail}` : ""}`,
|
|
87
|
+
runtimeHostOwnerMismatch: (ownerPid, childPid) => `the runtime host socket is owned by pid ${ownerPid}, while this CLI spawned pid ${childPid}; stop the other agent-log-viewer instance for this installation and try again`,
|
|
88
|
+
runtimeHostRestart: (delay, detail) => `[runtime host] ${detail}; restarting in ${delay}ms`,
|
|
89
|
+
runtimeHostRestartFail: (detail) => `[runtime host] restart failed: ${detail}`,
|
|
76
90
|
},
|
|
77
91
|
uk: {
|
|
78
92
|
usage: () => `Використання: agent-log-viewer [опції]
|
|
@@ -104,6 +118,13 @@ Options:
|
|
|
104
118
|
tsCookie: () => " Після першого відкриття ключ зберігається у cookie на 30 днів.",
|
|
105
119
|
nonLocalWarn: () => "Увага: нелокальна адреса відкриває viewer для мережі, тому режим ключа доступу увімкнено примусово.",
|
|
106
120
|
serverNotReady: () => "Сервер не готовий.",
|
|
121
|
+
runtimeHostEntryMissing: () => "У цьому пакеті немає runtime host. Перевстановіть agent-log-viewer і повторіть спробу.",
|
|
122
|
+
runtimeHostStartFail: (detail) => `Не вдалося запустити structured runtime host: ${detail}`,
|
|
123
|
+
runtimeHostTimeout: (socketPath) => `runtime host не створив ${socketPath} за ${RUNTIME_HOST_READINESS_TIMEOUT_MS / 1_000} секунд; перевірте права каталогу сокета`,
|
|
124
|
+
runtimeHostExited: (detail) => `runtime host завершився до готовності сокета${detail ? `: ${detail}` : ""}`,
|
|
125
|
+
runtimeHostOwnerMismatch: (ownerPid, childPid) => `сокетом runtime host володіє процес ${ownerPid}, а цей CLI запустив процес ${childPid}; зупиніть інший agent-log-viewer для цієї інсталяції та повторіть спробу`,
|
|
126
|
+
runtimeHostRestart: (delay, detail) => `[runtime host] ${detail}; повторний запуск за ${delay} мс`,
|
|
127
|
+
runtimeHostRestartFail: (detail) => `[runtime host] помилка повторного запуску: ${detail}`,
|
|
107
128
|
},
|
|
108
129
|
};
|
|
109
130
|
|
|
@@ -255,9 +276,9 @@ function resolveServer(packageRoot) {
|
|
|
255
276
|
};
|
|
256
277
|
}
|
|
257
278
|
|
|
258
|
-
function buildChildEnv(options, runtime, packageRoot) {
|
|
279
|
+
function buildChildEnv(options, runtime, packageRoot, runtimeHostEnvironment) {
|
|
259
280
|
const env = {
|
|
260
|
-
...
|
|
281
|
+
...runtimeHostEnvironment,
|
|
261
282
|
PORT: String(options.port),
|
|
262
283
|
// zsh exports HOSTNAME with the machine name on this user's machine; setting it here keeps standalone bound to the requested address.
|
|
263
284
|
HOSTNAME: options.hostname,
|
|
@@ -306,10 +327,10 @@ function buildChildEnv(options, runtime, packageRoot) {
|
|
|
306
327
|
return env;
|
|
307
328
|
}
|
|
308
329
|
|
|
309
|
-
function startServer(server, options, runtime, tailscaleProcessRef, packageRoot) {
|
|
330
|
+
function startServer(server, options, runtime, tailscaleProcessRef, runtimeHostSupervisor, packageRoot, runtimeHostEnvironment) {
|
|
310
331
|
const child = spawn(server.command, server.args, viewerChildProcessOptions({
|
|
311
332
|
cwd: server.cwd,
|
|
312
|
-
env: buildChildEnv(options, runtime, packageRoot),
|
|
333
|
+
env: buildChildEnv(options, runtime, packageRoot, runtimeHostEnvironment),
|
|
313
334
|
stdio: ["ignore", "inherit", "pipe"],
|
|
314
335
|
}));
|
|
315
336
|
|
|
@@ -333,7 +354,11 @@ function startServer(server, options, runtime, tailscaleProcessRef, packageRoot)
|
|
|
333
354
|
});
|
|
334
355
|
|
|
335
356
|
child.on("error", (error) => {
|
|
336
|
-
|
|
357
|
+
state.stopping = true;
|
|
358
|
+
void Promise.all([
|
|
359
|
+
tailscaleProcessRef?.current ? stopChild(tailscaleProcessRef.current) : Promise.resolve(),
|
|
360
|
+
runtimeHostSupervisor.stop(),
|
|
361
|
+
]).finally(() => fail(m.serverStartFail(error.message)));
|
|
337
362
|
});
|
|
338
363
|
|
|
339
364
|
child.on("exit", async (code, signal) => {
|
|
@@ -348,6 +373,7 @@ function startServer(server, options, runtime, tailscaleProcessRef, packageRoot)
|
|
|
348
373
|
if (tailscaleProcessRef?.current) {
|
|
349
374
|
await stopChild(tailscaleProcessRef.current);
|
|
350
375
|
}
|
|
376
|
+
await runtimeHostSupervisor.stop();
|
|
351
377
|
|
|
352
378
|
if (state.sawAddressInUse) {
|
|
353
379
|
process.exit(1);
|
|
@@ -369,6 +395,156 @@ function wait(ms) {
|
|
|
369
395
|
});
|
|
370
396
|
}
|
|
371
397
|
|
|
398
|
+
function probeRuntimeHost(socketPath) {
|
|
399
|
+
return new Promise((resolve) => {
|
|
400
|
+
const socket = net.createConnection(socketPath);
|
|
401
|
+
let settled = false;
|
|
402
|
+
const finish = (ready) => {
|
|
403
|
+
if (settled) return;
|
|
404
|
+
settled = true;
|
|
405
|
+
socket.destroy();
|
|
406
|
+
resolve(ready);
|
|
407
|
+
};
|
|
408
|
+
socket.once("connect", () => finish(true));
|
|
409
|
+
socket.once("error", () => finish(false));
|
|
410
|
+
socket.setTimeout(1_000, () => finish(false));
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function runtimeHostFenceOwner(socketPath) {
|
|
415
|
+
try {
|
|
416
|
+
const owner = JSON.parse(readFileSync(`${socketPath}.lock`, "utf8"));
|
|
417
|
+
if (!Number.isSafeInteger(owner?.pid) || owner.pid <= 1) return null;
|
|
418
|
+
if (typeof owner.startIdentity !== "string" || !owner.startIdentity.startsWith(`${owner.pid}:`)) return null;
|
|
419
|
+
if (typeof owner.acquisitionId !== "string" || owner.acquisitionId.length < 16) return null;
|
|
420
|
+
return owner;
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function runtimeHostExitDetail(processHandle) {
|
|
427
|
+
const { child, state } = processHandle;
|
|
428
|
+
if (state.spawnError) {
|
|
429
|
+
return state.spawnError.code === "ENOENT"
|
|
430
|
+
? `Bun executable ${state.command} is unavailable (${state.spawnError.message})`
|
|
431
|
+
: `Bun could not launch the host (${state.spawnError.message})`;
|
|
432
|
+
}
|
|
433
|
+
const outcome = child.signalCode ? `signal ${child.signalCode}` : `exit code ${child.exitCode ?? "unknown"}`;
|
|
434
|
+
const stderr = state.stderrTail
|
|
435
|
+
.split(/\r?\n/)
|
|
436
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
437
|
+
.filter(Boolean)
|
|
438
|
+
.slice(-3)
|
|
439
|
+
.join(" | ");
|
|
440
|
+
return stderr ? `${outcome}: ${stderr}` : outcome;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function waitForRuntimeHost(socketPath, processHandle = null) {
|
|
444
|
+
const deadline = Date.now() + RUNTIME_HOST_READINESS_TIMEOUT_MS;
|
|
445
|
+
while (Date.now() < deadline) {
|
|
446
|
+
if (processHandle?.state.spawnError) {
|
|
447
|
+
throw new Error(runtimeHostExitDetail(processHandle));
|
|
448
|
+
}
|
|
449
|
+
if (processHandle && (processHandle.child.exitCode !== null || processHandle.child.signalCode !== null)) {
|
|
450
|
+
throw new Error(m.runtimeHostExited(runtimeHostExitDetail(processHandle)));
|
|
451
|
+
}
|
|
452
|
+
if (await probeRuntimeHost(socketPath)) {
|
|
453
|
+
const owner = runtimeHostFenceOwner(socketPath);
|
|
454
|
+
if (!processHandle) return;
|
|
455
|
+
if (owner?.pid === processHandle.child.pid) return;
|
|
456
|
+
if (owner) throw new Error(m.runtimeHostOwnerMismatch(owner.pid, processHandle.child.pid));
|
|
457
|
+
}
|
|
458
|
+
await wait(RUNTIME_HOST_READINESS_INTERVAL_MS);
|
|
459
|
+
}
|
|
460
|
+
throw new Error(m.runtimeHostTimeout(socketPath));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function createRuntimeHostSupervisor(config, bunRuntime, environment, packageRoot) {
|
|
464
|
+
let current = null;
|
|
465
|
+
let restartTimer = null;
|
|
466
|
+
let restartFailures = 0;
|
|
467
|
+
let stopping = false;
|
|
468
|
+
|
|
469
|
+
const scheduleRestart = (detail, uptimeMs = 0) => {
|
|
470
|
+
if (stopping || restartTimer) return;
|
|
471
|
+
restartFailures = uptimeMs >= RUNTIME_HOST_STABLE_UPTIME_MS ? 1 : restartFailures + 1;
|
|
472
|
+
const delay = Math.min(
|
|
473
|
+
RUNTIME_HOST_RESTART_BASE_MS * (2 ** Math.max(0, restartFailures - 1)),
|
|
474
|
+
RUNTIME_HOST_RESTART_MAX_MS,
|
|
475
|
+
);
|
|
476
|
+
console.error(m.runtimeHostRestart(delay, detail));
|
|
477
|
+
restartTimer = setTimeout(() => {
|
|
478
|
+
restartTimer = null;
|
|
479
|
+
void launch(false).catch((error) => {
|
|
480
|
+
if (stopping) return;
|
|
481
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
482
|
+
console.error(m.runtimeHostRestartFail(message));
|
|
483
|
+
scheduleRestart(message);
|
|
484
|
+
});
|
|
485
|
+
}, delay);
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const spawnHost = () => {
|
|
489
|
+
const child = spawn(bunRuntime, ["--bun", config.entrypoint], viewerChildProcessOptions({
|
|
490
|
+
cwd: packageRoot,
|
|
491
|
+
env: environment,
|
|
492
|
+
stdio: ["ignore", "inherit", "pipe"],
|
|
493
|
+
}));
|
|
494
|
+
const state = {
|
|
495
|
+
command: bunRuntime,
|
|
496
|
+
readyAt: null,
|
|
497
|
+
spawnError: null,
|
|
498
|
+
stderrTail: "",
|
|
499
|
+
stopping: false,
|
|
500
|
+
};
|
|
501
|
+
const processHandle = { child, state };
|
|
502
|
+
current = processHandle;
|
|
503
|
+
child.stderr.on("data", (chunk) => {
|
|
504
|
+
state.stderrTail = `${state.stderrTail}${chunk}`.slice(-8_192);
|
|
505
|
+
process.stderr.write(chunk);
|
|
506
|
+
});
|
|
507
|
+
child.once("error", (error) => {
|
|
508
|
+
state.spawnError = error;
|
|
509
|
+
});
|
|
510
|
+
child.once("exit", () => {
|
|
511
|
+
if (current !== processHandle || stopping || state.stopping || state.readyAt === null) return;
|
|
512
|
+
scheduleRestart(runtimeHostExitDetail(processHandle), Date.now() - state.readyAt);
|
|
513
|
+
});
|
|
514
|
+
return processHandle;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const launch = async (initial) => {
|
|
518
|
+
const processHandle = spawnHost();
|
|
519
|
+
try {
|
|
520
|
+
await waitForRuntimeHost(config.socketPath, processHandle);
|
|
521
|
+
processHandle.state.readyAt = Date.now();
|
|
522
|
+
if (processHandle.child.exitCode !== null || processHandle.child.signalCode !== null) {
|
|
523
|
+
throw new Error(m.runtimeHostExited(runtimeHostExitDetail(processHandle)));
|
|
524
|
+
}
|
|
525
|
+
} catch (error) {
|
|
526
|
+
await stopChild(processHandle);
|
|
527
|
+
if (initial) throw error;
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
async start() {
|
|
534
|
+
if (!existsSync(config.entrypoint)) throw new Error(m.runtimeHostEntryMissing());
|
|
535
|
+
await launch(true);
|
|
536
|
+
},
|
|
537
|
+
async stop() {
|
|
538
|
+
stopping = true;
|
|
539
|
+
if (restartTimer) {
|
|
540
|
+
clearTimeout(restartTimer);
|
|
541
|
+
restartTimer = null;
|
|
542
|
+
}
|
|
543
|
+
if (current) await stopChild(current);
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
372
548
|
function probe(url) {
|
|
373
549
|
return new Promise((resolve) => {
|
|
374
550
|
const request = http.get(url, (response) => {
|
|
@@ -477,16 +653,17 @@ async function stopChild(processHandle) {
|
|
|
477
653
|
});
|
|
478
654
|
}
|
|
479
655
|
|
|
480
|
-
async function stopAll(serverProcess, tailscaleProcess) {
|
|
656
|
+
async function stopAll(serverProcess, tailscaleProcess, runtimeHostSupervisor) {
|
|
481
657
|
await Promise.all([
|
|
482
658
|
serverProcess ? stopChild(serverProcess) : Promise.resolve(),
|
|
483
659
|
tailscaleProcess ? stopChild(tailscaleProcess) : Promise.resolve(),
|
|
660
|
+
runtimeHostSupervisor ? runtimeHostSupervisor.stop() : Promise.resolve(),
|
|
484
661
|
]);
|
|
485
662
|
}
|
|
486
663
|
|
|
487
|
-
function installSignalHandlers(serverProcess, tailscaleProcessRef) {
|
|
664
|
+
function installSignalHandlers(serverProcess, tailscaleProcessRef, runtimeHostSupervisor) {
|
|
488
665
|
const shutdown = async () => {
|
|
489
|
-
await stopAll(serverProcess, tailscaleProcessRef.current);
|
|
666
|
+
await stopAll(serverProcess, tailscaleProcessRef.current, runtimeHostSupervisor);
|
|
490
667
|
process.exit(0);
|
|
491
668
|
};
|
|
492
669
|
|
|
@@ -626,9 +803,32 @@ async function main() {
|
|
|
626
803
|
}
|
|
627
804
|
|
|
628
805
|
const server = resolveServer(packageRoot);
|
|
806
|
+
const runtimeHostConfig = cliRuntimeHostConfig(packageRoot);
|
|
807
|
+
const runtimeHostEnvironment = cliRuntimeHostEnvironment(process.env, runtimeHostConfig);
|
|
808
|
+
const runtimeHostSupervisor = createRuntimeHostSupervisor(
|
|
809
|
+
runtimeHostConfig,
|
|
810
|
+
viewerServerBunRuntime(),
|
|
811
|
+
runtimeHostEnvironment,
|
|
812
|
+
packageRoot,
|
|
813
|
+
);
|
|
814
|
+
try {
|
|
815
|
+
await runtimeHostSupervisor.start();
|
|
816
|
+
} catch (error) {
|
|
817
|
+
await runtimeHostSupervisor.stop();
|
|
818
|
+
fail(m.runtimeHostStartFail(error instanceof Error ? error.message : String(error)));
|
|
819
|
+
}
|
|
820
|
+
|
|
629
821
|
const tailscaleProcessRef = { current: null };
|
|
630
|
-
const serverProcess = startServer(
|
|
631
|
-
|
|
822
|
+
const serverProcess = startServer(
|
|
823
|
+
server,
|
|
824
|
+
options,
|
|
825
|
+
runtime,
|
|
826
|
+
tailscaleProcessRef,
|
|
827
|
+
runtimeHostSupervisor,
|
|
828
|
+
packageRoot,
|
|
829
|
+
runtimeHostEnvironment,
|
|
830
|
+
);
|
|
831
|
+
installSignalHandlers(serverProcess, tailscaleProcessRef, runtimeHostSupervisor);
|
|
632
832
|
|
|
633
833
|
if (options.tailscale && runtime.tailscalePath) {
|
|
634
834
|
tailscaleProcessRef.current = serveTailscale(runtime.tailscalePath, options.port);
|
|
@@ -637,7 +837,7 @@ async function main() {
|
|
|
637
837
|
try {
|
|
638
838
|
await waitForReadiness(options.port);
|
|
639
839
|
} catch (error) {
|
|
640
|
-
await stopAll(serverProcess, tailscaleProcessRef.current);
|
|
840
|
+
await stopAll(serverProcess, tailscaleProcessRef.current, runtimeHostSupervisor);
|
|
641
841
|
fail(error instanceof Error ? error.message : m.serverNotReady());
|
|
642
842
|
}
|
|
643
843
|
|
package/bin/server-runtime.mjs
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
|
|
1
6
|
export const WAKATIME_CREDENTIAL_ENV = "WAKATIME_API_KEY";
|
|
2
7
|
|
|
3
8
|
/** @param {Record<string, string | undefined>} environment */
|
|
@@ -51,3 +56,49 @@ export function viewerServerBunRuntime(options = {}) {
|
|
|
51
56
|
const execPath = options.execPath ?? process.execPath;
|
|
52
57
|
return versions.bun ? execPath : (env.LLV_BUN_EXECUTABLE || "bun");
|
|
53
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the runtime host the public CLI will supervise. Ordinary package
|
|
62
|
+
* installs share the Viewer state directory while retaining distinct socket
|
|
63
|
+
* and journal ownership. Ambient deployment runtime configuration never
|
|
64
|
+
* crosses this CLI ownership boundary.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} packageRoot
|
|
67
|
+
* @param {{ env?: Readonly<Record<string, string | undefined>>, home?: string }} [options]
|
|
68
|
+
*/
|
|
69
|
+
export function cliRuntimeHostConfig(packageRoot, options = {}) {
|
|
70
|
+
const env = options.env ?? process.env;
|
|
71
|
+
const home = options.home ?? homedir();
|
|
72
|
+
const stateDirectory = env.LLV_STATE_DIR?.trim()
|
|
73
|
+
|| join(env.XDG_CONFIG_HOME?.trim() || join(home, ".config"), "agent-log-viewer", "state");
|
|
74
|
+
const installId = createHash("sha256").update(resolve(packageRoot)).digest("hex").slice(0, 16);
|
|
75
|
+
const bundled = join(packageRoot, "dist", "runtime-host.mjs");
|
|
76
|
+
const source = join(packageRoot, "src", "runtime-host", "main.ts");
|
|
77
|
+
return {
|
|
78
|
+
socketPath: join(stateDirectory, `runtime-host-${installId}.sock`),
|
|
79
|
+
journalPath: join(stateDirectory, `runtime-events-${installId}.sqlite`),
|
|
80
|
+
entrypoint: existsSync(bundled) ? bundled : source,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Environment shared by the supervised runtime host and the Viewer server.
|
|
86
|
+
* The CLI is the structured-host activation boundary, including for callers
|
|
87
|
+
* whose shell still carries one of the former rollback values. Its journal
|
|
88
|
+
* follows the same installation boundary as its socket, so a CLI host cannot
|
|
89
|
+
* claim an ambient deployment's host epoch through the shared state directory.
|
|
90
|
+
*
|
|
91
|
+
* @param {Readonly<Record<string, string | undefined>>} base
|
|
92
|
+
* @param {{ socketPath: string, journalPath: string }} config
|
|
93
|
+
*/
|
|
94
|
+
export function cliRuntimeHostEnvironment(base, config) {
|
|
95
|
+
return {
|
|
96
|
+
...withoutWakatimeCredential(base),
|
|
97
|
+
LLV_RUNTIME_HOST_SOCKET: config.socketPath,
|
|
98
|
+
LLV_RUNTIME_JOURNAL: config.journalPath,
|
|
99
|
+
LLV_STRUCTURED_HOSTS: "1",
|
|
100
|
+
LLV_RUNTIME_EVENTS: "1",
|
|
101
|
+
LLV_SPAWN_TRANSPORT: "structured",
|
|
102
|
+
NEXT_PUBLIC_RUNTIME_UI: "1",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
6
|
|
|
@@ -10,8 +11,11 @@ import { loadFlows } from "@/lib/flows/store";
|
|
|
10
11
|
import { loadArchivedPipelines, loadPipelines } from "@/lib/pipelines/store";
|
|
11
12
|
import { structuredHostsEnabled as structuredHostsEnabledInApp } from "@/lib/runtime/flags";
|
|
12
13
|
import { loadWorkflows } from "@/lib/workflows/store";
|
|
14
|
+
import { RuntimeHostFence } from "@/runtime-host/runtimeHostFence";
|
|
13
15
|
|
|
14
16
|
import {
|
|
17
|
+
cliRuntimeHostConfig,
|
|
18
|
+
cliRuntimeHostEnvironment,
|
|
15
19
|
structuredHostsEnabled as structuredHostsEnabledInLauncher,
|
|
16
20
|
viewerServerBunRuntime,
|
|
17
21
|
viewerChildProcessOptions,
|
|
@@ -19,6 +23,192 @@ import {
|
|
|
19
23
|
withoutWakatimeCredential,
|
|
20
24
|
} from "./server-runtime.mjs";
|
|
21
25
|
|
|
26
|
+
async function availablePort(): Promise<number> {
|
|
27
|
+
const server = net.createServer();
|
|
28
|
+
await new Promise<void>((resolve, reject) => {
|
|
29
|
+
server.once("error", reject);
|
|
30
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
31
|
+
});
|
|
32
|
+
const address = server.address();
|
|
33
|
+
if (!address || typeof address === "string") throw new Error("could not reserve a launcher test port");
|
|
34
|
+
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
35
|
+
return address.port;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("the CLI derives a distinct managed runtime host for each install", () => {
|
|
39
|
+
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "llv-runtime-package-"));
|
|
40
|
+
const firstPackageRoot = path.join(sandbox, "first");
|
|
41
|
+
const secondPackageRoot = path.join(sandbox, "second");
|
|
42
|
+
const stateDirectory = path.join(sandbox, "state");
|
|
43
|
+
const ambientSocket = path.join(sandbox, "operator-runtime.sock");
|
|
44
|
+
const ambientJournal = path.join(sandbox, "operator-runtime.sqlite");
|
|
45
|
+
const firstBundle = path.join(firstPackageRoot, "dist", "runtime-host.mjs");
|
|
46
|
+
const secondBundle = path.join(secondPackageRoot, "dist", "runtime-host.mjs");
|
|
47
|
+
fs.mkdirSync(path.dirname(firstBundle), { recursive: true });
|
|
48
|
+
fs.mkdirSync(path.dirname(secondBundle), { recursive: true });
|
|
49
|
+
fs.writeFileSync(firstBundle, "export {};\n");
|
|
50
|
+
fs.writeFileSync(secondBundle, "export {};\n");
|
|
51
|
+
try {
|
|
52
|
+
const options = {
|
|
53
|
+
env: {
|
|
54
|
+
LLV_STATE_DIR: stateDirectory,
|
|
55
|
+
LLV_RUNTIME_HOST_SOCKET: ambientSocket,
|
|
56
|
+
LLV_RUNTIME_JOURNAL: ambientJournal,
|
|
57
|
+
},
|
|
58
|
+
home: path.join(sandbox, "home"),
|
|
59
|
+
};
|
|
60
|
+
const first = cliRuntimeHostConfig(firstPackageRoot, options);
|
|
61
|
+
const second = cliRuntimeHostConfig(secondPackageRoot, options);
|
|
62
|
+
|
|
63
|
+
expect(first.entrypoint).toBe(firstBundle);
|
|
64
|
+
expect(second.entrypoint).toBe(secondBundle);
|
|
65
|
+
expect(path.dirname(first.socketPath)).toBe(stateDirectory);
|
|
66
|
+
expect(path.dirname(second.socketPath)).toBe(stateDirectory);
|
|
67
|
+
expect(path.basename(first.socketPath)).toMatch(/^runtime-host-[a-f0-9]{16}\.sock$/);
|
|
68
|
+
expect(path.basename(second.socketPath)).toMatch(/^runtime-host-[a-f0-9]{16}\.sock$/);
|
|
69
|
+
expect(path.dirname(first.journalPath)).toBe(stateDirectory);
|
|
70
|
+
expect(path.dirname(second.journalPath)).toBe(stateDirectory);
|
|
71
|
+
expect(path.basename(first.journalPath)).toMatch(/^runtime-events-[a-f0-9]{16}\.sqlite$/);
|
|
72
|
+
expect(path.basename(second.journalPath)).toMatch(/^runtime-events-[a-f0-9]{16}\.sqlite$/);
|
|
73
|
+
expect(first.socketPath).not.toBe(second.socketPath);
|
|
74
|
+
expect(first.journalPath).not.toBe(second.journalPath);
|
|
75
|
+
expect(first.socketPath).not.toBe(ambientSocket);
|
|
76
|
+
expect(second.socketPath).not.toBe(ambientSocket);
|
|
77
|
+
expect(first.journalPath).not.toBe(ambientJournal);
|
|
78
|
+
expect(second.journalPath).not.toBe(ambientJournal);
|
|
79
|
+
} finally {
|
|
80
|
+
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the CLI uses the source runtime host in a checkout", () => {
|
|
85
|
+
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "llv-runtime-source-"));
|
|
86
|
+
const source = path.join(packageRoot, "src", "runtime-host", "main.ts");
|
|
87
|
+
const stateDirectory = path.join(packageRoot, "state");
|
|
88
|
+
fs.mkdirSync(path.dirname(source), { recursive: true });
|
|
89
|
+
fs.writeFileSync(source, "export {};\n");
|
|
90
|
+
try {
|
|
91
|
+
const config = cliRuntimeHostConfig(packageRoot, {
|
|
92
|
+
env: { LLV_STATE_DIR: stateDirectory },
|
|
93
|
+
home: path.join(packageRoot, "home"),
|
|
94
|
+
});
|
|
95
|
+
expect(config.entrypoint).toBe(source);
|
|
96
|
+
expect(path.dirname(config.socketPath)).toBe(stateDirectory);
|
|
97
|
+
expect(path.dirname(config.journalPath)).toBe(stateDirectory);
|
|
98
|
+
} finally {
|
|
99
|
+
fs.rmSync(packageRoot, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the CLI runtime environment owns its socket and journal and strips ambient WakaTime credentials", () => {
|
|
104
|
+
const socketPath = "/runtime/viewer.sock";
|
|
105
|
+
const journalPath = "/runtime/viewer.sqlite";
|
|
106
|
+
const env: Record<string, string | undefined> = cliRuntimeHostEnvironment({
|
|
107
|
+
PATH: "/usr/bin",
|
|
108
|
+
LLV_RUNTIME_HOST_SOCKET: "/runtime/operator.sock",
|
|
109
|
+
LLV_RUNTIME_JOURNAL: "/runtime/operator.sqlite",
|
|
110
|
+
LLV_STRUCTURED_HOSTS: "off",
|
|
111
|
+
LLV_RUNTIME_EVENTS: "0",
|
|
112
|
+
LLV_SPAWN_TRANSPORT: "tmux",
|
|
113
|
+
NEXT_PUBLIC_RUNTIME_UI: "0",
|
|
114
|
+
[WAKATIME_CREDENTIAL_ENV]: "fixture-value",
|
|
115
|
+
}, { socketPath, journalPath });
|
|
116
|
+
|
|
117
|
+
expect(env).toMatchObject({
|
|
118
|
+
PATH: "/usr/bin",
|
|
119
|
+
LLV_RUNTIME_HOST_SOCKET: socketPath,
|
|
120
|
+
LLV_RUNTIME_JOURNAL: journalPath,
|
|
121
|
+
LLV_STRUCTURED_HOSTS: "1",
|
|
122
|
+
LLV_RUNTIME_EVENTS: "1",
|
|
123
|
+
LLV_SPAWN_TRANSPORT: "structured",
|
|
124
|
+
NEXT_PUBLIC_RUNTIME_UI: "1",
|
|
125
|
+
});
|
|
126
|
+
expect(env[WAKATIME_CREDENTIAL_ENV]).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("the CLI rejects a ready runtime socket owned by another process", async () => {
|
|
130
|
+
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "llv-runtime-incumbent-"));
|
|
131
|
+
const packageRoot = path.resolve(import.meta.dir, "..");
|
|
132
|
+
const stateDirectory = path.join(sandbox, "state");
|
|
133
|
+
const environment: Record<string, string | undefined> = {
|
|
134
|
+
...process.env,
|
|
135
|
+
HOME: path.join(sandbox, "home"),
|
|
136
|
+
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
|
137
|
+
LLV_STATE_DIR: stateDirectory,
|
|
138
|
+
TMPDIR: path.join(sandbox, "tmp"),
|
|
139
|
+
LLV_LANG: "en",
|
|
140
|
+
LLV_RUNTIME_HOST_FENCE_WAIT_MS: "0",
|
|
141
|
+
};
|
|
142
|
+
const socketPath = cliRuntimeHostConfig(packageRoot, { env: environment, home: environment.HOME }).socketPath;
|
|
143
|
+
const fence = new RuntimeHostFence(`${socketPath}.lock`);
|
|
144
|
+
const listener = net.createServer((socket) => socket.end());
|
|
145
|
+
fs.mkdirSync(environment.HOME!, { recursive: true });
|
|
146
|
+
fs.mkdirSync(environment.XDG_CONFIG_HOME!, { recursive: true });
|
|
147
|
+
fs.mkdirSync(environment.TMPDIR!, { recursive: true });
|
|
148
|
+
fence.acquire();
|
|
149
|
+
await new Promise<void>((resolve, reject) => {
|
|
150
|
+
listener.once("error", reject);
|
|
151
|
+
listener.listen(socketPath, resolve);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const child = Bun.spawn([
|
|
155
|
+
process.execPath,
|
|
156
|
+
"--bun",
|
|
157
|
+
path.join(import.meta.dir, "cli.mjs"),
|
|
158
|
+
"--no-open",
|
|
159
|
+
"--port",
|
|
160
|
+
String(await availablePort()),
|
|
161
|
+
], { cwd: packageRoot, env: environment, stdout: "pipe", stderr: "pipe" });
|
|
162
|
+
try {
|
|
163
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
164
|
+
expect(exitCode).toBe(1);
|
|
165
|
+
expect(stderr).toContain("Couldn't start the structured runtime host");
|
|
166
|
+
expect(stderr).toContain(`runtime host socket is owned by pid ${process.pid}`);
|
|
167
|
+
expect(stderr).toContain("stop the other agent-log-viewer instance");
|
|
168
|
+
} finally {
|
|
169
|
+
if (!child.killed) child.kill();
|
|
170
|
+
await new Promise<void>((resolve, reject) => listener.close((error) => error ? reject(error) : resolve()));
|
|
171
|
+
fence.release();
|
|
172
|
+
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}, 10_000);
|
|
175
|
+
|
|
176
|
+
test("the CLI names a missing Bun prerequisite before starting the Viewer", async () => {
|
|
177
|
+
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "llv-runtime-missing-bun-"));
|
|
178
|
+
const nodeSearchPath = (process.env.PATH ?? "")
|
|
179
|
+
.split(path.delimiter)
|
|
180
|
+
.filter((directory) => !path.basename(directory).startsWith("bun-node-"))
|
|
181
|
+
.join(path.delimiter);
|
|
182
|
+
const nodeExecutable = Bun.which("node", { PATH: nodeSearchPath });
|
|
183
|
+
if (!nodeExecutable) throw new Error("the launcher prerequisite test requires Node");
|
|
184
|
+
const environment: Record<string, string | undefined> = {
|
|
185
|
+
...process.env,
|
|
186
|
+
PATH: nodeSearchPath,
|
|
187
|
+
HOME: path.join(sandbox, "home"),
|
|
188
|
+
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
|
189
|
+
LLV_STATE_DIR: path.join(sandbox, "state"),
|
|
190
|
+
TMPDIR: path.join(sandbox, "tmp"),
|
|
191
|
+
LLV_BUN_EXECUTABLE: path.join(sandbox, "missing-bun"),
|
|
192
|
+
};
|
|
193
|
+
delete environment.LLV_RUNTIME_HOST_SOCKET;
|
|
194
|
+
const child = Bun.spawn([
|
|
195
|
+
nodeExecutable,
|
|
196
|
+
path.join(import.meta.dir, "cli.mjs"),
|
|
197
|
+
"--no-open",
|
|
198
|
+
"--port",
|
|
199
|
+
String(await availablePort()),
|
|
200
|
+
], { cwd: path.resolve(import.meta.dir, ".."), env: environment, stdout: "pipe", stderr: "pipe" });
|
|
201
|
+
try {
|
|
202
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
203
|
+
expect(exitCode).toBe(1);
|
|
204
|
+
expect(stderr).toContain("Couldn't start the structured runtime host");
|
|
205
|
+
expect(stderr).toContain("Bun executable");
|
|
206
|
+
expect(stderr).toContain("is unavailable");
|
|
207
|
+
} finally {
|
|
208
|
+
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
}, 10_000);
|
|
211
|
+
|
|
22
212
|
test("structured hosts select Bun for a CLI process launched by Node", () => {
|
|
23
213
|
expect(viewerServerBunRuntime({
|
|
24
214
|
env: { LLV_STRUCTURED_HOSTS: "1" },
|