@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.
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +9 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +449 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +468 -0
- package/package.json +1 -1
- package/src/__tests__/drain.test.ts +337 -0
- package/src/__tests__/flags.test.ts +11 -11
- package/src/__tests__/local-hatch-lock.test.ts +163 -0
- package/src/__tests__/sleep.test.ts +171 -2
- package/src/__tests__/wake.test.ts +38 -1
- package/src/commands/flags.ts +3 -3
- package/src/commands/pair.ts +2 -2
- package/src/commands/sleep.ts +151 -2
- package/src/commands/wake.ts +47 -5
- package/src/lib/drain.ts +346 -0
- package/src/lib/flag-args.test.ts +3 -3
- package/src/lib/flag-args.ts +2 -2
- package/src/lib/hatch-local.ts +98 -86
- package/src/lib/local-hatch-lock.ts +322 -0
- package/src/lib/local.ts +20 -2
- package/src/lib/provider-secrets.ts +4 -0
- package/src/shared/provider-env-vars.ts +2 -0
|
@@ -30,10 +30,53 @@ mock.module("../lib/process.js", () => ({
|
|
|
30
30
|
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
|
-
//
|
|
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
|
});
|
package/src/commands/flags.ts
CHANGED
|
@@ -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
|
|
96
|
+
" $ vellum flags get browser # inspect one flag",
|
|
97
97
|
);
|
|
98
98
|
console.log(
|
|
99
|
-
" $ vellum flags set
|
|
99
|
+
" $ vellum flags set browser true # enable a flag",
|
|
100
100
|
);
|
|
101
101
|
console.log(
|
|
102
|
-
" $ vellum flags set
|
|
102
|
+
" $ vellum flags set browser true --assistant eval-1 # target by name/id",
|
|
103
103
|
);
|
|
104
104
|
}
|
|
105
105
|
|
package/src/commands/pair.ts
CHANGED
|
@@ -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
|
|
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
|
|
481
|
+
"Error: --qr needs a public https URL the scanning device can open — " +
|
|
482
482
|
`${detailByReason[qrResult.reason]}.`,
|
|
483
483
|
);
|
|
484
484
|
console.error(
|
package/src/commands/sleep.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/commands/wake.ts
CHANGED
|
@@ -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
|
|
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\`. ${
|