@vellumai/cli 0.6.6 → 0.7.1

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.
Files changed (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  findAssistantByName,
3
3
  loadAllAssistants,
4
+ getDaemonPidPath,
4
5
  removeAssistantEntry,
5
6
  saveAssistantEntry,
6
7
  setActiveAssistant,
@@ -16,16 +17,10 @@ import {
16
17
  getPlatformUrl,
17
18
  hatchAssistant,
18
19
  checkExistingPlatformAssistant,
19
- platformInitiateExport,
20
- platformPollExportStatus,
21
- platformDownloadExport,
22
- platformImportPreflight,
23
- platformImportBundle,
24
- platformRequestUploadUrl,
25
- platformUploadToSignedUrl,
26
- platformImportPreflightFromGcs,
20
+ platformPollJobStatus,
27
21
  platformImportBundleFromGcs,
28
- platformPollImportStatus,
22
+ platformImportPreflightFromGcs,
23
+ platformRequestSignedUrl,
29
24
  ensureSelfHostedLocalRegistration,
30
25
  readGatewayCredential,
31
26
  reprovisionAssistantApiKey,
@@ -33,6 +28,13 @@ import {
33
28
  fetchCurrentUser,
34
29
  fetchOrganizationId,
35
30
  } from "../lib/platform-client.js";
31
+ import {
32
+ localRuntimeExportToGcs,
33
+ localRuntimeImportFromGcs,
34
+ localRuntimePollJobStatus,
35
+ MigrationInProgressError,
36
+ } from "../lib/local-runtime-client.js";
37
+ import { pollJobUntilDone } from "../lib/job-polling.js";
36
38
  import {
37
39
  hatchDocker,
38
40
  retireDocker,
@@ -221,11 +223,17 @@ async function getAccessToken(
221
223
  runtimeUrl: string,
222
224
  assistantId: string,
223
225
  displayName: string,
226
+ options?: { forceRefresh?: boolean },
224
227
  ): Promise<string> {
225
- const tokenData = loadGuardianToken(assistantId);
226
-
227
- if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
228
- return tokenData.accessToken;
228
+ // When forceRefresh is set (e.g. after a runtime 401 on the cached token)
229
+ // we skip the cache and lease a brand-new token from the gateway, so a
230
+ // stale-but-unexpired token can't keep failing on every retry.
231
+ if (!options?.forceRefresh) {
232
+ const tokenData = loadGuardianToken(assistantId);
233
+
234
+ if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
235
+ return tokenData.accessToken;
236
+ }
229
237
  }
230
238
 
231
239
  try {
@@ -244,336 +252,49 @@ async function getAccessToken(
244
252
  }
245
253
  }
246
254
 
247
- // ---------------------------------------------------------------------------
248
- // HTTP-based export/import helpers (shared by local and docker)
249
- // ---------------------------------------------------------------------------
250
-
251
- async function exportViaHttp(
252
- entry: AssistantEntry,
253
- ): Promise<Uint8Array<ArrayBuffer>> {
254
- let accessToken = await getAccessToken(
255
- entry.runtimeUrl,
256
- entry.assistantId,
257
- entry.assistantId,
258
- );
259
-
260
- let response: Response;
261
- try {
262
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
263
- method: "POST",
264
- headers: {
265
- Authorization: `Bearer ${accessToken}`,
266
- "Content-Type": "application/json",
267
- },
268
- body: JSON.stringify({ description: "teleport export" }),
269
- signal: AbortSignal.timeout(120_000),
270
- });
271
-
272
- // Retry once with a fresh token on 401
273
- if (response.status === 401) {
274
- let refreshedToken: string | null = null;
275
- try {
276
- const freshToken = await leaseGuardianToken(
277
- entry.runtimeUrl,
278
- entry.assistantId,
279
- );
280
- refreshedToken = freshToken.accessToken;
281
- } catch {
282
- // If token refresh fails, fall through to the error handler below
283
- }
284
- if (refreshedToken) {
285
- accessToken = refreshedToken;
286
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
287
- method: "POST",
288
- headers: {
289
- Authorization: `Bearer ${accessToken}`,
290
- "Content-Type": "application/json",
291
- },
292
- body: JSON.stringify({ description: "teleport export" }),
293
- signal: AbortSignal.timeout(120_000),
294
- });
295
- }
296
- }
297
- } catch (err) {
298
- if (err instanceof Error && err.name === "TimeoutError") {
299
- console.error("Error: Export request timed out after 2 minutes.");
300
- process.exit(1);
301
- }
302
- const msg = err instanceof Error ? err.message : String(err);
303
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
304
- console.error(
305
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
306
- );
307
- console.error(`Try: vellum wake ${entry.assistantId}`);
308
- process.exit(1);
309
- }
310
- throw err;
311
- }
312
-
313
- if (response.status === 401 || response.status === 403) {
314
- console.error("Authentication failed.");
315
- process.exit(1);
316
- }
317
-
318
- if (response.status === 404) {
319
- console.error("Assistant not found or not running.");
320
- process.exit(1);
321
- }
322
-
323
- if (
324
- response.status === 502 ||
325
- response.status === 503 ||
326
- response.status === 504
327
- ) {
328
- console.error(
329
- `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
330
- );
331
- process.exit(1);
332
- }
333
-
334
- if (!response.ok) {
335
- const body = await response.text();
336
- console.error(`Error: Export failed (${response.status}): ${body}`);
337
- process.exit(1);
338
- }
339
-
340
- const arrayBuffer = await response.arrayBuffer();
341
- return new Uint8Array(arrayBuffer);
255
+ /**
256
+ * Detect a 401 Unauthorized raised by `localRuntimeExportToGcs` /
257
+ * `localRuntimeImportFromGcs`. Both throw Error with a message of the form
258
+ * `"Local runtime <op> failed (401): ..."` when the gateway rejects the
259
+ * cached guardian token.
260
+ */
261
+ function isRuntime401(err: unknown): boolean {
262
+ const msg = err instanceof Error ? err.message : String(err);
263
+ return /Local runtime [^(]*failed \(401\)/.test(msg);
342
264
  }
343
265
 
344
- async function importViaHttp(
345
- entry: AssistantEntry,
346
- bundleData: Uint8Array<ArrayBuffer>,
347
- dryRun: boolean,
348
- ): Promise<void> {
349
- let accessToken = await getAccessToken(
350
- entry.runtimeUrl,
351
- entry.assistantId,
352
- entry.assistantId,
353
- );
354
-
355
- if (dryRun) {
356
- console.log("Running preflight analysis...\n");
357
-
358
- let response: Response;
359
- try {
360
- response = await fetch(
361
- `${entry.runtimeUrl}/v1/migrations/import-preflight`,
362
- {
363
- method: "POST",
364
- headers: {
365
- Authorization: `Bearer ${accessToken}`,
366
- "Content-Type": "application/octet-stream",
367
- },
368
- body: new Blob([bundleData]),
369
- signal: AbortSignal.timeout(120_000),
370
- },
371
- );
372
-
373
- // Retry once with a fresh token on 401
374
- if (response.status === 401) {
375
- let refreshedToken: string | null = null;
376
- try {
377
- const freshToken = await leaseGuardianToken(
378
- entry.runtimeUrl,
379
- entry.assistantId,
380
- );
381
- refreshedToken = freshToken.accessToken;
382
- } catch {
383
- // If token refresh fails, fall through to the error handler below
384
- }
385
- if (refreshedToken) {
386
- accessToken = refreshedToken;
387
- response = await fetch(
388
- `${entry.runtimeUrl}/v1/migrations/import-preflight`,
389
- {
390
- method: "POST",
391
- headers: {
392
- Authorization: `Bearer ${accessToken}`,
393
- "Content-Type": "application/octet-stream",
394
- },
395
- body: new Blob([bundleData]),
396
- signal: AbortSignal.timeout(120_000),
397
- },
398
- );
399
- }
400
- }
401
- } catch (err) {
402
- if (err instanceof Error && err.name === "TimeoutError") {
403
- console.error("Error: Preflight request timed out after 2 minutes.");
404
- process.exit(1);
405
- }
406
- const msg = err instanceof Error ? err.message : String(err);
407
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
408
- console.error(
409
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
410
- );
411
- console.error(`Try: vellum wake ${entry.assistantId}`);
412
- process.exit(1);
413
- }
414
- throw err;
415
- }
416
-
417
- handleLocalResponseErrors(response, entry.assistantId);
418
-
419
- const result = (await response.json()) as PreflightResponse;
420
- printPreflightSummary(result);
421
- return;
422
- }
423
-
424
- // Actual import
425
- console.log("Importing data...");
426
-
427
- let response: Response;
266
+ /**
267
+ * Run a runtime kickoff (`localRuntimeExportToGcs` / `localRuntimeImportFromGcs`)
268
+ * with a one-shot refresh-and-retry on 401. Matches the pre-rewrite
269
+ * `exportViaHttp`/`importViaHttp` behavior: if the cached guardian token is
270
+ * stale-but-unexpired and the runtime returns 401, we lease a fresh token
271
+ * and retry once. Any other error — or a repeated 401 on the refreshed token
272
+ * — propagates to the caller.
273
+ */
274
+ async function callRuntimeWithAuthRetry<T>(
275
+ runtimeUrl: string,
276
+ assistantId: string,
277
+ fn: (token: string) => Promise<T>,
278
+ ): Promise<T> {
279
+ const firstToken = await getAccessToken(runtimeUrl, assistantId, assistantId);
428
280
  try {
429
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
430
- method: "POST",
431
- headers: {
432
- Authorization: `Bearer ${accessToken}`,
433
- "Content-Type": "application/octet-stream",
434
- },
435
- body: new Blob([bundleData]),
436
- signal: AbortSignal.timeout(300_000),
437
- });
438
-
439
- // Retry once with a fresh token on 401
440
- if (response.status === 401) {
441
- let refreshedToken: string | null = null;
442
- try {
443
- const freshToken = await leaseGuardianToken(
444
- entry.runtimeUrl,
445
- entry.assistantId,
446
- );
447
- refreshedToken = freshToken.accessToken;
448
- } catch {
449
- // If token refresh fails, fall through to the error handler below
450
- }
451
- if (refreshedToken) {
452
- accessToken = refreshedToken;
453
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
454
- method: "POST",
455
- headers: {
456
- Authorization: `Bearer ${accessToken}`,
457
- "Content-Type": "application/octet-stream",
458
- },
459
- body: new Blob([bundleData]),
460
- signal: AbortSignal.timeout(300_000),
461
- });
462
- }
463
- }
281
+ return await fn(firstToken);
464
282
  } catch (err) {
465
- if (err instanceof Error && err.name === "TimeoutError") {
466
- console.error("Error: Import request timed out after 5 minutes.");
467
- process.exit(1);
468
- }
469
- const msg = err instanceof Error ? err.message : String(err);
470
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
471
- console.error(
472
- `Error: Could not connect to assistant '${entry.assistantId}'. Is it running?`,
473
- );
474
- console.error(`Try: vellum wake ${entry.assistantId}`);
475
- process.exit(1);
476
- }
477
- throw err;
478
- }
479
-
480
- handleLocalResponseErrors(response, entry.assistantId);
481
-
482
- const result = (await response.json()) as ImportResponse;
483
- printImportSummary(result);
484
- }
485
-
486
- // ---------------------------------------------------------------------------
487
- // Export from source assistant
488
- // ---------------------------------------------------------------------------
489
-
490
- async function exportFromAssistant(
491
- entry: AssistantEntry,
492
- cloud: string,
493
- ): Promise<Uint8Array<ArrayBuffer>> {
494
- if (cloud === "vellum") {
495
- // Platform source — use Django async export
496
- const token = readPlatformToken();
497
- if (!token) {
498
- console.error("Not logged in. Run 'vellum login' first.");
499
- process.exit(1);
283
+ if (!isRuntime401(err)) {
284
+ throw err;
500
285
  }
501
-
502
- // Initiate export job
503
- const { jobId } = await platformInitiateExport(
504
- token,
505
- "teleport export",
506
- entry.runtimeUrl,
286
+ const refreshedToken = await getAccessToken(
287
+ runtimeUrl,
288
+ assistantId,
289
+ assistantId,
290
+ { forceRefresh: true },
507
291
  );
508
-
509
- console.log(`Export started (job ${jobId})...`);
510
-
511
- // Poll for completion
512
- const POLL_INTERVAL_MS = 2_000;
513
- const TIMEOUT_MS = 5 * 60 * 1_000;
514
- const deadline = Date.now() + TIMEOUT_MS;
515
- let downloadUrl: string | undefined;
516
-
517
- while (Date.now() < deadline) {
518
- let status: { status: string; downloadUrl?: string; error?: string };
519
- try {
520
- status = await platformPollExportStatus(jobId, token, entry.runtimeUrl);
521
- } catch (err) {
522
- const msg = err instanceof Error ? err.message : String(err);
523
- if (msg.includes("not found")) {
524
- throw err;
525
- }
526
- // Re-throw permanent 4xx errors (auth, forbidden, etc.)
527
- // but retry transient 5xx errors
528
- const statusMatch = msg.match(/status check failed: (\d+)/);
529
- if (statusMatch) {
530
- const statusCode = parseInt(statusMatch[1], 10);
531
- if (statusCode >= 400 && statusCode < 500) {
532
- throw err;
533
- }
534
- }
535
- // Transient error — retry
536
- console.warn(`Polling failed, retrying... (${msg})`);
537
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
538
- continue;
539
- }
540
-
541
- if (status.status === "complete") {
542
- downloadUrl = status.downloadUrl;
543
- break;
544
- }
545
-
546
- if (status.status === "failed") {
547
- console.error(`Export failed: ${status.error ?? "unknown error"}`);
548
- process.exit(1);
549
- }
550
-
551
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
552
- }
553
-
554
- if (!downloadUrl) {
555
- console.error("Export timed out after 5 minutes.");
556
- process.exit(1);
557
- }
558
-
559
- // Download the bundle
560
- const response = await platformDownloadExport(downloadUrl);
561
- const arrayBuffer = await response.arrayBuffer();
562
- return new Uint8Array(arrayBuffer);
563
- }
564
-
565
- if (cloud === "local" || cloud === "docker") {
566
- return exportViaHttp(entry);
292
+ return await fn(refreshedToken);
567
293
  }
568
-
569
- console.error(
570
- "Teleport only supports local, docker, and platform assistants as source.",
571
- );
572
- process.exit(1);
573
294
  }
574
295
 
575
296
  // ---------------------------------------------------------------------------
576
- // Import into target assistant
297
+ // Summary response shapes (reused by the GCS job result payload)
577
298
  // ---------------------------------------------------------------------------
578
299
 
579
300
  interface PreflightFileEntry {
@@ -625,86 +346,214 @@ interface ImportResponse {
625
346
  };
626
347
  }
627
348
 
628
- async function importToAssistant(
349
+ // ---------------------------------------------------------------------------
350
+ // Export from source — unified GCS flow
351
+ //
352
+ // Every source (local, docker, platform) produces a `bundleKey` referring to
353
+ // a bundle sitting in GCS. The CLI never holds the bundle bytes.
354
+ // ---------------------------------------------------------------------------
355
+
356
+ async function exportFromAssistant(
629
357
  entry: AssistantEntry,
630
358
  cloud: string,
631
- bundleData: Uint8Array<ArrayBuffer>,
632
- dryRun: boolean,
633
- preUploadedBundleKey?: string | null,
634
- ): Promise<void> {
635
- if (cloud === "vellum") {
636
- // Platform target
637
- const token = readPlatformToken();
638
- if (!token) {
639
- console.error("Not logged in. Run 'vellum login' first.");
640
- process.exit(1);
359
+ bundlePlatformUrl?: string,
360
+ ): Promise<{ bundleKey: string }> {
361
+ const platformToken = readPlatformToken();
362
+ if (!platformToken) {
363
+ console.error(
364
+ "Not logged in. Run 'vellum login' first (required for GCS-based teleport).",
365
+ );
366
+ process.exit(1);
367
+ }
368
+
369
+ if (cloud === "local" || cloud === "docker") {
370
+ // Request a signed upload URL from the platform instance that will
371
+ // eventually own the bundle (i.e. the one the importer will read from).
372
+ // Passing the target's runtime URL here keeps upload and download on
373
+ // the same platform — otherwise a non-default/stale platform URL would
374
+ // cause the import to look at an empty object.
375
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
376
+ { operation: "upload" },
377
+ platformToken,
378
+ bundlePlatformUrl,
379
+ );
380
+
381
+ // Wrap the kickoff in a one-shot refresh-and-retry helper so a stale-but-
382
+ // unexpired cached guardian token surfaces as 401 → re-lease → retry
383
+ // rather than a terminal failure. `accessToken` below is whichever token
384
+ // succeeded on the kickoff; we reuse it for polling so the runtime sees a
385
+ // consistent credential throughout the migration.
386
+ let jobId: string;
387
+ let accessToken: string;
388
+ try {
389
+ const result = await callRuntimeWithAuthRetry(
390
+ entry.runtimeUrl,
391
+ entry.assistantId,
392
+ async (token) => {
393
+ const r = await localRuntimeExportToGcs(entry, token, {
394
+ uploadUrl,
395
+ description: "teleport export",
396
+ });
397
+ return { jobId: r.jobId, token };
398
+ },
399
+ );
400
+ jobId = result.jobId;
401
+ accessToken = result.token;
402
+ } catch (err) {
403
+ if (err instanceof MigrationInProgressError) {
404
+ // Fail fast — the existing job is writing to a different GCS object
405
+ // (its caller's signed URL, not ours), so polling it would leave us
406
+ // pointing at an empty/unrelated bundle. Surface the existing job id
407
+ // so the user can decide whether to wait or investigate.
408
+ console.error(
409
+ `Error: Another teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish or check its status, then re-run.`,
410
+ );
411
+ process.exit(1);
412
+ }
413
+ throw err;
641
414
  }
642
415
 
643
- // Use pre-uploaded bundle key if provided (string), skip upload if null
644
- // (signals signed URLs were already tried and unavailable), or try
645
- // signed-URL upload if undefined (never attempted).
646
- let bundleKey: string | undefined =
647
- preUploadedBundleKey === null ? undefined : preUploadedBundleKey;
648
- if (preUploadedBundleKey === undefined) {
649
- try {
650
- const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
651
- token,
416
+ console.log(`Export started (job ${jobId})...`);
417
+
418
+ const terminal = await pollJobUntilDone({
419
+ label: "local-runtime export",
420
+ poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
421
+ // Large exports can take longer than a guardian-token lease. If the
422
+ // runtime returns 401 mid-poll, re-lease a fresh token and rebind the
423
+ // closure variable so the next poll uses it.
424
+ refreshOn401: async () => {
425
+ accessToken = await getAccessToken(
652
426
  entry.runtimeUrl,
427
+ entry.assistantId,
428
+ entry.assistantId,
429
+ { forceRefresh: true },
653
430
  );
654
- bundleKey = key;
655
- console.log("Uploading bundle...");
656
- await platformUploadToSignedUrl(uploadUrl, bundleData);
657
- } catch (err) {
658
- // If signed uploads unavailable (503), fall back to inline upload
659
- const msg = err instanceof Error ? err.message : String(err);
660
- if (msg.includes("not available")) {
661
- bundleKey = undefined;
662
- } else {
663
- throw err;
664
- }
431
+ },
432
+ });
433
+
434
+ if (terminal.status === "failed") {
435
+ console.error(`Export failed: ${terminal.error}`);
436
+ process.exit(1);
437
+ }
438
+
439
+ return { bundleKey };
440
+ }
441
+
442
+ if (cloud === "vellum") {
443
+ // Platform source — request a signed upload URL on the same platform
444
+ // instance the bundle will eventually be imported from, then ask the
445
+ // managed runtime to export directly to GCS. The runtime endpoint is
446
+ // reached via the platform's wildcard runtime proxy at
447
+ // `/v1/assistants/<id>/migrations/export-to-gcs` — the
448
+ // `localRuntimeExportToGcs` helper uses `resolveRuntimeMigrationUrl` to
449
+ // pick that shape for `cloud === "vellum"` and `migrationRequestHeaders`
450
+ // to send platform-token auth (no guardian-token bootstrap).
451
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
452
+ { operation: "upload" },
453
+ platformToken,
454
+ bundlePlatformUrl,
455
+ );
456
+
457
+ let jobId: string;
458
+ let exportPlatformToken = platformToken;
459
+ try {
460
+ ({ jobId } = await localRuntimeExportToGcs(entry, exportPlatformToken, {
461
+ uploadUrl,
462
+ description: "teleport export",
463
+ }));
464
+ } catch (err) {
465
+ if (err instanceof MigrationInProgressError) {
466
+ console.error(
467
+ `Error: Another teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish or check its status, then re-run.`,
468
+ );
469
+ process.exit(1);
665
470
  }
471
+ throw err;
472
+ }
473
+
474
+ console.log(`Export started (job ${jobId})...`);
475
+
476
+ // Polling also goes through the wildcard proxy — `localRuntimePollJobStatus`
477
+ // builds `/v1/assistants/<id>/migrations/jobs/<jobId>` for `cloud === "vellum"`
478
+ // (the dedicated `/v1/migrations/jobs/{id}/` endpoint queries platform-side
479
+ // ImportJob records and 404s on runtime-created job IDs).
480
+ const terminal = await pollJobUntilDone({
481
+ label: "platform export",
482
+ poll: () => localRuntimePollJobStatus(entry, exportPlatformToken, jobId),
483
+ // The platform token is normally static per-process, but re-reading the
484
+ // on-disk credential covers the case where the user ran `vellum login`
485
+ // in another terminal during a long migration. A persistent 401 after
486
+ // a re-read surfaces to the caller with a clear next step.
487
+ refreshOn401: async () => {
488
+ const refreshed = readPlatformToken();
489
+ if (!refreshed) {
490
+ throw new Error(
491
+ "Platform auth expired during export and no credential was found on disk. Run 'vellum login' and retry.",
492
+ );
493
+ }
494
+ exportPlatformToken = refreshed;
495
+ },
496
+ });
497
+
498
+ if (terminal.status === "failed") {
499
+ console.error(`Export failed: ${terminal.error}`);
500
+ process.exit(1);
666
501
  }
667
502
 
503
+ return { bundleKey };
504
+ }
505
+
506
+ console.error(
507
+ "Teleport only supports local, docker, and platform assistants as source.",
508
+ );
509
+ process.exit(1);
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // Import into target — unified GCS flow
514
+ // ---------------------------------------------------------------------------
515
+
516
+ async function importToAssistant(
517
+ entry: AssistantEntry,
518
+ cloud: string,
519
+ bundleKey: string,
520
+ dryRun: boolean,
521
+ bundlePlatformUrl?: string,
522
+ ): Promise<void> {
523
+ const platformToken = readPlatformToken();
524
+ if (!platformToken) {
525
+ console.error(
526
+ "Not logged in. Run 'vellum login' first (required for GCS-based teleport).",
527
+ );
528
+ process.exit(1);
529
+ }
530
+
531
+ if (cloud === "vellum") {
532
+ // Platform target — the bundle is already in GCS; kick off preflight or
533
+ // async import via the unified job-status endpoint.
668
534
  if (dryRun) {
669
535
  console.log("Running preflight analysis...\n");
670
536
 
671
- let preflightResult: {
672
- statusCode: number;
673
- body: Record<string, unknown>;
674
- };
675
- try {
676
- preflightResult = bundleKey
677
- ? await platformImportPreflightFromGcs(
678
- bundleKey,
679
- token,
680
- entry.runtimeUrl,
681
- )
682
- : await platformImportPreflight(bundleData, token, entry.runtimeUrl);
683
- } catch (err) {
684
- if (err instanceof Error && err.name === "TimeoutError") {
685
- console.error("Error: Preflight request timed out after 2 minutes.");
686
- process.exit(1);
687
- }
688
- throw err;
689
- }
537
+ const preflight = await platformImportPreflightFromGcs(
538
+ bundleKey,
539
+ platformToken,
540
+ entry.runtimeUrl,
541
+ );
690
542
 
691
- if (
692
- preflightResult.statusCode === 401 ||
693
- preflightResult.statusCode === 403
694
- ) {
543
+ if (preflight.statusCode === 401 || preflight.statusCode === 403) {
695
544
  console.error("Authentication failed. Run 'vellum login' to refresh.");
696
545
  process.exit(1);
697
546
  }
698
547
 
699
- if (preflightResult.statusCode === 404) {
548
+ if (preflight.statusCode === 404) {
700
549
  console.error("Assistant not found or not running.");
701
550
  process.exit(1);
702
551
  }
703
552
 
704
553
  if (
705
- preflightResult.statusCode === 502 ||
706
- preflightResult.statusCode === 503 ||
707
- preflightResult.statusCode === 504
554
+ preflight.statusCode === 502 ||
555
+ preflight.statusCode === 503 ||
556
+ preflight.statusCode === 504
708
557
  ) {
709
558
  console.error(
710
559
  `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
@@ -712,35 +561,53 @@ async function importToAssistant(
712
561
  process.exit(1);
713
562
  }
714
563
 
715
- if (preflightResult.statusCode !== 200) {
564
+ if (preflight.statusCode !== 200) {
716
565
  console.error(
717
- `Error: Preflight check failed (${preflightResult.statusCode}): ${JSON.stringify(preflightResult.body)}`,
566
+ `Error: Preflight check failed (${preflight.statusCode}): ${JSON.stringify(preflight.body)}`,
718
567
  );
719
568
  process.exit(1);
720
569
  }
721
570
 
722
- const result = preflightResult.body as unknown as PreflightResponse;
571
+ const result = preflight.body as unknown as PreflightResponse;
723
572
  printPreflightSummary(result);
724
573
  return;
725
574
  }
726
575
 
727
- // Actual import
728
576
  console.log("Importing data...");
729
577
 
730
- let importResult: { statusCode: number; body: Record<string, unknown> };
731
- try {
732
- importResult = bundleKey
733
- ? await platformImportBundleFromGcs(bundleKey, token, entry.runtimeUrl)
734
- : await platformImportBundle(bundleData, token, entry.runtimeUrl);
735
- } catch (err) {
736
- if (err instanceof Error && err.name === "TimeoutError") {
737
- console.error("Error: Import request timed out.");
738
- process.exit(1);
739
- }
740
- throw err;
578
+ const importResult = await platformImportBundleFromGcs(
579
+ bundleKey,
580
+ platformToken,
581
+ entry.runtimeUrl,
582
+ );
583
+
584
+ if (importResult.statusCode === 401 || importResult.statusCode === 403) {
585
+ console.error("Authentication failed. Run 'vellum login' to refresh.");
586
+ process.exit(1);
587
+ }
588
+
589
+ if (importResult.statusCode === 404) {
590
+ console.error("Assistant not found or not running.");
591
+ process.exit(1);
741
592
  }
742
593
 
743
- handleImportStatusErrors(importResult.statusCode, entry.assistantId);
594
+ if (
595
+ importResult.statusCode === 502 ||
596
+ importResult.statusCode === 503 ||
597
+ importResult.statusCode === 504
598
+ ) {
599
+ console.error(
600
+ `Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
601
+ );
602
+ process.exit(1);
603
+ }
604
+
605
+ if (importResult.statusCode !== 202 && importResult.statusCode !== 200) {
606
+ console.error(`Error: Import failed (${importResult.statusCode})`);
607
+ process.exit(1);
608
+ }
609
+
610
+ let finalBody: Record<string, unknown> = importResult.body;
744
611
 
745
612
  if (importResult.statusCode === 202) {
746
613
  const jobId = (importResult.body as { job_id?: string }).job_id;
@@ -749,74 +616,106 @@ async function importToAssistant(
749
616
  process.exit(1);
750
617
  }
751
618
 
752
- const POLL_INTERVAL_MS = 5_000;
753
- const TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes (platform staleness is 930s)
754
- const startTime = Date.now();
755
- const deadline = startTime + TIMEOUT_MS;
756
-
757
- while (Date.now() < deadline) {
758
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
759
-
760
- let status: {
761
- status: string;
762
- result?: Record<string, unknown>;
763
- error?: string;
764
- };
765
- try {
766
- status = await platformPollImportStatus(
767
- jobId,
768
- token,
769
- entry.runtimeUrl,
770
- );
771
- } catch (err) {
772
- const msg = err instanceof Error ? err.message : String(err);
773
- if (msg.includes("not found")) {
774
- throw err;
775
- }
776
- // Re-throw permanent 4xx errors (auth, forbidden, etc.)
777
- // but retry transient 5xx errors
778
- const statusMatch = msg.match(/status check failed: (\d+)/);
779
- if (statusMatch) {
780
- const statusCode = parseInt(statusMatch[1], 10);
781
- if (statusCode >= 400 && statusCode < 500) {
782
- throw err;
783
- }
619
+ let importPlatformToken = platformToken;
620
+ const terminal = await pollJobUntilDone({
621
+ label: "platform import",
622
+ poll: () =>
623
+ platformPollJobStatus(jobId, importPlatformToken, entry.runtimeUrl),
624
+ refreshOn401: async () => {
625
+ const refreshed = readPlatformToken();
626
+ if (!refreshed) {
627
+ throw new Error(
628
+ "Platform auth expired during import and no credential was found on disk. Run 'vellum login' and retry.",
629
+ );
784
630
  }
785
- // Transient error — retry
786
- console.warn(`Polling failed, retrying... (${msg})`);
787
- continue;
788
- }
789
-
790
- if (status.status === "complete") {
791
- importResult = { statusCode: 200, body: status.result ?? {} };
792
- break;
793
- }
794
-
795
- if (status.status === "failed") {
796
- console.error(`Import failed: ${status.error ?? "unknown error"}`);
797
- process.exit(1);
798
- }
799
-
800
- const elapsed = Math.round((Date.now() - startTime) / 1000);
801
- process.stdout.write(`\rImporting... ${elapsed}s elapsed`);
802
- }
803
-
804
- // Clear the progress line
805
- process.stdout.write("\r" + " ".repeat(40) + "\r");
631
+ importPlatformToken = refreshed;
632
+ },
633
+ });
806
634
 
807
- if (importResult.statusCode === 202) {
808
- console.error("Import timed out after 10 minutes.");
635
+ if (terminal.status === "failed") {
636
+ console.error(`Import failed: ${terminal.error}`);
809
637
  process.exit(1);
810
638
  }
639
+
640
+ finalBody = (terminal.result as Record<string, unknown>) ?? {};
811
641
  }
812
642
 
813
- const result = importResult.body as unknown as ImportResponse;
643
+ const result = finalBody as unknown as ImportResponse;
814
644
  printImportSummary(result);
815
645
  return;
816
646
  }
817
647
 
818
648
  if (cloud === "local" || cloud === "docker") {
819
- await importViaHttp(entry, bundleData, dryRun);
649
+ if (dryRun) {
650
+ // TODO(cli): support dry-run against local targets
651
+ console.error(
652
+ "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
653
+ );
654
+ process.exit(1);
655
+ }
656
+
657
+ // Ask the platform for a signed download URL and hand it to the local
658
+ // runtime. The runtime streams the bundle straight out of GCS — the CLI
659
+ // never touches the bytes. The URL must target the same platform the
660
+ // bundle was uploaded to; otherwise the object won't exist on this
661
+ // platform's GCS bucket.
662
+ const { url: bundleUrl } = await platformRequestSignedUrl(
663
+ { operation: "download", bundleKey },
664
+ platformToken,
665
+ bundlePlatformUrl,
666
+ );
667
+
668
+ console.log("Importing data...");
669
+
670
+ let jobId: string;
671
+ let accessToken: string;
672
+ try {
673
+ const result = await callRuntimeWithAuthRetry(
674
+ entry.runtimeUrl,
675
+ entry.assistantId,
676
+ async (token) => {
677
+ const r = await localRuntimeImportFromGcs(entry, token, {
678
+ bundleUrl,
679
+ });
680
+ return { jobId: r.jobId, token };
681
+ },
682
+ );
683
+ jobId = result.jobId;
684
+ accessToken = result.token;
685
+ } catch (err) {
686
+ if (err instanceof MigrationInProgressError) {
687
+ // Fail fast — the existing job is importing someone else's bundle
688
+ // (the original caller's), not ours. Polling it would report success
689
+ // on an import that wasn't the one we just kicked off.
690
+ console.error(
691
+ `Error: Another teleport import is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish or check its status, then re-run.`,
692
+ );
693
+ process.exit(1);
694
+ }
695
+ throw err;
696
+ }
697
+
698
+ const terminal = await pollJobUntilDone({
699
+ label: "local-runtime import",
700
+ poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
701
+ refreshOn401: async () => {
702
+ accessToken = await getAccessToken(
703
+ entry.runtimeUrl,
704
+ entry.assistantId,
705
+ entry.assistantId,
706
+ { forceRefresh: true },
707
+ );
708
+ },
709
+ });
710
+
711
+ if (terminal.status === "failed") {
712
+ console.error(`Import failed: ${terminal.error}`);
713
+ process.exit(1);
714
+ }
715
+
716
+ const result = ((terminal.result as Record<string, unknown>) ??
717
+ {}) as unknown as ImportResponse;
718
+ printImportSummary(result);
820
719
  return;
821
720
  }
822
721
 
@@ -952,68 +851,6 @@ export async function resolveOrHatchTarget(
952
851
  process.exit(1);
953
852
  }
954
853
 
955
- // ---------------------------------------------------------------------------
956
- // Error handling helpers
957
- // ---------------------------------------------------------------------------
958
-
959
- function handleLocalResponseErrors(
960
- response: Response,
961
- assistantName: string,
962
- ): void {
963
- if (response.status === 401 || response.status === 403) {
964
- console.error("Authentication failed.");
965
- process.exit(1);
966
- }
967
-
968
- if (response.status === 404) {
969
- console.error("Assistant not found or not running.");
970
- process.exit(1);
971
- }
972
-
973
- if (
974
- response.status === 502 ||
975
- response.status === 503 ||
976
- response.status === 504
977
- ) {
978
- console.error(
979
- `Assistant is unreachable. Try 'vellum wake ${assistantName}'.`,
980
- );
981
- process.exit(1);
982
- }
983
-
984
- if (!response.ok) {
985
- console.error(`Error: Request failed (${response.status})`);
986
- process.exit(1);
987
- }
988
- }
989
-
990
- function handleImportStatusErrors(
991
- statusCode: number,
992
- assistantName: string,
993
- ): void {
994
- if (statusCode === 401 || statusCode === 403) {
995
- console.error("Authentication failed. Run 'vellum login' to refresh.");
996
- process.exit(1);
997
- }
998
-
999
- if (statusCode === 404) {
1000
- console.error("Assistant not found or not running.");
1001
- process.exit(1);
1002
- }
1003
-
1004
- if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
1005
- console.error(
1006
- `Assistant is unreachable. Try 'vellum wake ${assistantName}'.`,
1007
- );
1008
- process.exit(1);
1009
- }
1010
-
1011
- if (statusCode < 200 || statusCode >= 300) {
1012
- console.error(`Error: Import failed (${statusCode})`);
1013
- process.exit(1);
1014
- }
1015
- }
1016
-
1017
854
  // ---------------------------------------------------------------------------
1018
855
  // Summary printing — matches restore.ts format
1019
856
  // ---------------------------------------------------------------------------
@@ -1270,6 +1107,19 @@ export async function teleport(): Promise<void> {
1270
1107
  process.exit(1);
1271
1108
  }
1272
1109
 
1110
+ // Dry-run feasibility check — reject local/docker targets BEFORE any
1111
+ // export work. The local runtime has no preflight-from-gcs endpoint yet,
1112
+ // so we can't actually run a dry-run against it; burning a GCS upload
1113
+ // just to fail afterwards would be wasteful.
1114
+ // TODO(cli): support dry-run against local targets (needs a
1115
+ // preflight-from-gcs endpoint on the runtime).
1116
+ if (toCloud === "local" || toCloud === "docker") {
1117
+ console.error(
1118
+ "Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
1119
+ );
1120
+ process.exit(1);
1121
+ }
1122
+
1273
1123
  // Version guard: block platform→non-platform when target is behind
1274
1124
  if (fromCloud === "vellum" && toCloud !== "vellum") {
1275
1125
  const [sourceVersion, targetVersion] = await Promise.all([
@@ -1292,41 +1142,49 @@ export async function teleport(): Promise<void> {
1292
1142
  }
1293
1143
  }
1294
1144
 
1145
+ // Pin both upload and download to the same platform instance. For
1146
+ // platform targets the bundle is owned by the target platform; for
1147
+ // platform sources it's owned by the source platform. Only one of
1148
+ // these branches applies at a time (same-env was rejected earlier).
1149
+ const bundlePlatformUrl =
1150
+ toCloud === "vellum"
1151
+ ? existingTarget.runtimeUrl
1152
+ : fromCloud === "vellum"
1153
+ ? fromEntry.runtimeUrl
1154
+ : undefined;
1155
+
1295
1156
  console.log(`Exporting from ${from} (${fromCloud})...`);
1296
- const bundleData = await exportFromAssistant(fromEntry, fromCloud);
1157
+ const { bundleKey } = await exportFromAssistant(
1158
+ fromEntry,
1159
+ fromCloud,
1160
+ bundlePlatformUrl,
1161
+ );
1297
1162
  console.log(`Importing to ${existingTarget.assistantId} (${toCloud})...`);
1298
- await importToAssistant(existingTarget, toCloud, bundleData, true);
1163
+ await importToAssistant(
1164
+ existingTarget,
1165
+ toCloud,
1166
+ bundleKey,
1167
+ true,
1168
+ bundlePlatformUrl,
1169
+ );
1299
1170
  } else {
1300
1171
  // No existing target — just describe what would happen
1301
1172
  console.log("Dry run summary:");
1302
1173
  console.log(` Would export data from: ${from} (${fromCloud})`);
1303
- if (targetEnv === "platform") {
1304
- // For platform targets, reflect the reordered flow
1305
- console.log(` Would upload bundle via signed URL (if available)`);
1306
- console.log(
1307
- ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1308
- );
1309
- console.log(` Would import data into the new assistant`);
1310
- } else {
1311
- console.log(
1312
- ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1313
- );
1314
- console.log(` Would import data into the new assistant`);
1315
- }
1174
+ console.log(` Would upload bundle via signed URL`);
1175
+ console.log(
1176
+ ` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
1177
+ );
1178
+ console.log(` Would import data into the new assistant`);
1316
1179
  }
1317
1180
 
1318
1181
  console.log(`Dry run complete — no changes were made.`);
1319
1182
  return;
1320
1183
  }
1321
1184
 
1322
- // Export from source
1323
- console.log(`Exporting from ${from} (${fromCloud})...`);
1324
- const bundleData = await exportFromAssistant(fromEntry, fromCloud);
1325
-
1326
1185
  // Platform target: reordered flow — upload to GCS before hatching so that
1327
- // if upload fails, no empty assistant is left dangling on the platform.
1186
+ // if export/upload fails, no empty assistant is left dangling on the platform.
1328
1187
  if (targetEnv === "platform") {
1329
- // Step B — Auth
1330
1188
  const token = readPlatformToken();
1331
1189
  if (!token) {
1332
1190
  console.error("Not logged in. Run 'vellum login' first.");
@@ -1334,7 +1192,7 @@ export async function teleport(): Promise<void> {
1334
1192
  }
1335
1193
 
1336
1194
  // If targeting an existing assistant, validate cloud match early — before
1337
- // uploading — so we don't waste a GCS upload on an invalid command.
1195
+ // exporting — so we don't waste work on an invalid command.
1338
1196
  const existingTarget = targetName ? findAssistantByName(targetName) : null;
1339
1197
  if (existingTarget) {
1340
1198
  const existingCloud = resolveCloud(existingTarget);
@@ -1347,12 +1205,12 @@ export async function teleport(): Promise<void> {
1347
1205
  }
1348
1206
  }
1349
1207
 
1350
- // Use the existing target's runtimeUrl for all platform calls so upload
1351
- // and import hit the same instance.
1208
+ // Use the existing target's runtimeUrl for all platform calls so the
1209
+ // export, upload, and import all hit the same instance.
1352
1210
  const targetPlatformUrl = existingTarget?.runtimeUrl;
1353
1211
 
1354
- // Step B2 — Pre-check: block if the user already has a platform assistant.
1355
- // This runs BEFORE the expensive GCS upload so we don't waste bandwidth.
1212
+ // Pre-check: block if the user already has a platform assistant. This
1213
+ // runs BEFORE the expensive export so we don't waste the upload.
1356
1214
  if (!existingTarget) {
1357
1215
  const existing = await checkExistingPlatformAssistant(
1358
1216
  token,
@@ -1376,43 +1234,40 @@ export async function teleport(): Promise<void> {
1376
1234
  }
1377
1235
  }
1378
1236
 
1379
- // Step C Upload to GCS
1380
- // bundleKey: string = uploaded successfully, null = tried but unavailable,
1381
- // undefined would mean "never tried" (not used here).
1382
- let bundleKey: string | null = null;
1383
- try {
1384
- const { uploadUrl, bundleKey: key } = await platformRequestUploadUrl(
1385
- token,
1386
- targetPlatformUrl,
1387
- );
1388
- bundleKey = key;
1389
- console.log("Uploading bundle to GCS...");
1390
- await platformUploadToSignedUrl(uploadUrl, bundleData);
1391
- } catch (err) {
1392
- // If signed uploads unavailable (503), fall back to inline upload later
1393
- const msg = err instanceof Error ? err.message : String(err);
1394
- if (msg.includes("not available")) {
1395
- bundleKey = null;
1396
- } else {
1397
- throw err;
1398
- }
1399
- }
1237
+ // Exportfor local/docker sources this uploads straight into GCS via
1238
+ // the platform's signed URL; for platform sources this runs a server-side
1239
+ // export and we read the resulting bundle_key.
1240
+ // The signed upload URL must be requested from the same platform instance
1241
+ // where the import will run. For existing targets that's the lockfile's
1242
+ // runtimeUrl; for fresh hatches it's getPlatformUrl() (which is what
1243
+ // resolveOrHatchTarget writes to the new entry).
1244
+ console.log(`Exporting from ${from} (${fromCloud})...`);
1245
+ const bundlePlatformUrl = targetPlatformUrl ?? getPlatformUrl();
1246
+ const { bundleKey } = await exportFromAssistant(
1247
+ fromEntry,
1248
+ fromCloud,
1249
+ bundlePlatformUrl,
1250
+ );
1400
1251
 
1401
- // Step D — Hatch (upload succeeded or fallback to inline safe to hatch)
1252
+ // Hatch (export succeeded safe to create the target)
1402
1253
  const toEntry = await resolveOrHatchTarget(targetEnv, targetName);
1403
1254
  const toCloud = resolveCloud(toEntry);
1404
1255
 
1405
- // Step E — Import from GCS (or inline fallback)
1406
- // Pass bundleKey (string) or null to signal "already tried, use inline".
1256
+ // Import from GCS
1407
1257
  console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1408
- await importToAssistant(toEntry, toCloud, bundleData, false, bundleKey);
1258
+ await importToAssistant(
1259
+ toEntry,
1260
+ toCloud,
1261
+ bundleKey,
1262
+ false,
1263
+ bundlePlatformUrl,
1264
+ );
1409
1265
 
1410
- // Success summary
1411
1266
  console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
1412
1267
  return;
1413
1268
  }
1414
1269
 
1415
- // Non-platform targets (local/docker): existing flow unchanged
1270
+ // Non-platform targets (local/docker)
1416
1271
  // For local<->docker transfers, stop (sleep) the source to free up ports
1417
1272
  // before hatching the target. We do NOT retire yet — if hatch or import
1418
1273
  // fails, the user can recover by running `vellum wake <source>`.
@@ -1448,16 +1303,34 @@ export async function teleport(): Promise<void> {
1448
1303
  }
1449
1304
  }
1450
1305
 
1306
+ // Pin the bundle's platform instance so upload and download land on the
1307
+ // same GCS bucket. For platform sources the bundle is owned by the source
1308
+ // platform. For local/docker→local/docker the bundle lives on whatever
1309
+ // platform getPlatformUrl() currently resolves to — we resolve it once
1310
+ // here so a lockfile change mid-teleport can't split export and import.
1311
+ const bundlePlatformUrl =
1312
+ fromCloud === "vellum" ? fromEntry.runtimeUrl : getPlatformUrl();
1313
+
1314
+ // Export from source (bundle lives in GCS after this returns).
1315
+ console.log(`Exporting from ${from} (${fromCloud})...`);
1316
+ const { bundleKey } = await exportFromAssistant(
1317
+ fromEntry,
1318
+ fromCloud,
1319
+ bundlePlatformUrl,
1320
+ );
1321
+
1451
1322
  if (sourceIsLocalOrDocker && targetIsLocalOrDocker && !keepSource) {
1452
1323
  console.log(`Stopping source assistant '${from}' to free ports...`);
1453
1324
  if (fromCloud === "docker") {
1454
1325
  const res = dockerResourceNames(fromEntry.assistantId);
1455
1326
  await sleepContainers(res);
1456
1327
  } else if (fromEntry.resources) {
1457
- const pidFile = fromEntry.resources.pidFile;
1458
1328
  const vellumDir = join(fromEntry.resources.instanceDir, ".vellum");
1459
1329
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1460
- await stopProcessByPidFile(pidFile, "assistant");
1330
+ await stopProcessByPidFile(
1331
+ getDaemonPidPath(fromEntry.resources),
1332
+ "assistant",
1333
+ );
1461
1334
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1462
1335
  }
1463
1336
  console.log(`Source assistant '${from}' stopped.`);
@@ -1513,9 +1386,15 @@ export async function teleport(): Promise<void> {
1513
1386
  }
1514
1387
  }
1515
1388
 
1516
- // Import to target
1389
+ // Import to target (also GCS-driven)
1517
1390
  console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
1518
- await importToAssistant(toEntry, toCloud, bundleData, false);
1391
+ await importToAssistant(
1392
+ toEntry,
1393
+ toCloud,
1394
+ bundleKey,
1395
+ false,
1396
+ bundlePlatformUrl,
1397
+ );
1519
1398
 
1520
1399
  // After successful import, inject fresh platform credentials if the
1521
1400
  // user is logged in — replaces the source's stale vellum:* credentials
@@ -1540,6 +1419,5 @@ export async function teleport(): Promise<void> {
1540
1419
  }
1541
1420
  }
1542
1421
 
1543
- // Success summary
1544
1422
  console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
1545
1423
  }