@vellumai/cli 0.6.6 → 0.7.0

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.
Files changed (45) hide show
  1. package/AGENTS.md +8 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/assistant-config.test.ts +1 -7
  4. package/src/__tests__/config-utils.test.ts +159 -0
  5. package/src/__tests__/env-drift.test.ts +10 -32
  6. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  7. package/src/__tests__/multi-local.test.ts +0 -5
  8. package/src/__tests__/sleep.test.ts +1 -2
  9. package/src/__tests__/teleport.test.ts +919 -1255
  10. package/src/commands/env.ts +93 -0
  11. package/src/commands/events.ts +2 -0
  12. package/src/commands/exec.ts +40 -8
  13. package/src/commands/hatch.ts +6 -2
  14. package/src/commands/login.ts +89 -6
  15. package/src/commands/ps.ts +104 -20
  16. package/src/commands/sleep.ts +5 -2
  17. package/src/commands/ssh.ts +15 -2
  18. package/src/commands/teleport.ts +447 -583
  19. package/src/commands/terminal.ts +9 -221
  20. package/src/commands/wake.ts +2 -1
  21. package/src/components/DefaultMainScreen.tsx +304 -152
  22. package/src/index.ts +3 -0
  23. package/src/lib/__tests__/docker.test.ts +50 -74
  24. package/src/lib/__tests__/job-polling.test.ts +278 -0
  25. package/src/lib/__tests__/local-runtime-client.test.ts +383 -0
  26. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  27. package/src/lib/assistant-config.ts +12 -8
  28. package/src/lib/client-identity.ts +67 -0
  29. package/src/lib/config-utils.ts +97 -1
  30. package/src/lib/docker.ts +73 -75
  31. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  32. package/src/lib/environments/resolve.ts +89 -7
  33. package/src/lib/environments/seeds.ts +8 -5
  34. package/src/lib/environments/types.ts +10 -0
  35. package/src/lib/hatch-local.ts +15 -120
  36. package/src/lib/health-check.ts +98 -0
  37. package/src/lib/job-polling.ts +195 -0
  38. package/src/lib/local-runtime-client.ts +178 -0
  39. package/src/lib/local.ts +139 -15
  40. package/src/lib/orphan-detection.ts +2 -35
  41. package/src/lib/platform-client.ts +215 -0
  42. package/src/lib/retire-local.ts +6 -2
  43. package/src/lib/terminal-session.ts +457 -0
  44. package/src/shared/provider-env-vars.ts +2 -3
  45. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Shared terminal session primitives for managed (cloud-hosted) assistants.
3
+ *
4
+ * Extracted from commands/terminal.ts so that ssh.ts, exec.ts, and
5
+ * terminal.ts can all use the same interactive session and assistant
6
+ * resolver without cross-importing commands (per cli/CONTRIBUTING.md).
7
+ */
8
+
9
+ import {
10
+ findAssistantByName,
11
+ loadLatestAssistant,
12
+ resolveCloud,
13
+ } from "./assistant-config.js";
14
+ import { getPlatformUrl, readPlatformToken } from "./platform-client.js";
15
+ import {
16
+ closeTerminalSession,
17
+ createTerminalSession,
18
+ resizeTerminalSession,
19
+ sendTerminalInput,
20
+ subscribeTerminalEvents,
21
+ } from "./terminal-client.js";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface ResolvedManagedAssistant {
28
+ assistantId: string;
29
+ token: string;
30
+ platformUrl: string;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Resolver
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Resolve a managed (cloud-hosted) assistant from the lockfile. Exits with
39
+ * an error if the assistant is not found, not managed, or the user isn't
40
+ * logged in.
41
+ */
42
+ export function resolveManagedAssistant(
43
+ nameArg?: string,
44
+ ): ResolvedManagedAssistant {
45
+ const entry = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
46
+
47
+ if (!entry) {
48
+ if (nameArg) {
49
+ console.error(`No assistant instance found with name '${nameArg}'.`);
50
+ } else {
51
+ console.error("No assistant instance found. Run `vellum hatch` first.");
52
+ }
53
+ process.exit(1);
54
+ }
55
+
56
+ const cloud = resolveCloud(entry);
57
+ if (cloud !== "vellum") {
58
+ if (cloud === "local") {
59
+ console.error(
60
+ "This assistant runs locally on your machine. You can access it directly.",
61
+ );
62
+ } else if (cloud === "docker") {
63
+ console.error(
64
+ `Use 'vellum exec -it -- /bin/bash' or 'vellum ssh' for ${cloud} instances.`,
65
+ );
66
+ } else {
67
+ console.error(
68
+ `'vellum terminal' is for managed (cloud-hosted) assistants. This assistant uses '${cloud}'.`,
69
+ );
70
+ }
71
+ process.exit(1);
72
+ }
73
+
74
+ const token = readPlatformToken();
75
+ if (!token) {
76
+ console.error(
77
+ "Not logged in. Run `vellum login` first to authenticate with the platform.",
78
+ );
79
+ process.exit(1);
80
+ }
81
+
82
+ return {
83
+ assistantId: entry.assistantId,
84
+ token,
85
+ platformUrl: getPlatformUrl(),
86
+ };
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Interactive session
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Open an interactive raw-tty terminal session to a managed assistant.
95
+ * Bridges local stdin/stdout to the platform terminal session API.
96
+ */
97
+ export async function interactiveSession(
98
+ assistant: ResolvedManagedAssistant,
99
+ initialCommand?: string,
100
+ ): Promise<void> {
101
+ const cols = process.stdout.columns || 80;
102
+ const rows = process.stdout.rows || 24;
103
+
104
+ console.error(`\x1b[2m🔗 Connecting to ${assistant.assistantId}...\x1b[0m`);
105
+
106
+ const { session_id: sessionId } = await createTerminalSession(
107
+ assistant.token,
108
+ assistant.assistantId,
109
+ cols,
110
+ rows,
111
+ assistant.platformUrl,
112
+ );
113
+
114
+ // --- TTY raw mode setup ---
115
+ const wasRaw = process.stdin.isRaw;
116
+ if (process.stdin.isTTY) {
117
+ process.stdin.setRawMode(true);
118
+ }
119
+ process.stdin.resume();
120
+ process.stdin.setEncoding("utf-8");
121
+
122
+ // Abort controller for the SSE stream
123
+ const abortController = new AbortController();
124
+ let exiting = false;
125
+
126
+ // --- Cleanup function (idempotent) ---
127
+ async function cleanup(): Promise<void> {
128
+ if (exiting) return;
129
+ exiting = true;
130
+
131
+ // Restore tty
132
+ if (process.stdin.isTTY) {
133
+ process.stdin.setRawMode(wasRaw ?? false);
134
+ }
135
+ process.stdin.pause();
136
+
137
+ // Abort SSE stream
138
+ abortController.abort();
139
+
140
+ // Close remote session (best-effort)
141
+ try {
142
+ await closeTerminalSession(
143
+ assistant.token,
144
+ assistant.assistantId,
145
+ sessionId,
146
+ assistant.platformUrl,
147
+ );
148
+ } catch {
149
+ // Best-effort cleanup
150
+ }
151
+ }
152
+
153
+ // --- Signal handlers ---
154
+ const onSigInt = () => {
155
+ cleanup().then(() => process.exit(0));
156
+ };
157
+ const onSigTerm = () => {
158
+ cleanup().then(() => process.exit(0));
159
+ };
160
+ process.on("SIGINT", onSigInt);
161
+ process.on("SIGTERM", onSigTerm);
162
+
163
+ // --- SIGWINCH (terminal resize) ---
164
+ const onResize = () => {
165
+ const newCols = process.stdout.columns || 80;
166
+ const newRows = process.stdout.rows || 24;
167
+ resizeTerminalSession(
168
+ assistant.token,
169
+ assistant.assistantId,
170
+ sessionId,
171
+ newCols,
172
+ newRows,
173
+ assistant.platformUrl,
174
+ ).catch(() => {
175
+ // Resize failures are non-fatal
176
+ });
177
+ };
178
+ process.stdout.on("resize", onResize);
179
+
180
+ // --- Input: stdin → remote ---
181
+ let inputBuffer = "";
182
+ let inputTimer: ReturnType<typeof setTimeout> | null = null;
183
+ const INPUT_DEBOUNCE_MS = 30;
184
+
185
+ function flushInput(): void {
186
+ if (inputBuffer.length === 0) return;
187
+ const data = inputBuffer;
188
+ inputBuffer = "";
189
+ sendTerminalInput(
190
+ assistant.token,
191
+ assistant.assistantId,
192
+ sessionId,
193
+ data,
194
+ assistant.platformUrl,
195
+ ).catch((err) => {
196
+ if (!exiting) {
197
+ console.error(`\r\nInput error: ${err.message}\r\n`);
198
+ }
199
+ });
200
+ }
201
+
202
+ process.stdin.on("data", (chunk: string) => {
203
+ if (exiting) return;
204
+ inputBuffer += chunk;
205
+ if (inputTimer) clearTimeout(inputTimer);
206
+ inputTimer = setTimeout(flushInput, INPUT_DEBOUNCE_MS);
207
+ });
208
+
209
+ // --- Send initial command (for `attach` subcommand) ---
210
+ if (initialCommand) {
211
+ // Brief delay to let the shell initialize
212
+ await new Promise((resolve) => setTimeout(resolve, 300));
213
+ await sendTerminalInput(
214
+ assistant.token,
215
+ assistant.assistantId,
216
+ sessionId,
217
+ initialCommand + "\r",
218
+ assistant.platformUrl,
219
+ );
220
+ }
221
+
222
+ // --- Output: remote SSE → stdout ---
223
+ try {
224
+ for await (const event of subscribeTerminalEvents(
225
+ assistant.token,
226
+ assistant.assistantId,
227
+ sessionId,
228
+ assistant.platformUrl,
229
+ abortController.signal,
230
+ )) {
231
+ if (exiting) break;
232
+ // Decode base64 output and write raw bytes to stdout
233
+ const bytes = Buffer.from(event.data, "base64");
234
+ process.stdout.write(bytes);
235
+ }
236
+ } catch (err) {
237
+ if (!exiting) {
238
+ const msg = err instanceof Error ? err.message : String(err);
239
+ // AbortError is expected on cleanup
240
+ if (!msg.includes("abort")) {
241
+ console.error(`\r\nConnection lost: ${msg}\r\n`);
242
+ }
243
+ }
244
+ } finally {
245
+ await cleanup();
246
+
247
+ // Remove listeners
248
+ process.off("SIGINT", onSigInt);
249
+ process.off("SIGTERM", onSigTerm);
250
+ process.stdout.off("resize", onResize);
251
+ }
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Shell escape helper
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Shell-escape an array of command arguments for safe transmission to a
260
+ * remote shell. Each arg is wrapped in single quotes with internal single
261
+ * quotes escaped.
262
+ */
263
+ export function shellEscapeArgs(args: string[]): string {
264
+ return args
265
+ .map((c) => c.replace(/'/g, "'\\''"))
266
+ .map((c) => `'${c}'`)
267
+ .join(" ");
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Non-interactive exec
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Run a command non-interactively in a managed assistant container. Creates
276
+ * an ephemeral terminal session, sends the command wrapped in sentinels for
277
+ * reliable output extraction, captures the result, and exits with the
278
+ * remote command's exit code.
279
+ */
280
+ export interface NonInteractiveExecOptions {
281
+ verbose?: boolean;
282
+ }
283
+
284
+ export async function nonInteractiveExec(
285
+ assistant: ResolvedManagedAssistant,
286
+ command: string[],
287
+ options?: NonInteractiveExecOptions,
288
+ ): Promise<void> {
289
+ const verbose = options?.verbose ?? false;
290
+ const dbg = verbose
291
+ ? (msg: string) => console.error(`\x1b[2m[exec] ${msg}\x1b[0m`)
292
+ : (_msg: string) => {};
293
+
294
+ dbg(`creating terminal session (cols=120, rows=24)`);
295
+
296
+ const { session_id: sessionId } = await createTerminalSession(
297
+ assistant.token,
298
+ assistant.assistantId,
299
+ 120,
300
+ 24,
301
+ assistant.platformUrl,
302
+ );
303
+
304
+ dbg(`session created: ${sessionId}`);
305
+
306
+ const abortController = new AbortController();
307
+ const output: Buffer[] = [];
308
+ let commandSent = false;
309
+ let eventCount = 0;
310
+
311
+ // Unique sentinels to delimit command output
312
+ const startSentinel = `__VELLUM_EXEC_START_${Date.now()}__`;
313
+ const endSentinel = `__VELLUM_EXEC_END_${Date.now()}__`;
314
+ const exitCodeSentinel = `__VELLUM_EXIT_`;
315
+
316
+ dbg(`sentinels: start=${startSentinel} end=${endSentinel}`);
317
+
318
+ const timeout = setTimeout(() => {
319
+ dbg(`30s timeout reached — aborting`);
320
+ abortController.abort();
321
+ }, 30_000);
322
+
323
+ try {
324
+ for await (const event of subscribeTerminalEvents(
325
+ assistant.token,
326
+ assistant.assistantId,
327
+ sessionId,
328
+ assistant.platformUrl,
329
+ abortController.signal,
330
+ )) {
331
+ eventCount++;
332
+ const bytes = Buffer.from(event.data, "base64");
333
+ output.push(bytes);
334
+
335
+ if (verbose) {
336
+ const text = bytes.toString("utf-8");
337
+ dbg(`SSE event #${eventCount} (seq=${event.seq}, ${bytes.length}B): ${JSON.stringify(text)}`);
338
+ }
339
+
340
+ // Wait for shell prompt before sending command
341
+ if (!commandSent) {
342
+ const joined = Buffer.concat(output).toString("utf-8");
343
+ if (
344
+ joined.includes("$") ||
345
+ joined.includes("#") ||
346
+ joined.includes("%")
347
+ ) {
348
+ commandSent = true;
349
+ const shellCmd = shellEscapeArgs(command);
350
+ const fullCmd = `echo '${startSentinel}'; ${shellCmd}; __ec=$?; echo '${endSentinel}'; echo '${exitCodeSentinel}'$__ec; exit $__ec\r`;
351
+ dbg(`prompt detected — sending command`);
352
+ if (verbose) {
353
+ dbg(`full command: ${JSON.stringify(fullCmd)}`);
354
+ }
355
+ // Wrap command: print start sentinel, run command, capture exit
356
+ // code, print end sentinel with exit code, then exit the shell
357
+ await sendTerminalInput(
358
+ assistant.token,
359
+ assistant.assistantId,
360
+ sessionId,
361
+ fullCmd,
362
+ assistant.platformUrl,
363
+ );
364
+ }
365
+ }
366
+
367
+ // Check for end sentinel in accumulated output
368
+ if (commandSent) {
369
+ const accumulated = Buffer.concat(output).toString("utf-8");
370
+ if (accumulated.includes(exitCodeSentinel)) {
371
+ dbg(`exit code sentinel detected — waiting 500ms for final output`);
372
+ // Give a moment for final output to arrive
373
+ setTimeout(() => abortController.abort(), 500);
374
+ }
375
+ }
376
+ }
377
+ } catch {
378
+ // Expected: abort on timeout or sentinel detection
379
+ } finally {
380
+ clearTimeout(timeout);
381
+ dbg(`stream ended after ${eventCount} events — closing session`);
382
+ await closeTerminalSession(
383
+ assistant.token,
384
+ assistant.assistantId,
385
+ sessionId,
386
+ assistant.platformUrl,
387
+ ).catch(() => {});
388
+ }
389
+
390
+ // Parse output between sentinels
391
+ const raw = Buffer.concat(output).toString("utf-8");
392
+
393
+ if (verbose) {
394
+ dbg(`--- raw output (${raw.length} chars) ---`);
395
+ console.error(raw);
396
+ dbg(`--- end raw output ---`);
397
+ }
398
+
399
+ // Strip ANSI escapes
400
+ const clean = raw.replace(
401
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
402
+ /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][^\n]|\r/g,
403
+ "",
404
+ );
405
+
406
+ if (verbose) {
407
+ dbg(`--- cleaned output (${clean.length} chars) ---`);
408
+ console.error(clean);
409
+ dbg(`--- end cleaned output ---`);
410
+ }
411
+
412
+ const lines = clean.split("\n");
413
+
414
+ // Find output between sentinels. Search backwards because each sentinel
415
+ // string appears twice: once in the shell command echo and once in the
416
+ // actual output. We want the last occurrence (the output line).
417
+ let startIdx = -1;
418
+ let endIdx = -1;
419
+ for (let i = lines.length - 1; i >= 0; i--) {
420
+ if (endIdx < 0 && lines[i].includes(endSentinel)) {
421
+ endIdx = i;
422
+ }
423
+ if (startIdx < 0 && lines[i].includes(startSentinel)) {
424
+ startIdx = i;
425
+ }
426
+ }
427
+
428
+ dbg(`sentinel indices: startLine=${startIdx} endLine=${endIdx} (of ${lines.length} lines)`);
429
+
430
+ const start = startIdx >= 0 ? startIdx + 1 : 0;
431
+ const end = endIdx >= 0 ? endIdx : lines.length;
432
+ const result = lines.slice(start, end).join("\n").trim();
433
+
434
+ dbg(`extracted result: ${result.length} chars`);
435
+
436
+ if (result) {
437
+ process.stdout.write(result + "\n");
438
+ } else {
439
+ dbg(`no output extracted between sentinels`);
440
+ }
441
+
442
+ // Extract exit code from sentinel (also search backwards)
443
+ let exitCode = 0;
444
+ for (let i = lines.length - 1; i >= 0; i--) {
445
+ if (lines[i].includes(exitCodeSentinel)) {
446
+ const match = lines[i].match(/__VELLUM_EXIT_(\d+)/);
447
+ if (match) {
448
+ exitCode = parseInt(match[1], 10);
449
+ }
450
+ break;
451
+ }
452
+ }
453
+
454
+ dbg(`exit code: ${exitCode}`);
455
+
456
+ process.exit(exitCode);
457
+ }
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * Two sources are merged into a single combined map:
5
5
  *
6
- * 1. Search-provider env vars — sourced from `meta/provider-env-vars.json`
7
- * (single source of truth, also bundled into the macOS client).
6
+ * 1. Search-provider env vars — hardcoded below (Brave, Perplexity).
8
7
  * 2. LLM-provider env vars — sourced from `PROVIDER_CATALOG` in
9
8
  * `assistant/src/providers/model-catalog.ts` via a locally-maintained
10
9
  * mirror (the CLI does not import from `assistant/src/`; drift is caught
@@ -26,7 +25,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
26
25
  openrouter: "OPENROUTER_API_KEY",
27
26
  };
28
27
 
29
- /** Search-provider env var names. Mirrors `meta/provider-env-vars.json`. */
28
+ /** Search-provider env var names. */
30
29
  export const SEARCH_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
31
30
  brave: "BRAVE_API_KEY",
32
31
  perplexity: "PERPLEXITY_API_KEY",
@@ -1,214 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
-
6
- // Redirect lockfile reads/writes to a scratch directory so the test never
7
- // touches the real `~/.vellum.lock.json`.
8
- const testDir = mkdtempSync(join(tmpdir(), "cli-orphan-detection-test-"));
9
- process.env.VELLUM_LOCKFILE_DIR = testDir;
10
-
11
- // Mock homedir() so `join(homedir(), ".vellum")` inside orphan-detection
12
- // resolves under the scratch directory — the legacy fallback scan is part
13
- // of the behavior under test and we need to keep it off the real filesystem.
14
- const realOs = await import("node:os");
15
- const fakeHome = mkdtempSync(join(tmpdir(), "cli-orphan-detection-home-"));
16
- mock.module("node:os", () => ({
17
- ...realOs,
18
- homedir: () => fakeHome,
19
- }));
20
- mock.module("os", () => ({
21
- ...realOs,
22
- homedir: () => fakeHome,
23
- }));
24
-
25
- // Stub execOutput so the process-table scan never shells out to `ps`.
26
- // The PID-file scan is the surface we want to exercise here.
27
- mock.module("../lib/step-runner", () => ({
28
- execOutput: () => Promise.resolve(""),
29
- exec: () => Promise.resolve(undefined),
30
- }));
31
-
32
- // Every detected PID claims to be a live process so the scan surfaces it.
33
- const originalKill = process.kill;
34
- process.kill = ((pid: number, signal?: string | number) => {
35
- if (signal === 0) return true;
36
- return originalKill.call(process, pid, signal);
37
- }) as typeof process.kill;
38
-
39
- import { detectOrphanedProcesses } from "../lib/orphan-detection.js";
40
- import {
41
- saveAssistantEntry,
42
- type AssistantEntry,
43
- type LocalInstanceResources,
44
- } from "../lib/assistant-config.js";
45
- import {
46
- DEFAULT_CES_PORT,
47
- DEFAULT_DAEMON_PORT,
48
- DEFAULT_GATEWAY_PORT,
49
- DEFAULT_QDRANT_PORT,
50
- } from "../lib/constants.js";
51
-
52
- afterAll(() => {
53
- process.kill = originalKill;
54
- rmSync(testDir, { recursive: true, force: true });
55
- rmSync(fakeHome, { recursive: true, force: true });
56
- delete process.env.VELLUM_LOCKFILE_DIR;
57
- });
58
-
59
- function resetLockfile(): void {
60
- for (const name of [".vellum.lock.json", ".vellum.lockfile.json"]) {
61
- try {
62
- rmSync(join(testDir, name));
63
- } catch {
64
- // file may not exist
65
- }
66
- }
67
- }
68
-
69
- function resetFakeHome(): void {
70
- rmSync(fakeHome, { recursive: true, force: true });
71
- mkdirSync(fakeHome, { recursive: true });
72
- }
73
-
74
- function makeResources(
75
- instanceDir: string,
76
- ports: Partial<LocalInstanceResources> = {},
77
- ): LocalInstanceResources {
78
- return {
79
- instanceDir,
80
- daemonPort: ports.daemonPort ?? DEFAULT_DAEMON_PORT,
81
- gatewayPort: ports.gatewayPort ?? DEFAULT_GATEWAY_PORT,
82
- qdrantPort: ports.qdrantPort ?? DEFAULT_QDRANT_PORT,
83
- cesPort: ports.cesPort ?? DEFAULT_CES_PORT,
84
- pidFile: join(instanceDir, ".vellum", "vellum.pid"),
85
- };
86
- }
87
-
88
- function makeEntry(
89
- id: string,
90
- instanceDir: string,
91
- extra?: Partial<AssistantEntry>,
92
- ): AssistantEntry {
93
- return {
94
- assistantId: id,
95
- runtimeUrl: `http://localhost:${DEFAULT_GATEWAY_PORT}`,
96
- cloud: "local",
97
- resources: makeResources(instanceDir),
98
- ...extra,
99
- };
100
- }
101
-
102
- function writePidFile(
103
- instanceDir: string,
104
- name: "vellum" | "gateway" | "qdrant",
105
- pid: number,
106
- ): void {
107
- const dir = join(instanceDir, ".vellum");
108
- mkdirSync(dir, { recursive: true });
109
- writeFileSync(join(dir, `${name}.pid`), String(pid));
110
- }
111
-
112
- describe("detectOrphanedProcesses", () => {
113
- beforeEach(() => {
114
- resetLockfile();
115
- resetFakeHome();
116
- });
117
-
118
- test("scans every local entry's instanceDir/.vellum and reports each PID", async () => {
119
- // GIVEN two local entries in the lockfile, each pointing at its own
120
- // instance directory with a stale PID file
121
- const instanceA = mkdtempSync(join(tmpdir(), "orphan-instance-a-"));
122
- const instanceB = mkdtempSync(join(tmpdir(), "orphan-instance-b-"));
123
- try {
124
- saveAssistantEntry(makeEntry("alpha", instanceA));
125
- saveAssistantEntry(
126
- makeEntry("beta", instanceB, {
127
- runtimeUrl: "http://localhost:8821",
128
- }),
129
- );
130
- writePidFile(instanceA, "vellum", 111111);
131
- writePidFile(instanceB, "gateway", 222222);
132
-
133
- // WHEN we run orphan detection
134
- const orphans = await detectOrphanedProcesses();
135
-
136
- // THEN both containers are scanned and each PID is surfaced
137
- const pidFileOrphans = orphans.filter((o) => o.source === "pid file");
138
- const pids = pidFileOrphans.map((o) => o.pid);
139
- expect(pids).toContain("111111");
140
- expect(pids).toContain("222222");
141
-
142
- const byName = new Map(pidFileOrphans.map((o) => [o.pid, o.name]));
143
- expect(byName.get("111111")).toBe("assistant");
144
- expect(byName.get("222222")).toBe("gateway");
145
- } finally {
146
- rmSync(instanceA, { recursive: true, force: true });
147
- rmSync(instanceB, { recursive: true, force: true });
148
- }
149
- });
150
-
151
- test("still scans legacy ~/.vellum/ when no lockfile entry covers it", async () => {
152
- // GIVEN no local entries in the lockfile but a stale PID file in the
153
- // legacy `~/.vellum/` root (pre-upgrade install)
154
- resetLockfile();
155
- writePidFile(fakeHome, "vellum", 333333);
156
-
157
- // WHEN we run orphan detection
158
- const orphans = await detectOrphanedProcesses();
159
-
160
- // THEN the legacy root is still scanned and the PID surfaces
161
- const pids = orphans
162
- .filter((o) => o.source === "pid file")
163
- .map((o) => o.pid);
164
- expect(pids).toContain("333333");
165
- });
166
-
167
- test("legacy ~/.vellum/ is scanned alongside multi-instance entries", async () => {
168
- // GIVEN a local entry AND a legacy `~/.vellum/` PID file at the same time
169
- const instanceA = mkdtempSync(join(tmpdir(), "orphan-instance-coexist-"));
170
- try {
171
- saveAssistantEntry(makeEntry("alpha", instanceA));
172
- writePidFile(instanceA, "vellum", 444444);
173
- writePidFile(fakeHome, "gateway", 555555);
174
-
175
- // WHEN we run orphan detection
176
- const orphans = await detectOrphanedProcesses();
177
-
178
- // THEN both the entry's instance dir and the legacy root are scanned
179
- const pids = orphans
180
- .filter((o) => o.source === "pid file")
181
- .map((o) => o.pid);
182
- expect(pids).toContain("444444");
183
- expect(pids).toContain("555555");
184
- } finally {
185
- rmSync(instanceA, { recursive: true, force: true });
186
- }
187
- });
188
-
189
- test("ignores remote entries — only local entries with resources are scanned", async () => {
190
- // GIVEN a remote entry (no resources) alongside a local entry
191
- const instanceA = mkdtempSync(join(tmpdir(), "orphan-instance-local-"));
192
- try {
193
- saveAssistantEntry({
194
- assistantId: "cloud-box",
195
- runtimeUrl: "http://10.0.0.1:7821",
196
- cloud: "gcp",
197
- });
198
- saveAssistantEntry(makeEntry("alpha", instanceA));
199
- writePidFile(instanceA, "qdrant", 666666);
200
-
201
- // WHEN we run orphan detection
202
- const orphans = await detectOrphanedProcesses();
203
-
204
- // THEN the local entry's PID still surfaces (the remote entry is
205
- // silently skipped)
206
- const pids = orphans
207
- .filter((o) => o.source === "pid file")
208
- .map((o) => o.pid);
209
- expect(pids).toContain("666666");
210
- } finally {
211
- rmSync(instanceA, { recursive: true, force: true });
212
- }
213
- });
214
- });