@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.
- package/AGENTS.md +8 -2
- package/README.md +49 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +1 -7
- package/src/__tests__/backup.test.ts +475 -0
- package/src/__tests__/config-utils.test.ts +146 -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 +988 -1266
- package/src/commands/backup.ts +117 -71
- package/src/commands/client.ts +10 -9
- package/src/commands/env.ts +93 -0
- package/src/commands/events.ts +2 -0
- package/src/commands/exec.ts +58 -13
- package/src/commands/login.ts +77 -12
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +144 -25
- package/src/commands/restore.ts +26 -47
- package/src/commands/sleep.ts +5 -2
- package/src/commands/ssh.ts +17 -7
- package/src/commands/teleport.ts +462 -584
- package/src/commands/terminal.ts +9 -221
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +108 -7
- package/src/commands/wake.ts +2 -1
- package/src/components/DefaultMainScreen.tsx +328 -154
- package/src/index.ts +5 -7
- 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 +480 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
- package/src/lib/__tests__/runtime-url.test.ts +87 -0
- package/src/lib/__tests__/terminal-session.test.ts +202 -0
- package/src/lib/assistant-client.ts +5 -21
- package/src/lib/assistant-config.ts +46 -24
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +67 -0
- package/src/lib/docker.ts +75 -77
- 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 +231 -0
- package/src/lib/local.ts +165 -72
- package/src/lib/orphan-detection.ts +2 -35
- package/src/lib/platform-client.ts +190 -194
- package/src/lib/platform-releases.ts +23 -0
- package/src/lib/retire-local.ts +6 -2
- package/src/lib/runtime-url.ts +30 -0
- package/src/lib/sync-cloud-assistants.ts +126 -0
- package/src/lib/terminal-client.ts +6 -1
- package/src/lib/terminal-session.ts +536 -0
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/xdg-log.ts +10 -4
- 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,
|
|
@@ -16,16 +17,10 @@ import {
|
|
|
16
17
|
getPlatformUrl,
|
|
17
18
|
hatchAssistant,
|
|
18
19
|
checkExistingPlatformAssistant,
|
|
19
|
-
|
|
20
|
-
platformPollExportStatus,
|
|
21
|
-
platformDownloadExport,
|
|
22
|
-
platformImportPreflight,
|
|
23
|
-
platformImportBundle,
|
|
24
|
-
platformRequestUploadUrl,
|
|
25
|
-
platformUploadToSignedUrl,
|
|
26
|
-
platformImportPreflightFromGcs,
|
|
20
|
+
platformPollJobStatus,
|
|
27
21
|
platformImportBundleFromGcs,
|
|
28
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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);
|
|
283
|
+
if (!isRuntime401(err)) {
|
|
284
|
+
throw err;
|
|
500
285
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
)
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 (
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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 (
|
|
564
|
+
if (preflight.statusCode !== 200) {
|
|
716
565
|
console.error(
|
|
717
|
-
`Error: Preflight check failed (${
|
|
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 =
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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");
|
|
631
|
+
importPlatformToken = refreshed;
|
|
632
|
+
},
|
|
633
|
+
});
|
|
806
634
|
|
|
807
|
-
if (
|
|
808
|
-
console.error(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1157
|
+
const { bundleKey } = await exportFromAssistant(
|
|
1158
|
+
fromEntry,
|
|
1159
|
+
fromCloud,
|
|
1160
|
+
bundlePlatformUrl,
|
|
1161
|
+
);
|
|
1297
1162
|
console.log(`Importing to ${existingTarget.assistantId} (${toCloud})...`);
|
|
1298
|
-
await importToAssistant(
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
1355
|
-
//
|
|
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
|
-
//
|
|
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
|
-
}
|
|
1237
|
+
// Export — for 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
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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
|
}
|