@vellumai/cli 0.11.4-dev.202608201710.fcf0423 → 0.11.4-dev.202608201811.0c1594d

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.11.4-dev.202608201710.fcf0423",
3
+ "version": "0.11.4-dev.202608201811.0c1594d",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import * as assistantConfig from "../lib/assistant-config.js";
16
16
  import * as docker from "../lib/docker.js";
17
17
  import * as guardianToken from "../lib/guardian-token.js";
18
+ import * as httpClient from "../lib/http-client.js";
18
19
  import * as ingressConfig from "../lib/ingress-config.js";
19
20
  import * as local from "../lib/local.js";
20
21
  import * as nginxIngress from "../lib/nginx-ingress.js";
@@ -167,12 +168,28 @@ mock.module("../lib/nginx-ingress.js", () => ({
167
168
  readIngressState: readIngressStateMock,
168
169
  }));
169
170
 
171
+ const realHttpClient = { ...httpClient };
172
+
173
+ const probeDaemonReadinessWithRetryMock = mock<
174
+ typeof httpClient.probeDaemonReadinessWithRetry
175
+ >(async () => "ready");
176
+ const waitForDaemonMigrationsReadyMock = mock<
177
+ typeof httpClient.waitForDaemonMigrationsReady
178
+ >(async () => "ready");
179
+
180
+ mock.module("../lib/http-client.js", () => ({
181
+ ...realHttpClient,
182
+ probeDaemonReadinessWithRetry: probeDaemonReadinessWithRetryMock,
183
+ waitForDaemonMigrationsReady: waitForDaemonMigrationsReadyMock,
184
+ }));
185
+
170
186
  const { wake } = await import("../commands/wake.js");
171
187
 
172
188
  let tempDir: string;
173
189
  let originalArgv: string[];
174
190
  let logSpy: ReturnType<typeof spyOn>;
175
191
  let warnSpy: ReturnType<typeof spyOn>;
192
+ let errorSpy: ReturnType<typeof spyOn>;
176
193
  let localEntry: AssistantEntry;
177
194
 
178
195
  function makeLocalEntry(): AssistantEntry {
@@ -199,6 +216,7 @@ beforeEach(() => {
199
216
  process.argv = ["bun", "vellum", "wake", "--watch", "local-assistant"];
200
217
  logSpy = spyOn(console, "log").mockImplementation(() => {});
201
218
  warnSpy = spyOn(console, "warn").mockImplementation(() => {});
219
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
202
220
 
203
221
  localEntry = makeLocalEntry();
204
222
  resolveTargetAssistantMock.mockReset();
@@ -231,6 +249,10 @@ beforeEach(() => {
231
249
  startCesMock.mockResolvedValue(undefined);
232
250
  isProcessAliveMock.mockReset();
233
251
  isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
252
+ probeDaemonReadinessWithRetryMock.mockReset();
253
+ probeDaemonReadinessWithRetryMock.mockResolvedValue("ready");
254
+ waitForDaemonMigrationsReadyMock.mockReset();
255
+ waitForDaemonMigrationsReadyMock.mockResolvedValue("ready");
234
256
  seedGuardianTokenFromSiblingEnvMock.mockReset();
235
257
  seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
236
258
  loadGuardianTokenMock.mockReset();
@@ -263,6 +285,7 @@ afterEach(() => {
263
285
  process.argv = originalArgv;
264
286
  logSpy.mockRestore();
265
287
  warnSpy.mockRestore();
288
+ errorSpy.mockRestore();
266
289
  if (tempDir) {
267
290
  rmSync(tempDir, { recursive: true, force: true });
268
291
  }
@@ -277,6 +300,7 @@ afterAll(() => {
277
300
  mock.module("../lib/ngrok", () => realNgrok);
278
301
  mock.module("../lib/ingress-config.js", () => realIngressConfig);
279
302
  mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
303
+ mock.module("../lib/http-client.js", () => realHttpClient);
280
304
  });
281
305
 
282
306
  describe("vellum wake", () => {
@@ -456,6 +480,102 @@ describe("vellum wake", () => {
456
480
  });
457
481
  });
458
482
 
483
+ describe("vellum wake: daemon startup failure", () => {
484
+ const daemonPidOf = (dir: string) => join(dir, ".vellum", "daemon.pid");
485
+
486
+ beforeEach(() => {
487
+ process.argv = ["bun", "vellum", "wake", "local-assistant"];
488
+ // The daemon needs starting; the gateway is already serving.
489
+ resolveProcessStateMock.mockImplementation(
490
+ async (_pidFile, _port, label) =>
491
+ label === "Gateway"
492
+ ? { status: "healthy", pid: 456 }
493
+ : { status: "needs_start", pid: null },
494
+ );
495
+ });
496
+
497
+ test("fails when the freshly spawned daemon is not running", async () => {
498
+ /**
499
+ * Tests that a daemon which aborts during startup (an occupied runtime
500
+ * HTTP port) fails the command instead of reporting a successful wake.
501
+ */
502
+
503
+ // GIVEN the spawned daemon left no live process behind
504
+ isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
505
+
506
+ // AND a foreign listener on the runtime HTTP port answers readiness in
507
+ // the dead daemon's place
508
+ probeDaemonReadinessWithRetryMock.mockResolvedValue("ready");
509
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
510
+ code?: number,
511
+ ) => {
512
+ throw new Error(`process.exit:${code}`);
513
+ }) as never);
514
+
515
+ // WHEN wake runs
516
+ const result = wake();
517
+
518
+ // THEN it exits nonzero, names the port on stderr, and never claims
519
+ // the wake completed
520
+ await expect(result).rejects.toThrow("process.exit:1");
521
+ expect(errorSpy).toHaveBeenCalledWith(
522
+ expect.stringContaining("exited during startup"),
523
+ );
524
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("7821"));
525
+ expect(logSpy).not.toHaveBeenCalledWith("Wake complete.");
526
+ expect(startGatewayMock).not.toHaveBeenCalled();
527
+ exitSpy.mockRestore();
528
+ });
529
+
530
+ test("completes when the freshly spawned daemon is running", async () => {
531
+ /**
532
+ * Tests that a healthy fresh spawn still reports a completed wake.
533
+ */
534
+
535
+ // GIVEN the spawned daemon is alive and ready
536
+ isProcessAliveMock.mockImplementation((pidFile) =>
537
+ pidFile === daemonPidOf(tempDir)
538
+ ? { alive: true, pid: 123 }
539
+ : { alive: false, pid: null },
540
+ );
541
+ probeDaemonReadinessWithRetryMock.mockResolvedValue("ready");
542
+
543
+ // WHEN wake runs
544
+ await wake();
545
+
546
+ // THEN it reports success and starts the gateway
547
+ expect(logSpy).toHaveBeenCalledWith("Wake complete.");
548
+ expect(errorSpy).not.toHaveBeenCalled();
549
+ expect(startLocalDaemonMock).toHaveBeenCalledTimes(1);
550
+ });
551
+
552
+ test("completes when the freshly spawned daemon is still migrating", async () => {
553
+ /**
554
+ * Tests that a live daemon running database migrations is reported as a
555
+ * degraded success, not a failure.
556
+ */
557
+
558
+ // GIVEN the spawned daemon is alive but still migrating past the
559
+ // gateway-coordination wait
560
+ isProcessAliveMock.mockImplementation((pidFile) =>
561
+ pidFile === daemonPidOf(tempDir)
562
+ ? { alive: true, pid: 123 }
563
+ : { alive: false, pid: null },
564
+ );
565
+ probeDaemonReadinessWithRetryMock.mockResolvedValue("migrating");
566
+ waitForDaemonMigrationsReadyMock.mockResolvedValue("migrating");
567
+
568
+ // WHEN wake runs
569
+ await wake();
570
+
571
+ // THEN it reports the migration state and completes
572
+ expect(logSpy).toHaveBeenCalledWith(
573
+ "Assistant is still running database migrations; DB-backed routes return 503 until they finish.",
574
+ );
575
+ expect(logSpy).toHaveBeenCalledWith("Wake complete.");
576
+ });
577
+ });
578
+
459
579
  describe("vellum wake — tunnel edge restore", () => {
460
580
  const webhookConfig = { telegram: { botUsername: "bot" } };
461
581
  const enabledConfig = {
@@ -229,7 +229,19 @@ export async function wake(): Promise<void> {
229
229
  startCes(watch, resources),
230
230
  startLocalDaemon(watch, resources, { foreground, signingKey }),
231
231
  ]);
232
- // startLocalDaemon's post-spawn wait is bounded (60s) — a longer
232
+ // A daemon that aborts during startup (an occupied runtime HTTP port, a
233
+ // fatal subsystem failure) leaves no process behind, and readiness alone
234
+ // cannot see that: whatever foreign listener holds the port answers the
235
+ // probe in its place. Liveness is the authoritative signal, so check it
236
+ // first and fail the command instead of reporting a successful wake for
237
+ // an assistant that is not running.
238
+ if (!isProcessAlive(pidFile).alive) {
239
+ console.error(
240
+ `Error: the assistant exited during startup and is not running. Its runtime HTTP port ${resources.daemonPort} may be held by another process (check \`lsof -i :${resources.daemonPort}\`); the daemon log records the startup error it reported.`,
241
+ );
242
+ process.exit(1);
243
+ }
244
+ // startLocalDaemon's post-spawn wait is bounded (60s), and a longer
233
245
  // migration outlives it. Classify the fresh spawn the same way the
234
246
  // attach path does, so the gateway-coordination wait below applies to
235
247
  // both paths and wake's closing summary stays honest.