Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d825318
fix(release): advertise only published versions on the update channel
elkaix Aug 7, 2026
88a90cc
fix(update): name the artifact in the update manifest
elkaix Aug 7, 2026
085d623
fix(installer): bound every network call in the installers
elkaix Aug 7, 2026
5b99dfa
fix(update): do not offer a native update with no artifact for this p…
elkaix Aug 7, 2026
07d6365
feat(update): report real download progress from the background insta…
elkaix Aug 7, 2026
3890ada
feat(tui): show update availability and download progress under the p…
elkaix Aug 7, 2026
7e2b436
refactor(update): make the manifest the client's only update source
elkaix Aug 7, 2026
be3479f
fix(update): bound every install lease, and state the rule once
elkaix Aug 7, 2026
a2544f6
fix(update): reconcile an abandoned install so a doomed version parks
elkaix Aug 7, 2026
fbd2357
fix(update): hold the install lock on the two foreground paths
elkaix Aug 7, 2026
548fd00
fix(update): say which version is installing and which one follows
elkaix Aug 7, 2026
e6d6bcc
fix(update): decide the target once, after the bounded refresh
elkaix Aug 7, 2026
1202842
refactor(tui): delete the startup banner's update chip
elkaix Aug 7, 2026
ba44f46
feat(update): let a release declare a minimum supported version
elkaix Aug 7, 2026
1db6df5
fix(update): tell the user why a parked update failed
elkaix Aug 7, 2026
c67f64e
docs(changeset): describe the update-flow changes
elkaix Aug 7, 2026
62ffdfd
test(update): prove the footer poll runs and pin the installer's real…
elkaix Aug 7, 2026
e486a10
Merge origin/main into fix/update-flow-hardening
elkaix Aug 7, 2026
4c1f9f3
fix(update): stop a slow progress write from erasing the install outcome
elkaix Aug 7, 2026
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
5 changes: 5 additions & 0 deletions .changeset/update-flow-reliability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": patch
---

Stop offering an update with no build for the running platform, give every installer network call a timeout, expire a stale install lease instead of blocking updates forever, and say which version is installing and why a failed one stopped retrying.
5 changes: 5 additions & 0 deletions .changeset/update-minimum-supported-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": minor
---

Let a release declare a minimum supported version, so a client below it is offered the update without waiting for its staged rollout batch.
5 changes: 5 additions & 0 deletions .changeset/update-status-under-the-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": minor
---

Show update availability and live download progress in the status row under the prompt, replacing the startup banner chip that was computed once and never refreshed.
105 changes: 104 additions & 1 deletion apps/pythinker-code/src/cli/sub/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import { log, type Logger } from '@pythoughts/pythinker-code-sdk';
import { track as trackTelemetry, type TelemetryProperties } from '@pythoughts/pythinker-telemetry';

import { refreshUpdateCache } from '#/cli/update/refresh';
import { selectUpdateTarget } from '#/cli/update/select';
import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock';
import type { UpdateInstallLockHandle, UpdateInstallLockRequest } from '#/cli/update/install-lock';
import {
emptyUpdateInstallState,
failureAttemptsFor,
hasFreshActiveInstall,
readUpdateInstallState,
writeUpdateInstallState,
} from '#/cli/update/install-state';
import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select';
import { detectInstallSource } from '#/cli/update/source';
import {
canAutoInstall,
Expand All @@ -20,6 +29,8 @@ import {
NPM_PACKAGE_NAME,
type InstallSource,
type UpdateCache,
type UpdateInstallState,
type UpdateTarget,
} from '#/cli/update/types';

interface WritableLike {
Expand All @@ -40,6 +51,11 @@ export interface UpgradeDeps {
readonly promptForInstallChoice: (
options: InstallPromptOptions,
) => Promise<InstallPromptChoiceValue>;
readonly readUpdateInstallState: () => Promise<UpdateInstallState>;
readonly writeUpdateInstallState: (state: UpdateInstallState) => Promise<void>;
readonly tryAcquireUpdateInstallLock: (
request: UpdateInstallLockRequest,
) => Promise<UpdateInstallLockHandle | null>;
readonly platform: NodeJS.Platform;
readonly stdout: WritableLike;
readonly stderr: WritableLike;
Expand Down Expand Up @@ -85,6 +101,20 @@ export async function handleUpgrade(
}

const source = await deps.detectInstallSource().catch(() => 'unsupported' as const);
// A native install consumes the manifest's platform artifact; without one
// the update cannot succeed, so take the same exit as being up to date.
if (!isTargetInstallable(source, cache.manifest)) {
trackUpgradeEvent(deps.track, 'upgrade_command_no_update', {
current_version: currentVersion,
});
logUpgradeInfo(deps.logger, 'manual upgrade no update', {
currentVersion,
});
deps.stdout.write(
`${formatDisplayVersion(target.version)} is published but has no build for this platform yet.\n`,
);
return 0;
}
const installCommand = installCommandFor(source, target.version, deps.platform);
if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) {
trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', {
Expand Down Expand Up @@ -131,13 +161,36 @@ export async function handleUpgrade(
return 0;
}

// The foreground install holds the update-install lock for its whole run:
// another live installer (usually a detached background one) must never be
// raced by this path, which writes the same executable. A fresh active
// record or a held lock means an install is already in flight — refuse.
const installState = await deps.readUpdateInstallState().catch(() => emptyUpdateInstallState());
if (hasFreshActiveInstall(installState)) {
return refuseForegroundInstall(deps, currentVersion, target, source, installState.active?.version);
}
const lock = await deps.tryAcquireUpdateInstallLock({ version: target.version });
if (lock === null) {
return refuseForegroundInstall(deps, currentVersion, target, source, undefined);
}

try {
trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', {
current_version: currentVersion,
target_version: target.version,
source,
});
await deps.installUpdate(source, target.version, deps.platform);
await deps.writeUpdateInstallState({
...installState,
active: null,
lastFailure: null,
lastSuccess: {
version: target.version,
installedAt: nowIso(),
notifiedAt: null,
},
}).catch(() => {});
trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', {
current_version: currentVersion,
target_version: target.version,
Expand All @@ -151,6 +204,18 @@ export async function handleUpgrade(
deps.stdout.write(renderInstallSuccessMessage(target));
return 0;
} catch (error) {
const attempts = failureAttemptsFor(installState, target, 'install') + 1;
await deps.writeUpdateInstallState({
...installState,
active: null,
lastFailure: {
version: target.version,
failedAt: nowIso(),
attempts,
operation: 'install',
message: formatErrorMessage(error),
},
}).catch(() => {});
trackUpgradeEvent(deps.track, 'upgrade_command_failed', {
current_version: currentVersion,
target_version: target.version,
Expand All @@ -169,6 +234,8 @@ export async function handleUpgrade(
`${formatErrorMessage(error)}\n`,
);
return 1;
} finally {
await lock.release().catch(() => {});
}
}

Expand All @@ -178,6 +245,9 @@ function createDefaultUpgradeDeps(overrides: Partial<UpgradeDeps>): UpgradeDeps
detectInstallSource: overrides.detectInstallSource ?? (() => detectInstallSource()),
installUpdate: overrides.installUpdate ?? installUpdateForeground,
promptForInstallChoice: overrides.promptForInstallChoice ?? promptForInstallChoice,
readUpdateInstallState: overrides.readUpdateInstallState ?? (() => readUpdateInstallState()),
writeUpdateInstallState: overrides.writeUpdateInstallState ?? writeUpdateInstallState,
tryAcquireUpdateInstallLock: overrides.tryAcquireUpdateInstallLock ?? tryAcquireUpdateInstallLock,
platform: overrides.platform ?? process.platform,
stdout: overrides.stdout ?? process.stdout,
stderr: overrides.stderr ?? process.stderr,
Expand All @@ -191,6 +261,39 @@ function formatDisplayVersion(version: string): string {
return version.startsWith('v') ? version : `v${version}`;
}

function nowIso(): string {
return new Date().toISOString();
}

/**
* Refuse the foreground install because another install is already in
* flight. The active-record case names the version being installed; the
* lock-held case cannot know it, so the message stays generic.
*/
function refuseForegroundInstall(
deps: UpgradeDeps,
currentVersion: string,
target: UpdateTarget,
source: InstallSource,
activeVersion: string | undefined,
): number {
trackUpgradeEvent(deps.track, 'upgrade_command_failed', {
current_version: currentVersion,
target_version: target.version,
source,
stage: 'install',
reason: 'another update install is already in progress',
});
const suffix = activeVersion === undefined
? ''
: ` (${formatDisplayVersion(activeVersion)})`;
deps.stderr.write(
`error: another update install is already in progress${suffix}; ` +
'try again once it finishes.\n',
);
return 1;
}

function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
Expand Down
136 changes: 92 additions & 44 deletions apps/pythinker-code/src/cli/update/cdn.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { valid } from 'semver';
import { lt, valid } from 'semver';
import { z } from 'zod';

import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app';
import { PYTHINKER_CODE_CDN_LATEST_JSON_URL } from '#/constant/app';

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

Expand All @@ -12,27 +12,59 @@ const RolloutBatchSchema = z.object({
delaySeconds: z.number().int().min(0),
});

const UpdateManifestPlatformSchema = z.object({
url: z
.string()
.refine(
(value) => {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
},
{ error: 'invalid url' },
),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
});

/**
* 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).
* clients. Hard-failing on unexpected content bricks the update path for
* every already-installed client, which is unrecoverable from our side.
*/
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([]),
/**
* Resolved per-platform artifacts, keyed `<platform>-<arch>`. A malformed
* value drops only this field via `.catch(undefined)` so `version` and
* `publishedAt` still parse — failing the whole manifest would cost the
* client its update over one unreadable field.
*/
platforms: z
.record(z.string(), UpdateManifestPlatformSchema)
.readonly()
.optional()
.catch(undefined),
/**
* Lowest version that can still work against the current services. A
* malformed value drops only this field via `.catch(undefined)` so
* `version` and `publishedAt` still parse — a client below the floor must
* not lose its update because the declaration is unreadable.
*/
minRequiredVersion: z
.string()
.refine((value) => valid(value) !== null, { error: 'invalid semver' })
.optional()
.catch(undefined),
});

export interface FetchLatestResult {
/** Raw newest version — what `pythinker 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;
}

async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => {
Expand All @@ -46,52 +78,68 @@ async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise
}

/**
* Fetch the latest published Pythinker Code version from the CDN.
* Fetch the CDN update manifest — the client's only source of update truth.
*
* **Throws** on any failure (network error, non-2xx, empty body, non-semver
* text). Callers must catch — `refreshUpdateCache` deliberately lets the
* error propagate so the existing cache stays intact instead of being
* overwritten with a null `latest` on a transient blip.
* **Throws** on any failure (network error, non-2xx, unparseable body). Callers
* must catch: `refreshUpdateCache` deliberately lets the error propagate so the
* existing cache stays intact instead of being overwritten on a transient blip.
*
* There is deliberately no fallback to the plain-text `/latest` endpoint, which
* still exists for `install.sh`. That endpoint carries no per-platform artifact
* data, so falling back to it turns "cannot verify this platform has a build"
* into "verified" and re-opens the hole `platforms` exists to close. It also
* cannot fail independently: both files come from the same generator in the same
* deploy, and the manifest schema already tolerates unknown fields and a
* malformed `platforms` value without failing the parse.
*
* `fetchImpl` is injectable for tests; defaults to the global `fetch`.
*/
export async function fetchLatestVersionFromCdn(
export async function fetchUpdateManifest(
fetchImpl: typeof fetch = fetch,
): Promise<string> {
const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_URL);
if (!response.ok) {
throw new Error(`CDN /latest returned HTTP ${response.status}`);
}
const raw = (await response.text()).trim();
if (valid(raw) === null) {
throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`);
}
return raw;
}

async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise<UpdateManifest> {
): Promise<UpdateManifest> {
const response = await fetchWithTimeout(fetchImpl, PYTHINKER_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()));
}

export type ArtifactAvailability = 'available' | 'unavailable';

/**
* 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).
* Whether the manifest advertises an artifact for `target`. Unknown — a
* null manifest or one that predates artifact addressing — resolves to
* 'available': a CDN blip must never stop a working update, while a
* manifest that explicitly omits the target platform is a definitive
* denial.
*/
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 };
export function manifestArtifactAvailability(
manifest: UpdateManifest | null,
target: string = `${process.platform}-${process.arch}`,
): ArtifactAvailability {
if (manifest === null) {
return 'available';
}
if (manifest.platforms === undefined) {
return 'available';
}
const latest = await fetchLatestVersionFromCdn(fetchImpl);
return { latest, manifest: null };
return Object.hasOwn(manifest.platforms, target) ? 'available' : 'unavailable';
}

/**
* Whether the running version is below the manifest's declared floor, which
* makes its update mandatory rather than merely available: the staged rollout
* delay exists for ordinary releases, not for one a client cannot skip.
*
* An absent, unreadable or non-semver floor answers false — a declaration we
* cannot understand must not escalate an update on its own.
*/
export function isBelowMinRequiredVersion(
manifest: UpdateManifest | null,
currentVersion: string,
): boolean {
const floor = manifest?.minRequiredVersion;
if (floor === undefined) return false;
if (valid(currentVersion) === null || valid(floor) === null) return false;
return lt(currentVersion, floor);
}
Loading
Loading