@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 +1 -1
- package/src/__tests__/wake.test.ts +120 -0
- package/src/commands/wake.ts +13 -1
package/package.json
CHANGED
|
@@ -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 = {
|
package/src/commands/wake.ts
CHANGED
|
@@ -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
|
-
//
|
|
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.
|