@vellumai/cli 0.6.5 → 0.7.0
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/AGENTS.md +8 -2
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +1 -7
- package/src/__tests__/config-utils.test.ts +159 -0
- package/src/__tests__/env-drift.test.ts +10 -32
- package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
- package/src/__tests__/multi-local.test.ts +0 -5
- package/src/__tests__/sleep.test.ts +1 -2
- package/src/__tests__/teleport.test.ts +919 -1255
- package/src/commands/env.ts +93 -0
- package/src/commands/events.ts +2 -0
- package/src/commands/exec.ts +40 -8
- package/src/commands/hatch.ts +6 -2
- package/src/commands/login.ts +89 -6
- package/src/commands/ps.ts +104 -20
- package/src/commands/retire.ts +23 -0
- package/src/commands/sleep.ts +5 -2
- package/src/commands/ssh.ts +15 -2
- package/src/commands/teleport.ts +447 -583
- package/src/commands/terminal.ts +225 -0
- package/src/commands/wake.ts +2 -1
- package/src/components/DefaultMainScreen.tsx +304 -152
- package/src/index.ts +6 -0
- package/src/lib/__tests__/docker.test.ts +50 -74
- package/src/lib/__tests__/job-polling.test.ts +278 -0
- package/src/lib/__tests__/local-runtime-client.test.ts +383 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
- package/src/lib/assistant-config.ts +12 -8
- package/src/lib/client-identity.ts +67 -0
- package/src/lib/config-utils.ts +97 -1
- package/src/lib/docker.ts +73 -75
- package/src/lib/environments/__tests__/paths.test.ts +2 -0
- package/src/lib/environments/resolve.ts +89 -7
- package/src/lib/environments/seeds.ts +8 -5
- package/src/lib/environments/types.ts +10 -0
- package/src/lib/hatch-local.ts +15 -120
- package/src/lib/health-check.ts +98 -0
- package/src/lib/job-polling.ts +195 -0
- package/src/lib/local-runtime-client.ts +178 -0
- package/src/lib/local.ts +139 -15
- package/src/lib/orphan-detection.ts +2 -35
- package/src/lib/platform-client.ts +215 -0
- package/src/lib/retire-local.ts +6 -2
- package/src/lib/terminal-client.ts +177 -0
- package/src/lib/terminal-session.ts +457 -0
- package/src/shared/provider-env-vars.ts +2 -3
- package/src/__tests__/orphan-detection.test.ts +0 -214
package/src/commands/teleport.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
findAssistantByName,
|
|
3
3
|
loadAllAssistants,
|
|
4
|
+
getDaemonPidPath,
|
|
4
5
|
removeAssistantEntry,
|
|
5
6
|
saveAssistantEntry,
|
|
6
7
|
setActiveAssistant,
|
|
@@ -17,15 +18,10 @@ import {
|
|
|
17
18
|
hatchAssistant,
|
|
18
19
|
checkExistingPlatformAssistant,
|
|
19
20
|
platformInitiateExport,
|
|
20
|
-
|
|
21
|
-
platformDownloadExport,
|
|
22
|
-
platformImportPreflight,
|
|
23
|
-
platformImportBundle,
|
|
24
|
-
platformRequestUploadUrl,
|
|
25
|
-
platformUploadToSignedUrl,
|
|
26
|
-
platformImportPreflightFromGcs,
|
|
21
|
+
platformPollJobStatus,
|
|
27
22
|
platformImportBundleFromGcs,
|
|
28
|
-
|
|
23
|
+
platformImportPreflightFromGcs,
|
|
24
|
+
platformRequestSignedUrl,
|
|
29
25
|
ensureSelfHostedLocalRegistration,
|
|
30
26
|
readGatewayCredential,
|
|
31
27
|
reprovisionAssistantApiKey,
|
|
@@ -33,6 +29,13 @@ import {
|
|
|
33
29
|
fetchCurrentUser,
|
|
34
30
|
fetchOrganizationId,
|
|
35
31
|
} from "../lib/platform-client.js";
|
|
32
|
+
import {
|
|
33
|
+
localRuntimeExportToGcs,
|
|
34
|
+
localRuntimeImportFromGcs,
|
|
35
|
+
localRuntimePollJobStatus,
|
|
36
|
+
MigrationInProgressError,
|
|
37
|
+
} from "../lib/local-runtime-client.js";
|
|
38
|
+
import { pollJobUntilDone } from "../lib/job-polling.js";
|
|
36
39
|
import {
|
|
37
40
|
hatchDocker,
|
|
38
41
|
retireDocker,
|
|
@@ -221,11 +224,17 @@ async function getAccessToken(
|
|
|
221
224
|
runtimeUrl: string,
|
|
222
225
|
assistantId: string,
|
|
223
226
|
displayName: string,
|
|
227
|
+
options?: { forceRefresh?: boolean },
|
|
224
228
|
): Promise<string> {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
+
// When forceRefresh is set (e.g. after a runtime 401 on the cached token)
|
|
230
|
+
// we skip the cache and lease a brand-new token from the gateway, so a
|
|
231
|
+
// stale-but-unexpired token can't keep failing on every retry.
|
|
232
|
+
if (!options?.forceRefresh) {
|
|
233
|
+
const tokenData = loadGuardianToken(assistantId);
|
|
234
|
+
|
|
235
|
+
if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
|
|
236
|
+
return tokenData.accessToken;
|
|
237
|
+
}
|
|
229
238
|
}
|
|
230
239
|
|
|
231
240
|
try {
|
|
@@ -244,336 +253,49 @@ async function getAccessToken(
|
|
|
244
253
|
}
|
|
245
254
|
}
|
|
246
255
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
):
|
|
254
|
-
|
|
255
|
-
|
|
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);
|
|
256
|
+
/**
|
|
257
|
+
* Detect a 401 Unauthorized raised by `localRuntimeExportToGcs` /
|
|
258
|
+
* `localRuntimeImportFromGcs`. Both throw Error with a message of the form
|
|
259
|
+
* `"Local runtime <op> failed (401): ..."` when the gateway rejects the
|
|
260
|
+
* cached guardian token.
|
|
261
|
+
*/
|
|
262
|
+
function isRuntime401(err: unknown): boolean {
|
|
263
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
264
|
+
return /Local runtime [^(]*failed \(401\)/.test(msg);
|
|
342
265
|
}
|
|
343
266
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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;
|
|
267
|
+
/**
|
|
268
|
+
* Run a runtime kickoff (`localRuntimeExportToGcs` / `localRuntimeImportFromGcs`)
|
|
269
|
+
* with a one-shot refresh-and-retry on 401. Matches the pre-rewrite
|
|
270
|
+
* `exportViaHttp`/`importViaHttp` behavior: if the cached guardian token is
|
|
271
|
+
* stale-but-unexpired and the runtime returns 401, we lease a fresh token
|
|
272
|
+
* and retry once. Any other error — or a repeated 401 on the refreshed token
|
|
273
|
+
* — propagates to the caller.
|
|
274
|
+
*/
|
|
275
|
+
async function callRuntimeWithAuthRetry<T>(
|
|
276
|
+
runtimeUrl: string,
|
|
277
|
+
assistantId: string,
|
|
278
|
+
fn: (token: string) => Promise<T>,
|
|
279
|
+
): Promise<T> {
|
|
280
|
+
const firstToken = await getAccessToken(runtimeUrl, assistantId, assistantId);
|
|
428
281
|
try {
|
|
429
|
-
|
|
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
|
-
}
|
|
282
|
+
return await fn(firstToken);
|
|
464
283
|
} catch (err) {
|
|
465
|
-
if (err
|
|
466
|
-
|
|
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);
|
|
284
|
+
if (!isRuntime401(err)) {
|
|
285
|
+
throw err;
|
|
500
286
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
entry.runtimeUrl,
|
|
287
|
+
const refreshedToken = await getAccessToken(
|
|
288
|
+
runtimeUrl,
|
|
289
|
+
assistantId,
|
|
290
|
+
assistantId,
|
|
291
|
+
{ forceRefresh: true },
|
|
507
292
|
);
|
|
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);
|
|
293
|
+
return await fn(refreshedToken);
|
|
563
294
|
}
|
|
564
|
-
|
|
565
|
-
if (cloud === "local" || cloud === "docker") {
|
|
566
|
-
return exportViaHttp(entry);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
console.error(
|
|
570
|
-
"Teleport only supports local, docker, and platform assistants as source.",
|
|
571
|
-
);
|
|
572
|
-
process.exit(1);
|
|
573
295
|
}
|
|
574
296
|
|
|
575
297
|
// ---------------------------------------------------------------------------
|
|
576
|
-
//
|
|
298
|
+
// Summary response shapes (reused by the GCS job result payload)
|
|
577
299
|
// ---------------------------------------------------------------------------
|
|
578
300
|
|
|
579
301
|
interface PreflightFileEntry {
|
|
@@ -625,86 +347,198 @@ interface ImportResponse {
|
|
|
625
347
|
};
|
|
626
348
|
}
|
|
627
349
|
|
|
628
|
-
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Export from source — unified GCS flow
|
|
352
|
+
//
|
|
353
|
+
// Every source (local, docker, platform) produces a `bundleKey` referring to
|
|
354
|
+
// a bundle sitting in GCS. The CLI never holds the bundle bytes.
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
async function exportFromAssistant(
|
|
629
358
|
entry: AssistantEntry,
|
|
630
359
|
cloud: string,
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
)
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
360
|
+
bundlePlatformUrl?: string,
|
|
361
|
+
): Promise<{ bundleKey: string }> {
|
|
362
|
+
const platformToken = readPlatformToken();
|
|
363
|
+
if (!platformToken) {
|
|
364
|
+
console.error(
|
|
365
|
+
"Not logged in. Run 'vellum login' first (required for GCS-based teleport).",
|
|
366
|
+
);
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (cloud === "local" || cloud === "docker") {
|
|
371
|
+
// Request a signed upload URL from the platform instance that will
|
|
372
|
+
// eventually own the bundle (i.e. the one the importer will read from).
|
|
373
|
+
// Passing the target's runtime URL here keeps upload and download on
|
|
374
|
+
// the same platform — otherwise a non-default/stale platform URL would
|
|
375
|
+
// cause the import to look at an empty object.
|
|
376
|
+
const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
|
|
377
|
+
{ operation: "upload" },
|
|
378
|
+
platformToken,
|
|
379
|
+
bundlePlatformUrl,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Wrap the kickoff in a one-shot refresh-and-retry helper so a stale-but-
|
|
383
|
+
// unexpired cached guardian token surfaces as 401 → re-lease → retry
|
|
384
|
+
// rather than a terminal failure. `accessToken` below is whichever token
|
|
385
|
+
// succeeded on the kickoff; we reuse it for polling so the runtime sees a
|
|
386
|
+
// consistent credential throughout the migration.
|
|
387
|
+
let jobId: string;
|
|
388
|
+
let accessToken: string;
|
|
389
|
+
try {
|
|
390
|
+
const result = await callRuntimeWithAuthRetry(
|
|
391
|
+
entry.runtimeUrl,
|
|
392
|
+
entry.assistantId,
|
|
393
|
+
async (token) => {
|
|
394
|
+
const r = await localRuntimeExportToGcs(entry.runtimeUrl, token, {
|
|
395
|
+
uploadUrl,
|
|
396
|
+
description: "teleport export",
|
|
397
|
+
});
|
|
398
|
+
return { jobId: r.jobId, token };
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
jobId = result.jobId;
|
|
402
|
+
accessToken = result.token;
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (err instanceof MigrationInProgressError) {
|
|
405
|
+
// Fail fast — the existing job is writing to a different GCS object
|
|
406
|
+
// (its caller's signed URL, not ours), so polling it would leave us
|
|
407
|
+
// pointing at an empty/unrelated bundle. Surface the existing job id
|
|
408
|
+
// so the user can decide whether to wait or investigate.
|
|
409
|
+
console.error(
|
|
410
|
+
`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.`,
|
|
411
|
+
);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
throw err;
|
|
641
415
|
}
|
|
642
416
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
417
|
+
console.log(`Export started (job ${jobId})...`);
|
|
418
|
+
|
|
419
|
+
const terminal = await pollJobUntilDone({
|
|
420
|
+
label: "local-runtime export",
|
|
421
|
+
poll: () =>
|
|
422
|
+
localRuntimePollJobStatus(entry.runtimeUrl, accessToken, jobId),
|
|
423
|
+
// Large exports can take longer than a guardian-token lease. If the
|
|
424
|
+
// runtime returns 401 mid-poll, re-lease a fresh token and rebind the
|
|
425
|
+
// closure variable so the next poll uses it.
|
|
426
|
+
refreshOn401: async () => {
|
|
427
|
+
accessToken = await getAccessToken(
|
|
652
428
|
entry.runtimeUrl,
|
|
429
|
+
entry.assistantId,
|
|
430
|
+
entry.assistantId,
|
|
431
|
+
{ forceRefresh: true },
|
|
653
432
|
);
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
if (terminal.status === "failed") {
|
|
437
|
+
console.error(`Export failed: ${terminal.error}`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { bundleKey };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (cloud === "vellum") {
|
|
445
|
+
// Platform source — initiate a server-side export. The platform writes
|
|
446
|
+
// the bundle to its own `exports/<org>/<id>.vbundle` key; we discover
|
|
447
|
+
// that key via the unified job-status endpoint's `bundle_key` field.
|
|
448
|
+
const { jobId } = await platformInitiateExport(
|
|
449
|
+
platformToken,
|
|
450
|
+
"teleport export",
|
|
451
|
+
entry.runtimeUrl,
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
console.log(`Export started (job ${jobId})...`);
|
|
455
|
+
|
|
456
|
+
let exportPlatformToken = platformToken;
|
|
457
|
+
const terminal = await pollJobUntilDone({
|
|
458
|
+
label: "platform export",
|
|
459
|
+
poll: () =>
|
|
460
|
+
platformPollJobStatus(jobId, exportPlatformToken, entry.runtimeUrl),
|
|
461
|
+
// The platform token is normally static per-process, but re-reading the
|
|
462
|
+
// on-disk credential covers the case where the user ran `vellum login`
|
|
463
|
+
// in another terminal during a long migration. A persistent 401 after
|
|
464
|
+
// a re-read surfaces to the caller with a clear next step.
|
|
465
|
+
refreshOn401: async () => {
|
|
466
|
+
const refreshed = readPlatformToken();
|
|
467
|
+
if (!refreshed) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
"Platform auth expired during export and no credential was found on disk. Run 'vellum login' and retry.",
|
|
470
|
+
);
|
|
664
471
|
}
|
|
665
|
-
|
|
472
|
+
exportPlatformToken = refreshed;
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
if (terminal.status === "failed") {
|
|
477
|
+
console.error(`Export failed: ${terminal.error}`);
|
|
478
|
+
process.exit(1);
|
|
666
479
|
}
|
|
667
480
|
|
|
481
|
+
if (!terminal.bundleKey) {
|
|
482
|
+
console.error(
|
|
483
|
+
"Export completed but the platform did not return a bundle_key. Is the platform up to date?",
|
|
484
|
+
);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { bundleKey: terminal.bundleKey };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
console.error(
|
|
492
|
+
"Teleport only supports local, docker, and platform assistants as source.",
|
|
493
|
+
);
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// Import into target — unified GCS flow
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
async function importToAssistant(
|
|
502
|
+
entry: AssistantEntry,
|
|
503
|
+
cloud: string,
|
|
504
|
+
bundleKey: string,
|
|
505
|
+
dryRun: boolean,
|
|
506
|
+
bundlePlatformUrl?: string,
|
|
507
|
+
): Promise<void> {
|
|
508
|
+
const platformToken = readPlatformToken();
|
|
509
|
+
if (!platformToken) {
|
|
510
|
+
console.error(
|
|
511
|
+
"Not logged in. Run 'vellum login' first (required for GCS-based teleport).",
|
|
512
|
+
);
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (cloud === "vellum") {
|
|
517
|
+
// Platform target — the bundle is already in GCS; kick off preflight or
|
|
518
|
+
// async import via the unified job-status endpoint.
|
|
668
519
|
if (dryRun) {
|
|
669
520
|
console.log("Running preflight analysis...\n");
|
|
670
521
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
}
|
|
522
|
+
const preflight = await platformImportPreflightFromGcs(
|
|
523
|
+
bundleKey,
|
|
524
|
+
platformToken,
|
|
525
|
+
entry.runtimeUrl,
|
|
526
|
+
);
|
|
690
527
|
|
|
691
|
-
if (
|
|
692
|
-
preflightResult.statusCode === 401 ||
|
|
693
|
-
preflightResult.statusCode === 403
|
|
694
|
-
) {
|
|
528
|
+
if (preflight.statusCode === 401 || preflight.statusCode === 403) {
|
|
695
529
|
console.error("Authentication failed. Run 'vellum login' to refresh.");
|
|
696
530
|
process.exit(1);
|
|
697
531
|
}
|
|
698
532
|
|
|
699
|
-
if (
|
|
533
|
+
if (preflight.statusCode === 404) {
|
|
700
534
|
console.error("Assistant not found or not running.");
|
|
701
535
|
process.exit(1);
|
|
702
536
|
}
|
|
703
537
|
|
|
704
538
|
if (
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
539
|
+
preflight.statusCode === 502 ||
|
|
540
|
+
preflight.statusCode === 503 ||
|
|
541
|
+
preflight.statusCode === 504
|
|
708
542
|
) {
|
|
709
543
|
console.error(
|
|
710
544
|
`Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
|
|
@@ -712,35 +546,53 @@ async function importToAssistant(
|
|
|
712
546
|
process.exit(1);
|
|
713
547
|
}
|
|
714
548
|
|
|
715
|
-
if (
|
|
549
|
+
if (preflight.statusCode !== 200) {
|
|
716
550
|
console.error(
|
|
717
|
-
`Error: Preflight check failed (${
|
|
551
|
+
`Error: Preflight check failed (${preflight.statusCode}): ${JSON.stringify(preflight.body)}`,
|
|
718
552
|
);
|
|
719
553
|
process.exit(1);
|
|
720
554
|
}
|
|
721
555
|
|
|
722
|
-
const result =
|
|
556
|
+
const result = preflight.body as unknown as PreflightResponse;
|
|
723
557
|
printPreflightSummary(result);
|
|
724
558
|
return;
|
|
725
559
|
}
|
|
726
560
|
|
|
727
|
-
// Actual import
|
|
728
561
|
console.log("Importing data...");
|
|
729
562
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
563
|
+
const importResult = await platformImportBundleFromGcs(
|
|
564
|
+
bundleKey,
|
|
565
|
+
platformToken,
|
|
566
|
+
entry.runtimeUrl,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
if (importResult.statusCode === 401 || importResult.statusCode === 403) {
|
|
570
|
+
console.error("Authentication failed. Run 'vellum login' to refresh.");
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (importResult.statusCode === 404) {
|
|
575
|
+
console.error("Assistant not found or not running.");
|
|
576
|
+
process.exit(1);
|
|
741
577
|
}
|
|
742
578
|
|
|
743
|
-
|
|
579
|
+
if (
|
|
580
|
+
importResult.statusCode === 502 ||
|
|
581
|
+
importResult.statusCode === 503 ||
|
|
582
|
+
importResult.statusCode === 504
|
|
583
|
+
) {
|
|
584
|
+
console.error(
|
|
585
|
+
`Assistant is unreachable. Try 'vellum wake ${entry.assistantId}'.`,
|
|
586
|
+
);
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (importResult.statusCode !== 202 && importResult.statusCode !== 200) {
|
|
591
|
+
console.error(`Error: Import failed (${importResult.statusCode})`);
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
let finalBody: Record<string, unknown> = importResult.body;
|
|
744
596
|
|
|
745
597
|
if (importResult.statusCode === 202) {
|
|
746
598
|
const jobId = (importResult.body as { job_id?: string }).job_id;
|
|
@@ -749,74 +601,107 @@ async function importToAssistant(
|
|
|
749
601
|
process.exit(1);
|
|
750
602
|
}
|
|
751
603
|
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
}
|
|
604
|
+
let importPlatformToken = platformToken;
|
|
605
|
+
const terminal = await pollJobUntilDone({
|
|
606
|
+
label: "platform import",
|
|
607
|
+
poll: () =>
|
|
608
|
+
platformPollJobStatus(jobId, importPlatformToken, entry.runtimeUrl),
|
|
609
|
+
refreshOn401: async () => {
|
|
610
|
+
const refreshed = readPlatformToken();
|
|
611
|
+
if (!refreshed) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
"Platform auth expired during import and no credential was found on disk. Run 'vellum login' and retry.",
|
|
614
|
+
);
|
|
784
615
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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");
|
|
616
|
+
importPlatformToken = refreshed;
|
|
617
|
+
},
|
|
618
|
+
});
|
|
806
619
|
|
|
807
|
-
if (
|
|
808
|
-
console.error(
|
|
620
|
+
if (terminal.status === "failed") {
|
|
621
|
+
console.error(`Import failed: ${terminal.error}`);
|
|
809
622
|
process.exit(1);
|
|
810
623
|
}
|
|
624
|
+
|
|
625
|
+
finalBody = (terminal.result as Record<string, unknown>) ?? {};
|
|
811
626
|
}
|
|
812
627
|
|
|
813
|
-
const result =
|
|
628
|
+
const result = finalBody as unknown as ImportResponse;
|
|
814
629
|
printImportSummary(result);
|
|
815
630
|
return;
|
|
816
631
|
}
|
|
817
632
|
|
|
818
633
|
if (cloud === "local" || cloud === "docker") {
|
|
819
|
-
|
|
634
|
+
if (dryRun) {
|
|
635
|
+
// TODO(cli): support dry-run against local targets
|
|
636
|
+
console.error(
|
|
637
|
+
"Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
|
|
638
|
+
);
|
|
639
|
+
process.exit(1);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Ask the platform for a signed download URL and hand it to the local
|
|
643
|
+
// runtime. The runtime streams the bundle straight out of GCS — the CLI
|
|
644
|
+
// never touches the bytes. The URL must target the same platform the
|
|
645
|
+
// bundle was uploaded to; otherwise the object won't exist on this
|
|
646
|
+
// platform's GCS bucket.
|
|
647
|
+
const { url: bundleUrl } = await platformRequestSignedUrl(
|
|
648
|
+
{ operation: "download", bundleKey },
|
|
649
|
+
platformToken,
|
|
650
|
+
bundlePlatformUrl,
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
console.log("Importing data...");
|
|
654
|
+
|
|
655
|
+
let jobId: string;
|
|
656
|
+
let accessToken: string;
|
|
657
|
+
try {
|
|
658
|
+
const result = await callRuntimeWithAuthRetry(
|
|
659
|
+
entry.runtimeUrl,
|
|
660
|
+
entry.assistantId,
|
|
661
|
+
async (token) => {
|
|
662
|
+
const r = await localRuntimeImportFromGcs(entry.runtimeUrl, token, {
|
|
663
|
+
bundleUrl,
|
|
664
|
+
});
|
|
665
|
+
return { jobId: r.jobId, token };
|
|
666
|
+
},
|
|
667
|
+
);
|
|
668
|
+
jobId = result.jobId;
|
|
669
|
+
accessToken = result.token;
|
|
670
|
+
} catch (err) {
|
|
671
|
+
if (err instanceof MigrationInProgressError) {
|
|
672
|
+
// Fail fast — the existing job is importing someone else's bundle
|
|
673
|
+
// (the original caller's), not ours. Polling it would report success
|
|
674
|
+
// on an import that wasn't the one we just kicked off.
|
|
675
|
+
console.error(
|
|
676
|
+
`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.`,
|
|
677
|
+
);
|
|
678
|
+
process.exit(1);
|
|
679
|
+
}
|
|
680
|
+
throw err;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const terminal = await pollJobUntilDone({
|
|
684
|
+
label: "local-runtime import",
|
|
685
|
+
poll: () =>
|
|
686
|
+
localRuntimePollJobStatus(entry.runtimeUrl, accessToken, jobId),
|
|
687
|
+
refreshOn401: async () => {
|
|
688
|
+
accessToken = await getAccessToken(
|
|
689
|
+
entry.runtimeUrl,
|
|
690
|
+
entry.assistantId,
|
|
691
|
+
entry.assistantId,
|
|
692
|
+
{ forceRefresh: true },
|
|
693
|
+
);
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
if (terminal.status === "failed") {
|
|
698
|
+
console.error(`Import failed: ${terminal.error}`);
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const result = ((terminal.result as Record<string, unknown>) ??
|
|
703
|
+
{}) as unknown as ImportResponse;
|
|
704
|
+
printImportSummary(result);
|
|
820
705
|
return;
|
|
821
706
|
}
|
|
822
707
|
|
|
@@ -952,68 +837,6 @@ export async function resolveOrHatchTarget(
|
|
|
952
837
|
process.exit(1);
|
|
953
838
|
}
|
|
954
839
|
|
|
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
840
|
// ---------------------------------------------------------------------------
|
|
1018
841
|
// Summary printing — matches restore.ts format
|
|
1019
842
|
// ---------------------------------------------------------------------------
|
|
@@ -1270,6 +1093,19 @@ export async function teleport(): Promise<void> {
|
|
|
1270
1093
|
process.exit(1);
|
|
1271
1094
|
}
|
|
1272
1095
|
|
|
1096
|
+
// Dry-run feasibility check — reject local/docker targets BEFORE any
|
|
1097
|
+
// export work. The local runtime has no preflight-from-gcs endpoint yet,
|
|
1098
|
+
// so we can't actually run a dry-run against it; burning a GCS upload
|
|
1099
|
+
// just to fail afterwards would be wasteful.
|
|
1100
|
+
// TODO(cli): support dry-run against local targets (needs a
|
|
1101
|
+
// preflight-from-gcs endpoint on the runtime).
|
|
1102
|
+
if (toCloud === "local" || toCloud === "docker") {
|
|
1103
|
+
console.error(
|
|
1104
|
+
"Error: --dry-run is not yet supported for local or docker targets (no preflight-from-gcs endpoint on the runtime).",
|
|
1105
|
+
);
|
|
1106
|
+
process.exit(1);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1273
1109
|
// Version guard: block platform→non-platform when target is behind
|
|
1274
1110
|
if (fromCloud === "vellum" && toCloud !== "vellum") {
|
|
1275
1111
|
const [sourceVersion, targetVersion] = await Promise.all([
|
|
@@ -1292,41 +1128,49 @@ export async function teleport(): Promise<void> {
|
|
|
1292
1128
|
}
|
|
1293
1129
|
}
|
|
1294
1130
|
|
|
1131
|
+
// Pin both upload and download to the same platform instance. For
|
|
1132
|
+
// platform targets the bundle is owned by the target platform; for
|
|
1133
|
+
// platform sources it's owned by the source platform. Only one of
|
|
1134
|
+
// these branches applies at a time (same-env was rejected earlier).
|
|
1135
|
+
const bundlePlatformUrl =
|
|
1136
|
+
toCloud === "vellum"
|
|
1137
|
+
? existingTarget.runtimeUrl
|
|
1138
|
+
: fromCloud === "vellum"
|
|
1139
|
+
? fromEntry.runtimeUrl
|
|
1140
|
+
: undefined;
|
|
1141
|
+
|
|
1295
1142
|
console.log(`Exporting from ${from} (${fromCloud})...`);
|
|
1296
|
-
const
|
|
1143
|
+
const { bundleKey } = await exportFromAssistant(
|
|
1144
|
+
fromEntry,
|
|
1145
|
+
fromCloud,
|
|
1146
|
+
bundlePlatformUrl,
|
|
1147
|
+
);
|
|
1297
1148
|
console.log(`Importing to ${existingTarget.assistantId} (${toCloud})...`);
|
|
1298
|
-
await importToAssistant(
|
|
1149
|
+
await importToAssistant(
|
|
1150
|
+
existingTarget,
|
|
1151
|
+
toCloud,
|
|
1152
|
+
bundleKey,
|
|
1153
|
+
true,
|
|
1154
|
+
bundlePlatformUrl,
|
|
1155
|
+
);
|
|
1299
1156
|
} else {
|
|
1300
1157
|
// No existing target — just describe what would happen
|
|
1301
1158
|
console.log("Dry run summary:");
|
|
1302
1159
|
console.log(` Would export data from: ${from} (${fromCloud})`);
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
}
|
|
1160
|
+
console.log(` Would upload bundle via signed URL`);
|
|
1161
|
+
console.log(
|
|
1162
|
+
` Would hatch a new ${targetEnv} assistant${targetName ? ` named '${targetName}'` : ""}`,
|
|
1163
|
+
);
|
|
1164
|
+
console.log(` Would import data into the new assistant`);
|
|
1316
1165
|
}
|
|
1317
1166
|
|
|
1318
1167
|
console.log(`Dry run complete — no changes were made.`);
|
|
1319
1168
|
return;
|
|
1320
1169
|
}
|
|
1321
1170
|
|
|
1322
|
-
// Export from source
|
|
1323
|
-
console.log(`Exporting from ${from} (${fromCloud})...`);
|
|
1324
|
-
const bundleData = await exportFromAssistant(fromEntry, fromCloud);
|
|
1325
|
-
|
|
1326
1171
|
// Platform target: reordered flow — upload to GCS before hatching so that
|
|
1327
|
-
// if upload fails, no empty assistant is left dangling on the platform.
|
|
1172
|
+
// if export/upload fails, no empty assistant is left dangling on the platform.
|
|
1328
1173
|
if (targetEnv === "platform") {
|
|
1329
|
-
// Step B — Auth
|
|
1330
1174
|
const token = readPlatformToken();
|
|
1331
1175
|
if (!token) {
|
|
1332
1176
|
console.error("Not logged in. Run 'vellum login' first.");
|
|
@@ -1334,7 +1178,7 @@ export async function teleport(): Promise<void> {
|
|
|
1334
1178
|
}
|
|
1335
1179
|
|
|
1336
1180
|
// If targeting an existing assistant, validate cloud match early — before
|
|
1337
|
-
//
|
|
1181
|
+
// exporting — so we don't waste work on an invalid command.
|
|
1338
1182
|
const existingTarget = targetName ? findAssistantByName(targetName) : null;
|
|
1339
1183
|
if (existingTarget) {
|
|
1340
1184
|
const existingCloud = resolveCloud(existingTarget);
|
|
@@ -1347,12 +1191,12 @@ export async function teleport(): Promise<void> {
|
|
|
1347
1191
|
}
|
|
1348
1192
|
}
|
|
1349
1193
|
|
|
1350
|
-
// Use the existing target's runtimeUrl for all platform calls so
|
|
1351
|
-
// and import hit the same instance.
|
|
1194
|
+
// Use the existing target's runtimeUrl for all platform calls so the
|
|
1195
|
+
// export, upload, and import all hit the same instance.
|
|
1352
1196
|
const targetPlatformUrl = existingTarget?.runtimeUrl;
|
|
1353
1197
|
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1198
|
+
// Pre-check: block if the user already has a platform assistant. This
|
|
1199
|
+
// runs BEFORE the expensive export so we don't waste the upload.
|
|
1356
1200
|
if (!existingTarget) {
|
|
1357
1201
|
const existing = await checkExistingPlatformAssistant(
|
|
1358
1202
|
token,
|
|
@@ -1376,43 +1220,40 @@ export async function teleport(): Promise<void> {
|
|
|
1376
1220
|
}
|
|
1377
1221
|
}
|
|
1378
1222
|
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
}
|
|
1223
|
+
// Export — for local/docker sources this uploads straight into GCS via
|
|
1224
|
+
// the platform's signed URL; for platform sources this runs a server-side
|
|
1225
|
+
// export and we read the resulting bundle_key.
|
|
1226
|
+
// The signed upload URL must be requested from the same platform instance
|
|
1227
|
+
// where the import will run. For existing targets that's the lockfile's
|
|
1228
|
+
// runtimeUrl; for fresh hatches it's getPlatformUrl() (which is what
|
|
1229
|
+
// resolveOrHatchTarget writes to the new entry).
|
|
1230
|
+
console.log(`Exporting from ${from} (${fromCloud})...`);
|
|
1231
|
+
const bundlePlatformUrl = targetPlatformUrl ?? getPlatformUrl();
|
|
1232
|
+
const { bundleKey } = await exportFromAssistant(
|
|
1233
|
+
fromEntry,
|
|
1234
|
+
fromCloud,
|
|
1235
|
+
bundlePlatformUrl,
|
|
1236
|
+
);
|
|
1400
1237
|
|
|
1401
|
-
//
|
|
1238
|
+
// Hatch (export succeeded — safe to create the target)
|
|
1402
1239
|
const toEntry = await resolveOrHatchTarget(targetEnv, targetName);
|
|
1403
1240
|
const toCloud = resolveCloud(toEntry);
|
|
1404
1241
|
|
|
1405
|
-
//
|
|
1406
|
-
// Pass bundleKey (string) or null to signal "already tried, use inline".
|
|
1242
|
+
// Import from GCS
|
|
1407
1243
|
console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
|
|
1408
|
-
await importToAssistant(
|
|
1244
|
+
await importToAssistant(
|
|
1245
|
+
toEntry,
|
|
1246
|
+
toCloud,
|
|
1247
|
+
bundleKey,
|
|
1248
|
+
false,
|
|
1249
|
+
bundlePlatformUrl,
|
|
1250
|
+
);
|
|
1409
1251
|
|
|
1410
|
-
// Success summary
|
|
1411
1252
|
console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
|
|
1412
1253
|
return;
|
|
1413
1254
|
}
|
|
1414
1255
|
|
|
1415
|
-
// Non-platform targets (local/docker)
|
|
1256
|
+
// Non-platform targets (local/docker)
|
|
1416
1257
|
// For local<->docker transfers, stop (sleep) the source to free up ports
|
|
1417
1258
|
// before hatching the target. We do NOT retire yet — if hatch or import
|
|
1418
1259
|
// fails, the user can recover by running `vellum wake <source>`.
|
|
@@ -1448,16 +1289,34 @@ export async function teleport(): Promise<void> {
|
|
|
1448
1289
|
}
|
|
1449
1290
|
}
|
|
1450
1291
|
|
|
1292
|
+
// Pin the bundle's platform instance so upload and download land on the
|
|
1293
|
+
// same GCS bucket. For platform sources the bundle is owned by the source
|
|
1294
|
+
// platform. For local/docker→local/docker the bundle lives on whatever
|
|
1295
|
+
// platform getPlatformUrl() currently resolves to — we resolve it once
|
|
1296
|
+
// here so a lockfile change mid-teleport can't split export and import.
|
|
1297
|
+
const bundlePlatformUrl =
|
|
1298
|
+
fromCloud === "vellum" ? fromEntry.runtimeUrl : getPlatformUrl();
|
|
1299
|
+
|
|
1300
|
+
// Export from source (bundle lives in GCS after this returns).
|
|
1301
|
+
console.log(`Exporting from ${from} (${fromCloud})...`);
|
|
1302
|
+
const { bundleKey } = await exportFromAssistant(
|
|
1303
|
+
fromEntry,
|
|
1304
|
+
fromCloud,
|
|
1305
|
+
bundlePlatformUrl,
|
|
1306
|
+
);
|
|
1307
|
+
|
|
1451
1308
|
if (sourceIsLocalOrDocker && targetIsLocalOrDocker && !keepSource) {
|
|
1452
1309
|
console.log(`Stopping source assistant '${from}' to free ports...`);
|
|
1453
1310
|
if (fromCloud === "docker") {
|
|
1454
1311
|
const res = dockerResourceNames(fromEntry.assistantId);
|
|
1455
1312
|
await sleepContainers(res);
|
|
1456
1313
|
} else if (fromEntry.resources) {
|
|
1457
|
-
const pidFile = fromEntry.resources.pidFile;
|
|
1458
1314
|
const vellumDir = join(fromEntry.resources.instanceDir, ".vellum");
|
|
1459
1315
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
1460
|
-
await stopProcessByPidFile(
|
|
1316
|
+
await stopProcessByPidFile(
|
|
1317
|
+
getDaemonPidPath(fromEntry.resources),
|
|
1318
|
+
"assistant",
|
|
1319
|
+
);
|
|
1461
1320
|
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
1462
1321
|
}
|
|
1463
1322
|
console.log(`Source assistant '${from}' stopped.`);
|
|
@@ -1513,9 +1372,15 @@ export async function teleport(): Promise<void> {
|
|
|
1513
1372
|
}
|
|
1514
1373
|
}
|
|
1515
1374
|
|
|
1516
|
-
// Import to target
|
|
1375
|
+
// Import to target (also GCS-driven)
|
|
1517
1376
|
console.log(`Importing to ${toEntry.assistantId} (${toCloud})...`);
|
|
1518
|
-
await importToAssistant(
|
|
1377
|
+
await importToAssistant(
|
|
1378
|
+
toEntry,
|
|
1379
|
+
toCloud,
|
|
1380
|
+
bundleKey,
|
|
1381
|
+
false,
|
|
1382
|
+
bundlePlatformUrl,
|
|
1383
|
+
);
|
|
1519
1384
|
|
|
1520
1385
|
// After successful import, inject fresh platform credentials if the
|
|
1521
1386
|
// user is logged in — replaces the source's stale vellum:* credentials
|
|
@@ -1540,6 +1405,5 @@ export async function teleport(): Promise<void> {
|
|
|
1540
1405
|
}
|
|
1541
1406
|
}
|
|
1542
1407
|
|
|
1543
|
-
// Success summary
|
|
1544
1408
|
console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
|
|
1545
1409
|
}
|