@vellumai/cli 0.4.42 → 0.4.44
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__/assistant-config.test.ts +282 -3
- package/src/__tests__/multi-local.test.ts +13 -21
- package/src/__tests__/sleep.test.ts +172 -0
- package/src/commands/client.ts +72 -10
- package/src/commands/hatch.ts +65 -14
- package/src/commands/ps.ts +25 -8
- package/src/commands/recover.ts +17 -8
- package/src/commands/retire.ts +14 -23
- package/src/commands/sleep.ts +88 -16
- package/src/commands/wake.ts +9 -7
- package/src/components/DefaultMainScreen.tsx +19 -85
- package/src/index.ts +0 -3
- package/src/lib/assistant-config.ts +154 -61
- package/src/lib/aws.ts +30 -1
- package/src/lib/docker.ts +321 -0
- package/src/lib/gcp.ts +53 -1
- package/src/lib/http-client.ts +114 -0
- package/src/lib/local.ts +117 -167
- package/src/lib/step-runner.ts +9 -1
- package/src/lib/xdg-log.ts +47 -3
- package/src/__tests__/skills-uninstall.test.ts +0 -203
- package/src/commands/skills.ts +0 -514
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// Point lockfile operations at a temp directory
|
|
7
7
|
const testDir = mkdtempSync(join(tmpdir(), "cli-assistant-config-test-"));
|
|
8
|
-
process.env.
|
|
8
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
loadLatestAssistant,
|
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
removeAssistantEntry,
|
|
14
14
|
loadAllAssistants,
|
|
15
15
|
saveAssistantEntry,
|
|
16
|
+
getActiveAssistant,
|
|
17
|
+
migrateLegacyEntry,
|
|
16
18
|
type AssistantEntry,
|
|
17
19
|
} from "../lib/assistant-config.js";
|
|
18
20
|
|
|
19
21
|
afterAll(() => {
|
|
20
22
|
rmSync(testDir, { recursive: true, force: true });
|
|
21
|
-
delete process.env.
|
|
23
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
function writeLockfile(data: unknown): void {
|
|
@@ -100,6 +102,36 @@ describe("assistant-config", () => {
|
|
|
100
102
|
expect(all.map((e) => e.assistantId)).toEqual(["a", "c"]);
|
|
101
103
|
});
|
|
102
104
|
|
|
105
|
+
test("removeAssistantEntry reassigns activeAssistant to remaining entry", () => {
|
|
106
|
+
writeLockfile({
|
|
107
|
+
assistants: [makeEntry("a"), makeEntry("b")],
|
|
108
|
+
activeAssistant: "a",
|
|
109
|
+
});
|
|
110
|
+
removeAssistantEntry("a");
|
|
111
|
+
expect(getActiveAssistant()).toBe("b");
|
|
112
|
+
expect(loadAllAssistants()).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("removeAssistantEntry clears activeAssistant when no entries remain", () => {
|
|
116
|
+
writeLockfile({
|
|
117
|
+
assistants: [makeEntry("only")],
|
|
118
|
+
activeAssistant: "only",
|
|
119
|
+
});
|
|
120
|
+
removeAssistantEntry("only");
|
|
121
|
+
expect(getActiveAssistant()).toBeNull();
|
|
122
|
+
expect(loadAllAssistants()).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("removeAssistantEntry preserves activeAssistant when removing a different entry", () => {
|
|
126
|
+
writeLockfile({
|
|
127
|
+
assistants: [makeEntry("a"), makeEntry("b"), makeEntry("c")],
|
|
128
|
+
activeAssistant: "a",
|
|
129
|
+
});
|
|
130
|
+
removeAssistantEntry("b");
|
|
131
|
+
expect(getActiveAssistant()).toBe("a");
|
|
132
|
+
expect(loadAllAssistants()).toHaveLength(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
103
135
|
test("loadLatestAssistant returns null when empty", () => {
|
|
104
136
|
expect(loadLatestAssistant()).toBeNull();
|
|
105
137
|
});
|
|
@@ -149,3 +181,250 @@ describe("assistant-config", () => {
|
|
|
149
181
|
expect(all[0].assistantId).toBe("valid");
|
|
150
182
|
});
|
|
151
183
|
});
|
|
184
|
+
|
|
185
|
+
describe("migrateLegacyEntry", () => {
|
|
186
|
+
test("rewrites baseDataDir as resources.instanceDir", () => {
|
|
187
|
+
/**
|
|
188
|
+
* Tests that a legacy entry with top-level baseDataDir gets migrated
|
|
189
|
+
* to the current resources.instanceDir format.
|
|
190
|
+
*/
|
|
191
|
+
|
|
192
|
+
// GIVEN a legacy entry with baseDataDir set at the top level
|
|
193
|
+
const entry: Record<string, unknown> = {
|
|
194
|
+
assistantId: "my-assistant",
|
|
195
|
+
runtimeUrl: "http://localhost:7830",
|
|
196
|
+
cloud: "local",
|
|
197
|
+
baseDataDir: "/home/user/.local/share/vellum/assistants/my-assistant",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// WHEN we migrate the entry
|
|
201
|
+
const changed = migrateLegacyEntry(entry);
|
|
202
|
+
|
|
203
|
+
// THEN the entry should be mutated
|
|
204
|
+
expect(changed).toBe(true);
|
|
205
|
+
|
|
206
|
+
// AND baseDataDir should be removed
|
|
207
|
+
expect(entry.baseDataDir).toBeUndefined();
|
|
208
|
+
|
|
209
|
+
// AND resources.instanceDir should contain the old baseDataDir value
|
|
210
|
+
const resources = entry.resources as Record<string, unknown>;
|
|
211
|
+
expect(resources.instanceDir).toBe(
|
|
212
|
+
"/home/user/.local/share/vellum/assistants/my-assistant",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("synthesises full resources when none exist", () => {
|
|
217
|
+
/**
|
|
218
|
+
* Tests that a legacy local entry with no resources object gets a
|
|
219
|
+
* complete resources object synthesised with default ports and pidFile.
|
|
220
|
+
*/
|
|
221
|
+
|
|
222
|
+
// GIVEN a local entry with no resources
|
|
223
|
+
const entry: Record<string, unknown> = {
|
|
224
|
+
assistantId: "old-assistant",
|
|
225
|
+
runtimeUrl: "http://localhost:7830",
|
|
226
|
+
cloud: "local",
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// WHEN we migrate the entry
|
|
230
|
+
const changed = migrateLegacyEntry(entry);
|
|
231
|
+
|
|
232
|
+
// THEN the entry should be mutated
|
|
233
|
+
expect(changed).toBe(true);
|
|
234
|
+
|
|
235
|
+
// AND resources should be fully populated
|
|
236
|
+
const resources = entry.resources as Record<string, unknown>;
|
|
237
|
+
expect(resources.instanceDir).toContain("old-assistant");
|
|
238
|
+
expect(resources.daemonPort).toBe(7821);
|
|
239
|
+
expect(resources.gatewayPort).toBe(7830);
|
|
240
|
+
expect(resources.qdrantPort).toBe(6333);
|
|
241
|
+
expect(resources.pidFile).toContain("vellum.pid");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("infers gateway port from runtimeUrl", () => {
|
|
245
|
+
/**
|
|
246
|
+
* Tests that the gateway port is extracted from the runtimeUrl when
|
|
247
|
+
* synthesising resources for a legacy entry.
|
|
248
|
+
*/
|
|
249
|
+
|
|
250
|
+
// GIVEN a local entry with a non-default gateway port in the runtimeUrl
|
|
251
|
+
const entry: Record<string, unknown> = {
|
|
252
|
+
assistantId: "custom-port",
|
|
253
|
+
runtimeUrl: "http://localhost:9999",
|
|
254
|
+
cloud: "local",
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// WHEN we migrate the entry
|
|
258
|
+
migrateLegacyEntry(entry);
|
|
259
|
+
|
|
260
|
+
// THEN the gateway port should match the runtimeUrl port
|
|
261
|
+
const resources = entry.resources as Record<string, unknown>;
|
|
262
|
+
expect(resources.gatewayPort).toBe(9999);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("skips non-local entries", () => {
|
|
266
|
+
/**
|
|
267
|
+
* Tests that remote (non-local) entries are left untouched.
|
|
268
|
+
*/
|
|
269
|
+
|
|
270
|
+
// GIVEN a GCP entry without resources
|
|
271
|
+
const entry: Record<string, unknown> = {
|
|
272
|
+
assistantId: "gcp-assistant",
|
|
273
|
+
runtimeUrl: "https://example.com",
|
|
274
|
+
cloud: "gcp",
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// WHEN we attempt to migrate it
|
|
278
|
+
const changed = migrateLegacyEntry(entry);
|
|
279
|
+
|
|
280
|
+
// THEN nothing should change
|
|
281
|
+
expect(changed).toBe(false);
|
|
282
|
+
expect(entry.resources).toBeUndefined();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("backfills missing fields on partial resources", () => {
|
|
286
|
+
/**
|
|
287
|
+
* Tests that an entry with a partial resources object (e.g. only
|
|
288
|
+
* instanceDir) gets the remaining fields backfilled.
|
|
289
|
+
*/
|
|
290
|
+
|
|
291
|
+
// GIVEN an entry with partial resources (only instanceDir)
|
|
292
|
+
const entry: Record<string, unknown> = {
|
|
293
|
+
assistantId: "partial",
|
|
294
|
+
runtimeUrl: "http://localhost:7830",
|
|
295
|
+
cloud: "local",
|
|
296
|
+
resources: {
|
|
297
|
+
instanceDir: "/custom/path",
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// WHEN we migrate the entry
|
|
302
|
+
const changed = migrateLegacyEntry(entry);
|
|
303
|
+
|
|
304
|
+
// THEN the entry should be mutated
|
|
305
|
+
expect(changed).toBe(true);
|
|
306
|
+
|
|
307
|
+
// AND all missing resources fields should be filled in
|
|
308
|
+
const resources = entry.resources as Record<string, unknown>;
|
|
309
|
+
expect(resources.instanceDir).toBe("/custom/path");
|
|
310
|
+
expect(resources.daemonPort).toBe(7821);
|
|
311
|
+
expect(resources.gatewayPort).toBe(7830);
|
|
312
|
+
expect(resources.qdrantPort).toBe(6333);
|
|
313
|
+
expect(resources.pidFile).toBe("/custom/path/.vellum/vellum.pid");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("does not overwrite existing resources fields", () => {
|
|
317
|
+
/**
|
|
318
|
+
* Tests that an entry with a complete resources object is left untouched.
|
|
319
|
+
*/
|
|
320
|
+
|
|
321
|
+
// GIVEN an entry with a complete resources object
|
|
322
|
+
const entry: Record<string, unknown> = {
|
|
323
|
+
assistantId: "complete",
|
|
324
|
+
runtimeUrl: "http://localhost:7830",
|
|
325
|
+
cloud: "local",
|
|
326
|
+
resources: {
|
|
327
|
+
instanceDir: "/my/path",
|
|
328
|
+
daemonPort: 8000,
|
|
329
|
+
gatewayPort: 8001,
|
|
330
|
+
qdrantPort: 8002,
|
|
331
|
+
pidFile: "/my/path/.vellum/vellum.pid",
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// WHEN we migrate the entry
|
|
336
|
+
const changed = migrateLegacyEntry(entry);
|
|
337
|
+
|
|
338
|
+
// THEN nothing should change
|
|
339
|
+
expect(changed).toBe(false);
|
|
340
|
+
|
|
341
|
+
// AND existing values should be preserved
|
|
342
|
+
const resources = entry.resources as Record<string, unknown>;
|
|
343
|
+
expect(resources.daemonPort).toBe(8000);
|
|
344
|
+
expect(resources.gatewayPort).toBe(8001);
|
|
345
|
+
expect(resources.qdrantPort).toBe(8002);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("baseDataDir does not overwrite existing resources.instanceDir", () => {
|
|
349
|
+
/**
|
|
350
|
+
* Tests that when both baseDataDir and resources.instanceDir exist,
|
|
351
|
+
* the existing instanceDir is preserved and baseDataDir is removed.
|
|
352
|
+
*/
|
|
353
|
+
|
|
354
|
+
// GIVEN an entry with both baseDataDir and resources.instanceDir
|
|
355
|
+
const entry: Record<string, unknown> = {
|
|
356
|
+
assistantId: "conflict",
|
|
357
|
+
runtimeUrl: "http://localhost:7830",
|
|
358
|
+
cloud: "local",
|
|
359
|
+
baseDataDir: "/old/path",
|
|
360
|
+
resources: {
|
|
361
|
+
instanceDir: "/new/path",
|
|
362
|
+
daemonPort: 7821,
|
|
363
|
+
gatewayPort: 7830,
|
|
364
|
+
qdrantPort: 6333,
|
|
365
|
+
pidFile: "/new/path/.vellum/vellum.pid",
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// WHEN we migrate the entry
|
|
370
|
+
const changed = migrateLegacyEntry(entry);
|
|
371
|
+
|
|
372
|
+
// THEN baseDataDir should be removed
|
|
373
|
+
expect(changed).toBe(true);
|
|
374
|
+
expect(entry.baseDataDir).toBeUndefined();
|
|
375
|
+
|
|
376
|
+
// AND the existing instanceDir should be preserved
|
|
377
|
+
const resources = entry.resources as Record<string, unknown>;
|
|
378
|
+
expect(resources.instanceDir).toBe("/new/path");
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("legacy migration via loadAllAssistants", () => {
|
|
383
|
+
beforeEach(() => {
|
|
384
|
+
try {
|
|
385
|
+
rmSync(join(testDir, ".vellum.lock.json"));
|
|
386
|
+
} catch {
|
|
387
|
+
// file may not exist
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("migrates legacy entries and persists to disk on read", () => {
|
|
392
|
+
/**
|
|
393
|
+
* Tests that reading assistants from a lockfile with legacy entries
|
|
394
|
+
* triggers migration and persists the updated format to disk.
|
|
395
|
+
*/
|
|
396
|
+
|
|
397
|
+
// GIVEN a lockfile with a legacy entry containing baseDataDir
|
|
398
|
+
writeLockfile({
|
|
399
|
+
assistants: [
|
|
400
|
+
{
|
|
401
|
+
assistantId: "legacy-bot",
|
|
402
|
+
runtimeUrl: "http://localhost:7830",
|
|
403
|
+
cloud: "local",
|
|
404
|
+
baseDataDir: "/home/user/.local/share/vellum/assistants/legacy-bot",
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// WHEN we load assistants
|
|
410
|
+
const all = loadAllAssistants();
|
|
411
|
+
|
|
412
|
+
// THEN the entry should have resources populated
|
|
413
|
+
expect(all).toHaveLength(1);
|
|
414
|
+
expect(all[0].resources).toBeDefined();
|
|
415
|
+
expect(all[0].resources!.instanceDir).toBe(
|
|
416
|
+
"/home/user/.local/share/vellum/assistants/legacy-bot",
|
|
417
|
+
);
|
|
418
|
+
expect(all[0].resources!.gatewayPort).toBe(7830);
|
|
419
|
+
|
|
420
|
+
// AND the lockfile on disk should reflect the migration
|
|
421
|
+
const rawDisk = JSON.parse(
|
|
422
|
+
readFileSync(join(testDir, ".vellum.lock.json"), "utf-8"),
|
|
423
|
+
);
|
|
424
|
+
const diskEntry = rawDisk.assistants[0];
|
|
425
|
+
expect(diskEntry.baseDataDir).toBeUndefined();
|
|
426
|
+
expect(diskEntry.resources.instanceDir).toBe(
|
|
427
|
+
"/home/user/.local/share/vellum/assistants/legacy-bot",
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// Create a temp directory that acts as a fake home, so allocateLocalResources()
|
|
7
|
-
//
|
|
7
|
+
// never touches the real ~/.vellum directory.
|
|
8
8
|
const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
|
|
9
|
-
process.env.
|
|
9
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
10
10
|
|
|
11
11
|
// Mock homedir() to return testDir — this isolates allocateLocalResources()
|
|
12
12
|
// which uses homedir() directly for instance directory creation.
|
|
@@ -31,7 +31,6 @@ mock.module("../lib/port-probe.js", () => ({
|
|
|
31
31
|
|
|
32
32
|
import {
|
|
33
33
|
allocateLocalResources,
|
|
34
|
-
defaultLocalResources,
|
|
35
34
|
resolveTargetAssistant,
|
|
36
35
|
setActiveAssistant,
|
|
37
36
|
getActiveAssistant,
|
|
@@ -47,7 +46,7 @@ import {
|
|
|
47
46
|
|
|
48
47
|
afterAll(() => {
|
|
49
48
|
rmSync(testDir, { recursive: true, force: true });
|
|
50
|
-
delete process.env.
|
|
49
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
51
50
|
});
|
|
52
51
|
|
|
53
52
|
function writeLockfile(data: unknown): void {
|
|
@@ -95,17 +94,22 @@ describe("multi-local", () => {
|
|
|
95
94
|
});
|
|
96
95
|
|
|
97
96
|
describe("allocateLocalResources() produces non-conflicting ports", () => {
|
|
98
|
-
test("first instance
|
|
97
|
+
test("first instance gets home directory and default ports", async () => {
|
|
99
98
|
// GIVEN no local assistants exist in the lockfile
|
|
100
99
|
|
|
101
100
|
// WHEN we allocate resources for the first instance
|
|
102
101
|
const res = await allocateLocalResources("instance-a");
|
|
103
102
|
|
|
104
|
-
// THEN it
|
|
103
|
+
// THEN it gets the home directory as its instance root
|
|
105
104
|
expect(res.instanceDir).toBe(testDir);
|
|
105
|
+
|
|
106
|
+
// AND it gets the default ports since no other instances exist
|
|
106
107
|
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
107
108
|
expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
|
|
108
109
|
expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
|
|
110
|
+
|
|
111
|
+
// AND the PID file is under ~/.vellum/
|
|
112
|
+
expect(res.pidFile).toBe(join(testDir, ".vellum", "vellum.pid"));
|
|
109
113
|
});
|
|
110
114
|
|
|
111
115
|
test("second instance gets distinct ports and dir when first instance is saved", async () => {
|
|
@@ -156,18 +160,6 @@ describe("multi-local", () => {
|
|
|
156
160
|
});
|
|
157
161
|
});
|
|
158
162
|
|
|
159
|
-
describe("defaultLocalResources() returns legacy paths", () => {
|
|
160
|
-
test("instanceDir is homedir", () => {
|
|
161
|
-
const res = defaultLocalResources();
|
|
162
|
-
expect(res.instanceDir).toBe(testDir);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("daemonPort is DEFAULT_DAEMON_PORT", () => {
|
|
166
|
-
const res = defaultLocalResources();
|
|
167
|
-
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
163
|
describe("resolveTargetAssistant() priority chain", () => {
|
|
172
164
|
test("explicit name returns that entry", () => {
|
|
173
165
|
writeLockfile({
|
|
@@ -243,14 +235,14 @@ describe("multi-local", () => {
|
|
|
243
235
|
});
|
|
244
236
|
});
|
|
245
237
|
|
|
246
|
-
describe("removeAssistantEntry()
|
|
247
|
-
test("set active to foo, remove foo, verify active is
|
|
238
|
+
describe("removeAssistantEntry() reassigns activeAssistant on removal", () => {
|
|
239
|
+
test("set active to foo, remove foo, verify active is reassigned to bar", () => {
|
|
248
240
|
writeLockfile({
|
|
249
241
|
assistants: [makeEntry("foo"), makeEntry("bar")],
|
|
250
242
|
activeAssistant: "foo",
|
|
251
243
|
});
|
|
252
244
|
removeAssistantEntry("foo");
|
|
253
|
-
expect(getActiveAssistant()).
|
|
245
|
+
expect(getActiveAssistant()).toBe("bar");
|
|
254
246
|
});
|
|
255
247
|
|
|
256
248
|
test("set active to foo, remove bar, verify active is still foo", () => {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// Create a temp directory and set VELLUM_LOCKFILE_DIR so the real
|
|
15
|
+
// assistant-config module reads/writes the lockfile here instead of ~/.
|
|
16
|
+
const testDir = mkdtempSync(join(tmpdir(), "sleep-command-test-"));
|
|
17
|
+
const assistantRootDir = join(testDir, ".vellum");
|
|
18
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
19
|
+
|
|
20
|
+
const stopProcessByPidFileMock = mock(async () => true);
|
|
21
|
+
const isProcessAliveMock = mock((): { alive: boolean; pid: number | null } => ({
|
|
22
|
+
alive: false,
|
|
23
|
+
pid: null,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
mock.module("../lib/process.js", () => ({
|
|
27
|
+
isProcessAlive: isProcessAliveMock,
|
|
28
|
+
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { sleep } from "../commands/sleep.js";
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_DAEMON_PORT,
|
|
34
|
+
DEFAULT_GATEWAY_PORT,
|
|
35
|
+
DEFAULT_QDRANT_PORT,
|
|
36
|
+
} from "../lib/constants.js";
|
|
37
|
+
|
|
38
|
+
// Write a lockfile entry so the real resolveTargetAssistant() finds our test
|
|
39
|
+
// assistant without needing to mock the entire assistant-config module.
|
|
40
|
+
function writeLockfile(): void {
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(testDir, ".vellum.lock.json"),
|
|
43
|
+
JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
assistants: [
|
|
46
|
+
{
|
|
47
|
+
assistantId: "sleep-test",
|
|
48
|
+
runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
|
|
49
|
+
cloud: "local",
|
|
50
|
+
resources: {
|
|
51
|
+
instanceDir: testDir,
|
|
52
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
53
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
54
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
55
|
+
pidFile: join(assistantRootDir, "vellum.pid"),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
activeAssistant: "sleep-test",
|
|
60
|
+
},
|
|
61
|
+
null,
|
|
62
|
+
2,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeLeaseFile(callSessionIds: string[]): void {
|
|
68
|
+
mkdirSync(assistantRootDir, { recursive: true });
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(assistantRootDir, "active-call-leases.json"),
|
|
71
|
+
JSON.stringify(
|
|
72
|
+
{
|
|
73
|
+
version: 1,
|
|
74
|
+
leases: callSessionIds.map((callSessionId) => ({
|
|
75
|
+
callSessionId,
|
|
76
|
+
providerCallSid: null,
|
|
77
|
+
updatedAt: Date.now(),
|
|
78
|
+
})),
|
|
79
|
+
},
|
|
80
|
+
null,
|
|
81
|
+
2,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("sleep command", () => {
|
|
87
|
+
let originalArgv: string[];
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
originalArgv = [...process.argv];
|
|
91
|
+
isProcessAliveMock.mockReset();
|
|
92
|
+
isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
|
|
93
|
+
stopProcessByPidFileMock.mockReset();
|
|
94
|
+
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
95
|
+
rmSync(assistantRootDir, { recursive: true, force: true });
|
|
96
|
+
writeLockfile();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(() => {
|
|
100
|
+
process.argv = originalArgv;
|
|
101
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
102
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("refuses normal sleep while an active call lease exists", async () => {
|
|
106
|
+
isProcessAliveMock.mockReturnValue({ alive: true, pid: 12345 });
|
|
107
|
+
writeLeaseFile(["call-active-1", "call-active-2"]);
|
|
108
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test"];
|
|
109
|
+
|
|
110
|
+
const consoleError = spyOn(console, "error").mockImplementation(() => {});
|
|
111
|
+
const exitMock = mock((code?: number) => {
|
|
112
|
+
throw new Error(`process.exit:${code}`);
|
|
113
|
+
});
|
|
114
|
+
const originalExit = process.exit;
|
|
115
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await expect(sleep()).rejects.toThrow("process.exit:1");
|
|
119
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
120
|
+
expect.stringContaining("vellum sleep --force"),
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
process.exit = originalExit;
|
|
124
|
+
consoleError.mockRestore();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("proceeds when assistant is not running even with stale lease file", async () => {
|
|
131
|
+
isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
|
|
132
|
+
writeLeaseFile(["call-stale-1"]);
|
|
133
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test"];
|
|
134
|
+
|
|
135
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await sleep();
|
|
139
|
+
} finally {
|
|
140
|
+
consoleLog.mockRestore();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("force stops the assistant even when an active call lease exists", async () => {
|
|
147
|
+
writeLeaseFile(["call-active-1"]);
|
|
148
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test", "--force"];
|
|
149
|
+
|
|
150
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await sleep();
|
|
154
|
+
} finally {
|
|
155
|
+
consoleLog.mockRestore();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
159
|
+
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
160
|
+
1,
|
|
161
|
+
join(assistantRootDir, "vellum.pid"),
|
|
162
|
+
"assistant",
|
|
163
|
+
);
|
|
164
|
+
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
165
|
+
2,
|
|
166
|
+
join(assistantRootDir, "gateway.pid"),
|
|
167
|
+
"gateway",
|
|
168
|
+
undefined,
|
|
169
|
+
7000,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|