Repository navigation
feat: track the latest Qoder CLI release for the COSY identity #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { | ||
| BUNDLED_GATEWAY_COSY_VERSION, | ||
| clearCosyVersionMemCache, | ||
| compareCliVersions, | ||
| getGatewayCosyVersion, | ||
| isCliVersion, | ||
| refreshGatewayCosyVersion, | ||
| } from "../cosy-version.js"; | ||
|
|
||
| const cachePath = join(process.env.HOME as string, ".pi", "agent", "qoder-cosy-version-cache.json"); | ||
|
|
||
| function seedCache(data: unknown): void { | ||
| writeFileSync(cachePath, JSON.stringify(data), "utf-8"); | ||
| clearCosyVersionMemCache(); | ||
| } | ||
|
|
||
| function stubReleasesFetch(releases: unknown, ok = true): ReturnType<typeof vi.fn> { | ||
| const fetchMock = vi.fn(async () => ({ | ||
| ok, | ||
| status: ok ? 200 : 500, | ||
| json: async () => releases, | ||
| })); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
| return fetchMock; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| rmSync(cachePath, { force: true }); | ||
| clearCosyVersionMemCache(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| rmSync(cachePath, { force: true }); | ||
| clearCosyVersionMemCache(); | ||
| }); | ||
|
|
||
| describe("cosy version resolution", () => { | ||
| it("falls back to the bundled version when no cache exists", () => { | ||
| expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); | ||
| }); | ||
|
|
||
| it("uses a cached version that is ahead of the bundled fallback", () => { | ||
| seedCache({ version: "1.1.99", checkedAt: Date.now() }); | ||
| expect(getGatewayCosyVersion()).toBe("1.1.99"); | ||
| }); | ||
|
|
||
| it("never lets a stale cached version downgrade the bundled fallback", () => { | ||
| seedCache({ version: "1.1.1", checkedAt: Date.now() }); | ||
| expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); | ||
| }); | ||
|
|
||
| it("ignores malformed cache content", () => { | ||
| writeFileSync(cachePath, "not json", "utf-8"); | ||
| clearCosyVersionMemCache(); | ||
| expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); | ||
| }); | ||
|
|
||
| it("validates and compares x.y.z versions numerically", () => { | ||
| expect(isCliVersion("1.1.72")).toBe(true); | ||
| expect(isCliVersion("v1.1.72")).toBe(false); | ||
| expect(isCliVersion("1.1")).toBe(false); | ||
| expect(isCliVersion(172)).toBe(false); | ||
| expect(compareCliVersions("1.1.72", "1.1.71")).toBeGreaterThan(0); | ||
| expect(compareCliVersions("1.2.0", "1.1.99")).toBeGreaterThan(0); | ||
| expect(compareCliVersions("1.1.72", "1.1.72")).toBe(0); | ||
| }); | ||
| }); | ||
|
|
||
| describe("refreshGatewayCosyVersion", () => { | ||
| it("caches the latest stable release tag and signs with it", async () => { | ||
| stubReleasesFetch([ | ||
| { tag_name: "1.1.73", draft: false, prerelease: false }, | ||
| { tag_name: "1.1.72", draft: false, prerelease: false }, | ||
| ]); | ||
| await refreshGatewayCosyVersion({ force: true }); | ||
| expect(getGatewayCosyVersion()).toBe("1.1.73"); | ||
| const saved = JSON.parse(readFileSync(cachePath, "utf8")) as { version?: string }; | ||
| expect(saved.version).toBe("1.1.73"); | ||
| }); | ||
|
|
||
| it("skips drafts, prereleases, and malformed tags", async () => { | ||
| stubReleasesFetch([ | ||
| { tag_name: "1.1.99", draft: false, prerelease: true }, | ||
| { tag_name: "1.1.98", draft: true, prerelease: false }, | ||
| { tag_name: "nightly", draft: false, prerelease: false }, | ||
| { tag_name: "1.1.80", draft: false, prerelease: false }, | ||
| ]); | ||
| await refreshGatewayCosyVersion({ force: true }); | ||
| expect(getGatewayCosyVersion()).toBe("1.1.80"); | ||
| }); | ||
|
|
||
| it("does not downgrade when the feed is behind the bundled fallback", async () => { | ||
| stubReleasesFetch([{ tag_name: "1.1.1", draft: false, prerelease: false }]); | ||
| await refreshGatewayCosyVersion({ force: true }); | ||
| expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); | ||
| }); | ||
|
|
||
| it("keeps the bundled fallback when the fetch fails", async () => { | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn(async () => { | ||
| throw new Error("offline"); | ||
| }), | ||
| ); | ||
| await refreshGatewayCosyVersion({ force: true }); | ||
| expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); | ||
| expect(existsSync(cachePath)).toBe(false); | ||
| }); | ||
|
|
||
| it("keeps the bundled fallback on a non-OK response", async () => { | ||
| stubReleasesFetch([], false); | ||
| await refreshGatewayCosyVersion({ force: true }); | ||
| expect(getGatewayCosyVersion()).toBe(BUNDLED_GATEWAY_COSY_VERSION); | ||
| }); | ||
|
|
||
| it("throttles repeat checks within the refresh interval", async () => { | ||
| const fetchMock = stubReleasesFetch([{ tag_name: "1.1.90", draft: false, prerelease: false }]); | ||
| await refreshGatewayCosyVersion({ force: true }); | ||
| expect(fetchMock).toHaveBeenCalledTimes(1); | ||
| await refreshGatewayCosyVersion(); | ||
| expect(fetchMock).toHaveBeenCalledTimes(1); | ||
| await refreshGatewayCosyVersion({ now: Date.now() + 25 * 60 * 60 * 1000 }); | ||
| expect(fetchMock).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { dirname, join } from "node:path"; | ||
|
|
||
| /** | ||
| * Dynamic gateway COSY client identity. | ||
| * | ||
| * The gateway hands out a reduced model catalog to clients whose COSY | ||
| * version lags the current Qoder CLI, so the identity has to track the | ||
| * latest CLI release. The source of truth is the same releases feed the | ||
| * public changelog page (qoder.com/changelog?type=cli) reads: | ||
| * a GitHub-shaped API over the QoderAI/changelog-CLI repo, served via | ||
| * repo.qoder.com. The resolved version is cached on disk next to the | ||
| * model catalog cache and refreshed at most once a day; any failure | ||
| * (offline, bad payload, older tag) falls back to the bundled version | ||
| * below, so request signing never depends on this fetch succeeding. | ||
| */ | ||
| export const BUNDLED_GATEWAY_COSY_VERSION = "1.1.72"; | ||
| export const QODER_CLI_RELEASES_URL = "https://repo.qoder.com/repos/QoderAI/changelog-CLI/releases?per_page=5&page=1"; | ||
|
|
||
| const CACHE_FILE = "qoder-cosy-version-cache.json"; | ||
| const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; | ||
| const FETCH_TIMEOUT_MS = 5000; | ||
|
|
||
| interface CosyVersionCache { | ||
| version?: string; | ||
| checkedAt?: number; | ||
| } | ||
|
|
||
| interface CliRelease { | ||
| tag_name?: unknown; | ||
| draft?: unknown; | ||
| prerelease?: unknown; | ||
| } | ||
|
|
||
| function getHomeDir(): string { | ||
| // Prefer process.env.HOME so vitest setup can isolate caches (same | ||
| // pattern as catalog.ts). | ||
| return process.env.HOME || process.env.USERPROFILE || homedir(); | ||
| } | ||
|
|
||
| function getCosyVersionCachePath(): string { | ||
| return join(getHomeDir(), ".pi", "agent", CACHE_FILE); | ||
| } | ||
|
|
||
| /** In-memory cache keyed by absolute cache path (HOME-safe across tests). */ | ||
| const cosyVersionMem = new Map<string, CosyVersionCache | null>(); | ||
|
|
||
| /** Clear the process-memory version cache (also used by tests). */ | ||
| export function clearCosyVersionMemCache(): void { | ||
| cosyVersionMem.clear(); | ||
| } | ||
|
|
||
| function readCosyVersionCache(): CosyVersionCache | null { | ||
| const cachePath = getCosyVersionCachePath(); | ||
| if (cosyVersionMem.has(cachePath)) { | ||
| return cosyVersionMem.get(cachePath) ?? null; | ||
| } | ||
| if (!existsSync(cachePath)) { | ||
| cosyVersionMem.set(cachePath, null); | ||
| return null; | ||
| } | ||
| try { | ||
| const data = JSON.parse(readFileSync(cachePath, "utf8")) as CosyVersionCache; | ||
| cosyVersionMem.set(cachePath, data); | ||
| return data; | ||
| } catch { | ||
| cosyVersionMem.set(cachePath, null); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function writeCosyVersionCache(data: CosyVersionCache): void { | ||
| const cachePath = getCosyVersionCachePath(); | ||
| try { | ||
| mkdirSync(dirname(cachePath), { recursive: true }); | ||
| writeFileSync(cachePath, JSON.stringify(data, null, 2), "utf-8"); | ||
| } catch { | ||
| // A read-only home must not break requests; the in-memory copy below | ||
| // still serves this process. | ||
| } | ||
| cosyVersionMem.set(cachePath, data); | ||
| } | ||
|
|
||
| export function isCliVersion(value: unknown): value is string { | ||
| return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value); | ||
| } | ||
|
|
||
| /** Numeric semver-ish comparison for plain x.y.z versions. */ | ||
| export function compareCliVersions(a: string, b: string): number { | ||
| const pa = a.split(".").map(Number); | ||
| const pb = b.split(".").map(Number); | ||
| for (let i = 0; i < 3; i++) { | ||
| if (pa[i] !== pb[i]) return pa[i] - pb[i]; | ||
| } | ||
| return 0; | ||
| } | ||
|
|
||
| /** | ||
| * The COSY version to sign requests with right now: the newest cached | ||
| * CLI release when it is ahead of the bundled fallback, otherwise the | ||
| * bundled fallback. Synchronous and network-free; refresh happens in | ||
| * refreshGatewayCosyVersion(). | ||
| */ | ||
| export function getGatewayCosyVersion(): string { | ||
| const cached = readCosyVersionCache()?.version; | ||
| if (isCliVersion(cached) && compareCliVersions(cached, BUNDLED_GATEWAY_COSY_VERSION) >= 0) { | ||
| return cached; | ||
| } | ||
| return BUNDLED_GATEWAY_COSY_VERSION; | ||
| } | ||
|
|
||
| let refreshInFlight: Promise<void> | null = null; | ||
|
|
||
| /** | ||
| * Fetch the latest Qoder CLI release tag and cache it when it is newer | ||
| * than the version currently in use. Throttled to one check per day via | ||
| * the cache's checkedAt stamp, deduplicated while in flight, and fully | ||
| * failure-tolerant: it never throws and never downgrades the identity. | ||
| */ | ||
| export async function refreshGatewayCosyVersion(options?: { force?: boolean; now?: number }): Promise<void> { | ||
| const now = options?.now ?? Date.now(); | ||
| const checkedAt = readCosyVersionCache()?.checkedAt; | ||
| if (!options?.force && typeof checkedAt === "number" && now - checkedAt < REFRESH_INTERVAL_MS) { | ||
| return; | ||
| } | ||
| if (refreshInFlight) return refreshInFlight; | ||
| refreshInFlight = (async () => { | ||
| try { | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); | ||
| let releases: unknown; | ||
| try { | ||
| const response = await fetch(QODER_CLI_RELEASES_URL, { | ||
| headers: { Accept: "application/vnd.github.v3+json" }, | ||
| signal: controller.signal, | ||
| }); | ||
| if (!response.ok) return; | ||
| releases = await response.json(); | ||
| } finally { | ||
| clearTimeout(timer); | ||
| } | ||
| if (!Array.isArray(releases)) return; | ||
| const latest = (releases as CliRelease[]).find( | ||
| (release) => release && !release.draft && !release.prerelease && isCliVersion(release.tag_name), | ||
| ); | ||
| const stampedAt = Date.now(); | ||
| if (latest && compareCliVersions(latest.tag_name as string, getGatewayCosyVersion()) > 0) { | ||
| writeCosyVersionCache({ version: latest.tag_name as string, checkedAt: stampedAt }); | ||
| } else { | ||
| writeCosyVersionCache({ version: readCosyVersionCache()?.version, checkedAt: stampedAt }); | ||
| } | ||
| } catch { | ||
| // Offline or a bad payload: keep signing with the cached/bundled | ||
| // version and try again on the next refresh window. | ||
| } finally { | ||
| refreshInFlight = null; | ||
| } | ||
| })(); | ||
| return refreshInFlight; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When Qoder publishes a version newer than the bundled or cached one, this refresh starts only after the model-list request has already been signed with the stale identity and its reduced result has been cached. Because the call is fire-and-forget, startup then registers that reduced list immediately, while the cache is considered fresh for an hour; later requests may use the new identity, but the current session's registered models are not rebuilt. Await the throttled version refresh before constructing the catalog request so the request this feature is intended to fix uses the current identity.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in #36. The catalog update now awaits the throttled COSY refresh before
buildAuthHeaderssigns the model-list request, so the request this feature exists for is the one that uses the fresh identity. The old fire-and-forget block (and itsVITESTskip) is removed; the catalog fixture stub is URL-aware instead, and a new regression test pins the ordering and asserts the catalog request goes out with the freshly fetchedCosy-Version. Thanks for catching it.