@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.
@@ -6,6 +6,7 @@ import {
6
6
  findAssistantByName,
7
7
  getActiveAssistant,
8
8
  loadAllAssistants,
9
+ normalizeVersion,
9
10
  resolveCloud,
10
11
  saveAssistantEntry,
11
12
  type AssistantEntry,
@@ -19,13 +20,21 @@ import {
19
20
  } from "../lib/docker";
20
21
  import {
21
22
  fetchLatestStableVersion,
22
- resolveImageRefs,
23
+ fetchReleases,
24
+ resolveImageRefsDetailed,
23
25
  } from "../lib/platform-releases";
24
26
  import {
25
27
  authHeaders,
28
+ fetchAssistantDetail,
29
+ fetchUpgradeInProgress,
26
30
  getPlatformUrl,
27
31
  readPlatformToken,
28
32
  } from "../lib/platform-client";
33
+ import { checkManagedHealth } from "../lib/health-check.js";
34
+ import {
35
+ evaluateUpgradePoll,
36
+ resolveUpgradeTarget,
37
+ } from "../lib/upgrade-preflight.js";
29
38
  import {
30
39
  createBackup,
31
40
  pruneOldBackups,
@@ -46,7 +55,11 @@ import {
46
55
  UPGRADE_PROGRESS,
47
56
  waitForReady,
48
57
  } from "../lib/upgrade-lifecycle.js";
49
- import { compareVersions } from "../lib/version-compat.js";
58
+ import {
59
+ compareVersions,
60
+ stripVersionPrefix,
61
+ versionsEqual,
62
+ } from "../lib/version-compat.js";
50
63
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
51
64
 
52
65
  interface UpgradeArgs {
@@ -55,6 +68,8 @@ interface UpgradeArgs {
55
68
  latest: boolean;
56
69
  prepare: boolean;
57
70
  finalize: boolean;
71
+ noWait: boolean;
72
+ force: boolean;
58
73
  }
59
74
 
60
75
  function parseArgs(): UpgradeArgs {
@@ -64,6 +79,8 @@ function parseArgs(): UpgradeArgs {
64
79
  let latest = false;
65
80
  let prepare = false;
66
81
  let finalize = false;
82
+ let noWait = false;
83
+ let force = false;
67
84
 
68
85
  for (let i = 0; i < args.length; i++) {
69
86
  const arg = args[i];
@@ -91,6 +108,12 @@ function parseArgs(): UpgradeArgs {
91
108
  console.log(
92
109
  " --finalize Run post-upgrade steps only (broadcast complete, workspace commit)",
93
110
  );
111
+ console.log(
112
+ " --no-wait Platform assistants only: return after the upgrade request is accepted instead of waiting for completion",
113
+ );
114
+ console.log(
115
+ " --force Docker assistants only: reinstall even when already on the target version",
116
+ );
94
117
  console.log("");
95
118
  console.log("Examples:");
96
119
  console.log(
@@ -121,6 +144,10 @@ function parseArgs(): UpgradeArgs {
121
144
  prepare = true;
122
145
  } else if (arg === "--finalize") {
123
146
  finalize = true;
147
+ } else if (arg === "--no-wait") {
148
+ noWait = true;
149
+ } else if (arg === "--force") {
150
+ force = true;
124
151
  } else if (!arg.startsWith("-")) {
125
152
  name = arg;
126
153
  } else {
@@ -142,7 +169,18 @@ function parseArgs(): UpgradeArgs {
142
169
  process.exit(1);
143
170
  }
144
171
 
145
- return { name, version, latest, prepare, finalize };
172
+ if (noWait && (prepare || finalize)) {
173
+ console.error(
174
+ "Error: --no-wait cannot be combined with --prepare or --finalize.",
175
+ );
176
+ emitCliError(
177
+ "UNKNOWN",
178
+ "--no-wait cannot be combined with --prepare or --finalize",
179
+ );
180
+ process.exit(1);
181
+ }
182
+
183
+ return { name, version, latest, prepare, finalize, noWait, force };
146
184
  }
147
185
 
148
186
  /**
@@ -190,6 +228,7 @@ function resolveTargetAssistant(nameArg: string | null): AssistantEntry {
190
228
  async function upgradeDocker(
191
229
  entry: AssistantEntry,
192
230
  version: string | null,
231
+ force: boolean,
193
232
  ): Promise<void> {
194
233
  const instanceName = entry.assistantId;
195
234
  const res = dockerResourceNames(instanceName);
@@ -197,35 +236,10 @@ async function upgradeDocker(
197
236
  const versionTag =
198
237
  version ?? (cliPkg.version ? `v${cliPkg.version}` : "latest");
199
238
 
200
- // Fetch the current running version from the health endpoint.
201
- // This is used for logging, commit messages, and version-direction guards.
239
+ // Capture current migration state and running version for rollback targeting
240
+ // and version guards. Must happen while the daemon is still running (before
241
+ // containers are stopped).
202
242
  let currentVersion: string | undefined;
203
-
204
- console.log("🔍 Resolving image references...");
205
- const { imageTags } = await resolveImageRefs(versionTag);
206
-
207
- console.log(
208
- `🔄 Upgrading Docker assistant '${instanceName}' to ${versionTag}...\n`,
209
- );
210
-
211
- // Capture rollback state from existing containers BEFORE pulling new
212
- // images or stopping anything. captureImageRefs uses the immutable
213
- // image digest ({{.Image}}), but capturing first keeps the intent
214
- // explicit and avoids relying on container-inspect ordering subtleties.
215
- console.log("📸 Capturing current image references for rollback...");
216
- const previousImageRefs = await captureImageRefs(res);
217
- if (previousImageRefs) {
218
- console.log(
219
- ` Captured refs for ${Object.keys(previousImageRefs).length} service(s)\n`,
220
- );
221
- } else {
222
- console.log(
223
- " Could not capture all container refs (fresh install or partial deployment)\n",
224
- );
225
- }
226
-
227
- // Capture current migration state and running version for rollback targeting.
228
- // Must happen while daemon is still running (before containers are stopped).
229
243
  let preMigrationState: {
230
244
  dbVersion?: number;
231
245
  lastWorkspaceMigrationId?: string;
@@ -266,6 +280,53 @@ async function upgradeDocker(
266
280
  }
267
281
  }
268
282
 
283
+ // No-op guard: skip the full stop/start cycle (real downtime) when already
284
+ // on the target version. --force allows an intentional reinstall/repair.
285
+ if (currentVersion && versionsEqual(versionTag, currentVersion)) {
286
+ if (!force) {
287
+ console.log(
288
+ `✅ Already on ${versionTag}. Nothing to do. Pass --force to reinstall.`,
289
+ );
290
+ return;
291
+ }
292
+ console.log(`🔁 Reinstalling ${versionTag} (--force)...\n`);
293
+ }
294
+
295
+ console.log("🔍 Resolving image references...");
296
+ const resolution = await resolveImageRefsDetailed(versionTag);
297
+ if (resolution.status === "version-not-found") {
298
+ const msg = `Version ${versionTag} not found in platform releases.`;
299
+ console.error(msg);
300
+ emitCliError("MISSING_VERSION", msg);
301
+ process.exit(1);
302
+ }
303
+ if (resolution.status === "dockerhub-fallback") {
304
+ console.warn(
305
+ `⚠️ Platform unreachable — falling back to DockerHub tags for ${versionTag}.`,
306
+ );
307
+ }
308
+ const { imageTags } = resolution;
309
+
310
+ console.log(
311
+ `🔄 Upgrading Docker assistant '${instanceName}' to ${versionTag}...\n`,
312
+ );
313
+
314
+ // Capture rollback state from existing containers BEFORE pulling new
315
+ // images or stopping anything. captureImageRefs uses the immutable
316
+ // image digest ({{.Image}}), but capturing first keeps the intent
317
+ // explicit and avoids relying on container-inspect ordering subtleties.
318
+ console.log("📸 Capturing current image references for rollback...");
319
+ const previousImageRefs = await captureImageRefs(res);
320
+ if (previousImageRefs) {
321
+ console.log(
322
+ ` Captured refs for ${Object.keys(previousImageRefs).length} service(s)\n`,
323
+ );
324
+ } else {
325
+ console.log(
326
+ " Could not capture all container refs (fresh install or partial deployment)\n",
327
+ );
328
+ }
329
+
269
330
  // Persist rollback state to lockfile BEFORE any destructive changes.
270
331
  // This enables the `vellum rollback` command to restore the previous version.
271
332
  if (entry.containerInfo) {
@@ -435,6 +496,7 @@ async function upgradeDocker(
435
496
  previousContainerInfo: entry.containerInfo,
436
497
  previousDbMigrationVersion: preMigrationState.dbVersion,
437
498
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
499
+ version: normalizeVersion(versionTag),
438
500
  // Preserve the backup path so `vellum rollback` can restore it later
439
501
  preUpgradeBackupPath: backupPath ?? undefined,
440
502
  };
@@ -669,9 +731,112 @@ interface UpgradeApiResponse {
669
731
  version: string | null;
670
732
  }
671
733
 
734
+ const PLATFORM_POLL_INTERVAL_MS = 3_000;
735
+ const PLATFORM_POLL_HEARTBEAT_MS = 30_000;
736
+ const PLATFORM_HEALTH_CONFIRM_TIMEOUT_MS = 120_000;
737
+ const PLATFORM_HEALTH_CONFIRM_INTERVAL_MS = 5_000;
738
+
739
+ const UPGRADE_IN_PROGRESS_MSG =
740
+ "An upgrade is already in progress for this assistant. Check `vellum ps`.";
741
+
742
+ function platformUpgradeTimeoutMs(): number {
743
+ const override = process.env.VELLUM_PLATFORM_UPGRADE_TIMEOUT_MS;
744
+ if (override) {
745
+ const parsed = parseInt(override, 10);
746
+ if (!isNaN(parsed) && parsed > 0) return parsed;
747
+ }
748
+ return 600_000;
749
+ }
750
+
751
+ function sleep(ms: number): Promise<void> {
752
+ return new Promise((r) => setTimeout(r, ms));
753
+ }
754
+
755
+ /**
756
+ * Poll the platform until the upgrade completes, then confirm health.
757
+ * Mirrors the web UI's Software Updates flow (poll the DB-backed
758
+ * `current_release_version` rather than the gateway, which bounces while
759
+ * the service group restarts).
760
+ */
761
+ async function waitForPlatformUpgrade(
762
+ entry: AssistantEntry,
763
+ token: string,
764
+ pollTarget: string | null,
765
+ initialVersion: string | null,
766
+ ): Promise<void> {
767
+ const timeoutMs = platformUpgradeTimeoutMs();
768
+ const start = Date.now();
769
+ let sawInProgress = false;
770
+ let lastHeartbeat = start;
771
+ let observedVersion: string | null = initialVersion;
772
+
773
+ console.log(
774
+ pollTarget
775
+ ? `⏳ Waiting for the upgrade to ${pollTarget} to complete...`
776
+ : "⏳ Waiting for the upgrade to complete...",
777
+ );
778
+
779
+ while (Date.now() - start < timeoutMs) {
780
+ await sleep(PLATFORM_POLL_INTERVAL_MS);
781
+
782
+ // The in-progress lock is only needed when we don't know the target
783
+ // version (server resolved "latest" without reporting it).
784
+ const [detail, inProgress] = await Promise.all([
785
+ fetchAssistantDetail(token, entry.assistantId, entry.runtimeUrl),
786
+ pollTarget
787
+ ? Promise.resolve(null)
788
+ : fetchUpgradeInProgress(token, entry.assistantId, entry.runtimeUrl),
789
+ ]);
790
+ if (detail) observedVersion = detail.currentReleaseVersion;
791
+ if (inProgress === true) sawInProgress = true;
792
+
793
+ const verdict = evaluateUpgradePoll({
794
+ targetVersion: pollTarget,
795
+ initialVersion,
796
+ observedVersion,
797
+ inProgress,
798
+ sawInProgress,
799
+ });
800
+
801
+ if (verdict === "complete") {
802
+ const finalVersion = observedVersion ?? pollTarget ?? "unknown";
803
+ const healthDeadline = Date.now() + PLATFORM_HEALTH_CONFIRM_TIMEOUT_MS;
804
+ while (Date.now() < healthDeadline) {
805
+ const health = await checkManagedHealth(
806
+ entry.runtimeUrl || getPlatformUrl(),
807
+ entry.assistantId,
808
+ );
809
+ if (health.status === "healthy") {
810
+ console.log(
811
+ `✅ Upgraded to ${finalVersion} — assistant is healthy.`,
812
+ );
813
+ return;
814
+ }
815
+ await sleep(PLATFORM_HEALTH_CONFIRM_INTERVAL_MS);
816
+ }
817
+ console.warn(
818
+ `⚠️ Upgraded to ${finalVersion}, but the health check did not confirm. Check \`vellum ps\`.`,
819
+ );
820
+ return;
821
+ }
822
+
823
+ if (Date.now() - lastHeartbeat >= PLATFORM_POLL_HEARTBEAT_MS) {
824
+ const elapsedSec = Math.round((Date.now() - start) / 1000);
825
+ console.log(` Still upgrading... (${elapsedSec}s elapsed)`);
826
+ lastHeartbeat = Date.now();
827
+ }
828
+ }
829
+
830
+ const timeoutMin = Math.round(timeoutMs / 60_000);
831
+ console.warn(
832
+ `⚠️ Upgrade request was accepted but completion was not confirmed within ${timeoutMin} minutes. The platform may still be working — check \`vellum ps\` or the web settings page.`,
833
+ );
834
+ }
835
+
672
836
  async function upgradePlatform(
673
837
  entry: AssistantEntry,
674
838
  version: string | null,
839
+ wait: boolean,
675
840
  ): Promise<void> {
676
841
  console.log(
677
842
  `🔄 Upgrading platform-hosted assistant '${entry.assistantId}'...\n`,
@@ -686,12 +851,78 @@ async function upgradePlatform(
686
851
  process.exit(1);
687
852
  }
688
853
 
854
+ // Pre-flight (all best-effort except an explicit version that the
855
+ // platform doesn't know about): resolve the target, detect no-ops and
856
+ // downgrades before POSTing, and bail if an upgrade is already running.
857
+ const [assistantDetail, health] = await Promise.all([
858
+ fetchAssistantDetail(token, entry.assistantId, entry.runtimeUrl),
859
+ checkManagedHealth(
860
+ entry.runtimeUrl || getPlatformUrl(),
861
+ entry.assistantId,
862
+ ),
863
+ ]);
864
+ // Health probe first, DB-backed field as the sleeping-assistant fallback.
865
+ const currentVersion =
866
+ health.version ?? assistantDetail?.currentReleaseVersion ?? undefined;
867
+
868
+ const releaseChannel = assistantDetail?.releaseChannel ?? "stable";
869
+ // Target the same platform as the detail/health/POST calls — the entry's
870
+ // platform may differ from the active lockfile default.
871
+ const releases = await fetchReleases({
872
+ channel: releaseChannel,
873
+ platformUrl: entry.runtimeUrl,
874
+ });
875
+
876
+ const resolution = resolveUpgradeTarget({
877
+ explicitVersion: version,
878
+ releases,
879
+ currentVersion,
880
+ });
881
+
882
+ if (resolution.kind === "version-not-found") {
883
+ const msg = `Version ${version} not found in platform releases (channel: ${releaseChannel}).`;
884
+ console.error(msg);
885
+ emitCliError("MISSING_VERSION", msg);
886
+ process.exit(1);
887
+ }
888
+
889
+ if (resolution.isNoOp && resolution.target) {
890
+ console.log(`✅ Already on ${resolution.target}. Nothing to do.`);
891
+ return;
892
+ }
893
+
894
+ if (resolution.isDowngrade && resolution.target && currentVersion) {
895
+ const msg = `Cannot upgrade to an older version (${resolution.target} < ${currentVersion}). Use \`vellum rollback --version ${resolution.target}\` instead.`;
896
+ console.error(msg);
897
+ emitCliError("VERSION_DIRECTION", msg);
898
+ process.exit(1);
899
+ }
900
+
901
+ if (resolution.kind === "no-releases") {
902
+ console.warn(
903
+ "⚠️ Platform releases unavailable — requesting latest from server.",
904
+ );
905
+ }
906
+
907
+ const inProgress = await fetchUpgradeInProgress(
908
+ token,
909
+ entry.assistantId,
910
+ entry.runtimeUrl,
911
+ );
912
+ if (inProgress === true) {
913
+ console.error(UPGRADE_IN_PROGRESS_MSG);
914
+ emitCliError("PLATFORM_API_ERROR", UPGRADE_IN_PROGRESS_MSG);
915
+ process.exit(1);
916
+ }
917
+
918
+ if (currentVersion && resolution.target) {
919
+ console.log(` ${currentVersion} → ${resolution.target}\n`);
920
+ }
921
+
689
922
  const headers = await authHeaders(token, entry.runtimeUrl);
690
923
 
691
- const url = `${entry.runtimeUrl || getPlatformUrl()}/v1/assistants/upgrade/`;
692
- const body: { assistant_id?: string; version?: string } = {
693
- assistant_id: entry.assistantId,
694
- };
924
+ const url = `${entry.runtimeUrl || getPlatformUrl()}/v1/assistants/${encodeURIComponent(entry.assistantId)}/upgrade/`;
925
+ const body: { version?: string } = {};
695
926
  if (version) {
696
927
  body.version = version;
697
928
  }
@@ -720,6 +951,13 @@ async function upgradePlatform(
720
951
  process.exit(1);
721
952
  }
722
953
 
954
+ if (response.status === 409) {
955
+ const text = await response.text();
956
+ console.error(UPGRADE_IN_PROGRESS_MSG);
957
+ emitCliError("PLATFORM_API_ERROR", UPGRADE_IN_PROGRESS_MSG, text);
958
+ process.exit(1);
959
+ }
960
+
723
961
  if (!response.ok) {
724
962
  const text = await response.text();
725
963
  console.error(
@@ -744,6 +982,13 @@ async function upgradePlatform(
744
982
 
745
983
  const result = (await response.json()) as UpgradeApiResponse;
746
984
 
985
+ // The server resolves "latest" itself; a no-op response means nothing was
986
+ // actually kicked off, so don't poll for a completion that will never come.
987
+ if (result.detail?.includes("Already on the latest")) {
988
+ console.warn(`⚠️ ${result.detail}`);
989
+ return;
990
+ }
991
+
747
992
  // NOTE: We intentionally do NOT broadcast a "complete" event here.
748
993
  // The platform API returning 200 only means "upgrade request accepted" —
749
994
  // the service group has not yet restarted with the new version. The
@@ -755,6 +1000,17 @@ async function upgradePlatform(
755
1000
  if (result.version) {
756
1001
  console.log(` Version: ${result.version}`);
757
1002
  }
1003
+
1004
+ if (!wait) {
1005
+ return;
1006
+ }
1007
+
1008
+ await waitForPlatformUpgrade(
1009
+ entry,
1010
+ token,
1011
+ result.version ?? resolution.target,
1012
+ currentVersion ?? null,
1013
+ );
758
1014
  }
759
1015
 
760
1016
  /**
@@ -865,6 +1121,7 @@ async function upgradeFinalize(
865
1121
  */
866
1122
  async function resolveLatestAndMaybeSelfUpdate(
867
1123
  name: string | null,
1124
+ flags: { noWait: boolean; force: boolean },
868
1125
  ): Promise<string> {
869
1126
  console.log("🔍 Fetching latest stable release...");
870
1127
  const latestVersion = await fetchLatestStableVersion();
@@ -893,7 +1150,7 @@ async function resolveLatestAndMaybeSelfUpdate(
893
1150
  console.log(`🔄 Updating CLI to ${latestTag}...`);
894
1151
  const installResult = spawnSync(
895
1152
  "bun",
896
- ["install", "-g", `vellum@${latestVersion}`],
1153
+ ["install", "-g", `vellum@${stripVersionPrefix(latestVersion)}`],
897
1154
  { stdio: "inherit" },
898
1155
  );
899
1156
  if (installResult.error || installResult.status !== 0) {
@@ -907,10 +1164,13 @@ async function resolveLatestAndMaybeSelfUpdate(
907
1164
  console.log(`✅ CLI updated to ${latestTag}\n`);
908
1165
 
909
1166
  // Re-exec with the updated CLI. Pass --version instead of --latest
910
- // to avoid re-fetching and to prevent infinite re-exec loops.
1167
+ // to avoid re-fetching and to prevent infinite re-exec loops; forward
1168
+ // the other flags so the re-exec keeps the requested semantics.
911
1169
  const reexecArgs = ["upgrade"];
912
1170
  if (name) reexecArgs.push(name);
913
1171
  reexecArgs.push("--version", latestTag);
1172
+ if (flags.noWait) reexecArgs.push("--no-wait");
1173
+ if (flags.force) reexecArgs.push("--force");
914
1174
 
915
1175
  console.log(`🚀 Re-running upgrade with updated CLI...\n`);
916
1176
  const reexecResult = spawnSync("vellum", reexecArgs, {
@@ -927,7 +1187,8 @@ async function resolveLatestAndMaybeSelfUpdate(
927
1187
  }
928
1188
 
929
1189
  export async function upgrade(): Promise<void> {
930
- const { name, version, latest, prepare, finalize } = parseArgs();
1190
+ const { name, version, latest, prepare, finalize, noWait, force } =
1191
+ parseArgs();
931
1192
  const entry = resolveTargetAssistant(name);
932
1193
 
933
1194
  if (prepare) {
@@ -945,7 +1206,10 @@ export async function upgrade(): Promise<void> {
945
1206
  // as the explicit target for the rest of the upgrade flow.
946
1207
  let effectiveVersion = version;
947
1208
  if (latest) {
948
- const latestTag = await resolveLatestAndMaybeSelfUpdate(name);
1209
+ const latestTag = await resolveLatestAndMaybeSelfUpdate(name, {
1210
+ noWait,
1211
+ force,
1212
+ });
949
1213
  effectiveVersion = latestTag;
950
1214
  }
951
1215
 
@@ -953,12 +1217,12 @@ export async function upgrade(): Promise<void> {
953
1217
 
954
1218
  try {
955
1219
  if (cloud === "docker") {
956
- await upgradeDocker(entry, effectiveVersion);
1220
+ await upgradeDocker(entry, effectiveVersion, force);
957
1221
  return;
958
1222
  }
959
1223
 
960
1224
  if (cloud === "vellum") {
961
- await upgradePlatform(entry, effectiveVersion);
1225
+ await upgradePlatform(entry, effectiveVersion, !noWait);
962
1226
  return;
963
1227
  }
964
1228
  } catch (err) {
@@ -97,6 +97,8 @@ export interface AssistantEntry {
97
97
  sshUser?: string;
98
98
  zone?: string;
99
99
  hatchedAt?: string;
100
+ /** Installed service-group release version (no `v` prefix), written at hatch/upgrade/rollback. */
101
+ version?: string;
100
102
  /** Per-instance resource config. Present for local entries in multi-instance setups. */
101
103
  resources?: LocalInstanceResources;
102
104
  /** PID of the file watcher process for docker instances hatched with --watch. */
@@ -584,6 +586,11 @@ export function extractHostFromUrl(url: string): string {
584
586
  }
585
587
  }
586
588
 
589
+ /** Strip a leading `v` so stored versions match the healthz `version` format. */
590
+ export function normalizeVersion(version: string): string {
591
+ return version.replace(/^v/, "");
592
+ }
593
+
587
594
  export function saveAssistantEntry(entry: AssistantEntry): void {
588
595
  const entries = readAssistants().filter(
589
596
  (e) => e.assistantId !== entry.assistantId,
package/src/lib/docker.ts CHANGED
@@ -15,6 +15,7 @@ import cliPkg from "../../package.json";
15
15
 
16
16
  import {
17
17
  findAssistantByName,
18
+ normalizeVersion,
18
19
  saveAssistantEntry,
19
20
  setActiveAssistant,
20
21
  } from "./assistant-config";
@@ -1151,6 +1152,8 @@ export async function hatchDocker(
1151
1152
  log("✅ Docker images built");
1152
1153
  }
1153
1154
 
1155
+ let releaseVersion: string | undefined;
1156
+
1154
1157
  if (!mode.build || !repoRoot) {
1155
1158
  emitProgress(2, 6, "Pulling images...");
1156
1159
 
@@ -1187,6 +1190,9 @@ export async function hatchDocker(
1187
1190
  `⚠️ Platform releases unavailable; falling back to CLI version ${versionTag}`,
1188
1191
  );
1189
1192
  }
1193
+ if (versionTag !== "latest") {
1194
+ releaseVersion = normalizeVersion(versionTag);
1195
+ }
1190
1196
  log("🔍 Resolving image references...");
1191
1197
  const resolved = await resolveImageRefs(versionTag, log);
1192
1198
  imageTags.assistant = resolved.imageTags.assistant;
@@ -1364,6 +1370,7 @@ export async function hatchDocker(
1364
1370
  cloud: "docker",
1365
1371
  species,
1366
1372
  hatchedAt: new Date().toISOString(),
1373
+ version: releaseVersion,
1367
1374
  guardianBootstrapSecret: ownSecret,
1368
1375
  containerInfo: {
1369
1376
  assistantImage: imageTags.assistant,
@@ -290,6 +290,7 @@ export async function hatchLocal(
290
290
  cloud: "local",
291
291
  species,
292
292
  hatchedAt: new Date().toISOString(),
293
+ version: cliPkg.version,
293
294
  resources: { ...resources, signingKey },
294
295
  guardianBootstrapSecret: bootstrapSecret,
295
296
  };
@@ -177,6 +177,7 @@ export interface HatchedAssistant {
177
177
  id: string;
178
178
  name: string;
179
179
  status: string;
180
+ current_release_version?: string | null;
180
181
  }
181
182
 
182
183
  export interface HatchAssistantResult {
@@ -601,6 +602,74 @@ export async function fetchPlatformAssistants(
601
602
  }
602
603
  }
603
604
 
605
+ export interface PlatformAssistantDetail {
606
+ currentReleaseVersion: string | null;
607
+ releaseChannel: "stable" | "preview";
608
+ }
609
+
610
+ /**
611
+ * Fetch a single assistant's upgrade-relevant fields from
612
+ * `GET /v1/assistants/{id}/`. Returns null on any failure (best-effort
613
+ * pre-flight / polling helper — never throws).
614
+ */
615
+ export async function fetchAssistantDetail(
616
+ token: string,
617
+ assistantId: string,
618
+ platformUrl?: string,
619
+ ): Promise<PlatformAssistantDetail | null> {
620
+ const resolvedUrl = platformUrl || getPlatformUrl();
621
+ const url = `${resolvedUrl}/v1/assistants/${encodeURIComponent(assistantId)}/`;
622
+
623
+ try {
624
+ const response = await loopbackSafeFetch(url, {
625
+ signal: AbortSignal.timeout(PLATFORM_FETCH_TIMEOUT_MS),
626
+ headers: await authHeaders(token, platformUrl),
627
+ });
628
+
629
+ if (!response.ok) return null;
630
+
631
+ const body = (await response.json()) as {
632
+ current_release_version?: string | null;
633
+ release_channel?: string;
634
+ };
635
+ return {
636
+ currentReleaseVersion: body.current_release_version ?? null,
637
+ releaseChannel: body.release_channel === "preview" ? "preview" : "stable",
638
+ };
639
+ } catch {
640
+ return null;
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Check whether the platform reports an upgrade in progress for the
646
+ * assistant via `GET /v1/assistants/{id}/upgrade-status/`. Returns null
647
+ * when the endpoint is unavailable (404 on older platforms, network
648
+ * error) so callers can skip the check. Never throws.
649
+ */
650
+ export async function fetchUpgradeInProgress(
651
+ token: string,
652
+ assistantId: string,
653
+ platformUrl?: string,
654
+ ): Promise<boolean | null> {
655
+ const resolvedUrl = platformUrl || getPlatformUrl();
656
+ const url = `${resolvedUrl}/v1/assistants/${encodeURIComponent(assistantId)}/upgrade-status/`;
657
+
658
+ try {
659
+ const response = await loopbackSafeFetch(url, {
660
+ signal: AbortSignal.timeout(PLATFORM_FETCH_TIMEOUT_MS),
661
+ headers: await authHeaders(token, platformUrl),
662
+ });
663
+
664
+ if (!response.ok) return null;
665
+
666
+ const body = (await response.json()) as { in_progress?: boolean };
667
+ return typeof body.in_progress === "boolean" ? body.in_progress : null;
668
+ } catch {
669
+ return null;
670
+ }
671
+ }
672
+
604
673
  export interface PlatformUser {
605
674
  id: string;
606
675
  email: string;