@vellumai/cli 0.8.11-staging.1 → 0.8.12-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,142 +2,164 @@ import { getPlatformUrl } from "./platform-client.js";
2
2
  import { DOCKERHUB_IMAGES } from "./docker.js";
3
3
  import type { ServiceName } from "./docker.js";
4
4
  import { loopbackSafeFetch } from "./loopback-fetch.js";
5
+ import { stripVersionPrefix } from "./version-compat.js";
5
6
 
6
7
  export interface ResolvedImageRefs {
7
8
  imageTags: Record<ServiceName, string>;
8
9
  source: "platform" | "dockerhub";
9
10
  }
10
11
 
12
+ export interface ReleaseListItem {
13
+ version: string;
14
+ is_stable?: boolean;
15
+ assistant_image_ref?: string | null;
16
+ gateway_image_ref?: string | null;
17
+ credential_executor_image_ref?: string | null;
18
+ }
19
+
11
20
  /**
12
- * Fetch the latest stable release version from the platform API.
13
- * Returns the version string (e.g. "0.7.0") or null if unavailable.
14
- * The releases endpoint returns entries ordered newest-first.
21
+ * The endpoint defaults `limit` to 10, which is too few to validate an
22
+ * explicit --version against ("absent from the list" is treated as a hard
23
+ * error) always request the documented maximum.
15
24
  */
16
- export async function fetchLatestStableVersion(): Promise<string | null> {
25
+ const RELEASES_FETCH_LIMIT = 100;
26
+
27
+ /**
28
+ * Fetch the releases list from the platform API, optionally filtered by
29
+ * channel (the `channel` param takes precedence over `stable` server-side).
30
+ * `platformUrl` overrides the lockfile/env-resolved default — pass the
31
+ * target assistant's platform URL when it may differ from the active one.
32
+ * Returns `null` when the platform is unreachable or responds non-OK —
33
+ * distinct from `[]` (platform answered, no releases).
34
+ */
35
+ export async function fetchReleases(opts?: {
36
+ channel?: "stable" | "preview";
37
+ platformUrl?: string;
38
+ }): Promise<ReleaseListItem[] | null> {
17
39
  try {
18
- const platformUrl = getPlatformUrl();
19
- const response = await loopbackSafeFetch(`${platformUrl}/v1/releases/?stable=true`, {
20
- signal: AbortSignal.timeout(10_000),
21
- });
40
+ const platformUrl = opts?.platformUrl || getPlatformUrl();
41
+ const filter = opts?.channel ? `channel=${opts.channel}` : "stable=true";
42
+ const response = await loopbackSafeFetch(
43
+ `${platformUrl}/v1/releases/?${filter}&limit=${RELEASES_FETCH_LIMIT}`,
44
+ { signal: AbortSignal.timeout(10_000) },
45
+ );
22
46
  if (!response.ok) return null;
23
-
24
- const releases = (await response.json()) as Array<{
25
- version?: string;
26
- }>;
27
- const first = releases[0];
28
- return first?.version ?? null;
47
+ return (await response.json()) as ReleaseListItem[];
29
48
  } catch {
30
49
  return null;
31
50
  }
32
51
  }
33
52
 
34
53
  /**
35
- * Resolve image references for a given version.
54
+ * Fetch the latest stable release version from the platform API.
55
+ * Returns the version string (e.g. "0.7.0") or null if unavailable.
56
+ * The releases endpoint returns entries ordered newest-first.
57
+ */
58
+ export async function fetchLatestStableVersion(): Promise<string | null> {
59
+ const releases = await fetchReleases();
60
+ return releases?.[0]?.version ?? null;
61
+ }
62
+
63
+ export type ImageRefResolution =
64
+ | { status: "platform"; imageTags: Record<ServiceName, string> }
65
+ | { status: "dockerhub-fallback"; imageTags: Record<ServiceName, string> }
66
+ | { status: "version-not-found" };
67
+
68
+ function dockerhubImageTags(version: string): Record<ServiceName, string> {
69
+ return {
70
+ assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
71
+ "credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
72
+ gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Resolve image references for a given version, distinguishing why the
78
+ * platform refs were unavailable:
36
79
  *
37
- * Tries the platform API first (returns GCR digest-based refs when available),
38
- * then falls back to DockerHub tag-based refs when the platform is unreachable
39
- * or the version is not found.
80
+ * - `platform` release found; GCR digest-based refs (credential-executor
81
+ * falls back to DockerHub per-service when its ref is null).
82
+ * - `dockerhub-fallback` platform unreachable or responded non-OK;
83
+ * tag-based DockerHub refs.
84
+ * - `version-not-found` — the platform answered but the version is absent
85
+ * from the releases list (likely a typo'd --version).
40
86
  */
41
- export async function resolveImageRefs(
87
+ export async function resolveImageRefsDetailed(
42
88
  version: string,
43
89
  log?: (msg: string) => void,
44
- ): Promise<ResolvedImageRefs> {
90
+ ): Promise<ImageRefResolution> {
45
91
  log?.("Resolving image references...");
46
92
 
47
- const platformRefs = await fetchPlatformImageRefs(version, log);
48
- if (platformRefs) {
49
- log?.("Resolved image refs from platform API");
93
+ const releases = await fetchReleases();
94
+ if (releases === null) {
95
+ log?.("Platform unreachable falling back to DockerHub tags");
50
96
  return {
51
- imageTags: platformRefs.imageTags,
52
- source: "platform",
97
+ status: "dockerhub-fallback",
98
+ imageTags: dockerhubImageTags(version),
53
99
  };
54
100
  }
55
101
 
56
- log?.("Falling back to DockerHub tags");
57
- const imageTags: Record<ServiceName, string> = {
58
- assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
59
- "credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
60
- gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
102
+ const normalizedVersion = stripVersionPrefix(version);
103
+ const release = releases.find(
104
+ (r) => stripVersionPrefix(r.version ?? "") === normalizedVersion,
105
+ );
106
+
107
+ if (!release) {
108
+ log?.(`Version ${version} not found in platform releases`);
109
+ return { status: "version-not-found" };
110
+ }
111
+
112
+ const assistantImage = release.assistant_image_ref;
113
+ const gatewayImage = release.gateway_image_ref;
114
+ let credentialExecutorImage = release.credential_executor_image_ref;
115
+
116
+ // Assistant and gateway images are required; a release missing them is a
117
+ // platform data gap, not a user typo — fall back to DockerHub tags.
118
+ if (!assistantImage || !gatewayImage) {
119
+ log?.("Platform release missing required image refs");
120
+ return {
121
+ status: "dockerhub-fallback",
122
+ imageTags: dockerhubImageTags(version),
123
+ };
124
+ }
125
+
126
+ if (!credentialExecutorImage) {
127
+ credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
128
+ log?.(
129
+ "credential-executor image not in platform release, using DockerHub fallback",
130
+ );
131
+ }
132
+
133
+ return {
134
+ status: "platform",
135
+ imageTags: {
136
+ assistant: assistantImage,
137
+ "credential-executor": credentialExecutorImage,
138
+ gateway: gatewayImage,
139
+ },
61
140
  };
62
- return { imageTags, source: "dockerhub" };
63
141
  }
64
142
 
65
143
  /**
66
- * Fetch image references from the platform releases API.
144
+ * Resolve image references for a given version.
67
145
  *
68
- * Returns a record of service name to image ref (GCR digest-based) for the
69
- * given version, or null if the platform is unreachable, the version is not
70
- * found, or any error occurs.
146
+ * Lenient wrapper around {@link resolveImageRefsDetailed}: maps
147
+ * `version-not-found` to the DockerHub fallback so existing callers
148
+ * (start flows, docker rollback) keep their permissive behavior. The
149
+ * upgrade command uses the detailed variant to fail fast on typos.
71
150
  */
72
- async function fetchPlatformImageRefs(
151
+ export async function resolveImageRefs(
73
152
  version: string,
74
153
  log?: (msg: string) => void,
75
- ): Promise<{
76
- imageTags: Record<ServiceName, string>;
77
- } | null> {
78
- try {
79
- const platformUrl = getPlatformUrl();
80
- const url = `${platformUrl}/v1/releases/?stable=true`;
81
-
82
- log?.(`Fetching releases from ${url}`);
83
-
84
- const response = await loopbackSafeFetch(url, {
85
- signal: AbortSignal.timeout(10_000),
86
- });
87
-
88
- if (!response.ok) {
89
- log?.(`Platform API returned ${response.status}`);
90
- return null;
91
- }
92
-
93
- const releases = (await response.json()) as Array<{
94
- version?: string;
95
- assistant_image_ref?: string | null;
96
- gateway_image_ref?: string | null;
97
- credential_executor_image_ref?: string | null;
98
- }>;
99
-
100
- // Strip leading "v" from the requested version for matching
101
- const normalizedVersion = version.replace(/^v/, "");
102
-
103
- const release = releases.find((r) => {
104
- const releaseVersion = (r.version ?? "").replace(/^v/, "");
105
- return releaseVersion === normalizedVersion;
106
- });
107
-
108
- if (!release) {
109
- log?.(`Version ${version} not found in platform releases`);
110
- return null;
111
- }
112
-
113
- const assistantImage = release.assistant_image_ref;
114
- const gatewayImage = release.gateway_image_ref;
115
- let credentialExecutorImage = release.credential_executor_image_ref;
116
-
117
- // Assistant and gateway images are required; credential-executor falls back to DockerHub
118
- if (!assistantImage || !gatewayImage) {
119
- log?.("Platform release missing required image refs");
120
- return null;
121
- }
122
-
123
- // Fall back to DockerHub for credential-executor if its image ref is null
124
- if (!credentialExecutorImage) {
125
- credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
126
- log?.(
127
- "credential-executor image not in platform release, using DockerHub fallback",
128
- );
129
- }
130
-
131
- return {
132
- imageTags: {
133
- assistant: assistantImage,
134
- "credential-executor": credentialExecutorImage,
135
- gateway: gatewayImage,
136
- },
137
- };
138
- } catch (err) {
139
- const message = err instanceof Error ? err.message : String(err);
140
- log?.(`Platform image ref resolution failed: ${message}`);
141
- return null;
154
+ ): Promise<ResolvedImageRefs> {
155
+ const resolution = await resolveImageRefsDetailed(version, log);
156
+ if (resolution.status === "platform") {
157
+ log?.("Resolved image refs from platform API");
158
+ return { imageTags: resolution.imageTags, source: "platform" };
159
+ }
160
+ if (resolution.status === "version-not-found") {
161
+ log?.("Falling back to DockerHub tags");
162
+ return { imageTags: dockerhubImageTags(version), source: "dockerhub" };
142
163
  }
164
+ return { imageTags: resolution.imageTags, source: "dockerhub" };
143
165
  }
@@ -15,6 +15,7 @@
15
15
 
16
16
  import {
17
17
  loadAllAssistants,
18
+ normalizeVersion,
18
19
  removeAssistantEntry,
19
20
  saveAssistantEntry,
20
21
  } from "./assistant-config.js";
@@ -22,6 +23,7 @@ import {
22
23
  fetchCurrentUser,
23
24
  fetchPlatformAssistants,
24
25
  getPlatformUrl,
26
+ type HatchedAssistant,
25
27
  } from "./platform-client.js";
26
28
 
27
29
  export type SyncLogger = (message: string) => void;
@@ -82,7 +84,7 @@ export async function syncCloudAssistants(
82
84
  }
83
85
  }
84
86
 
85
- let platformAssistants: { id: string; name: string; status: string }[];
87
+ let platformAssistants: HatchedAssistant[];
86
88
  try {
87
89
  log?.("Fetching platform assistants…");
88
90
  platformAssistants = await fetchPlatformAssistants(token);
@@ -126,22 +128,33 @@ export async function syncCloudAssistants(
126
128
  const existing = existingCloudById.get(pa.id);
127
129
  const assistantName = pa.name.trim();
128
130
  const nameFields = assistantName ? { name: assistantName } : {};
131
+ // undefined when the platform reports no release — written through on
132
+ // update so a stale cached version is cleared, not preserved.
133
+ const version =
134
+ pa.current_release_version != null
135
+ ? normalizeVersion(pa.current_release_version)
136
+ : undefined;
129
137
  if (!existing) {
130
138
  log?.(`Adding ${pa.name || pa.id} to lockfile`);
131
139
  saveAssistantEntry({
132
140
  assistantId: pa.id,
133
141
  ...nameFields,
142
+ ...(version && { version }),
134
143
  runtimeUrl: getPlatformUrl(),
135
144
  cloud: "vellum",
136
145
  species: "vellum",
137
146
  hatchedAt: new Date().toISOString(),
138
147
  });
139
148
  added++;
140
- } else if (assistantName && existing.name !== assistantName) {
141
- log?.(`Updating ${pa.id} name to ${assistantName}`);
149
+ } else if (
150
+ (assistantName && existing.name !== assistantName) ||
151
+ existing.version !== version
152
+ ) {
153
+ log?.(`Updating ${pa.id} from platform`);
142
154
  saveAssistantEntry({
143
155
  ...existing,
144
- name: assistantName,
156
+ ...nameFields,
157
+ version,
145
158
  });
146
159
  updated++;
147
160
  }
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
5
5
 
6
6
  import type { AssistantEntry } from "./assistant-config.js";
7
- import { saveAssistantEntry } from "./assistant-config.js";
7
+ import { normalizeVersion, saveAssistantEntry } from "./assistant-config.js";
8
8
  import { createBackup, pruneOldBackups, restoreBackup } from "./backup-ops.js";
9
9
  import { emitCliError } from "./cli-error.js";
10
10
  import { getOrCreateHostDeviceId } from "./device-id.js";
@@ -19,14 +19,14 @@ import {
19
19
  import { getStateDir } from "./environments/paths.js";
20
20
  import { getCurrentEnvironment } from "./environments/resolve.js";
21
21
  import { loadGuardianToken } from "./guardian-token.js";
22
- import { resolveImageRefs } from "./platform-releases.js";
22
+ import { fetchReleases, resolveImageRefs } from "./platform-releases.js";
23
23
  import {
24
24
  getBuilderManagedEnvKeys,
25
25
  type DockerStatefulSetSpec,
26
26
  type ServiceName,
27
27
  } from "./statefulset.js";
28
28
  import { exec, execOutput } from "./step-runner.js";
29
- import { compareVersions } from "./version-compat.js";
29
+ import { compareVersions, stripVersionPrefix } from "./version-compat.js";
30
30
  import { loopbackSafeFetch } from "./loopback-fetch.js";
31
31
 
32
32
  // ---------------------------------------------------------------------------
@@ -339,27 +339,18 @@ export async function fetchPreviousVersion(
339
339
 
340
340
  // 2. Derive from releases list
341
341
  if (!currentVersion) return undefined;
342
- try {
343
- const { getPlatformUrl } = await import("./platform-client.js");
344
- const platformUrl = getPlatformUrl();
345
- const resp = await loopbackSafeFetch(`${platformUrl}/v1/releases/?stable=true`, {
346
- signal: AbortSignal.timeout(10_000),
347
- });
348
- if (!resp.ok) return undefined;
342
+ const releases = await fetchReleases();
343
+ if (!releases) return undefined;
349
344
 
350
- const releases = (await resp.json()) as Array<{ version?: string }>;
351
- const normalizedCurrent = currentVersion.replace(/^v/, "");
345
+ const normalizedCurrent = stripVersionPrefix(currentVersion);
352
346
 
353
- // Releases are ordered newest-first; find the entry right after the
354
- // current version (i.e. the one that was running before the upgrade).
355
- const idx = releases.findIndex(
356
- (r) => (r.version ?? "").replace(/^v/, "") === normalizedCurrent,
357
- );
358
- if (idx >= 0 && idx + 1 < releases.length) {
359
- return releases[idx + 1].version;
360
- }
361
- } catch {
362
- // Best-effort
347
+ // Releases are ordered newest-first; find the entry right after the
348
+ // current version (i.e. the one that was running before the upgrade).
349
+ const idx = releases.findIndex(
350
+ (r) => stripVersionPrefix(r.version ?? "") === normalizedCurrent,
351
+ );
352
+ if (idx >= 0 && idx + 1 < releases.length) {
353
+ return releases[idx + 1].version;
363
354
  }
364
355
  return undefined;
365
356
  }
@@ -788,6 +779,7 @@ export async function performDockerRollback(
788
779
  previousContainerInfo: entry.containerInfo,
789
780
  previousDbMigrationVersion: preMigrationState.dbVersion,
790
781
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
782
+ version: normalizeVersion(targetVersion),
791
783
  preUpgradeBackupPath: undefined,
792
784
  };
793
785
  saveAssistantEntry(updatedEntry);
@@ -0,0 +1,127 @@
1
+ import type { ReleaseListItem } from "./platform-releases.js";
2
+ import { compareVersions, versionsEqual } from "./version-compat.js";
3
+
4
+ export interface UpgradeTargetResolution {
5
+ kind: "ok" | "version-not-found" | "no-releases";
6
+ /** Resolved target version (kind "ok" only). */
7
+ target: string | null;
8
+ /** compareVersions(target, current); null when either side is unknown/unparseable. */
9
+ comparison: number | null;
10
+ isNoOp: boolean;
11
+ isDowngrade: boolean;
12
+ }
13
+
14
+ /**
15
+ * Resolve the upgrade target version from the releases list and compare it
16
+ * against the running version. Pure — callers fetch releases/current first.
17
+ *
18
+ * - An explicit version is validated against the releases list when one is
19
+ * available (`releases !== null`); absent from the list → "version-not-found".
20
+ * - No explicit version → latest release, skipping non-stable heads
21
+ * (mirrors the web UI: `releases.find(r => r.is_stable !== false) ?? releases[0]`).
22
+ * - `releases === null` (platform unreachable) with an explicit version
23
+ * trusts the explicit version; without one → "no-releases".
24
+ */
25
+ export function resolveUpgradeTarget(args: {
26
+ explicitVersion: string | null;
27
+ releases: ReleaseListItem[] | null;
28
+ currentVersion: string | undefined;
29
+ }): UpgradeTargetResolution {
30
+ const { explicitVersion, releases, currentVersion } = args;
31
+
32
+ let target: string | null = null;
33
+ if (explicitVersion) {
34
+ if (releases !== null) {
35
+ const found = releases.find((r) =>
36
+ versionsEqual(r.version ?? "", explicitVersion),
37
+ );
38
+ if (!found) {
39
+ return {
40
+ kind: "version-not-found",
41
+ target: null,
42
+ comparison: null,
43
+ isNoOp: false,
44
+ isDowngrade: false,
45
+ };
46
+ }
47
+ }
48
+ target = explicitVersion;
49
+ } else {
50
+ if (releases === null || releases.length === 0) {
51
+ return {
52
+ kind: "no-releases",
53
+ target: null,
54
+ comparison: null,
55
+ isNoOp: false,
56
+ isDowngrade: false,
57
+ };
58
+ }
59
+ target =
60
+ (releases.find((r) => r.is_stable !== false) ?? releases[0]).version;
61
+ }
62
+
63
+ const comparison = currentVersion
64
+ ? compareVersions(target, currentVersion)
65
+ : null;
66
+ const isNoOp = currentVersion
67
+ ? versionsEqual(target, currentVersion)
68
+ : false;
69
+
70
+ return {
71
+ kind: "ok",
72
+ target,
73
+ comparison,
74
+ isNoOp,
75
+ isDowngrade: comparison !== null && comparison < 0,
76
+ };
77
+ }
78
+
79
+ export interface UpgradePollState {
80
+ /** Resolved target; null when the server resolved "latest" without reporting it. */
81
+ targetVersion: string | null;
82
+ /** Version observed before the upgrade was triggered. */
83
+ initialVersion: string | null;
84
+ /** Latest current_release_version from the assistant detail endpoint. */
85
+ observedVersion: string | null;
86
+ /** Latest upgrade-status in_progress; null when the endpoint is unavailable. */
87
+ inProgress: boolean | null;
88
+ /** Whether in_progress === true was ever observed during this poll. */
89
+ sawInProgress: boolean;
90
+ }
91
+
92
+ /**
93
+ * Completion predicate for the platform upgrade poll loop. Pure.
94
+ *
95
+ * The primary signal is the DB-backed `current_release_version` (works while
96
+ * the service group restarts or the assistant sleeps); the upgrade-status
97
+ * lock is secondary, used only when the target version is unknown.
98
+ */
99
+ export function evaluateUpgradePoll(
100
+ state: UpgradePollState,
101
+ ): "pending" | "complete" {
102
+ const { targetVersion, initialVersion, observedVersion, inProgress, sawInProgress } =
103
+ state;
104
+
105
+ if (targetVersion) {
106
+ return observedVersion && versionsEqual(observedVersion, targetVersion)
107
+ ? "complete"
108
+ : "pending";
109
+ }
110
+
111
+ // Target unknown: a version change from the pre-upgrade value is
112
+ // definitive on its own — the upgrade can finish before the first poll,
113
+ // leaving in_progress false without sawInProgress ever being set.
114
+ if (
115
+ observedVersion &&
116
+ initialVersion &&
117
+ !versionsEqual(observedVersion, initialVersion)
118
+ ) {
119
+ return "complete";
120
+ }
121
+
122
+ // Otherwise rely on the in-progress lock releasing (e.g. when the
123
+ // pre-upgrade version was unknown).
124
+ if (sawInProgress && inProgress === false) return "complete";
125
+
126
+ return "pending";
127
+ }
@@ -86,6 +86,24 @@ export function compareVersions(a: string, b: string): number | null {
86
86
  return comparePreRelease(pa.pre!, pb.pre!);
87
87
  }
88
88
 
89
+ /**
90
+ * Strip an optional leading `v`/`V` prefix: "v0.7.0" → "0.7.0".
91
+ */
92
+ export function stripVersionPrefix(version: string): string {
93
+ return version.replace(/^[vV]/, "");
94
+ }
95
+
96
+ /**
97
+ * Check whether two version strings refer to the same version.
98
+ * Compares via semver when both parse; falls back to prefix-stripped
99
+ * string equality when either is unparseable.
100
+ */
101
+ export function versionsEqual(a: string, b: string): boolean {
102
+ const cmp = compareVersions(a, b);
103
+ if (cmp !== null) return cmp === 0;
104
+ return stripVersionPrefix(a) === stripVersionPrefix(b);
105
+ }
106
+
89
107
  /**
90
108
  * Check whether two version strings are compatible.
91
109
  * Compatibility requires matching major AND minor versions.