@vellumai/cli 0.4.43 → 0.4.45
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/CONTRIBUTING.md +5 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +249 -1
- package/src/__tests__/multi-local.test.ts +6 -5
- package/src/commands/clean.ts +34 -0
- package/src/commands/hatch.ts +48 -6
- package/src/commands/ps.ts +8 -104
- package/src/commands/wake.ts +17 -1
- package/src/components/DefaultMainScreen.tsx +234 -105
- package/src/index.ts +3 -0
- package/src/lib/assistant-config.ts +144 -6
- package/src/lib/aws.ts +0 -1
- package/src/lib/docker.ts +59 -7
- package/src/lib/gcp.ts +0 -52
- package/src/lib/local.ts +38 -20
- package/src/lib/ngrok.ts +92 -0
- package/src/lib/orphan-detection.ts +103 -0
- package/src/lib/xdg-log.ts +47 -3
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# CLI Package — Contributing Guidelines
|
|
2
|
+
|
|
3
|
+
## Module Boundaries
|
|
4
|
+
|
|
5
|
+
- **Commands must not import from other commands.** Shared logic belongs in `src/lib/`. If two commands need the same function, extract it into an appropriate lib module rather than importing across `src/commands/` files.
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
loadAllAssistants,
|
|
15
15
|
saveAssistantEntry,
|
|
16
16
|
getActiveAssistant,
|
|
17
|
+
migrateLegacyEntry,
|
|
17
18
|
type AssistantEntry,
|
|
18
19
|
} from "../lib/assistant-config.js";
|
|
19
20
|
|
|
@@ -180,3 +181,250 @@ describe("assistant-config", () => {
|
|
|
180
181
|
expect(all[0].assistantId).toBe("valid");
|
|
181
182
|
});
|
|
182
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
|
+
});
|
|
@@ -94,21 +94,22 @@ describe("multi-local", () => {
|
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
describe("allocateLocalResources() produces non-conflicting ports", () => {
|
|
97
|
-
test("first instance gets
|
|
97
|
+
test("first instance gets home directory and default ports", async () => {
|
|
98
98
|
// GIVEN no local assistants exist in the lockfile
|
|
99
99
|
|
|
100
100
|
// WHEN we allocate resources for the first instance
|
|
101
101
|
const res = await allocateLocalResources("instance-a");
|
|
102
102
|
|
|
103
|
-
// THEN it gets
|
|
104
|
-
expect(res.instanceDir).toBe(
|
|
105
|
-
join(testDir, ".local", "share", "vellum", "assistants", "instance-a"),
|
|
106
|
-
);
|
|
103
|
+
// THEN it gets the home directory as its instance root
|
|
104
|
+
expect(res.instanceDir).toBe(testDir);
|
|
107
105
|
|
|
108
106
|
// AND it gets the default ports since no other instances exist
|
|
109
107
|
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
110
108
|
expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
|
|
111
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"));
|
|
112
113
|
});
|
|
113
114
|
|
|
114
115
|
test("second instance gets distinct ports and dir when first instance is saved", async () => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { detectOrphanedProcesses } from "../lib/orphan-detection";
|
|
2
|
+
import { stopProcess } from "../lib/process";
|
|
3
|
+
|
|
4
|
+
export async function clean(): Promise<void> {
|
|
5
|
+
const args = process.argv.slice(3);
|
|
6
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
7
|
+
console.log("Usage: vellum clean");
|
|
8
|
+
console.log("");
|
|
9
|
+
console.log("Kill all orphaned vellum processes that are not tracked by any assistant.");
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const orphans = await detectOrphanedProcesses();
|
|
14
|
+
|
|
15
|
+
if (orphans.length === 0) {
|
|
16
|
+
console.log("No orphaned processes found.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log(`Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"}.\n`);
|
|
21
|
+
|
|
22
|
+
let killed = 0;
|
|
23
|
+
for (const orphan of orphans) {
|
|
24
|
+
const pid = parseInt(orphan.pid, 10);
|
|
25
|
+
const stopped = await stopProcess(pid, `${orphan.name} (PID ${orphan.pid})`);
|
|
26
|
+
if (stopped) {
|
|
27
|
+
killed++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(
|
|
32
|
+
`\nCleaned up ${killed} process${killed === 1 ? "" : "es"}.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/commands/hatch.ts
CHANGED
|
@@ -47,9 +47,11 @@ import {
|
|
|
47
47
|
startGateway,
|
|
48
48
|
stopLocalProcesses,
|
|
49
49
|
} from "../lib/local";
|
|
50
|
+
import { maybeStartNgrokTunnel } from "../lib/ngrok";
|
|
50
51
|
import { isProcessAlive } from "../lib/process";
|
|
51
52
|
import { generateRandomSuffix } from "../lib/random-name";
|
|
52
53
|
import { validateAssistantName } from "../lib/retire-archive";
|
|
54
|
+
import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
|
|
53
55
|
|
|
54
56
|
export type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
55
57
|
|
|
@@ -265,7 +267,16 @@ function parseArgs(): HatchArgs {
|
|
|
265
267
|
}
|
|
266
268
|
}
|
|
267
269
|
|
|
268
|
-
return {
|
|
270
|
+
return {
|
|
271
|
+
species,
|
|
272
|
+
detached,
|
|
273
|
+
keepAlive,
|
|
274
|
+
name,
|
|
275
|
+
remote,
|
|
276
|
+
daemonOnly,
|
|
277
|
+
restart,
|
|
278
|
+
watch,
|
|
279
|
+
};
|
|
269
280
|
}
|
|
270
281
|
|
|
271
282
|
function formatElapsed(ms: number): string {
|
|
@@ -730,6 +741,16 @@ async function hatchLocal(
|
|
|
730
741
|
resources = await allocateLocalResources(instanceName);
|
|
731
742
|
}
|
|
732
743
|
|
|
744
|
+
const logsDir = join(
|
|
745
|
+
resources.instanceDir,
|
|
746
|
+
".vellum",
|
|
747
|
+
"workspace",
|
|
748
|
+
"data",
|
|
749
|
+
"logs",
|
|
750
|
+
);
|
|
751
|
+
archiveLogFile("hatch.log", logsDir);
|
|
752
|
+
resetLogFile("hatch.log");
|
|
753
|
+
|
|
733
754
|
console.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
734
755
|
console.log(` Species: ${species}`);
|
|
735
756
|
console.log("");
|
|
@@ -749,6 +770,21 @@ async function hatchLocal(
|
|
|
749
770
|
throw error;
|
|
750
771
|
}
|
|
751
772
|
|
|
773
|
+
// Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
|
|
774
|
+
// Set BASE_DATA_DIR so ngrok reads the correct instance config.
|
|
775
|
+
const prevBaseDataDir = process.env.BASE_DATA_DIR;
|
|
776
|
+
process.env.BASE_DATA_DIR = resources.instanceDir;
|
|
777
|
+
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
778
|
+
if (ngrokChild?.pid) {
|
|
779
|
+
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
780
|
+
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
781
|
+
}
|
|
782
|
+
if (prevBaseDataDir !== undefined) {
|
|
783
|
+
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
784
|
+
} else {
|
|
785
|
+
delete process.env.BASE_DATA_DIR;
|
|
786
|
+
}
|
|
787
|
+
|
|
752
788
|
// Read the bearer token (JWT) written by the daemon so the CLI can
|
|
753
789
|
// with the gateway (which requires auth by default). The daemon writes under
|
|
754
790
|
// getRootDir() which resolves to <instanceDir>/.vellum/.
|
|
@@ -826,9 +862,7 @@ async function hatchLocal(
|
|
|
826
862
|
consecutiveFailures++;
|
|
827
863
|
}
|
|
828
864
|
if (consecutiveFailures >= MAX_FAILURES) {
|
|
829
|
-
console.log(
|
|
830
|
-
"\n⚠️ Gateway stopped responding — shutting down.",
|
|
831
|
-
);
|
|
865
|
+
console.log("\n⚠️ Gateway stopped responding — shutting down.");
|
|
832
866
|
await stopLocalProcesses(resources);
|
|
833
867
|
process.exit(1);
|
|
834
868
|
}
|
|
@@ -844,8 +878,16 @@ export async function hatch(): Promise<void> {
|
|
|
844
878
|
const cliVersion = getCliVersion();
|
|
845
879
|
console.log(`@vellumai/cli v${cliVersion}`);
|
|
846
880
|
|
|
847
|
-
const {
|
|
848
|
-
|
|
881
|
+
const {
|
|
882
|
+
species,
|
|
883
|
+
detached,
|
|
884
|
+
keepAlive,
|
|
885
|
+
name,
|
|
886
|
+
remote,
|
|
887
|
+
daemonOnly,
|
|
888
|
+
restart,
|
|
889
|
+
watch,
|
|
890
|
+
} = parseArgs();
|
|
849
891
|
|
|
850
892
|
if (restart && remote !== "local") {
|
|
851
893
|
console.error(
|
package/src/commands/ps.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "fs";
|
|
2
|
-
import { homedir } from "os";
|
|
3
1
|
import { join } from "path";
|
|
4
2
|
|
|
5
3
|
import {
|
|
@@ -9,6 +7,13 @@ import {
|
|
|
9
7
|
type AssistantEntry,
|
|
10
8
|
} from "../lib/assistant-config";
|
|
11
9
|
import { checkHealth } from "../lib/health-check";
|
|
10
|
+
import {
|
|
11
|
+
classifyProcess,
|
|
12
|
+
detectOrphanedProcesses,
|
|
13
|
+
isProcessAlive,
|
|
14
|
+
parseRemotePs,
|
|
15
|
+
readPidFile,
|
|
16
|
+
} from "../lib/orphan-detection";
|
|
12
17
|
import { pgrepExact } from "../lib/pgrep";
|
|
13
18
|
import { probePort } from "../lib/port-probe";
|
|
14
19
|
import { withStatusEmoji } from "../lib/status-emoji";
|
|
@@ -77,40 +82,6 @@ const REMOTE_PS_CMD = [
|
|
|
77
82
|
"| grep -v grep",
|
|
78
83
|
].join(" ");
|
|
79
84
|
|
|
80
|
-
interface RemoteProcess {
|
|
81
|
-
pid: string;
|
|
82
|
-
ppid: string;
|
|
83
|
-
command: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function classifyProcess(command: string): string {
|
|
87
|
-
if (/qdrant/.test(command)) return "qdrant";
|
|
88
|
-
if (/vellum-gateway/.test(command)) return "gateway";
|
|
89
|
-
if (/openclaw/.test(command)) return "openclaw-adapter";
|
|
90
|
-
if (/vellum-daemon/.test(command)) return "assistant";
|
|
91
|
-
if (/daemon\s+(start|restart)/.test(command)) return "assistant";
|
|
92
|
-
// Exclude macOS desktop app processes — their path contains .app/Contents/MacOS/
|
|
93
|
-
// but they are not background service processes.
|
|
94
|
-
if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
|
|
95
|
-
if (/vellum/.test(command)) return "vellum";
|
|
96
|
-
return "unknown";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function parseRemotePs(output: string): RemoteProcess[] {
|
|
100
|
-
return output
|
|
101
|
-
.trim()
|
|
102
|
-
.split("\n")
|
|
103
|
-
.filter((line) => line.trim().length > 0)
|
|
104
|
-
.map((line) => {
|
|
105
|
-
const trimmed = line.trim();
|
|
106
|
-
const parts = trimmed.split(/\s+/);
|
|
107
|
-
const pid = parts[0];
|
|
108
|
-
const ppid = parts[1];
|
|
109
|
-
const command = parts.slice(2).join(" ");
|
|
110
|
-
return { pid, ppid, command };
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
85
|
function extractHostFromUrl(url: string): string {
|
|
115
86
|
try {
|
|
116
87
|
const parsed = new URL(url);
|
|
@@ -157,21 +128,6 @@ interface ProcessSpec {
|
|
|
157
128
|
pidFile: string;
|
|
158
129
|
}
|
|
159
130
|
|
|
160
|
-
function readPidFile(pidFile: string): string | null {
|
|
161
|
-
if (!existsSync(pidFile)) return null;
|
|
162
|
-
const pid = readFileSync(pidFile, "utf-8").trim();
|
|
163
|
-
return pid || null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function isProcessAlive(pid: string): boolean {
|
|
167
|
-
try {
|
|
168
|
-
process.kill(parseInt(pid, 10), 0);
|
|
169
|
-
return true;
|
|
170
|
-
} catch {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
131
|
interface DetectedProcess {
|
|
176
132
|
name: string;
|
|
177
133
|
pid: string | null;
|
|
@@ -318,57 +274,6 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
|
|
|
318
274
|
printTable(rows);
|
|
319
275
|
}
|
|
320
276
|
|
|
321
|
-
// ── Orphaned process detection ──────────────────────────────────
|
|
322
|
-
|
|
323
|
-
interface OrphanedProcess {
|
|
324
|
-
name: string;
|
|
325
|
-
pid: string;
|
|
326
|
-
source: string;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
|
|
330
|
-
const results: OrphanedProcess[] = [];
|
|
331
|
-
const seenPids = new Set<string>();
|
|
332
|
-
const vellumDir = join(homedir(), ".vellum");
|
|
333
|
-
|
|
334
|
-
// Strategy 1: PID file scan
|
|
335
|
-
const pidFiles: Array<{ file: string; name: string }> = [
|
|
336
|
-
{ file: join(vellumDir, "vellum.pid"), name: "assistant" },
|
|
337
|
-
{ file: join(vellumDir, "gateway.pid"), name: "gateway" },
|
|
338
|
-
{ file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
|
|
339
|
-
];
|
|
340
|
-
|
|
341
|
-
for (const { file, name } of pidFiles) {
|
|
342
|
-
const pid = readPidFile(file);
|
|
343
|
-
if (pid && isProcessAlive(pid)) {
|
|
344
|
-
results.push({ name, pid, source: "pid file" });
|
|
345
|
-
seenPids.add(pid);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Strategy 2: Process table scan
|
|
350
|
-
try {
|
|
351
|
-
const output = await execOutput("sh", [
|
|
352
|
-
"-c",
|
|
353
|
-
"ps ax -o pid=,ppid=,args= | grep -E 'vellum|vellum-gateway|qdrant|openclaw' | grep -v grep",
|
|
354
|
-
]);
|
|
355
|
-
const procs = parseRemotePs(output);
|
|
356
|
-
const ownPid = String(process.pid);
|
|
357
|
-
|
|
358
|
-
for (const p of procs) {
|
|
359
|
-
if (p.pid === ownPid || seenPids.has(p.pid)) continue;
|
|
360
|
-
const type = classifyProcess(p.command);
|
|
361
|
-
if (type === "unknown") continue;
|
|
362
|
-
results.push({ name: type, pid: p.pid, source: "process table" });
|
|
363
|
-
seenPids.add(p.pid);
|
|
364
|
-
}
|
|
365
|
-
} catch {
|
|
366
|
-
// grep exits 1 when no matches found — ignore
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return results;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
277
|
// ── List all assistants (no arg) ────────────────────────────────
|
|
373
278
|
|
|
374
279
|
async function listAllAssistants(): Promise<void> {
|
|
@@ -387,9 +292,8 @@ async function listAllAssistants(): Promise<void> {
|
|
|
387
292
|
info: `PID ${o.pid} (from ${o.source})`,
|
|
388
293
|
}));
|
|
389
294
|
printTable(rows);
|
|
390
|
-
const pids = orphans.map((o) => o.pid).join(" ");
|
|
391
295
|
console.log(
|
|
392
|
-
`\nHint: Run \`
|
|
296
|
+
`\nHint: Run \`vellum clean\` to clean up orphaned processes.`,
|
|
393
297
|
);
|
|
394
298
|
}
|
|
395
299
|
|
package/src/commands/wake.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "fs";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
4
|
import { resolveTargetAssistant } from "../lib/assistant-config.js";
|
|
5
5
|
import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
|
|
6
6
|
import { startLocalDaemon, startGateway } from "../lib/local";
|
|
7
|
+
import { maybeStartNgrokTunnel } from "../lib/ngrok";
|
|
7
8
|
|
|
8
9
|
export async function wake(): Promise<void> {
|
|
9
10
|
const args = process.argv.slice(3);
|
|
@@ -95,5 +96,20 @@ export async function wake(): Promise<void> {
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
|
|
100
|
+
// Set BASE_DATA_DIR so ngrok reads the correct instance config.
|
|
101
|
+
const prevBaseDataDir = process.env.BASE_DATA_DIR;
|
|
102
|
+
process.env.BASE_DATA_DIR = resources.instanceDir;
|
|
103
|
+
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
104
|
+
if (ngrokChild?.pid) {
|
|
105
|
+
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
106
|
+
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
107
|
+
}
|
|
108
|
+
if (prevBaseDataDir !== undefined) {
|
|
109
|
+
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
110
|
+
} else {
|
|
111
|
+
delete process.env.BASE_DATA_DIR;
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
console.log("Wake complete.");
|
|
99
115
|
}
|