@vellumai/cli 0.4.43 → 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 +249 -1
- package/src/__tests__/multi-local.test.ts +6 -5
- package/src/commands/hatch.ts +5 -0
- package/src/components/DefaultMainScreen.tsx +16 -2
- package/src/lib/assistant-config.ts +144 -6
- package/src/lib/docker.ts +3 -1
- package/src/lib/local.ts +22 -20
- package/src/lib/xdg-log.ts +47 -3
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 () => {
|
package/src/commands/hatch.ts
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
import { isProcessAlive } from "../lib/process";
|
|
51
51
|
import { generateRandomSuffix } from "../lib/random-name";
|
|
52
52
|
import { validateAssistantName } from "../lib/retire-archive";
|
|
53
|
+
import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
|
|
53
54
|
|
|
54
55
|
export type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
55
56
|
|
|
@@ -730,6 +731,10 @@ async function hatchLocal(
|
|
|
730
731
|
resources = await allocateLocalResources(instanceName);
|
|
731
732
|
}
|
|
732
733
|
|
|
734
|
+
const logsDir = join(resources.instanceDir, ".vellum", "workspace", "data", "logs");
|
|
735
|
+
archiveLogFile("hatch.log", logsDir);
|
|
736
|
+
resetLogFile("hatch.log");
|
|
737
|
+
|
|
733
738
|
console.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
734
739
|
console.log(` Species: ${species}`);
|
|
735
740
|
console.log("");
|
|
@@ -152,6 +152,19 @@ interface HealthResponse {
|
|
|
152
152
|
message?: string;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** Extract human-readable message from a daemon JSON error response. */
|
|
156
|
+
function friendlyErrorMessage(status: number, body: string): string {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(body) as { error?: { message?: string } };
|
|
159
|
+
if (parsed?.error?.message) {
|
|
160
|
+
return parsed.error.message;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// Not JSON — fall through
|
|
164
|
+
}
|
|
165
|
+
return `HTTP ${status}: ${body || "Unknown error"}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
async function runtimeRequest<T>(
|
|
156
169
|
baseUrl: string,
|
|
157
170
|
assistantId: string,
|
|
@@ -171,7 +184,7 @@ async function runtimeRequest<T>(
|
|
|
171
184
|
|
|
172
185
|
if (!response.ok) {
|
|
173
186
|
const body = await response.text().catch(() => "");
|
|
174
|
-
throw new Error(
|
|
187
|
+
throw new Error(friendlyErrorMessage(response.status, body));
|
|
175
188
|
}
|
|
176
189
|
|
|
177
190
|
if (response.status === 204) {
|
|
@@ -1758,7 +1771,8 @@ function ChatApp({
|
|
|
1758
1771
|
clearTimeout(timeoutId);
|
|
1759
1772
|
h.setBusy(false);
|
|
1760
1773
|
h.hideSpinner();
|
|
1761
|
-
const errorMsg =
|
|
1774
|
+
const errorMsg =
|
|
1775
|
+
sendErr instanceof Error ? sendErr.message : String(sendErr);
|
|
1762
1776
|
h.showError(errorMsg);
|
|
1763
1777
|
chatLogRef.current.push({ role: "error", content: errorMsg });
|
|
1764
1778
|
return;
|
|
@@ -16,7 +16,9 @@ import { probePort } from "./port-probe.js";
|
|
|
16
16
|
*/
|
|
17
17
|
export interface LocalInstanceResources {
|
|
18
18
|
/**
|
|
19
|
-
* Instance-specific data root
|
|
19
|
+
* Instance-specific data root. The first local assistant uses `~` (home
|
|
20
|
+
* directory) with default ports. Subsequent instances are placed under
|
|
21
|
+
* `~/.local/share/vellum/assistants/<name>/`.
|
|
20
22
|
* The daemon's `.vellum/` directory lives inside it.
|
|
21
23
|
*/
|
|
22
24
|
instanceDir: string;
|
|
@@ -28,6 +30,7 @@ export interface LocalInstanceResources {
|
|
|
28
30
|
qdrantPort: number;
|
|
29
31
|
/** Absolute path to the daemon PID file */
|
|
30
32
|
pidFile: string;
|
|
33
|
+
[key: string]: unknown;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export interface AssistantEntry {
|
|
@@ -48,10 +51,11 @@ export interface AssistantEntry {
|
|
|
48
51
|
hatchedAt?: string;
|
|
49
52
|
/** Per-instance resource config. Present for local entries in multi-instance setups. */
|
|
50
53
|
resources?: LocalInstanceResources;
|
|
54
|
+
[key: string]: unknown;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
interface LockfileData {
|
|
54
|
-
assistants?:
|
|
58
|
+
assistants?: Record<string, unknown>[];
|
|
55
59
|
activeAssistant?: string;
|
|
56
60
|
platformBaseUrl?: string;
|
|
57
61
|
[key: string]: unknown;
|
|
@@ -92,14 +96,132 @@ function writeLockfile(data: LockfileData): void {
|
|
|
92
96
|
writeFileSync(lockfilePath, JSON.stringify(data, null, 2) + "\n");
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
|
|
101
|
+
* Returns undefined if the URL is malformed or has no explicit port.
|
|
102
|
+
*/
|
|
103
|
+
function parsePortFromUrl(url: unknown): number | undefined {
|
|
104
|
+
if (typeof url !== "string") return undefined;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = new URL(url);
|
|
107
|
+
const port = parseInt(parsed.port, 10);
|
|
108
|
+
return isNaN(port) ? undefined : port;
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect and migrate legacy lockfile entries to the current format.
|
|
116
|
+
*
|
|
117
|
+
* Legacy entries stored `baseDataDir` as a top-level field. The current
|
|
118
|
+
* format nests this under `resources.instanceDir`. This function also
|
|
119
|
+
* synthesises a full `resources` object when one is missing by inferring
|
|
120
|
+
* ports from the entry's `runtimeUrl` and falling back to defaults.
|
|
121
|
+
*
|
|
122
|
+
* Returns `true` if the entry was mutated (so the caller can persist).
|
|
123
|
+
*/
|
|
124
|
+
export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
125
|
+
if (typeof raw.cloud === "string" && raw.cloud !== "local") {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let mutated = false;
|
|
130
|
+
|
|
131
|
+
// Migrate top-level `baseDataDir` → `resources.instanceDir`
|
|
132
|
+
if (typeof raw.baseDataDir === "string" && raw.baseDataDir) {
|
|
133
|
+
if (!raw.resources || typeof raw.resources !== "object") {
|
|
134
|
+
raw.resources = {};
|
|
135
|
+
}
|
|
136
|
+
const res = raw.resources as Record<string, unknown>;
|
|
137
|
+
if (!res.instanceDir) {
|
|
138
|
+
res.instanceDir = raw.baseDataDir;
|
|
139
|
+
mutated = true;
|
|
140
|
+
}
|
|
141
|
+
delete raw.baseDataDir;
|
|
142
|
+
mutated = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Synthesise missing `resources` for local entries
|
|
146
|
+
if (!raw.resources || typeof raw.resources !== "object") {
|
|
147
|
+
const gatewayPort =
|
|
148
|
+
parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
|
|
149
|
+
const instanceDir = join(
|
|
150
|
+
homedir(),
|
|
151
|
+
".local",
|
|
152
|
+
"share",
|
|
153
|
+
"vellum",
|
|
154
|
+
"assistants",
|
|
155
|
+
typeof raw.assistantId === "string" ? raw.assistantId : "default",
|
|
156
|
+
);
|
|
157
|
+
raw.resources = {
|
|
158
|
+
instanceDir,
|
|
159
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
160
|
+
gatewayPort,
|
|
161
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
162
|
+
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
163
|
+
};
|
|
164
|
+
mutated = true;
|
|
165
|
+
} else {
|
|
166
|
+
// Backfill any missing fields on an existing partial `resources` object
|
|
167
|
+
const res = raw.resources as Record<string, unknown>;
|
|
168
|
+
if (!res.instanceDir) {
|
|
169
|
+
res.instanceDir = join(
|
|
170
|
+
homedir(),
|
|
171
|
+
".local",
|
|
172
|
+
"share",
|
|
173
|
+
"vellum",
|
|
174
|
+
"assistants",
|
|
175
|
+
typeof raw.assistantId === "string" ? raw.assistantId : "default",
|
|
176
|
+
);
|
|
177
|
+
mutated = true;
|
|
178
|
+
}
|
|
179
|
+
if (typeof res.daemonPort !== "number") {
|
|
180
|
+
res.daemonPort = DEFAULT_DAEMON_PORT;
|
|
181
|
+
mutated = true;
|
|
182
|
+
}
|
|
183
|
+
if (typeof res.gatewayPort !== "number") {
|
|
184
|
+
res.gatewayPort =
|
|
185
|
+
parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
|
|
186
|
+
mutated = true;
|
|
187
|
+
}
|
|
188
|
+
if (typeof res.qdrantPort !== "number") {
|
|
189
|
+
res.qdrantPort = DEFAULT_QDRANT_PORT;
|
|
190
|
+
mutated = true;
|
|
191
|
+
}
|
|
192
|
+
if (typeof res.pidFile !== "string") {
|
|
193
|
+
res.pidFile = join(
|
|
194
|
+
res.instanceDir as string,
|
|
195
|
+
".vellum",
|
|
196
|
+
"vellum.pid",
|
|
197
|
+
);
|
|
198
|
+
mutated = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return mutated;
|
|
203
|
+
}
|
|
204
|
+
|
|
95
205
|
function readAssistants(): AssistantEntry[] {
|
|
96
206
|
const data = readLockfile();
|
|
97
207
|
const entries = data.assistants;
|
|
98
208
|
if (!Array.isArray(entries)) {
|
|
99
209
|
return [];
|
|
100
210
|
}
|
|
211
|
+
|
|
212
|
+
let migrated = false;
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (migrateLegacyEntry(entry)) {
|
|
215
|
+
migrated = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (migrated) {
|
|
220
|
+
writeLockfile(data);
|
|
221
|
+
}
|
|
222
|
+
|
|
101
223
|
return entries.filter(
|
|
102
|
-
(e) =>
|
|
224
|
+
(e): e is AssistantEntry =>
|
|
103
225
|
typeof e.assistantId === "string" && typeof e.runtimeUrl === "string",
|
|
104
226
|
);
|
|
105
227
|
}
|
|
@@ -131,14 +253,14 @@ export function findAssistantByName(name: string): AssistantEntry | null {
|
|
|
131
253
|
export function removeAssistantEntry(assistantId: string): void {
|
|
132
254
|
const data = readLockfile();
|
|
133
255
|
const entries = (data.assistants ?? []).filter(
|
|
134
|
-
(e
|
|
256
|
+
(e) => e.assistantId !== assistantId,
|
|
135
257
|
);
|
|
136
258
|
data.assistants = entries;
|
|
137
259
|
// Reassign active assistant if it matches the removed entry
|
|
138
260
|
if (data.activeAssistant === assistantId) {
|
|
139
261
|
const remaining = entries[0];
|
|
140
262
|
if (remaining) {
|
|
141
|
-
data.activeAssistant = remaining.assistantId;
|
|
263
|
+
data.activeAssistant = String(remaining.assistantId);
|
|
142
264
|
} else {
|
|
143
265
|
delete data.activeAssistant;
|
|
144
266
|
}
|
|
@@ -229,12 +351,28 @@ async function findAvailablePort(
|
|
|
229
351
|
|
|
230
352
|
/**
|
|
231
353
|
* Allocate an isolated set of resources for a named local instance.
|
|
232
|
-
*
|
|
354
|
+
* The first local assistant uses the home directory with default ports.
|
|
355
|
+
* Subsequent assistants are placed under
|
|
233
356
|
* `~/.local/share/vellum/assistants/<name>/` with scanned ports.
|
|
234
357
|
*/
|
|
235
358
|
export async function allocateLocalResources(
|
|
236
359
|
instanceName: string,
|
|
237
360
|
): Promise<LocalInstanceResources> {
|
|
361
|
+
// First local assistant gets the home directory with default ports.
|
|
362
|
+
const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
|
|
363
|
+
if (existingLocals.length === 0) {
|
|
364
|
+
const home = homedir();
|
|
365
|
+
const vellumDir = join(home, ".vellum");
|
|
366
|
+
mkdirSync(vellumDir, { recursive: true });
|
|
367
|
+
return {
|
|
368
|
+
instanceDir: home,
|
|
369
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
370
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
371
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
372
|
+
pidFile: join(vellumDir, "vellum.pid"),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
238
376
|
const instanceDir = join(
|
|
239
377
|
homedir(),
|
|
240
378
|
".local",
|
package/src/lib/docker.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { Species } from "./constants";
|
|
|
10
10
|
import { discoverPublicUrl } from "./local";
|
|
11
11
|
import { generateRandomSuffix } from "./random-name";
|
|
12
12
|
import { exec, execOutput } from "./step-runner";
|
|
13
|
-
import { closeLogFile, openLogFile, writeToLogFile } from "./xdg-log";
|
|
13
|
+
import { closeLogFile, openLogFile, resetLogFile, writeToLogFile } from "./xdg-log";
|
|
14
14
|
|
|
15
15
|
const _require = createRequire(import.meta.url);
|
|
16
16
|
|
|
@@ -163,6 +163,8 @@ export async function hatchDocker(
|
|
|
163
163
|
process.exit(1);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
resetLogFile("hatch.log");
|
|
167
|
+
|
|
166
168
|
console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
|
|
167
169
|
console.log(` Species: ${species}`);
|
|
168
170
|
console.log(` Dockerfile: ${dockerfile}`);
|
package/src/lib/local.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { execFileSync, execSync, spawn } from "child_process";
|
|
2
2
|
import {
|
|
3
|
-
closeSync,
|
|
4
3
|
existsSync,
|
|
5
4
|
mkdirSync,
|
|
6
5
|
readFileSync,
|
|
@@ -258,16 +257,13 @@ async function startDaemonFromSource(
|
|
|
258
257
|
delete env.QDRANT_URL;
|
|
259
258
|
}
|
|
260
259
|
|
|
261
|
-
|
|
262
|
-
// after the parent (hatch) exits. Bun does not ignore SIGPIPE, so piped
|
|
263
|
-
// stdio would kill the daemon on its first write after the parent closes.
|
|
264
|
-
const logFd = openLogFile("hatch.log");
|
|
260
|
+
const daemonLogFd = openLogFile("hatch.log");
|
|
265
261
|
const child = spawn("bun", ["run", daemonMainPath], {
|
|
266
262
|
detached: true,
|
|
267
|
-
stdio: ["ignore",
|
|
263
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
268
264
|
env,
|
|
269
265
|
});
|
|
270
|
-
|
|
266
|
+
pipeToLogFile(child, daemonLogFd, "daemon");
|
|
271
267
|
child.unref();
|
|
272
268
|
|
|
273
269
|
if (child.pid) {
|
|
@@ -472,7 +468,9 @@ function recoverPidFile(
|
|
|
472
468
|
return pid;
|
|
473
469
|
}
|
|
474
470
|
|
|
475
|
-
export async function discoverPublicUrl(
|
|
471
|
+
export async function discoverPublicUrl(
|
|
472
|
+
port?: number,
|
|
473
|
+
): Promise<string | undefined> {
|
|
476
474
|
const effectivePort = port ?? GATEWAY_PORT;
|
|
477
475
|
const cloud = process.env.VELLUM_CLOUD;
|
|
478
476
|
|
|
@@ -714,18 +712,14 @@ export async function startLocalDaemon(
|
|
|
714
712
|
delete daemonEnv.QDRANT_URL;
|
|
715
713
|
}
|
|
716
714
|
|
|
717
|
-
// Use fd inheritance instead of pipes so the daemon's stdout/stderr
|
|
718
|
-
// survive after the parent (hatch) exits. Bun does not ignore SIGPIPE,
|
|
719
|
-
// so piped stdio would kill the daemon on its first write after the
|
|
720
|
-
// parent closes.
|
|
721
715
|
const daemonLogFd = openLogFile("hatch.log");
|
|
722
716
|
const child = spawn(daemonBinary, [], {
|
|
723
717
|
cwd: dirname(daemonBinary),
|
|
724
718
|
detached: true,
|
|
725
|
-
stdio: ["ignore",
|
|
719
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
726
720
|
env: daemonEnv,
|
|
727
721
|
});
|
|
728
|
-
|
|
722
|
+
pipeToLogFile(child, daemonLogFd, "daemon");
|
|
729
723
|
child.unref();
|
|
730
724
|
const daemonPid = child.pid;
|
|
731
725
|
|
|
@@ -816,6 +810,16 @@ export async function startGateway(
|
|
|
816
810
|
): Promise<string> {
|
|
817
811
|
const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
|
|
818
812
|
|
|
813
|
+
// Kill any existing gateway process before spawning a new one.
|
|
814
|
+
// Without this, crashed/stale gateways accumulate as zombies — the old
|
|
815
|
+
// process holds the port (or lingers after losing it), and every restart
|
|
816
|
+
// attempt spawns yet another process that fails with EADDRINUSE.
|
|
817
|
+
const gwPidDir = resources
|
|
818
|
+
? join(resources.instanceDir, ".vellum")
|
|
819
|
+
: join(homedir(), ".vellum");
|
|
820
|
+
const gwPidFile = join(gwPidDir, "gateway.pid");
|
|
821
|
+
await stopProcessByPidFile(gwPidFile, "gateway");
|
|
822
|
+
|
|
819
823
|
const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
|
|
820
824
|
if (publicUrl) {
|
|
821
825
|
console.log(` Public URL: ${publicUrl}`);
|
|
@@ -952,15 +956,13 @@ export async function startGateway(
|
|
|
952
956
|
);
|
|
953
957
|
}
|
|
954
958
|
|
|
955
|
-
// Use fd inheritance (not pipes) so the gateway survives after the
|
|
956
|
-
// hatch CLI exits — Bun does not ignore SIGPIPE.
|
|
957
959
|
const gatewayLogFd = openLogFile("hatch.log");
|
|
958
960
|
gateway = spawn(gatewayBinary, [], {
|
|
959
961
|
detached: true,
|
|
960
|
-
stdio: ["ignore",
|
|
962
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
961
963
|
env: gatewayEnv,
|
|
962
964
|
});
|
|
963
|
-
|
|
965
|
+
pipeToLogFile(gateway, gatewayLogFd, "gateway");
|
|
964
966
|
} else {
|
|
965
967
|
// Source tree / bunx: resolve the gateway source directory and run via bun.
|
|
966
968
|
const gatewayDir = resolveGatewayDir();
|
|
@@ -971,10 +973,10 @@ export async function startGateway(
|
|
|
971
973
|
gateway = spawn("bun", bunArgs, {
|
|
972
974
|
cwd: gatewayDir,
|
|
973
975
|
detached: true,
|
|
974
|
-
stdio: ["ignore",
|
|
976
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
975
977
|
env: gatewayEnv,
|
|
976
978
|
});
|
|
977
|
-
|
|
979
|
+
pipeToLogFile(gateway, gwLogFd, "gateway");
|
|
978
980
|
if (watch) {
|
|
979
981
|
console.log(" Gateway started in watch mode (bun --watch)");
|
|
980
982
|
}
|
package/src/lib/xdg-log.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import { closeSync, mkdirSync, openSync, writeSync } from "fs";
|
|
2
1
|
import type { ChildProcess } from "child_process";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
writeSync,
|
|
11
|
+
} from "fs";
|
|
3
12
|
import { homedir } from "os";
|
|
4
13
|
import { join } from "path";
|
|
5
14
|
|
|
15
|
+
/** Regex matching pino-pretty's short time prefix, e.g. `[12:07:37.467] `. */
|
|
16
|
+
const PINO_TIME_RE = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/;
|
|
17
|
+
|
|
6
18
|
/** Returns the XDG-compatible log directory for Vellum CLI logs. */
|
|
7
19
|
export function getLogDir(): string {
|
|
8
20
|
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
@@ -23,6 +35,36 @@ export function openLogFile(name: string): number | "ignore" {
|
|
|
23
35
|
}
|
|
24
36
|
}
|
|
25
37
|
|
|
38
|
+
/** Truncate (or create) a log file so each session starts fresh. */
|
|
39
|
+
export function resetLogFile(name: string): void {
|
|
40
|
+
try {
|
|
41
|
+
const dir = getLogDir();
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
writeFileSync(join(dir, name), "");
|
|
44
|
+
} catch {
|
|
45
|
+
/* best-effort */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Copy the current log file into `destDir` with a timestamped name so that
|
|
51
|
+
* previous session logs are preserved for debugging. No-op when the source
|
|
52
|
+
* file is missing or empty.
|
|
53
|
+
*/
|
|
54
|
+
export function archiveLogFile(name: string, destDir: string): void {
|
|
55
|
+
try {
|
|
56
|
+
const srcPath = join(getLogDir(), name);
|
|
57
|
+
if (!existsSync(srcPath) || statSync(srcPath).size === 0) return;
|
|
58
|
+
|
|
59
|
+
mkdirSync(destDir, { recursive: true });
|
|
60
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
61
|
+
const base = name.replace(/\.log$/, "");
|
|
62
|
+
copyFileSync(srcPath, join(destDir, `${base}-${ts}.log`));
|
|
63
|
+
} catch {
|
|
64
|
+
/* best-effort */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
26
68
|
/** Close a file descriptor returned by openLogFile (no-op for "ignore"). */
|
|
27
69
|
export function closeLogFile(fd: number | "ignore"): void {
|
|
28
70
|
if (typeof fd === "number") {
|
|
@@ -46,7 +88,8 @@ export function writeToLogFile(fd: number | "ignore", msg: string): void {
|
|
|
46
88
|
}
|
|
47
89
|
|
|
48
90
|
/** Pipe a child process's stdout/stderr to a shared log file descriptor,
|
|
49
|
-
* prefixing each line with
|
|
91
|
+
* prefixing each line with an ISO timestamp and tag (e.g. "[daemon]").
|
|
92
|
+
* Strips pino-pretty's redundant short time prefix when present.
|
|
50
93
|
* Streams are unref'd so they don't prevent the parent from exiting.
|
|
51
94
|
* The fd is closed automatically when both streams end. */
|
|
52
95
|
export function pipeToLogFile(
|
|
@@ -80,9 +123,10 @@ export function pipeToLogFile(
|
|
|
80
123
|
for (let i = 0; i < lines.length; i++) {
|
|
81
124
|
if (i === lines.length - 1 && lines[i] === "") break;
|
|
82
125
|
const nl = i < lines.length - 1 ? "\n" : "";
|
|
126
|
+
const stripped = lines[i].replace(PINO_TIME_RE, "");
|
|
83
127
|
const prefix = `${new Date().toISOString()} ${tagLabel} `;
|
|
84
128
|
try {
|
|
85
|
-
writeSync(numFd, prefix +
|
|
129
|
+
writeSync(numFd, prefix + stripped + nl);
|
|
86
130
|
} catch {
|
|
87
131
|
/* best-effort */
|
|
88
132
|
}
|