@vellumai/cli 0.6.6 → 0.7.0
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/AGENTS.md +8 -2
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +1 -7
- package/src/__tests__/config-utils.test.ts +159 -0
- package/src/__tests__/env-drift.test.ts +10 -32
- package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
- package/src/__tests__/multi-local.test.ts +0 -5
- package/src/__tests__/sleep.test.ts +1 -2
- package/src/__tests__/teleport.test.ts +919 -1255
- package/src/commands/env.ts +93 -0
- package/src/commands/events.ts +2 -0
- package/src/commands/exec.ts +40 -8
- package/src/commands/hatch.ts +6 -2
- package/src/commands/login.ts +89 -6
- package/src/commands/ps.ts +104 -20
- package/src/commands/sleep.ts +5 -2
- package/src/commands/ssh.ts +15 -2
- package/src/commands/teleport.ts +447 -583
- package/src/commands/terminal.ts +9 -221
- package/src/commands/wake.ts +2 -1
- package/src/components/DefaultMainScreen.tsx +304 -152
- package/src/index.ts +3 -0
- package/src/lib/__tests__/docker.test.ts +50 -74
- package/src/lib/__tests__/job-polling.test.ts +278 -0
- package/src/lib/__tests__/local-runtime-client.test.ts +383 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
- package/src/lib/assistant-config.ts +12 -8
- package/src/lib/client-identity.ts +67 -0
- package/src/lib/config-utils.ts +97 -1
- package/src/lib/docker.ts +73 -75
- package/src/lib/environments/__tests__/paths.test.ts +2 -0
- package/src/lib/environments/resolve.ts +89 -7
- package/src/lib/environments/seeds.ts +8 -5
- package/src/lib/environments/types.ts +10 -0
- package/src/lib/hatch-local.ts +15 -120
- package/src/lib/health-check.ts +98 -0
- package/src/lib/job-polling.ts +195 -0
- package/src/lib/local-runtime-client.ts +178 -0
- package/src/lib/local.ts +139 -15
- package/src/lib/orphan-detection.ts +2 -35
- package/src/lib/platform-client.ts +215 -0
- package/src/lib/retire-local.ts +6 -2
- package/src/lib/terminal-session.ts +457 -0
- package/src/shared/provider-env-vars.ts +2 -3
- package/src/__tests__/orphan-detection.test.ts +0 -214
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
platformPollJobStatus,
|
|
5
|
+
platformRequestSignedUrl,
|
|
6
|
+
type UnifiedJobStatus,
|
|
7
|
+
} from "../platform-client.js";
|
|
8
|
+
|
|
9
|
+
const PLATFORM_URL = "https://platform.example.test";
|
|
10
|
+
const VAK_TOKEN = "vak_test_1234567890"; // API-key path skips org-ID fetch.
|
|
11
|
+
const SESSION_TOKEN = "session_test_1234567890"; // non-vak_ triggers org-ID lookup.
|
|
12
|
+
|
|
13
|
+
interface CapturedCall {
|
|
14
|
+
url: string;
|
|
15
|
+
method: string;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
body: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function captureFetch(
|
|
21
|
+
responder: (call: CapturedCall) => Response | Promise<Response>,
|
|
22
|
+
): {
|
|
23
|
+
calls: CapturedCall[];
|
|
24
|
+
fetchMock: typeof globalThis.fetch;
|
|
25
|
+
} {
|
|
26
|
+
const calls: CapturedCall[] = [];
|
|
27
|
+
const fetchMock = mock(
|
|
28
|
+
async (url: string | URL | Request, init?: RequestInit) => {
|
|
29
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
30
|
+
const rawHeaders = (init?.headers ?? {}) as
|
|
31
|
+
| Record<string, string>
|
|
32
|
+
| Headers;
|
|
33
|
+
const headers: Record<string, string> = {};
|
|
34
|
+
if (rawHeaders instanceof Headers) {
|
|
35
|
+
rawHeaders.forEach((v, k) => {
|
|
36
|
+
headers[k] = v;
|
|
37
|
+
});
|
|
38
|
+
} else {
|
|
39
|
+
Object.assign(headers, rawHeaders);
|
|
40
|
+
}
|
|
41
|
+
let parsedBody: unknown = undefined;
|
|
42
|
+
const b = init?.body;
|
|
43
|
+
if (typeof b === "string") {
|
|
44
|
+
try {
|
|
45
|
+
parsedBody = JSON.parse(b);
|
|
46
|
+
} catch {
|
|
47
|
+
parsedBody = b;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const call: CapturedCall = {
|
|
51
|
+
url: urlStr,
|
|
52
|
+
method: init?.method ?? "GET",
|
|
53
|
+
headers,
|
|
54
|
+
body: parsedBody,
|
|
55
|
+
};
|
|
56
|
+
calls.push(call);
|
|
57
|
+
return responder(call);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
return { calls, fetchMock: fetchMock as unknown as typeof globalThis.fetch };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let originalFetch: typeof globalThis.fetch;
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
originalFetch = globalThis.fetch;
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
globalThis.fetch = originalFetch;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("platformRequestSignedUrl", () => {
|
|
72
|
+
test("upload operation with just operation → posts correct body and parses response", async () => {
|
|
73
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
74
|
+
return new Response(
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
url: "https://storage.example/signed/abc",
|
|
77
|
+
bundle_key: "bundles/abc.tar.gz",
|
|
78
|
+
expires_at: "2026-04-22T00:00:00Z",
|
|
79
|
+
}),
|
|
80
|
+
{ status: 201, headers: { "Content-Type": "application/json" } },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
globalThis.fetch = fetchMock;
|
|
84
|
+
|
|
85
|
+
const result = await platformRequestSignedUrl(
|
|
86
|
+
{ operation: "upload" },
|
|
87
|
+
VAK_TOKEN,
|
|
88
|
+
PLATFORM_URL,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual({
|
|
92
|
+
url: "https://storage.example/signed/abc",
|
|
93
|
+
bundleKey: "bundles/abc.tar.gz",
|
|
94
|
+
expiresAt: "2026-04-22T00:00:00Z",
|
|
95
|
+
maxContentLength: undefined,
|
|
96
|
+
});
|
|
97
|
+
expect(calls).toHaveLength(1);
|
|
98
|
+
expect(calls[0]!.url).toBe(`${PLATFORM_URL}/v1/migrations/signed-url/`);
|
|
99
|
+
expect(calls[0]!.method).toBe("POST");
|
|
100
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
|
|
101
|
+
expect(calls[0]!.headers["Content-Type"]).toBe("application/json");
|
|
102
|
+
expect(calls[0]!.body).toEqual({ operation: "upload" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("upload operation with content_length + content_type passes them through", async () => {
|
|
106
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
107
|
+
return new Response(
|
|
108
|
+
JSON.stringify({
|
|
109
|
+
url: "https://storage.example/signed/xyz",
|
|
110
|
+
bundle_key: "bundles/xyz.tar.gz",
|
|
111
|
+
expires_at: "2026-04-22T01:00:00Z",
|
|
112
|
+
max_content_length: 10_000_000,
|
|
113
|
+
}),
|
|
114
|
+
{ status: 201 },
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
globalThis.fetch = fetchMock;
|
|
118
|
+
|
|
119
|
+
const result = await platformRequestSignedUrl(
|
|
120
|
+
{
|
|
121
|
+
operation: "upload",
|
|
122
|
+
contentType: "application/octet-stream",
|
|
123
|
+
contentLength: 12345,
|
|
124
|
+
},
|
|
125
|
+
VAK_TOKEN,
|
|
126
|
+
PLATFORM_URL,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(result.maxContentLength).toBe(10_000_000);
|
|
130
|
+
expect(calls[0]!.body).toEqual({
|
|
131
|
+
operation: "upload",
|
|
132
|
+
content_type: "application/octet-stream",
|
|
133
|
+
content_length: 12345,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("download operation with bundleKey → posts bundle_key and parses response", async () => {
|
|
138
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
139
|
+
return new Response(
|
|
140
|
+
JSON.stringify({
|
|
141
|
+
url: "https://storage.example/signed/dl-xyz",
|
|
142
|
+
bundle_key: "bundles/xyz.tar.gz",
|
|
143
|
+
expires_at: "2026-04-22T02:00:00Z",
|
|
144
|
+
}),
|
|
145
|
+
{ status: 201 },
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
globalThis.fetch = fetchMock;
|
|
149
|
+
|
|
150
|
+
const result = await platformRequestSignedUrl(
|
|
151
|
+
{ operation: "download", bundleKey: "bundles/xyz.tar.gz" },
|
|
152
|
+
VAK_TOKEN,
|
|
153
|
+
PLATFORM_URL,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(result.url).toBe("https://storage.example/signed/dl-xyz");
|
|
157
|
+
expect(result.bundleKey).toBe("bundles/xyz.tar.gz");
|
|
158
|
+
expect(calls[0]!.body).toEqual({
|
|
159
|
+
operation: "download",
|
|
160
|
+
bundle_key: "bundles/xyz.tar.gz",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("401 → retries once and returns success on the retry", async () => {
|
|
165
|
+
let callCount = 0;
|
|
166
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
167
|
+
callCount += 1;
|
|
168
|
+
if (callCount === 1) {
|
|
169
|
+
return new Response(JSON.stringify({ detail: "unauthorized" }), {
|
|
170
|
+
status: 401,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return new Response(
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
url: "https://storage.example/signed/after-retry",
|
|
176
|
+
bundle_key: "bundles/r.tar.gz",
|
|
177
|
+
expires_at: "2026-04-22T03:00:00Z",
|
|
178
|
+
}),
|
|
179
|
+
{ status: 201 },
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
globalThis.fetch = fetchMock;
|
|
183
|
+
|
|
184
|
+
const result = await platformRequestSignedUrl(
|
|
185
|
+
{ operation: "upload" },
|
|
186
|
+
VAK_TOKEN,
|
|
187
|
+
PLATFORM_URL,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(result.url).toBe("https://storage.example/signed/after-retry");
|
|
191
|
+
expect(calls).toHaveLength(2);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("401 with session token → invalidates org-ID cache and re-fetches on retry", async () => {
|
|
195
|
+
// Session tokens (non-vak_) take the org-ID-fetch path. A 401 here
|
|
196
|
+
// frequently means the cached org ID is stale, so the retry must
|
|
197
|
+
// clear the cache and re-fetch before the second signed-url POST.
|
|
198
|
+
const orgIdCalls: string[] = [];
|
|
199
|
+
const signedUrlCalls: CapturedCall[] = [];
|
|
200
|
+
let orgIdFetchCount = 0;
|
|
201
|
+
let signedUrlCount = 0;
|
|
202
|
+
|
|
203
|
+
const fetchMock = mock(
|
|
204
|
+
async (url: string | URL | Request, init?: RequestInit) => {
|
|
205
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
206
|
+
const rawHeaders = (init?.headers ?? {}) as
|
|
207
|
+
| Record<string, string>
|
|
208
|
+
| Headers;
|
|
209
|
+
const headers: Record<string, string> = {};
|
|
210
|
+
if (rawHeaders instanceof Headers) {
|
|
211
|
+
rawHeaders.forEach((v, k) => {
|
|
212
|
+
headers[k] = v;
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
Object.assign(headers, rawHeaders);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (urlStr.endsWith("/v1/organizations/")) {
|
|
219
|
+
orgIdFetchCount += 1;
|
|
220
|
+
orgIdCalls.push(urlStr);
|
|
221
|
+
const orgId = orgIdFetchCount === 1 ? "org-stale" : "org-fresh";
|
|
222
|
+
return new Response(
|
|
223
|
+
JSON.stringify({ results: [{ id: orgId, name: "Test Org" }] }),
|
|
224
|
+
{ status: 200 },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (urlStr.endsWith("/v1/migrations/signed-url/")) {
|
|
229
|
+
signedUrlCount += 1;
|
|
230
|
+
let parsedBody: unknown = undefined;
|
|
231
|
+
const b = init?.body;
|
|
232
|
+
if (typeof b === "string") {
|
|
233
|
+
try {
|
|
234
|
+
parsedBody = JSON.parse(b);
|
|
235
|
+
} catch {
|
|
236
|
+
parsedBody = b;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
signedUrlCalls.push({
|
|
240
|
+
url: urlStr,
|
|
241
|
+
method: init?.method ?? "GET",
|
|
242
|
+
headers,
|
|
243
|
+
body: parsedBody,
|
|
244
|
+
});
|
|
245
|
+
if (signedUrlCount === 1) {
|
|
246
|
+
return new Response(JSON.stringify({ detail: "stale org" }), {
|
|
247
|
+
status: 401,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return new Response(
|
|
251
|
+
JSON.stringify({
|
|
252
|
+
url: "https://storage.example/signed/fresh",
|
|
253
|
+
bundle_key: "bundles/fresh.tar.gz",
|
|
254
|
+
expires_at: "2026-04-22T04:00:00Z",
|
|
255
|
+
}),
|
|
256
|
+
{ status: 201 },
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
throw new Error(`Unexpected URL: ${urlStr}`);
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
264
|
+
|
|
265
|
+
const result = await platformRequestSignedUrl(
|
|
266
|
+
{ operation: "upload" },
|
|
267
|
+
SESSION_TOKEN,
|
|
268
|
+
PLATFORM_URL,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// Both signed-url attempts were made and the retry succeeded.
|
|
272
|
+
expect(result.url).toBe("https://storage.example/signed/fresh");
|
|
273
|
+
expect(signedUrlCalls).toHaveLength(2);
|
|
274
|
+
|
|
275
|
+
// The cache was cleared after the 401, so the org-ID endpoint was
|
|
276
|
+
// hit a second time to fetch a fresh ID before the retry.
|
|
277
|
+
expect(orgIdFetchCount).toBe(2);
|
|
278
|
+
expect(orgIdCalls).toHaveLength(2);
|
|
279
|
+
|
|
280
|
+
// The first signed-url POST used the stale org ID, the second used
|
|
281
|
+
// the fresh one.
|
|
282
|
+
expect(signedUrlCalls[0]!.headers["Vellum-Organization-Id"]).toBe(
|
|
283
|
+
"org-stale",
|
|
284
|
+
);
|
|
285
|
+
expect(signedUrlCalls[1]!.headers["Vellum-Organization-Id"]).toBe(
|
|
286
|
+
"org-fresh",
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Session tokens use X-Session-Token, not Bearer.
|
|
290
|
+
expect(signedUrlCalls[0]!.headers["X-Session-Token"]).toBe(SESSION_TOKEN);
|
|
291
|
+
expect(signedUrlCalls[0]!.headers.Authorization).toBeUndefined();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("503 → throws so callers can fall back to legacy inline upload", async () => {
|
|
295
|
+
const { fetchMock } = captureFetch(() => {
|
|
296
|
+
return new Response(JSON.stringify({ detail: "temporarily down" }), {
|
|
297
|
+
status: 503,
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
globalThis.fetch = fetchMock;
|
|
301
|
+
|
|
302
|
+
await expect(
|
|
303
|
+
platformRequestSignedUrl(
|
|
304
|
+
{ operation: "upload" },
|
|
305
|
+
VAK_TOKEN,
|
|
306
|
+
PLATFORM_URL,
|
|
307
|
+
),
|
|
308
|
+
).rejects.toThrow(/503/);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("platformPollJobStatus", () => {
|
|
313
|
+
test("GET /v1/migrations/jobs/{jobId}/ parses processing", async () => {
|
|
314
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
315
|
+
return new Response(
|
|
316
|
+
JSON.stringify({
|
|
317
|
+
job_id: "job-1",
|
|
318
|
+
type: "export",
|
|
319
|
+
status: "processing",
|
|
320
|
+
}),
|
|
321
|
+
{ status: 200 },
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
globalThis.fetch = fetchMock;
|
|
325
|
+
|
|
326
|
+
const status = await platformPollJobStatus(
|
|
327
|
+
"job-1",
|
|
328
|
+
VAK_TOKEN,
|
|
329
|
+
PLATFORM_URL,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(status).toEqual({
|
|
333
|
+
jobId: "job-1",
|
|
334
|
+
type: "export",
|
|
335
|
+
status: "processing",
|
|
336
|
+
} satisfies UnifiedJobStatus);
|
|
337
|
+
expect(calls[0]!.url).toBe(`${PLATFORM_URL}/v1/migrations/jobs/job-1/`);
|
|
338
|
+
expect(calls[0]!.method).toBe("GET");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("parses complete with bundle_key + result", async () => {
|
|
342
|
+
const { fetchMock } = captureFetch(() => {
|
|
343
|
+
return new Response(
|
|
344
|
+
JSON.stringify({
|
|
345
|
+
job_id: "job-2",
|
|
346
|
+
type: "export",
|
|
347
|
+
status: "complete",
|
|
348
|
+
bundle_key: "bundles/done.tar.gz",
|
|
349
|
+
result: { files: 42 },
|
|
350
|
+
}),
|
|
351
|
+
{ status: 200 },
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
globalThis.fetch = fetchMock;
|
|
355
|
+
|
|
356
|
+
const status = await platformPollJobStatus(
|
|
357
|
+
"job-2",
|
|
358
|
+
VAK_TOKEN,
|
|
359
|
+
PLATFORM_URL,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
expect(status.status).toBe("complete");
|
|
363
|
+
if (status.status === "complete") {
|
|
364
|
+
expect(status.bundleKey).toBe("bundles/done.tar.gz");
|
|
365
|
+
expect(status.result).toEqual({ files: 42 });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("parses failed with error", async () => {
|
|
370
|
+
const { fetchMock } = captureFetch(() => {
|
|
371
|
+
return new Response(
|
|
372
|
+
JSON.stringify({
|
|
373
|
+
job_id: "job-3",
|
|
374
|
+
type: "import",
|
|
375
|
+
status: "failed",
|
|
376
|
+
error: "bundle corrupt",
|
|
377
|
+
}),
|
|
378
|
+
{ status: 200 },
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
globalThis.fetch = fetchMock;
|
|
382
|
+
|
|
383
|
+
const status = await platformPollJobStatus(
|
|
384
|
+
"job-3",
|
|
385
|
+
VAK_TOKEN,
|
|
386
|
+
PLATFORM_URL,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
expect(status.status).toBe("failed");
|
|
390
|
+
if (status.status === "failed") {
|
|
391
|
+
expect(status.error).toBe("bundle corrupt");
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("404 → throws 'Migration job not found'", async () => {
|
|
396
|
+
const { fetchMock } = captureFetch(() => {
|
|
397
|
+
return new Response("{}", { status: 404 });
|
|
398
|
+
});
|
|
399
|
+
globalThis.fetch = fetchMock;
|
|
400
|
+
|
|
401
|
+
await expect(
|
|
402
|
+
platformPollJobStatus("missing", VAK_TOKEN, PLATFORM_URL),
|
|
403
|
+
).rejects.toThrow(/Migration job not found/);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
@@ -42,8 +42,6 @@ export interface LocalInstanceResources {
|
|
|
42
42
|
qdrantPort: number;
|
|
43
43
|
/** HTTP port for the CES (Claude Extension Server) */
|
|
44
44
|
cesPort: number;
|
|
45
|
-
/** Absolute path to the daemon PID file */
|
|
46
|
-
pidFile: string;
|
|
47
45
|
/** Persisted HMAC signing key (hex). Survives daemon/gateway restarts so
|
|
48
46
|
* client actor tokens remain valid across `wake` cycles. */
|
|
49
47
|
signingKey?: string;
|
|
@@ -114,6 +112,18 @@ export function getBaseDir(): string {
|
|
|
114
112
|
return process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
115
113
|
}
|
|
116
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Derive the daemon PID file path from a resources object. The PID file
|
|
117
|
+
* lives inside the instance's workspace directory. When no resources are
|
|
118
|
+
* available, falls back to `~/.vellum/workspace/vellum.pid`.
|
|
119
|
+
*/
|
|
120
|
+
export function getDaemonPidPath(resources?: LocalInstanceResources): string {
|
|
121
|
+
const vellumDir = resources
|
|
122
|
+
? join(resources.instanceDir, ".vellum")
|
|
123
|
+
: join(homedir(), ".vellum");
|
|
124
|
+
return join(vellumDir, "workspace", "vellum.pid");
|
|
125
|
+
}
|
|
126
|
+
|
|
117
127
|
function readLockfile(): LockfileData {
|
|
118
128
|
for (const lockfilePath of getLockfilePaths(getCurrentEnvironment())) {
|
|
119
129
|
if (!existsSync(lockfilePath)) continue;
|
|
@@ -215,7 +225,6 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
215
225
|
gatewayPort,
|
|
216
226
|
qdrantPort: defaultPorts.qdrant,
|
|
217
227
|
cesPort: defaultPorts.ces,
|
|
218
|
-
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
219
228
|
};
|
|
220
229
|
mutated = true;
|
|
221
230
|
} else {
|
|
@@ -247,10 +256,6 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
247
256
|
res.cesPort = defaultPorts.ces;
|
|
248
257
|
mutated = true;
|
|
249
258
|
}
|
|
250
|
-
if (typeof res.pidFile !== "string") {
|
|
251
|
-
res.pidFile = join(res.instanceDir as string, ".vellum", "vellum.pid");
|
|
252
|
-
mutated = true;
|
|
253
|
-
}
|
|
254
259
|
}
|
|
255
260
|
|
|
256
261
|
return mutated;
|
|
@@ -474,7 +479,6 @@ export async function allocateLocalResources(
|
|
|
474
479
|
gatewayPort,
|
|
475
480
|
qdrantPort,
|
|
476
481
|
cesPort,
|
|
477
|
-
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
478
482
|
};
|
|
479
483
|
}
|
|
480
484
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable per-install client identity for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Generates a UUID on first use and persists it to
|
|
5
|
+
* `~/.config/vellum/client-id` so the daemon's ClientRegistry can
|
|
6
|
+
* track this terminal across SSE reconnects and CLI restarts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
10
|
+
import { randomUUID } from "crypto";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
|
|
14
|
+
const CLI_INTERFACE_ID = "cli";
|
|
15
|
+
|
|
16
|
+
let cached: string | null = null;
|
|
17
|
+
|
|
18
|
+
function getConfigDir(): string {
|
|
19
|
+
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
20
|
+
return join(configHome, "vellum");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns a stable UUID identifying this CLI installation.
|
|
25
|
+
* Generated once and persisted to `~/.config/vellum/client-id`.
|
|
26
|
+
*/
|
|
27
|
+
export function getClientId(): string {
|
|
28
|
+
if (cached) return cached;
|
|
29
|
+
|
|
30
|
+
const configDir = getConfigDir();
|
|
31
|
+
const idFile = join(configDir, "client-id");
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
if (existsSync(idFile)) {
|
|
35
|
+
const stored = readFileSync(idFile, "utf-8").trim();
|
|
36
|
+
if (stored) {
|
|
37
|
+
cached = stored;
|
|
38
|
+
return stored;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
/* best-effort read */
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const id = randomUUID();
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(configDir, { recursive: true });
|
|
48
|
+
writeFileSync(idFile, id, "utf-8");
|
|
49
|
+
} catch {
|
|
50
|
+
/* best-effort persist — transient id still works for this session */
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cached = id;
|
|
54
|
+
return id;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Headers that identify this CLI client to the assistant daemon.
|
|
59
|
+
* Attach to SSE streaming connections so the ClientRegistry can
|
|
60
|
+
* track connected clients and their capabilities.
|
|
61
|
+
*/
|
|
62
|
+
export function getClientRegistrationHeaders(): Record<string, string> {
|
|
63
|
+
return {
|
|
64
|
+
"X-Vellum-Client-Id": getClientId(),
|
|
65
|
+
"X-Vellum-Interface-Id": CLI_INTERFACE_ID,
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/lib/config-utils.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { writeFileSync } from "fs";
|
|
|
2
2
|
import { tmpdir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
|
|
5
|
+
const ANTHROPIC_PROVIDER = "anthropic";
|
|
6
|
+
const ANTHROPIC_DEFAULT_MODEL = "claude-opus-4-7";
|
|
7
|
+
const MAIN_AGENT_OPUS_MODEL = "claude-opus-4-7";
|
|
8
|
+
const MAIN_AGENT_OPUS_MAX_TOKENS = 32000;
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* Convert flat dot-notation key=value pairs into a nested config object.
|
|
7
12
|
*
|
|
@@ -32,6 +37,20 @@ export function buildNestedConfig(
|
|
|
32
37
|
return config;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Build the first-boot workspace config overlay passed to the assistant during
|
|
42
|
+
* hatch. Anthropic onboarding sets `llm.default.model` to Sonnet so background
|
|
43
|
+
* fallback work stays cheaper, while the main conversation thread should remain
|
|
44
|
+
* on Opus via the same call-site override seeded by workspace migration 050.
|
|
45
|
+
*/
|
|
46
|
+
export function buildInitialConfig(
|
|
47
|
+
configValues: Record<string, string>,
|
|
48
|
+
): Record<string, unknown> {
|
|
49
|
+
const config = buildNestedConfig(configValues);
|
|
50
|
+
seedAnthropicMainAgentCallSite(config);
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
/**
|
|
36
55
|
* Write arbitrary key-value pairs to a temporary JSON file and return its
|
|
37
56
|
* path. The caller passes this path to the daemon via the
|
|
@@ -49,7 +68,7 @@ export function writeInitialConfig(
|
|
|
49
68
|
): string | undefined {
|
|
50
69
|
if (Object.keys(configValues).length === 0) return undefined;
|
|
51
70
|
|
|
52
|
-
const config =
|
|
71
|
+
const config = buildInitialConfig(configValues);
|
|
53
72
|
const tempPath = join(
|
|
54
73
|
tmpdir(),
|
|
55
74
|
`vellum-default-workspace-config-${process.pid}-${Date.now()}.json`,
|
|
@@ -57,3 +76,80 @@ export function writeInitialConfig(
|
|
|
57
76
|
writeFileSync(tempPath, JSON.stringify(config, null, 2) + "\n");
|
|
58
77
|
return tempPath;
|
|
59
78
|
}
|
|
79
|
+
|
|
80
|
+
function seedAnthropicMainAgentCallSite(config: Record<string, unknown>): void {
|
|
81
|
+
const llm = ensureObject(config, "llm");
|
|
82
|
+
|
|
83
|
+
const existingCallSites = readObject(llm.callSites);
|
|
84
|
+
if (existingCallSites !== null && "mainAgent" in existingCallSites) return;
|
|
85
|
+
|
|
86
|
+
const { provider, model } = resolveInitialMainAgentBaseSelection(llm);
|
|
87
|
+
if (provider !== ANTHROPIC_PROVIDER) return;
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
model !== undefined &&
|
|
91
|
+
model !== ANTHROPIC_DEFAULT_MODEL &&
|
|
92
|
+
model !== MAIN_AGENT_OPUS_MODEL
|
|
93
|
+
) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const callSites = ensureObject(llm, "callSites");
|
|
98
|
+
|
|
99
|
+
callSites.mainAgent = {
|
|
100
|
+
model: MAIN_AGENT_OPUS_MODEL,
|
|
101
|
+
maxTokens: MAIN_AGENT_OPUS_MAX_TOKENS,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveInitialMainAgentBaseSelection(llm: Record<string, unknown>): {
|
|
106
|
+
provider: string;
|
|
107
|
+
model?: string;
|
|
108
|
+
} {
|
|
109
|
+
const defaultBlock = readObject(llm.default);
|
|
110
|
+
let provider = readString(defaultBlock?.provider) ?? ANTHROPIC_PROVIDER;
|
|
111
|
+
let model = readString(defaultBlock?.model);
|
|
112
|
+
|
|
113
|
+
const profiles = readObject(llm.profiles);
|
|
114
|
+
const activeProfileName = readString(llm.activeProfile);
|
|
115
|
+
const activeProfile =
|
|
116
|
+
profiles !== null && activeProfileName !== undefined
|
|
117
|
+
? readObject(profiles[activeProfileName])
|
|
118
|
+
: null;
|
|
119
|
+
|
|
120
|
+
if (activeProfile !== null) {
|
|
121
|
+
provider = readString(activeProfile.provider) ?? provider;
|
|
122
|
+
model = readString(activeProfile.model) ?? model;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return model === undefined ? { provider } : { provider, model };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ensureObject(
|
|
129
|
+
parent: Record<string, unknown>,
|
|
130
|
+
key: string,
|
|
131
|
+
): Record<string, unknown> {
|
|
132
|
+
const existing = parent[key];
|
|
133
|
+
if (
|
|
134
|
+
existing != null &&
|
|
135
|
+
typeof existing === "object" &&
|
|
136
|
+
!Array.isArray(existing)
|
|
137
|
+
) {
|
|
138
|
+
return existing as Record<string, unknown>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const next: Record<string, unknown> = {};
|
|
142
|
+
parent[key] = next;
|
|
143
|
+
return next;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readObject(value: unknown): Record<string, unknown> | null {
|
|
147
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return value as Record<string, unknown>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readString(value: unknown): string | undefined {
|
|
154
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
155
|
+
}
|