@vellumai/cli 0.7.0 → 0.7.2
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 +3 -11
- package/README.md +49 -0
- package/bun.lock +0 -15
- package/package.json +1 -6
- package/src/__tests__/backup.test.ts +591 -0
- package/src/__tests__/config-utils.test.ts +35 -48
- package/src/__tests__/teleport.test.ts +597 -37
- package/src/commands/backup.ts +149 -70
- package/src/commands/client.ts +56 -14
- package/src/commands/events.ts +3 -0
- package/src/commands/exec.ts +34 -12
- package/src/commands/hatch.ts +3 -7
- package/src/commands/login.ts +15 -33
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +41 -6
- package/src/commands/restore.ts +32 -47
- package/src/commands/setup.ts +38 -73
- package/src/commands/ssh.ts +2 -5
- package/src/commands/teleport.ts +148 -34
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +114 -7
- package/src/commands/wake.ts +5 -16
- package/src/components/DefaultMainScreen.tsx +65 -129
- package/src/index.ts +2 -13
- package/src/lib/__tests__/docker.test.ts +50 -32
- package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
- package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
- package/src/lib/__tests__/runtime-url.test.ts +125 -0
- package/src/lib/__tests__/terminal-session.test.ts +202 -0
- package/src/lib/assistant-client.ts +18 -26
- package/src/lib/assistant-config.ts +34 -41
- package/src/lib/backup-ops.ts +43 -17
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +1 -1
- package/src/lib/config-utils.ts +1 -97
- package/src/lib/docker-statefulset.ts +381 -0
- package/src/lib/docker.ts +8 -247
- package/src/lib/guardian-token.ts +56 -6
- package/src/lib/hatch-local.ts +3 -26
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local-runtime-client.ts +162 -28
- package/src/lib/local.ts +35 -64
- package/src/lib/ngrok.ts +36 -26
- package/src/lib/platform-client.ts +97 -221
- package/src/lib/platform-releases.ts +23 -0
- package/src/lib/retire-local.ts +2 -2
- package/src/lib/runtime-url.ts +52 -0
- package/src/lib/sync-cloud-assistants.ts +126 -0
- package/src/lib/terminal-client.ts +6 -1
- package/src/lib/terminal-session.ts +127 -48
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/upgrade-lifecycle.ts +65 -0
- package/src/lib/xdg-log.ts +10 -4
- package/src/commands/pair.ts +0 -212
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import type { AssistantEntry } from "../assistant-config.js";
|
|
3
4
|
import {
|
|
4
5
|
MigrationInProgressError,
|
|
5
6
|
localRuntimeExportToGcs,
|
|
7
|
+
localRuntimeIdentity,
|
|
6
8
|
localRuntimeImportFromGcs,
|
|
7
9
|
localRuntimePollJobStatus,
|
|
8
10
|
} from "../local-runtime-client.js";
|
|
@@ -10,6 +12,17 @@ import {
|
|
|
10
12
|
const RUNTIME_URL = "http://127.0.0.1:8765";
|
|
11
13
|
const TOKEN = "local-bearer-token";
|
|
12
14
|
|
|
15
|
+
// All tests in this file exercise the local/docker code path (cloud="local"),
|
|
16
|
+
// which builds `{runtimeUrl}/v1/migrations/<subpath>` URLs and uses
|
|
17
|
+
// guardian-token bearer auth. The platform path (cloud="vellum") is covered
|
|
18
|
+
// by `runtime-url.test.ts` (URL construction) and the teleport tests
|
|
19
|
+
// (call-site wiring).
|
|
20
|
+
const ENTRY: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId"> = {
|
|
21
|
+
cloud: "local",
|
|
22
|
+
runtimeUrl: RUNTIME_URL,
|
|
23
|
+
assistantId: "ast-test-1",
|
|
24
|
+
};
|
|
25
|
+
|
|
13
26
|
interface CapturedCall {
|
|
14
27
|
url: string;
|
|
15
28
|
method: string;
|
|
@@ -82,7 +95,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
82
95
|
});
|
|
83
96
|
globalThis.fetch = fetchMock;
|
|
84
97
|
|
|
85
|
-
const result = await localRuntimeExportToGcs(
|
|
98
|
+
const result = await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
86
99
|
uploadUrl: "https://storage.example/signed/abc",
|
|
87
100
|
description: "teleport export",
|
|
88
101
|
});
|
|
@@ -108,7 +121,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
108
121
|
});
|
|
109
122
|
globalThis.fetch = fetchMock;
|
|
110
123
|
|
|
111
|
-
await localRuntimeExportToGcs(
|
|
124
|
+
await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
112
125
|
uploadUrl: "https://storage.example/signed/abc",
|
|
113
126
|
});
|
|
114
127
|
|
|
@@ -132,7 +145,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
132
145
|
globalThis.fetch = fetchMock;
|
|
133
146
|
|
|
134
147
|
try {
|
|
135
|
-
await localRuntimeExportToGcs(
|
|
148
|
+
await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
136
149
|
uploadUrl: "https://storage.example/signed/abc",
|
|
137
150
|
});
|
|
138
151
|
throw new Error("expected to throw");
|
|
@@ -156,7 +169,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
156
169
|
globalThis.fetch = fetchMock;
|
|
157
170
|
|
|
158
171
|
try {
|
|
159
|
-
await localRuntimeExportToGcs(
|
|
172
|
+
await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
160
173
|
uploadUrl: "https://storage.example/signed/abc",
|
|
161
174
|
});
|
|
162
175
|
throw new Error("expected to throw");
|
|
@@ -182,7 +195,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
182
195
|
globalThis.fetch = fetchMock;
|
|
183
196
|
|
|
184
197
|
try {
|
|
185
|
-
await localRuntimeExportToGcs(
|
|
198
|
+
await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
186
199
|
uploadUrl: "https://storage.example/signed/abc",
|
|
187
200
|
});
|
|
188
201
|
throw new Error("expected to throw");
|
|
@@ -201,7 +214,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
201
214
|
globalThis.fetch = fetchMock;
|
|
202
215
|
|
|
203
216
|
await expect(
|
|
204
|
-
localRuntimeExportToGcs(
|
|
217
|
+
localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
205
218
|
uploadUrl: "https://storage.example/signed/abc",
|
|
206
219
|
}),
|
|
207
220
|
).rejects.toThrow(/500/);
|
|
@@ -222,7 +235,7 @@ describe("localRuntimeImportFromGcs", () => {
|
|
|
222
235
|
});
|
|
223
236
|
globalThis.fetch = fetchMock;
|
|
224
237
|
|
|
225
|
-
const result = await localRuntimeImportFromGcs(
|
|
238
|
+
const result = await localRuntimeImportFromGcs(ENTRY, TOKEN, {
|
|
226
239
|
bundleUrl: "https://storage.example/signed/dl-xyz",
|
|
227
240
|
});
|
|
228
241
|
|
|
@@ -250,7 +263,7 @@ describe("localRuntimeImportFromGcs", () => {
|
|
|
250
263
|
globalThis.fetch = fetchMock;
|
|
251
264
|
|
|
252
265
|
try {
|
|
253
|
-
await localRuntimeImportFromGcs(
|
|
266
|
+
await localRuntimeImportFromGcs(ENTRY, TOKEN, {
|
|
254
267
|
bundleUrl: "https://storage.example/signed/dl-xyz",
|
|
255
268
|
});
|
|
256
269
|
throw new Error("expected to throw");
|
|
@@ -275,7 +288,7 @@ describe("localRuntimeImportFromGcs", () => {
|
|
|
275
288
|
globalThis.fetch = fetchMock;
|
|
276
289
|
|
|
277
290
|
try {
|
|
278
|
-
await localRuntimeImportFromGcs(
|
|
291
|
+
await localRuntimeImportFromGcs(ENTRY, TOKEN, {
|
|
279
292
|
bundleUrl: "https://storage.example/signed/dl-xyz",
|
|
280
293
|
});
|
|
281
294
|
throw new Error("expected to throw");
|
|
@@ -302,11 +315,7 @@ describe("localRuntimePollJobStatus", () => {
|
|
|
302
315
|
});
|
|
303
316
|
globalThis.fetch = fetchMock;
|
|
304
317
|
|
|
305
|
-
const status = await localRuntimePollJobStatus(
|
|
306
|
-
RUNTIME_URL,
|
|
307
|
-
TOKEN,
|
|
308
|
-
"poll-1",
|
|
309
|
-
);
|
|
318
|
+
const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-1");
|
|
310
319
|
|
|
311
320
|
expect(status).toEqual({
|
|
312
321
|
jobId: "poll-1",
|
|
@@ -332,11 +341,7 @@ describe("localRuntimePollJobStatus", () => {
|
|
|
332
341
|
});
|
|
333
342
|
globalThis.fetch = fetchMock;
|
|
334
343
|
|
|
335
|
-
const status = await localRuntimePollJobStatus(
|
|
336
|
-
RUNTIME_URL,
|
|
337
|
-
TOKEN,
|
|
338
|
-
"poll-2",
|
|
339
|
-
);
|
|
344
|
+
const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-2");
|
|
340
345
|
|
|
341
346
|
expect(status.status).toBe("complete");
|
|
342
347
|
if (status.status === "complete") {
|
|
@@ -358,11 +363,7 @@ describe("localRuntimePollJobStatus", () => {
|
|
|
358
363
|
});
|
|
359
364
|
globalThis.fetch = fetchMock;
|
|
360
365
|
|
|
361
|
-
const status = await localRuntimePollJobStatus(
|
|
362
|
-
RUNTIME_URL,
|
|
363
|
-
TOKEN,
|
|
364
|
-
"poll-3",
|
|
365
|
-
);
|
|
366
|
+
const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-3");
|
|
366
367
|
|
|
367
368
|
expect(status.status).toBe("failed");
|
|
368
369
|
if (status.status === "failed") {
|
|
@@ -377,7 +378,289 @@ describe("localRuntimePollJobStatus", () => {
|
|
|
377
378
|
globalThis.fetch = fetchMock;
|
|
378
379
|
|
|
379
380
|
await expect(
|
|
380
|
-
localRuntimePollJobStatus(
|
|
381
|
+
localRuntimePollJobStatus(ENTRY, TOKEN, "missing"),
|
|
381
382
|
).rejects.toThrow(/Migration job not found/);
|
|
382
383
|
});
|
|
383
384
|
});
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Platform-managed assistants (cloud="vellum") route through the platform's
|
|
388
|
+
// wildcard runtime proxy at `/v1/assistants/<id>/migrations/...` with
|
|
389
|
+
// platform-token auth (NOT guardian-token bearer). This block asserts the
|
|
390
|
+
// actual URL and headers built by the helpers — not mocked, not abstracted.
|
|
391
|
+
// Regression guard for the routing bug fixed in this PR.
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
const VELLUM_ENTRY: Pick<
|
|
394
|
+
AssistantEntry,
|
|
395
|
+
"cloud" | "runtimeUrl" | "assistantId"
|
|
396
|
+
> = {
|
|
397
|
+
cloud: "vellum",
|
|
398
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
399
|
+
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
400
|
+
};
|
|
401
|
+
// `vak_` prefix bypasses `fetchOrganizationId` (org-scoped API keys); the
|
|
402
|
+
// auth header collapses to a single `Authorization: Bearer vak_...` so this
|
|
403
|
+
// test stays free of network mocks.
|
|
404
|
+
const VAK_TOKEN = "vak_platform-token";
|
|
405
|
+
|
|
406
|
+
describe("vellum-cloud routing through wildcard proxy", () => {
|
|
407
|
+
test("export-to-gcs URL has /v1/assistants/<id>/migrations/ prefix and uses platform-token bearer (no guardian)", async () => {
|
|
408
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
409
|
+
return new Response(
|
|
410
|
+
JSON.stringify({ job_id: "wp-export-1", status: "pending" }),
|
|
411
|
+
{ status: 202, headers: { "Content-Type": "application/json" } },
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
globalThis.fetch = fetchMock;
|
|
415
|
+
|
|
416
|
+
const result = await localRuntimeExportToGcs(VELLUM_ENTRY, VAK_TOKEN, {
|
|
417
|
+
uploadUrl: "https://storage.example/signed/x",
|
|
418
|
+
description: "teleport export",
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(result.jobId).toBe("wp-export-1");
|
|
422
|
+
expect(calls[0]!.url).toBe(
|
|
423
|
+
`https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs`,
|
|
424
|
+
);
|
|
425
|
+
expect(calls[0]!.method).toBe("POST");
|
|
426
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
|
|
427
|
+
expect(calls[0]!.body).toEqual({
|
|
428
|
+
upload_url: "https://storage.example/signed/x",
|
|
429
|
+
description: "teleport export",
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("import-from-gcs URL has /v1/assistants/<id>/migrations/ prefix", async () => {
|
|
434
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
435
|
+
return new Response(
|
|
436
|
+
JSON.stringify({ job_id: "wp-import-1", status: "pending" }),
|
|
437
|
+
{ status: 202 },
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
globalThis.fetch = fetchMock;
|
|
441
|
+
|
|
442
|
+
await localRuntimeImportFromGcs(VELLUM_ENTRY, VAK_TOKEN, {
|
|
443
|
+
bundleUrl: "https://storage.example/download/y",
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(calls[0]!.url).toBe(
|
|
447
|
+
`https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs`,
|
|
448
|
+
);
|
|
449
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("jobs/<id> URL has /v1/assistants/<id>/migrations/ prefix (NOT the dedicated platform endpoint)", async () => {
|
|
453
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
454
|
+
return new Response(
|
|
455
|
+
JSON.stringify({
|
|
456
|
+
job_id: "wp-export-1",
|
|
457
|
+
status: "complete",
|
|
458
|
+
type: "export",
|
|
459
|
+
bundle_key: "exports/org-1/x.vbundle",
|
|
460
|
+
}),
|
|
461
|
+
{ status: 200 },
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
globalThis.fetch = fetchMock;
|
|
465
|
+
|
|
466
|
+
const status = await localRuntimePollJobStatus(
|
|
467
|
+
VELLUM_ENTRY,
|
|
468
|
+
VAK_TOKEN,
|
|
469
|
+
"wp-export-1",
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
expect(calls[0]!.url).toBe(
|
|
473
|
+
`https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/wp-export-1`,
|
|
474
|
+
);
|
|
475
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
|
|
476
|
+
expect(status.status).toBe("complete");
|
|
477
|
+
if (status.status === "complete") {
|
|
478
|
+
expect(status.bundleKey).toBe("exports/org-1/x.vbundle");
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe("localRuntimeIdentity", () => {
|
|
484
|
+
test("local entry: GETs /v1/health with Bearer auth and returns the version", async () => {
|
|
485
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
486
|
+
return new Response(
|
|
487
|
+
JSON.stringify({
|
|
488
|
+
status: "healthy",
|
|
489
|
+
timestamp: "2025-01-01T00:00:00Z",
|
|
490
|
+
version: "0.6.5",
|
|
491
|
+
}),
|
|
492
|
+
{
|
|
493
|
+
status: 200,
|
|
494
|
+
headers: { "Content-Type": "application/json" },
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
globalThis.fetch = fetchMock;
|
|
499
|
+
|
|
500
|
+
const result = await localRuntimeIdentity(ENTRY, TOKEN);
|
|
501
|
+
|
|
502
|
+
expect(result.version).toBe("0.6.5");
|
|
503
|
+
expect(calls).toHaveLength(1);
|
|
504
|
+
expect(calls[0]!.url).toBe(`${RUNTIME_URL}/v1/health`);
|
|
505
|
+
expect(calls[0]!.method).toBe("GET");
|
|
506
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${TOKEN}`);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("GETs /v1/health, not /v1/identity (works on pre-onboarding runtimes)", async () => {
|
|
510
|
+
// Regression guard: /v1/identity reads IDENTITY.md (written during
|
|
511
|
+
// onboarding, NOT hatch) and 404s on freshly-hatched targets. /v1/health
|
|
512
|
+
// returns the version field unconditionally, so it's the right source.
|
|
513
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
514
|
+
return new Response(JSON.stringify({ version: "0.7.0" }), {
|
|
515
|
+
status: 200,
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
globalThis.fetch = fetchMock;
|
|
519
|
+
|
|
520
|
+
await localRuntimeIdentity(ENTRY, TOKEN);
|
|
521
|
+
|
|
522
|
+
expect(calls[0]!.url.endsWith("/v1/health")).toBe(true);
|
|
523
|
+
expect(calls[0]!.url).not.toContain("/v1/identity");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("vellum entry: GETs /v1/assistants/<id>/health through the wildcard proxy", async () => {
|
|
527
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
528
|
+
return new Response(JSON.stringify({ version: "0.7.2" }), {
|
|
529
|
+
status: 200,
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
globalThis.fetch = fetchMock;
|
|
533
|
+
|
|
534
|
+
const result = await localRuntimeIdentity(VELLUM_ENTRY, VAK_TOKEN);
|
|
535
|
+
|
|
536
|
+
expect(result.version).toBe("0.7.2");
|
|
537
|
+
expect(calls[0]!.url).toBe(
|
|
538
|
+
`https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/health`,
|
|
539
|
+
);
|
|
540
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("non-2xx status throws with status + statusText", async () => {
|
|
544
|
+
const { fetchMock } = captureFetch(() => {
|
|
545
|
+
return new Response("nope", {
|
|
546
|
+
status: 503,
|
|
547
|
+
statusText: "Service Unavailable",
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
globalThis.fetch = fetchMock;
|
|
551
|
+
|
|
552
|
+
await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
|
|
553
|
+
/Failed to fetch runtime identity: 503/,
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("missing version in body throws", async () => {
|
|
558
|
+
const { fetchMock } = captureFetch(() => {
|
|
559
|
+
return new Response(JSON.stringify({}), { status: 200 });
|
|
560
|
+
});
|
|
561
|
+
globalThis.fetch = fetchMock;
|
|
562
|
+
|
|
563
|
+
await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
|
|
564
|
+
/Runtime identity response missing version/,
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("non-string version in body throws", async () => {
|
|
569
|
+
const { fetchMock } = captureFetch(() => {
|
|
570
|
+
return new Response(JSON.stringify({ version: 123 }), { status: 200 });
|
|
571
|
+
});
|
|
572
|
+
globalThis.fetch = fetchMock;
|
|
573
|
+
|
|
574
|
+
await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
|
|
575
|
+
/Runtime identity response missing version/,
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("empty-string version in body throws", async () => {
|
|
580
|
+
const { fetchMock } = captureFetch(() => {
|
|
581
|
+
return new Response(JSON.stringify({ version: "" }), { status: 200 });
|
|
582
|
+
});
|
|
583
|
+
globalThis.fetch = fetchMock;
|
|
584
|
+
|
|
585
|
+
await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
|
|
586
|
+
/Runtime identity response missing version/,
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------
|
|
591
|
+
// 401-retry parity with platformRequestSignedUrl (Codex P2 regression
|
|
592
|
+
// guard). localRuntimeIdentity is the first network call in the
|
|
593
|
+
// backup/teleport export flow for vellum-cloud assistants, so a stale
|
|
594
|
+
// Vellum-Organization-Id cache entry would surface as a hard abort
|
|
595
|
+
// before any other helper got a chance to clear the cache and retry.
|
|
596
|
+
// ---------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
test("vellum entry: retries once after 401 with a fresh org-ID lookup", async () => {
|
|
599
|
+
// Use a non-vak session token so authHeaders fetches + caches an org ID.
|
|
600
|
+
const SESSION_TOKEN = "session-abcdef";
|
|
601
|
+
const PLATFORM_URL = "https://platform.vellum.ai";
|
|
602
|
+
const ASSISTANT_ID = "11111111-2222-3333-4444-555555555555";
|
|
603
|
+
|
|
604
|
+
let healthCalls = 0;
|
|
605
|
+
const orgIdFetchedAs: string[] = [];
|
|
606
|
+
|
|
607
|
+
const fetchMock = mock(async (url: string | URL | Request) => {
|
|
608
|
+
const urlStr = typeof url === "string" ? url : url.toString();
|
|
609
|
+
if (urlStr.endsWith("/v1/organizations/")) {
|
|
610
|
+
// Each org-ID fetch returns a different ID to prove that the
|
|
611
|
+
// second health request DID re-resolve the org rather than
|
|
612
|
+
// reuse a stale cache entry.
|
|
613
|
+
orgIdFetchedAs.push(`org-${orgIdFetchedAs.length + 1}`);
|
|
614
|
+
return new Response(
|
|
615
|
+
JSON.stringify({
|
|
616
|
+
results: [{ id: orgIdFetchedAs[orgIdFetchedAs.length - 1]! }],
|
|
617
|
+
}),
|
|
618
|
+
{ status: 200 },
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
if (urlStr.endsWith(`/v1/assistants/${ASSISTANT_ID}/health`)) {
|
|
622
|
+
healthCalls += 1;
|
|
623
|
+
if (healthCalls === 1) {
|
|
624
|
+
return new Response("unauthorized", { status: 401 });
|
|
625
|
+
}
|
|
626
|
+
return new Response(JSON.stringify({ version: "0.7.4" }), {
|
|
627
|
+
status: 200,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
return new Response("unexpected", { status: 500 });
|
|
631
|
+
});
|
|
632
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
633
|
+
|
|
634
|
+
const result = await localRuntimeIdentity(
|
|
635
|
+
{
|
|
636
|
+
cloud: "vellum",
|
|
637
|
+
runtimeUrl: PLATFORM_URL,
|
|
638
|
+
assistantId: ASSISTANT_ID,
|
|
639
|
+
},
|
|
640
|
+
SESSION_TOKEN,
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
expect(result.version).toBe("0.7.4");
|
|
644
|
+
expect(healthCalls).toBe(2);
|
|
645
|
+
// Two org-ID fetches: the first to satisfy the initial authHeaders
|
|
646
|
+
// call, the second after the 401-driven cache invalidation.
|
|
647
|
+
expect(orgIdFetchedAs).toEqual(["org-1", "org-2"]);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("local entry: does NOT retry after 401 (guardian-token refresh is the caller's job via callRuntimeWithAuthRetry)", async () => {
|
|
651
|
+
let identityCalls = 0;
|
|
652
|
+
const fetchMock = mock(async () => {
|
|
653
|
+
identityCalls += 1;
|
|
654
|
+
return new Response("unauthorized", {
|
|
655
|
+
status: 401,
|
|
656
|
+
statusText: "Unauthorized",
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
660
|
+
|
|
661
|
+
await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
|
|
662
|
+
/Failed to fetch runtime identity: 401/,
|
|
663
|
+
);
|
|
664
|
+
expect(identityCalls).toBe(1);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
3
3
|
import {
|
|
4
4
|
platformPollJobStatus,
|
|
5
5
|
platformRequestSignedUrl,
|
|
6
|
+
VersionMismatchError,
|
|
6
7
|
type UnifiedJobStatus,
|
|
7
8
|
} from "../platform-client.js";
|
|
8
9
|
|
|
@@ -291,7 +292,241 @@ describe("platformRequestSignedUrl", () => {
|
|
|
291
292
|
expect(signedUrlCalls[0]!.headers.Authorization).toBeUndefined();
|
|
292
293
|
});
|
|
293
294
|
|
|
294
|
-
test("
|
|
295
|
+
test("upload operation with min/max runtime versions → posts them in body", async () => {
|
|
296
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
297
|
+
return new Response(
|
|
298
|
+
JSON.stringify({
|
|
299
|
+
url: "https://storage.example/signed/v",
|
|
300
|
+
bundle_key: "bundles/v.tar.gz",
|
|
301
|
+
expires_at: "2026-04-22T05:00:00Z",
|
|
302
|
+
}),
|
|
303
|
+
{ status: 201 },
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
globalThis.fetch = fetchMock;
|
|
307
|
+
|
|
308
|
+
await platformRequestSignedUrl(
|
|
309
|
+
{
|
|
310
|
+
operation: "upload",
|
|
311
|
+
minRuntimeVersion: "1.2.3",
|
|
312
|
+
maxRuntimeVersion: null,
|
|
313
|
+
},
|
|
314
|
+
VAK_TOKEN,
|
|
315
|
+
PLATFORM_URL,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(calls[0]!.body).toEqual({
|
|
319
|
+
operation: "upload",
|
|
320
|
+
min_runtime_version: "1.2.3",
|
|
321
|
+
max_runtime_version: null,
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("download operation with target_runtime_version → posts it in body", async () => {
|
|
326
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
327
|
+
return new Response(
|
|
328
|
+
JSON.stringify({
|
|
329
|
+
url: "https://storage.example/signed/dl-v",
|
|
330
|
+
bundle_key: "bundles/dl-v.tar.gz",
|
|
331
|
+
expires_at: "2026-04-22T06:00:00Z",
|
|
332
|
+
}),
|
|
333
|
+
{ status: 201 },
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
globalThis.fetch = fetchMock;
|
|
337
|
+
|
|
338
|
+
await platformRequestSignedUrl(
|
|
339
|
+
{
|
|
340
|
+
operation: "download",
|
|
341
|
+
bundleKey: "bundles/dl-v.tar.gz",
|
|
342
|
+
targetRuntimeVersion: "2.0.0",
|
|
343
|
+
},
|
|
344
|
+
VAK_TOKEN,
|
|
345
|
+
PLATFORM_URL,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(calls[0]!.body).toEqual({
|
|
349
|
+
operation: "download",
|
|
350
|
+
bundle_key: "bundles/dl-v.tar.gz",
|
|
351
|
+
target_runtime_version: "2.0.0",
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("download 422 with version_mismatch body → throws VersionMismatchError", async () => {
|
|
356
|
+
const { fetchMock } = captureFetch(() => {
|
|
357
|
+
return new Response(
|
|
358
|
+
JSON.stringify({
|
|
359
|
+
reason: "version_mismatch",
|
|
360
|
+
bundle_compat: {
|
|
361
|
+
min_runtime_version: "2.0.0",
|
|
362
|
+
max_runtime_version: "2.5.0",
|
|
363
|
+
},
|
|
364
|
+
target_runtime_version: "1.9.0",
|
|
365
|
+
}),
|
|
366
|
+
{ status: 422 },
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
globalThis.fetch = fetchMock;
|
|
370
|
+
|
|
371
|
+
let caught: unknown;
|
|
372
|
+
try {
|
|
373
|
+
await platformRequestSignedUrl(
|
|
374
|
+
{
|
|
375
|
+
operation: "download",
|
|
376
|
+
bundleKey: "bundles/foo.tar.gz",
|
|
377
|
+
targetRuntimeVersion: "1.9.0",
|
|
378
|
+
},
|
|
379
|
+
VAK_TOKEN,
|
|
380
|
+
PLATFORM_URL,
|
|
381
|
+
);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
caught = err;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
expect(caught).toBeInstanceOf(VersionMismatchError);
|
|
387
|
+
expect(caught).toBeInstanceOf(Error);
|
|
388
|
+
const err = caught as VersionMismatchError;
|
|
389
|
+
expect(err.bundleCompat).toEqual({
|
|
390
|
+
min_runtime_version: "2.0.0",
|
|
391
|
+
max_runtime_version: "2.5.0",
|
|
392
|
+
});
|
|
393
|
+
expect(err.targetRuntimeVersion).toBe("1.9.0");
|
|
394
|
+
expect(err.message).toBe(
|
|
395
|
+
"Cannot import: bundle requires runtime 2.0.0–2.5.0, but this runtime is 1.9.0. Update your runtime before importing.",
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("download 422 with version_mismatch and null max_runtime_version → '+' suffix in message", async () => {
|
|
400
|
+
const { fetchMock } = captureFetch(() => {
|
|
401
|
+
return new Response(
|
|
402
|
+
JSON.stringify({
|
|
403
|
+
reason: "version_mismatch",
|
|
404
|
+
bundle_compat: {
|
|
405
|
+
min_runtime_version: "3.0.0",
|
|
406
|
+
max_runtime_version: null,
|
|
407
|
+
},
|
|
408
|
+
target_runtime_version: "2.9.0",
|
|
409
|
+
}),
|
|
410
|
+
{ status: 422 },
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
globalThis.fetch = fetchMock;
|
|
414
|
+
|
|
415
|
+
let caught: unknown;
|
|
416
|
+
try {
|
|
417
|
+
await platformRequestSignedUrl(
|
|
418
|
+
{
|
|
419
|
+
operation: "download",
|
|
420
|
+
bundleKey: "bundles/foo.tar.gz",
|
|
421
|
+
targetRuntimeVersion: "2.9.0",
|
|
422
|
+
},
|
|
423
|
+
VAK_TOKEN,
|
|
424
|
+
PLATFORM_URL,
|
|
425
|
+
);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
caught = err;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
expect(caught).toBeInstanceOf(VersionMismatchError);
|
|
431
|
+
const err = caught as VersionMismatchError;
|
|
432
|
+
expect(err.message).toBe(
|
|
433
|
+
"Cannot import: bundle requires runtime 3.0.0+, but this runtime is 2.9.0. Update your runtime before importing.",
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("download 422 with arbitrary body (no reason field) → falls through to generic Error", async () => {
|
|
438
|
+
const { fetchMock } = captureFetch(() => {
|
|
439
|
+
return new Response(JSON.stringify({ detail: "validation failed" }), {
|
|
440
|
+
status: 422,
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
globalThis.fetch = fetchMock;
|
|
444
|
+
|
|
445
|
+
let caught: unknown;
|
|
446
|
+
try {
|
|
447
|
+
await platformRequestSignedUrl(
|
|
448
|
+
{ operation: "download", bundleKey: "bundles/foo.tar.gz" },
|
|
449
|
+
VAK_TOKEN,
|
|
450
|
+
PLATFORM_URL,
|
|
451
|
+
);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
caught = err;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
expect(caught).toBeInstanceOf(Error);
|
|
457
|
+
expect(caught).not.toBeInstanceOf(VersionMismatchError);
|
|
458
|
+
expect((caught as Error).message).toBe("validation failed");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("422 is NOT retried after a 401", async () => {
|
|
462
|
+
let callCount = 0;
|
|
463
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
464
|
+
callCount += 1;
|
|
465
|
+
if (callCount === 1) {
|
|
466
|
+
return new Response(JSON.stringify({ detail: "unauthorized" }), {
|
|
467
|
+
status: 401,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return new Response(
|
|
471
|
+
JSON.stringify({
|
|
472
|
+
reason: "version_mismatch",
|
|
473
|
+
bundle_compat: {
|
|
474
|
+
min_runtime_version: "2.0.0",
|
|
475
|
+
max_runtime_version: "2.5.0",
|
|
476
|
+
},
|
|
477
|
+
target_runtime_version: "1.9.0",
|
|
478
|
+
}),
|
|
479
|
+
{ status: 422 },
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
globalThis.fetch = fetchMock;
|
|
483
|
+
|
|
484
|
+
let caught: unknown;
|
|
485
|
+
try {
|
|
486
|
+
await platformRequestSignedUrl(
|
|
487
|
+
{
|
|
488
|
+
operation: "download",
|
|
489
|
+
bundleKey: "bundles/foo.tar.gz",
|
|
490
|
+
targetRuntimeVersion: "1.9.0",
|
|
491
|
+
},
|
|
492
|
+
VAK_TOKEN,
|
|
493
|
+
PLATFORM_URL,
|
|
494
|
+
);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
caught = err;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
expect(calls).toHaveLength(2);
|
|
500
|
+
expect(caught).toBeInstanceOf(VersionMismatchError);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("non-422 download path remains unchanged (regression pin on body shape)", async () => {
|
|
504
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
505
|
+
return new Response(
|
|
506
|
+
JSON.stringify({
|
|
507
|
+
url: "https://storage.example/signed/dl-xyz",
|
|
508
|
+
bundle_key: "bundles/xyz.tar.gz",
|
|
509
|
+
expires_at: "2026-04-22T02:00:00Z",
|
|
510
|
+
}),
|
|
511
|
+
{ status: 201 },
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
globalThis.fetch = fetchMock;
|
|
515
|
+
|
|
516
|
+
const result = await platformRequestSignedUrl(
|
|
517
|
+
{ operation: "download", bundleKey: "bundles/xyz.tar.gz" },
|
|
518
|
+
VAK_TOKEN,
|
|
519
|
+
PLATFORM_URL,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
expect(result.url).toBe("https://storage.example/signed/dl-xyz");
|
|
523
|
+
expect(calls[0]!.body).toEqual({
|
|
524
|
+
operation: "download",
|
|
525
|
+
bundle_key: "bundles/xyz.tar.gz",
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("5xx error response → surfaces platform detail message", async () => {
|
|
295
530
|
const { fetchMock } = captureFetch(() => {
|
|
296
531
|
return new Response(JSON.stringify({ detail: "temporarily down" }), {
|
|
297
532
|
status: 503,
|
|
@@ -305,7 +540,7 @@ describe("platformRequestSignedUrl", () => {
|
|
|
305
540
|
VAK_TOKEN,
|
|
306
541
|
PLATFORM_URL,
|
|
307
542
|
),
|
|
308
|
-
).rejects.toThrow(/
|
|
543
|
+
).rejects.toThrow(/temporarily down/);
|
|
309
544
|
});
|
|
310
545
|
});
|
|
311
546
|
|