@vellumai/cli 0.5.6 → 0.5.7

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.
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "crypto";
2
+ import { join } from "path";
2
3
 
3
4
  import {
4
5
  findAssistantByName,
@@ -9,7 +10,6 @@ import {
9
10
  import type { AssistantEntry } from "../lib/assistant-config";
10
11
  import {
11
12
  captureImageRefs,
12
- clearSigningKeyBootstrapLock,
13
13
  GATEWAY_INTERNAL_PORT,
14
14
  dockerResourceNames,
15
15
  migrateCesSecurityFiles,
@@ -18,12 +18,25 @@ import {
18
18
  stopContainers,
19
19
  } from "../lib/docker";
20
20
  import type { ServiceName } from "../lib/docker";
21
- import { loadBootstrapSecret } from "../lib/guardian-token";
21
+ import {
22
+ loadBootstrapSecret,
23
+ saveBootstrapSecret,
24
+ } from "../lib/guardian-token";
25
+ import { restoreBackup } from "../lib/backup-ops.js";
26
+ import { emitCliError, categorizeUpgradeError } from "../lib/cli-error.js";
22
27
  import {
23
28
  broadcastUpgradeEvent,
29
+ buildCompleteEvent,
30
+ buildProgressEvent,
31
+ buildStartingEvent,
32
+ buildUpgradeCommitMessage,
24
33
  captureContainerEnv,
34
+ CONTAINER_ENV_EXCLUDE_KEYS,
35
+ rollbackMigrations,
36
+ UPGRADE_PROGRESS,
25
37
  waitForReady,
26
- } from "./upgrade";
38
+ } from "../lib/upgrade-lifecycle.js";
39
+ import { commitWorkspaceState } from "../lib/workspace-git.js";
27
40
 
28
41
  function parseArgs(): { name: string | null } {
29
42
  const args = process.argv.slice(3);
@@ -53,6 +66,7 @@ function parseArgs(): { name: string | null } {
53
66
  name = arg;
54
67
  } else {
55
68
  console.error(`Error: Unknown option '${arg}'.`);
69
+ emitCliError("UNKNOWN", `Unknown option '${arg}'`);
56
70
  process.exit(1);
57
71
  }
58
72
  }
@@ -84,6 +98,10 @@ function resolveTargetAssistant(nameArg: string | null): AssistantEntry {
84
98
  const entry = findAssistantByName(nameArg);
85
99
  if (!entry) {
86
100
  console.error(`No assistant found with name '${nameArg}'.`);
101
+ emitCliError(
102
+ "ASSISTANT_NOT_FOUND",
103
+ `No assistant found with name '${nameArg}'.`,
104
+ );
87
105
  process.exit(1);
88
106
  }
89
107
  return entry;
@@ -99,11 +117,14 @@ function resolveTargetAssistant(nameArg: string | null): AssistantEntry {
99
117
  if (all.length === 1) return all[0];
100
118
 
101
119
  if (all.length === 0) {
102
- console.error("No assistants found. Run 'vellum hatch' first.");
120
+ const msg = "No assistants found. Run 'vellum hatch' first.";
121
+ console.error(msg);
122
+ emitCliError("ASSISTANT_NOT_FOUND", msg);
103
123
  } else {
104
- console.error(
105
- "Multiple assistants found. Specify a name or set an active assistant with 'vellum use <name>'.",
106
- );
124
+ const msg =
125
+ "Multiple assistants found. Specify a name or set an active assistant with 'vellum use <name>'.";
126
+ console.error(msg);
127
+ emitCliError("ASSISTANT_NOT_FOUND", msg);
107
128
  }
108
129
  process.exit(1);
109
130
  }
@@ -115,26 +136,29 @@ export async function rollback(): Promise<void> {
115
136
 
116
137
  // Only Docker assistants support rollback
117
138
  if (cloud !== "docker") {
118
- console.error(
119
- "Rollback is only supported for Docker assistants. For managed assistants, use the version picker to upgrade to the previous version.",
120
- );
139
+ const msg =
140
+ "Rollback is only supported for Docker assistants. For managed assistants, use the version picker to upgrade to the previous version.";
141
+ console.error(msg);
142
+ emitCliError("UNSUPPORTED_TOPOLOGY", msg);
121
143
  process.exit(1);
122
144
  }
123
145
 
124
146
  // Verify rollback state exists
125
147
  if (!entry.previousServiceGroupVersion || !entry.previousContainerInfo) {
126
- console.error(
127
- "No rollback state available. Run `vellum upgrade` first to create a rollback point.",
128
- );
148
+ const msg =
149
+ "No rollback state available. Run `vellum upgrade` first to create a rollback point.";
150
+ console.error(msg);
151
+ emitCliError("ROLLBACK_NO_STATE", msg);
129
152
  process.exit(1);
130
153
  }
131
154
 
132
155
  // Verify all three digest fields are present
133
156
  const prev = entry.previousContainerInfo;
134
157
  if (!prev.assistantDigest || !prev.gatewayDigest || !prev.cesDigest) {
135
- console.error(
136
- "Incomplete rollback state. Previous container digests are missing.",
137
- );
158
+ const msg =
159
+ "Incomplete rollback state. Previous container digests are missing.";
160
+ console.error(msg);
161
+ emitCliError("ROLLBACK_NO_STATE", msg);
138
162
  process.exit(1);
139
163
  }
140
164
 
@@ -148,133 +172,272 @@ export async function rollback(): Promise<void> {
148
172
  const instanceName = entry.assistantId;
149
173
  const res = dockerResourceNames(instanceName);
150
174
 
151
- console.log(
152
- `🔄 Rolling back Docker assistant '${instanceName}' to ${entry.previousServiceGroupVersion}...\n`,
153
- );
154
-
155
- // Capture current container env
156
- console.log("💾 Capturing existing container environment...");
157
- const capturedEnv = await captureContainerEnv(res.assistantContainer);
158
- console.log(
159
- ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
160
- );
161
-
162
- // Extract CES_SERVICE_TOKEN from captured env, or generate fresh one
163
- const cesServiceToken =
164
- capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
165
-
166
- // Retrieve or generate a bootstrap secret for the gateway.
167
- const bootstrapSecret =
168
- loadBootstrapSecret(instanceName) || randomBytes(32).toString("hex");
169
-
170
- // Build extra env vars, excluding keys managed by serviceDockerRunArgs
171
- const envKeysSetByRunArgs = new Set([
172
- "CES_SERVICE_TOKEN",
173
- "VELLUM_ASSISTANT_NAME",
174
- "RUNTIME_HTTP_HOST",
175
- "PATH",
176
- ]);
177
- for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
178
- if (process.env[envVar]) {
179
- envKeysSetByRunArgs.add(envVar);
175
+ try {
176
+ const workspaceDir = entry.resources
177
+ ? join(entry.resources.instanceDir, ".vellum", "workspace")
178
+ : undefined;
179
+
180
+ // Record rollback start in workspace git history
181
+ if (workspaceDir) {
182
+ try {
183
+ await commitWorkspaceState(
184
+ workspaceDir,
185
+ buildUpgradeCommitMessage({
186
+ action: "rollback",
187
+ phase: "starting",
188
+ from: entry.serviceGroupVersion ?? "unknown",
189
+ to: entry.previousServiceGroupVersion ?? "unknown",
190
+ topology: "docker",
191
+ assistantId: entry.assistantId,
192
+ }),
193
+ );
194
+ } catch (err) {
195
+ console.warn(
196
+ `⚠️ Failed to create pre-rollback workspace commit: ${err instanceof Error ? err.message : String(err)}`,
197
+ );
198
+ }
180
199
  }
181
- }
182
- const extraAssistantEnv: Record<string, string> = {};
183
- for (const [key, value] of Object.entries(capturedEnv)) {
184
- if (!envKeysSetByRunArgs.has(key)) {
185
- extraAssistantEnv[key] = value;
200
+
201
+ console.log(
202
+ `🔄 Rolling back Docker assistant '${instanceName}' to ${entry.previousServiceGroupVersion}...\n`,
203
+ );
204
+
205
+ // Capture current container env
206
+ console.log("💾 Capturing existing container environment...");
207
+ const capturedEnv = await captureContainerEnv(res.assistantContainer);
208
+ console.log(
209
+ ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
210
+ );
211
+
212
+ // Extract CES_SERVICE_TOKEN from captured env, or generate fresh one
213
+ const cesServiceToken =
214
+ capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
215
+
216
+ // Retrieve or generate a bootstrap secret for the gateway.
217
+ const loadedSecret = loadBootstrapSecret(instanceName);
218
+ const bootstrapSecret = loadedSecret || randomBytes(32).toString("hex");
219
+ if (!loadedSecret) {
220
+ saveBootstrapSecret(instanceName, bootstrapSecret);
186
221
  }
187
- }
188
222
 
189
- // Parse gateway port from entry's runtimeUrl, fall back to default
190
- let gatewayPort = GATEWAY_INTERNAL_PORT;
191
- try {
192
- const parsed = new URL(entry.runtimeUrl);
193
- const port = parseInt(parsed.port, 10);
194
- if (!isNaN(port)) {
195
- gatewayPort = port;
223
+ // Extract or generate the shared JWT signing key.
224
+ const signingKey =
225
+ capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
226
+
227
+ // Build extra env vars, excluding keys managed by serviceDockerRunArgs
228
+ const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
229
+ for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
230
+ if (process.env[envVar]) {
231
+ envKeysSetByRunArgs.add(envVar);
232
+ }
233
+ }
234
+ const extraAssistantEnv: Record<string, string> = {};
235
+ for (const [key, value] of Object.entries(capturedEnv)) {
236
+ if (!envKeysSetByRunArgs.has(key)) {
237
+ extraAssistantEnv[key] = value;
238
+ }
239
+ }
240
+
241
+ // Parse gateway port from entry's runtimeUrl, fall back to default
242
+ let gatewayPort = GATEWAY_INTERNAL_PORT;
243
+ try {
244
+ const parsed = new URL(entry.runtimeUrl);
245
+ const port = parseInt(parsed.port, 10);
246
+ if (!isNaN(port)) {
247
+ gatewayPort = port;
248
+ }
249
+ } catch {
250
+ // use default
196
251
  }
197
- } catch {
198
- // use default
199
- }
200
252
 
201
- // Notify connected clients that a rollback is about to begin (best-effort)
202
- console.log("📢 Notifying connected clients...");
203
- await broadcastUpgradeEvent(entry.runtimeUrl, entry.assistantId, {
204
- type: "starting",
205
- targetVersion: entry.previousServiceGroupVersion,
206
- expectedDowntimeSeconds: 60,
207
- });
208
- // Brief pause to allow SSE delivery before containers stop.
209
- await new Promise((r) => setTimeout(r, 500));
210
-
211
- console.log("🛑 Stopping existing containers...");
212
- await stopContainers(res);
213
- console.log("✅ Containers stopped\n");
214
-
215
- // Run security file migrations and signing key cleanup
216
- console.log("🔄 Migrating security files to gateway volume...");
217
- await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
218
-
219
- console.log("🔄 Migrating credential files to CES security volume...");
220
- await migrateCesSecurityFiles(res, (msg) => console.log(msg));
221
-
222
- console.log("🔑 Clearing signing key bootstrap lock...");
223
- await clearSigningKeyBootstrapLock(res);
224
-
225
- console.log("🚀 Starting containers with previous version...");
226
- await startContainers(
227
- {
228
- bootstrapSecret,
229
- cesServiceToken,
230
- extraAssistantEnv,
231
- gatewayPort,
232
- imageTags: previousImageRefs,
233
- instanceName,
234
- res,
235
- },
236
- (msg) => console.log(msg),
237
- );
238
- console.log("✅ Containers started\n");
239
-
240
- console.log("Waiting for assistant to become ready...");
241
- const ready = await waitForReady(entry.runtimeUrl);
242
-
243
- if (ready) {
244
- // Capture new digests from the rolled-back containers
245
- const newDigests = await captureImageRefs(res);
246
-
247
- // Swap current/previous state to enable "rollback the rollback"
248
- const updatedEntry: AssistantEntry = {
249
- ...entry,
250
- serviceGroupVersion: entry.previousServiceGroupVersion,
251
- containerInfo: {
252
- assistantImage: prev.assistantImage ?? previousImageRefs.assistant,
253
- gatewayImage: prev.gatewayImage ?? previousImageRefs.gateway,
254
- cesImage: prev.cesImage ?? previousImageRefs["credential-executor"],
255
- assistantDigest: newDigests?.assistant,
256
- gatewayDigest: newDigests?.gateway,
257
- cesDigest: newDigests?.["credential-executor"],
258
- networkName: res.network,
253
+ // Notify connected clients that a rollback is about to begin (best-effort)
254
+ console.log("📢 Notifying connected clients...");
255
+ await broadcastUpgradeEvent(
256
+ entry.runtimeUrl,
257
+ entry.assistantId,
258
+ buildStartingEvent(entry.previousServiceGroupVersion),
259
+ );
260
+ // Brief pause to allow SSE delivery before containers stop.
261
+ await new Promise((r) => setTimeout(r, 500));
262
+
263
+ // Roll back migrations to pre-upgrade state (must happen before containers stop)
264
+ if (
265
+ entry.previousDbMigrationVersion !== undefined ||
266
+ entry.previousWorkspaceMigrationId !== undefined
267
+ ) {
268
+ console.log("🔄 Reverting database changes...");
269
+ await broadcastUpgradeEvent(
270
+ entry.runtimeUrl,
271
+ entry.assistantId,
272
+ buildProgressEvent(UPGRADE_PROGRESS.REVERTING_MIGRATIONS),
273
+ );
274
+ await rollbackMigrations(
275
+ entry.runtimeUrl,
276
+ entry.assistantId,
277
+ entry.previousDbMigrationVersion,
278
+ entry.previousWorkspaceMigrationId,
279
+ );
280
+ }
281
+
282
+ // Progress: switching version (must be sent BEFORE stopContainers)
283
+ await broadcastUpgradeEvent(
284
+ entry.runtimeUrl,
285
+ entry.assistantId,
286
+ buildProgressEvent(UPGRADE_PROGRESS.SWITCHING),
287
+ );
288
+
289
+ console.log("🛑 Stopping existing containers...");
290
+ await stopContainers(res);
291
+ console.log("✅ Containers stopped\n");
292
+
293
+ // Run security file migrations and signing key cleanup
294
+ console.log("🔄 Migrating security files to gateway volume...");
295
+ await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
296
+
297
+ console.log("🔄 Migrating credential files to CES security volume...");
298
+ await migrateCesSecurityFiles(res, (msg) => console.log(msg));
299
+
300
+ console.log("🚀 Starting containers with previous version...");
301
+ await startContainers(
302
+ {
303
+ signingKey,
304
+ bootstrapSecret,
305
+ cesServiceToken,
306
+ extraAssistantEnv,
307
+ gatewayPort,
308
+ imageTags: previousImageRefs,
309
+ instanceName,
310
+ res,
259
311
  },
260
- previousServiceGroupVersion: entry.serviceGroupVersion,
261
- previousContainerInfo: entry.containerInfo,
262
- };
263
- saveAssistantEntry(updatedEntry);
264
-
265
- // Notify clients that the rollback succeeded
266
- await broadcastUpgradeEvent(entry.runtimeUrl, entry.assistantId, {
267
- type: "complete",
268
- installedVersion: entry.previousServiceGroupVersion,
269
- success: true,
270
- });
312
+ (msg) => console.log(msg),
313
+ );
314
+ console.log("✅ Containers started\n");
315
+
316
+ console.log("Waiting for assistant to become ready...");
317
+ const ready = await waitForReady(entry.runtimeUrl);
318
+
319
+ if (ready) {
320
+ // Restore data from the backup created for the specific upgrade being
321
+ // rolled back. We use the persisted preUpgradeBackupPath rather than
322
+ // scanning for the latest backup on disk — if the most recent upgrade's
323
+ // backup failed, a global scan would find a stale backup from a prior
324
+ // cycle and overwrite newer user data.
325
+ const backupPath = entry.preUpgradeBackupPath as string | undefined;
326
+ if (backupPath) {
327
+ // Progress: restoring data (gateway is back up at this point)
328
+ await broadcastUpgradeEvent(
329
+ entry.runtimeUrl,
330
+ entry.assistantId,
331
+ buildProgressEvent(UPGRADE_PROGRESS.RESTORING),
332
+ );
333
+
334
+ console.log(`📦 Restoring data from pre-upgrade backup...`);
335
+ console.log(` Source: ${backupPath}`);
336
+ const restored = await restoreBackup(
337
+ entry.runtimeUrl,
338
+ entry.assistantId,
339
+ backupPath,
340
+ );
341
+ if (restored) {
342
+ console.log(" ✅ Data restored successfully\n");
343
+ } else {
344
+ console.warn(
345
+ " ⚠️ Data restore failed (rollback continues without data restoration)\n",
346
+ );
347
+ }
348
+ } else {
349
+ console.log(
350
+ "ℹ️ No pre-upgrade backup was created for this upgrade, skipping data restoration\n",
351
+ );
352
+ }
353
+
354
+ // Capture new digests from the rolled-back containers
355
+ const newDigests = await captureImageRefs(res);
356
+
357
+ // Swap current/previous state to enable "rollback the rollback"
358
+ const updatedEntry: AssistantEntry = {
359
+ ...entry,
360
+ serviceGroupVersion: entry.previousServiceGroupVersion,
361
+ containerInfo: {
362
+ assistantImage: prev.assistantImage ?? previousImageRefs.assistant,
363
+ gatewayImage: prev.gatewayImage ?? previousImageRefs.gateway,
364
+ cesImage: prev.cesImage ?? previousImageRefs["credential-executor"],
365
+ assistantDigest: newDigests?.assistant,
366
+ gatewayDigest: newDigests?.gateway,
367
+ cesDigest: newDigests?.["credential-executor"],
368
+ networkName: res.network,
369
+ },
370
+ previousServiceGroupVersion: entry.serviceGroupVersion,
371
+ previousContainerInfo: entry.containerInfo,
372
+ // Clear the backup path — it belonged to the upgrade we just rolled back
373
+ preUpgradeBackupPath: undefined,
374
+ previousDbMigrationVersion: undefined,
375
+ previousWorkspaceMigrationId: undefined,
376
+ };
377
+ saveAssistantEntry(updatedEntry);
378
+
379
+ // Notify clients that the rollback succeeded
380
+ await broadcastUpgradeEvent(
381
+ entry.runtimeUrl,
382
+ entry.assistantId,
383
+ buildCompleteEvent(entry.previousServiceGroupVersion, true),
384
+ );
271
385
 
272
- console.log(
273
- `\n✅ Docker assistant '${instanceName}' rolled back to ${entry.previousServiceGroupVersion}.`,
386
+ // Record successful rollback in workspace git history
387
+ if (workspaceDir) {
388
+ try {
389
+ await commitWorkspaceState(
390
+ workspaceDir,
391
+ buildUpgradeCommitMessage({
392
+ action: "rollback",
393
+ phase: "complete",
394
+ from: entry.serviceGroupVersion ?? "unknown",
395
+ to: entry.previousServiceGroupVersion ?? "unknown",
396
+ topology: "docker",
397
+ assistantId: entry.assistantId,
398
+ result: "success",
399
+ }),
400
+ );
401
+ } catch (err) {
402
+ console.warn(
403
+ `⚠️ Failed to create post-rollback workspace commit: ${err instanceof Error ? err.message : String(err)}`,
404
+ );
405
+ }
406
+ }
407
+
408
+ console.log(
409
+ `\n✅ Docker assistant '${instanceName}' rolled back to ${entry.previousServiceGroupVersion}.`,
410
+ );
411
+ } else {
412
+ console.error(
413
+ `\n❌ Containers failed to become ready within the timeout.`,
414
+ );
415
+ console.log(
416
+ ` Check logs with: docker logs -f ${res.assistantContainer}`,
417
+ );
418
+ await broadcastUpgradeEvent(
419
+ entry.runtimeUrl,
420
+ entry.assistantId,
421
+ buildCompleteEvent(
422
+ entry.previousServiceGroupVersion ?? "unknown",
423
+ false,
424
+ ),
425
+ );
426
+ emitCliError(
427
+ "READINESS_TIMEOUT",
428
+ "Rolled-back containers failed to become ready within the timeout.",
429
+ );
430
+ process.exit(1);
431
+ }
432
+ } catch (err) {
433
+ const detail = err instanceof Error ? err.message : String(err);
434
+ console.error(`\n❌ Rollback failed: ${detail}`);
435
+ await broadcastUpgradeEvent(
436
+ entry.runtimeUrl,
437
+ entry.assistantId,
438
+ buildCompleteEvent(entry.serviceGroupVersion ?? "unknown", false),
274
439
  );
275
- } else {
276
- console.error(`\n❌ Containers failed to become ready within the timeout.`);
277
- console.log(` Check logs with: docker logs -f ${res.assistantContainer}`);
440
+ emitCliError(categorizeUpgradeError(err), "Rollback failed", detail);
278
441
  process.exit(1);
279
442
  }
280
443
  }