@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
|
@@ -2,142 +2,164 @@ import { getPlatformUrl } from "./platform-client.js";
|
|
|
2
2
|
import { DOCKERHUB_IMAGES } from "./docker.js";
|
|
3
3
|
import type { ServiceName } from "./docker.js";
|
|
4
4
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
5
|
+
import { stripVersionPrefix } from "./version-compat.js";
|
|
5
6
|
|
|
6
7
|
export interface ResolvedImageRefs {
|
|
7
8
|
imageTags: Record<ServiceName, string>;
|
|
8
9
|
source: "platform" | "dockerhub";
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
export interface ReleaseListItem {
|
|
13
|
+
version: string;
|
|
14
|
+
is_stable?: boolean;
|
|
15
|
+
assistant_image_ref?: string | null;
|
|
16
|
+
gateway_image_ref?: string | null;
|
|
17
|
+
credential_executor_image_ref?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
21
|
+
* The endpoint defaults `limit` to 10, which is too few to validate an
|
|
22
|
+
* explicit --version against ("absent from the list" is treated as a hard
|
|
23
|
+
* error) — always request the documented maximum.
|
|
15
24
|
*/
|
|
16
|
-
|
|
25
|
+
const RELEASES_FETCH_LIMIT = 100;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetch the releases list from the platform API, optionally filtered by
|
|
29
|
+
* channel (the `channel` param takes precedence over `stable` server-side).
|
|
30
|
+
* `platformUrl` overrides the lockfile/env-resolved default — pass the
|
|
31
|
+
* target assistant's platform URL when it may differ from the active one.
|
|
32
|
+
* Returns `null` when the platform is unreachable or responds non-OK —
|
|
33
|
+
* distinct from `[]` (platform answered, no releases).
|
|
34
|
+
*/
|
|
35
|
+
export async function fetchReleases(opts?: {
|
|
36
|
+
channel?: "stable" | "preview";
|
|
37
|
+
platformUrl?: string;
|
|
38
|
+
}): Promise<ReleaseListItem[] | null> {
|
|
17
39
|
try {
|
|
18
|
-
const platformUrl = getPlatformUrl();
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
const platformUrl = opts?.platformUrl || getPlatformUrl();
|
|
41
|
+
const filter = opts?.channel ? `channel=${opts.channel}` : "stable=true";
|
|
42
|
+
const response = await loopbackSafeFetch(
|
|
43
|
+
`${platformUrl}/v1/releases/?${filter}&limit=${RELEASES_FETCH_LIMIT}`,
|
|
44
|
+
{ signal: AbortSignal.timeout(10_000) },
|
|
45
|
+
);
|
|
22
46
|
if (!response.ok) return null;
|
|
23
|
-
|
|
24
|
-
const releases = (await response.json()) as Array<{
|
|
25
|
-
version?: string;
|
|
26
|
-
}>;
|
|
27
|
-
const first = releases[0];
|
|
28
|
-
return first?.version ?? null;
|
|
47
|
+
return (await response.json()) as ReleaseListItem[];
|
|
29
48
|
} catch {
|
|
30
49
|
return null;
|
|
31
50
|
}
|
|
32
51
|
}
|
|
33
52
|
|
|
34
53
|
/**
|
|
35
|
-
*
|
|
54
|
+
* Fetch the latest stable release version from the platform API.
|
|
55
|
+
* Returns the version string (e.g. "0.7.0") or null if unavailable.
|
|
56
|
+
* The releases endpoint returns entries ordered newest-first.
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchLatestStableVersion(): Promise<string | null> {
|
|
59
|
+
const releases = await fetchReleases();
|
|
60
|
+
return releases?.[0]?.version ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ImageRefResolution =
|
|
64
|
+
| { status: "platform"; imageTags: Record<ServiceName, string> }
|
|
65
|
+
| { status: "dockerhub-fallback"; imageTags: Record<ServiceName, string> }
|
|
66
|
+
| { status: "version-not-found" };
|
|
67
|
+
|
|
68
|
+
function dockerhubImageTags(version: string): Record<ServiceName, string> {
|
|
69
|
+
return {
|
|
70
|
+
assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
|
|
71
|
+
"credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
|
|
72
|
+
gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve image references for a given version, distinguishing why the
|
|
78
|
+
* platform refs were unavailable:
|
|
36
79
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
80
|
+
* - `platform` — release found; GCR digest-based refs (credential-executor
|
|
81
|
+
* falls back to DockerHub per-service when its ref is null).
|
|
82
|
+
* - `dockerhub-fallback` — platform unreachable or responded non-OK;
|
|
83
|
+
* tag-based DockerHub refs.
|
|
84
|
+
* - `version-not-found` — the platform answered but the version is absent
|
|
85
|
+
* from the releases list (likely a typo'd --version).
|
|
40
86
|
*/
|
|
41
|
-
export async function
|
|
87
|
+
export async function resolveImageRefsDetailed(
|
|
42
88
|
version: string,
|
|
43
89
|
log?: (msg: string) => void,
|
|
44
|
-
): Promise<
|
|
90
|
+
): Promise<ImageRefResolution> {
|
|
45
91
|
log?.("Resolving image references...");
|
|
46
92
|
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
log?.("
|
|
93
|
+
const releases = await fetchReleases();
|
|
94
|
+
if (releases === null) {
|
|
95
|
+
log?.("Platform unreachable — falling back to DockerHub tags");
|
|
50
96
|
return {
|
|
51
|
-
|
|
52
|
-
|
|
97
|
+
status: "dockerhub-fallback",
|
|
98
|
+
imageTags: dockerhubImageTags(version),
|
|
53
99
|
};
|
|
54
100
|
}
|
|
55
101
|
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
102
|
+
const normalizedVersion = stripVersionPrefix(version);
|
|
103
|
+
const release = releases.find(
|
|
104
|
+
(r) => stripVersionPrefix(r.version ?? "") === normalizedVersion,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (!release) {
|
|
108
|
+
log?.(`Version ${version} not found in platform releases`);
|
|
109
|
+
return { status: "version-not-found" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const assistantImage = release.assistant_image_ref;
|
|
113
|
+
const gatewayImage = release.gateway_image_ref;
|
|
114
|
+
let credentialExecutorImage = release.credential_executor_image_ref;
|
|
115
|
+
|
|
116
|
+
// Assistant and gateway images are required; a release missing them is a
|
|
117
|
+
// platform data gap, not a user typo — fall back to DockerHub tags.
|
|
118
|
+
if (!assistantImage || !gatewayImage) {
|
|
119
|
+
log?.("Platform release missing required image refs");
|
|
120
|
+
return {
|
|
121
|
+
status: "dockerhub-fallback",
|
|
122
|
+
imageTags: dockerhubImageTags(version),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!credentialExecutorImage) {
|
|
127
|
+
credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
|
|
128
|
+
log?.(
|
|
129
|
+
"credential-executor image not in platform release, using DockerHub fallback",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
status: "platform",
|
|
135
|
+
imageTags: {
|
|
136
|
+
assistant: assistantImage,
|
|
137
|
+
"credential-executor": credentialExecutorImage,
|
|
138
|
+
gateway: gatewayImage,
|
|
139
|
+
},
|
|
61
140
|
};
|
|
62
|
-
return { imageTags, source: "dockerhub" };
|
|
63
141
|
}
|
|
64
142
|
|
|
65
143
|
/**
|
|
66
|
-
*
|
|
144
|
+
* Resolve image references for a given version.
|
|
67
145
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
146
|
+
* Lenient wrapper around {@link resolveImageRefsDetailed}: maps
|
|
147
|
+
* `version-not-found` to the DockerHub fallback so existing callers
|
|
148
|
+
* (start flows, docker rollback) keep their permissive behavior. The
|
|
149
|
+
* upgrade command uses the detailed variant to fail fast on typos.
|
|
71
150
|
*/
|
|
72
|
-
async function
|
|
151
|
+
export async function resolveImageRefs(
|
|
73
152
|
version: string,
|
|
74
153
|
log?: (msg: string) => void,
|
|
75
|
-
): Promise<{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
log?.(
|
|
83
|
-
|
|
84
|
-
const response = await loopbackSafeFetch(url, {
|
|
85
|
-
signal: AbortSignal.timeout(10_000),
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
if (!response.ok) {
|
|
89
|
-
log?.(`Platform API returned ${response.status}`);
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const releases = (await response.json()) as Array<{
|
|
94
|
-
version?: string;
|
|
95
|
-
assistant_image_ref?: string | null;
|
|
96
|
-
gateway_image_ref?: string | null;
|
|
97
|
-
credential_executor_image_ref?: string | null;
|
|
98
|
-
}>;
|
|
99
|
-
|
|
100
|
-
// Strip leading "v" from the requested version for matching
|
|
101
|
-
const normalizedVersion = version.replace(/^v/, "");
|
|
102
|
-
|
|
103
|
-
const release = releases.find((r) => {
|
|
104
|
-
const releaseVersion = (r.version ?? "").replace(/^v/, "");
|
|
105
|
-
return releaseVersion === normalizedVersion;
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
if (!release) {
|
|
109
|
-
log?.(`Version ${version} not found in platform releases`);
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const assistantImage = release.assistant_image_ref;
|
|
114
|
-
const gatewayImage = release.gateway_image_ref;
|
|
115
|
-
let credentialExecutorImage = release.credential_executor_image_ref;
|
|
116
|
-
|
|
117
|
-
// Assistant and gateway images are required; credential-executor falls back to DockerHub
|
|
118
|
-
if (!assistantImage || !gatewayImage) {
|
|
119
|
-
log?.("Platform release missing required image refs");
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Fall back to DockerHub for credential-executor if its image ref is null
|
|
124
|
-
if (!credentialExecutorImage) {
|
|
125
|
-
credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
|
|
126
|
-
log?.(
|
|
127
|
-
"credential-executor image not in platform release, using DockerHub fallback",
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
imageTags: {
|
|
133
|
-
assistant: assistantImage,
|
|
134
|
-
"credential-executor": credentialExecutorImage,
|
|
135
|
-
gateway: gatewayImage,
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
} catch (err) {
|
|
139
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
140
|
-
log?.(`Platform image ref resolution failed: ${message}`);
|
|
141
|
-
return null;
|
|
154
|
+
): Promise<ResolvedImageRefs> {
|
|
155
|
+
const resolution = await resolveImageRefsDetailed(version, log);
|
|
156
|
+
if (resolution.status === "platform") {
|
|
157
|
+
log?.("Resolved image refs from platform API");
|
|
158
|
+
return { imageTags: resolution.imageTags, source: "platform" };
|
|
159
|
+
}
|
|
160
|
+
if (resolution.status === "version-not-found") {
|
|
161
|
+
log?.("Falling back to DockerHub tags");
|
|
162
|
+
return { imageTags: dockerhubImageTags(version), source: "dockerhub" };
|
|
142
163
|
}
|
|
164
|
+
return { imageTags: resolution.imageTags, source: "dockerhub" };
|
|
143
165
|
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
loadAllAssistants,
|
|
18
|
+
normalizeVersion,
|
|
18
19
|
removeAssistantEntry,
|
|
19
20
|
saveAssistantEntry,
|
|
20
21
|
} from "./assistant-config.js";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
fetchCurrentUser,
|
|
23
24
|
fetchPlatformAssistants,
|
|
24
25
|
getPlatformUrl,
|
|
26
|
+
type HatchedAssistant,
|
|
25
27
|
} from "./platform-client.js";
|
|
26
28
|
|
|
27
29
|
export type SyncLogger = (message: string) => void;
|
|
@@ -82,7 +84,7 @@ export async function syncCloudAssistants(
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
let platformAssistants:
|
|
87
|
+
let platformAssistants: HatchedAssistant[];
|
|
86
88
|
try {
|
|
87
89
|
log?.("Fetching platform assistants…");
|
|
88
90
|
platformAssistants = await fetchPlatformAssistants(token);
|
|
@@ -126,22 +128,33 @@ export async function syncCloudAssistants(
|
|
|
126
128
|
const existing = existingCloudById.get(pa.id);
|
|
127
129
|
const assistantName = pa.name.trim();
|
|
128
130
|
const nameFields = assistantName ? { name: assistantName } : {};
|
|
131
|
+
// undefined when the platform reports no release — written through on
|
|
132
|
+
// update so a stale cached version is cleared, not preserved.
|
|
133
|
+
const version =
|
|
134
|
+
pa.current_release_version != null
|
|
135
|
+
? normalizeVersion(pa.current_release_version)
|
|
136
|
+
: undefined;
|
|
129
137
|
if (!existing) {
|
|
130
138
|
log?.(`Adding ${pa.name || pa.id} to lockfile`);
|
|
131
139
|
saveAssistantEntry({
|
|
132
140
|
assistantId: pa.id,
|
|
133
141
|
...nameFields,
|
|
142
|
+
...(version && { version }),
|
|
134
143
|
runtimeUrl: getPlatformUrl(),
|
|
135
144
|
cloud: "vellum",
|
|
136
145
|
species: "vellum",
|
|
137
146
|
hatchedAt: new Date().toISOString(),
|
|
138
147
|
});
|
|
139
148
|
added++;
|
|
140
|
-
} else if (
|
|
141
|
-
|
|
149
|
+
} else if (
|
|
150
|
+
(assistantName && existing.name !== assistantName) ||
|
|
151
|
+
existing.version !== version
|
|
152
|
+
) {
|
|
153
|
+
log?.(`Updating ${pa.id} from platform`);
|
|
142
154
|
saveAssistantEntry({
|
|
143
155
|
...existing,
|
|
144
|
-
|
|
156
|
+
...nameFields,
|
|
157
|
+
version,
|
|
145
158
|
});
|
|
146
159
|
updated++;
|
|
147
160
|
}
|
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
|
|
6
6
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
7
|
-
import { saveAssistantEntry } from "./assistant-config.js";
|
|
7
|
+
import { normalizeVersion, saveAssistantEntry } from "./assistant-config.js";
|
|
8
8
|
import { createBackup, pruneOldBackups, restoreBackup } from "./backup-ops.js";
|
|
9
9
|
import { emitCliError } from "./cli-error.js";
|
|
10
10
|
import { getOrCreateHostDeviceId } from "./device-id.js";
|
|
@@ -19,14 +19,14 @@ import {
|
|
|
19
19
|
import { getStateDir } from "./environments/paths.js";
|
|
20
20
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
21
21
|
import { loadGuardianToken } from "./guardian-token.js";
|
|
22
|
-
import { resolveImageRefs } from "./platform-releases.js";
|
|
22
|
+
import { fetchReleases, resolveImageRefs } from "./platform-releases.js";
|
|
23
23
|
import {
|
|
24
24
|
getBuilderManagedEnvKeys,
|
|
25
25
|
type DockerStatefulSetSpec,
|
|
26
26
|
type ServiceName,
|
|
27
27
|
} from "./statefulset.js";
|
|
28
28
|
import { exec, execOutput } from "./step-runner.js";
|
|
29
|
-
import { compareVersions } from "./version-compat.js";
|
|
29
|
+
import { compareVersions, stripVersionPrefix } from "./version-compat.js";
|
|
30
30
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
31
31
|
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
@@ -339,27 +339,18 @@ export async function fetchPreviousVersion(
|
|
|
339
339
|
|
|
340
340
|
// 2. Derive from releases list
|
|
341
341
|
if (!currentVersion) return undefined;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const platformUrl = getPlatformUrl();
|
|
345
|
-
const resp = await loopbackSafeFetch(`${platformUrl}/v1/releases/?stable=true`, {
|
|
346
|
-
signal: AbortSignal.timeout(10_000),
|
|
347
|
-
});
|
|
348
|
-
if (!resp.ok) return undefined;
|
|
342
|
+
const releases = await fetchReleases();
|
|
343
|
+
if (!releases) return undefined;
|
|
349
344
|
|
|
350
|
-
|
|
351
|
-
const normalizedCurrent = currentVersion.replace(/^v/, "");
|
|
345
|
+
const normalizedCurrent = stripVersionPrefix(currentVersion);
|
|
352
346
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
} catch {
|
|
362
|
-
// Best-effort
|
|
347
|
+
// Releases are ordered newest-first; find the entry right after the
|
|
348
|
+
// current version (i.e. the one that was running before the upgrade).
|
|
349
|
+
const idx = releases.findIndex(
|
|
350
|
+
(r) => stripVersionPrefix(r.version ?? "") === normalizedCurrent,
|
|
351
|
+
);
|
|
352
|
+
if (idx >= 0 && idx + 1 < releases.length) {
|
|
353
|
+
return releases[idx + 1].version;
|
|
363
354
|
}
|
|
364
355
|
return undefined;
|
|
365
356
|
}
|
|
@@ -788,6 +779,7 @@ export async function performDockerRollback(
|
|
|
788
779
|
previousContainerInfo: entry.containerInfo,
|
|
789
780
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
790
781
|
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
782
|
+
version: normalizeVersion(targetVersion),
|
|
791
783
|
preUpgradeBackupPath: undefined,
|
|
792
784
|
};
|
|
793
785
|
saveAssistantEntry(updatedEntry);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ReleaseListItem } from "./platform-releases.js";
|
|
2
|
+
import { compareVersions, versionsEqual } from "./version-compat.js";
|
|
3
|
+
|
|
4
|
+
export interface UpgradeTargetResolution {
|
|
5
|
+
kind: "ok" | "version-not-found" | "no-releases";
|
|
6
|
+
/** Resolved target version (kind "ok" only). */
|
|
7
|
+
target: string | null;
|
|
8
|
+
/** compareVersions(target, current); null when either side is unknown/unparseable. */
|
|
9
|
+
comparison: number | null;
|
|
10
|
+
isNoOp: boolean;
|
|
11
|
+
isDowngrade: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the upgrade target version from the releases list and compare it
|
|
16
|
+
* against the running version. Pure — callers fetch releases/current first.
|
|
17
|
+
*
|
|
18
|
+
* - An explicit version is validated against the releases list when one is
|
|
19
|
+
* available (`releases !== null`); absent from the list → "version-not-found".
|
|
20
|
+
* - No explicit version → latest release, skipping non-stable heads
|
|
21
|
+
* (mirrors the web UI: `releases.find(r => r.is_stable !== false) ?? releases[0]`).
|
|
22
|
+
* - `releases === null` (platform unreachable) with an explicit version
|
|
23
|
+
* trusts the explicit version; without one → "no-releases".
|
|
24
|
+
*/
|
|
25
|
+
export function resolveUpgradeTarget(args: {
|
|
26
|
+
explicitVersion: string | null;
|
|
27
|
+
releases: ReleaseListItem[] | null;
|
|
28
|
+
currentVersion: string | undefined;
|
|
29
|
+
}): UpgradeTargetResolution {
|
|
30
|
+
const { explicitVersion, releases, currentVersion } = args;
|
|
31
|
+
|
|
32
|
+
let target: string | null = null;
|
|
33
|
+
if (explicitVersion) {
|
|
34
|
+
if (releases !== null) {
|
|
35
|
+
const found = releases.find((r) =>
|
|
36
|
+
versionsEqual(r.version ?? "", explicitVersion),
|
|
37
|
+
);
|
|
38
|
+
if (!found) {
|
|
39
|
+
return {
|
|
40
|
+
kind: "version-not-found",
|
|
41
|
+
target: null,
|
|
42
|
+
comparison: null,
|
|
43
|
+
isNoOp: false,
|
|
44
|
+
isDowngrade: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
target = explicitVersion;
|
|
49
|
+
} else {
|
|
50
|
+
if (releases === null || releases.length === 0) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "no-releases",
|
|
53
|
+
target: null,
|
|
54
|
+
comparison: null,
|
|
55
|
+
isNoOp: false,
|
|
56
|
+
isDowngrade: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
target =
|
|
60
|
+
(releases.find((r) => r.is_stable !== false) ?? releases[0]).version;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const comparison = currentVersion
|
|
64
|
+
? compareVersions(target, currentVersion)
|
|
65
|
+
: null;
|
|
66
|
+
const isNoOp = currentVersion
|
|
67
|
+
? versionsEqual(target, currentVersion)
|
|
68
|
+
: false;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
kind: "ok",
|
|
72
|
+
target,
|
|
73
|
+
comparison,
|
|
74
|
+
isNoOp,
|
|
75
|
+
isDowngrade: comparison !== null && comparison < 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface UpgradePollState {
|
|
80
|
+
/** Resolved target; null when the server resolved "latest" without reporting it. */
|
|
81
|
+
targetVersion: string | null;
|
|
82
|
+
/** Version observed before the upgrade was triggered. */
|
|
83
|
+
initialVersion: string | null;
|
|
84
|
+
/** Latest current_release_version from the assistant detail endpoint. */
|
|
85
|
+
observedVersion: string | null;
|
|
86
|
+
/** Latest upgrade-status in_progress; null when the endpoint is unavailable. */
|
|
87
|
+
inProgress: boolean | null;
|
|
88
|
+
/** Whether in_progress === true was ever observed during this poll. */
|
|
89
|
+
sawInProgress: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Completion predicate for the platform upgrade poll loop. Pure.
|
|
94
|
+
*
|
|
95
|
+
* The primary signal is the DB-backed `current_release_version` (works while
|
|
96
|
+
* the service group restarts or the assistant sleeps); the upgrade-status
|
|
97
|
+
* lock is secondary, used only when the target version is unknown.
|
|
98
|
+
*/
|
|
99
|
+
export function evaluateUpgradePoll(
|
|
100
|
+
state: UpgradePollState,
|
|
101
|
+
): "pending" | "complete" {
|
|
102
|
+
const { targetVersion, initialVersion, observedVersion, inProgress, sawInProgress } =
|
|
103
|
+
state;
|
|
104
|
+
|
|
105
|
+
if (targetVersion) {
|
|
106
|
+
return observedVersion && versionsEqual(observedVersion, targetVersion)
|
|
107
|
+
? "complete"
|
|
108
|
+
: "pending";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Target unknown: a version change from the pre-upgrade value is
|
|
112
|
+
// definitive on its own — the upgrade can finish before the first poll,
|
|
113
|
+
// leaving in_progress false without sawInProgress ever being set.
|
|
114
|
+
if (
|
|
115
|
+
observedVersion &&
|
|
116
|
+
initialVersion &&
|
|
117
|
+
!versionsEqual(observedVersion, initialVersion)
|
|
118
|
+
) {
|
|
119
|
+
return "complete";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Otherwise rely on the in-progress lock releasing (e.g. when the
|
|
123
|
+
// pre-upgrade version was unknown).
|
|
124
|
+
if (sawInProgress && inProgress === false) return "complete";
|
|
125
|
+
|
|
126
|
+
return "pending";
|
|
127
|
+
}
|
|
@@ -86,6 +86,24 @@ export function compareVersions(a: string, b: string): number | null {
|
|
|
86
86
|
return comparePreRelease(pa.pre!, pb.pre!);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Strip an optional leading `v`/`V` prefix: "v0.7.0" → "0.7.0".
|
|
91
|
+
*/
|
|
92
|
+
export function stripVersionPrefix(version: string): string {
|
|
93
|
+
return version.replace(/^[vV]/, "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check whether two version strings refer to the same version.
|
|
98
|
+
* Compares via semver when both parse; falls back to prefix-stripped
|
|
99
|
+
* string equality when either is unparseable.
|
|
100
|
+
*/
|
|
101
|
+
export function versionsEqual(a: string, b: string): boolean {
|
|
102
|
+
const cmp = compareVersions(a, b);
|
|
103
|
+
if (cmp !== null) return cmp === 0;
|
|
104
|
+
return stripVersionPrefix(a) === stripVersionPrefix(b);
|
|
105
|
+
}
|
|
106
|
+
|
|
89
107
|
/**
|
|
90
108
|
* Check whether two version strings are compatible.
|
|
91
109
|
* Compatibility requires matching major AND minor versions.
|