fix(server): prefer highest available UI version

Selects the newest UI across bundled/current/previous with a tie-break for current, and only downloads remote UI when it is strictly newer. This prevents stale cached UIs from overriding a newer bundled release.
This commit is contained in:
Shantur Rathore
2026-01-22 23:04:53 +00:00
parent 292f695395
commit 8c48455ae5
2 changed files with 156 additions and 62 deletions

View File

@@ -0,0 +1,58 @@
import assert from "node:assert/strict"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { mkdir } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, it } from "node:test"
import type { Logger } from "../../logger"
import { resolveUi } from "../remote-ui"
const noopLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
trace: () => {},
child: () => noopLogger,
isLevelEnabled: () => false,
} as any
let tempRoot: string
beforeEach(() => {
tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-"))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
describe("resolveUi local version preference", () => {
it("prefers bundled when bundled version is higher", async () => {
const bundledDir = path.join(tempRoot, "bundled")
const configDir = path.join(tempRoot, "config")
const currentDir = path.join(configDir, "ui", "current")
await mkdir(bundledDir, { recursive: true })
await mkdir(currentDir, { recursive: true })
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.0" }))
const result = await resolveUi({
serverVersion: "0.8.1",
bundledUiDir: bundledDir,
autoUpdate: false,
configDir,
logger: noopLogger,
})
assert.equal(result.source, "bundled")
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
})

View File

@@ -73,23 +73,13 @@ export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution>
const previousDir = path.join(uiRoot, "previous")
if (!options.autoUpdate) {
const local = await resolveStaticUiDir(currentDir)
if (local) {
return {
uiStaticDir: local,
source: "downloaded",
uiVersion: await readUiVersion(local),
supported: true,
}
}
const bundled = await resolveStaticUiDir(options.bundledUiDir)
return {
uiStaticDir: bundled ?? options.bundledUiDir,
source: bundled ? "bundled" : "missing",
uiVersion: bundled ? await readUiVersion(bundled) : undefined,
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
}
})
}
let manifest: RemoteUiManifest | null = null
@@ -125,20 +115,28 @@ export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution>
})
}
const currentVersion = await readUiVersion(currentDir)
if (currentVersion && currentVersion === manifest.latestUIVersion) {
const currentResolved = await resolveStaticUiDir(currentDir)
if (currentResolved) {
return {
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: currentVersion,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
}
}
const bestLocal = await pickBestLocalUi({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
})
const remoteIsNewer =
!bestLocal ||
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
if (!remoteIsNewer) {
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
try {
@@ -206,40 +204,18 @@ async function resolveFromCacheOrBundled(args: {
latestServerUrl?: string
minServerVersion?: string
}): Promise<UiResolution> {
const currentResolved = await resolveStaticUiDir(args.currentDir)
if (currentResolved) {
return {
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
const bestLocal = await pickBestLocalUi({
logger: args.logger,
bundledUiDir: args.bundledUiDir,
currentDir: args.currentDir,
previousDir: args.previousDir,
})
const previousResolved = await resolveStaticUiDir(args.previousDir)
if (previousResolved) {
if (bestLocal) {
return {
uiStaticDir: previousResolved,
source: "previous",
uiVersion: await readUiVersion(previousResolved),
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
if (bundledResolved) {
return {
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
uiStaticDir: bestLocal.uiStaticDir,
source: bestLocal.source,
uiVersion: bestLocal.uiVersion,
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
@@ -260,6 +236,66 @@ async function resolveFromCacheOrBundled(args: {
}
}
async function pickBestLocalUi(args: {
logger: Logger
bundledUiDir: string
currentDir: string
previousDir: string
}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> {
const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = []
const currentResolved = await resolveStaticUiDir(args.currentDir)
if (currentResolved) {
candidates.push({
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
priority: 2,
})
}
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
if (bundledResolved) {
candidates.push({
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
priority: 1,
})
}
const previousResolved = await resolveStaticUiDir(args.previousDir)
if (previousResolved) {
candidates.push({
uiStaticDir: previousResolved,
source: "previous",
uiVersion: await readUiVersion(previousResolved),
priority: 0,
})
}
if (candidates.length === 0) {
return null
}
candidates.sort((a, b) => {
const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion)
if (versionCmp !== 0) return -versionCmp
return b.priority - a.priority
})
const best = candidates[0]
if (!best) return null
return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion }
}
function compareSemverMaybe(a: string | undefined, b: string | undefined): number {
if (!a && !b) return 0
if (!a) return -1
if (!b) return 1
return compareSemverCore(a, b)
}
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
try {
const indexPath = path.join(uiDir, "index.html")