@vellumai/cli 0.8.11 → 0.8.12-dev.202606122239.169d5e4
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/bun.lock +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/platform-client.test.ts +107 -0
- package/src/__tests__/platform-releases.test.ts +117 -0
- package/src/__tests__/upgrade-preflight.test.ts +203 -0
- package/src/__tests__/version-compat.test.ts +31 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/login.ts +123 -59
- package/src/commands/upgrade.ts +303 -41
- package/src/commands/wake.ts +7 -5
- package/src/lib/platform-client.ts +68 -0
- package/src/lib/platform-releases.ts +125 -103
- package/src/lib/upgrade-lifecycle.ts +12 -21
- package/src/lib/upgrade-preflight.ts +127 -0
- package/src/lib/version-compat.ts +18 -0
- package/src/lib/workos-pkce.ts +160 -0
package/src/commands/upgrade.ts
CHANGED
|
@@ -19,13 +19,21 @@ import {
|
|
|
19
19
|
} from "../lib/docker";
|
|
20
20
|
import {
|
|
21
21
|
fetchLatestStableVersion,
|
|
22
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
201
|
-
//
|
|
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: {
|
|
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 } =
|
|
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) {
|
package/src/commands/wake.ts
CHANGED
|
@@ -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
|
|
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):
|
|
242
|
-
//
|
|
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
|
|
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;
|