@vellumai/cli 0.8.11 → 0.8.12-dev.202606122337.5897832

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.
@@ -19,13 +19,21 @@ import {
19
19
  } from "../lib/docker";
20
20
  import {
21
21
  fetchLatestStableVersion,
22
- resolveImageRefs,
22
+ fetchReleases,
23
+ resolveImageRefsDetailed,
23
24
  } from "../lib/platform-releases";
24
25
  import {
25
26
  authHeaders,
27
+ fetchAssistantDetail,
28
+ fetchUpgradeInProgress,
26
29
  getPlatformUrl,
27
30
  readPlatformToken,
28
31
  } from "../lib/platform-client";
32
+ import { checkManagedHealth } from "../lib/health-check.js";
33
+ import {
34
+ evaluateUpgradePoll,
35
+ resolveUpgradeTarget,
36
+ } from "../lib/upgrade-preflight.js";
29
37
  import {
30
38
  createBackup,
31
39
  pruneOldBackups,
@@ -46,7 +54,11 @@ import {
46
54
  UPGRADE_PROGRESS,
47
55
  waitForReady,
48
56
  } from "../lib/upgrade-lifecycle.js";
49
- import { compareVersions } from "../lib/version-compat.js";
57
+ import {
58
+ compareVersions,
59
+ stripVersionPrefix,
60
+ versionsEqual,
61
+ } from "../lib/version-compat.js";
50
62
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
51
63
 
52
64
  interface UpgradeArgs {
@@ -55,6 +67,8 @@ interface UpgradeArgs {
55
67
  latest: boolean;
56
68
  prepare: boolean;
57
69
  finalize: boolean;
70
+ noWait: boolean;
71
+ force: boolean;
58
72
  }
59
73
 
60
74
  function parseArgs(): UpgradeArgs {
@@ -64,6 +78,8 @@ function parseArgs(): UpgradeArgs {
64
78
  let latest = false;
65
79
  let prepare = false;
66
80
  let finalize = false;
81
+ let noWait = false;
82
+ let force = false;
67
83
 
68
84
  for (let i = 0; i < args.length; i++) {
69
85
  const arg = args[i];
@@ -91,6 +107,12 @@ function parseArgs(): UpgradeArgs {
91
107
  console.log(
92
108
  " --finalize Run post-upgrade steps only (broadcast complete, workspace commit)",
93
109
  );
110
+ console.log(
111
+ " --no-wait Platform assistants only: return after the upgrade request is accepted instead of waiting for completion",
112
+ );
113
+ console.log(
114
+ " --force Docker assistants only: reinstall even when already on the target version",
115
+ );
94
116
  console.log("");
95
117
  console.log("Examples:");
96
118
  console.log(
@@ -121,6 +143,10 @@ function parseArgs(): UpgradeArgs {
121
143
  prepare = true;
122
144
  } else if (arg === "--finalize") {
123
145
  finalize = true;
146
+ } else if (arg === "--no-wait") {
147
+ noWait = true;
148
+ } else if (arg === "--force") {
149
+ force = true;
124
150
  } else if (!arg.startsWith("-")) {
125
151
  name = arg;
126
152
  } else {
@@ -142,7 +168,18 @@ function parseArgs(): UpgradeArgs {
142
168
  process.exit(1);
143
169
  }
144
170
 
145
- return { name, version, latest, prepare, finalize };
171
+ if (noWait && (prepare || finalize)) {
172
+ console.error(
173
+ "Error: --no-wait cannot be combined with --prepare or --finalize.",
174
+ );
175
+ emitCliError(
176
+ "UNKNOWN",
177
+ "--no-wait cannot be combined with --prepare or --finalize",
178
+ );
179
+ process.exit(1);
180
+ }
181
+
182
+ return { name, version, latest, prepare, finalize, noWait, force };
146
183
  }
147
184
 
148
185
  /**
@@ -190,6 +227,7 @@ function resolveTargetAssistant(nameArg: string | null): AssistantEntry {
190
227
  async function upgradeDocker(
191
228
  entry: AssistantEntry,
192
229
  version: string | null,
230
+ force: boolean,
193
231
  ): Promise<void> {
194
232
  const instanceName = entry.assistantId;
195
233
  const res = dockerResourceNames(instanceName);
@@ -197,35 +235,10 @@ async function upgradeDocker(
197
235
  const versionTag =
198
236
  version ?? (cliPkg.version ? `v${cliPkg.version}` : "latest");
199
237
 
200
- // Fetch the current running version from the health endpoint.
201
- // This is used for logging, commit messages, and version-direction guards.
238
+ // Capture current migration state and running version for rollback targeting
239
+ // and version guards. Must happen while the daemon is still running (before
240
+ // containers are stopped).
202
241
  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
242
  let preMigrationState: {
230
243
  dbVersion?: number;
231
244
  lastWorkspaceMigrationId?: string;
@@ -266,6 +279,53 @@ async function upgradeDocker(
266
279
  }
267
280
  }
268
281
 
282
+ // No-op guard: skip the full stop/start cycle (real downtime) when already
283
+ // on the target version. --force allows an intentional reinstall/repair.
284
+ if (currentVersion && versionsEqual(versionTag, currentVersion)) {
285
+ if (!force) {
286
+ console.log(
287
+ `✅ Already on ${versionTag}. Nothing to do. Pass --force to reinstall.`,
288
+ );
289
+ return;
290
+ }
291
+ console.log(`🔁 Reinstalling ${versionTag} (--force)...\n`);
292
+ }
293
+
294
+ console.log("🔍 Resolving image references...");
295
+ const resolution = await resolveImageRefsDetailed(versionTag);
296
+ if (resolution.status === "version-not-found") {
297
+ const msg = `Version ${versionTag} not found in platform releases.`;
298
+ console.error(msg);
299
+ emitCliError("MISSING_VERSION", msg);
300
+ process.exit(1);
301
+ }
302
+ if (resolution.status === "dockerhub-fallback") {
303
+ console.warn(
304
+ `⚠️ Platform unreachable — falling back to DockerHub tags for ${versionTag}.`,
305
+ );
306
+ }
307
+ const { imageTags } = resolution;
308
+
309
+ console.log(
310
+ `🔄 Upgrading Docker assistant '${instanceName}' to ${versionTag}...\n`,
311
+ );
312
+
313
+ // Capture rollback state from existing containers BEFORE pulling new
314
+ // images or stopping anything. captureImageRefs uses the immutable
315
+ // image digest ({{.Image}}), but capturing first keeps the intent
316
+ // explicit and avoids relying on container-inspect ordering subtleties.
317
+ console.log("📸 Capturing current image references for rollback...");
318
+ const previousImageRefs = await captureImageRefs(res);
319
+ if (previousImageRefs) {
320
+ console.log(
321
+ ` Captured refs for ${Object.keys(previousImageRefs).length} service(s)\n`,
322
+ );
323
+ } else {
324
+ console.log(
325
+ " Could not capture all container refs (fresh install or partial deployment)\n",
326
+ );
327
+ }
328
+
269
329
  // Persist rollback state to lockfile BEFORE any destructive changes.
270
330
  // This enables the `vellum rollback` command to restore the previous version.
271
331
  if (entry.containerInfo) {
@@ -669,9 +729,112 @@ interface UpgradeApiResponse {
669
729
  version: string | null;
670
730
  }
671
731
 
732
+ const PLATFORM_POLL_INTERVAL_MS = 3_000;
733
+ const PLATFORM_POLL_HEARTBEAT_MS = 30_000;
734
+ const PLATFORM_HEALTH_CONFIRM_TIMEOUT_MS = 120_000;
735
+ const PLATFORM_HEALTH_CONFIRM_INTERVAL_MS = 5_000;
736
+
737
+ const UPGRADE_IN_PROGRESS_MSG =
738
+ "An upgrade is already in progress for this assistant. Check `vellum ps`.";
739
+
740
+ function platformUpgradeTimeoutMs(): number {
741
+ const override = process.env.VELLUM_PLATFORM_UPGRADE_TIMEOUT_MS;
742
+ if (override) {
743
+ const parsed = parseInt(override, 10);
744
+ if (!isNaN(parsed) && parsed > 0) return parsed;
745
+ }
746
+ return 600_000;
747
+ }
748
+
749
+ function sleep(ms: number): Promise<void> {
750
+ return new Promise((r) => setTimeout(r, ms));
751
+ }
752
+
753
+ /**
754
+ * Poll the platform until the upgrade completes, then confirm health.
755
+ * Mirrors the web UI's Software Updates flow (poll the DB-backed
756
+ * `current_release_version` rather than the gateway, which bounces while
757
+ * the service group restarts).
758
+ */
759
+ async function waitForPlatformUpgrade(
760
+ entry: AssistantEntry,
761
+ token: string,
762
+ pollTarget: string | null,
763
+ initialVersion: string | null,
764
+ ): Promise<void> {
765
+ const timeoutMs = platformUpgradeTimeoutMs();
766
+ const start = Date.now();
767
+ let sawInProgress = false;
768
+ let lastHeartbeat = start;
769
+ let observedVersion: string | null = initialVersion;
770
+
771
+ console.log(
772
+ pollTarget
773
+ ? `⏳ Waiting for the upgrade to ${pollTarget} to complete...`
774
+ : "⏳ Waiting for the upgrade to complete...",
775
+ );
776
+
777
+ while (Date.now() - start < timeoutMs) {
778
+ await sleep(PLATFORM_POLL_INTERVAL_MS);
779
+
780
+ // The in-progress lock is only needed when we don't know the target
781
+ // version (server resolved "latest" without reporting it).
782
+ const [detail, inProgress] = await Promise.all([
783
+ fetchAssistantDetail(token, entry.assistantId, entry.runtimeUrl),
784
+ pollTarget
785
+ ? Promise.resolve(null)
786
+ : fetchUpgradeInProgress(token, entry.assistantId, entry.runtimeUrl),
787
+ ]);
788
+ if (detail) observedVersion = detail.currentReleaseVersion;
789
+ if (inProgress === true) sawInProgress = true;
790
+
791
+ const verdict = evaluateUpgradePoll({
792
+ targetVersion: pollTarget,
793
+ initialVersion,
794
+ observedVersion,
795
+ inProgress,
796
+ sawInProgress,
797
+ });
798
+
799
+ if (verdict === "complete") {
800
+ const finalVersion = observedVersion ?? pollTarget ?? "unknown";
801
+ const healthDeadline = Date.now() + PLATFORM_HEALTH_CONFIRM_TIMEOUT_MS;
802
+ while (Date.now() < healthDeadline) {
803
+ const health = await checkManagedHealth(
804
+ entry.runtimeUrl || getPlatformUrl(),
805
+ entry.assistantId,
806
+ );
807
+ if (health.status === "healthy") {
808
+ console.log(
809
+ `✅ Upgraded to ${finalVersion} — assistant is healthy.`,
810
+ );
811
+ return;
812
+ }
813
+ await sleep(PLATFORM_HEALTH_CONFIRM_INTERVAL_MS);
814
+ }
815
+ console.warn(
816
+ `⚠️ Upgraded to ${finalVersion}, but the health check did not confirm. Check \`vellum ps\`.`,
817
+ );
818
+ return;
819
+ }
820
+
821
+ if (Date.now() - lastHeartbeat >= PLATFORM_POLL_HEARTBEAT_MS) {
822
+ const elapsedSec = Math.round((Date.now() - start) / 1000);
823
+ console.log(` Still upgrading... (${elapsedSec}s elapsed)`);
824
+ lastHeartbeat = Date.now();
825
+ }
826
+ }
827
+
828
+ const timeoutMin = Math.round(timeoutMs / 60_000);
829
+ console.warn(
830
+ `⚠️ 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.`,
831
+ );
832
+ }
833
+
672
834
  async function upgradePlatform(
673
835
  entry: AssistantEntry,
674
836
  version: string | null,
837
+ wait: boolean,
675
838
  ): Promise<void> {
676
839
  console.log(
677
840
  `🔄 Upgrading platform-hosted assistant '${entry.assistantId}'...\n`,
@@ -686,12 +849,78 @@ async function upgradePlatform(
686
849
  process.exit(1);
687
850
  }
688
851
 
852
+ // Pre-flight (all best-effort except an explicit version that the
853
+ // platform doesn't know about): resolve the target, detect no-ops and
854
+ // downgrades before POSTing, and bail if an upgrade is already running.
855
+ const [assistantDetail, health] = await Promise.all([
856
+ fetchAssistantDetail(token, entry.assistantId, entry.runtimeUrl),
857
+ checkManagedHealth(
858
+ entry.runtimeUrl || getPlatformUrl(),
859
+ entry.assistantId,
860
+ ),
861
+ ]);
862
+ // Health probe first, DB-backed field as the sleeping-assistant fallback.
863
+ const currentVersion =
864
+ health.version ?? assistantDetail?.currentReleaseVersion ?? undefined;
865
+
866
+ const releaseChannel = assistantDetail?.releaseChannel ?? "stable";
867
+ // Target the same platform as the detail/health/POST calls — the entry's
868
+ // platform may differ from the active lockfile default.
869
+ const releases = await fetchReleases({
870
+ channel: releaseChannel,
871
+ platformUrl: entry.runtimeUrl,
872
+ });
873
+
874
+ const resolution = resolveUpgradeTarget({
875
+ explicitVersion: version,
876
+ releases,
877
+ currentVersion,
878
+ });
879
+
880
+ if (resolution.kind === "version-not-found") {
881
+ const msg = `Version ${version} not found in platform releases (channel: ${releaseChannel}).`;
882
+ console.error(msg);
883
+ emitCliError("MISSING_VERSION", msg);
884
+ process.exit(1);
885
+ }
886
+
887
+ if (resolution.isNoOp && resolution.target) {
888
+ console.log(`✅ Already on ${resolution.target}. Nothing to do.`);
889
+ return;
890
+ }
891
+
892
+ if (resolution.isDowngrade && resolution.target && currentVersion) {
893
+ const msg = `Cannot upgrade to an older version (${resolution.target} < ${currentVersion}). Use \`vellum rollback --version ${resolution.target}\` instead.`;
894
+ console.error(msg);
895
+ emitCliError("VERSION_DIRECTION", msg);
896
+ process.exit(1);
897
+ }
898
+
899
+ if (resolution.kind === "no-releases") {
900
+ console.warn(
901
+ "⚠️ Platform releases unavailable — requesting latest from server.",
902
+ );
903
+ }
904
+
905
+ const inProgress = await fetchUpgradeInProgress(
906
+ token,
907
+ entry.assistantId,
908
+ entry.runtimeUrl,
909
+ );
910
+ if (inProgress === true) {
911
+ console.error(UPGRADE_IN_PROGRESS_MSG);
912
+ emitCliError("PLATFORM_API_ERROR", UPGRADE_IN_PROGRESS_MSG);
913
+ process.exit(1);
914
+ }
915
+
916
+ if (currentVersion && resolution.target) {
917
+ console.log(` ${currentVersion} → ${resolution.target}\n`);
918
+ }
919
+
689
920
  const headers = await authHeaders(token, entry.runtimeUrl);
690
921
 
691
- const url = `${entry.runtimeUrl || getPlatformUrl()}/v1/assistants/upgrade/`;
692
- const body: { assistant_id?: string; version?: string } = {
693
- assistant_id: entry.assistantId,
694
- };
922
+ const url = `${entry.runtimeUrl || getPlatformUrl()}/v1/assistants/${encodeURIComponent(entry.assistantId)}/upgrade/`;
923
+ const body: { version?: string } = {};
695
924
  if (version) {
696
925
  body.version = version;
697
926
  }
@@ -720,6 +949,13 @@ async function upgradePlatform(
720
949
  process.exit(1);
721
950
  }
722
951
 
952
+ if (response.status === 409) {
953
+ const text = await response.text();
954
+ console.error(UPGRADE_IN_PROGRESS_MSG);
955
+ emitCliError("PLATFORM_API_ERROR", UPGRADE_IN_PROGRESS_MSG, text);
956
+ process.exit(1);
957
+ }
958
+
723
959
  if (!response.ok) {
724
960
  const text = await response.text();
725
961
  console.error(
@@ -744,6 +980,13 @@ async function upgradePlatform(
744
980
 
745
981
  const result = (await response.json()) as UpgradeApiResponse;
746
982
 
983
+ // The server resolves "latest" itself; a no-op response means nothing was
984
+ // actually kicked off, so don't poll for a completion that will never come.
985
+ if (result.detail?.includes("Already on the latest")) {
986
+ console.warn(`⚠️ ${result.detail}`);
987
+ return;
988
+ }
989
+
747
990
  // NOTE: We intentionally do NOT broadcast a "complete" event here.
748
991
  // The platform API returning 200 only means "upgrade request accepted" —
749
992
  // the service group has not yet restarted with the new version. The
@@ -755,6 +998,17 @@ async function upgradePlatform(
755
998
  if (result.version) {
756
999
  console.log(` Version: ${result.version}`);
757
1000
  }
1001
+
1002
+ if (!wait) {
1003
+ return;
1004
+ }
1005
+
1006
+ await waitForPlatformUpgrade(
1007
+ entry,
1008
+ token,
1009
+ result.version ?? resolution.target,
1010
+ currentVersion ?? null,
1011
+ );
758
1012
  }
759
1013
 
760
1014
  /**
@@ -865,6 +1119,7 @@ async function upgradeFinalize(
865
1119
  */
866
1120
  async function resolveLatestAndMaybeSelfUpdate(
867
1121
  name: string | null,
1122
+ flags: { noWait: boolean; force: boolean },
868
1123
  ): Promise<string> {
869
1124
  console.log("🔍 Fetching latest stable release...");
870
1125
  const latestVersion = await fetchLatestStableVersion();
@@ -893,7 +1148,7 @@ async function resolveLatestAndMaybeSelfUpdate(
893
1148
  console.log(`🔄 Updating CLI to ${latestTag}...`);
894
1149
  const installResult = spawnSync(
895
1150
  "bun",
896
- ["install", "-g", `vellum@${latestVersion}`],
1151
+ ["install", "-g", `vellum@${stripVersionPrefix(latestVersion)}`],
897
1152
  { stdio: "inherit" },
898
1153
  );
899
1154
  if (installResult.error || installResult.status !== 0) {
@@ -907,10 +1162,13 @@ async function resolveLatestAndMaybeSelfUpdate(
907
1162
  console.log(`✅ CLI updated to ${latestTag}\n`);
908
1163
 
909
1164
  // Re-exec with the updated CLI. Pass --version instead of --latest
910
- // to avoid re-fetching and to prevent infinite re-exec loops.
1165
+ // to avoid re-fetching and to prevent infinite re-exec loops; forward
1166
+ // the other flags so the re-exec keeps the requested semantics.
911
1167
  const reexecArgs = ["upgrade"];
912
1168
  if (name) reexecArgs.push(name);
913
1169
  reexecArgs.push("--version", latestTag);
1170
+ if (flags.noWait) reexecArgs.push("--no-wait");
1171
+ if (flags.force) reexecArgs.push("--force");
914
1172
 
915
1173
  console.log(`🚀 Re-running upgrade with updated CLI...\n`);
916
1174
  const reexecResult = spawnSync("vellum", reexecArgs, {
@@ -927,7 +1185,8 @@ async function resolveLatestAndMaybeSelfUpdate(
927
1185
  }
928
1186
 
929
1187
  export async function upgrade(): Promise<void> {
930
- const { name, version, latest, prepare, finalize } = parseArgs();
1188
+ const { name, version, latest, prepare, finalize, noWait, force } =
1189
+ parseArgs();
931
1190
  const entry = resolveTargetAssistant(name);
932
1191
 
933
1192
  if (prepare) {
@@ -945,7 +1204,10 @@ export async function upgrade(): Promise<void> {
945
1204
  // as the explicit target for the rest of the upgrade flow.
946
1205
  let effectiveVersion = version;
947
1206
  if (latest) {
948
- const latestTag = await resolveLatestAndMaybeSelfUpdate(name);
1207
+ const latestTag = await resolveLatestAndMaybeSelfUpdate(name, {
1208
+ noWait,
1209
+ force,
1210
+ });
949
1211
  effectiveVersion = latestTag;
950
1212
  }
951
1213
 
@@ -953,12 +1215,12 @@ export async function upgrade(): Promise<void> {
953
1215
 
954
1216
  try {
955
1217
  if (cloud === "docker") {
956
- await upgradeDocker(entry, effectiveVersion);
1218
+ await upgradeDocker(entry, effectiveVersion, force);
957
1219
  return;
958
1220
  }
959
1221
 
960
1222
  if (cloud === "vellum") {
961
- await upgradePlatform(entry, effectiveVersion);
1223
+ await upgradePlatform(entry, effectiveVersion, !noWait);
962
1224
  return;
963
1225
  }
964
1226
  } catch (err) {
@@ -9,7 +9,6 @@ import {
9
9
  import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
10
10
  import {
11
11
  leaseGuardianToken,
12
- loadGuardianToken,
13
12
  resetGuardianBootstrap,
14
13
  seedGuardianTokenFromSiblingEnv,
15
14
  } from "../lib/guardian-token.js";
@@ -43,7 +42,7 @@ export async function wake(): Promise<void> {
43
42
  " --foreground Run assistant in foreground with logs printed to terminal",
44
43
  );
45
44
  console.log(
46
- " --repair-guardian Re-provision the guardian token if missing (resets the\n" +
45
+ " --repair-guardian Force-re-provision the guardian token (resets the\n" +
47
46
  " gateway bootstrap and re-leases — REVOKES other device-bound\n" +
48
47
  " tokens, so only use deliberately, never from auto-repair)",
49
48
  );
@@ -238,8 +237,11 @@ export async function wake(): Promise<void> {
238
237
  console.log(" Seeded guardian token from sibling environment.");
239
238
  }
240
239
 
241
- // Last-resort recovery (explicit `--repair-guardian` only): if no guardian
242
- // token exists for this env even after sibling seeding, re-provision one. The
240
+ // Last-resort recovery (explicit `--repair-guardian` only): force a
241
+ // re-provision. Token health can't be judged locally a connect can 401
242
+ // off a token whose local expiry looks fine (revoked, mis-seeded, wrong
243
+ // principal) — and the user explicitly confirmed the destructive repair,
244
+ // so guessing "looks healthy, skip" just recreates the no-op loop. The
243
245
  // single-use bootstrap secret may already be spent — a prior connect can
244
246
  // lease a token that's then lost, or the gateway marks the secret consumed
245
247
  // before the client persists it — which otherwise bricks connect into a
@@ -248,7 +250,7 @@ export async function wake(): Promise<void> {
248
250
  // by the lockfile secret — mirrors the macOS client's forceReBootstrap), then
249
251
  // re-lease. Gated behind the flag because the re-lease revokes other
250
252
  // device-bound tokens; it must never run from the automatic repair path.
251
- if (repairGuardian && !loadGuardianToken(entry.assistantId)) {
253
+ if (repairGuardian) {
252
254
  const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
253
255
  const maxAttempts = 3;
254
256
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -601,6 +601,74 @@ export async function fetchPlatformAssistants(
601
601
  }
602
602
  }
603
603
 
604
+ export interface PlatformAssistantDetail {
605
+ currentReleaseVersion: string | null;
606
+ releaseChannel: "stable" | "preview";
607
+ }
608
+
609
+ /**
610
+ * Fetch a single assistant's upgrade-relevant fields from
611
+ * `GET /v1/assistants/{id}/`. Returns null on any failure (best-effort
612
+ * pre-flight / polling helper — never throws).
613
+ */
614
+ export async function fetchAssistantDetail(
615
+ token: string,
616
+ assistantId: string,
617
+ platformUrl?: string,
618
+ ): Promise<PlatformAssistantDetail | null> {
619
+ const resolvedUrl = platformUrl || getPlatformUrl();
620
+ const url = `${resolvedUrl}/v1/assistants/${encodeURIComponent(assistantId)}/`;
621
+
622
+ try {
623
+ const response = await loopbackSafeFetch(url, {
624
+ signal: AbortSignal.timeout(PLATFORM_FETCH_TIMEOUT_MS),
625
+ headers: await authHeaders(token, platformUrl),
626
+ });
627
+
628
+ if (!response.ok) return null;
629
+
630
+ const body = (await response.json()) as {
631
+ current_release_version?: string | null;
632
+ release_channel?: string;
633
+ };
634
+ return {
635
+ currentReleaseVersion: body.current_release_version ?? null,
636
+ releaseChannel: body.release_channel === "preview" ? "preview" : "stable",
637
+ };
638
+ } catch {
639
+ return null;
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Check whether the platform reports an upgrade in progress for the
645
+ * assistant via `GET /v1/assistants/{id}/upgrade-status/`. Returns null
646
+ * when the endpoint is unavailable (404 on older platforms, network
647
+ * error) so callers can skip the check. Never throws.
648
+ */
649
+ export async function fetchUpgradeInProgress(
650
+ token: string,
651
+ assistantId: string,
652
+ platformUrl?: string,
653
+ ): Promise<boolean | null> {
654
+ const resolvedUrl = platformUrl || getPlatformUrl();
655
+ const url = `${resolvedUrl}/v1/assistants/${encodeURIComponent(assistantId)}/upgrade-status/`;
656
+
657
+ try {
658
+ const response = await loopbackSafeFetch(url, {
659
+ signal: AbortSignal.timeout(PLATFORM_FETCH_TIMEOUT_MS),
660
+ headers: await authHeaders(token, platformUrl),
661
+ });
662
+
663
+ if (!response.ok) return null;
664
+
665
+ const body = (await response.json()) as { in_progress?: boolean };
666
+ return typeof body.in_progress === "boolean" ? body.in_progress : null;
667
+ } catch {
668
+ return null;
669
+ }
670
+ }
671
+
604
672
  export interface PlatformUser {
605
673
  id: string;
606
674
  email: string;