@vellumai/cli 0.12.7 → 0.12.9

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.
@@ -29,6 +29,7 @@
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
30
  "./plan-credit": "./src/plan-credit.ts",
31
31
  "./integration-categories": "./src/integration-categories.ts",
32
+ "./migration-import": "./src/migration-import.ts",
32
33
  "./mcp-oauth": "./src/mcp-oauth.ts"
33
34
  },
34
35
  "scripts": {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parseImportFailure } from "../migration-import.js";
4
+
5
+ describe("parseImportFailure", () => {
6
+ test("parses a validation report", () => {
7
+ const failure = parseImportFailure(
8
+ JSON.stringify({
9
+ success: false,
10
+ reason: "validation_failed",
11
+ errors: [
12
+ {
13
+ code: "entry_hash",
14
+ message: "Checksum mismatch",
15
+ path: "workspace/a.md",
16
+ },
17
+ ],
18
+ }),
19
+ );
20
+ expect(failure).toEqual({
21
+ reason: "validation_failed",
22
+ errors: [{ message: "Checksum mismatch", path: "workspace/a.md" }],
23
+ });
24
+ });
25
+
26
+ test("parses a plain message body", () => {
27
+ expect(parseImportFailure('{"message":"disk full"}')).toEqual({
28
+ message: "disk full",
29
+ });
30
+ });
31
+
32
+ test("returns null for text that is not an import failure", () => {
33
+ expect(parseImportFailure("not json")).toBeNull();
34
+ expect(parseImportFailure('{"job_id":"abc"}')).toBeNull();
35
+ });
36
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Failure body of a `.vbundle` import from the assistant's migration routes:
3
+ * a structured validation report (`reason` plus per-file `errors`) or a plain
4
+ * `message`. Clients that restore bundles parse it to tell the owner which
5
+ * file failed instead of printing raw JSON.
6
+ */
7
+
8
+ import { z } from "zod";
9
+
10
+ export const importFailureSchema = z.object({
11
+ reason: z.string().optional(),
12
+ message: z.string().optional(),
13
+ errors: z
14
+ .array(z.object({ message: z.string(), path: z.string().optional() }))
15
+ .optional(),
16
+ });
17
+
18
+ export type ImportFailure = z.infer<typeof importFailureSchema>;
19
+
20
+ /** Parse an import failure from JSON text, or null when the text is not one. */
21
+ export function parseImportFailure(text: string): ImportFailure | null {
22
+ try {
23
+ const parsed = importFailureSchema.safeParse(JSON.parse(text));
24
+ return parsed.success &&
25
+ (parsed.data.reason || parsed.data.message || parsed.data.errors)
26
+ ? parsed.data
27
+ : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
@@ -29,6 +29,7 @@
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
30
  "./plan-credit": "./src/plan-credit.ts",
31
31
  "./integration-categories": "./src/integration-categories.ts",
32
+ "./migration-import": "./src/migration-import.ts",
32
33
  "./mcp-oauth": "./src/mcp-oauth.ts"
33
34
  },
34
35
  "scripts": {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parseImportFailure } from "../migration-import.js";
4
+
5
+ describe("parseImportFailure", () => {
6
+ test("parses a validation report", () => {
7
+ const failure = parseImportFailure(
8
+ JSON.stringify({
9
+ success: false,
10
+ reason: "validation_failed",
11
+ errors: [
12
+ {
13
+ code: "entry_hash",
14
+ message: "Checksum mismatch",
15
+ path: "workspace/a.md",
16
+ },
17
+ ],
18
+ }),
19
+ );
20
+ expect(failure).toEqual({
21
+ reason: "validation_failed",
22
+ errors: [{ message: "Checksum mismatch", path: "workspace/a.md" }],
23
+ });
24
+ });
25
+
26
+ test("parses a plain message body", () => {
27
+ expect(parseImportFailure('{"message":"disk full"}')).toEqual({
28
+ message: "disk full",
29
+ });
30
+ });
31
+
32
+ test("returns null for text that is not an import failure", () => {
33
+ expect(parseImportFailure("not json")).toBeNull();
34
+ expect(parseImportFailure('{"job_id":"abc"}')).toBeNull();
35
+ });
36
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Failure body of a `.vbundle` import from the assistant's migration routes:
3
+ * a structured validation report (`reason` plus per-file `errors`) or a plain
4
+ * `message`. Clients that restore bundles parse it to tell the owner which
5
+ * file failed instead of printing raw JSON.
6
+ */
7
+
8
+ import { z } from "zod";
9
+
10
+ export const importFailureSchema = z.object({
11
+ reason: z.string().optional(),
12
+ message: z.string().optional(),
13
+ errors: z
14
+ .array(z.object({ message: z.string(), path: z.string().optional() }))
15
+ .optional(),
16
+ });
17
+
18
+ export type ImportFailure = z.infer<typeof importFailureSchema>;
19
+
20
+ /** Parse an import failure from JSON text, or null when the text is not one. */
21
+ export function parseImportFailure(text: string): ImportFailure | null {
22
+ try {
23
+ const parsed = importFailureSchema.safeParse(JSON.parse(text));
24
+ return parsed.success &&
25
+ (parsed.data.reason || parsed.data.message || parsed.data.errors)
26
+ ? parsed.data
27
+ : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -324,6 +324,7 @@ describe("vellum backup <platform-managed>: GCS happy path", () => {
324
324
  operation: "upload",
325
325
  minRuntimeVersion: "0.6.5",
326
326
  maxRuntimeVersion: null,
327
+ consumer: "runtime",
327
328
  }),
328
329
  "platform-token",
329
330
  "https://platform.vellum.ai",
@@ -23,6 +23,7 @@ import { restoreBackup } from "../lib/backup-ops.js";
23
23
  import {
24
24
  bundleFileSizeBytes,
25
25
  formatBundleSizeMb,
26
+ importJobFailure,
26
27
  importStagedBundle,
27
28
  RESTORE_STAGING_DIRNAME,
28
29
  stageBundleForRestore,
@@ -44,7 +45,9 @@ function makeEntry(overrides: Partial<AssistantEntry> = {}): AssistantEntry {
44
45
  }
45
46
 
46
47
  let tmpRoot: string;
47
- let fetchSpy: ReturnType<typeof spyOn<typeof loopbackFetch, "loopbackSafeFetch">>;
48
+ let fetchSpy: ReturnType<
49
+ typeof spyOn<typeof loopbackFetch, "loopbackSafeFetch">
50
+ >;
48
51
  let execSpy: ReturnType<typeof spyOn<typeof stepRunner, "exec">>;
49
52
 
50
53
  beforeEach(() => {
@@ -98,12 +101,7 @@ describe("stageBundleForRestore", () => {
98
101
  hostBundle,
99
102
  );
100
103
 
101
- const dest = join(
102
- instanceDir,
103
- ".vellum",
104
- "workspace",
105
- staged.relativePath,
106
- );
104
+ const dest = join(instanceDir, ".vellum", "workspace", staged.relativePath);
107
105
  expect(staged.relativePath.startsWith(`${RESTORE_STAGING_DIRNAME}/`)).toBe(
108
106
  true,
109
107
  );
@@ -270,3 +268,40 @@ describe("bundle size helpers", () => {
270
268
  expect(bundleFileSizeBytes(join(tmpRoot, "missing.vbundle"))).toBe(0);
271
269
  });
272
270
  });
271
+
272
+ describe("importJobFailure", () => {
273
+ test("restores a validation report recorded as the job error", () => {
274
+ const report = {
275
+ success: false,
276
+ reason: "validation_failed",
277
+ errors: [
278
+ {
279
+ code: "entry_hash",
280
+ message: "Checksum mismatch",
281
+ path: "workspace/data/monitoring/samples.jsonl",
282
+ },
283
+ ],
284
+ };
285
+ const failure = importJobFailure(JSON.stringify(report));
286
+ expect(failure.status).toBe(400);
287
+ expect(failure.body.reason).toBe("validation_failed");
288
+ expect(failure.body.errors).toEqual([
289
+ {
290
+ message: "Checksum mismatch",
291
+ path: "workspace/data/monitoring/samples.jsonl",
292
+ },
293
+ ]);
294
+ });
295
+
296
+ test("keeps a plain error message as a 500", () => {
297
+ const failure = importJobFailure("disk full");
298
+ expect(failure).toEqual({
299
+ status: 500,
300
+ body: { success: false, message: "disk full" },
301
+ });
302
+ });
303
+
304
+ test("reports a missing error as a generic failure", () => {
305
+ expect(importJobFailure(undefined).body.message).toBe("Import failed");
306
+ });
307
+ });
@@ -288,6 +288,10 @@ async function backupPlatform(
288
288
  operation: "upload",
289
289
  minRuntimeVersion: runtimeIdentity.version,
290
290
  maxRuntimeVersion: null,
291
+ // The managed pod PUTs the bundle. A runtime-consumer upload also
292
+ // registers the platform export record that Step 4's download URL
293
+ // request is checked against.
294
+ consumer: "runtime",
291
295
  },
292
296
  exportPlatformToken,
293
297
  platformUrl,
@@ -10,6 +10,10 @@ import {
10
10
  stageBundleForRestore,
11
11
  stagingTargetFromEntry,
12
12
  } from "../lib/bundle-staging.js";
13
+ import {
14
+ type ImportFailure,
15
+ parseImportFailure,
16
+ } from "@vellumai/service-contracts/migration-import";
13
17
  import {
14
18
  loadGuardianToken,
15
19
  leaseGuardianToken,
@@ -161,6 +165,24 @@ interface PreflightResponse {
161
165
  conflicts?: StructuredError[];
162
166
  }
163
167
 
168
+ const IMPORT_FAILURE_REASONS: Record<string, string> = {
169
+ validation_failed: "the backup file failed validation",
170
+ version_incompatible: "the backup needs a different assistant version",
171
+ extraction_failed: "the backup could not be unpacked",
172
+ };
173
+
174
+ function printImportFailure(failure: ImportFailure): void {
175
+ const reason = failure.reason
176
+ ? (IMPORT_FAILURE_REASONS[failure.reason] ?? failure.reason)
177
+ : undefined;
178
+ console.error(
179
+ `Error: Import failed: ${failure.message ?? reason ?? "unknown reason"}`,
180
+ );
181
+ for (const err of failure.errors ?? []) {
182
+ console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
183
+ }
184
+ }
185
+
164
186
  interface ImportResponse {
165
187
  success: boolean;
166
188
  reason?: string;
@@ -480,12 +502,7 @@ async function restorePlatform(
480
502
  const result = importResult.body as unknown as ImportResponse;
481
503
 
482
504
  if (!result.success) {
483
- console.error(
484
- `Error: Import failed — ${result.message ?? result.reason ?? "unknown reason"}`,
485
- );
486
- for (const err of result.errors ?? []) {
487
- console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
488
- }
505
+ printImportFailure(result);
489
506
  process.exit(1);
490
507
  }
491
508
 
@@ -771,7 +788,12 @@ async function runLocalRestore(opts: {
771
788
  });
772
789
  if (!response.ok) {
773
790
  const body = await response.text();
774
- console.error(`Error: Import failed (${response.status}): ${body}`);
791
+ const failure = parseImportFailure(body);
792
+ if (failure) {
793
+ printImportFailure(failure);
794
+ } else {
795
+ console.error(`Error: Import failed (${response.status}): ${body}`);
796
+ }
775
797
  process.exit(1);
776
798
  }
777
799
  result = (await response.json()) as ImportResponse;
@@ -792,12 +814,7 @@ async function runLocalRestore(opts: {
792
814
  }
793
815
 
794
816
  if (!result.success) {
795
- console.error(
796
- `Error: Import failed — ${result.message ?? result.reason ?? "unknown reason"}`,
797
- );
798
- for (const err of result.errors ?? []) {
799
- console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
800
- }
817
+ printImportFailure(result);
801
818
  process.exit(1);
802
819
  }
803
820
 
@@ -1,13 +1,12 @@
1
1
  import { randomUUID } from "crypto";
2
- import {
3
- copyFileSync,
4
- existsSync,
5
- mkdirSync,
6
- statSync,
7
- unlinkSync,
8
- } from "fs";
2
+ import { copyFileSync, existsSync, mkdirSync, statSync, unlinkSync } from "fs";
9
3
  import { join } from "path";
10
4
 
5
+ import {
6
+ type ImportFailure,
7
+ parseImportFailure,
8
+ } from "@vellumai/service-contracts/migration-import";
9
+
11
10
  import type { AssistantEntry } from "./assistant-config.js";
12
11
  import { dockerResourceNames } from "./docker.js";
13
12
  import { loopbackSafeFetch } from "./loopback-fetch.js";
@@ -156,6 +155,28 @@ export async function importStagedBundle(
156
155
  return response;
157
156
  }
158
157
 
158
+ /**
159
+ * Decode a failed import job's error into the body and status the import
160
+ * route would have returned. The runtime records a validation failure as that
161
+ * JSON body, so a plain 500 would hide the per-file errors.
162
+ */
163
+ export function importJobFailure(error: string | undefined): {
164
+ status: number;
165
+ body: ImportFailure & { success: false };
166
+ } {
167
+ const report = error ? parseImportFailure(error) : null;
168
+ if (report?.reason) {
169
+ return {
170
+ status: report.reason === "validation_failed" ? 400 : 500,
171
+ body: { ...report, success: false },
172
+ };
173
+ }
174
+ return {
175
+ status: 500,
176
+ body: { success: false, message: error ?? "Import failed" },
177
+ };
178
+ }
179
+
159
180
  async function pollImportJob(
160
181
  runtimeUrl: string,
161
182
  accessToken: string,
@@ -194,16 +215,16 @@ async function pollImportJob(
194
215
  });
195
216
  }
196
217
  if (job.status === "failed") {
197
- return new Response(
198
- JSON.stringify({
199
- success: false,
200
- message: job.error ?? "Import failed",
201
- }),
202
- { status: 500, headers: { "Content-Type": "application/json" } },
203
- );
218
+ const failure = importJobFailure(job.error);
219
+ return new Response(JSON.stringify(failure.body), {
220
+ status: failure.status,
221
+ headers: { "Content-Type": "application/json" },
222
+ });
204
223
  }
205
224
 
206
- await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_INTERVAL_MS));
225
+ await new Promise((resolve) =>
226
+ setTimeout(resolve, IMPORT_POLL_INTERVAL_MS),
227
+ );
207
228
  }
208
229
 
209
230
  return new Response(