@vellumai/cli 0.8.11 → 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
|
@@ -14,6 +14,7 @@ describe("parseLockfile", () => {
|
|
|
14
14
|
runtimeUrl: "http://127.0.0.1:7777",
|
|
15
15
|
species: "vellum",
|
|
16
16
|
hatchedAt: "2026-01-01T00:00:00.000Z",
|
|
17
|
+
version: "0.7.0",
|
|
17
18
|
organizationId: "org_1",
|
|
18
19
|
resources: { gatewayPort: 7777, daemonPort: 7778 },
|
|
19
20
|
},
|
|
@@ -169,6 +170,20 @@ describe("parseLockfile", () => {
|
|
|
169
170
|
]);
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
test("keeps version when a string and drops it when mistyped", () => {
|
|
174
|
+
const raw = {
|
|
175
|
+
assistants: [
|
|
176
|
+
{ assistantId: "asst_1", cloud: "docker", version: "0.7.0" },
|
|
177
|
+
{ assistantId: "asst_2", cloud: "docker", version: 7 }, // mistyped
|
|
178
|
+
],
|
|
179
|
+
activeAssistant: null,
|
|
180
|
+
};
|
|
181
|
+
expect(parseLockfile(raw).assistants).toEqual([
|
|
182
|
+
{ assistantId: "asst_1", cloud: "docker", version: "0.7.0" },
|
|
183
|
+
{ assistantId: "asst_2", cloud: "docker" },
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
172
187
|
test("drops a resources object missing its numeric ports", () => {
|
|
173
188
|
const raw = {
|
|
174
189
|
assistants: [
|
|
@@ -42,6 +42,8 @@ export interface LockfileAssistant {
|
|
|
42
42
|
runtimeUrl?: string;
|
|
43
43
|
species?: string;
|
|
44
44
|
hatchedAt?: string;
|
|
45
|
+
/** Installed release version (no `v` prefix), written by the CLI at hatch/upgrade. */
|
|
46
|
+
version?: string;
|
|
45
47
|
/** Owning org for platform assistants; absent for local ones. */
|
|
46
48
|
organizationId?: string;
|
|
47
49
|
resources?: LocalAssistantResources;
|
|
@@ -89,6 +91,7 @@ function parseAssistant(value: unknown): LockfileAssistant | null {
|
|
|
89
91
|
if (typeof value.runtimeUrl === "string") assistant.runtimeUrl = value.runtimeUrl;
|
|
90
92
|
if (typeof value.species === "string") assistant.species = value.species;
|
|
91
93
|
if (typeof value.hatchedAt === "string") assistant.hatchedAt = value.hatchedAt;
|
|
94
|
+
if (typeof value.version === "string") assistant.version = value.version;
|
|
92
95
|
if (typeof value.organizationId === "string") assistant.organizationId = value.organizationId;
|
|
93
96
|
const resources = parseResources(value.resources);
|
|
94
97
|
if (resources) assistant.resources = resources;
|
package/package.json
CHANGED
|
@@ -75,11 +75,12 @@ describe("assistant-config", () => {
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
test("saveAssistantEntry and loadAllAssistants round-trip", () => {
|
|
78
|
-
const entry = makeEntry("test-1");
|
|
78
|
+
const entry = makeEntry("test-1", undefined, { version: "0.7.0" });
|
|
79
79
|
saveAssistantEntry(entry);
|
|
80
80
|
const all = loadAllAssistants();
|
|
81
81
|
expect(all).toHaveLength(1);
|
|
82
82
|
expect(all[0].assistantId).toBe("test-1");
|
|
83
|
+
expect(all[0].version).toBe("0.7.0");
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
test("findAssistantByName returns matching entry", () => {
|
|
@@ -9,8 +9,12 @@ import {
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
+
import { mock } from "bun:test";
|
|
13
|
+
|
|
12
14
|
import {
|
|
13
15
|
clearPlatformToken,
|
|
16
|
+
fetchAssistantDetail,
|
|
17
|
+
fetchUpgradeInProgress,
|
|
14
18
|
getPlatformUrl,
|
|
15
19
|
readPlatformToken,
|
|
16
20
|
savePlatformToken,
|
|
@@ -202,3 +206,106 @@ describe("getPlatformUrl resolution order", () => {
|
|
|
202
206
|
expect(getPlatformUrl()).toBe("https://trimmed.vellum.ai");
|
|
203
207
|
});
|
|
204
208
|
});
|
|
209
|
+
|
|
210
|
+
describe("fetchAssistantDetail / fetchUpgradeInProgress", () => {
|
|
211
|
+
// vak_ token → authHeaders skips the org-ID fetch, so the single mocked
|
|
212
|
+
// fetch call is the endpoint under test.
|
|
213
|
+
const TOKEN = "vak_test_token";
|
|
214
|
+
const ASSISTANT_ID = "11111111-2222-3333-4444-555555555555";
|
|
215
|
+
const PLATFORM_URL = "https://platform.test";
|
|
216
|
+
|
|
217
|
+
const originalFetch = globalThis.fetch;
|
|
218
|
+
|
|
219
|
+
afterEach(() => {
|
|
220
|
+
globalThis.fetch = originalFetch;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
function mockFetchJson(body: unknown, status = 200) {
|
|
224
|
+
const fetchMock = mock(
|
|
225
|
+
async (_url: RequestInfo | URL, _init?: RequestInit) =>
|
|
226
|
+
new Response(JSON.stringify(body), { status }),
|
|
227
|
+
);
|
|
228
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
229
|
+
return fetchMock;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mockFetchNetworkError() {
|
|
233
|
+
globalThis.fetch = mock(async () => {
|
|
234
|
+
throw new TypeError("fetch failed");
|
|
235
|
+
}) as unknown as typeof globalThis.fetch;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
test("fetchAssistantDetail maps fields", async () => {
|
|
239
|
+
const fetchMock = mockFetchJson({
|
|
240
|
+
current_release_version: "0.7.0",
|
|
241
|
+
release_channel: "preview",
|
|
242
|
+
});
|
|
243
|
+
const detail = await fetchAssistantDetail(TOKEN, ASSISTANT_ID, PLATFORM_URL);
|
|
244
|
+
expect(detail).toEqual({
|
|
245
|
+
currentReleaseVersion: "0.7.0",
|
|
246
|
+
releaseChannel: "preview",
|
|
247
|
+
});
|
|
248
|
+
const url = String(fetchMock.mock.calls[0][0]);
|
|
249
|
+
expect(url).toBe(`${PLATFORM_URL}/v1/assistants/${ASSISTANT_ID}/`);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("fetchAssistantDetail defaults missing fields", async () => {
|
|
253
|
+
mockFetchJson({});
|
|
254
|
+
const detail = await fetchAssistantDetail(TOKEN, ASSISTANT_ID, PLATFORM_URL);
|
|
255
|
+
expect(detail).toEqual({
|
|
256
|
+
currentReleaseVersion: null,
|
|
257
|
+
releaseChannel: "stable",
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("fetchAssistantDetail returns null on non-OK", async () => {
|
|
262
|
+
mockFetchJson({ detail: "not found" }, 404);
|
|
263
|
+
expect(
|
|
264
|
+
await fetchAssistantDetail(TOKEN, ASSISTANT_ID, PLATFORM_URL),
|
|
265
|
+
).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("fetchAssistantDetail returns null on network error", async () => {
|
|
269
|
+
mockFetchNetworkError();
|
|
270
|
+
expect(
|
|
271
|
+
await fetchAssistantDetail(TOKEN, ASSISTANT_ID, PLATFORM_URL),
|
|
272
|
+
).toBeNull();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("fetchUpgradeInProgress returns the boolean", async () => {
|
|
276
|
+
const fetchMock = mockFetchJson({ in_progress: true });
|
|
277
|
+
expect(
|
|
278
|
+
await fetchUpgradeInProgress(TOKEN, ASSISTANT_ID, PLATFORM_URL),
|
|
279
|
+
).toBe(true);
|
|
280
|
+
const url = String(fetchMock.mock.calls[0][0]);
|
|
281
|
+
expect(url).toBe(
|
|
282
|
+
`${PLATFORM_URL}/v1/assistants/${ASSISTANT_ID}/upgrade-status/`,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
mockFetchJson({ in_progress: false });
|
|
286
|
+
expect(
|
|
287
|
+
await fetchUpgradeInProgress(TOKEN, ASSISTANT_ID, PLATFORM_URL),
|
|
288
|
+
).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("fetchUpgradeInProgress returns null on 404 (older platform)", async () => {
|
|
292
|
+
mockFetchJson({ detail: "not found" }, 404);
|
|
293
|
+
expect(
|
|
294
|
+
await fetchUpgradeInProgress(TOKEN, ASSISTANT_ID, PLATFORM_URL),
|
|
295
|
+
).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("fetchUpgradeInProgress returns null on network error", async () => {
|
|
299
|
+
mockFetchNetworkError();
|
|
300
|
+
expect(
|
|
301
|
+
await fetchUpgradeInProgress(TOKEN, ASSISTANT_ID, PLATFORM_URL),
|
|
302
|
+
).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("fetchUpgradeInProgress returns null on a malformed body", async () => {
|
|
306
|
+
mockFetchJson({ something_else: 1 });
|
|
307
|
+
expect(
|
|
308
|
+
await fetchUpgradeInProgress(TOKEN, ASSISTANT_ID, PLATFORM_URL),
|
|
309
|
+
).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
fetchReleases,
|
|
5
|
+
resolveImageRefsDetailed,
|
|
6
|
+
} from "../lib/platform-releases.js";
|
|
7
|
+
|
|
8
|
+
const originalFetch = globalThis.fetch;
|
|
9
|
+
|
|
10
|
+
function mockFetchJson(body: unknown, status = 200) {
|
|
11
|
+
const fetchMock = mock(
|
|
12
|
+
async (_url: RequestInfo | URL, _init?: RequestInit) =>
|
|
13
|
+
new Response(JSON.stringify(body), { status }),
|
|
14
|
+
);
|
|
15
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
16
|
+
return fetchMock;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mockFetchError() {
|
|
20
|
+
globalThis.fetch = mock(async () => {
|
|
21
|
+
throw new TypeError("fetch failed");
|
|
22
|
+
}) as unknown as typeof globalThis.fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
process.env.VELLUM_PLATFORM_URL = "https://platform.test";
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
globalThis.fetch = originalFetch;
|
|
31
|
+
delete process.env.VELLUM_PLATFORM_URL;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const RELEASE = {
|
|
35
|
+
version: "0.7.0",
|
|
36
|
+
assistant_image_ref: "gcr.io/vellum/assistant@sha256:aaa",
|
|
37
|
+
gateway_image_ref: "gcr.io/vellum/gateway@sha256:bbb",
|
|
38
|
+
credential_executor_image_ref: "gcr.io/vellum/ces@sha256:ccc",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("fetchReleases", () => {
|
|
42
|
+
test("defaults to stable=true and returns the list", async () => {
|
|
43
|
+
const fetchMock = mockFetchJson([RELEASE]);
|
|
44
|
+
const releases = await fetchReleases();
|
|
45
|
+
expect(releases).toEqual([RELEASE]);
|
|
46
|
+
const url = String(fetchMock.mock.calls[0][0]);
|
|
47
|
+
expect(url).toContain("/v1/releases/?stable=true");
|
|
48
|
+
expect(url).toContain("limit=100");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("passes the channel param when given", async () => {
|
|
52
|
+
const fetchMock = mockFetchJson([RELEASE]);
|
|
53
|
+
await fetchReleases({ channel: "preview" });
|
|
54
|
+
const url = String(fetchMock.mock.calls[0][0]);
|
|
55
|
+
expect(url).toContain("/v1/releases/?channel=preview");
|
|
56
|
+
expect(url).toContain("limit=100");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("uses the platformUrl override over the resolved default", async () => {
|
|
60
|
+
const fetchMock = mockFetchJson([RELEASE]);
|
|
61
|
+
await fetchReleases({ platformUrl: "https://other-platform.test" });
|
|
62
|
+
const url = String(fetchMock.mock.calls[0][0]);
|
|
63
|
+
expect(url).toContain("https://other-platform.test/v1/releases/");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("returns null on non-OK response", async () => {
|
|
67
|
+
mockFetchJson({ detail: "nope" }, 500);
|
|
68
|
+
expect(await fetchReleases()).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns null on network error", async () => {
|
|
72
|
+
mockFetchError();
|
|
73
|
+
expect(await fetchReleases()).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("resolveImageRefsDetailed", () => {
|
|
78
|
+
test("returns platform refs when the version is found", async () => {
|
|
79
|
+
mockFetchJson([RELEASE]);
|
|
80
|
+
const result = await resolveImageRefsDetailed("v0.7.0");
|
|
81
|
+
expect(result.status).toBe("platform");
|
|
82
|
+
if (result.status === "platform") {
|
|
83
|
+
expect(result.imageTags.assistant).toBe(RELEASE.assistant_image_ref);
|
|
84
|
+
expect(result.imageTags.gateway).toBe(RELEASE.gateway_image_ref);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("falls back to DockerHub for a null credential-executor ref", async () => {
|
|
89
|
+
mockFetchJson([{ ...RELEASE, credential_executor_image_ref: null }]);
|
|
90
|
+
const result = await resolveImageRefsDetailed("0.7.0");
|
|
91
|
+
expect(result.status).toBe("platform");
|
|
92
|
+
if (result.status === "platform") {
|
|
93
|
+
expect(result.imageTags["credential-executor"]).toContain(":0.7.0");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("returns version-not-found when the platform list lacks the version", async () => {
|
|
98
|
+
mockFetchJson([RELEASE]);
|
|
99
|
+
const result = await resolveImageRefsDetailed("v9.9.9");
|
|
100
|
+
expect(result.status).toBe("version-not-found");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("returns dockerhub-fallback when the platform is unreachable", async () => {
|
|
104
|
+
mockFetchError();
|
|
105
|
+
const result = await resolveImageRefsDetailed("v0.7.0");
|
|
106
|
+
expect(result.status).toBe("dockerhub-fallback");
|
|
107
|
+
if (result.status === "dockerhub-fallback") {
|
|
108
|
+
expect(result.imageTags.assistant).toContain(":v0.7.0");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("returns dockerhub-fallback when required refs are missing", async () => {
|
|
113
|
+
mockFetchJson([{ ...RELEASE, assistant_image_ref: null }]);
|
|
114
|
+
const result = await resolveImageRefsDetailed("0.7.0");
|
|
115
|
+
expect(result.status).toBe("dockerhub-fallback");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
evaluateUpgradePoll,
|
|
5
|
+
resolveUpgradeTarget,
|
|
6
|
+
} from "../lib/upgrade-preflight.js";
|
|
7
|
+
|
|
8
|
+
const RELEASES = [
|
|
9
|
+
{ version: "0.8.0", is_stable: false },
|
|
10
|
+
{ version: "0.7.0" },
|
|
11
|
+
{ version: "0.6.0" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("resolveUpgradeTarget", () => {
|
|
15
|
+
test("explicit version present in releases resolves ok", () => {
|
|
16
|
+
const result = resolveUpgradeTarget({
|
|
17
|
+
explicitVersion: "v0.7.0",
|
|
18
|
+
releases: RELEASES,
|
|
19
|
+
currentVersion: "0.6.0",
|
|
20
|
+
});
|
|
21
|
+
expect(result.kind).toBe("ok");
|
|
22
|
+
expect(result.target).toBe("v0.7.0");
|
|
23
|
+
expect(result.isNoOp).toBe(false);
|
|
24
|
+
expect(result.isDowngrade).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("explicit version absent from releases is version-not-found", () => {
|
|
28
|
+
const result = resolveUpgradeTarget({
|
|
29
|
+
explicitVersion: "v9.9.9",
|
|
30
|
+
releases: RELEASES,
|
|
31
|
+
currentVersion: "0.6.0",
|
|
32
|
+
});
|
|
33
|
+
expect(result.kind).toBe("version-not-found");
|
|
34
|
+
expect(result.target).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("explicit version is trusted when releases are unavailable", () => {
|
|
38
|
+
const result = resolveUpgradeTarget({
|
|
39
|
+
explicitVersion: "v0.7.0",
|
|
40
|
+
releases: null,
|
|
41
|
+
currentVersion: "0.6.0",
|
|
42
|
+
});
|
|
43
|
+
expect(result.kind).toBe("ok");
|
|
44
|
+
expect(result.target).toBe("v0.7.0");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("default target skips non-stable heads", () => {
|
|
48
|
+
const result = resolveUpgradeTarget({
|
|
49
|
+
explicitVersion: null,
|
|
50
|
+
releases: RELEASES,
|
|
51
|
+
currentVersion: "0.6.0",
|
|
52
|
+
});
|
|
53
|
+
expect(result.kind).toBe("ok");
|
|
54
|
+
expect(result.target).toBe("0.7.0");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("default target falls back to first release when none are stable", () => {
|
|
58
|
+
const result = resolveUpgradeTarget({
|
|
59
|
+
explicitVersion: null,
|
|
60
|
+
releases: [{ version: "0.8.0", is_stable: false }],
|
|
61
|
+
currentVersion: "0.6.0",
|
|
62
|
+
});
|
|
63
|
+
expect(result.target).toBe("0.8.0");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("no explicit version with unreachable releases is no-releases", () => {
|
|
67
|
+
const result = resolveUpgradeTarget({
|
|
68
|
+
explicitVersion: null,
|
|
69
|
+
releases: null,
|
|
70
|
+
currentVersion: "0.6.0",
|
|
71
|
+
});
|
|
72
|
+
expect(result.kind).toBe("no-releases");
|
|
73
|
+
expect(result.target).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("no explicit version with empty releases is no-releases", () => {
|
|
77
|
+
const result = resolveUpgradeTarget({
|
|
78
|
+
explicitVersion: null,
|
|
79
|
+
releases: [],
|
|
80
|
+
currentVersion: "0.6.0",
|
|
81
|
+
});
|
|
82
|
+
expect(result.kind).toBe("no-releases");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("detects no-op across v prefix", () => {
|
|
86
|
+
const result = resolveUpgradeTarget({
|
|
87
|
+
explicitVersion: "v0.7.0",
|
|
88
|
+
releases: RELEASES,
|
|
89
|
+
currentVersion: "0.7.0",
|
|
90
|
+
});
|
|
91
|
+
expect(result.isNoOp).toBe(true);
|
|
92
|
+
expect(result.isDowngrade).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("detects downgrade", () => {
|
|
96
|
+
const result = resolveUpgradeTarget({
|
|
97
|
+
explicitVersion: "v0.6.0",
|
|
98
|
+
releases: RELEASES,
|
|
99
|
+
currentVersion: "0.7.0",
|
|
100
|
+
});
|
|
101
|
+
expect(result.isDowngrade).toBe(true);
|
|
102
|
+
expect(result.isNoOp).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("unknown current version yields no flags", () => {
|
|
106
|
+
const result = resolveUpgradeTarget({
|
|
107
|
+
explicitVersion: "v0.7.0",
|
|
108
|
+
releases: RELEASES,
|
|
109
|
+
currentVersion: undefined,
|
|
110
|
+
});
|
|
111
|
+
expect(result.comparison).toBeNull();
|
|
112
|
+
expect(result.isNoOp).toBe(false);
|
|
113
|
+
expect(result.isDowngrade).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("evaluateUpgradePoll", () => {
|
|
118
|
+
test("known target completes on version match", () => {
|
|
119
|
+
expect(
|
|
120
|
+
evaluateUpgradePoll({
|
|
121
|
+
targetVersion: "v0.7.0",
|
|
122
|
+
initialVersion: "0.6.0",
|
|
123
|
+
observedVersion: "0.7.0",
|
|
124
|
+
inProgress: null,
|
|
125
|
+
sawInProgress: false,
|
|
126
|
+
}),
|
|
127
|
+
).toBe("complete");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("known target pends until the version matches", () => {
|
|
131
|
+
expect(
|
|
132
|
+
evaluateUpgradePoll({
|
|
133
|
+
targetVersion: "0.7.0",
|
|
134
|
+
initialVersion: "0.6.0",
|
|
135
|
+
observedVersion: "0.6.0",
|
|
136
|
+
inProgress: false,
|
|
137
|
+
sawInProgress: false,
|
|
138
|
+
}),
|
|
139
|
+
).toBe("pending");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("unknown target completes when the in-progress lock releases", () => {
|
|
143
|
+
expect(
|
|
144
|
+
evaluateUpgradePoll({
|
|
145
|
+
targetVersion: null,
|
|
146
|
+
initialVersion: "0.6.0",
|
|
147
|
+
observedVersion: "0.6.0",
|
|
148
|
+
inProgress: false,
|
|
149
|
+
sawInProgress: true,
|
|
150
|
+
}),
|
|
151
|
+
).toBe("complete");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("unknown target pends while the lock was never observed", () => {
|
|
155
|
+
expect(
|
|
156
|
+
evaluateUpgradePoll({
|
|
157
|
+
targetVersion: null,
|
|
158
|
+
initialVersion: "0.6.0",
|
|
159
|
+
observedVersion: "0.6.0",
|
|
160
|
+
inProgress: false,
|
|
161
|
+
sawInProgress: false,
|
|
162
|
+
}),
|
|
163
|
+
).toBe("pending");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("unknown target without upgrade-status completes on version change", () => {
|
|
167
|
+
expect(
|
|
168
|
+
evaluateUpgradePoll({
|
|
169
|
+
targetVersion: null,
|
|
170
|
+
initialVersion: "0.6.0",
|
|
171
|
+
observedVersion: "0.7.0",
|
|
172
|
+
inProgress: null,
|
|
173
|
+
sawInProgress: false,
|
|
174
|
+
}),
|
|
175
|
+
).toBe("complete");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("unknown target completes on version change even when the lock was never observed", () => {
|
|
179
|
+
// Upgrade finished before the first poll: in_progress already false,
|
|
180
|
+
// sawInProgress never set — the version change alone must complete.
|
|
181
|
+
expect(
|
|
182
|
+
evaluateUpgradePoll({
|
|
183
|
+
targetVersion: null,
|
|
184
|
+
initialVersion: "0.6.0",
|
|
185
|
+
observedVersion: "0.7.0",
|
|
186
|
+
inProgress: false,
|
|
187
|
+
sawInProgress: false,
|
|
188
|
+
}),
|
|
189
|
+
).toBe("complete");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("unknown target without upgrade-status pends while the version is unchanged", () => {
|
|
193
|
+
expect(
|
|
194
|
+
evaluateUpgradePoll({
|
|
195
|
+
targetVersion: null,
|
|
196
|
+
initialVersion: "0.6.0",
|
|
197
|
+
observedVersion: "v0.6.0",
|
|
198
|
+
inProgress: null,
|
|
199
|
+
sawInProgress: false,
|
|
200
|
+
}),
|
|
201
|
+
).toBe("pending");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
parseVersion,
|
|
5
5
|
compareVersions,
|
|
6
6
|
isVersionCompatible,
|
|
7
|
+
stripVersionPrefix,
|
|
8
|
+
versionsEqual,
|
|
7
9
|
} from "../lib/version-compat.js";
|
|
8
10
|
|
|
9
11
|
describe("parseVersion", () => {
|
|
@@ -204,3 +206,32 @@ describe("isVersionCompatible", () => {
|
|
|
204
206
|
expect(isVersionCompatible("bad", "1.0.0")).toBe(false);
|
|
205
207
|
});
|
|
206
208
|
});
|
|
209
|
+
|
|
210
|
+
describe("stripVersionPrefix", () => {
|
|
211
|
+
test("strips v prefix", () => {
|
|
212
|
+
expect(stripVersionPrefix("v0.7.0")).toBe("0.7.0");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("strips V prefix", () => {
|
|
216
|
+
expect(stripVersionPrefix("V0.7.0")).toBe("0.7.0");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("leaves unprefixed versions unchanged", () => {
|
|
220
|
+
expect(stripVersionPrefix("0.7.0")).toBe("0.7.0");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("versionsEqual", () => {
|
|
225
|
+
test("equal across v prefix", () => {
|
|
226
|
+
expect(versionsEqual("v0.7.0", "0.7.0")).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("different versions are not equal", () => {
|
|
230
|
+
expect(versionsEqual("0.7.0", "0.7.1")).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("unparseable falls back to prefix-stripped string equality", () => {
|
|
234
|
+
expect(versionsEqual("vweird-build", "weird-build")).toBe(true);
|
|
235
|
+
expect(versionsEqual("weird-build", "other-build")).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
});
|
package/src/commands/hatch.ts
CHANGED
|
@@ -5,6 +5,7 @@ import cliPkg from "../../package.json";
|
|
|
5
5
|
|
|
6
6
|
import { buildOpenclawStartupScript } from "../adapters/openclaw";
|
|
7
7
|
import {
|
|
8
|
+
normalizeVersion,
|
|
8
9
|
saveAssistantEntry,
|
|
9
10
|
setActiveAssistant,
|
|
10
11
|
} from "../lib/assistant-config";
|
|
@@ -638,6 +639,9 @@ async function hatchVellumPlatform(): Promise<void> {
|
|
|
638
639
|
cloud: "vellum",
|
|
639
640
|
species: "vellum",
|
|
640
641
|
hatchedAt: new Date().toISOString(),
|
|
642
|
+
...(result.current_release_version != null && {
|
|
643
|
+
version: normalizeVersion(result.current_release_version),
|
|
644
|
+
}),
|
|
641
645
|
});
|
|
642
646
|
setActiveAssistant(result.id);
|
|
643
647
|
|
package/src/commands/rollback.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
findAssistantByName,
|
|
3
3
|
getActiveAssistant,
|
|
4
4
|
loadAllAssistants,
|
|
5
|
+
normalizeVersion,
|
|
5
6
|
resolveCloud,
|
|
6
7
|
saveAssistantEntry,
|
|
7
8
|
type AssistantEntry,
|
|
@@ -402,6 +403,11 @@ export async function rollback(): Promise<void> {
|
|
|
402
403
|
networkName: res.network,
|
|
403
404
|
},
|
|
404
405
|
previousContainerInfo: entry.containerInfo,
|
|
406
|
+
// Cleared (not preserved) when the rolled-back-to version is unknown
|
|
407
|
+
version:
|
|
408
|
+
previousVersion !== "unknown"
|
|
409
|
+
? normalizeVersion(previousVersion)
|
|
410
|
+
: undefined,
|
|
405
411
|
// Clear the backup path — it belonged to the upgrade we just rolled back
|
|
406
412
|
preUpgradeBackupPath: undefined,
|
|
407
413
|
previousDbMigrationVersion: undefined,
|