@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.
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/__tests__/migration-import.test.ts +36 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/migration-import.ts +31 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/migration-import.test.ts +36 -0
- package/node_modules/@vellumai/service-contracts/src/migration-import.ts +31 -0
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +1 -0
- package/src/__tests__/bundle-staging.test.ts +42 -7
- package/src/commands/backup.ts +4 -0
- package/src/commands/restore.ts +30 -13
- package/src/lib/bundle-staging.ts +36 -15
package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -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
|
@@ -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<
|
|
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
|
+
});
|
package/src/commands/backup.ts
CHANGED
|
@@ -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,
|
package/src/commands/restore.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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) =>
|
|
225
|
+
await new Promise((resolve) =>
|
|
226
|
+
setTimeout(resolve, IMPORT_POLL_INTERVAL_MS),
|
|
227
|
+
);
|
|
207
228
|
}
|
|
208
229
|
|
|
209
230
|
return new Response(
|