@vellumai/cli 0.12.1-staging.2 → 0.12.2-dev.202609162214.308bda0
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/environments/src/shell.test.ts +21 -0
- package/node_modules/@vellumai/environments/src/shell.ts +24 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/shell.ts +24 -0
- package/node_modules/@vellumai/local-mode/package.json +2 -1
- package/node_modules/@vellumai/local-mode/src/teleport-backup-policy.test.ts +88 -0
- package/node_modules/@vellumai/local-mode/src/teleport-backup-policy.ts +74 -0
- package/package.json +1 -1
- package/src/__tests__/backup-ops-matching.test.ts +48 -0
- package/src/__tests__/teleport-backup.test.ts +208 -0
- package/src/__tests__/teleport.test.ts +297 -0
- package/src/commands/teleport.ts +95 -0
- package/src/lib/__tests__/docker.test.ts +1 -0
- package/src/lib/__tests__/local-ces.test.ts +25 -2
- package/src/lib/backup-ops.ts +75 -8
- package/src/lib/local.ts +32 -21
- package/src/lib/platform-client.ts +0 -12
- package/src/lib/provider-secrets.ts +4 -0
- package/src/lib/statefulset.ts +1 -0
- package/src/lib/teleport-backup.ts +139 -0
- package/src/shared/provider-env-vars.ts +1 -0
|
@@ -39,6 +39,8 @@ import * as assistantConfig from "../lib/assistant-config.js";
|
|
|
39
39
|
import * as guardianToken from "../lib/guardian-token.js";
|
|
40
40
|
import * as platformClient from "../lib/platform-client.js";
|
|
41
41
|
import * as localRuntimeClient from "../lib/local-runtime-client.js";
|
|
42
|
+
import * as backupOps from "../lib/backup-ops.js";
|
|
43
|
+
import * as teleportBackup from "../lib/teleport-backup.js";
|
|
42
44
|
|
|
43
45
|
// Snapshot the real exports before any `mock.module()` call so we can
|
|
44
46
|
// reliably restore them after this file's tests complete, regardless of
|
|
@@ -47,6 +49,8 @@ const realAssistantConfig = { ...assistantConfig };
|
|
|
47
49
|
const realGuardianToken = { ...guardianToken };
|
|
48
50
|
const realPlatformClient = { ...platformClient };
|
|
49
51
|
const realLocalRuntimeClient = { ...localRuntimeClient };
|
|
52
|
+
const realBackupOps = { ...backupOps };
|
|
53
|
+
const realTeleportBackup = { ...teleportBackup };
|
|
50
54
|
|
|
51
55
|
const findAssistantByNameMock = mock<
|
|
52
56
|
typeof assistantConfig.findAssistantByName
|
|
@@ -282,6 +286,36 @@ mock.module("../lib/local-runtime-client.js", () => ({
|
|
|
282
286
|
localRuntimePreflightFromGcs: localRuntimePreflightFromGcsMock,
|
|
283
287
|
}));
|
|
284
288
|
|
|
289
|
+
// Pre-export source backup. Default to "no backups yet" so the create path
|
|
290
|
+
// runs; individual tests override to exercise reuse and failure.
|
|
291
|
+
const listAssistantBackupTimesMock = mock<
|
|
292
|
+
typeof backupOps.listAssistantBackupTimes
|
|
293
|
+
>(() => []);
|
|
294
|
+
const createBackupMock = mock<typeof backupOps.createBackup>(
|
|
295
|
+
async () => "/tmp/backups/my-local-pre-teleport-2026.vbundle",
|
|
296
|
+
);
|
|
297
|
+
const pruneOldBackupsMock = mock<typeof backupOps.pruneOldBackups>(() => {});
|
|
298
|
+
|
|
299
|
+
mock.module("../lib/backup-ops.js", () => ({
|
|
300
|
+
...realBackupOps,
|
|
301
|
+
listAssistantBackupTimes: listAssistantBackupTimesMock,
|
|
302
|
+
createBackup: createBackupMock,
|
|
303
|
+
pruneOldBackups: pruneOldBackupsMock,
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
const listPlatformBackupsMock = mock<typeof teleportBackup.listPlatformBackups>(
|
|
307
|
+
async () => [],
|
|
308
|
+
);
|
|
309
|
+
const createPlatformBackupMock = mock<
|
|
310
|
+
typeof teleportBackup.createPlatformBackup
|
|
311
|
+
>(async () => {});
|
|
312
|
+
|
|
313
|
+
mock.module("../lib/teleport-backup.js", () => ({
|
|
314
|
+
...realTeleportBackup,
|
|
315
|
+
listPlatformBackups: listPlatformBackupsMock,
|
|
316
|
+
createPlatformBackup: createPlatformBackupMock,
|
|
317
|
+
}));
|
|
318
|
+
|
|
285
319
|
// Snapshot the remaining real modules before mocking so `afterAll` can
|
|
286
320
|
// restore them too — otherwise these mocks leak into sibling test files that
|
|
287
321
|
// import the same modules in the same `bun test` run.
|
|
@@ -362,6 +396,8 @@ afterAll(() => {
|
|
|
362
396
|
mock.module("../lib/guardian-token.js", () => realGuardianToken);
|
|
363
397
|
mock.module("../lib/platform-client.js", () => realPlatformClient);
|
|
364
398
|
mock.module("../lib/local-runtime-client.js", () => realLocalRuntimeClient);
|
|
399
|
+
mock.module("../lib/backup-ops.js", () => realBackupOps);
|
|
400
|
+
mock.module("../lib/teleport-backup.js", () => realTeleportBackup);
|
|
365
401
|
mock.module("../lib/hatch-local.js", () => realHatchLocal);
|
|
366
402
|
mock.module("../lib/docker.js", () => realDocker);
|
|
367
403
|
mock.module("../lib/process.js", () => realProcess);
|
|
@@ -532,6 +568,19 @@ beforeEach(() => {
|
|
|
532
568
|
localRuntimePollJobStatusMock.mockReset();
|
|
533
569
|
localRuntimePollJobStatusMock.mockImplementation(defaultLocalRuntimePollImpl);
|
|
534
570
|
|
|
571
|
+
listAssistantBackupTimesMock.mockReset();
|
|
572
|
+
listAssistantBackupTimesMock.mockReturnValue([]);
|
|
573
|
+
createBackupMock.mockReset();
|
|
574
|
+
createBackupMock.mockResolvedValue(
|
|
575
|
+
"/tmp/backups/my-local-pre-teleport-2026.vbundle",
|
|
576
|
+
);
|
|
577
|
+
pruneOldBackupsMock.mockReset();
|
|
578
|
+
pruneOldBackupsMock.mockImplementation(() => {});
|
|
579
|
+
listPlatformBackupsMock.mockReset();
|
|
580
|
+
listPlatformBackupsMock.mockResolvedValue([]);
|
|
581
|
+
createPlatformBackupMock.mockReset();
|
|
582
|
+
createPlatformBackupMock.mockResolvedValue(undefined);
|
|
583
|
+
|
|
535
584
|
hatchLocalMock.mockReset();
|
|
536
585
|
hatchLocalMock.mockResolvedValue(undefined);
|
|
537
586
|
hatchDockerMock.mockReset();
|
|
@@ -2019,6 +2068,254 @@ describe("dry-run", () => {
|
|
|
2019
2068
|
// Pre-check: block teleport to platform when existing assistant detected
|
|
2020
2069
|
// ---------------------------------------------------------------------------
|
|
2021
2070
|
|
|
2071
|
+
describe("pre-export source backup", () => {
|
|
2072
|
+
test("local source with no recent backup: exports a host-side backup before exporting", async () => {
|
|
2073
|
+
setArgv("--from", "my-local", "--platform");
|
|
2074
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2075
|
+
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2076
|
+
name === "my-local" ? localEntry : null,
|
|
2077
|
+
);
|
|
2078
|
+
|
|
2079
|
+
const order: string[] = [];
|
|
2080
|
+
createBackupMock.mockImplementation(async () => {
|
|
2081
|
+
order.push("backup");
|
|
2082
|
+
return "/tmp/backups/my-local-pre-teleport-2026.vbundle";
|
|
2083
|
+
});
|
|
2084
|
+
localRuntimeExportToGcsMock.mockImplementation(async () => {
|
|
2085
|
+
order.push("export");
|
|
2086
|
+
return { jobId: "local-export-job-1" };
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
const restoreFetch = installTrackingFetch();
|
|
2090
|
+
try {
|
|
2091
|
+
await teleport();
|
|
2092
|
+
} finally {
|
|
2093
|
+
restoreFetch();
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
expect(listAssistantBackupTimesMock).toHaveBeenCalledWith("my-local");
|
|
2097
|
+
expect(createBackupMock).toHaveBeenCalledWith(
|
|
2098
|
+
"http://localhost:7821",
|
|
2099
|
+
"my-local",
|
|
2100
|
+
expect.objectContaining({ prefix: "my-local-pre-teleport" }),
|
|
2101
|
+
);
|
|
2102
|
+
expect(pruneOldBackupsMock).toHaveBeenCalledWith(
|
|
2103
|
+
"my-local",
|
|
2104
|
+
3,
|
|
2105
|
+
"pre-teleport",
|
|
2106
|
+
);
|
|
2107
|
+
expect(createPlatformBackupMock).not.toHaveBeenCalled();
|
|
2108
|
+
expect(order).toEqual(["backup", "export"]);
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
test("local source with a recent backup: reuses it and skips the export", async () => {
|
|
2112
|
+
setArgv("--from", "my-local", "--platform");
|
|
2113
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2114
|
+
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2115
|
+
name === "my-local" ? localEntry : null,
|
|
2116
|
+
);
|
|
2117
|
+
listAssistantBackupTimesMock.mockReturnValue([
|
|
2118
|
+
new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
2119
|
+
]);
|
|
2120
|
+
|
|
2121
|
+
const restoreFetch = installTrackingFetch();
|
|
2122
|
+
try {
|
|
2123
|
+
await teleport();
|
|
2124
|
+
} finally {
|
|
2125
|
+
restoreFetch();
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
expect(createBackupMock).not.toHaveBeenCalled();
|
|
2129
|
+
expect(localRuntimeExportToGcsMock).toHaveBeenCalled();
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
test("local source with only stale backups: takes a fresh one", async () => {
|
|
2133
|
+
setArgv("--from", "my-local", "--platform");
|
|
2134
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2135
|
+
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2136
|
+
name === "my-local" ? localEntry : null,
|
|
2137
|
+
);
|
|
2138
|
+
listAssistantBackupTimesMock.mockReturnValue([
|
|
2139
|
+
new Date(Date.now() - 3 * 60 * 60_000).toISOString(),
|
|
2140
|
+
]);
|
|
2141
|
+
|
|
2142
|
+
const restoreFetch = installTrackingFetch();
|
|
2143
|
+
try {
|
|
2144
|
+
await teleport();
|
|
2145
|
+
} finally {
|
|
2146
|
+
restoreFetch();
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
expect(createBackupMock).toHaveBeenCalledTimes(1);
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
test("docker source: backs up host-side through its runtime URL", async () => {
|
|
2153
|
+
setArgv("--from", "my-docker", "--local");
|
|
2154
|
+
const dockerEntry = makeEntry("my-docker", {
|
|
2155
|
+
cloud: "docker",
|
|
2156
|
+
runtimeUrl: "http://localhost:9821",
|
|
2157
|
+
});
|
|
2158
|
+
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2159
|
+
name === "my-docker" ? dockerEntry : null,
|
|
2160
|
+
);
|
|
2161
|
+
const freshLocal = makeEntry("fresh-local", { cloud: "local" });
|
|
2162
|
+
loadAllAssistantsMock.mockImplementation(() =>
|
|
2163
|
+
hatchLocalMock.mock.calls.length > 0
|
|
2164
|
+
? [dockerEntry, freshLocal]
|
|
2165
|
+
: [dockerEntry],
|
|
2166
|
+
);
|
|
2167
|
+
|
|
2168
|
+
const restoreFetch = installTrackingFetch();
|
|
2169
|
+
try {
|
|
2170
|
+
await teleport();
|
|
2171
|
+
} finally {
|
|
2172
|
+
restoreFetch();
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
expect(createBackupMock).toHaveBeenCalledWith(
|
|
2176
|
+
"http://localhost:9821",
|
|
2177
|
+
"my-docker",
|
|
2178
|
+
expect.objectContaining({ prefix: "my-docker-pre-teleport" }),
|
|
2179
|
+
);
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
test("platform source: takes a PVC snapshot through the source platform before exporting", async () => {
|
|
2183
|
+
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2184
|
+
const platformEntry = makeEntry("my-platform", {
|
|
2185
|
+
cloud: "vellum",
|
|
2186
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
2187
|
+
});
|
|
2188
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2189
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2190
|
+
if (name === "my-platform") return platformEntry;
|
|
2191
|
+
if (name === "my-local") return localEntry;
|
|
2192
|
+
return null;
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
const order: string[] = [];
|
|
2196
|
+
createPlatformBackupMock.mockImplementation(async () => {
|
|
2197
|
+
order.push("backup");
|
|
2198
|
+
});
|
|
2199
|
+
localRuntimeExportToGcsMock.mockImplementation(async () => {
|
|
2200
|
+
order.push("export");
|
|
2201
|
+
return { jobId: "local-export-job-1" };
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
const restoreFetch = installTrackingFetch();
|
|
2205
|
+
try {
|
|
2206
|
+
await teleport();
|
|
2207
|
+
} finally {
|
|
2208
|
+
restoreFetch();
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
expect(listPlatformBackupsMock).toHaveBeenCalledWith(
|
|
2212
|
+
expect.objectContaining({
|
|
2213
|
+
assistantId: "my-platform",
|
|
2214
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
2215
|
+
}),
|
|
2216
|
+
"platform-token",
|
|
2217
|
+
);
|
|
2218
|
+
expect(createPlatformBackupMock).toHaveBeenCalledWith(
|
|
2219
|
+
expect.objectContaining({ assistantId: "my-platform" }),
|
|
2220
|
+
"platform-token",
|
|
2221
|
+
);
|
|
2222
|
+
expect(createBackupMock).not.toHaveBeenCalled();
|
|
2223
|
+
expect(order).toEqual(["backup", "export"]);
|
|
2224
|
+
});
|
|
2225
|
+
|
|
2226
|
+
test("platform source with a recent ready snapshot: reuses it", async () => {
|
|
2227
|
+
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2228
|
+
const platformEntry = makeEntry("my-platform", {
|
|
2229
|
+
cloud: "vellum",
|
|
2230
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
2231
|
+
});
|
|
2232
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2233
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2234
|
+
if (name === "my-platform") return platformEntry;
|
|
2235
|
+
if (name === "my-local") return localEntry;
|
|
2236
|
+
return null;
|
|
2237
|
+
});
|
|
2238
|
+
listPlatformBackupsMock.mockResolvedValue([
|
|
2239
|
+
new Date(Date.now() - 20 * 60_000).toISOString(),
|
|
2240
|
+
]);
|
|
2241
|
+
|
|
2242
|
+
const restoreFetch = installTrackingFetch();
|
|
2243
|
+
try {
|
|
2244
|
+
await teleport();
|
|
2245
|
+
} finally {
|
|
2246
|
+
restoreFetch();
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
expect(createPlatformBackupMock).not.toHaveBeenCalled();
|
|
2250
|
+
expect(localRuntimeExportToGcsMock).toHaveBeenCalled();
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
test("host-side backup failure aborts before export and before hatching", async () => {
|
|
2254
|
+
setArgv("--from", "my-local", "--platform");
|
|
2255
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2256
|
+
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2257
|
+
name === "my-local" ? localEntry : null,
|
|
2258
|
+
);
|
|
2259
|
+
createBackupMock.mockResolvedValue(null);
|
|
2260
|
+
|
|
2261
|
+
const restoreFetch = installTrackingFetch();
|
|
2262
|
+
try {
|
|
2263
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2264
|
+
} finally {
|
|
2265
|
+
restoreFetch();
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
2269
|
+
expect(hatchAssistantMock).not.toHaveBeenCalled();
|
|
2270
|
+
expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
|
|
2271
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2272
|
+
expect.stringContaining("Could not back up 'my-local'"),
|
|
2273
|
+
);
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
test("platform snapshot failure (never ready) aborts too", async () => {
|
|
2277
|
+
setArgv("--from", "my-platform", "--local", "my-local");
|
|
2278
|
+
const platformEntry = makeEntry("my-platform", {
|
|
2279
|
+
cloud: "vellum",
|
|
2280
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
2281
|
+
});
|
|
2282
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2283
|
+
findAssistantByNameMock.mockImplementation((name: string) => {
|
|
2284
|
+
if (name === "my-platform") return platformEntry;
|
|
2285
|
+
if (name === "my-local") return localEntry;
|
|
2286
|
+
return null;
|
|
2287
|
+
});
|
|
2288
|
+
createPlatformBackupMock.mockRejectedValue(
|
|
2289
|
+
new Error("Platform backup snap-1 was not ready after 600s"),
|
|
2290
|
+
);
|
|
2291
|
+
|
|
2292
|
+
const restoreFetch = installTrackingFetch();
|
|
2293
|
+
try {
|
|
2294
|
+
await expect(teleport()).rejects.toThrow("process.exit:1");
|
|
2295
|
+
} finally {
|
|
2296
|
+
restoreFetch();
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
2300
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2301
|
+
expect.stringContaining("was not ready"),
|
|
2302
|
+
);
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
test("dry-run never backs up the source", async () => {
|
|
2306
|
+
setArgv("--from", "my-local", "--platform", "--dry-run");
|
|
2307
|
+
const localEntry = makeEntry("my-local", { cloud: "local" });
|
|
2308
|
+
findAssistantByNameMock.mockImplementation((name: string) =>
|
|
2309
|
+
name === "my-local" ? localEntry : null,
|
|
2310
|
+
);
|
|
2311
|
+
|
|
2312
|
+
await teleport();
|
|
2313
|
+
|
|
2314
|
+
expect(listAssistantBackupTimesMock).not.toHaveBeenCalled();
|
|
2315
|
+
expect(createBackupMock).not.toHaveBeenCalled();
|
|
2316
|
+
});
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2022
2319
|
describe("pre-check: existing platform assistant", () => {
|
|
2023
2320
|
test("blocks before any work when pre-check finds existing assistant", async () => {
|
|
2024
2321
|
setArgv("--from", "my-local", "--platform");
|
package/src/commands/teleport.ts
CHANGED
|
@@ -37,6 +37,15 @@ import {
|
|
|
37
37
|
MigrationInProgressError,
|
|
38
38
|
} from "../lib/local-runtime-client.js";
|
|
39
39
|
import { pollJobUntilDone } from "../lib/job-polling.js";
|
|
40
|
+
import {
|
|
41
|
+
createBackup,
|
|
42
|
+
listAssistantBackupTimes,
|
|
43
|
+
pruneOldBackups,
|
|
44
|
+
} from "../lib/backup-ops.js";
|
|
45
|
+
import {
|
|
46
|
+
createPlatformBackup,
|
|
47
|
+
listPlatformBackups,
|
|
48
|
+
} from "../lib/teleport-backup.js";
|
|
40
49
|
import {
|
|
41
50
|
hatchDocker,
|
|
42
51
|
retireDocker,
|
|
@@ -56,6 +65,10 @@ import {
|
|
|
56
65
|
} from "../lib/upgrade-lifecycle.js";
|
|
57
66
|
import { compareVersions } from "../lib/version-compat.js";
|
|
58
67
|
import { join } from "node:path";
|
|
68
|
+
import {
|
|
69
|
+
hasRecentBackup,
|
|
70
|
+
RECENT_BACKUP_MAX_AGE_MS,
|
|
71
|
+
} from "@vellumai/local-mode/teleport-backup-policy";
|
|
59
72
|
|
|
60
73
|
function printHelp(): void {
|
|
61
74
|
console.log(
|
|
@@ -88,6 +101,20 @@ function printHelp(): void {
|
|
|
88
101
|
);
|
|
89
102
|
console.log("transfers (e.g. local to local) are not supported.");
|
|
90
103
|
console.log("");
|
|
104
|
+
console.log(
|
|
105
|
+
"Before exporting, the source assistant is backed up. Platform sources",
|
|
106
|
+
);
|
|
107
|
+
console.log(
|
|
108
|
+
"get a PVC snapshot (waited on until restorable); local and docker",
|
|
109
|
+
);
|
|
110
|
+
console.log(
|
|
111
|
+
"sources get a .vbundle written to ~/.local/share/vellum/backups/ on this",
|
|
112
|
+
);
|
|
113
|
+
console.log(
|
|
114
|
+
`machine. A backup of the source taken within the last ${RECENT_BACKUP_MAX_AGE_MS / 60_000} minutes is`,
|
|
115
|
+
);
|
|
116
|
+
console.log("reused. If the backup fails, the teleport is aborted.");
|
|
117
|
+
console.log("");
|
|
91
118
|
console.log(
|
|
92
119
|
"For local-to-docker and docker-to-local transfers, the source assistant",
|
|
93
120
|
);
|
|
@@ -363,6 +390,70 @@ interface ImportResponse {
|
|
|
363
390
|
};
|
|
364
391
|
}
|
|
365
392
|
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Pre-export safety backup of the source
|
|
395
|
+
//
|
|
396
|
+
// Runs before any data leaves the source. Reuses a backup of this assistant
|
|
397
|
+
// younger than RECENT_BACKUP_MAX_AGE_MS; otherwise takes one and blocks until
|
|
398
|
+
// it is usable. Any failure aborts the teleport: the source is retired at the
|
|
399
|
+
// end of a successful teleport, and that is only safe with a restore point
|
|
400
|
+
// behind it.
|
|
401
|
+
//
|
|
402
|
+
// Local and docker sources are exported to the host's CLI backup directory
|
|
403
|
+
// rather than snapshotted by their own gateway: docker retirement removes
|
|
404
|
+
// every source volume (including the gateway's backup pool), and bare-metal
|
|
405
|
+
// gateways share one unlabelled pool across assistants, so neither would
|
|
406
|
+
// give this assistant a restore point that outlives the teleport.
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
/** Export timeout for the host-side pre-teleport backup of a local/docker source. */
|
|
410
|
+
const LOCAL_SOURCE_BACKUP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
411
|
+
|
|
412
|
+
async function backupSourceBeforeTeleport(
|
|
413
|
+
entry: AssistantEntry,
|
|
414
|
+
cloud: string,
|
|
415
|
+
displayName: string,
|
|
416
|
+
): Promise<void> {
|
|
417
|
+
console.log(`Backing up ${displayName} (${cloud})...`);
|
|
418
|
+
try {
|
|
419
|
+
if (cloud === "vellum") {
|
|
420
|
+
const platformToken = readPlatformToken();
|
|
421
|
+
if (!platformToken) {
|
|
422
|
+
console.error("Not logged in. Run 'vellum login' first.");
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
const createdAts = await listPlatformBackups(entry, platformToken);
|
|
426
|
+
if (hasRecentBackup(createdAts)) {
|
|
427
|
+
console.log("Recent backup found, reusing it.");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
await createPlatformBackup(entry, platformToken);
|
|
431
|
+
console.log("Backup complete.");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (hasRecentBackup(listAssistantBackupTimes(entry.assistantId))) {
|
|
436
|
+
console.log("Recent backup found, reusing it.");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const backupPath = await createBackup(entry.runtimeUrl, entry.assistantId, {
|
|
440
|
+
prefix: `${entry.assistantId}-pre-teleport`,
|
|
441
|
+
description: `Pre-teleport snapshot of ${displayName} (${cloud})`,
|
|
442
|
+
timeoutMs: LOCAL_SOURCE_BACKUP_TIMEOUT_MS,
|
|
443
|
+
});
|
|
444
|
+
if (!backupPath) {
|
|
445
|
+
throw new Error("backup export failed (see warning above)");
|
|
446
|
+
}
|
|
447
|
+
pruneOldBackups(entry.assistantId, 3, "pre-teleport");
|
|
448
|
+
console.log(`Backup saved: ${backupPath}`);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
451
|
+
console.error(`Error: Could not back up '${displayName}': ${msg}`);
|
|
452
|
+
console.error("Teleport aborted; the source assistant was not modified.");
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
366
457
|
// ---------------------------------------------------------------------------
|
|
367
458
|
// Export from source — unified GCS flow
|
|
368
459
|
//
|
|
@@ -1399,6 +1490,8 @@ export async function teleport(): Promise<void> {
|
|
|
1399
1490
|
// where the import will run. For existing targets that's the lockfile's
|
|
1400
1491
|
// runtimeUrl; for fresh hatches it's getPlatformUrl() (which is what
|
|
1401
1492
|
// resolveOrHatchTarget writes to the new entry).
|
|
1493
|
+
await backupSourceBeforeTeleport(fromEntry, fromCloud, from);
|
|
1494
|
+
|
|
1402
1495
|
console.log(`Exporting from ${from} (${fromCloud})...`);
|
|
1403
1496
|
const bundlePlatformUrl = targetPlatformUrl ?? getPlatformUrl();
|
|
1404
1497
|
const { bundleKey } = await exportFromAssistant(
|
|
@@ -1469,6 +1562,8 @@ export async function teleport(): Promise<void> {
|
|
|
1469
1562
|
const bundlePlatformUrl =
|
|
1470
1563
|
fromCloud === "vellum" ? fromEntry.runtimeUrl : getPlatformUrl();
|
|
1471
1564
|
|
|
1565
|
+
await backupSourceBeforeTeleport(fromEntry, fromCloud, from);
|
|
1566
|
+
|
|
1472
1567
|
// Export from source (bundle lives in GCS after this returns).
|
|
1473
1568
|
console.log(`Exporting from ${from} (${fromCloud})...`);
|
|
1474
1569
|
const { bundleKey } = await exportFromAssistant(
|
|
@@ -83,6 +83,7 @@ describe("buildServiceRunArgs — assistant", () => {
|
|
|
83
83
|
const args = buildAssistantArgs();
|
|
84
84
|
expect(args).toContain("IS_CONTAINERIZED=true");
|
|
85
85
|
expect(args).toContain("VELLUM_WORKSPACE_DIR=/workspace");
|
|
86
|
+
expect(args).toContain("CES_BOOTSTRAP_SOCKET_DIR=/run/ces-bootstrap");
|
|
86
87
|
expect(args).toContain(`VELLUM_ASSISTANT_NAME=${instanceName}`);
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -9,6 +9,8 @@ import { tmpdir } from "node:os";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
11
11
|
|
|
12
|
+
import { resolveIpcEndpoint } from "@vellumai/ipc-server-utils";
|
|
13
|
+
|
|
12
14
|
import { resolveCesSocketPath, startCes } from "../local.js";
|
|
13
15
|
|
|
14
16
|
// ---------------------------------------------------------------------------
|
|
@@ -106,7 +108,7 @@ describe("startCes", () => {
|
|
|
106
108
|
// startCes unlinks the stale socket, then polls for it. We create it with
|
|
107
109
|
// a slight delay so the poll catches it.
|
|
108
110
|
setTimeout(() => {
|
|
109
|
-
const socketDir = join(vellumDir, "
|
|
111
|
+
const socketDir = join(vellumDir, "protected", "credential-executor");
|
|
110
112
|
mkdirSync(socketDir, { recursive: true });
|
|
111
113
|
writeFileSync(join(socketDir, "ces.sock"), "");
|
|
112
114
|
}, 50);
|
|
@@ -126,7 +128,9 @@ describe("startCes", () => {
|
|
|
126
128
|
|
|
127
129
|
// Verify env vars
|
|
128
130
|
const env = lastSpawnCall!.options.env!;
|
|
129
|
-
expect(env["
|
|
131
|
+
expect(env["CES_BOOTSTRAP_SOCKET_DIR"]).toBe(
|
|
132
|
+
join(tempDir, ".vellum", "protected", "credential-executor"),
|
|
133
|
+
);
|
|
130
134
|
expect(env["CREDENTIAL_SECURITY_DIR"]).toBeDefined();
|
|
131
135
|
expect(env["VELLUM_WORKSPACE_DIR"]).toBeDefined();
|
|
132
136
|
|
|
@@ -143,4 +147,23 @@ describe("startCes", () => {
|
|
|
143
147
|
expect(socketPath.startsWith("\\\\.\\pipe\\vellum-ces-")).toBe(true);
|
|
144
148
|
expect(socketPath).not.toContain("Example");
|
|
145
149
|
});
|
|
150
|
+
|
|
151
|
+
test("POSIX local CES seam matches resolveIpcEndpoint", () => {
|
|
152
|
+
const resources = {
|
|
153
|
+
instanceDir: "/tmp/assistant-123",
|
|
154
|
+
} as Parameters<typeof resolveCesSocketPath>[0];
|
|
155
|
+
|
|
156
|
+
const socketPath = resolveCesSocketPath(resources, "linux");
|
|
157
|
+
expect(socketPath).toBe(
|
|
158
|
+
resolveIpcEndpoint("ces", {
|
|
159
|
+
workspaceDir: join(
|
|
160
|
+
"/tmp/assistant-123",
|
|
161
|
+
".vellum",
|
|
162
|
+
"protected",
|
|
163
|
+
"credential-executor",
|
|
164
|
+
),
|
|
165
|
+
platform: "linux",
|
|
166
|
+
}).path,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
146
169
|
});
|
package/src/lib/backup-ops.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
mkdirSync,
|
|
4
4
|
readdirSync,
|
|
5
5
|
readFileSync,
|
|
6
|
+
statSync,
|
|
6
7
|
unlinkSync,
|
|
7
8
|
writeFileSync,
|
|
8
9
|
} from "fs";
|
|
@@ -60,8 +61,9 @@ async function getGuardianAccessToken(
|
|
|
60
61
|
export async function createBackup(
|
|
61
62
|
runtimeUrl: string,
|
|
62
63
|
assistantId: string,
|
|
63
|
-
options?: { prefix?: string; description?: string },
|
|
64
|
+
options?: { prefix?: string; description?: string; timeoutMs?: number },
|
|
64
65
|
): Promise<string | null> {
|
|
66
|
+
const timeoutMs = options?.timeoutMs ?? 120_000;
|
|
65
67
|
try {
|
|
66
68
|
let accessToken = await getGuardianAccessToken(runtimeUrl, assistantId);
|
|
67
69
|
if (!accessToken) {
|
|
@@ -82,7 +84,7 @@ export async function createBackup(
|
|
|
82
84
|
body: JSON.stringify({
|
|
83
85
|
description: options?.description ?? "CLI backup",
|
|
84
86
|
}),
|
|
85
|
-
signal: AbortSignal.timeout(
|
|
87
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
86
88
|
},
|
|
87
89
|
);
|
|
88
90
|
|
|
@@ -106,7 +108,7 @@ export async function createBackup(
|
|
|
106
108
|
body: JSON.stringify({
|
|
107
109
|
description: options?.description ?? "CLI backup",
|
|
108
110
|
}),
|
|
109
|
-
signal: AbortSignal.timeout(
|
|
111
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
110
112
|
});
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -283,19 +285,84 @@ export async function restoreBackup(
|
|
|
283
285
|
}
|
|
284
286
|
}
|
|
285
287
|
|
|
288
|
+
/** Filename kinds this CLI writes: `<assistantId>-<kind>-<timestamp>.vbundle`. */
|
|
289
|
+
export const CLI_BACKUP_KINDS = ["pre-upgrade", "pre-teleport"] as const;
|
|
290
|
+
export type CliBackupKind = (typeof CLI_BACKUP_KINDS)[number];
|
|
291
|
+
|
|
292
|
+
/** `new Date().toISOString().replace(/[:.]/g, "-")`, as used in every CLI backup filename. */
|
|
293
|
+
const BACKUP_TIMESTAMP_PATTERN =
|
|
294
|
+
"\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z";
|
|
295
|
+
|
|
296
|
+
function escapeRegExp(value: string): string {
|
|
297
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Matches the backup filenames this CLI wrote for exactly `assistantId` and
|
|
302
|
+
* one of `kinds`. The kind and timestamp segments are matched in full, so an
|
|
303
|
+
* assistant whose id is a prefix of another's (`alpha` vs `alpha-prod`, or
|
|
304
|
+
* `alpha` vs `alpha-pre-teleport-prod`) never matches the other's files.
|
|
305
|
+
*/
|
|
306
|
+
export function assistantBackupFilenamePattern(
|
|
307
|
+
assistantId: string,
|
|
308
|
+
kinds: readonly CliBackupKind[] = CLI_BACKUP_KINDS,
|
|
309
|
+
): RegExp {
|
|
310
|
+
return new RegExp(
|
|
311
|
+
`^${escapeRegExp(assistantId)}-(?:${kinds.join("|")})-${BACKUP_TIMESTAMP_PATTERN}\\.vbundle$`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Modification times of the `.vbundle` backups this CLI has written for
|
|
317
|
+
* `assistantId` (every kind in `CLI_BACKUP_KINDS`), as ISO timestamps.
|
|
318
|
+
* Missing directory yields `[]`.
|
|
319
|
+
*/
|
|
320
|
+
export function listAssistantBackupTimes(assistantId: string): string[] {
|
|
321
|
+
const backupsDir = getBackupsDir();
|
|
322
|
+
let names: string[];
|
|
323
|
+
try {
|
|
324
|
+
names = readdirSync(backupsDir);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
const pattern = assistantBackupFilenamePattern(assistantId);
|
|
332
|
+
const times: string[] = [];
|
|
333
|
+
for (const name of names) {
|
|
334
|
+
if (!pattern.test(name)) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
times.push(statSync(join(backupsDir, name)).mtime.toISOString());
|
|
339
|
+
} catch (err) {
|
|
340
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return times;
|
|
346
|
+
}
|
|
347
|
+
|
|
286
348
|
/**
|
|
287
|
-
* Keep only the N most recent
|
|
288
|
-
* deleting older ones.
|
|
349
|
+
* Keep only the N most recent backups of one `kind` for an assistant,
|
|
350
|
+
* deleting older ones. Filenames are matched exactly (id, kind and
|
|
351
|
+
* timestamp), never by prefix. Default: keep 3 pre-upgrade backups.
|
|
289
352
|
* Never throws — failures are silently ignored.
|
|
290
353
|
*/
|
|
291
|
-
export function pruneOldBackups(
|
|
354
|
+
export function pruneOldBackups(
|
|
355
|
+
assistantId: string,
|
|
356
|
+
keep: number = 3,
|
|
357
|
+
kind: CliBackupKind = "pre-upgrade",
|
|
358
|
+
): void {
|
|
292
359
|
try {
|
|
293
360
|
const backupsDir = getBackupsDir();
|
|
294
361
|
if (!existsSync(backupsDir)) return;
|
|
295
362
|
|
|
296
|
-
const
|
|
363
|
+
const pattern = assistantBackupFilenamePattern(assistantId, [kind]);
|
|
297
364
|
const entries = readdirSync(backupsDir)
|
|
298
|
-
.filter((f) =>
|
|
365
|
+
.filter((f) => pattern.test(f))
|
|
299
366
|
.sort();
|
|
300
367
|
|
|
301
368
|
if (entries.length <= keep) return;
|