@vellumai/cli 0.7.0 → 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/README.md +49 -0
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +475 -0
- package/src/__tests__/config-utils.test.ts +35 -48
- package/src/__tests__/teleport.test.ts +86 -28
- package/src/commands/backup.ts +117 -71
- package/src/commands/client.ts +10 -9
- package/src/commands/exec.ts +21 -8
- package/src/commands/hatch.ts +2 -6
- package/src/commands/login.ts +15 -33
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +41 -6
- package/src/commands/restore.ts +26 -47
- package/src/commands/ssh.ts +2 -5
- package/src/commands/teleport.ts +38 -24
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +108 -7
- package/src/components/DefaultMainScreen.tsx +25 -3
- package/src/index.ts +2 -7
- package/src/lib/__tests__/local-runtime-client.test.ts +122 -25
- package/src/lib/__tests__/platform-client-signed-url.test.ts +2 -2
- 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 +34 -16
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +1 -1
- package/src/lib/config-utils.ts +1 -97
- package/src/lib/docker.ts +2 -2
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local-runtime-client.ts +81 -28
- package/src/lib/local.ts +27 -58
- package/src/lib/platform-client.ts +1 -220
- package/src/lib/platform-releases.ts +23 -0
- 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 +127 -48
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/xdg-log.ts +10 -4
|
@@ -91,14 +91,6 @@ const hatchAssistantMock = spyOn(
|
|
|
91
91
|
reusedExisting: false,
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
const platformInitiateExportMock = spyOn(
|
|
95
|
-
platformClient,
|
|
96
|
-
"platformInitiateExport",
|
|
97
|
-
).mockResolvedValue({
|
|
98
|
-
jobId: "platform-export-job-1",
|
|
99
|
-
status: "pending",
|
|
100
|
-
});
|
|
101
|
-
|
|
102
94
|
const platformPollJobStatusMock = spyOn(
|
|
103
95
|
platformClient,
|
|
104
96
|
"platformPollJobStatus",
|
|
@@ -294,7 +286,6 @@ afterAll(() => {
|
|
|
294
286
|
getPlatformUrlMock.mockRestore();
|
|
295
287
|
hatchAssistantMock.mockRestore();
|
|
296
288
|
checkExistingPlatformAssistantMock.mockRestore();
|
|
297
|
-
platformInitiateExportMock.mockRestore();
|
|
298
289
|
platformPollJobStatusMock.mockRestore();
|
|
299
290
|
platformRequestSignedUrlMock.mockRestore();
|
|
300
291
|
platformImportBundleFromGcsMock.mockRestore();
|
|
@@ -319,7 +310,7 @@ let consoleErrorSpy: ReturnType<typeof spyOn>;
|
|
|
319
310
|
let fetchCalls: Array<{ url: string; body: unknown }>;
|
|
320
311
|
|
|
321
312
|
function defaultLocalRuntimePollImpl(
|
|
322
|
-
|
|
313
|
+
_entry: unknown,
|
|
323
314
|
_token: string,
|
|
324
315
|
jobId: string,
|
|
325
316
|
): Promise<{
|
|
@@ -378,11 +369,6 @@ beforeEach(() => {
|
|
|
378
369
|
},
|
|
379
370
|
reusedExisting: false,
|
|
380
371
|
});
|
|
381
|
-
platformInitiateExportMock.mockReset();
|
|
382
|
-
platformInitiateExportMock.mockResolvedValue({
|
|
383
|
-
jobId: "platform-export-job-1",
|
|
384
|
-
status: "pending",
|
|
385
|
-
});
|
|
386
372
|
platformPollJobStatusMock.mockReset();
|
|
387
373
|
platformPollJobStatusMock.mockResolvedValue({
|
|
388
374
|
jobId: "platform-job-1",
|
|
@@ -869,9 +855,14 @@ describe("unified GCS flow — four directions", () => {
|
|
|
869
855
|
"https://platform.vellum.ai",
|
|
870
856
|
);
|
|
871
857
|
|
|
872
|
-
// Runtime export-to-gcs kicked off with the signed upload URL
|
|
858
|
+
// Runtime export-to-gcs kicked off with the signed upload URL.
|
|
859
|
+
// Helper takes an entry, not a bare URL — the entry's cloud drives
|
|
860
|
+
// URL construction (local → gateway loopback path).
|
|
873
861
|
expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
|
|
874
|
-
|
|
862
|
+
expect.objectContaining({
|
|
863
|
+
cloud: "local",
|
|
864
|
+
runtimeUrl: "http://localhost:7821",
|
|
865
|
+
}),
|
|
875
866
|
"local-token",
|
|
876
867
|
expect.objectContaining({
|
|
877
868
|
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
|
|
@@ -926,13 +917,58 @@ describe("unified GCS flow — four directions", () => {
|
|
|
926
917
|
bundleKey: "platform-exports/org-1/bundle-abc.vbundle",
|
|
927
918
|
});
|
|
928
919
|
|
|
920
|
+
// The bundle key now flows from the upload signed-URL request rather than
|
|
921
|
+
// the job-status payload — pin it so the download-URL assertion below
|
|
922
|
+
// still uses the same expected key.
|
|
923
|
+
platformRequestSignedUrlMock.mockImplementation(async (params) => ({
|
|
924
|
+
url:
|
|
925
|
+
params.operation === "upload"
|
|
926
|
+
? "https://storage.googleapis.com/bucket/signed-upload"
|
|
927
|
+
: "https://storage.googleapis.com/bucket/signed-download",
|
|
928
|
+
bundleKey:
|
|
929
|
+
params.bundleKey ?? "platform-exports/org-1/bundle-abc.vbundle",
|
|
930
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
931
|
+
}));
|
|
932
|
+
|
|
929
933
|
const restoreFetch = installTrackingFetch();
|
|
930
934
|
try {
|
|
931
935
|
await teleport();
|
|
932
936
|
|
|
933
|
-
// Platform side:
|
|
934
|
-
|
|
935
|
-
expect(
|
|
937
|
+
// Platform side: requested an upload URL, kicked off a runtime export to
|
|
938
|
+
// GCS, and polled the unified job status.
|
|
939
|
+
expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
940
|
+
expect.objectContaining({ operation: "upload" }),
|
|
941
|
+
"platform-token",
|
|
942
|
+
"https://platform.vellum.ai",
|
|
943
|
+
);
|
|
944
|
+
// For platform sources, export-to-gcs is reached via the platform's
|
|
945
|
+
// wildcard runtime proxy. The helper builds the assistant-scoped URL
|
|
946
|
+
// from the entry (`/v1/assistants/<id>/migrations/export-to-gcs`) and
|
|
947
|
+
// sends platform-token auth — no guardian-token bootstrap.
|
|
948
|
+
expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
|
|
949
|
+
expect.objectContaining({
|
|
950
|
+
cloud: "vellum",
|
|
951
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
952
|
+
assistantId: "my-platform",
|
|
953
|
+
}),
|
|
954
|
+
"platform-token",
|
|
955
|
+
expect.objectContaining({
|
|
956
|
+
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
|
|
957
|
+
description: "teleport export",
|
|
958
|
+
}),
|
|
959
|
+
);
|
|
960
|
+
// Polling for platform sources also goes through the wildcard via
|
|
961
|
+
// localRuntimePollJobStatus(entry, ...) — the dedicated
|
|
962
|
+
// `/v1/migrations/jobs/{id}/` endpoint queries platform-side
|
|
963
|
+
// ImportJob records and would 404 on runtime-created job IDs.
|
|
964
|
+
expect(localRuntimePollJobStatusMock).toHaveBeenCalledWith(
|
|
965
|
+
expect.objectContaining({
|
|
966
|
+
cloud: "vellum",
|
|
967
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
968
|
+
}),
|
|
969
|
+
"platform-token",
|
|
970
|
+
"local-export-job-1",
|
|
971
|
+
);
|
|
936
972
|
|
|
937
973
|
// For the local target we request a download URL keyed by the
|
|
938
974
|
// platform's bundle_key. The URL must target the SOURCE platform
|
|
@@ -949,7 +985,10 @@ describe("unified GCS flow — four directions", () => {
|
|
|
949
985
|
|
|
950
986
|
// Runtime import-from-gcs was kicked off with that URL.
|
|
951
987
|
expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
|
|
952
|
-
|
|
988
|
+
expect.objectContaining({
|
|
989
|
+
cloud: "local",
|
|
990
|
+
runtimeUrl: "http://localhost:7821",
|
|
991
|
+
}),
|
|
953
992
|
"local-token",
|
|
954
993
|
expect.objectContaining({
|
|
955
994
|
bundleUrl: "https://storage.googleapis.com/bucket/signed-download",
|
|
@@ -1009,7 +1048,10 @@ describe("unified GCS flow — four directions", () => {
|
|
|
1009
1048
|
"https://platform.vellum.ai",
|
|
1010
1049
|
);
|
|
1011
1050
|
expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
|
|
1012
|
-
|
|
1051
|
+
expect.objectContaining({
|
|
1052
|
+
cloud: "docker",
|
|
1053
|
+
runtimeUrl: "http://localhost:7822",
|
|
1054
|
+
}),
|
|
1013
1055
|
"local-token",
|
|
1014
1056
|
expect.objectContaining({
|
|
1015
1057
|
bundleUrl: "https://storage.googleapis.com/bucket/signed-download",
|
|
@@ -1062,7 +1104,10 @@ describe("unified GCS flow — four directions", () => {
|
|
|
1062
1104
|
"https://platform.vellum.ai",
|
|
1063
1105
|
);
|
|
1064
1106
|
expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
|
|
1065
|
-
|
|
1107
|
+
expect.objectContaining({
|
|
1108
|
+
cloud: "docker",
|
|
1109
|
+
runtimeUrl: "http://localhost:7822",
|
|
1110
|
+
}),
|
|
1066
1111
|
"local-token",
|
|
1067
1112
|
expect.objectContaining({
|
|
1068
1113
|
uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
|
|
@@ -1071,7 +1116,10 @@ describe("unified GCS flow — four directions", () => {
|
|
|
1071
1116
|
|
|
1072
1117
|
// Import leg: download-URL targets the new local runtime
|
|
1073
1118
|
expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
|
|
1074
|
-
|
|
1119
|
+
expect.objectContaining({
|
|
1120
|
+
cloud: "local",
|
|
1121
|
+
runtimeUrl: "http://localhost:7823",
|
|
1122
|
+
}),
|
|
1075
1123
|
"local-token",
|
|
1076
1124
|
expect.anything(),
|
|
1077
1125
|
);
|
|
@@ -1159,12 +1207,23 @@ describe("signed-URL request targets the bundle-owning platform", () => {
|
|
|
1159
1207
|
bundleKey: "dev-bundle-key",
|
|
1160
1208
|
});
|
|
1161
1209
|
|
|
1210
|
+
// Bundle key flows from the upload signed-URL request now; pin it so the
|
|
1211
|
+
// download-URL assertion below uses the same key.
|
|
1212
|
+
platformRequestSignedUrlMock.mockImplementation(async (params) => ({
|
|
1213
|
+
url:
|
|
1214
|
+
params.operation === "upload"
|
|
1215
|
+
? "https://storage.googleapis.com/bucket/signed-upload"
|
|
1216
|
+
: "https://storage.googleapis.com/bucket/signed-download",
|
|
1217
|
+
bundleKey: params.bundleKey ?? "dev-bundle-key",
|
|
1218
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
1219
|
+
}));
|
|
1220
|
+
|
|
1162
1221
|
const restoreFetch = installTrackingFetch();
|
|
1163
1222
|
try {
|
|
1164
1223
|
await teleport();
|
|
1165
1224
|
|
|
1166
1225
|
// The download URL must be requested from the SOURCE platform (where
|
|
1167
|
-
// the bundle was written by the
|
|
1226
|
+
// the bundle was written by the runtime export), not the default.
|
|
1168
1227
|
expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
|
|
1169
1228
|
{ operation: "download", bundleKey: "dev-bundle-key" },
|
|
1170
1229
|
"platform-token",
|
|
@@ -1555,10 +1614,9 @@ describe("dry-run", () => {
|
|
|
1555
1614
|
),
|
|
1556
1615
|
);
|
|
1557
1616
|
|
|
1558
|
-
// Must fail BEFORE any export work — no signed URL request, no
|
|
1559
|
-
// export
|
|
1617
|
+
// Must fail BEFORE any export work — no signed URL request, no runtime
|
|
1618
|
+
// export kickoff, nothing that costs time or bandwidth.
|
|
1560
1619
|
expect(platformRequestSignedUrlMock).not.toHaveBeenCalled();
|
|
1561
|
-
expect(platformInitiateExportMock).not.toHaveBeenCalled();
|
|
1562
1620
|
expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
|
|
1563
1621
|
} finally {
|
|
1564
1622
|
restoreFetch();
|
package/src/commands/backup.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "fs";
|
|
2
2
|
import { dirname, join } from "path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
5
|
+
import { findAssistantByName } from "../lib/assistant-config.js";
|
|
5
6
|
import { getBackupsDir, formatSize } from "../lib/backup-ops.js";
|
|
6
7
|
import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
|
|
8
|
+
import { pollJobUntilDone } from "../lib/job-polling.js";
|
|
7
9
|
import {
|
|
10
|
+
MigrationInProgressError,
|
|
11
|
+
localRuntimeExportToGcs,
|
|
12
|
+
localRuntimePollJobStatus,
|
|
13
|
+
} from "../lib/local-runtime-client.js";
|
|
14
|
+
import {
|
|
15
|
+
platformRequestSignedUrl,
|
|
8
16
|
readPlatformToken,
|
|
9
|
-
platformInitiateExport,
|
|
10
|
-
platformPollExportStatus,
|
|
11
|
-
platformDownloadExport,
|
|
12
17
|
} from "../lib/platform-client.js";
|
|
13
18
|
|
|
14
19
|
export async function backup(): Promise<void> {
|
|
@@ -73,7 +78,7 @@ export async function backup(): Promise<void> {
|
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
if (cloud === "vellum") {
|
|
76
|
-
await backupPlatform(name, outputArg
|
|
81
|
+
await backupPlatform(entry, name, outputArg);
|
|
77
82
|
return;
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -188,39 +193,66 @@ export async function backup(): Promise<void> {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
// ---------------------------------------------------------------------------
|
|
191
|
-
// Platform (
|
|
196
|
+
// Platform-managed (cloud="vellum") backup over GCS.
|
|
197
|
+
//
|
|
198
|
+
// The runtime exports the bundle straight to a platform-issued signed GCS
|
|
199
|
+
// URL; the CLI then downloads from GCS to local disk. Bytes never flow
|
|
200
|
+
// through Django. Same architectural shape as the platform-source half of
|
|
201
|
+
// `vellum teleport`. Output format and success log lines match mode 1
|
|
202
|
+
// (runtime-direct local backup) so users see one consistent UX.
|
|
203
|
+
//
|
|
204
|
+
// Lifecycle: the GCS bucket has a 1-day TTL on `uploads/<org>/*` objects
|
|
205
|
+
// (see `vellum-assistant-platform/django/app/assistant/migration/views.py`
|
|
206
|
+
// and `migration/services.py`). Backup is single-shot with no import to
|
|
207
|
+
// trigger best-effort cleanup, so the bundle sits in GCS up to 24h before
|
|
208
|
+
// TTL deletion. No explicit cleanup endpoint exists; relying on TTL is
|
|
209
|
+
// intentional.
|
|
192
210
|
// ---------------------------------------------------------------------------
|
|
193
|
-
|
|
194
211
|
async function backupPlatform(
|
|
212
|
+
entry: AssistantEntry,
|
|
195
213
|
name: string,
|
|
196
214
|
outputArg?: string,
|
|
197
|
-
runtimeUrl?: string,
|
|
198
215
|
): Promise<void> {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
216
|
+
const platformToken = readPlatformToken();
|
|
217
|
+
if (!platformToken) {
|
|
218
|
+
console.error(
|
|
219
|
+
"Not logged in. Run 'vellum login' first (required for platform-managed backup).",
|
|
220
|
+
);
|
|
203
221
|
process.exit(1);
|
|
204
222
|
}
|
|
205
|
-
|
|
206
|
-
//
|
|
223
|
+
// Pin upload, download, and runtime requests to the same platform instance
|
|
224
|
+
// the assistant lives on. Using `getPlatformUrl()` instead would target
|
|
225
|
+
// whatever the lockfile / env-var resolves to, which may differ from
|
|
226
|
+
// `entry.runtimeUrl` for staging/dev assistants and end up signing URLs
|
|
227
|
+
// for the wrong GCS bucket. Mirrors the teleport bundlePlatformUrl
|
|
228
|
+
// threading at `cli/src/commands/teleport.ts:1311-1312`.
|
|
229
|
+
const platformUrl = entry.runtimeUrl;
|
|
230
|
+
// Track the working platform token across kickoff/poll/download so a
|
|
231
|
+
// 401-driven refresh during polling stays consistent through the final
|
|
232
|
+
// signed-download request.
|
|
233
|
+
let exportPlatformToken = platformToken;
|
|
234
|
+
|
|
235
|
+
// Step 1 — Request a signed upload URL.
|
|
236
|
+
const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
|
|
237
|
+
{ operation: "upload" },
|
|
238
|
+
exportPlatformToken,
|
|
239
|
+
platformUrl,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Step 2 — Kick off runtime export-to-GCS through the platform's
|
|
243
|
+
// wildcard runtime proxy. `localRuntimeExportToGcs` builds the
|
|
244
|
+
// `/v1/assistants/<id>/migrations/export-to-gcs` URL for cloud="vellum"
|
|
245
|
+
// and uses platform-token auth (no guardian-token bootstrap).
|
|
207
246
|
let jobId: string;
|
|
208
247
|
try {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
"CLI backup",
|
|
212
|
-
|
|
213
|
-
);
|
|
214
|
-
jobId = result.jobId;
|
|
248
|
+
({ jobId } = await localRuntimeExportToGcs(entry, exportPlatformToken, {
|
|
249
|
+
uploadUrl,
|
|
250
|
+
description: "CLI backup",
|
|
251
|
+
}));
|
|
215
252
|
} catch (err) {
|
|
216
|
-
|
|
217
|
-
if (msg.includes("401") || msg.includes("403")) {
|
|
218
|
-
console.error("Authentication failed. Run 'vellum login' to refresh.");
|
|
219
|
-
process.exit(1);
|
|
220
|
-
}
|
|
221
|
-
if (msg.includes("429")) {
|
|
253
|
+
if (err instanceof MigrationInProgressError) {
|
|
222
254
|
console.error(
|
|
223
|
-
|
|
255
|
+
`Error: Another backup or teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish, then re-run.`,
|
|
224
256
|
);
|
|
225
257
|
process.exit(1);
|
|
226
258
|
}
|
|
@@ -229,65 +261,79 @@ async function backupPlatform(
|
|
|
229
261
|
|
|
230
262
|
console.log(`Export started (job ${jobId})...`);
|
|
231
263
|
|
|
232
|
-
// Step 3 — Poll
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// Let non-transient errors (e.g. 404 "job not found") propagate immediately
|
|
245
|
-
if (msg.includes("not found")) {
|
|
246
|
-
throw err;
|
|
264
|
+
// Step 3 — Poll the job through the wildcard proxy. The dedicated
|
|
265
|
+
// `/v1/migrations/jobs/{id}/` endpoint queries platform-side ImportJob
|
|
266
|
+
// records and would 404 on runtime-created job IDs.
|
|
267
|
+
const terminal = await pollJobUntilDone({
|
|
268
|
+
label: "platform export",
|
|
269
|
+
poll: () => localRuntimePollJobStatus(entry, exportPlatformToken, jobId),
|
|
270
|
+
refreshOn401: async () => {
|
|
271
|
+
const refreshed = readPlatformToken();
|
|
272
|
+
if (!refreshed) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
"Platform auth expired during export and no credential was found on disk. Run 'vellum login' and retry.",
|
|
275
|
+
);
|
|
247
276
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
277
|
+
exportPlatformToken = refreshed;
|
|
278
|
+
},
|
|
279
|
+
});
|
|
252
280
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (status.status === "failed") {
|
|
259
|
-
console.error(`Export failed: ${status.error ?? "unknown error"}`);
|
|
260
|
-
process.exit(1);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Still in progress — wait and retry
|
|
264
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
281
|
+
if (terminal.status === "failed") {
|
|
282
|
+
console.error(`Error: Export failed: ${terminal.error}`);
|
|
283
|
+
process.exit(1);
|
|
265
284
|
}
|
|
266
285
|
|
|
267
|
-
|
|
268
|
-
|
|
286
|
+
// Step 4 — Request a signed download URL for the same bundle and fetch
|
|
287
|
+
// it from GCS directly. No auth on signed URLs.
|
|
288
|
+
// Use `exportPlatformToken` (not the original `platformToken`) so a
|
|
289
|
+
// poll-loop 401 refresh doesn't get clobbered here — otherwise a long
|
|
290
|
+
// export that recovered mid-poll via re-auth would still 401 on the
|
|
291
|
+
// download-URL request and abort an otherwise successful run.
|
|
292
|
+
const { url: bundleUrl } = await platformRequestSignedUrl(
|
|
293
|
+
{ operation: "download", bundleKey },
|
|
294
|
+
exportPlatformToken,
|
|
295
|
+
platformUrl,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
let downloadResponse: Response;
|
|
299
|
+
try {
|
|
300
|
+
downloadResponse = await fetch(bundleUrl);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
303
|
+
console.error(`Error: Failed to fetch bundle from GCS: ${msg}`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
if (!downloadResponse.ok) {
|
|
307
|
+
const body = await downloadResponse.text().catch(() => "");
|
|
308
|
+
console.error(
|
|
309
|
+
`Error: Failed to fetch bundle from GCS (${downloadResponse.status}): ${body}`,
|
|
310
|
+
);
|
|
269
311
|
process.exit(1);
|
|
270
312
|
}
|
|
271
313
|
|
|
272
|
-
|
|
314
|
+
const arrayBuffer = await downloadResponse.arrayBuffer();
|
|
315
|
+
const data = new Uint8Array(arrayBuffer);
|
|
316
|
+
|
|
317
|
+
// Step 5 — Write to disk using the same path resolution mode 1 uses.
|
|
273
318
|
const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
274
319
|
const outputPath =
|
|
275
320
|
outputArg || join(getBackupsDir(), `${name}-${isoTimestamp}.vbundle`);
|
|
276
|
-
|
|
277
321
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
278
|
-
|
|
279
|
-
const response = await platformDownloadExport(downloadUrl);
|
|
280
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
281
|
-
const data = new Uint8Array(arrayBuffer);
|
|
282
|
-
|
|
283
322
|
writeFileSync(outputPath, data);
|
|
284
323
|
|
|
285
|
-
// Step
|
|
324
|
+
// Step 6 — Print success. Manifest SHA is included only if the runtime
|
|
325
|
+
// surfaced it via the unified job result; the export-to-gcs runtime
|
|
326
|
+
// route does not set the legacy `X-Vbundle-Manifest-Sha256` response
|
|
327
|
+
// header.
|
|
286
328
|
console.log(`Backup saved to ${outputPath}`);
|
|
287
329
|
console.log(`Size: ${formatSize(data.byteLength)}`);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
330
|
+
const manifestSha =
|
|
331
|
+
terminal.status === "complete" &&
|
|
332
|
+
terminal.result &&
|
|
333
|
+
typeof terminal.result === "object"
|
|
334
|
+
? (terminal.result as Record<string, unknown>).manifest_sha256
|
|
335
|
+
: undefined;
|
|
336
|
+
if (typeof manifestSha === "string") {
|
|
291
337
|
console.log(`Manifest SHA-256: ${manifestSha}`);
|
|
292
338
|
}
|
|
293
339
|
}
|
package/src/commands/client.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { hostname } from "os";
|
|
|
3
3
|
import {
|
|
4
4
|
findAssistantByName,
|
|
5
5
|
getActiveAssistant,
|
|
6
|
-
|
|
6
|
+
resolveAssistant,
|
|
7
7
|
} from "../lib/assistant-config";
|
|
8
8
|
import {
|
|
9
9
|
DAEMON_INTERNAL_ASSISTANT_ID,
|
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
type Species,
|
|
12
12
|
} from "../lib/constants";
|
|
13
13
|
import { loadGuardianToken } from "../lib/guardian-token";
|
|
14
|
-
import { getLocalLanIPv4
|
|
14
|
+
import { getLocalLanIPv4 } from "../lib/local";
|
|
15
|
+
import { tuiLog } from "../lib/tui-log";
|
|
15
16
|
|
|
16
17
|
const ANSI = {
|
|
17
18
|
reset: "\x1b[0m",
|
|
@@ -76,8 +77,8 @@ function parseArgs(): ParsedArgs {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
if (!entry && hasExplicitUrl) {
|
|
79
|
-
// URL provided but active assistant missing or unset —
|
|
80
|
-
entry =
|
|
80
|
+
// URL provided but active assistant missing or unset — resolve for remaining defaults
|
|
81
|
+
entry = resolveAssistant();
|
|
81
82
|
} else if (!entry) {
|
|
82
83
|
console.error(
|
|
83
84
|
"No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
|
|
@@ -140,11 +141,6 @@ function maybeSwapToLocalhost(url: string): string {
|
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
const macHost = getMacLocalHostname();
|
|
144
|
-
if (macHost) {
|
|
145
|
-
localNames.push(macHost.toLowerCase());
|
|
146
|
-
}
|
|
147
|
-
|
|
148
144
|
const lanIp = getLocalLanIPv4();
|
|
149
145
|
if (lanIp) {
|
|
150
146
|
localNames.push(lanIp);
|
|
@@ -188,6 +184,9 @@ export async function client(): Promise<void> {
|
|
|
188
184
|
const { runtimeUrl, assistantId, species, bearerToken, project, zone } =
|
|
189
185
|
parseArgs();
|
|
190
186
|
|
|
187
|
+
tuiLog.init();
|
|
188
|
+
tuiLog.info("session start", { runtimeUrl, assistantId, species });
|
|
189
|
+
|
|
191
190
|
const { renderChatApp } = await import("../components/DefaultMainScreen");
|
|
192
191
|
|
|
193
192
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
@@ -197,6 +196,8 @@ export async function client(): Promise<void> {
|
|
|
197
196
|
assistantId,
|
|
198
197
|
species,
|
|
199
198
|
() => {
|
|
199
|
+
tuiLog.info("session end (user disconnect)");
|
|
200
|
+
tuiLog.close();
|
|
200
201
|
app.unmount();
|
|
201
202
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
202
203
|
console.log(`${ANSI.dim}Disconnected.${ANSI.reset}`);
|
package/src/commands/exec.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
findAssistantByName,
|
|
5
|
-
loadLatestAssistant,
|
|
6
|
-
resolveCloud,
|
|
7
|
-
} from "../lib/assistant-config";
|
|
3
|
+
import { resolveAssistant, resolveCloud } from "../lib/assistant-config";
|
|
8
4
|
import { dockerResourceNames } from "../lib/docker";
|
|
9
5
|
import type { ServiceName } from "../lib/docker";
|
|
10
6
|
import { execAppleContainer } from "../lib/exec-apple-container";
|
|
@@ -84,6 +80,9 @@ export async function exec(): Promise<void> {
|
|
|
84
80
|
console.log(
|
|
85
81
|
" -it Interactive mode with TTY (like docker exec -it)",
|
|
86
82
|
);
|
|
83
|
+
console.log(
|
|
84
|
+
" --timeout <secs> Timeout in seconds (default: 30, 0 = no timeout)",
|
|
85
|
+
);
|
|
87
86
|
console.log(
|
|
88
87
|
" --verbose Show debug output (SSE events, sentinel parsing)",
|
|
89
88
|
);
|
|
@@ -120,12 +119,20 @@ export async function exec(): Promise<void> {
|
|
|
120
119
|
let serviceRaw = "assistant";
|
|
121
120
|
let interactive = false;
|
|
122
121
|
let verbose = false;
|
|
122
|
+
let timeoutMs = 30_000;
|
|
123
123
|
|
|
124
124
|
for (let i = 0; i < preArgs.length; i++) {
|
|
125
125
|
if (preArgs[i] === "--service" && preArgs[i + 1]) {
|
|
126
126
|
serviceRaw = preArgs[++i];
|
|
127
127
|
} else if (preArgs[i] === "-it" || preArgs[i] === "-ti") {
|
|
128
128
|
interactive = true;
|
|
129
|
+
} else if (preArgs[i] === "--timeout" && preArgs[i + 1]) {
|
|
130
|
+
const secs = Number(preArgs[++i]);
|
|
131
|
+
if (!Number.isFinite(secs) || secs < 0) {
|
|
132
|
+
console.error("Error: --timeout must be a non-negative number.");
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
timeoutMs = secs === 0 ? 0 : secs * 1000;
|
|
129
136
|
} else if (preArgs[i] === "--verbose") {
|
|
130
137
|
verbose = true;
|
|
131
138
|
} else if (!preArgs[i].startsWith("-")) {
|
|
@@ -135,7 +142,7 @@ export async function exec(): Promise<void> {
|
|
|
135
142
|
|
|
136
143
|
const service = normalizeService(serviceRaw);
|
|
137
144
|
|
|
138
|
-
const entry =
|
|
145
|
+
const entry = resolveAssistant(nameArg);
|
|
139
146
|
|
|
140
147
|
if (!entry) {
|
|
141
148
|
if (nameArg) {
|
|
@@ -200,14 +207,20 @@ export async function exec(): Promise<void> {
|
|
|
200
207
|
platformUrl: getPlatformUrl(),
|
|
201
208
|
};
|
|
202
209
|
|
|
210
|
+
const serviceParam = service === "assistant" ? undefined : service;
|
|
211
|
+
|
|
203
212
|
if (interactive) {
|
|
204
213
|
// Interactive mode: shell-escape argv and delegate to full terminal
|
|
205
|
-
await interactiveSession(assistant, shellEscapeArgs(command));
|
|
214
|
+
await interactiveSession(assistant, shellEscapeArgs(command), serviceParam);
|
|
206
215
|
return;
|
|
207
216
|
}
|
|
208
217
|
|
|
209
218
|
// Non-interactive: sentinel-based output capture with exit code
|
|
210
|
-
await nonInteractiveExec(assistant, command, {
|
|
219
|
+
await nonInteractiveExec(assistant, command, {
|
|
220
|
+
verbose,
|
|
221
|
+
timeoutMs,
|
|
222
|
+
service: serviceParam,
|
|
223
|
+
});
|
|
211
224
|
return;
|
|
212
225
|
}
|
|
213
226
|
|
package/src/commands/hatch.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
VALID_SPECIES,
|
|
16
16
|
} from "../lib/constants";
|
|
17
17
|
import type { RemoteHost, Species } from "../lib/constants";
|
|
18
|
-
import {
|
|
18
|
+
import { buildNestedConfig } from "../lib/config-utils";
|
|
19
19
|
import { hatchDocker } from "../lib/docker";
|
|
20
20
|
import { hatchGcp } from "../lib/gcp";
|
|
21
21
|
import type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
@@ -123,11 +123,7 @@ export async function buildStartupScript(
|
|
|
123
123
|
// and export the env var so the daemon reads it on first boot.
|
|
124
124
|
let configWriteBlock = "";
|
|
125
125
|
if (Object.keys(configValues).length > 0) {
|
|
126
|
-
const configJson = JSON.stringify(
|
|
127
|
-
buildInitialConfig(configValues),
|
|
128
|
-
null,
|
|
129
|
-
2,
|
|
130
|
-
);
|
|
126
|
+
const configJson = JSON.stringify(buildNestedConfig(configValues), null, 2);
|
|
131
127
|
configWriteBlock = `
|
|
132
128
|
echo "Writing default workspace config..."
|
|
133
129
|
VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH="/tmp/vellum-initial-config-$$.json"
|