@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.
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +15 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +3 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +2 -1
- 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/commands/hatch.ts +4 -0
- package/src/commands/rollback.ts +6 -0
- package/src/commands/upgrade.ts +305 -41
- package/src/lib/assistant-config.ts +7 -0
- package/src/lib/docker.ts +7 -0
- package/src/lib/hatch-local.ts +1 -0
- package/src/lib/platform-client.ts +69 -0
- package/src/lib/platform-releases.ts +125 -103
- package/src/lib/sync-cloud-assistants.ts +17 -4
- package/src/lib/upgrade-lifecycle.ts +14 -22
- package/src/lib/upgrade-preflight.ts +127 -0
- package/src/lib/version-compat.ts +18 -0
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
201
|
-
//
|
|
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: {
|
|
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 } =
|
|
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,
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -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;
|