From aee481f759386f4ac29ca83e4d6d5d3768405267 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 16:51:09 +0800 Subject: [PATCH 1/5] feat(update): roll out automatic updates in staged batches via CDN manifest --- .changeset/auto-update-staged-rollout.md | 5 + apps/kimi-code/src/cli/update/cache.ts | 6 +- apps/kimi-code/src/cli/update/cdn.ts | 66 +++- apps/kimi-code/src/cli/update/preflight.ts | 129 ++++++- apps/kimi-code/src/cli/update/refresh.ts | 18 +- apps/kimi-code/src/cli/update/rollout.ts | 206 +++++++++++ apps/kimi-code/src/cli/update/types.ts | 19 + apps/kimi-code/src/constant/app.ts | 8 + apps/kimi-code/src/utils/paths.ts | 8 + apps/kimi-code/test/cli/update/cache.test.ts | 58 +++ apps/kimi-code/test/cli/update/cdn.test.ts | 173 ++++++++- .../test/cli/update/preflight.test.ts | 250 ++++++++++++- .../kimi-code/test/cli/update/refresh.test.ts | 31 +- .../kimi-code/test/cli/update/rollout.test.ts | 336 ++++++++++++++++++ apps/kimi-code/test/cli/upgrade.test.ts | 30 +- docs/en/configuration/data-locations.md | 5 +- docs/zh/configuration/data-locations.md | 5 +- 17 files changed, 1322 insertions(+), 31 deletions(-) create mode 100644 .changeset/auto-update-staged-rollout.md create mode 100644 apps/kimi-code/src/cli/update/rollout.ts create mode 100644 apps/kimi-code/test/cli/update/rollout.test.ts diff --git a/.changeset/auto-update-staged-rollout.md b/.changeset/auto-update-staged-rollout.md new file mode 100644 index 000000000..ee0ea9c8c --- /dev/null +++ b/.changeset/auto-update-staged-rollout.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Roll out automatic updates in staged batches driven by a CDN manifest, while `kimi upgrade` keeps installing the newest version immediately. diff --git a/apps/kimi-code/src/cli/update/cache.ts b/apps/kimi-code/src/cli/update/cache.ts index 83132bf36..972c4229a 100644 --- a/apps/kimi-code/src/cli/update/cache.ts +++ b/apps/kimi-code/src/cli/update/cache.ts @@ -3,13 +3,17 @@ import { z } from 'zod'; import { getUpdateStateFile } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; +import { UpdateManifestSchema } from './cdn'; import { emptyUpdateCache, type UpdateCache } from './types'; -const UpdateCacheSchema: z.ZodType = z +// Stays `.strict()` (we own this file), but `manifest` defaults to null so +// cache files written by pre-rollout versions keep parsing. +const UpdateCacheSchema = z .object({ source: z.literal('cdn'), checkedAt: z.string().min(1).nullable(), latest: z.string().min(1).nullable(), + manifest: UpdateManifestSchema.nullable().default(null), }) .strict(); diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts index 5664f021f..c2ccbe1d0 100644 --- a/apps/kimi-code/src/cli/update/cdn.ts +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -1,6 +1,41 @@ import { valid } from 'semver'; +import { z } from 'zod'; -import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { KIMI_CODE_CDN_BASE, KIMI_CODE_UPDATE_CDN_BASE_ENV } from '#/constant/app'; + +import type { UpdateManifest } from './types'; + +/** Resolved per call so tests and local mock CDNs can override via env. */ +function updateCdnBase(): string { + const override = process.env[KIMI_CODE_UPDATE_CDN_BASE_ENV]?.trim(); + return override !== undefined && override.length > 0 ? override : KIMI_CODE_CDN_BASE; +} + +const RolloutBatchSchema = z.object({ + percent: z.number().int().min(0).max(100), + delaySeconds: z.number().int().min(0), +}); + +/** + * CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown + * fields are ignored so future manifest additions never break shipped + * clients (the plain-text `/latest` taught us that hard-failing on + * unexpected content bricks the update path forever). + */ +export const UpdateManifestSchema = z.object({ + version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), + publishedAt: z + .string() + .refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }), + rollout: z.array(RolloutBatchSchema).readonly().default([]), +}); + +export interface FetchLatestResult { + /** Raw newest version — what `kimi upgrade` installs, never rollout-gated. */ + readonly latest: string; + /** Null when the JSON manifest was unavailable and we fell back to plain text. */ + readonly manifest: UpdateManifest | null; +} /** * Fetch the latest published Kimi Code version from the CDN. @@ -15,7 +50,7 @@ import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; export async function fetchLatestVersionFromCdn( fetchImpl: typeof fetch = fetch, ): Promise { - const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL); + const response = await fetchImpl(`${updateCdnBase()}/latest`); if (!response.ok) { throw new Error(`CDN /latest returned HTTP ${response.status}`); } @@ -25,3 +60,30 @@ export async function fetchLatestVersionFromCdn( } return raw; } + +async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { + const response = await fetchImpl(`${updateCdnBase()}/latest.json`); + if (!response.ok) { + throw new Error(`CDN /latest.json returned HTTP ${response.status}`); + } + return UpdateManifestSchema.parse(JSON.parse(await response.text())); +} + +/** + * Fetch the rollout manifest, falling back to the plain-text `/latest` when + * `latest.json` is unavailable or malformed. The fallback removes any + * deployment-order coupling between client releases and the CDN file, and a + * null manifest means "fully rolled out" — exactly the pre-rollout behavior. + * + * **Throws** only when both sources fail; callers must catch (see above). + */ +export async function fetchLatestFromCdn( + fetchImpl: typeof fetch = fetch, +): Promise { + const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null); + if (manifest !== null) { + return { latest: manifest.version, manifest }; + } + const latest = await fetchLatestVersionFromCdn(fetchImpl); + return { latest, manifest: null }; +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 3d28b5609..aaeb34f47 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -19,13 +19,22 @@ import { type InstallPromptOptions, } from './prompt'; import { refreshUpdateCache } from './refresh'; -import { selectUpdateTarget } from './select'; +import { + appendRolloutDecisionLog, + decidePassiveUpdateTarget, + isRolloutBypassedByExperimentalEnv, + resolveUpdateDeviceId, + rolloutBucket, + rolloutDelayForBucket, + type PassiveUpdateDecision, +} from './rollout'; import { detectInstallSource } from './source'; import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, type UpdateInstallState, + type UpdateManifest, type UpdatePreflightResult, type UpdateTarget, } from './types'; @@ -177,8 +186,59 @@ function refreshInBackground(): void { void refreshUpdateCache().catch(() => {}); } +/** Telemetry properties describing where this device sits in the rollout. */ +interface RolloutTelemetry { + readonly rollout_bucket: number; + readonly rollout_delay_seconds: number; + readonly rollout_from_manifest: boolean; + readonly rollout_bypassed: boolean; +} + +function rolloutTelemetryFor( + deviceId: string, + targetVersion: string, + manifest: UpdateManifest | null, + bypassRollout: boolean, +): RolloutTelemetry { + const bucket = rolloutBucket(deviceId, targetVersion); + return { + rollout_bucket: bucket, + rollout_delay_seconds: + manifest === null || bypassRollout ? 0 : rolloutDelayForBucket(manifest.rollout, bucket), + rollout_from_manifest: manifest !== null, + rollout_bypassed: bypassRollout, + }; +} + +type RolloutCheckPhase = 'startup-cache' | 'background-refresh' | 'prompt-refresh'; + +/** Record which case a passive version check hit in `updates/rollout.log`. */ +function logRolloutDecision( + phase: RolloutCheckPhase, + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + decision: PassiveUpdateDecision, +): void { + void appendRolloutDecisionLog({ + ts: nowIso(), + phase, + reason: decision.reason, + current: currentVersion, + latest, + target: decision.target?.version ?? null, + manifestPresent: manifest !== null, + publishedAt: manifest?.publishedAt ?? null, + bucket: decision.bucket, + delaySeconds: decision.delaySeconds, + eligibleAt: decision.eligibleAt, + }); +} + function refreshAndMaybeInstallInBackground( currentVersion: string, + deviceId: string, + bypassRollout: boolean, isInteractive: boolean, installState: UpdateInstallState, platform: NodeJS.Platform, @@ -188,7 +248,16 @@ function refreshAndMaybeInstallInBackground( void (async () => { const refreshed = await refreshUpdateCache(); if (!isInteractive) return; - const target = selectUpdateTarget(currentVersion, refreshed.latest); + const decision = decidePassiveUpdateTarget( + currentVersion, + refreshed.latest, + refreshed.manifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('background-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision); + const target = decision.target; if (target === null) return; const source = await detectInstallSource().catch(() => 'unsupported' as const); await tryStartAutomaticBackgroundInstall( @@ -199,18 +268,32 @@ function refreshAndMaybeInstallInBackground( platform, track, logger, + rolloutTelemetryFor(deviceId, target.version, refreshed.manifest, bypassRollout), ); })().catch(() => {}); } async function refreshUserVisibleUpdateTarget( currentVersion: string, + deviceId: string, + bypassRollout: boolean, fallbackTarget: UpdateTarget, ): Promise { let timeout: ReturnType | undefined; try { const refresh = refreshUpdateCache() - .then((refreshed) => selectUpdateTarget(currentVersion, refreshed.latest)) + .then((refreshed) => { + const decision = decidePassiveUpdateTarget( + currentVersion, + refreshed.latest, + refreshed.manifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('prompt-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision); + return decision.target; + }) .catch(() => fallbackTarget); const fallback = new Promise((resolve) => { timeout = setTimeout(() => { @@ -331,6 +414,7 @@ function trackUpdatePrompted( target: UpdateTarget, source: InstallSource, decision: UpdateDecision, + rolloutTelemetry: RolloutTelemetry, ): void { trackUpdateEvent(track, 'update_prompted', { current: currentVersion, @@ -339,6 +423,7 @@ function trackUpdatePrompted( target_version: target.version, source, decision, + ...rolloutTelemetry, }); } @@ -413,6 +498,7 @@ async function startBackgroundInstall( platform: NodeJS.Platform, track: RunUpdatePreflightOptions['track'], logger: UpdateLogger, + rolloutTelemetry: RolloutTelemetry, ): Promise { const lock = await tryAcquireUpdateInstallLock({ version: target.version }); if (lock === null) return; @@ -439,6 +525,7 @@ async function startBackgroundInstall( current_version: currentVersion, target_version: target.version, source, + ...rolloutTelemetry, }); logUpdateInfo(logger, 'background update install started', { currentVersion, @@ -515,6 +602,7 @@ async function tryStartAutomaticBackgroundInstall( platform: NodeJS.Platform, track: RunUpdatePreflightOptions['track'], logger: UpdateLogger, + rolloutTelemetry: RolloutTelemetry, ): Promise { const sourceCanAutoInstall = canAutoInstall(source, platform); const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false; @@ -531,6 +619,7 @@ async function tryStartAutomaticBackgroundInstall( platform, track, logger, + rolloutTelemetry, ).catch(() => {}); } return true; @@ -562,6 +651,8 @@ export async function runUpdatePreflight( try { const isInteractive = options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY); + const deviceId = resolveUpdateDeviceId(); + const bypassRollout = isRolloutBypassedByExperimentalEnv(); let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); if (isInteractive) { installState = await showPendingBackgroundInstallNotice( @@ -574,11 +665,22 @@ export async function runUpdatePreflight( } const cache = await readUpdateCache().catch(() => null); - const latest = cache?.latest ?? null; - const target = selectUpdateTarget(currentVersion, latest); + const cachedManifest = cache?.manifest ?? null; + const cachedDecision = decidePassiveUpdateTarget( + currentVersion, + cache?.latest ?? null, + cachedManifest, + deviceId, + new Date(), + bypassRollout, + ); + logRolloutDecision('startup-cache', currentVersion, cache?.latest ?? null, cachedManifest, cachedDecision); + const target = cachedDecision.target; if (target === null) { refreshAndMaybeInstallInBackground( currentVersion, + deviceId, + bypassRollout, isInteractive, installState, platform, @@ -608,14 +710,26 @@ export async function runUpdatePreflight( platform, options.track, logger, + rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout), ) ) { refreshInBackground(); return 'continue'; } - const userVisibleTarget = await refreshUserVisibleUpdateTarget(currentVersion, target); + const userVisibleTarget = await refreshUserVisibleUpdateTarget( + currentVersion, + deviceId, + bypassRollout, + target, + ); if (userVisibleTarget === null) return 'continue'; + const userVisibleRollout = rolloutTelemetryFor( + deviceId, + userVisibleTarget.version, + cachedManifest, + bypassRollout, + ); if ( await tryStartAutomaticBackgroundInstall( installState, @@ -625,13 +739,14 @@ export async function runUpdatePreflight( platform, options.track, logger, + userVisibleRollout, ) ) { return 'continue'; } const installCommand = installCommandFor(source, userVisibleTarget.version, platform); - trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision); + trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision, userVisibleRollout); if (decision === 'manual-command') { stdout.write(renderManualUpdateMessage( diff --git a/apps/kimi-code/src/cli/update/refresh.ts b/apps/kimi-code/src/cli/update/refresh.ts index 20bcdf0de..938a4a0fa 100644 --- a/apps/kimi-code/src/cli/update/refresh.ts +++ b/apps/kimi-code/src/cli/update/refresh.ts @@ -1,13 +1,14 @@ import { writeUpdateCache } from './cache'; -import { fetchLatestVersionFromCdn } from './cdn'; +import { fetchLatestFromCdn, type FetchLatestResult } from './cdn'; import { type UpdateCache } from './types'; export interface RefreshUpdateCacheDeps { - /** Resolves with the latest semver. **Throws** on any failure — callers - * (including the default background invocation in preflight) must catch. - * Errors intentionally skip `writeCache` so a transient CDN blip does not - * overwrite a previously known `latest` with `null`. */ - readonly fetchLatest: () => Promise; + /** Resolves with the latest version + rollout manifest. **Throws** on any + * failure — callers (including the default background invocation in + * preflight) must catch. Errors intentionally skip `writeCache` so a + * transient CDN blip does not overwrite a previously known `latest` with + * `null`. */ + readonly fetchLatest: () => Promise; readonly writeCache: (cache: UpdateCache) => Promise; readonly now: () => Date; } @@ -16,16 +17,17 @@ export async function refreshUpdateCache( overrides: Partial = {}, ): Promise { const resolved: RefreshUpdateCacheDeps = { - fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()), + fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()), writeCache: overrides.writeCache ?? writeUpdateCache, now: overrides.now ?? (() => new Date()), }; - const latest = await resolved.fetchLatest(); + const { latest, manifest } = await resolved.fetchLatest(); const cache: UpdateCache = { source: 'cdn', checkedAt: resolved.now().toISOString(), latest, + manifest, }; await resolved.writeCache(cache); return cache; diff --git a/apps/kimi-code/src/cli/update/rollout.ts b/apps/kimi-code/src/cli/update/rollout.ts new file mode 100644 index 000000000..7a59d57d3 --- /dev/null +++ b/apps/kimi-code/src/cli/update/rollout.ts @@ -0,0 +1,206 @@ +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; +import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk'; + +import { getUpdateRolloutLogFile } from '#/utils/paths'; + +import { selectUpdateTarget } from './select'; +import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types'; + +/** + * Hard ceiling for any rollout delay. Combined with the uncovered-bucket + * fallback below, it guarantees every device sees a release no later than + * `publishedAt + 24h`, no matter what the published plan says. + */ +export const MAX_ROLLOUT_DELAY_SECONDS = 86_400; + +/** + * Deterministic 0-99 bucket for a device. The version is mixed into the hash + * so each release reshuffles which devices land in the early batches. + */ +export function rolloutBucket(deviceId: string, version: string): number { + const digest = createHash('sha256').update(`${deviceId}:${version}`, 'utf-8').digest(); + return digest.readUInt32BE(0) % 100; +} + +/** + * Delay assigned to a bucket by the published plan. Batches claim bucket + * ranges in array order; buckets left uncovered (percents summing under 100) + * fall into the slowest cohort, and oversized delays are clamped to 24h. + */ +export function rolloutDelayForBucket(rollout: readonly RolloutBatch[], bucket: number): number { + let cumulative = 0; + for (const batch of rollout) { + cumulative += batch.percent; + if (bucket < cumulative) { + return Math.min(Math.max(batch.delaySeconds, 0), MAX_ROLLOUT_DELAY_SECONDS); + } + } + if (rollout.length === 0) return 0; + return MAX_ROLLOUT_DELAY_SECONDS; +} + +export function rolloutDelaySeconds(manifest: UpdateManifest, deviceId: string): number { + return rolloutDelayForBucket(manifest.rollout, rolloutBucket(deviceId, manifest.version)); +} + +export function isRolloutEligible( + manifest: UpdateManifest, + deviceId: string, + now: Date, +): boolean { + const publishedAt = Date.parse(manifest.publishedAt); + // Schema validation rejects unparseable timestamps before they get here; + // fail open defensively so a defect can never block updates indefinitely. + if (!Number.isFinite(publishedAt)) return true; + const delayMs = rolloutDelaySeconds(manifest, deviceId) * 1000; + return now.getTime() >= publishedAt + delayMs; +} + +/** Which case a passive update check hit; written to the rollout log. */ +export type PassiveUpdateReason = + /** Nothing known yet (no cache / CDN never reached). */ + | 'no-latest' + /** Known version is not an upgrade over the running one. */ + | 'not-newer' + /** Plain-text fallback or legacy cache: visible immediately, no gating. */ + | 'no-manifest' + /** Gated: this device's batch delay has not elapsed yet. */ + | 'held' + /** Gated and the batch delay has elapsed: update is visible. */ + | 'eligible' + /** KIMI_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */ + | 'experimental'; + +export interface PassiveUpdateDecision { + readonly target: UpdateTarget | null; + readonly reason: PassiveUpdateReason; + readonly bucket: number | null; + readonly delaySeconds: number | null; + readonly eligibleAt: string | null; +} + +/** + * Update decision for the passive surfaces (background install, startup + * prompt, manual-command notice). Devices whose batch is not yet eligible see + * no update at all. A null manifest (plain-text fallback or legacy cache) + * keeps the pre-rollout behavior: the latest version is visible immediately. + * + * `kimi upgrade` must NOT go through this gate — it selects directly from the + * raw latest version. + */ +export function decidePassiveUpdateTarget( + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + deviceId: string, + now: Date, + bypassRollout = false, +): PassiveUpdateDecision { + if (bypassRollout) { + if (latest === null) { + return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null }; + } + const target = selectUpdateTarget(currentVersion, latest); + return { + target, + reason: target === null ? 'not-newer' : 'experimental', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }; + } + + if (manifest === null) { + if (latest === null) { + return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null }; + } + const target = selectUpdateTarget(currentVersion, latest); + return { + target, + reason: target === null ? 'not-newer' : 'no-manifest', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }; + } + + const target = selectUpdateTarget(currentVersion, manifest.version); + if (target === null) { + return { target: null, reason: 'not-newer', bucket: null, delaySeconds: null, eligibleAt: null }; + } + + const bucket = rolloutBucket(deviceId, manifest.version); + const delaySeconds = rolloutDelayForBucket(manifest.rollout, bucket); + const publishedAt = Date.parse(manifest.publishedAt); + const eligibleAt = Number.isFinite(publishedAt) + ? new Date(publishedAt + delaySeconds * 1000).toISOString() + : null; + const eligible = isRolloutEligible(manifest, deviceId, now); + return { + target: eligible ? target : null, + reason: eligible ? 'eligible' : 'held', + bucket, + delaySeconds, + eligibleAt, + }; +} + +export function selectPassiveUpdateTarget( + currentVersion: string, + latest: string | null, + manifest: UpdateManifest | null, + deviceId: string, + now: Date, +): UpdateTarget | null { + return decidePassiveUpdateTarget(currentVersion, latest, manifest, deviceId, now).target; +} + +const ROLLOUT_LOG_MAX_BYTES = 256 * 1024; + +/** + * Append one JSON line describing a passive update decision to + * `/updates/rollout.log`. Best-effort diagnostics: any I/O failure + * is swallowed — logging must never affect update prompting. The file is + * reset once it grows past a small cap so it cannot grow unbounded. + */ +export async function appendRolloutDecisionLog( + entry: Record, + filePath: string = getUpdateRolloutLogFile(), +): Promise { + try { + await mkdir(dirname(filePath), { recursive: true }); + const line = `${JSON.stringify(entry)}\n`; + const size = await stat(filePath).then((s) => s.size, () => 0); + if (size > ROLLOUT_LOG_MAX_BYTES) { + await writeFile(filePath, line, 'utf-8'); + return; + } + await appendFile(filePath, line, 'utf-8'); + } catch { + // Diagnostic logging must never affect the update flow. + } +} + +/** Stable per-installation id used for bucketing; same id telemetry uses. */ +export function resolveUpdateDeviceId(): string { + return createKimiDeviceId(resolveKimiHome()); +} + +/** + * The experimental master switch opts a device out of staged rollouts: the + * newest version is always visible to the passive update surfaces, exactly as + * if every release were fully rolled out. Read directly from the env (same + * truthy values as `KIMI_CODE_NO_AUTO_UPDATE`) — the update preflight runs + * before the harness exists, so the core flag registry is not consulted. + * `KIMI_CODE_NO_AUTO_UPDATE` still wins: disabling updates beats opting in. + */ +export function isRolloutBypassedByExperimentalEnv( + env: Readonly> = process.env, +): boolean { + const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase(); + return ['1', 'true', 'yes', 'on'].includes(value); +} diff --git a/apps/kimi-code/src/cli/update/types.ts b/apps/kimi-code/src/cli/update/types.ts index ff4c93c1d..b03af767d 100644 --- a/apps/kimi-code/src/cli/update/types.ts +++ b/apps/kimi-code/src/cli/update/types.ts @@ -16,10 +16,28 @@ export interface UpdateTarget { readonly version: string; } +/** One gradual-rollout cohort: `percent` of devices delayed by `delaySeconds`. */ +export interface RolloutBatch { + readonly percent: number; + readonly delaySeconds: number; +} + +/** + * Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array + * order; an empty array means the release is fully rolled out immediately. + */ +export interface UpdateManifest { + readonly version: string; + readonly publishedAt: string; + readonly rollout: readonly RolloutBatch[]; +} + export interface UpdateCache { readonly source: 'cdn'; readonly checkedAt: string | null; readonly latest: string | null; + /** Null when the manifest came from the plain-text fallback or a legacy cache file. */ + readonly manifest: UpdateManifest | null; } export interface UpdateInstallActive { @@ -54,6 +72,7 @@ export function emptyUpdateCache(): UpdateCache { source: 'cdn', checkedAt: null, latest: null, + manifest: null, }; } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 623f7e715..1d10930ed 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -23,6 +23,7 @@ export const KIMI_CODE_BIN_DIR_NAME = 'bin'; export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; +export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; // Managed Kimi auth provider key shared with OAuth/SDK config. @@ -45,6 +46,13 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; +// Rollout manifest consumed by update checks; the plain-text `/latest` above +// stays unchanged forever — already-shipped clients hard-fail on non-semver +// bodies, and the CDN install scripts read it for fresh installs. +export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; +// Overrides the base for update checks only (latest / latest.json), so a +// local mock CDN can exercise the rollout flow end-to-end. +export const KIMI_CODE_UPDATE_CDN_BASE_ENV = 'KIMI_CODE_UPDATE_CDN_BASE'; export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index c5e246876..eb58a5265 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -18,6 +18,7 @@ import { KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, KIMI_CODE_UPDATE_DIR_NAME, + KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME, KIMI_CODE_UPDATE_STATE_FILE_NAME, } from '#/constant/app'; @@ -69,6 +70,13 @@ export function getUpdateInstallLockFile(): string { return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME); } +/** + * Return the rollout decision log: `/updates/rollout.log`. + */ +export function getUpdateRolloutLogFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); +} + /** * Return the user input history file for a given working directory. * Layout: `/user-history/.jsonl`. diff --git a/apps/kimi-code/test/cli/update/cache.test.ts b/apps/kimi-code/test/cli/update/cache.test.ts index b7760fb84..385035cb5 100644 --- a/apps/kimi-code/test/cli/update/cache.test.ts +++ b/apps/kimi-code/test/cli/update/cache.test.ts @@ -57,6 +57,7 @@ describe('update cache', () => { source: 'cdn', checkedAt: '2026-04-23T08:00:00.000Z', latest: '0.5.0', + manifest: null, } as const; await writeUpdateCache(cache); @@ -64,6 +65,63 @@ describe('update cache', () => { expect(getUpdateStateFile()).toBe(join(dir, 'updates', 'latest.json')); await expect(readUpdateCache()).resolves.toEqual(cache); }); + + it('writes and reads back a cache carrying a rollout manifest', async () => { + const cache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: { + version: '0.5.0', + publishedAt: '2026-04-23T07:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }, + } as const; + + await writeUpdateCache(cache); + + await expect(readUpdateCache()).resolves.toEqual(cache); + }); + + it('reads a legacy cache file without a manifest field as manifest null', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateStateFile(), + JSON.stringify({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + }), + 'utf-8', + ); + + await expect(readUpdateCache()).resolves.toEqual({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: null, + }); + }); + + it('falls back to an empty cache when the manifest field is malformed', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateStateFile(), + JSON.stringify({ + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + manifest: { version: 'not-semver', publishedAt: 'nope', rollout: 'bad' }, + }), + 'utf-8', + ); + + await expect(readUpdateCache()).resolves.toEqual(emptyUpdateCache()); + }); }); describe('update install state', () => { diff --git a/apps/kimi-code/test/cli/update/cdn.test.ts b/apps/kimi-code/test/cli/update/cdn.test.ts index 3127bd71d..387525489 100644 --- a/apps/kimi-code/test/cli/update/cdn.test.ts +++ b/apps/kimi-code/test/cli/update/cdn.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchLatestVersionFromCdn } from '#/cli/update/cdn'; -import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn'; +import { + KIMI_CODE_CDN_LATEST_JSON_URL, + KIMI_CODE_CDN_LATEST_URL, + KIMI_CODE_UPDATE_CDN_BASE_ENV, +} from '#/constant/app'; function mockFetchOk(body: string): typeof fetch { return vi.fn(async () => ({ @@ -19,6 +23,36 @@ function mockFetchStatus(status: number): typeof fetch { })) as unknown as typeof fetch; } +type Route = { readonly status?: number; readonly body?: string } | Error; + +/** URL-routed fetch mock: unrouted URLs return 404. */ +function mockRoutedFetch(routes: Record): typeof fetch { + return vi.fn(async (input: string | URL) => { + const route = routes[String(input)]; + if (route === undefined) { + return { ok: false, status: 404, text: async () => '' }; + } + if (route instanceof Error) throw route; + const status = route.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + text: async () => route.body ?? '', + }; + }) as unknown as typeof fetch; +} + +const MANIFEST_BODY = JSON.stringify({ + schemaVersion: 1, + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], +}); + describe('fetchLatestVersionFromCdn', () => { it('returns the trimmed semver returned by CDN /latest', async () => { const f = mockFetchOk(' 0.5.0\n'); @@ -47,3 +81,136 @@ describe('fetchLatestVersionFromCdn', () => { await expect(fetchLatestVersionFromCdn(f)).rejects.toThrow(/network down/); }); }); + +describe('fetchLatestFromCdn', () => { + it('parses latest.json and returns the manifest', async () => { + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY } }); + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '2.0.0', + manifest: { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }, + }); + expect(f).toHaveBeenCalledWith(KIMI_CODE_CDN_LATEST_JSON_URL); + expect(f).toHaveBeenCalledTimes(1); + }); + + it('ignores unknown manifest fields (lenient parsing)', async () => { + const body = JSON.stringify({ + schemaVersion: 99, + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + futureField: { nested: true }, + }); + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.manifest).toEqual({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + }); + }); + + it('defaults a missing rollout to an empty plan (fully rolled out)', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + }); + const f = mockRoutedFetch({ [KIMI_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchLatestFromCdn(f); + expect(result.manifest?.rollout).toEqual([]); + }); + + const fallbackCases: ReadonlyArray = [ + ['latest.json is missing (HTTP 404)', { status: 404 }], + ['latest.json fetch throws', new Error('network down')], + ['body is not valid JSON', { body: 'not json {' }], + ['version is not semver', { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }], + ['publishedAt is unparseable', { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }], + ['a batch percent is out of range', { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 150, delaySeconds: 0 }], + }), + }], + ['a batch delay is negative', { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: -1 }], + }), + }], + ]; + + for (const [name, route] of fallbackCases) { + it(`falls back to plain /latest when ${name}`, async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: route, + [KIMI_CODE_CDN_LATEST_URL]: { body: '1.9.0\n' }, + }); + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '1.9.0', + manifest: null, + }); + }); + } + + it('throws when both latest.json and plain /latest fail', async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: { status: 500 }, + [KIMI_CODE_CDN_LATEST_URL]: { status: 500 }, + }); + await expect(fetchLatestFromCdn(f)).rejects.toThrow(/HTTP 500/); + }); + + it('propagates the plain /latest error when the fallback also breaks', async () => { + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: new Error('json down'), + [KIMI_CODE_CDN_LATEST_URL]: { body: 'not-a-version' }, + }); + await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/); + }); + + describe('KIMI_CODE_UPDATE_CDN_BASE override', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('reads latest.json from the overridden base', async () => { + vi.stubEnv(KIMI_CODE_UPDATE_CDN_BASE_ENV, 'http://127.0.0.1:8787/mock'); + const f = mockRoutedFetch({ + 'http://127.0.0.1:8787/mock/latest.json': { body: MANIFEST_BODY }, + }); + const result = await fetchLatestFromCdn(f); + expect(result.latest).toBe('2.0.0'); + expect(f).toHaveBeenCalledWith('http://127.0.0.1:8787/mock/latest.json'); + }); + + it('falls back to the overridden plain latest', async () => { + vi.stubEnv(KIMI_CODE_UPDATE_CDN_BASE_ENV, 'http://127.0.0.1:8787/mock'); + const f = mockRoutedFetch({ + 'http://127.0.0.1:8787/mock/latest': { body: '1.9.0\n' }, + }); + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '1.9.0', + manifest: null, + }); + }); + + it('ignores a blank override and keeps the default CDN base', async () => { + vi.stubEnv(KIMI_CODE_UPDATE_CDN_BASE_ENV, ' '); + const f = mockRoutedFetch({ + [KIMI_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY }, + }); + await expect(fetchLatestFromCdn(f)).resolves.toMatchObject({ latest: '2.0.0' }); + }); + }); +}); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 33c4d42a3..f254c1172 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -15,8 +15,14 @@ import { promptForInstallChoice } from '#/cli/update/prompt'; import type * as PromptModule from '#/cli/update/prompt'; import { refreshUpdateCache } from '#/cli/update/refresh'; import type * as RefreshModule from '#/cli/update/refresh'; +import type * as RolloutModule from '#/cli/update/rollout'; import { detectInstallSource } from '#/cli/update/source'; -import { emptyUpdateCache, type UpdateCache, type UpdateInstallState } from '#/cli/update/types'; +import { + emptyUpdateCache, + type UpdateCache, + type UpdateInstallState, + type UpdateManifest, +} from '#/cli/update/types'; import type { TuiConfig } from '#/tui/config'; const mocks = vi.hoisted(() => ({ @@ -28,6 +34,8 @@ const mocks = vi.hoisted(() => ({ detectInstallSource: vi.fn(), promptForInstallChoice: vi.fn(), refreshUpdateCache: vi.fn(), + resolveUpdateDeviceId: vi.fn(), + appendRolloutDecisionLog: vi.fn(), spawn: vi.fn(), })); @@ -81,6 +89,16 @@ vi.mock('../../../src/cli/update/refresh', async () => { }; }); +vi.mock('../../../src/cli/update/rollout', async () => { + const actual = await vi.importActual('../../../src/cli/update/rollout.js'); + return { + ...actual, + resolveUpdateDeviceId: mocks.resolveUpdateDeviceId, + // Stubbed so preflight tests never write a real rollout.log. + appendRolloutDecisionLog: mocks.appendRolloutDecisionLog, + }; +}); + vi.mock('node:child_process', async () => { const actual = await vi.importActual('node:child_process'); return { @@ -94,9 +112,43 @@ function cacheWith(version: string): UpdateCache { source: 'cdn', checkedAt: '2026-04-23T08:00:00.000Z', latest: version, + manifest: null, }; } +function manifestFor(version: string, overrides: Partial = {}): UpdateManifest { + return { + version, + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + ...overrides, + }; +} + +function cacheWithManifest(manifest: UpdateManifest): UpdateCache { + return { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: manifest.version, + manifest, + }; +} + +/** Every bucket delayed by 24h and the clock just started: nobody is eligible. */ +function heldForEveryone(version: string): UpdateManifest { + return manifestFor(version, { + publishedAt: new Date(Date.now() - 1_000).toISOString(), + rollout: [{ percent: 100, delaySeconds: 86_400 }], + }); +} + +/** Every bucket immediate and publishedAt long past: everybody is eligible. */ +function releasedForEveryone(version: string): UpdateManifest { + return manifestFor(version, { + rollout: [{ percent: 100, delaySeconds: 0 }], + }); +} + function installState(overrides: Partial = {}): UpdateInstallState { return { active: null, @@ -177,6 +229,8 @@ describe('runUpdatePreflight', () => { mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.writeUpdateInstallState.mockResolvedValue(undefined); mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); + mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ filePath: '/tmp/kimi-update-install.lock', release: vi.fn().mockResolvedValue(undefined), @@ -780,6 +834,200 @@ describe('runUpdatePreflight', () => { source: 'npm-global', })); }); + + describe('rollout gating', () => { + it('hides a cached update whose batch is not yet eligible', async () => { + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + // The launch still refreshes the cache in the background so the device + // flips to eligible purely by time passing. + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + // Both checks of this launch are recorded in the rollout log. + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'held', + current: '0.4.0', + latest: '0.5.0', + bucket: expect.any(Number), + delaySeconds: 86_400, + eligibleAt: expect.any(String), + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'background-refresh', + reason: 'held', + })); + }); + + it('starts the background install once the device batch is eligible', async () => { + const released = cacheWithManifest(releasedForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(released); + mocks.refreshUpdateCache.mockResolvedValue(released); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + target_version: '0.5.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 0, + rollout_from_manifest: true, + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'eligible', + target: '0.5.0', + })); + }); + + it('prompts with rollout telemetry when eligible and auto-install is disabled', async () => { + disableAutoInstall(); + const released = cacheWithManifest(releasedForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(released); + mocks.refreshUpdateCache.mockResolvedValue(released); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.5.0' } }), + ); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + latest: '0.5.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 0, + rollout_from_manifest: true, + })); + }); + + it('suppresses the manual-command notice while a homebrew device batch is held', async () => { + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(stdout.join('')).toBe(''); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('does not start a fresh-check background install while the refreshed manifest is held', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('stays silent when the user-visible refresh reveals a held newer version', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(releasedForEveryone('0.6.0'))); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.7.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('KIMI_CODE_EXPERIMENTAL_FLAG bypasses the rollout: held devices still update', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + target_version: '0.5.0', + rollout_bypassed: true, + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'experimental', + target: '0.5.0', + })); + }); + + it('KIMI_CODE_NO_AUTO_UPDATE still wins over the experimental flag', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '1'); + vi.stubEnv('KIMI_CODE_NO_AUTO_UPDATE', '1'); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(releasedForEveryone('0.5.0'))); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(readUpdateCache).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('treats any plan older than 24h as fully rolled out', async () => { + disableAutoInstall(); + const staleRollout = manifestFor('0.5.0', { + publishedAt: new Date(Date.now() - 25 * 3_600 * 1_000).toISOString(), + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.5.0' } }), + ); + }); + }); }); describe('spawnForSource native', () => { diff --git a/apps/kimi-code/test/cli/update/refresh.test.ts b/apps/kimi-code/test/cli/update/refresh.test.ts index 16a18d2f3..ceb1306f7 100644 --- a/apps/kimi-code/test/cli/update/refresh.test.ts +++ b/apps/kimi-code/test/cli/update/refresh.test.ts @@ -1,12 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; import { refreshUpdateCache } from '#/cli/update/refresh'; +import type { UpdateManifest } from '#/cli/update/types'; + +const MANIFEST: UpdateManifest = { + version: '0.5.0', + publishedAt: '2026-05-20T12:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], +}; describe('refreshUpdateCache', () => { - it('writes a fresh cache on successful fetch', async () => { + it('writes a fresh cache carrying the manifest on successful fetch', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchLatest: async () => '0.5.0', + fetchLatest: async () => ({ latest: '0.5.0', manifest: MANIFEST }), writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); @@ -15,12 +26,26 @@ describe('refreshUpdateCache', () => { source: 'cdn', checkedAt: '2026-05-20T12:34:56.000Z', latest: '0.5.0', + manifest: MANIFEST, + }); + expect(writeCache).toHaveBeenCalledWith(result); + }); + + it('writes a null manifest when the fetch fell back to plain text', async () => { + const writeCache = vi.fn(async () => {}); + const result = await refreshUpdateCache({ + fetchLatest: async () => ({ latest: '0.5.0', manifest: null }), + writeCache, + now: () => new Date('2026-05-20T12:34:56.000Z'), }); - expect(writeCache).toHaveBeenCalledWith({ + + expect(result).toEqual({ source: 'cdn', checkedAt: '2026-05-20T12:34:56.000Z', latest: '0.5.0', + manifest: null, }); + expect(writeCache).toHaveBeenCalledWith(result); }); it('propagates fetch errors and skips writeCache so the cache is preserved', async () => { diff --git a/apps/kimi-code/test/cli/update/rollout.test.ts b/apps/kimi-code/test/cli/update/rollout.test.ts new file mode 100644 index 000000000..0a384fa27 --- /dev/null +++ b/apps/kimi-code/test/cli/update/rollout.test.ts @@ -0,0 +1,336 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + appendRolloutDecisionLog, + decidePassiveUpdateTarget, + isRolloutBypassedByExperimentalEnv, + isRolloutEligible, + MAX_ROLLOUT_DELAY_SECONDS, + rolloutBucket, + rolloutDelayForBucket, + rolloutDelaySeconds, + selectPassiveUpdateTarget, +} from '#/cli/update/rollout'; +import type { RolloutBatch, UpdateManifest } from '#/cli/update/types'; + +const STANDARD_ROLLOUT: readonly RolloutBatch[] = [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, +]; + +const PUBLISHED_AT = '2026-06-12T00:00:00.000Z'; +const PUBLISHED_AT_MS = Date.parse(PUBLISHED_AT); + +function makeManifest(overrides: Partial = {}): UpdateManifest { + return { + version: '2.0.0', + publishedAt: PUBLISHED_AT, + rollout: STANDARD_ROLLOUT, + ...overrides, + }; +} + +function secondsAfterPublish(seconds: number): Date { + return new Date(PUBLISHED_AT_MS + seconds * 1000); +} + +describe('rolloutBucket', () => { + it('is deterministic and within 0-99', () => { + for (let i = 0; i < 200; i++) { + const bucket = rolloutBucket(`device-${i}`, '2.0.0'); + expect(bucket).toBe(rolloutBucket(`device-${i}`, '2.0.0')); + expect(bucket).toBeGreaterThanOrEqual(0); + expect(bucket).toBeLessThan(100); + expect(Number.isInteger(bucket)).toBe(true); + } + }); + + it('matches pinned vectors (regression guard for the hash layout)', () => { + expect(rolloutBucket('device-a', '1.0.0')).toBe(65); + expect(rolloutBucket('device-b', '1.0.0')).toBe(76); + expect(rolloutBucket('fixed-device', '2.0.0')).toBe(26); + }); + + it('reshuffles buckets when the version changes', () => { + expect(rolloutBucket('device-a', '1.0.1')).toBe(79); + expect(rolloutBucket('device-a', '1.0.1')).not.toBe(rolloutBucket('device-a', '1.0.0')); + }); +}); + +describe('rolloutDelayForBucket', () => { + it('maps buckets to batches at the exact boundaries', () => { + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 0)).toBe(0); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 29)).toBe(0); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 30)).toBe(43_200); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 59)).toBe(43_200); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 60)).toBe(86_400); + expect(rolloutDelayForBucket(STANDARD_ROLLOUT, 99)).toBe(86_400); + }); + + it('clamps oversized delays to 24h', () => { + const rollout: readonly RolloutBatch[] = [{ percent: 100, delaySeconds: 999_999 }]; + expect(rolloutDelayForBucket(rollout, 50)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + }); + + it('assigns buckets not covered by the plan to the slowest cohort', () => { + const rollout: readonly RolloutBatch[] = [{ percent: 30, delaySeconds: 0 }]; + expect(rolloutDelayForBucket(rollout, 29)).toBe(0); + expect(rolloutDelayForBucket(rollout, 30)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + expect(rolloutDelayForBucket(rollout, 99)).toBe(MAX_ROLLOUT_DELAY_SECONDS); + }); + + it('tolerates percents summing past 100', () => { + const rollout: readonly RolloutBatch[] = [ + { percent: 60, delaySeconds: 0 }, + { percent: 60, delaySeconds: 43_200 }, + ]; + expect(rolloutDelayForBucket(rollout, 59)).toBe(0); + expect(rolloutDelayForBucket(rollout, 99)).toBe(43_200); + }); + + it('treats an empty plan as fully rolled out', () => { + expect(rolloutDelayForBucket([], 0)).toBe(0); + expect(rolloutDelayForBucket([], 99)).toBe(0); + }); +}); + +describe('rolloutDelaySeconds', () => { + it('splits 10k devices roughly 30/30/40 across the standard plan', () => { + const counts = new Map([ + [0, 0], + [43_200, 0], + [86_400, 0], + ]); + const manifest = makeManifest(); + for (let i = 0; i < 10_000; i++) { + const delay = rolloutDelaySeconds(manifest, `device-${i}`); + counts.set(delay, (counts.get(delay) ?? 0) + 1); + } + expect(counts.get(0)).toBeGreaterThanOrEqual(2_700); + expect(counts.get(0)).toBeLessThanOrEqual(3_300); + expect(counts.get(43_200)).toBeGreaterThanOrEqual(2_700); + expect(counts.get(43_200)).toBeLessThanOrEqual(3_300); + expect(counts.get(86_400)).toBeGreaterThanOrEqual(3_700); + expect(counts.get(86_400)).toBeLessThanOrEqual(4_300); + }); +}); + +describe('isRolloutEligible', () => { + const delayedForEveryone = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 43_200 }], + }); + + it('is not eligible before publishedAt + delay', () => { + const justBefore = new Date(PUBLISHED_AT_MS + 43_200 * 1000 - 1); + expect(isRolloutEligible(delayedForEveryone, 'device-a', justBefore)).toBe(false); + }); + + it('is eligible exactly at publishedAt + delay', () => { + expect(isRolloutEligible(delayedForEveryone, 'device-a', secondsAfterPublish(43_200))).toBe( + true, + ); + }); + + it('is not eligible while publishedAt is still in the future', () => { + const manifest = makeManifest({ rollout: [] }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(-3_600))).toBe(false); + }); + + it('is always eligible 24h after publish regardless of the plan', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 999_999 }] }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(86_400))).toBe(true); + }); + + it('fails open when publishedAt cannot be parsed', () => { + const manifest = makeManifest({ publishedAt: 'not-a-date' }); + expect(isRolloutEligible(manifest, 'device-a', secondsAfterPublish(-999_999))).toBe(true); + }); +}); + +describe('selectPassiveUpdateTarget', () => { + const now = secondsAfterPublish(60); + + it('falls back to plain latest when manifest is null', () => { + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now)).toEqual({ + version: '2.0.0', + }); + expect(selectPassiveUpdateTarget('1.0.0', null, null, 'device-a', now)).toBeNull(); + }); + + it('returns null when the manifest version is not newer', () => { + const manifest = makeManifest({ rollout: [] }); + expect(selectPassiveUpdateTarget('2.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + expect(selectPassiveUpdateTarget('3.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + }); + + it('returns the target once the device batch is eligible', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 0 }] }); + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now)).toEqual({ + version: '2.0.0', + }); + }); + + it('hides the target while the device batch is not yet eligible', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + expect(selectPassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now)).toBeNull(); + }); +}); + +describe('decidePassiveUpdateTarget', () => { + const now = secondsAfterPublish(60); + + it('reports no-latest when nothing is known yet', () => { + const decision = decidePassiveUpdateTarget('1.0.0', null, null, 'device-a', now); + expect(decision).toMatchObject({ target: null, reason: 'no-latest' }); + }); + + it('reports not-newer when the known version is not an upgrade', () => { + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', null, 'device-a', now)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + const manifest = makeManifest({ rollout: [] }); + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', manifest, 'device-a', now)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + }); + + it('reports no-manifest legacy visibility when only plain latest is known', () => { + const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'no-manifest', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }); + }); + + it('reports held with bucket, delay and eligibleAt while the batch is gated', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(60), + ); + expect(decision).toMatchObject({ + target: null, + reason: 'held', + bucket: rolloutBucket('device-a', '2.0.0'), + delaySeconds: 86_400, + eligibleAt: new Date(PUBLISHED_AT_MS + 86_400 * 1000).toISOString(), + }); + }); + + it('reports eligible once the batch delay has passed', () => { + const manifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 43_200 }] }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(43_200), + ); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'eligible', + delaySeconds: 43_200, + }); + }); +}); + +describe('appendRolloutDecisionLog', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rollout-log-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('appends one JSON line per decision', async () => { + const file = join(dir, 'updates', 'rollout.log'); + await appendRolloutDecisionLog({ phase: 'startup-cache', reason: 'held' }, file); + await appendRolloutDecisionLog({ phase: 'prompt-refresh', reason: 'eligible' }, file); + + const lines = readFileSync(file, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] ?? '')).toMatchObject({ phase: 'startup-cache', reason: 'held' }); + expect(JSON.parse(lines[1] ?? '')).toMatchObject({ phase: 'prompt-refresh', reason: 'eligible' }); + }); + + it('resets the file once it grows past the size cap', async () => { + const file = join(dir, 'rollout.log'); + writeFileSync(file, 'x'.repeat(300 * 1024), 'utf-8'); + await appendRolloutDecisionLog({ reason: 'eligible' }, file); + + const content = readFileSync(file, 'utf-8'); + expect(content.length).toBeLessThan(1024); + expect(JSON.parse(content.trim())).toMatchObject({ reason: 'eligible' }); + }); + + it('never throws on unwritable paths', async () => { + await expect( + appendRolloutDecisionLog({ reason: 'held' }, '/dev/null/nope/rollout.log'), + ).resolves.toBeUndefined(); + }); +}); + +describe('experimental flag bypass', () => { + const now = secondsAfterPublish(60); + const heldManifest = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + + it('bypasses a held rollout and reports experimental', () => { + const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', heldManifest, 'device-a', now, true); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'experimental', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }); + }); + + it('still reports not-newer / no-latest under bypass', () => { + expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', heldManifest, 'device-a', now, true)).toMatchObject({ + target: null, + reason: 'not-newer', + }); + expect(decidePassiveUpdateTarget('1.0.0', null, null, 'device-a', now, true)).toMatchObject({ + target: null, + reason: 'no-latest', + }); + }); + + it('marks plain-latest visibility as experimental when bypassing', () => { + expect(decidePassiveUpdateTarget('1.0.0', '2.0.0', null, 'device-a', now, true)).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'experimental', + }); + }); +}); + +describe('isRolloutBypassedByExperimentalEnv', () => { + it('is on for the usual truthy values of KIMI_CODE_EXPERIMENTAL_FLAG', () => { + for (const value of ['1', 'true', 'YES', ' on ']) { + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: value })).toBe(true); + } + }); + + it('is off when unset, blank, or falsy', () => { + expect(isRolloutBypassedByExperimentalEnv({})).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '' })).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '0' })).toBe(false); + expect(isRolloutBypassedByExperimentalEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: 'off' })).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts index 8995f07cf..31b53b1d9 100644 --- a/apps/kimi-code/test/cli/upgrade.test.ts +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -4,11 +4,15 @@ import { handleUpgrade } from '#/cli/sub/upgrade'; import type { InstallPromptChoiceValue } from '#/cli/update/prompt'; import type { InstallSource, UpdateCache } from '#/cli/update/types'; -function cacheWith(version: string | null): UpdateCache { +function cacheWith( + version: string | null, + manifest: UpdateCache['manifest'] = null, +): UpdateCache { return { source: 'cdn', checkedAt: '2026-04-23T08:00:00.000Z', latest: version, + manifest, }; } @@ -34,6 +38,7 @@ function captureOutput(): { function createDeps(overrides: { readonly latest?: string | null; + readonly manifest?: UpdateCache['manifest']; readonly source?: InstallSource; readonly isInteractive?: boolean; readonly promptForInstallChoice?: () => Promise; @@ -48,7 +53,9 @@ function createDeps(overrides: { ) => Promise>().mockResolvedValue(undefined); return { - refreshUpdateCache: vi.fn().mockResolvedValue(cacheWith(overrides.latest ?? '0.5.0')), + refreshUpdateCache: vi + .fn() + .mockResolvedValue(cacheWith(overrides.latest ?? '0.5.0', overrides.manifest ?? null)), detectInstallSource: vi.fn().mockResolvedValue(overrides.source ?? 'npm-global'), promptForInstallChoice: overrides.promptForInstallChoice ?? vi.fn().mockResolvedValue('install'), @@ -206,4 +213,23 @@ describe('handleUpgrade', () => { })); expect(stderr.join('')).toContain('error: failed to check for updates: cdn unavailable'); }); + + it('ignores rollout gating: installs the latest version while every batch is still held', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + // Published seconds ago with every device delayed by 24h — passive + // update surfaces would hide this version, manual upgrade must not. + manifest: { + version: '0.5.0', + publishedAt: new Date(Date.now() - 1_000).toISOString(), + rollout: [{ percent: 100, delaySeconds: 86_400 }], + }, + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); + }); }); diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index 9855c46bb..17ad91d3c 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -50,7 +50,8 @@ $KIMI_CODE_HOME (default: ~/.kimi-code) ├── updates/ │ ├── latest.json │ ├── install.json -│ └── install.lock +│ ├── install.lock +│ └── rollout.log └── user-history/ └── .jsonl ``` @@ -93,7 +94,7 @@ The first time the `Grep` tool needs ripgrep, the CLI can automatically download When reporting a bug, prefer exporting the relevant session with `kimi export` (see [kimi command](../reference/kimi-command.md)); the session log is included in the export by default. Add `--no-include-global-log` if you do not want to share the global log. -The three files under `updates/` (`latest.json`, `install.json`, `install.lock`) are maintained automatically by the auto-update mechanism and normally do not need manual editing. +The files under `updates/` (`latest.json`, `install.json`, `install.lock`, `rollout.log`) are maintained automatically by the auto-update mechanism and normally do not need manual editing. `rollout.log` records which staged-rollout case each update check hit, which helps explain when a device will receive a new release. ## Input history diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index cb0fa66ce..9dd6d102b 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -50,7 +50,8 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) ├── updates/ │ ├── latest.json │ ├── install.json -│ └── install.lock +│ ├── install.lock +│ └── rollout.log └── user-history/ └── .jsonl ``` @@ -93,7 +94,7 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) 报 bug 时,优先用 `kimi export` 导出相关会话(详见 [kimi 命令](../reference/kimi-command.md));会话日志默认包含在导出包里。不想分享全局日志时加 `--no-include-global-log`。 -`updates/` 下的三个文件(`latest.json`、`install.json`、`install.lock`)由自动更新机制维护,通常无需手动编辑。 +`updates/` 下的文件(`latest.json`、`install.json`、`install.lock`、`rollout.log`)由自动更新机制维护,通常无需手动编辑。`rollout.log` 记录每次更新检查命中的灰度分批情况,可用于排查设备何时能收到新版本。 ## 输入历史 From ef99183720bd03dcd1b47e545ac2531e62a9b8aa Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 16:52:48 +0800 Subject: [PATCH 2/5] chore: remove changeset --- .changeset/auto-update-staged-rollout.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/auto-update-staged-rollout.md diff --git a/.changeset/auto-update-staged-rollout.md b/.changeset/auto-update-staged-rollout.md deleted file mode 100644 index ee0ea9c8c..000000000 --- a/.changeset/auto-update-staged-rollout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Roll out automatic updates in staged batches driven by a CDN manifest, while `kimi upgrade` keeps installing the newest version immediately. From c6e5c6c4f29613a9e0ba9baaf00f89e5f9fa7863 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 16:57:20 +0800 Subject: [PATCH 3/5] refactor(update): single-source the CDN latest file names --- apps/kimi-code/src/cli/update/cdn.ts | 11 ++++++++--- apps/kimi-code/src/constant/app.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts index c2ccbe1d0..5e6a1f070 100644 --- a/apps/kimi-code/src/cli/update/cdn.ts +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -1,7 +1,12 @@ import { valid } from 'semver'; import { z } from 'zod'; -import { KIMI_CODE_CDN_BASE, KIMI_CODE_UPDATE_CDN_BASE_ENV } from '#/constant/app'; +import { + KIMI_CODE_CDN_BASE, + KIMI_CODE_CDN_LATEST_FILE_NAME, + KIMI_CODE_CDN_LATEST_JSON_FILE_NAME, + KIMI_CODE_UPDATE_CDN_BASE_ENV, +} from '#/constant/app'; import type { UpdateManifest } from './types'; @@ -50,7 +55,7 @@ export interface FetchLatestResult { export async function fetchLatestVersionFromCdn( fetchImpl: typeof fetch = fetch, ): Promise { - const response = await fetchImpl(`${updateCdnBase()}/latest`); + const response = await fetchImpl(`${updateCdnBase()}/${KIMI_CODE_CDN_LATEST_FILE_NAME}`); if (!response.ok) { throw new Error(`CDN /latest returned HTTP ${response.status}`); } @@ -62,7 +67,7 @@ export async function fetchLatestVersionFromCdn( } async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { - const response = await fetchImpl(`${updateCdnBase()}/latest.json`); + const response = await fetchImpl(`${updateCdnBase()}/${KIMI_CODE_CDN_LATEST_JSON_FILE_NAME}`); if (!response.ok) { throw new Error(`CDN /latest.json returned HTTP ${response.status}`); } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 1d10930ed..91a9294e4 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -45,11 +45,15 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; -export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; +// CDN object names, kept separate from the full URLs because update checks +// re-resolve the base per call (see KIMI_CODE_UPDATE_CDN_BASE_ENV). +export const KIMI_CODE_CDN_LATEST_FILE_NAME = 'latest'; +export const KIMI_CODE_CDN_LATEST_JSON_FILE_NAME = 'latest.json'; +export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/${KIMI_CODE_CDN_LATEST_FILE_NAME}`; // Rollout manifest consumed by update checks; the plain-text `/latest` above // stays unchanged forever — already-shipped clients hard-fail on non-semver // bodies, and the CDN install scripts read it for fresh installs. -export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; +export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/${KIMI_CODE_CDN_LATEST_JSON_FILE_NAME}`; // Overrides the base for update checks only (latest / latest.json), so a // local mock CDN can exercise the rollout flow end-to-end. export const KIMI_CODE_UPDATE_CDN_BASE_ENV = 'KIMI_CODE_UPDATE_CDN_BASE'; From 0139192017738425978621f4d21a1b32bccd6379 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 16:59:58 +0800 Subject: [PATCH 4/5] refactor(update): reuse the CDN latest URL constants in update checks --- apps/kimi-code/src/cli/update/cdn.ts | 18 +++++++++++------- apps/kimi-code/src/constant/app.ts | 8 ++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts index 5e6a1f070..1c8fa872d 100644 --- a/apps/kimi-code/src/cli/update/cdn.ts +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -3,17 +3,21 @@ import { z } from 'zod'; import { KIMI_CODE_CDN_BASE, - KIMI_CODE_CDN_LATEST_FILE_NAME, - KIMI_CODE_CDN_LATEST_JSON_FILE_NAME, + KIMI_CODE_CDN_LATEST_JSON_URL, + KIMI_CODE_CDN_LATEST_URL, KIMI_CODE_UPDATE_CDN_BASE_ENV, } from '#/constant/app'; import type { UpdateManifest } from './types'; -/** Resolved per call so tests and local mock CDNs can override via env. */ -function updateCdnBase(): string { +/** + * Resolved per call so tests and local mock CDNs can swap the base via env; + * without the override the canonical URL constant is used as-is. + */ +function updateCdnUrl(defaultUrl: string): string { const override = process.env[KIMI_CODE_UPDATE_CDN_BASE_ENV]?.trim(); - return override !== undefined && override.length > 0 ? override : KIMI_CODE_CDN_BASE; + if (override === undefined || override.length === 0) return defaultUrl; + return `${override}${defaultUrl.slice(KIMI_CODE_CDN_BASE.length)}`; } const RolloutBatchSchema = z.object({ @@ -55,7 +59,7 @@ export interface FetchLatestResult { export async function fetchLatestVersionFromCdn( fetchImpl: typeof fetch = fetch, ): Promise { - const response = await fetchImpl(`${updateCdnBase()}/${KIMI_CODE_CDN_LATEST_FILE_NAME}`); + const response = await fetchImpl(updateCdnUrl(KIMI_CODE_CDN_LATEST_URL)); if (!response.ok) { throw new Error(`CDN /latest returned HTTP ${response.status}`); } @@ -67,7 +71,7 @@ export async function fetchLatestVersionFromCdn( } async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { - const response = await fetchImpl(`${updateCdnBase()}/${KIMI_CODE_CDN_LATEST_JSON_FILE_NAME}`); + const response = await fetchImpl(updateCdnUrl(KIMI_CODE_CDN_LATEST_JSON_URL)); if (!response.ok) { throw new Error(`CDN /latest.json returned HTTP ${response.status}`); } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 91a9294e4..1d10930ed 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -45,15 +45,11 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; -// CDN object names, kept separate from the full URLs because update checks -// re-resolve the base per call (see KIMI_CODE_UPDATE_CDN_BASE_ENV). -export const KIMI_CODE_CDN_LATEST_FILE_NAME = 'latest'; -export const KIMI_CODE_CDN_LATEST_JSON_FILE_NAME = 'latest.json'; -export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/${KIMI_CODE_CDN_LATEST_FILE_NAME}`; +export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; // Rollout manifest consumed by update checks; the plain-text `/latest` above // stays unchanged forever — already-shipped clients hard-fail on non-semver // bodies, and the CDN install scripts read it for fresh installs. -export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/${KIMI_CODE_CDN_LATEST_JSON_FILE_NAME}`; +export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; // Overrides the base for update checks only (latest / latest.json), so a // local mock CDN can exercise the rollout flow end-to-end. export const KIMI_CODE_UPDATE_CDN_BASE_ENV = 'KIMI_CODE_UPDATE_CDN_BASE'; From 7602b43a49d726186bbe5ea499ac7d0f2c51083f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 12 Jun 2026 17:05:59 +0800 Subject: [PATCH 5/5] refactor(update): drop the test-only CDN base override --- apps/kimi-code/src/cli/update/cdn.ts | 21 ++--------- apps/kimi-code/src/constant/app.ts | 3 -- apps/kimi-code/test/cli/update/cdn.test.ts | 43 +--------------------- 3 files changed, 5 insertions(+), 62 deletions(-) diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts index 1c8fa872d..009f35282 100644 --- a/apps/kimi-code/src/cli/update/cdn.ts +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -1,25 +1,10 @@ import { valid } from 'semver'; import { z } from 'zod'; -import { - KIMI_CODE_CDN_BASE, - KIMI_CODE_CDN_LATEST_JSON_URL, - KIMI_CODE_CDN_LATEST_URL, - KIMI_CODE_UPDATE_CDN_BASE_ENV, -} from '#/constant/app'; +import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; import type { UpdateManifest } from './types'; -/** - * Resolved per call so tests and local mock CDNs can swap the base via env; - * without the override the canonical URL constant is used as-is. - */ -function updateCdnUrl(defaultUrl: string): string { - const override = process.env[KIMI_CODE_UPDATE_CDN_BASE_ENV]?.trim(); - if (override === undefined || override.length === 0) return defaultUrl; - return `${override}${defaultUrl.slice(KIMI_CODE_CDN_BASE.length)}`; -} - const RolloutBatchSchema = z.object({ percent: z.number().int().min(0).max(100), delaySeconds: z.number().int().min(0), @@ -59,7 +44,7 @@ export interface FetchLatestResult { export async function fetchLatestVersionFromCdn( fetchImpl: typeof fetch = fetch, ): Promise { - const response = await fetchImpl(updateCdnUrl(KIMI_CODE_CDN_LATEST_URL)); + const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL); if (!response.ok) { throw new Error(`CDN /latest returned HTTP ${response.status}`); } @@ -71,7 +56,7 @@ export async function fetchLatestVersionFromCdn( } async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { - const response = await fetchImpl(updateCdnUrl(KIMI_CODE_CDN_LATEST_JSON_URL)); + const response = await fetchImpl(KIMI_CODE_CDN_LATEST_JSON_URL); if (!response.ok) { throw new Error(`CDN /latest.json returned HTTP ${response.status}`); } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 1d10930ed..9ec4223ce 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -50,9 +50,6 @@ export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; // stays unchanged forever — already-shipped clients hard-fail on non-semver // bodies, and the CDN install scripts read it for fresh installs. export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; -// Overrides the base for update checks only (latest / latest.json), so a -// local mock CDN can exercise the rollout flow end-to-end. -export const KIMI_CODE_UPDATE_CDN_BASE_ENV = 'KIMI_CODE_UPDATE_CDN_BASE'; export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; diff --git a/apps/kimi-code/test/cli/update/cdn.test.ts b/apps/kimi-code/test/cli/update/cdn.test.ts index 387525489..09e9446c4 100644 --- a/apps/kimi-code/test/cli/update/cdn.test.ts +++ b/apps/kimi-code/test/cli/update/cdn.test.ts @@ -1,11 +1,7 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn'; -import { - KIMI_CODE_CDN_LATEST_JSON_URL, - KIMI_CODE_CDN_LATEST_URL, - KIMI_CODE_UPDATE_CDN_BASE_ENV, -} from '#/constant/app'; +import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; function mockFetchOk(body: string): typeof fetch { return vi.fn(async () => ({ @@ -178,39 +174,4 @@ describe('fetchLatestFromCdn', () => { }); await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/); }); - - describe('KIMI_CODE_UPDATE_CDN_BASE override', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('reads latest.json from the overridden base', async () => { - vi.stubEnv(KIMI_CODE_UPDATE_CDN_BASE_ENV, 'http://127.0.0.1:8787/mock'); - const f = mockRoutedFetch({ - 'http://127.0.0.1:8787/mock/latest.json': { body: MANIFEST_BODY }, - }); - const result = await fetchLatestFromCdn(f); - expect(result.latest).toBe('2.0.0'); - expect(f).toHaveBeenCalledWith('http://127.0.0.1:8787/mock/latest.json'); - }); - - it('falls back to the overridden plain latest', async () => { - vi.stubEnv(KIMI_CODE_UPDATE_CDN_BASE_ENV, 'http://127.0.0.1:8787/mock'); - const f = mockRoutedFetch({ - 'http://127.0.0.1:8787/mock/latest': { body: '1.9.0\n' }, - }); - await expect(fetchLatestFromCdn(f)).resolves.toEqual({ - latest: '1.9.0', - manifest: null, - }); - }); - - it('ignores a blank override and keeps the default CDN base', async () => { - vi.stubEnv(KIMI_CODE_UPDATE_CDN_BASE_ENV, ' '); - const f = mockRoutedFetch({ - [KIMI_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY }, - }); - await expect(fetchLatestFromCdn(f)).resolves.toMatchObject({ latest: '2.0.0' }); - }); - }); });