@vellumai/cli 0.10.12 → 0.11.0-dev.202607290043.3b26b6b

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.
@@ -30,10 +30,53 @@ mock.module("../lib/process.js", () => ({
30
30
  stopProcessByPidFile: stopProcessByPidFileMock,
31
31
  }));
32
32
 
33
- // Restore the real module once this file finishes so the mock does not leak
33
+ // Mock the drain phase and the guardian-token loader so `--wait` tests can
34
+ // steer outcomes without a live daemon. Both spread the real module so
35
+ // untouched exports keep working (parseWaitDuration stays real).
36
+ import type { DrainOptions, DrainOutcome } from "../lib/drain.js";
37
+
38
+ const drainAssistantMock = mock(
39
+ async (_opts: DrainOptions): Promise<DrainOutcome> => "drained",
40
+ );
41
+ const realDrain = { ...(await import("../lib/drain.js")) };
42
+ mock.module("../lib/drain.js", () => ({
43
+ ...realDrain,
44
+ drainAssistant: drainAssistantMock,
45
+ }));
46
+
47
+ const realGuardianToken = { ...(await import("../lib/guardian-token.js")) };
48
+ const loadGuardianTokenMock = mock(
49
+ (_assistantId: string) =>
50
+ ({ accessToken: "test-guardian-token" }) as unknown as ReturnType<
51
+ typeof realGuardianToken.loadGuardianToken
52
+ >,
53
+ );
54
+ mock.module("../lib/guardian-token.js", () => ({
55
+ ...realGuardianToken,
56
+ loadGuardianToken: loadGuardianTokenMock,
57
+ }));
58
+
59
+ // Stub the token-refresh helper without importing the real client module
60
+ // (it drags in the interactive chat client's dependency graph). Pass-through
61
+ // by default: the stored token is returned as-is.
62
+ const resolveFreshBearerTokenMock = mock(
63
+ async (
64
+ _runtimeUrl: string,
65
+ _assistantId: string,
66
+ bearerToken: string | undefined,
67
+ _cloud: string | undefined,
68
+ ) => bearerToken,
69
+ );
70
+ mock.module("../commands/client.js", () => ({
71
+ resolveFreshBearerToken: resolveFreshBearerTokenMock,
72
+ }));
73
+
74
+ // Restore the real modules once this file finishes so the mocks do not leak
34
75
  // into other test files in the same `bun test` run.
35
76
  afterAll(() => {
36
77
  mock.module("../lib/process.js", () => realProcess);
78
+ mock.module("../lib/drain.js", () => realDrain);
79
+ mock.module("../lib/guardian-token.js", () => realGuardianToken);
37
80
  });
38
81
 
39
82
  import { sleep } from "../commands/sleep.js";
@@ -45,7 +88,7 @@ import {
45
88
 
46
89
  // Write a lockfile entry so the real resolveTargetAssistant() finds our test
47
90
  // assistant without needing to mock the entire assistant-config module.
48
- function writeLockfile(): void {
91
+ function writeLockfile(extraEntryFields: Record<string, unknown> = {}): void {
49
92
  writeFileSync(
50
93
  join(testDir, ".vellum.lock.json"),
51
94
  JSON.stringify(
@@ -61,6 +104,7 @@ function writeLockfile(): void {
61
104
  gatewayPort: DEFAULT_GATEWAY_PORT,
62
105
  qdrantPort: DEFAULT_QDRANT_PORT,
63
106
  },
107
+ ...extraEntryFields,
64
108
  },
65
109
  ],
66
110
  activeAssistant: "sleep-test",
@@ -99,6 +143,16 @@ describe("sleep command", () => {
99
143
  isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
100
144
  stopProcessByPidFileMock.mockReset();
101
145
  stopProcessByPidFileMock.mockResolvedValue(true);
146
+ drainAssistantMock.mockReset();
147
+ drainAssistantMock.mockResolvedValue("drained");
148
+ loadGuardianTokenMock.mockReset();
149
+ loadGuardianTokenMock.mockReturnValue({
150
+ accessToken: "test-guardian-token",
151
+ } as unknown as ReturnType<typeof realGuardianToken.loadGuardianToken>);
152
+ resolveFreshBearerTokenMock.mockReset();
153
+ resolveFreshBearerTokenMock.mockImplementation(
154
+ async (_url, _id, bearerToken, _cloud) => bearerToken,
155
+ );
102
156
  rmSync(assistantRootDir, { recursive: true, force: true });
103
157
  writeLockfile();
104
158
  });
@@ -187,4 +241,119 @@ describe("sleep command", () => {
187
241
  "credential-executor",
188
242
  );
189
243
  });
244
+
245
+ describe("--wait", () => {
246
+ test("runs the drain phase against localUrl before stopping", async () => {
247
+ writeLockfile({ localUrl: "http://127.0.0.1:9999" });
248
+ isProcessAliveMock.mockReturnValue({ alive: true, pid: 4321 });
249
+ process.argv = ["bun", "vellum", "sleep", "sleep-test", "--wait"];
250
+
251
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
252
+ try {
253
+ await sleep();
254
+ } finally {
255
+ consoleLog.mockRestore();
256
+ }
257
+
258
+ expect(drainAssistantMock).toHaveBeenCalledTimes(1);
259
+ const opts = drainAssistantMock.mock.calls[0]![0];
260
+ expect(opts.baseUrl).toBe("http://127.0.0.1:9999");
261
+ expect(opts.token).toBe("test-guardian-token");
262
+ // Bare --wait waits as long as it takes.
263
+ expect(opts.deadlineAt).toBeNull();
264
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
265
+ });
266
+
267
+ test("a duration bounds the wait with a deadline", async () => {
268
+ isProcessAliveMock.mockReturnValue({ alive: true, pid: 4321 });
269
+ process.argv = ["bun", "vellum", "sleep", "sleep-test", "--wait", "90s"];
270
+
271
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
272
+ const before = Date.now();
273
+ try {
274
+ await sleep();
275
+ } finally {
276
+ consoleLog.mockRestore();
277
+ }
278
+
279
+ const opts = drainAssistantMock.mock.calls[0]![0];
280
+ expect(opts.deadlineAt).toBeGreaterThanOrEqual(before + 89_000);
281
+ expect(opts.deadlineAt).toBeLessThanOrEqual(Date.now() + 91_000);
282
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
283
+ });
284
+
285
+ test("cancelled drain exits 130 without stopping anything", async () => {
286
+ isProcessAliveMock.mockReturnValue({ alive: true, pid: 4321 });
287
+ drainAssistantMock.mockResolvedValue("cancelled");
288
+ process.argv = ["bun", "vellum", "sleep", "sleep-test", "--wait"];
289
+
290
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
291
+ const exitMock = mock((code?: number) => {
292
+ throw new Error(`process.exit:${code}`);
293
+ });
294
+ const originalExit = process.exit;
295
+ process.exit = exitMock as unknown as typeof process.exit;
296
+ try {
297
+ await expect(sleep()).rejects.toThrow("process.exit:130");
298
+ } finally {
299
+ process.exit = originalExit;
300
+ consoleLog.mockRestore();
301
+ }
302
+
303
+ expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
304
+ });
305
+
306
+ test("skips the drain when the assistant is not running", async () => {
307
+ isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
308
+ process.argv = ["bun", "vellum", "sleep", "sleep-test", "--wait"];
309
+
310
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
311
+ try {
312
+ await sleep();
313
+ } finally {
314
+ consoleLog.mockRestore();
315
+ }
316
+
317
+ expect(drainAssistantMock).not.toHaveBeenCalled();
318
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
319
+ });
320
+
321
+ test("stops without waiting when no guardian token is available", async () => {
322
+ isProcessAliveMock.mockReturnValue({ alive: true, pid: 4321 });
323
+ loadGuardianTokenMock.mockReturnValue(null);
324
+ process.argv = ["bun", "vellum", "sleep", "sleep-test", "--wait"];
325
+
326
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
327
+ try {
328
+ await sleep();
329
+ } finally {
330
+ consoleLog.mockRestore();
331
+ }
332
+
333
+ expect(drainAssistantMock).not.toHaveBeenCalled();
334
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
335
+ });
336
+
337
+ test("rejects an invalid --wait duration", async () => {
338
+ process.argv = ["bun", "vellum", "sleep", "sleep-test", "--wait=abc"];
339
+
340
+ const consoleError = spyOn(console, "error").mockImplementation(() => {});
341
+ const exitMock = mock((code?: number) => {
342
+ throw new Error(`process.exit:${code}`);
343
+ });
344
+ const originalExit = process.exit;
345
+ process.exit = exitMock as unknown as typeof process.exit;
346
+ try {
347
+ await expect(sleep()).rejects.toThrow("process.exit:1");
348
+ expect(consoleError).toHaveBeenCalledWith(
349
+ expect.stringContaining("invalid --wait duration"),
350
+ );
351
+ } finally {
352
+ process.exit = originalExit;
353
+ consoleError.mockRestore();
354
+ }
355
+
356
+ expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
357
+ });
358
+ });
190
359
  });
@@ -179,7 +179,7 @@ mock.module("../lib/nginx-ingress.js", () => ({
179
179
  startRemoteWebIngress: startRemoteWebIngressMock,
180
180
  }));
181
181
 
182
- const { wake } = await import("../commands/wake.js");
182
+ const { wake, WEB_INGRESS_FLAG_RETRY } = await import("../commands/wake.js");
183
183
 
184
184
  let tempDir: string;
185
185
  let originalArgv: string[];
@@ -408,8 +408,18 @@ describe("vellum wake — web ingress restore", () => {
408
408
  ingress: { enabled: true, publicBaseUrl: "https://assistant.example.com" },
409
409
  };
410
410
 
411
+ const originalRetry = { ...WEB_INGRESS_FLAG_RETRY };
412
+
411
413
  beforeEach(() => {
412
414
  process.argv = ["bun", "vellum", "wake", "local-assistant"];
415
+ // Shrink the startup-window retry so failure-path tests stay fast.
416
+ WEB_INGRESS_FLAG_RETRY.attempts = 3;
417
+ WEB_INGRESS_FLAG_RETRY.intervalMs = 5;
418
+ });
419
+
420
+ afterEach(() => {
421
+ WEB_INGRESS_FLAG_RETRY.attempts = originalRetry.attempts;
422
+ WEB_INGRESS_FLAG_RETRY.intervalMs = originalRetry.intervalMs;
413
423
  });
414
424
 
415
425
  test("restores the edge when ingress is enabled, the flag is on, and it is not already running", async () => {
@@ -515,8 +525,35 @@ describe("vellum wake — web ingress restore", () => {
515
525
 
516
526
  await wake();
517
527
 
528
+ // The probe retries through the startup window before giving up.
529
+ expect(isAssistantFeatureFlagEnabledMock.mock.calls.length).toBe(3);
518
530
  expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
519
531
  expect(warnSpy).toHaveBeenCalled();
520
532
  expect(logSpy).toHaveBeenCalledWith("Wake complete.");
521
533
  });
534
+
535
+ test("a flag probe that fails while the gateway is starting retries and then restores", async () => {
536
+ loadRawConfigMock.mockReturnValue(enabledConfig);
537
+ isAssistantFeatureFlagEnabledMock
538
+ .mockRejectedValueOnce(
539
+ new Error(
540
+ 'Failed to fetch feature flags: HTTP 503 {"status":"starting"}',
541
+ ),
542
+ )
543
+ .mockRejectedValueOnce(
544
+ new Error(
545
+ 'Failed to fetch feature flags: HTTP 503 {"status":"starting"}',
546
+ ),
547
+ )
548
+ .mockResolvedValueOnce(true);
549
+
550
+ await wake();
551
+
552
+ expect(isAssistantFeatureFlagEnabledMock.mock.calls.length).toBe(3);
553
+ expect(logSpy).toHaveBeenCalledWith(
554
+ " Waiting for the gateway before restoring the web ingress edge...",
555
+ );
556
+ expect(startRemoteWebIngressMock).toHaveBeenCalled();
557
+ expect(logSpy).toHaveBeenCalledWith("Wake complete.");
558
+ });
522
559
  });
@@ -93,13 +93,13 @@ function printHelp(): void {
93
93
  " $ vellum flags # list flags for active assistant",
94
94
  );
95
95
  console.log(
96
- " $ vellum flags get voice-mode # inspect one flag",
96
+ " $ vellum flags get browser # inspect one flag",
97
97
  );
98
98
  console.log(
99
- " $ vellum flags set voice-mode true # enable a flag",
99
+ " $ vellum flags set browser true # enable a flag",
100
100
  );
101
101
  console.log(
102
- " $ vellum flags set voice-mode true --assistant eval-1 # target by name/id",
102
+ " $ vellum flags set browser true --assistant eval-1 # target by name/id",
103
103
  );
104
104
  }
105
105
 
@@ -390,7 +390,7 @@ export async function pair(): Promise<void> {
390
390
  `Re-run with a reachable URL, e.g.:\n vellum pair --url ${suggestion}`,
391
391
  );
392
392
  console.error(
393
- "Pairing a phone by QR instead? Use: vellum pair --qr " +
393
+ "Pairing another device by QR instead? Use: vellum pair --qr " +
394
394
  "(needs an https URL — run `vellum tunnel --provider tailscale` first).",
395
395
  );
396
396
  process.exit(1);
@@ -478,7 +478,7 @@ export async function pair(): Promise<void> {
478
478
  }'s website, not your assistant's address`,
479
479
  };
480
480
  console.error(
481
- "Error: --qr needs a public https URL the phone can open — " +
481
+ "Error: --qr needs a public https URL the scanning device can open — " +
482
482
  `${detailByReason[qrResult.reason]}.`,
483
483
  );
484
484
  console.error(
@@ -7,8 +7,15 @@ import {
7
7
  } from "../lib/assistant-config.js";
8
8
  import type { AssistantEntry } from "../lib/assistant-config.js";
9
9
  import { dockerResourceNames, sleepContainers } from "../lib/docker.js";
10
+ import {
11
+ drainAssistant,
12
+ type DrainOutcome,
13
+ parseWaitDuration,
14
+ } from "../lib/drain.js";
15
+ import { loadGuardianToken } from "../lib/guardian-token.js";
10
16
  import { stopIngressNginx } from "../lib/nginx-ingress.js";
11
17
  import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
18
+ import { resolveFreshBearerToken } from "./client.js";
12
19
 
13
20
  const ACTIVE_CALL_LEASES_FILE = "active-call-leases.json";
14
21
 
@@ -49,7 +56,7 @@ function readActiveCallLeases(vellumDir: string): ActiveCallLease[] {
49
56
  export async function sleep(): Promise<void> {
50
57
  const args = process.argv.slice(3);
51
58
  if (args.includes("--help") || args.includes("-h")) {
52
- console.log("Usage: vellum sleep [<name>] [--force]");
59
+ console.log("Usage: vellum sleep [<name>] [--force] [--wait [<duration>]]");
53
60
  console.log("");
54
61
  console.log("Stop the assistant and gateway processes.");
55
62
  console.log("");
@@ -62,14 +69,66 @@ export async function sleep(): Promise<void> {
62
69
  console.log(
63
70
  " --force Stop the assistant even if a phone call keepalive lease is active",
64
71
  );
72
+ console.log(
73
+ " --wait Wait for in-flight background work (heartbeats, memory jobs,",
74
+ );
75
+ console.log(
76
+ " schedules) to finish before stopping. Waits as long as it",
77
+ );
78
+ console.log(
79
+ " takes by default; pass a duration (e.g. --wait 90s, --wait 10m)",
80
+ );
81
+ console.log(
82
+ " to bound the wait. Ctrl-C cancels and leaves the assistant running.",
83
+ );
65
84
  process.exit(0);
66
85
  }
67
86
 
68
87
  const force = args.includes("--force");
69
- const nameArg = args.find((a) => !a.startsWith("-"));
88
+ let wait = false;
89
+ let waitMs: number | null = null;
90
+ let waitDurationToken: string | null = null;
91
+ const waitEqArg = args.find((a) => a.startsWith("--wait="));
92
+ if (waitEqArg) {
93
+ wait = true;
94
+ const raw = waitEqArg.slice("--wait=".length);
95
+ waitMs = parseWaitDuration(raw);
96
+ if (waitMs == null) {
97
+ console.error(
98
+ `Error: invalid --wait duration '${raw}'. Use seconds or a value like 90s or 10m.`,
99
+ );
100
+ process.exit(1);
101
+ }
102
+ } else {
103
+ const waitIndex = args.indexOf("--wait");
104
+ if (waitIndex !== -1) {
105
+ wait = true;
106
+ // A bare `--wait` waits indefinitely; a following duration token
107
+ // (e.g. `90s`, `10m`, `120`) bounds it. Anything else is the name arg.
108
+ const next = args[waitIndex + 1];
109
+ if (next !== undefined && /^\d+(s|m)?$/.test(next)) {
110
+ waitDurationToken = next;
111
+ waitMs = parseWaitDuration(next);
112
+ if (waitMs == null) {
113
+ console.error(
114
+ `Error: invalid --wait duration '${next}'. Use seconds or a value like 90s or 10m.`,
115
+ );
116
+ process.exit(1);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ const nameArg = args.find(
122
+ (a) => !a.startsWith("-") && a !== waitDurationToken,
123
+ );
70
124
  const entry = resolveTargetAssistant(nameArg);
71
125
 
72
126
  if (entry.cloud === "docker") {
127
+ if (wait) {
128
+ console.log(
129
+ "--wait is not supported for Docker assistants yet — stopping containers now.",
130
+ );
131
+ }
73
132
  const res = dockerResourceNames(entry.assistantId);
74
133
  await sleepContainers(res);
75
134
  console.log("Docker containers stopped.");
@@ -135,6 +194,14 @@ export async function sleep(): Promise<void> {
135
194
  }
136
195
  }
137
196
 
197
+ if (wait) {
198
+ const outcome = await runDrainPhase(entry, assistantPidFile, waitMs);
199
+ if (outcome === "cancelled") {
200
+ console.log("Sleep cancelled — the assistant is still running.");
201
+ process.exit(130);
202
+ }
203
+ }
204
+
138
205
  // Stop assistant — use a generous timeout. On SIGTERM the daemon runs a
139
206
  // WAL checkpoint before exiting, which can take several seconds on a
140
207
  // multi-GB database. The default 2s grace in stopProcess() would SIGKILL a
@@ -186,3 +253,85 @@ export async function sleep(): Promise<void> {
186
253
  console.log("nginx ingress stopped.");
187
254
  }
188
255
  }
256
+
257
+ /**
258
+ * Wait for the assistant's in-flight background work (heartbeats, memory
259
+ * jobs, schedule runs, active conversation turns) to finish before the stop.
260
+ * Every failure mode falls back to a normal stop with a note — a drain must
261
+ * never make `vellum sleep` less reliable than it is without `--wait`.
262
+ */
263
+ async function runDrainPhase(
264
+ entry: AssistantEntry,
265
+ assistantPidFile: string,
266
+ waitMs: number | null,
267
+ ): Promise<DrainOutcome | "skipped"> {
268
+ if (!isProcessAlive(assistantPidFile).alive) {
269
+ return "skipped";
270
+ }
271
+ const baseUrl = entry.localUrl ?? entry.runtimeUrl;
272
+ const stored = loadGuardianToken(entry.assistantId)?.accessToken;
273
+ if (!baseUrl || !stored) {
274
+ console.log(
275
+ "Cannot reach the assistant API to wait for background work (missing URL or guardian token) — stopping without waiting.",
276
+ );
277
+ return "skipped";
278
+ }
279
+ // A token past its renewal point would 401 and read as "unreachable",
280
+ // silently skipping the wait — refresh-and-fall-back like other CLI
281
+ // request paths.
282
+ const token = await resolveFreshBearerToken(
283
+ baseUrl,
284
+ entry.assistantId,
285
+ stored,
286
+ entry.cloud,
287
+ );
288
+ if (!token) {
289
+ console.log(
290
+ "Cannot reach the assistant API to wait for background work (missing URL or guardian token) — stopping without waiting.",
291
+ );
292
+ return "skipped";
293
+ }
294
+
295
+ console.log(
296
+ waitMs == null
297
+ ? "Waiting for the assistant to finish background work (Ctrl-C cancels)…"
298
+ : `Waiting up to ${Math.round(waitMs / 1000)}s for the assistant to finish background work (Ctrl-C cancels)…`,
299
+ );
300
+
301
+ const controller = new AbortController();
302
+ const onSigint = (): void => controller.abort();
303
+ process.once("SIGINT", onSigint);
304
+ try {
305
+ const outcome = await drainAssistant({
306
+ baseUrl,
307
+ token,
308
+ deadlineAt: waitMs == null ? null : Date.now() + waitMs,
309
+ signal: controller.signal,
310
+ });
311
+ switch (outcome) {
312
+ case "drained":
313
+ console.log("Background work finished — stopping now.");
314
+ break;
315
+ case "timeout":
316
+ console.log(
317
+ "Wait limit reached — stopping now. Interrupted background jobs resume after the next start.",
318
+ );
319
+ break;
320
+ case "unsupported":
321
+ console.log(
322
+ "This assistant version does not support --wait — stopping without waiting.",
323
+ );
324
+ break;
325
+ case "unreachable":
326
+ console.log(
327
+ "Could not reach the assistant to check background work — stopping without waiting.",
328
+ );
329
+ break;
330
+ case "cancelled":
331
+ break;
332
+ }
333
+ return outcome;
334
+ } finally {
335
+ process.removeListener("SIGINT", onSigint);
336
+ }
337
+ }
@@ -425,6 +425,52 @@ export async function wake(): Promise<void> {
425
425
  }
426
426
  }
427
427
 
428
+ /**
429
+ * Retry policy for the flag probe that gates the web-ingress restore. The
430
+ * gateway has typically been up for milliseconds at this point and answers
431
+ * `503 {"status":"starting"}` (or refuses connections) until its startup
432
+ * completes, so a single probe races it. Mutable so tests can shrink the
433
+ * window.
434
+ */
435
+ export const WEB_INGRESS_FLAG_RETRY = {
436
+ attempts: 15,
437
+ intervalMs: 2_000,
438
+ };
439
+
440
+ /**
441
+ * Probe the `web-remote-ingress` flag, riding out the gateway's startup
442
+ * window: transient failures retry on an interval until the attempt budget is
443
+ * spent, then the last error propagates to the caller's warn path.
444
+ */
445
+ async function verifyWebIngressFlagWithRetry(
446
+ assistantId: string,
447
+ gatewayPort: number,
448
+ ): Promise<boolean> {
449
+ let lastError: unknown;
450
+ for (let attempt = 1; attempt <= WEB_INGRESS_FLAG_RETRY.attempts; attempt++) {
451
+ try {
452
+ return await isAssistantFeatureFlagEnabled(
453
+ assistantId,
454
+ WEB_REMOTE_INGRESS_FLAG,
455
+ { runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
456
+ );
457
+ } catch (err) {
458
+ lastError = err;
459
+ if (attempt === 1) {
460
+ console.log(
461
+ " Waiting for the gateway before restoring the web ingress edge...",
462
+ );
463
+ }
464
+ if (attempt < WEB_INGRESS_FLAG_RETRY.attempts) {
465
+ await new Promise((resolve) =>
466
+ setTimeout(resolve, WEB_INGRESS_FLAG_RETRY.intervalMs),
467
+ );
468
+ }
469
+ }
470
+ }
471
+ throw lastError;
472
+ }
473
+
428
474
  /**
429
475
  * Bring the nginx web ingress edge back up after a wake when the workspace
430
476
  * config still wants it. Only restores when ingress is explicitly enabled with
@@ -460,11 +506,7 @@ async function restoreWebIngressIfEnabled(
460
506
 
461
507
  let flagEnabled: boolean;
462
508
  try {
463
- flagEnabled = await isAssistantFeatureFlagEnabled(
464
- assistantId,
465
- WEB_REMOTE_INGRESS_FLAG,
466
- { runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
467
- );
509
+ flagEnabled = await verifyWebIngressFlagWithRetry(assistantId, gatewayPort);
468
510
  } catch (err) {
469
511
  console.warn(
470
512
  ` Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` flag to restore the web ingress edge; leaving it down. Bring it up manually with \`vellum nginx-ingress up\`. ${