Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/kimi-code/src/cli/update/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdateCache> = 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();

Expand Down
58 changes: 57 additions & 1 deletion apps/kimi-code/src/cli/update/cdn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import { valid } from 'semver';
import { z } from 'zod';

import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';

import type { UpdateManifest } from './types';

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.
Expand All @@ -25,3 +54,30 @@ export async function fetchLatestVersionFromCdn(
}
return raw;
}

async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise<UpdateManifest> {
const response = await fetchImpl(KIMI_CODE_CDN_LATEST_JSON_URL);
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<FetchLatestResult> {
const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null);
if (manifest !== null) {
return { latest: manifest.version, manifest };
}
const latest = await fetchLatestVersionFromCdn(fetchImpl);
return { latest, manifest: null };
}
129 changes: 122 additions & 7 deletions apps/kimi-code/src/cli/update/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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<UpdateTarget | null> {
let timeout: ReturnType<typeof setTimeout> | 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<UpdateTarget>((resolve) => {
timeout = setTimeout(() => {
Expand Down Expand Up @@ -331,6 +414,7 @@ function trackUpdatePrompted(
target: UpdateTarget,
source: InstallSource,
decision: UpdateDecision,
rolloutTelemetry: RolloutTelemetry,
): void {
trackUpdateEvent(track, 'update_prompted', {
current: currentVersion,
Expand All @@ -339,6 +423,7 @@ function trackUpdatePrompted(
target_version: target.version,
source,
decision,
...rolloutTelemetry,
});
}

Expand Down Expand Up @@ -413,6 +498,7 @@ async function startBackgroundInstall(
platform: NodeJS.Platform,
track: RunUpdatePreflightOptions['track'],
logger: UpdateLogger,
rolloutTelemetry: RolloutTelemetry,
): Promise<void> {
const lock = await tryAcquireUpdateInstallLock({ version: target.version });
if (lock === null) return;
Expand All @@ -439,6 +525,7 @@ async function startBackgroundInstall(
current_version: currentVersion,
target_version: target.version,
source,
...rolloutTelemetry,
});
logUpdateInfo(logger, 'background update install started', {
currentVersion,
Expand Down Expand Up @@ -515,6 +602,7 @@ async function tryStartAutomaticBackgroundInstall(
platform: NodeJS.Platform,
track: RunUpdatePreflightOptions['track'],
logger: UpdateLogger,
rolloutTelemetry: RolloutTelemetry,
): Promise<boolean> {
const sourceCanAutoInstall = canAutoInstall(source, platform);
const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false;
Expand All @@ -531,6 +619,7 @@ async function tryStartAutomaticBackgroundInstall(
platform,
track,
logger,
rolloutTelemetry,
).catch(() => {});
}
return true;
Expand Down Expand Up @@ -562,6 +651,8 @@ export async function runUpdatePreflight(
try {
const isInteractive =
options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY);
const deviceId = resolveUpdateDeviceId();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve first-launch telemetry before bucketing

On a first-ever normal launch, handleMainCommand runs update preflight before runShell/runPrompt initialize telemetry, and this call now creates ~/.kimi-code/device_id without an onFirstLaunch callback. When telemetry bootstrap runs later it sees an existing id and never emits the first_launch event, so default users with auto-update enabled lose first-launch attribution just by passing through the rollout check.

Useful? React with 👍 / 👎.

const bypassRollout = isRolloutBypassedByExperimentalEnv();
let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState());
if (isInteractive) {
installState = await showPendingBackgroundInstallNotice(
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
18 changes: 10 additions & 8 deletions apps/kimi-code/src/cli/update/refresh.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
/** 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<FetchLatestResult>;
readonly writeCache: (cache: UpdateCache) => Promise<void>;
readonly now: () => Date;
}
Expand All @@ -16,16 +17,17 @@ export async function refreshUpdateCache(
overrides: Partial<RefreshUpdateCacheDeps> = {},
): Promise<UpdateCache> {
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;
Expand Down
Loading
Loading