@vellumai/cli 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/AGENTS.md +3 -11
  2. package/bun.lock +0 -15
  3. package/package.json +1 -6
  4. package/src/__tests__/backup.test.ts +121 -5
  5. package/src/__tests__/teleport.test.ts +515 -10
  6. package/src/commands/backup.ts +35 -2
  7. package/src/commands/client.ts +90 -7
  8. package/src/commands/exec.ts +13 -4
  9. package/src/commands/hatch.ts +1 -1
  10. package/src/commands/login.ts +11 -0
  11. package/src/commands/restore.ts +7 -1
  12. package/src/commands/rollback.ts +1 -1
  13. package/src/commands/setup.ts +38 -73
  14. package/src/commands/teleport.ts +122 -12
  15. package/src/commands/upgrade.ts +8 -2
  16. package/src/commands/wake.ts +5 -16
  17. package/src/components/DefaultMainScreen.tsx +42 -130
  18. package/src/index.ts +1 -7
  19. package/src/lib/__tests__/docker.test.ts +53 -35
  20. package/src/lib/__tests__/local-runtime-client.test.ts +186 -0
  21. package/src/lib/__tests__/platform-client-signed-url.test.ts +235 -0
  22. package/src/lib/__tests__/runtime-url.test.ts +39 -1
  23. package/src/lib/assistant-client.ts +13 -5
  24. package/src/lib/assistant-config.ts +0 -25
  25. package/src/lib/backup-ops.ts +43 -17
  26. package/src/lib/client-identity.ts +9 -5
  27. package/src/lib/docker.ts +6 -267
  28. package/src/lib/environments/paths.ts +20 -0
  29. package/src/lib/guardian-token.ts +56 -6
  30. package/src/lib/hatch-local.ts +3 -26
  31. package/src/lib/local-runtime-client.ts +82 -1
  32. package/src/lib/local.ts +9 -7
  33. package/src/lib/ngrok.ts +36 -26
  34. package/src/lib/platform-client.ts +100 -1
  35. package/src/lib/retire-local.ts +2 -2
  36. package/src/lib/runtime-url.ts +22 -0
  37. package/src/lib/statefulset.ts +375 -0
  38. package/src/lib/upgrade-lifecycle.ts +97 -1
  39. package/src/commands/pair.ts +0 -212
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Declarative StatefulSet spec for the assistant service group.
3
+ *
4
+ * Defines the static topology of all three containers (assistant, gateway,
5
+ * credential-executor), their volumes, ports, and env vars. Used by both
6
+ * the hatch and upgrade flows to build `docker run` argument arrays.
7
+ */
8
+
9
+ import { existsSync } from "fs";
10
+
11
+ import {
12
+ ASSISTANT_INTERNAL_PORT,
13
+ GATEWAY_INTERNAL_PORT,
14
+ } from "./environments/paths.js";
15
+ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
16
+
17
+ const AVATAR_DEVICE_ENV_VAR = "VELLUM_AVATAR_DEVICE";
18
+
19
+ /** Logical service name used throughout the CLI. */
20
+ export type ServiceName = "assistant" | "gateway" | "credential-executor";
21
+
22
+ /**
23
+ /**
24
+ * The four fields from `dockerResourceNames()` that the builder actually uses.
25
+ * Container/network names come from here; volume names are generated by the
26
+ * `volumeClaimTemplates` in the spec. `ReturnType<typeof dockerResourceNames>`
27
+ * structurally satisfies this interface (it has all these fields plus more).
28
+ */
29
+ export interface DockerResourceNames {
30
+ assistantContainer: string;
31
+ cesContainer: string;
32
+ gatewayContainer: string;
33
+ network: string;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Types
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** A static env var whose value is known at spec-definition time. */
41
+ interface StaticEnv {
42
+ kind: "static";
43
+ name: string;
44
+ value: string;
45
+ }
46
+
47
+ /**
48
+ * An env var whose value comes from the opts object passed to
49
+ * `buildServiceRunArgs` at container-start time.
50
+ */
51
+ interface SecretEnv {
52
+ kind: "secret";
53
+ name: string;
54
+ /** Key in `DockerRunSecrets`. */
55
+ secret: keyof DockerRunSecrets;
56
+ }
57
+
58
+ /**
59
+ * An env var conditionally forwarded from the host process environment.
60
+ * Only emitted when `process.env[hostVar]` is set.
61
+ * Defaults to `name` for `hostVar` when omitted.
62
+ */
63
+ interface HostEnv {
64
+ kind: "host";
65
+ name: string;
66
+ hostVar?: string;
67
+ }
68
+
69
+ type EnvEntry = StaticEnv | SecretEnv | HostEnv;
70
+
71
+ interface VolumeMount {
72
+ /** Logical volume name — maps to a Docker volume via `DockerVolumeClaimTemplate`. */
73
+ volumeName: string;
74
+ mountPath: string;
75
+ readOnly?: boolean;
76
+ }
77
+
78
+ interface PortSpec {
79
+ containerPort: number;
80
+ /**
81
+ * Host-side port. Literal string = use as-is. `"{{ gatewayPort }}"` is
82
+ * a sentinel replaced with the instance-specific gateway port at build time.
83
+ */
84
+ hostPort?: string;
85
+ description?: string;
86
+ }
87
+
88
+ export interface DockerContainerSpec {
89
+ /** K8s-style container name (matches `stateful_template.yaml`). */
90
+ name: string;
91
+ /** Internal CLI `ServiceName` key used by `SERVICE_START_ORDER`. */
92
+ internalName: ServiceName;
93
+ /**
94
+ * Network mode:
95
+ * - "bridge": owns the network (assistant only — gateway port published here).
96
+ * - "container": share the assistant container's network namespace.
97
+ */
98
+ network: "bridge" | "container";
99
+ ports?: PortSpec[];
100
+ env: EnvEntry[];
101
+ volumeMounts: VolumeMount[];
102
+ }
103
+
104
+ export interface DockerVolumeClaimTemplate {
105
+ name: string;
106
+ dockerVolume: (instanceName: string) => string;
107
+ }
108
+
109
+ export interface DockerStatefulSetSpec {
110
+ containers: DockerContainerSpec[];
111
+ startOrder: ServiceName[];
112
+ volumeClaimTemplates: DockerVolumeClaimTemplate[];
113
+ readiness: {
114
+ endpoint: string;
115
+ timeoutMs: number;
116
+ intervalMs: number;
117
+ };
118
+ }
119
+
120
+ /** Secrets injected at container-start time (from hatch / upgrade opts). */
121
+ export interface DockerRunSecrets {
122
+ signingKey?: string;
123
+ bootstrapSecret?: string;
124
+ cesServiceToken?: string;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Spec
129
+ // ---------------------------------------------------------------------------
130
+
131
+ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
132
+ startOrder: ["assistant", "gateway", "credential-executor"],
133
+
134
+ readiness: {
135
+ endpoint: "/readyz",
136
+ timeoutMs: 180_000,
137
+ intervalMs: 1_000,
138
+ },
139
+
140
+ volumeClaimTemplates: [
141
+ { name: "assistant-workspace", dockerVolume: (n) => `${n}-workspace` },
142
+ { name: "ces-bootstrap-socket", dockerVolume: (n) => `${n}-socket` },
143
+ { name: "assistant-ipc-socket", dockerVolume: (n) => `${n}-assistant-ipc` },
144
+ { name: "gateway-ipc-socket", dockerVolume: (n) => `${n}-gateway-ipc` },
145
+ { name: "gateway-security", dockerVolume: (n) => `${n}-gateway-sec` },
146
+ { name: "ces-security", dockerVolume: (n) => `${n}-ces-sec` },
147
+ ],
148
+
149
+ containers: [
150
+ {
151
+ name: "assistant-container",
152
+ internalName: "assistant",
153
+ network: "bridge",
154
+ ports: [
155
+ {
156
+ containerPort: GATEWAY_INTERNAL_PORT,
157
+ hostPort: "{{ gatewayPort }}",
158
+ description: "Gateway reverse-proxy (published to host)",
159
+ },
160
+ {
161
+ containerPort: ASSISTANT_INTERNAL_PORT,
162
+ hostPort: `${ASSISTANT_INTERNAL_PORT}`,
163
+ description: "Assistant HTTP API",
164
+ },
165
+ ],
166
+ env: [
167
+ { kind: "static", name: "IS_CONTAINERIZED", value: "true" },
168
+ { kind: "static", name: "DEBUG_STDOUT_LOGS", value: "1" },
169
+ { kind: "static", name: "VELLUM_CLOUD", value: "docker" },
170
+ { kind: "static", name: "RUNTIME_HTTP_HOST", value: "0.0.0.0" },
171
+ { kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
172
+ { kind: "static", name: "VELLUM_BACKUP_DIR", value: "/workspace/.backups" },
173
+ { kind: "static", name: "VELLUM_BACKUP_KEY_PATH", value: "/workspace/.backup.key" },
174
+ { kind: "static", name: "CES_CREDENTIAL_URL", value: "http://localhost:8090" },
175
+ { kind: "static", name: "GATEWAY_IPC_SOCKET_DIR", value: "/run/gateway-ipc" },
176
+ { kind: "static", name: "ASSISTANT_IPC_SOCKET_DIR", value: "/run/assistant-ipc" },
177
+ { kind: "secret", name: "CES_SERVICE_TOKEN", secret: "cesServiceToken" },
178
+ { kind: "secret", name: "ACTOR_TOKEN_SIGNING_KEY", secret: "signingKey" },
179
+ { kind: "secret", name: "GUARDIAN_BOOTSTRAP_SECRET", secret: "bootstrapSecret" },
180
+ // Provider API keys — forwarded from host if set
181
+ ...Object.values(PROVIDER_ENV_VAR_NAMES).map(
182
+ (v): HostEnv => ({ kind: "host", name: v }),
183
+ ),
184
+ { kind: "host", name: "VELLUM_ENVIRONMENT" },
185
+ { kind: "host", name: "VELLUM_PLATFORM_URL" },
186
+ ],
187
+ volumeMounts: [
188
+ { volumeName: "assistant-workspace", mountPath: "/workspace" },
189
+ { volumeName: "ces-bootstrap-socket", mountPath: "/run/ces-bootstrap" },
190
+ { volumeName: "assistant-ipc-socket", mountPath: "/run/assistant-ipc" },
191
+ { volumeName: "gateway-ipc-socket", mountPath: "/run/gateway-ipc" },
192
+ ],
193
+ },
194
+
195
+ {
196
+ name: "gateway-sidecar",
197
+ internalName: "gateway",
198
+ network: "container",
199
+ env: [
200
+ { kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
201
+ { kind: "static", name: "GATEWAY_SECURITY_DIR", value: "/gateway-security" },
202
+ { kind: "static", name: "ASSISTANT_HOST", value: "localhost" },
203
+ { kind: "static", name: "CES_CREDENTIAL_URL", value: "http://localhost:8090" },
204
+ { kind: "static", name: "GATEWAY_IPC_SOCKET_DIR", value: "/run/gateway-ipc" },
205
+ { kind: "static", name: "ASSISTANT_IPC_SOCKET_DIR", value: "/run/assistant-ipc" },
206
+ { kind: "static", name: "GATEWAY_PORT", value: `${GATEWAY_INTERNAL_PORT}` },
207
+ { kind: "static", name: "RUNTIME_HTTP_PORT", value: `${ASSISTANT_INTERNAL_PORT}` },
208
+ { kind: "secret", name: "CES_SERVICE_TOKEN", secret: "cesServiceToken" },
209
+ { kind: "secret", name: "ACTOR_TOKEN_SIGNING_KEY", secret: "signingKey" },
210
+ { kind: "secret", name: "GUARDIAN_BOOTSTRAP_SECRET", secret: "bootstrapSecret" },
211
+ { kind: "host", name: "VELLUM_ENVIRONMENT" },
212
+ { kind: "host", name: "VELLUM_PLATFORM_URL" },
213
+ { kind: "host", name: "VELAY_BASE_URL" },
214
+ ],
215
+ volumeMounts: [
216
+ { volumeName: "assistant-workspace", mountPath: "/workspace" },
217
+ { volumeName: "gateway-security", mountPath: "/gateway-security" },
218
+ { volumeName: "assistant-ipc-socket", mountPath: "/run/assistant-ipc" },
219
+ { volumeName: "gateway-ipc-socket", mountPath: "/run/gateway-ipc" },
220
+ ],
221
+ },
222
+
223
+ {
224
+ name: "credential-executor-sidecar",
225
+ internalName: "credential-executor",
226
+ network: "container",
227
+ env: [
228
+ { kind: "static", name: "CES_MODE", value: "managed" },
229
+ { kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
230
+ { kind: "static", name: "CES_BOOTSTRAP_SOCKET_DIR", value: "/run/ces-bootstrap" },
231
+ { kind: "static", name: "CREDENTIAL_SECURITY_DIR", value: "/ces-security" },
232
+ { kind: "secret", name: "CES_SERVICE_TOKEN", secret: "cesServiceToken" },
233
+ ],
234
+ volumeMounts: [
235
+ { volumeName: "assistant-workspace", mountPath: "/workspace", readOnly: true },
236
+ { volumeName: "ces-bootstrap-socket", mountPath: "/run/ces-bootstrap" },
237
+ { volumeName: "ces-security", mountPath: "/ces-security" },
238
+ ],
239
+ },
240
+ ],
241
+ };
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Builder
245
+ // ---------------------------------------------------------------------------
246
+
247
+ export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
248
+ gatewayPort: number;
249
+ imageTags: Record<ServiceName, string>;
250
+ instanceName: string;
251
+ res: DockerResourceNames;
252
+ extraAssistantEnv?: Record<string, string>;
253
+ defaultWorkspaceConfigPath?: string;
254
+ /** Avatar device path, if available. Injected by `docker.ts` after resolving. */
255
+ avatarDevicePath?: string;
256
+ }
257
+
258
+ function resolveVolume(
259
+ spec: DockerStatefulSetSpec,
260
+ instanceName: string,
261
+ volumeName: string,
262
+ ): string {
263
+ const claim = spec.volumeClaimTemplates.find((v) => v.name === volumeName);
264
+ if (!claim) throw new Error(`docker-statefulset: unknown volume "${volumeName}"`);
265
+ return claim.dockerVolume(instanceName);
266
+ }
267
+
268
+ /**
269
+ * Build `docker run` argument arrays for each container in the StatefulSet.
270
+ * Returns `Record<ServiceName, () => string[]>` — callers evaluate lazily.
271
+ */
272
+ export function buildServiceRunArgs(
273
+ opts: BuildServiceRunArgsOpts,
274
+ spec = DOCKER_STATEFUL_SET_SPEC,
275
+ ): Record<ServiceName, () => string[]> {
276
+ const {
277
+ gatewayPort,
278
+ imageTags,
279
+ instanceName,
280
+ res,
281
+ extraAssistantEnv,
282
+ defaultWorkspaceConfigPath,
283
+ avatarDevicePath,
284
+ } = opts;
285
+
286
+ const result = {} as Record<ServiceName, () => string[]>;
287
+
288
+ for (const container of spec.containers) {
289
+ const svc = container.internalName;
290
+
291
+ result[svc] = () => {
292
+ const args: string[] = ["run", "--init", "-d"];
293
+
294
+ // Container name
295
+ const containerName =
296
+ svc === "assistant"
297
+ ? res.assistantContainer
298
+ : svc === "gateway"
299
+ ? res.gatewayContainer
300
+ : res.cesContainer;
301
+ args.push("--name", containerName);
302
+
303
+ // Network
304
+ if (container.network === "bridge") {
305
+ args.push(`--network=${res.network}`);
306
+ for (const port of container.ports ?? []) {
307
+ const hostSide =
308
+ port.hostPort === "{{ gatewayPort }}"
309
+ ? `${gatewayPort}`
310
+ : port.hostPort;
311
+ if (hostSide !== undefined) {
312
+ args.push("-p", `${hostSide}:${port.containerPort}`);
313
+ }
314
+ }
315
+ } else {
316
+ args.push(`--network=container:${res.assistantContainer}`);
317
+ }
318
+
319
+ // Volume mounts
320
+ for (const mount of container.volumeMounts) {
321
+ const vol = resolveVolume(spec, instanceName, mount.volumeName);
322
+ args.push("-v", mount.readOnly ? `${vol}:${mount.mountPath}:ro` : `${vol}:${mount.mountPath}`);
323
+ }
324
+
325
+ // Env vars from spec
326
+ for (const entry of container.env) {
327
+ if (entry.kind === "static") {
328
+ args.push("-e", `${entry.name}=${entry.value}`);
329
+ } else if (entry.kind === "secret") {
330
+ const val = opts[entry.secret];
331
+ if (val) args.push("-e", `${entry.name}=${val}`);
332
+ } else {
333
+ const hostVar = entry.hostVar ?? entry.name;
334
+ const val = process.env[hostVar];
335
+ if (val) args.push("-e", `${entry.name}=${val}`);
336
+ }
337
+ }
338
+
339
+ // Assistant-only computed / optional additions
340
+ if (svc === "assistant") {
341
+ args.push(
342
+ "-e", `VELLUM_ASSISTANT_NAME=${instanceName}`,
343
+ "-e", `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
344
+ );
345
+
346
+ if (defaultWorkspaceConfigPath) {
347
+ const cPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
348
+ args.push(
349
+ "-v", `${defaultWorkspaceConfigPath}:${cPath}:ro`,
350
+ "-e", `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH=${cPath}`,
351
+ );
352
+ }
353
+
354
+ if (extraAssistantEnv) {
355
+ for (const [k, v] of Object.entries(extraAssistantEnv)) {
356
+ args.push("-e", `${k}=${v}`);
357
+ }
358
+ }
359
+
360
+ if (avatarDevicePath && existsSync(avatarDevicePath)) {
361
+ args.push(
362
+ "--device", `${avatarDevicePath}:${avatarDevicePath}`,
363
+ "-e", `${AVATAR_DEVICE_ENV_VAR}=${avatarDevicePath}`,
364
+ );
365
+ }
366
+ }
367
+
368
+ // Image is always last
369
+ args.push(imageTags[svc]);
370
+ return args;
371
+ };
372
+ }
373
+
374
+ return result;
375
+ }
@@ -1,4 +1,7 @@
1
1
  import { randomBytes } from "crypto";
2
+ import { spawnSync } from "child_process";
3
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
2
5
 
3
6
  import type { AssistantEntry } from "./assistant-config.js";
4
7
  import { saveAssistantEntry } from "./assistant-config.js";
@@ -12,12 +15,68 @@ import {
12
15
  startContainers,
13
16
  stopContainers,
14
17
  } from "./docker.js";
18
+ import { getStateDir } from "./environments/paths.js";
19
+ import { getCurrentEnvironment } from "./environments/resolve.js";
15
20
  import { loadGuardianToken } from "./guardian-token.js";
16
21
  import { getPlatformUrl } from "./platform-client.js";
17
22
  import { resolveImageRefs } from "./platform-releases.js";
18
23
  import { exec, execOutput } from "./step-runner.js";
19
24
  import { compareVersions } from "./version-compat.js";
20
25
 
26
+ // ---------------------------------------------------------------------------
27
+ // Failure log capture
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** XDG-compliant directory for upgrade failure logs, scoped to the current environment. */
31
+ function getUpgradeLogsDir(): string {
32
+ return join(getStateDir(getCurrentEnvironment()), "upgrade-logs");
33
+ }
34
+
35
+ /**
36
+ * Capture stdout/stderr from all three containers after a readiness failure
37
+ * and write them to an XDG state directory. Returns the directory path so
38
+ * the caller can print it for the user.
39
+ *
40
+ * Runs best-effort — never throws.
41
+ */
42
+ export async function captureUpgradeFailureLogs(
43
+ res: ReturnType<typeof dockerResourceNames>,
44
+ label: string,
45
+ ): Promise<string | null> {
46
+ const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
47
+ const logDir = join(getUpgradeLogsDir(), `${label}-${isoTimestamp}`);
48
+ try {
49
+ mkdirSync(logDir, { recursive: true });
50
+
51
+ const containers: [string, string][] = [
52
+ [res.assistantContainer, "assistant.log"],
53
+ [res.gatewayContainer, "gateway.log"],
54
+ [res.cesContainer, "credential-executor.log"],
55
+ ];
56
+
57
+ for (const [container, filename] of containers) {
58
+ try {
59
+ // Capture stdout + stderr together so container logs written to either
60
+ // stream (docker logs writes container stdout→stdout, stderr→stderr)
61
+ // are preserved in a single file. spawnSync avoids the execOutput
62
+ // limitation of returning only stdout on success.
63
+ const result = spawnSync("docker", ["logs", "--tail", "500", container], {
64
+ encoding: "utf8",
65
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
66
+ });
67
+ const output = [result.stdout, result.stderr].filter(Boolean).join("");
68
+ if (output) writeFileSync(join(logDir, filename), output);
69
+ } catch {
70
+ // Container may not exist or may have already been removed
71
+ }
72
+ }
73
+
74
+ return existsSync(logDir) ? logDir : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
21
80
  // ---------------------------------------------------------------------------
22
81
  // Shared constants & builders for upgrade / rollback lifecycle events
23
82
  // ---------------------------------------------------------------------------
@@ -147,6 +206,38 @@ export async function fetchCurrentVersion(
147
206
  return undefined;
148
207
  }
149
208
 
209
+ /**
210
+ * Best-effort fetch of the assistant's configured public ingress URL from the
211
+ * gateway `integrations/ingress/config` endpoint. Returns `undefined` when
212
+ * the gateway is unreachable, the bearer token is missing, or no public URL
213
+ * is configured.
214
+ */
215
+ export async function fetchAssistantIngressUrl(
216
+ runtimeUrl: string,
217
+ bearerToken?: string,
218
+ ): Promise<string | undefined> {
219
+ if (!bearerToken) return undefined;
220
+ try {
221
+ const resp = await fetch(`${runtimeUrl}/integrations/ingress/config`, {
222
+ headers: { Authorization: `Bearer ${bearerToken}` },
223
+ signal: AbortSignal.timeout(5000),
224
+ });
225
+ if (resp.ok) {
226
+ const body = (await resp.json()) as {
227
+ publicBaseUrl?: string;
228
+ managedCallbacks?: boolean;
229
+ };
230
+ // Ignore managed-callback URLs — those belong to the platform, not the
231
+ // self-hosted assistant's own ingress.
232
+ if (body.managedCallbacks) return undefined;
233
+ return body.publicBaseUrl || undefined;
234
+ }
235
+ } catch {
236
+ // Best-effort
237
+ }
238
+ return undefined;
239
+ }
240
+
150
241
  /**
151
242
  * Determine the version that was running before the current one.
152
243
  *
@@ -545,7 +636,7 @@ export async function performDockerRollback(
545
636
  const signingKey =
546
637
  capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
547
638
 
548
- // Build extra env vars, excluding keys managed by serviceDockerRunArgs
639
+ // Build extra env vars, excluding keys managed by buildServiceRunArgs
549
640
  const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
550
641
  for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
551
642
  if (process.env[envVar]) {
@@ -734,6 +825,11 @@ export async function performDockerRollback(
734
825
  // Failure path — attempt auto-rollback to original version
735
826
  console.error(`\n❌ Containers failed to become ready within the timeout.`);
736
827
 
828
+ const logDir = await captureUpgradeFailureLogs(res, `${instanceName}-rollback-failure`);
829
+ if (logDir) {
830
+ console.log(`📋 Container logs saved to: ${logDir}`);
831
+ }
832
+
737
833
  if (currentImageRefs) {
738
834
  await broadcastUpgradeEvent(
739
835
  entry.runtimeUrl,