@vellumai/cli 0.12.1 → 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.
@@ -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");
@@ -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, "workspace");
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["CES_LOCAL_SOCKET"]).toBeDefined();
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
  });
@@ -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(120_000),
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(120_000),
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 pre-upgrade backups for an assistant,
288
- * deleting older ones. Default: keep 3.
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(assistantId: string, keep: number = 3): void {
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 prefix = `${assistantId}-pre-upgrade-`;
363
+ const pattern = assistantBackupFilenamePattern(assistantId, [kind]);
297
364
  const entries = readdirSync(backupsDir)
298
- .filter((f) => f.startsWith(prefix) && f.endsWith(".vbundle"))
365
+ .filter((f) => pattern.test(f))
299
366
  .sort();
300
367
 
301
368
  if (entries.length <= keep) return;