@vellumai/cli 0.6.2 → 0.6.4
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 +12 -2
- package/README.md +3 -3
- package/bunfig.toml +6 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +124 -0
- package/src/__tests__/env-drift.test.ts +87 -0
- package/src/__tests__/guardian-token.test.ts +172 -0
- package/src/__tests__/multi-local.test.ts +61 -14
- package/src/__tests__/orphan-detection.test.ts +214 -0
- package/src/__tests__/platform-client.test.ts +204 -0
- package/src/__tests__/preload.ts +27 -0
- package/src/__tests__/ssh-user-guard.test.ts +28 -0
- package/src/__tests__/teleport.test.ts +1073 -57
- package/src/commands/backup.ts +8 -0
- package/src/commands/hatch.ts +5 -28
- package/src/commands/login.ts +178 -9
- package/src/commands/logs.ts +652 -0
- package/src/commands/pair.ts +9 -1
- package/src/commands/ps.ts +37 -7
- package/src/commands/recover.ts +8 -4
- package/src/commands/restore.ts +124 -12
- package/src/commands/retire.ts +17 -3
- package/src/commands/rollback.ts +32 -33
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh-apple-container.ts +162 -0
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +307 -3
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +21 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +3 -0
- package/src/lib/__tests__/docker.test.ts +78 -0
- package/src/lib/assistant-config.ts +54 -87
- package/src/lib/aws.ts +12 -1
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +73 -4
- package/src/lib/environments/__tests__/paths.test.ts +234 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/paths.ts +110 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +46 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +8 -10
- package/src/lib/hatch-local.ts +30 -35
- package/src/lib/local.ts +46 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +261 -25
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
|
@@ -6,36 +6,37 @@ import {
|
|
|
6
6
|
existsSync,
|
|
7
7
|
mkdirSync,
|
|
8
8
|
} from "fs";
|
|
9
|
-
import { homedir } from "os";
|
|
10
9
|
import { join, dirname } from "path";
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
11
|
+
import { getLockfilePlatformBaseUrl } from "./assistant-config.js";
|
|
12
|
+
import { getConfigDir } from "./environments/paths.js";
|
|
13
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
15
14
|
|
|
16
15
|
function getPlatformTokenPath(): string {
|
|
17
|
-
return join(
|
|
16
|
+
return join(getConfigDir(getCurrentEnvironment()), "platform-token");
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the platform API base URL. Resolution order:
|
|
21
|
+
* 1. `platformBaseUrl` persisted on the lockfile by
|
|
22
|
+
* {@link syncConfigToLockfile} when the active assistant was last
|
|
23
|
+
* hatched/waked. This is the source of truth for "what URL does the
|
|
24
|
+
* currently-active assistant target" — reading the workspace
|
|
25
|
+
* `config.json` directly is incorrect for multi-instance and
|
|
26
|
+
* non-production XDG layouts because the CLI process has no way to
|
|
27
|
+
* know which instance to read from without first consulting the
|
|
28
|
+
* lockfile anyway.
|
|
29
|
+
* 2. `VELLUM_PLATFORM_URL` env var (explicit override, e.g. in CI).
|
|
30
|
+
* 3. The current environment's seed URL (e.g. `https://dev-platform.vellum.ai`
|
|
31
|
+
* for `VELLUM_ENVIRONMENT=dev`, `https://platform.vellum.ai` for prod).
|
|
32
|
+
* This makes the CLI environment-aware when no lockfile entry exists yet.
|
|
33
|
+
*/
|
|
20
34
|
export function getPlatformUrl(): string {
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
const base = process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
24
|
-
const configPath = join(base, ".vellum", "workspace", "config.json");
|
|
25
|
-
if (existsSync(configPath)) {
|
|
26
|
-
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
27
|
-
string,
|
|
28
|
-
unknown
|
|
29
|
-
>;
|
|
30
|
-
const val = (raw.platform as Record<string, unknown> | undefined)
|
|
31
|
-
?.baseUrl;
|
|
32
|
-
if (typeof val === "string" && val.trim()) configUrl = val.trim();
|
|
33
|
-
}
|
|
34
|
-
} catch {
|
|
35
|
-
// Config not available — fall through
|
|
36
|
-
}
|
|
35
|
+
const lockfileUrl = getLockfilePlatformBaseUrl();
|
|
37
36
|
return (
|
|
38
|
-
|
|
37
|
+
lockfileUrl ||
|
|
38
|
+
process.env.VELLUM_PLATFORM_URL?.trim() ||
|
|
39
|
+
getCurrentEnvironment().platformUrl
|
|
39
40
|
);
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -146,10 +147,173 @@ export interface HatchedAssistant {
|
|
|
146
147
|
status: string;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
export interface HatchAssistantResult {
|
|
151
|
+
assistant: HatchedAssistant;
|
|
152
|
+
/** true when the platform returned an existing assistant (HTTP 200) */
|
|
153
|
+
reusedExisting: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Self-hosted local assistant registration
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export interface EnsureRegistrationResponse {
|
|
161
|
+
assistant: { id: string; name: string };
|
|
162
|
+
registration: {
|
|
163
|
+
client_installation_id: string;
|
|
164
|
+
runtime_assistant_id: string;
|
|
165
|
+
client_platform: string;
|
|
166
|
+
};
|
|
167
|
+
assistant_api_key: string | null;
|
|
168
|
+
webhook_secret: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Register (or re-confirm) a self-hosted local assistant with the platform.
|
|
173
|
+
*
|
|
174
|
+
* Calls `POST /v1/assistants/self-hosted-local/ensure-registration/`.
|
|
175
|
+
* The endpoint is idempotent: the first call provisions an API key;
|
|
176
|
+
* subsequent calls return `assistant_api_key: null`.
|
|
177
|
+
*/
|
|
178
|
+
export async function ensureSelfHostedLocalRegistration(
|
|
179
|
+
token: string,
|
|
180
|
+
organizationId: string,
|
|
181
|
+
clientInstallationId: string,
|
|
182
|
+
runtimeAssistantId: string,
|
|
183
|
+
clientPlatform: string,
|
|
184
|
+
assistantVersion?: string,
|
|
185
|
+
platformUrl?: string,
|
|
186
|
+
): Promise<EnsureRegistrationResponse> {
|
|
187
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
188
|
+
const body: Record<string, string> = {
|
|
189
|
+
client_installation_id: clientInstallationId,
|
|
190
|
+
runtime_assistant_id: runtimeAssistantId,
|
|
191
|
+
client_platform: clientPlatform,
|
|
192
|
+
};
|
|
193
|
+
if (assistantVersion) {
|
|
194
|
+
body.assistant_version = assistantVersion;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const response = await fetch(
|
|
198
|
+
`${resolvedUrl}/v1/assistants/self-hosted-local/ensure-registration/`,
|
|
199
|
+
{
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: {
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
Accept: "application/json",
|
|
204
|
+
"X-Session-Token": token,
|
|
205
|
+
"Vellum-Organization-Id": organizationId,
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (response.status === 401 || response.status === 403) {
|
|
212
|
+
throw new Error("Authentication required for assistant registration.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
const detail = await response.text().catch(() => "");
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Registration failed (${response.status}): ${detail || response.statusText}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (await response.json()) as EnsureRegistrationResponse;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Credential injection into running assistant via gateway
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Inject a single credential into the assistant's secret store via the
|
|
231
|
+
* gateway's `POST /v1/secrets` endpoint.
|
|
232
|
+
*
|
|
233
|
+
* Mirrors the desktop app's `GatewayHTTPClient.post(path: "secrets", …)`
|
|
234
|
+
* calls in `LocalAssistantBootstrapService.swift`.
|
|
235
|
+
*/
|
|
236
|
+
async function injectGatewayCredential(
|
|
237
|
+
gatewayUrl: string,
|
|
238
|
+
name: string,
|
|
239
|
+
value: string,
|
|
240
|
+
bearerToken?: string,
|
|
241
|
+
): Promise<boolean> {
|
|
242
|
+
const headers: Record<string, string> = {
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
Accept: "application/json",
|
|
245
|
+
};
|
|
246
|
+
if (bearerToken) {
|
|
247
|
+
headers["Authorization"] = `Bearer ${bearerToken}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const response = await fetch(`${gatewayUrl}/v1/secrets`, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers,
|
|
253
|
+
body: JSON.stringify({ type: "credential", name, value }),
|
|
254
|
+
signal: AbortSignal.timeout(10_000),
|
|
255
|
+
});
|
|
256
|
+
return response.ok;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface CredentialInjectionParams {
|
|
260
|
+
gatewayUrl: string;
|
|
261
|
+
bearerToken?: string;
|
|
262
|
+
assistantApiKey?: string | null;
|
|
263
|
+
platformAssistantId: string;
|
|
264
|
+
platformBaseUrl: string;
|
|
265
|
+
organizationId: string;
|
|
266
|
+
userId?: string;
|
|
267
|
+
webhookSecret?: string | null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Inject platform credentials into a running assistant via the gateway,
|
|
272
|
+
* mirroring `LocalAssistantBootstrapService.injectKeyIntoAssistant` et al.
|
|
273
|
+
*
|
|
274
|
+
* Each credential is posted individually. Failures are collected but do
|
|
275
|
+
* not prevent the remaining credentials from being injected.
|
|
276
|
+
*
|
|
277
|
+
* Returns true if all injections succeeded.
|
|
278
|
+
*/
|
|
279
|
+
export async function injectCredentialsIntoAssistant(
|
|
280
|
+
params: CredentialInjectionParams,
|
|
281
|
+
): Promise<boolean> {
|
|
282
|
+
const inject = (name: string, value: string) =>
|
|
283
|
+
injectGatewayCredential(params.gatewayUrl, name, value, params.bearerToken);
|
|
284
|
+
|
|
285
|
+
const promises: Promise<boolean>[] = [];
|
|
286
|
+
|
|
287
|
+
if (params.assistantApiKey) {
|
|
288
|
+
promises.push(inject("vellum:assistant_api_key", params.assistantApiKey));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
promises.push(
|
|
292
|
+
inject("vellum:platform_assistant_id", params.platformAssistantId),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
promises.push(inject("vellum:platform_base_url", params.platformBaseUrl));
|
|
296
|
+
|
|
297
|
+
promises.push(
|
|
298
|
+
inject("vellum:platform_organization_id", params.organizationId),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (params.userId) {
|
|
302
|
+
promises.push(inject("vellum:platform_user_id", params.userId));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (params.webhookSecret) {
|
|
306
|
+
promises.push(inject("vellum:webhook_secret", params.webhookSecret));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const results = await Promise.all(promises);
|
|
310
|
+
return results.every(Boolean);
|
|
311
|
+
}
|
|
312
|
+
|
|
149
313
|
export async function hatchAssistant(
|
|
150
314
|
token: string,
|
|
151
315
|
platformUrl?: string,
|
|
152
|
-
): Promise<
|
|
316
|
+
): Promise<HatchAssistantResult> {
|
|
153
317
|
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
154
318
|
const url = `${resolvedUrl}/v1/assistants/hatch/`;
|
|
155
319
|
|
|
@@ -160,7 +324,8 @@ export async function hatchAssistant(
|
|
|
160
324
|
});
|
|
161
325
|
|
|
162
326
|
if (response.ok) {
|
|
163
|
-
|
|
327
|
+
const assistant = (await response.json()) as HatchedAssistant;
|
|
328
|
+
return { assistant, reusedExisting: response.status === 200 };
|
|
164
329
|
}
|
|
165
330
|
|
|
166
331
|
if (response.status === 401 || response.status === 403) {
|
|
@@ -186,6 +351,37 @@ export async function hatchAssistant(
|
|
|
186
351
|
);
|
|
187
352
|
}
|
|
188
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Lightweight pre-check: returns the first active managed assistant for the
|
|
356
|
+
* authenticated user, or `null` if none exists. Calls `GET /v1/assistants/`
|
|
357
|
+
* and looks for any assistant with status "active".
|
|
358
|
+
*
|
|
359
|
+
* Used by the teleport flow to block BEFORE the expensive GCS upload when
|
|
360
|
+
* the user already has a platform assistant.
|
|
361
|
+
*/
|
|
362
|
+
export async function checkExistingPlatformAssistant(
|
|
363
|
+
token: string,
|
|
364
|
+
platformUrl?: string,
|
|
365
|
+
): Promise<HatchedAssistant | null> {
|
|
366
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
367
|
+
const url = `${resolvedUrl}/v1/assistants/`;
|
|
368
|
+
|
|
369
|
+
const response = await fetch(url, {
|
|
370
|
+
headers: await authHeaders(token, platformUrl),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
if (!response.ok) {
|
|
374
|
+
// Non-fatal: if the list call fails, fall through and let hatch handle it.
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const body = (await response.json()) as {
|
|
379
|
+
results?: HatchedAssistant[];
|
|
380
|
+
};
|
|
381
|
+
const active = body.results?.find((a) => a.status === "active");
|
|
382
|
+
return active ?? null;
|
|
383
|
+
}
|
|
384
|
+
|
|
189
385
|
export interface PlatformUser {
|
|
190
386
|
id: string;
|
|
191
387
|
email: string;
|
|
@@ -529,7 +725,7 @@ export async function platformImportBundleFromGcs(
|
|
|
529
725
|
method: "POST",
|
|
530
726
|
headers: await authHeaders(token, platformUrl),
|
|
531
727
|
body: JSON.stringify({ bundle_key: bundleKey }),
|
|
532
|
-
signal: AbortSignal.timeout(
|
|
728
|
+
signal: AbortSignal.timeout(60_000),
|
|
533
729
|
},
|
|
534
730
|
);
|
|
535
731
|
|
|
@@ -543,3 +739,43 @@ export async function platformImportBundleFromGcs(
|
|
|
543
739
|
>;
|
|
544
740
|
return { statusCode: response.status, body };
|
|
545
741
|
}
|
|
742
|
+
|
|
743
|
+
export async function platformPollImportStatus(
|
|
744
|
+
jobId: string,
|
|
745
|
+
token: string,
|
|
746
|
+
platformUrl?: string,
|
|
747
|
+
): Promise<{
|
|
748
|
+
status: string;
|
|
749
|
+
result?: Record<string, unknown>;
|
|
750
|
+
error?: string;
|
|
751
|
+
}> {
|
|
752
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
753
|
+
const response = await fetch(
|
|
754
|
+
`${resolvedUrl}/v1/migrations/import/${jobId}/status/`,
|
|
755
|
+
{
|
|
756
|
+
headers: await authHeaders(token, platformUrl),
|
|
757
|
+
},
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
if (response.status === 404) {
|
|
761
|
+
throw new Error("Import job not found");
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (!response.ok) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`Import status check failed: ${response.status} ${response.statusText}`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const body = (await response.json()) as {
|
|
771
|
+
status: string;
|
|
772
|
+
job_id?: string;
|
|
773
|
+
result?: Record<string, unknown>;
|
|
774
|
+
error?: string;
|
|
775
|
+
};
|
|
776
|
+
return {
|
|
777
|
+
status: body.status,
|
|
778
|
+
result: body.result,
|
|
779
|
+
error: body.error,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createConnection } from "net";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
|
|
4
|
+
import type { AssistantEntry } from "./assistant-config.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Retire an Apple Container assistant by sending a retire command to the
|
|
8
|
+
* macOS app via the management socket. The app handles the full lifecycle:
|
|
9
|
+
* stop the pod, archive the instance directory, remove the guardian token,
|
|
10
|
+
* deregister from the platform, and remove the lockfile entry.
|
|
11
|
+
*/
|
|
12
|
+
export async function retireAppleContainer(
|
|
13
|
+
name: string,
|
|
14
|
+
entry: AssistantEntry,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
console.log(`\u{1F5D1}\ufe0f Retiring Apple Container assistant '${name}'...\n`);
|
|
17
|
+
|
|
18
|
+
const mgmtSocket = entry.mgmtSocket as string | undefined;
|
|
19
|
+
if (!mgmtSocket) {
|
|
20
|
+
console.error(
|
|
21
|
+
`No management socket found for '${name}'.\n` +
|
|
22
|
+
"The assistant may not be running. If the macOS app is closed, " +
|
|
23
|
+
"open it and try again.",
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!existsSync(mgmtSocket)) {
|
|
29
|
+
console.error(
|
|
30
|
+
`Management socket not found at ${mgmtSocket}.\n` +
|
|
31
|
+
"The assistant may have been stopped. Open the macOS app and try again.",
|
|
32
|
+
);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const handshake = JSON.stringify({ action: "retire" }) + "\n";
|
|
37
|
+
|
|
38
|
+
return new Promise<void>((resolve, reject) => {
|
|
39
|
+
const socket = createConnection({ path: mgmtSocket }, () => {
|
|
40
|
+
socket.write(handshake);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const TIMEOUT_MS = 30_000;
|
|
44
|
+
const chunks: Buffer[] = [];
|
|
45
|
+
let totalLen = 0;
|
|
46
|
+
|
|
47
|
+
socket.setTimeout(TIMEOUT_MS);
|
|
48
|
+
socket.on("timeout", () => {
|
|
49
|
+
console.error("Timed out waiting for retire response from the macOS app.");
|
|
50
|
+
socket.destroy();
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
socket.on("data", (data: Buffer) => {
|
|
55
|
+
chunks.push(data);
|
|
56
|
+
totalLen += data.length;
|
|
57
|
+
const accumulated = Buffer.concat(chunks, totalLen);
|
|
58
|
+
const nlIndex = accumulated.indexOf(0x0a);
|
|
59
|
+
if (nlIndex === -1) return;
|
|
60
|
+
|
|
61
|
+
const responseLine = accumulated.slice(0, nlIndex).toString("utf-8");
|
|
62
|
+
socket.destroy();
|
|
63
|
+
|
|
64
|
+
let response: { status: string; message?: string };
|
|
65
|
+
try {
|
|
66
|
+
response = JSON.parse(responseLine) as {
|
|
67
|
+
status: string;
|
|
68
|
+
message?: string;
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
reject(new Error("Invalid response from management socket."));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (response.status === "ok") {
|
|
76
|
+
console.log(`\u2705 Apple Container assistant '${name}' retired.`);
|
|
77
|
+
resolve();
|
|
78
|
+
} else {
|
|
79
|
+
reject(
|
|
80
|
+
new Error(
|
|
81
|
+
`Retire failed: ${response.message || "unknown error"}`,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
socket.on("error", (err) => {
|
|
88
|
+
reject(new Error(`Management socket error: ${err.message}`));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
socket.on("end", () => {
|
|
92
|
+
if (chunks.length === 0) {
|
|
93
|
+
reject(
|
|
94
|
+
new Error(
|
|
95
|
+
"Management socket closed without responding. " +
|
|
96
|
+
"The macOS app may have crashed during retire.",
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -125,6 +125,72 @@ export async function captureContainerEnv(
|
|
|
125
125
|
return captured;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Best-effort fetch of the running service group version from the gateway
|
|
130
|
+
* `/healthz` endpoint. Returns `undefined` when the endpoint is
|
|
131
|
+
* unreachable or does not include a version field.
|
|
132
|
+
*/
|
|
133
|
+
export async function fetchCurrentVersion(
|
|
134
|
+
runtimeUrl: string,
|
|
135
|
+
): Promise<string | undefined> {
|
|
136
|
+
try {
|
|
137
|
+
const resp = await fetch(`${runtimeUrl}/healthz`, {
|
|
138
|
+
signal: AbortSignal.timeout(5000),
|
|
139
|
+
});
|
|
140
|
+
if (resp.ok) {
|
|
141
|
+
const body = (await resp.json()) as { version?: string };
|
|
142
|
+
return body.version;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Best-effort
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Determine the version that was running before the current one.
|
|
152
|
+
*
|
|
153
|
+
* Checks (in order):
|
|
154
|
+
* 1. `entry.previousVersion` (saved by the upgrade flow from health).
|
|
155
|
+
* 2. The releases list from the platform API — finds the version
|
|
156
|
+
* immediately before `currentVersion`.
|
|
157
|
+
*
|
|
158
|
+
* Returns `undefined` when neither source yields a result.
|
|
159
|
+
*/
|
|
160
|
+
export async function fetchPreviousVersion(
|
|
161
|
+
currentVersion: string | undefined,
|
|
162
|
+
previousVersionFromLockfile: string | undefined,
|
|
163
|
+
): Promise<string | undefined> {
|
|
164
|
+
// 1. Lockfile-cached value (written during upgrade from health endpoint)
|
|
165
|
+
if (previousVersionFromLockfile) return previousVersionFromLockfile;
|
|
166
|
+
|
|
167
|
+
// 2. Derive from releases list
|
|
168
|
+
if (!currentVersion) return undefined;
|
|
169
|
+
try {
|
|
170
|
+
const { getPlatformUrl } = await import("./platform-client.js");
|
|
171
|
+
const platformUrl = getPlatformUrl();
|
|
172
|
+
const resp = await fetch(`${platformUrl}/v1/releases/?stable=true`, {
|
|
173
|
+
signal: AbortSignal.timeout(10_000),
|
|
174
|
+
});
|
|
175
|
+
if (!resp.ok) return undefined;
|
|
176
|
+
|
|
177
|
+
const releases = (await resp.json()) as Array<{ version?: string }>;
|
|
178
|
+
const normalizedCurrent = currentVersion.replace(/^v/, "");
|
|
179
|
+
|
|
180
|
+
// Releases are ordered newest-first; find the entry right after the
|
|
181
|
+
// current version (i.e. the one that was running before the upgrade).
|
|
182
|
+
const idx = releases.findIndex(
|
|
183
|
+
(r) => (r.version ?? "").replace(/^v/, "") === normalizedCurrent,
|
|
184
|
+
);
|
|
185
|
+
if (idx >= 0 && idx + 1 < releases.length) {
|
|
186
|
+
return releases[idx + 1].version;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Best-effort
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
128
194
|
/**
|
|
129
195
|
* Poll the gateway `/readyz` endpoint until it returns 200 or the timeout
|
|
130
196
|
* elapses. Returns whether the assistant became ready.
|
|
@@ -314,27 +380,8 @@ export async function performDockerRollback(
|
|
|
314
380
|
throw new Error("targetVersion is required for performDockerRollback");
|
|
315
381
|
}
|
|
316
382
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// Validate target version < current version
|
|
320
|
-
if (currentVersion) {
|
|
321
|
-
const cmp = compareVersions(targetVersion, currentVersion);
|
|
322
|
-
if (cmp !== null) {
|
|
323
|
-
if (cmp > 0) {
|
|
324
|
-
const msg =
|
|
325
|
-
"Cannot roll back to a newer version. Use `vellum upgrade` instead.";
|
|
326
|
-
console.error(msg);
|
|
327
|
-
emitCliError("VERSION_DIRECTION", msg);
|
|
328
|
-
process.exit(1);
|
|
329
|
-
}
|
|
330
|
-
if (cmp === 0) {
|
|
331
|
-
const msg = `Already on version ${targetVersion}. Nothing to roll back to.`;
|
|
332
|
-
console.error(msg);
|
|
333
|
-
emitCliError("VERSION_DIRECTION", msg);
|
|
334
|
-
process.exit(1);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
383
|
+
// Fetch the current running version from the health endpoint.
|
|
384
|
+
let currentVersion: string | undefined;
|
|
338
385
|
|
|
339
386
|
const instanceName = entry.assistantId;
|
|
340
387
|
const res = dockerResourceNames(instanceName);
|
|
@@ -383,7 +430,7 @@ export async function performDockerRollback(
|
|
|
383
430
|
console.log("📸 Capturing current image references for rollback...");
|
|
384
431
|
const currentImageRefs = await captureImageRefs(res);
|
|
385
432
|
|
|
386
|
-
// Capture current migration state for rollback targeting
|
|
433
|
+
// Capture current migration state and running version for rollback targeting
|
|
387
434
|
let preMigrationState: {
|
|
388
435
|
dbVersion?: number;
|
|
389
436
|
lastWorkspaceMigrationId?: string;
|
|
@@ -395,25 +442,54 @@ export async function performDockerRollback(
|
|
|
395
442
|
);
|
|
396
443
|
if (healthResp.ok) {
|
|
397
444
|
const health = (await healthResp.json()) as {
|
|
445
|
+
version?: string;
|
|
398
446
|
migrations?: { dbVersion?: number; lastWorkspaceMigrationId?: string };
|
|
399
447
|
};
|
|
400
448
|
preMigrationState = health.migrations ?? {};
|
|
449
|
+
currentVersion = health.version;
|
|
401
450
|
}
|
|
402
451
|
} catch {
|
|
403
452
|
// Best-effort
|
|
404
453
|
}
|
|
405
454
|
|
|
455
|
+
// Validate target version < current version
|
|
456
|
+
if (!currentVersion) {
|
|
457
|
+
console.warn(
|
|
458
|
+
"⚠️ Could not determine current version from health endpoint — skipping version-direction check.\n",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (currentVersion) {
|
|
462
|
+
const cmp = compareVersions(targetVersion, currentVersion);
|
|
463
|
+
if (cmp !== null) {
|
|
464
|
+
if (cmp > 0) {
|
|
465
|
+
const msg =
|
|
466
|
+
"Cannot roll back to a newer version. Use `vellum upgrade` instead.";
|
|
467
|
+
console.error(msg);
|
|
468
|
+
emitCliError("VERSION_DIRECTION", msg);
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
if (cmp === 0) {
|
|
472
|
+
const msg = `Already on version ${targetVersion}. Nothing to roll back to.`;
|
|
473
|
+
console.error(msg);
|
|
474
|
+
emitCliError("VERSION_DIRECTION", msg);
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
406
480
|
// Persist rollback state to lockfile BEFORE any destructive changes
|
|
407
|
-
if (entry.
|
|
481
|
+
if (entry.containerInfo) {
|
|
408
482
|
const rollbackEntry: AssistantEntry = {
|
|
409
483
|
...entry,
|
|
410
|
-
previousServiceGroupVersion: entry.serviceGroupVersion,
|
|
411
484
|
previousContainerInfo: { ...entry.containerInfo },
|
|
485
|
+
previousVersion: currentVersion,
|
|
412
486
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
413
487
|
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
414
488
|
};
|
|
415
489
|
saveAssistantEntry(rollbackEntry);
|
|
416
|
-
|
|
490
|
+
if (currentVersion) {
|
|
491
|
+
console.log(` Saved rollback state: ${currentVersion}\n`);
|
|
492
|
+
}
|
|
417
493
|
}
|
|
418
494
|
|
|
419
495
|
// Record rollback start in workspace git history
|
|
@@ -613,7 +689,6 @@ export async function performDockerRollback(
|
|
|
613
689
|
// Swap current/previous state to enable "rollback the rollback"
|
|
614
690
|
const updatedEntry: AssistantEntry = {
|
|
615
691
|
...entry,
|
|
616
|
-
serviceGroupVersion: targetVersion,
|
|
617
692
|
containerInfo: {
|
|
618
693
|
assistantImage: targetImageTags.assistant,
|
|
619
694
|
gatewayImage: targetImageTags.gateway,
|
|
@@ -623,7 +698,6 @@ export async function performDockerRollback(
|
|
|
623
698
|
cesDigest: newDigests?.["credential-executor"],
|
|
624
699
|
networkName: res.network,
|
|
625
700
|
},
|
|
626
|
-
previousServiceGroupVersion: entry.serviceGroupVersion,
|
|
627
701
|
previousContainerInfo: entry.containerInfo,
|
|
628
702
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
629
703
|
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
@@ -749,7 +823,6 @@ export async function performDockerRollback(
|
|
|
749
823
|
currentImageRefs["credential-executor"],
|
|
750
824
|
networkName: res.network,
|
|
751
825
|
},
|
|
752
|
-
previousServiceGroupVersion: undefined,
|
|
753
826
|
previousContainerInfo: undefined,
|
|
754
827
|
previousDbMigrationVersion: undefined,
|
|
755
828
|
previousWorkspaceMigrationId: undefined,
|