@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.
- package/knip.json +3 -1
- package/package.json +1 -1
- package/src/commands/backup.ts +28 -13
- package/src/commands/hatch.ts +96 -60
- package/src/commands/retire.ts +5 -5
- package/src/commands/rollback.ts +298 -135
- package/src/commands/upgrade.ts +548 -200
- package/src/lib/assistant-config.ts +33 -6
- package/src/lib/aws.ts +2 -0
- package/src/lib/backup-ops.ts +213 -0
- package/src/lib/cli-error.ts +91 -0
- package/src/lib/config-utils.ts +59 -0
- package/src/lib/docker.ts +45 -37
- package/src/lib/doctor-client.ts +11 -1
- package/src/lib/gcp.ts +5 -1
- package/src/lib/local.ts +29 -9
- package/src/lib/platform-client.ts +17 -3
- package/src/lib/platform-releases.ts +112 -0
- package/src/lib/upgrade-lifecycle.ts +237 -0
- package/src/lib/workspace-git.ts +39 -0
package/src/lib/local.ts
CHANGED
|
@@ -192,10 +192,15 @@ function resolveDaemonMainPath(assistantIndex: string): string {
|
|
|
192
192
|
return join(dirname(assistantIndex), "daemon", "main.ts");
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
type DaemonStartOptions = {
|
|
196
|
+
foreground?: boolean;
|
|
197
|
+
defaultWorkspaceConfigPath?: string;
|
|
198
|
+
};
|
|
199
|
+
|
|
195
200
|
async function startDaemonFromSource(
|
|
196
201
|
assistantIndex: string,
|
|
197
202
|
resources: LocalInstanceResources,
|
|
198
|
-
options?:
|
|
203
|
+
options?: DaemonStartOptions,
|
|
199
204
|
): Promise<void> {
|
|
200
205
|
const foreground = options?.foreground ?? false;
|
|
201
206
|
const daemonMainPath = resolveDaemonMainPath(assistantIndex);
|
|
@@ -260,6 +265,7 @@ async function startDaemonFromSource(
|
|
|
260
265
|
const env: Record<string, string | undefined> = {
|
|
261
266
|
...process.env,
|
|
262
267
|
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
|
|
268
|
+
VELLUM_CLOUD: "local",
|
|
263
269
|
};
|
|
264
270
|
if (resources) {
|
|
265
271
|
env.BASE_DATA_DIR = resources.instanceDir;
|
|
@@ -268,6 +274,10 @@ async function startDaemonFromSource(
|
|
|
268
274
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
269
275
|
delete env.QDRANT_URL;
|
|
270
276
|
}
|
|
277
|
+
if (options?.defaultWorkspaceConfigPath) {
|
|
278
|
+
env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
279
|
+
options.defaultWorkspaceConfigPath;
|
|
280
|
+
}
|
|
271
281
|
|
|
272
282
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
273
283
|
// detect the in-progress spawn and wait instead of racing.
|
|
@@ -306,6 +316,7 @@ async function startDaemonFromSource(
|
|
|
306
316
|
async function startDaemonWatchFromSource(
|
|
307
317
|
assistantIndex: string,
|
|
308
318
|
resources: LocalInstanceResources,
|
|
319
|
+
options?: DaemonStartOptions,
|
|
309
320
|
): Promise<void> {
|
|
310
321
|
const mainPath = resolveDaemonMainPath(assistantIndex);
|
|
311
322
|
if (!existsSync(mainPath)) {
|
|
@@ -381,6 +392,10 @@ async function startDaemonWatchFromSource(
|
|
|
381
392
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
382
393
|
delete env.QDRANT_URL;
|
|
383
394
|
}
|
|
395
|
+
if (options?.defaultWorkspaceConfigPath) {
|
|
396
|
+
env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
397
|
+
options.defaultWorkspaceConfigPath;
|
|
398
|
+
}
|
|
384
399
|
|
|
385
400
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
386
401
|
// detect the in-progress spawn and wait instead of racing.
|
|
@@ -819,7 +834,7 @@ export function isGatewayWatchModeAvailable(): boolean {
|
|
|
819
834
|
export async function startLocalDaemon(
|
|
820
835
|
watch: boolean = false,
|
|
821
836
|
resources: LocalInstanceResources,
|
|
822
|
-
options?:
|
|
837
|
+
options?: DaemonStartOptions,
|
|
823
838
|
): Promise<void> {
|
|
824
839
|
const foreground = options?.foreground ?? false;
|
|
825
840
|
// Check for a compiled daemon binary adjacent to the CLI executable.
|
|
@@ -905,7 +920,7 @@ export async function startLocalDaemon(
|
|
|
905
920
|
|
|
906
921
|
// Build a minimal environment for the daemon. When launched from the
|
|
907
922
|
// macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
|
|
908
|
-
// __CFBundleIdentifier,
|
|
923
|
+
// __CFBundleIdentifier, etc.) that can cause
|
|
909
924
|
// the daemon to take 50+ seconds to start instead of ~1s.
|
|
910
925
|
const home = homedir();
|
|
911
926
|
const bunBinDir = join(home, ".bun", "bin");
|
|
@@ -924,20 +939,25 @@ export async function startLocalDaemon(
|
|
|
924
939
|
"ANTHROPIC_API_KEY",
|
|
925
940
|
"APP_VERSION",
|
|
926
941
|
"BASE_DATA_DIR",
|
|
927
|
-
"
|
|
942
|
+
"VELLUM_PLATFORM_URL",
|
|
928
943
|
"QDRANT_HTTP_PORT",
|
|
929
944
|
"QDRANT_URL",
|
|
930
945
|
"RUNTIME_HTTP_PORT",
|
|
931
|
-
"
|
|
946
|
+
"SENTRY_DSN_ASSISTANT",
|
|
932
947
|
"TMPDIR",
|
|
933
948
|
"USER",
|
|
934
949
|
"LANG",
|
|
935
950
|
"VELLUM_DEBUG",
|
|
951
|
+
"VELLUM_DESKTOP_APP",
|
|
936
952
|
]) {
|
|
937
953
|
if (process.env[key]) {
|
|
938
954
|
daemonEnv[key] = process.env[key]!;
|
|
939
955
|
}
|
|
940
956
|
}
|
|
957
|
+
if (options?.defaultWorkspaceConfigPath) {
|
|
958
|
+
daemonEnv.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
959
|
+
options.defaultWorkspaceConfigPath;
|
|
960
|
+
}
|
|
941
961
|
// When running a named instance, override env so the daemon resolves
|
|
942
962
|
// all paths under the instance directory and listens on its own port.
|
|
943
963
|
if (resources) {
|
|
@@ -1005,9 +1025,9 @@ export async function startLocalDaemon(
|
|
|
1005
1025
|
// Kill the bundled daemon to avoid two processes competing for the same port
|
|
1006
1026
|
await stopProcessByPidFile(pidFile, "bundled daemon");
|
|
1007
1027
|
if (watch) {
|
|
1008
|
-
await startDaemonWatchFromSource(assistantIndex, resources);
|
|
1028
|
+
await startDaemonWatchFromSource(assistantIndex, resources, options);
|
|
1009
1029
|
} else {
|
|
1010
|
-
await startDaemonFromSource(assistantIndex, resources);
|
|
1030
|
+
await startDaemonFromSource(assistantIndex, resources, options);
|
|
1011
1031
|
}
|
|
1012
1032
|
daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
|
|
1013
1033
|
}
|
|
@@ -1031,7 +1051,7 @@ export async function startLocalDaemon(
|
|
|
1031
1051
|
);
|
|
1032
1052
|
}
|
|
1033
1053
|
if (watch) {
|
|
1034
|
-
await startDaemonWatchFromSource(assistantIndex, resources);
|
|
1054
|
+
await startDaemonWatchFromSource(assistantIndex, resources, options);
|
|
1035
1055
|
|
|
1036
1056
|
const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
|
|
1037
1057
|
if (daemonReady) {
|
|
@@ -1042,7 +1062,7 @@ export async function startLocalDaemon(
|
|
|
1042
1062
|
);
|
|
1043
1063
|
}
|
|
1044
1064
|
} else {
|
|
1045
|
-
await startDaemonFromSource(assistantIndex, resources,
|
|
1065
|
+
await startDaemonFromSource(assistantIndex, resources, options);
|
|
1046
1066
|
|
|
1047
1067
|
const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
|
|
1048
1068
|
if (daemonReady) {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import { join, dirname } from "path";
|
|
11
11
|
|
|
12
|
-
const DEFAULT_PLATFORM_URL = "
|
|
12
|
+
const DEFAULT_PLATFORM_URL = "";
|
|
13
13
|
|
|
14
14
|
function getXdgConfigHome(): string {
|
|
15
15
|
return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
@@ -23,6 +23,20 @@ export function getPlatformUrl(): string {
|
|
|
23
23
|
return process.env.VELLUM_PLATFORM_URL ?? DEFAULT_PLATFORM_URL;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Returns the platform URL, throwing a clear error if it is not configured.
|
|
28
|
+
* Use this in functions that need a valid URL to make HTTP requests.
|
|
29
|
+
*/
|
|
30
|
+
function requirePlatformUrl(): string {
|
|
31
|
+
const url = getPlatformUrl();
|
|
32
|
+
if (!url) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"VELLUM_PLATFORM_URL is not configured. Set it in your environment or .env file.",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
export function readPlatformToken(): string | null {
|
|
27
41
|
try {
|
|
28
42
|
return readFileSync(getPlatformTokenPath(), "utf-8").trim();
|
|
@@ -60,7 +74,7 @@ interface OrganizationListResponse {
|
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
export async function fetchOrganizationId(token: string): Promise<string> {
|
|
63
|
-
const platformUrl =
|
|
77
|
+
const platformUrl = requirePlatformUrl();
|
|
64
78
|
const url = `${platformUrl}/v1/organizations/`;
|
|
65
79
|
const response = await fetch(url, {
|
|
66
80
|
headers: { "X-Session-Token": token },
|
|
@@ -92,7 +106,7 @@ interface AllauthSessionResponse {
|
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
export async function fetchCurrentUser(token: string): Promise<PlatformUser> {
|
|
95
|
-
const url = `${
|
|
109
|
+
const url = `${requirePlatformUrl()}/_allauth/app/v1/auth/session`;
|
|
96
110
|
const response = await fetch(url, {
|
|
97
111
|
headers: { "X-Session-Token": token },
|
|
98
112
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { getPlatformUrl } from "./platform-client.js";
|
|
2
|
+
import { DOCKERHUB_IMAGES } from "./docker.js";
|
|
3
|
+
import type { ServiceName } from "./docker.js";
|
|
4
|
+
|
|
5
|
+
export interface ResolvedImageRefs {
|
|
6
|
+
imageTags: Record<ServiceName, string>;
|
|
7
|
+
source: "platform" | "dockerhub";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve image references for a given version.
|
|
12
|
+
*
|
|
13
|
+
* Tries the platform API first (returns GCR digest-based refs when available),
|
|
14
|
+
* then falls back to DockerHub tag-based refs when the platform is unreachable
|
|
15
|
+
* or the version is not found.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveImageRefs(
|
|
18
|
+
version: string,
|
|
19
|
+
log?: (msg: string) => void,
|
|
20
|
+
): Promise<ResolvedImageRefs> {
|
|
21
|
+
log?.("Resolving image references...");
|
|
22
|
+
|
|
23
|
+
const platformRefs = await fetchPlatformImageRefs(version, log);
|
|
24
|
+
if (platformRefs) {
|
|
25
|
+
log?.("Resolved image refs from platform API");
|
|
26
|
+
return { imageTags: platformRefs, source: "platform" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
log?.("Falling back to DockerHub tags");
|
|
30
|
+
const imageTags: Record<ServiceName, string> = {
|
|
31
|
+
assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
|
|
32
|
+
"credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
|
|
33
|
+
gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
|
|
34
|
+
};
|
|
35
|
+
return { imageTags, source: "dockerhub" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch image references from the platform releases API.
|
|
40
|
+
*
|
|
41
|
+
* Returns a record of service name to image ref (GCR digest-based) for the
|
|
42
|
+
* given version, or null if the platform is unreachable, the version is not
|
|
43
|
+
* found, or any error occurs.
|
|
44
|
+
*/
|
|
45
|
+
async function fetchPlatformImageRefs(
|
|
46
|
+
version: string,
|
|
47
|
+
log?: (msg: string) => void,
|
|
48
|
+
): Promise<Record<ServiceName, string> | null> {
|
|
49
|
+
try {
|
|
50
|
+
const platformUrl = getPlatformUrl();
|
|
51
|
+
const url = `${platformUrl}/v1/releases/?stable=true`;
|
|
52
|
+
|
|
53
|
+
log?.(`Fetching releases from ${url}`);
|
|
54
|
+
|
|
55
|
+
const response = await fetch(url, {
|
|
56
|
+
signal: AbortSignal.timeout(10_000),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
log?.(`Platform API returned ${response.status}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const releases = (await response.json()) as Array<{
|
|
65
|
+
version?: string;
|
|
66
|
+
assistant_image_ref?: string | null;
|
|
67
|
+
gateway_image_ref?: string | null;
|
|
68
|
+
credential_executor_image_ref?: string | null;
|
|
69
|
+
}>;
|
|
70
|
+
|
|
71
|
+
// Strip leading "v" from the requested version for matching
|
|
72
|
+
const normalizedVersion = version.replace(/^v/, "");
|
|
73
|
+
|
|
74
|
+
const release = releases.find((r) => {
|
|
75
|
+
const releaseVersion = (r.version ?? "").replace(/^v/, "");
|
|
76
|
+
return releaseVersion === normalizedVersion;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!release) {
|
|
80
|
+
log?.(`Version ${version} not found in platform releases`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const assistantImage = release.assistant_image_ref;
|
|
85
|
+
const gatewayImage = release.gateway_image_ref;
|
|
86
|
+
let credentialExecutorImage = release.credential_executor_image_ref;
|
|
87
|
+
|
|
88
|
+
// Assistant and gateway images are required; credential-executor falls back to DockerHub
|
|
89
|
+
if (!assistantImage || !gatewayImage) {
|
|
90
|
+
log?.("Platform release missing required image refs");
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fall back to DockerHub for credential-executor if its image ref is null
|
|
95
|
+
if (!credentialExecutorImage) {
|
|
96
|
+
credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
|
|
97
|
+
log?.(
|
|
98
|
+
"credential-executor image not in platform release, using DockerHub fallback",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
assistant: assistantImage,
|
|
104
|
+
"credential-executor": credentialExecutorImage,
|
|
105
|
+
gateway: gatewayImage,
|
|
106
|
+
};
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
109
|
+
log?.(`Platform image ref resolution failed: ${message}`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { DOCKER_READY_TIMEOUT_MS } from "./docker.js";
|
|
2
|
+
import { loadGuardianToken } from "./guardian-token.js";
|
|
3
|
+
import { execOutput } from "./step-runner.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Shared constants & builders for upgrade / rollback lifecycle events
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** User-facing progress messages shared across upgrade and rollback flows. */
|
|
10
|
+
export const UPGRADE_PROGRESS = {
|
|
11
|
+
DOWNLOADING: "Downloading the update…",
|
|
12
|
+
BACKING_UP: "Saving a backup of your data…",
|
|
13
|
+
INSTALLING: "Installing the update…",
|
|
14
|
+
REVERTING: "The update didn't work. Reverting to the previous version…",
|
|
15
|
+
REVERTING_MIGRATIONS: "Reverting database changes…",
|
|
16
|
+
RESTORING: "Restoring your data…",
|
|
17
|
+
SWITCHING: "Switching to the previous version…",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export function buildStartingEvent(
|
|
21
|
+
targetVersion: string,
|
|
22
|
+
expectedDowntimeSeconds = 60,
|
|
23
|
+
) {
|
|
24
|
+
return { type: "starting" as const, targetVersion, expectedDowntimeSeconds };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildProgressEvent(statusMessage: string) {
|
|
28
|
+
return { type: "progress" as const, statusMessage };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildCompleteEvent(
|
|
32
|
+
installedVersion: string,
|
|
33
|
+
success: boolean,
|
|
34
|
+
rolledBackToVersion?: string,
|
|
35
|
+
) {
|
|
36
|
+
return {
|
|
37
|
+
type: "complete" as const,
|
|
38
|
+
installedVersion,
|
|
39
|
+
success,
|
|
40
|
+
...(rolledBackToVersion ? { rolledBackToVersion } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildUpgradeCommitMessage(options: {
|
|
45
|
+
action: "upgrade" | "rollback";
|
|
46
|
+
phase: "starting" | "complete";
|
|
47
|
+
from: string;
|
|
48
|
+
to: string;
|
|
49
|
+
topology: "docker" | "managed";
|
|
50
|
+
assistantId: string;
|
|
51
|
+
result?: "success" | "failure";
|
|
52
|
+
}): string {
|
|
53
|
+
const { action, phase, from, to, topology, assistantId, result } = options;
|
|
54
|
+
const header =
|
|
55
|
+
phase === "starting"
|
|
56
|
+
? `[${action}] Starting: ${from} → ${to}`
|
|
57
|
+
: `[${action}] Complete: ${from} → ${to}`;
|
|
58
|
+
const lines = [
|
|
59
|
+
header,
|
|
60
|
+
"",
|
|
61
|
+
`assistant: ${assistantId}`,
|
|
62
|
+
`from: ${from}`,
|
|
63
|
+
`to: ${to}`,
|
|
64
|
+
];
|
|
65
|
+
if (result) lines.push(`result: ${result}`);
|
|
66
|
+
lines.push(`topology: ${topology}`);
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Environment variable keys that are set by CLI run arguments and should
|
|
72
|
+
* not be replayed from a captured container environment during upgrades
|
|
73
|
+
* or rollbacks. Shared between upgrade.ts and rollback.ts.
|
|
74
|
+
*/
|
|
75
|
+
export const CONTAINER_ENV_EXCLUDE_KEYS: ReadonlySet<string> = new Set([
|
|
76
|
+
"CES_SERVICE_TOKEN",
|
|
77
|
+
"VELLUM_ASSISTANT_NAME",
|
|
78
|
+
"RUNTIME_HTTP_HOST",
|
|
79
|
+
"PATH",
|
|
80
|
+
"ACTOR_TOKEN_SIGNING_KEY",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Capture environment variables from a running Docker container so they
|
|
85
|
+
* can be replayed onto the replacement container after upgrade.
|
|
86
|
+
*/
|
|
87
|
+
export async function captureContainerEnv(
|
|
88
|
+
containerName: string,
|
|
89
|
+
): Promise<Record<string, string>> {
|
|
90
|
+
const captured: Record<string, string> = {};
|
|
91
|
+
try {
|
|
92
|
+
const raw = await execOutput("docker", [
|
|
93
|
+
"inspect",
|
|
94
|
+
"--format",
|
|
95
|
+
"{{json .Config.Env}}",
|
|
96
|
+
containerName,
|
|
97
|
+
]);
|
|
98
|
+
const entries = JSON.parse(raw) as string[];
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const eqIdx = entry.indexOf("=");
|
|
101
|
+
if (eqIdx > 0) {
|
|
102
|
+
captured[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Container may not exist or not be inspectable
|
|
107
|
+
}
|
|
108
|
+
return captured;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Poll the gateway `/readyz` endpoint until it returns 200 or the timeout
|
|
113
|
+
* elapses. Returns whether the assistant became ready.
|
|
114
|
+
*/
|
|
115
|
+
export async function waitForReady(runtimeUrl: string): Promise<boolean> {
|
|
116
|
+
const readyUrl = `${runtimeUrl}/readyz`;
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
|
|
119
|
+
while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
|
|
120
|
+
try {
|
|
121
|
+
const resp = await fetch(readyUrl, {
|
|
122
|
+
signal: AbortSignal.timeout(5000),
|
|
123
|
+
});
|
|
124
|
+
if (resp.ok) {
|
|
125
|
+
const elapsedSec = ((Date.now() - start) / 1000).toFixed(1);
|
|
126
|
+
console.log(`Assistant ready after ${elapsedSec}s`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
let detail = "";
|
|
130
|
+
try {
|
|
131
|
+
const body = await resp.text();
|
|
132
|
+
const json = JSON.parse(body);
|
|
133
|
+
const parts = [json.status];
|
|
134
|
+
if (json.upstream != null) parts.push(`upstream=${json.upstream}`);
|
|
135
|
+
detail = ` — ${parts.join(", ")}`;
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore parse errors
|
|
138
|
+
}
|
|
139
|
+
console.log(`Readiness check: ${resp.status}${detail} (retrying...)`);
|
|
140
|
+
} catch {
|
|
141
|
+
// Connection refused / timeout — not up yet
|
|
142
|
+
}
|
|
143
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Best-effort broadcast of an upgrade lifecycle event to connected clients
|
|
151
|
+
* via the gateway's upgrade-broadcast proxy. Uses guardian token auth.
|
|
152
|
+
* Failures are logged but never block the upgrade flow.
|
|
153
|
+
*/
|
|
154
|
+
export async function broadcastUpgradeEvent(
|
|
155
|
+
gatewayUrl: string,
|
|
156
|
+
assistantId: string,
|
|
157
|
+
event: Record<string, unknown>,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
try {
|
|
160
|
+
const token = loadGuardianToken(assistantId);
|
|
161
|
+
const headers: Record<string, string> = {
|
|
162
|
+
"Content-Type": "application/json",
|
|
163
|
+
};
|
|
164
|
+
if (token?.accessToken) {
|
|
165
|
+
headers["Authorization"] = `Bearer ${token.accessToken}`;
|
|
166
|
+
}
|
|
167
|
+
await fetch(`${gatewayUrl}/v1/admin/upgrade-broadcast`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers,
|
|
170
|
+
body: JSON.stringify(event),
|
|
171
|
+
signal: AbortSignal.timeout(3000),
|
|
172
|
+
});
|
|
173
|
+
} catch {
|
|
174
|
+
// Best-effort — gateway/daemon may already be shutting down or not yet ready
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Roll back DB and workspace migrations to a target state via the gateway.
|
|
180
|
+
* Best-effort — failures are logged but never block the rollback flow.
|
|
181
|
+
*/
|
|
182
|
+
export async function rollbackMigrations(
|
|
183
|
+
gatewayUrl: string,
|
|
184
|
+
assistantId: string,
|
|
185
|
+
targetDbVersion?: number,
|
|
186
|
+
targetWorkspaceMigrationId?: string,
|
|
187
|
+
rollbackToRegistryCeiling?: boolean,
|
|
188
|
+
): Promise<boolean> {
|
|
189
|
+
if (
|
|
190
|
+
!rollbackToRegistryCeiling &&
|
|
191
|
+
targetDbVersion === undefined &&
|
|
192
|
+
targetWorkspaceMigrationId === undefined
|
|
193
|
+
) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const token = loadGuardianToken(assistantId);
|
|
198
|
+
const headers: Record<string, string> = {
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
};
|
|
201
|
+
if (token?.accessToken) {
|
|
202
|
+
headers["Authorization"] = `Bearer ${token.accessToken}`;
|
|
203
|
+
}
|
|
204
|
+
const body: Record<string, unknown> = {};
|
|
205
|
+
if (targetDbVersion !== undefined) body.targetDbVersion = targetDbVersion;
|
|
206
|
+
if (targetWorkspaceMigrationId !== undefined)
|
|
207
|
+
body.targetWorkspaceMigrationId = targetWorkspaceMigrationId;
|
|
208
|
+
if (rollbackToRegistryCeiling) body.rollbackToRegistryCeiling = true;
|
|
209
|
+
|
|
210
|
+
const resp = await fetch(`${gatewayUrl}/v1/admin/rollback-migrations`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers,
|
|
213
|
+
body: JSON.stringify(body),
|
|
214
|
+
signal: AbortSignal.timeout(120_000),
|
|
215
|
+
});
|
|
216
|
+
if (!resp.ok) {
|
|
217
|
+
const text = await resp.text();
|
|
218
|
+
console.warn(`⚠️ Migration rollback failed (${resp.status}): ${text}`);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const result = (await resp.json()) as {
|
|
222
|
+
rolledBack?: { db?: string[]; workspace?: string[] };
|
|
223
|
+
};
|
|
224
|
+
const dbCount = result.rolledBack?.db?.length ?? 0;
|
|
225
|
+
const wsCount = result.rolledBack?.workspace?.length ?? 0;
|
|
226
|
+
if (dbCount > 0 || wsCount > 0) {
|
|
227
|
+
console.log(
|
|
228
|
+
` Rolled back ${dbCount} DB migration(s) and ${wsCount} workspace migration(s)`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
+
console.warn(`⚠️ Migration rollback failed: ${msg}`);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { exec } from "./step-runner";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort git commit in a workspace directory.
|
|
5
|
+
*
|
|
6
|
+
* Stages all changes and creates an `--allow-empty` commit so the
|
|
7
|
+
* history records every upgrade/rollback even when no files changed.
|
|
8
|
+
*
|
|
9
|
+
* Safety measures (mirroring WorkspaceGitService in the assistant package):
|
|
10
|
+
* - Deterministic committer identity (`vellum-cli`)
|
|
11
|
+
* - Hooks disabled (`core.hooksPath=/dev/null`, `--no-verify`)
|
|
12
|
+
*
|
|
13
|
+
* Callers should wrap this in try/catch — failures must never block
|
|
14
|
+
* the upgrade or rollback flow.
|
|
15
|
+
*/
|
|
16
|
+
export async function commitWorkspaceState(
|
|
17
|
+
workspaceDir: string,
|
|
18
|
+
message: string,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const opts = { cwd: workspaceDir };
|
|
21
|
+
await exec("git", ["add", "-A"], opts);
|
|
22
|
+
await exec(
|
|
23
|
+
"git",
|
|
24
|
+
[
|
|
25
|
+
"-c",
|
|
26
|
+
`user.name=${process.env.CLI_GIT_USER_NAME || "vellum-cli"}`,
|
|
27
|
+
"-c",
|
|
28
|
+
`user.email=${process.env.CLI_GIT_USER_EMAIL || "cli@vellum.ai"}`,
|
|
29
|
+
"-c",
|
|
30
|
+
"core.hooksPath=/dev/null",
|
|
31
|
+
"commit",
|
|
32
|
+
"--no-verify",
|
|
33
|
+
"--allow-empty",
|
|
34
|
+
"-m",
|
|
35
|
+
message,
|
|
36
|
+
],
|
|
37
|
+
opts,
|
|
38
|
+
);
|
|
39
|
+
}
|