feat(updater): manual cache clear, installer-reinstall floor, and crash-recovery lifecycle (#6032)

* feat(updater): manual cache clear + installer-reinstall floor on the installed outer version

Two disaster-recovery capabilities for the packaged updater:

- Reuse control.launcher.version.min properly: the gate now compares the
  physically installed outer package version (read at check time from the
  outer bundle's open-design-config.json via the launcher launch path;
  OD_UPDATE_INSTALLED_VERSION overrides for tests), not the running payload
  version. When the floor trips - or min is set but the outer version is
  unreadable (conservative) - the updater selects the installer artifact and
  offers a same-version installer reinstall when no newer release exists
  (clamped to min <= candidate so the offer always converges). The status
  snapshot carries a reinstall block {reason, installedVersion, minVersion,
  url}; UpdateDialog/UpdaterPopup render reinstall copy with an optional
  operator jump link. tools-release publish-metadata publishes the block from
  RELEASE_LAUNCHER_VERSION_MIN(_URL) (per-channel repo vars with a _STABLE
  fallback wired in all release workflows) and hard-fails when min exceeds
  the release version; verify-metadata re-checks the published block and
  summary-metadata surfaces the floor.

- Manual clear-cache action (od:update:clear-cache IPC, sidecar clear-cache
  action, Settings > About row with a two-stage inline confirm): resets
  one-shot update state (downloaded release, install freeze) back to idle,
  purges releases/staging/downloads/.back, removes a stale launcher
  attempt.json plus any non-confirmed desktop-handoff journal, and deletes
  non-retained payload versions. Runtime active/lastSuccessful versions,
  retained cleanup entries, install.json, and a confirmed handoff journal
  are never touched; locked files defer through the existing cleanup.json
  retry machinery.

Also: tools-pack --update-action now validates through sidecar-proto's
exported isDesktopUpdateAction so clear-cache reaches the ops surface;
tools-serve updater fixture gains control min/url knobs and CLI flags;
9 new i18n keys across all 19 locales; tools/pack/AGENTS.md documents the
gate semantics and clear-cache boundaries.

Validated locally per the updater matrix (desktop/web/tools-serve/tools-pack
suites, guard, typechecks) plus a mac release-beta closed loop: platform full
spec, same-version reinstall against a real outer/payload version split,
clear-cache recovery, and a real-feed payload update to 0.15.1-beta.4.

* test(updater): close Windows recovery acceptance

* test(updater): mirror Windows same-version reinstall recovery on the mac lane

Port the Windows runSameVersionUpdaterRecoveryAcceptance shape into the mac
full profile: after the payload cold-start acceptance, restart the packaged
app against a fixture publishing an installed-outer floor at the running
payload version and assert the same-version installer reinstall snapshot
(reason/installedVersion/minVersion/url), the popup reinstall learn-more
link, clear-cache back to idle with launcher pointers untouched, explicit
re-check recovery, and the installer open in dry-run mode — macOS has no
silent DMG install to execute, unlike the Windows NSIS transaction lane.
Extends the popup eval expression/validator with reinstallLinkVisible and
records an updaterRecovery block in the smoke summary. Full-profile test
timeout raised 240s -> 360s for the extra app restart.

Validated locally: release-smoke full profile green in 80s with the recovery
summary populated (reinstall block exact, cleared idle, recovered dmg,
dryRun install).

* refactor(release): centralize launcher version floor channel policy in one resolver

Channel policy for control.launcher.version.{min,url} is now managed as one
repo-vars pair per channel (RELEASE_LAUNCHER_VERSION_MIN[_URL]_<CHANNEL>).
Workflows pass the channel pair plus the STABLE pair through verbatim — the
|| fallback expressions are gone from YAML — and the shared resolver in
tools/release/src/storage/launcher-version-floor.ts owns resolution and
validation: pair-level stable fallback for non-stable channels (never mixing
one channel's min with another's url), version format, http(s) url, orphan
url rejection, and the floor <= releaseVersion guard. publish-metadata and
verify-metadata consume the same resolver, so verify re-derives the expected
block from the same channel policy instead of trusting a pre-resolved env.
release-workflows.test.ts pins the verbatim passthrough keys in all five
release lanes and rejects any reintroduced YAML fallback.

* feat(updater): let clear-cache rebuild a provably-owned corrupt update store

A corrupt store is one of the blocking scenarios the manual clear exists to
recover from, yet the action previously bailed on the very store errors it
should heal. clear-cache now rebuilds the update root when — and only when —
ownership is provable: the current-generation sentinel is the proof, and
everything else inside an owned root is updater cache by definition. Roots
without a sentinel, with a foreign-generation marker, or failing IO are never
touched; the original store error surfaces unchanged. Four specs pin the
boundary (corrupt metadata rebuilt, stray entries rebuilt, unowned root
refused with files intact, foreign-generation marker refused).

* fix(launcher): pre-arm delegated payload launches so pre-bookkeeping crashes roll back

A payload that spawned successfully but died before reaching its own launcher
bookkeeping left no attempt.json behind, so every later cold start re-selected
the same broken active pointer and re-delegated to the dead payload — a
permanent dead loop the installer fallback could never surface (found while
designing the crash-rollback acceptance).

The delegating parent now arms attempt.json BEFORE spawning the payload and
passes the pointer as --od-launcher-delegated-* argv. Selection gains an
active-delegated reason: an attempt matching active AND the delegated pointer
is the launch in progress, not a previous failure; only an exact active match
qualifies, so a stale pointer cannot defeat the rollback. Rollback
(last-successful) delegations deliberately carry no marker and never re-arm —
the attempt on disk IS the rollback evidence the child must re-derive.

Covered end to end: packaged outer delegation pre-arms (and skips pre-arm on
rollback), the updater's payload activation pre-arms and threads the pointer
through the deferred relaunch, and confirm clears the attempt for delegated
launches. launcher-proto 18, packaged 237, desktop 264 all green.

* fix(updater): scrub a stale payload install freeze after a rollback

A payload install freezes the updater with installResult.activeVersion
awaiting the relaunch. When that payload crashed and the launcher rolled
back, the app restarts on the OLD version with the freeze still persisted —
every later check became a frozen no-op, so a rolled-back install never saw
another update offer (the crash-rollback acceptance exposed this in its
self-heal phase). Cold-start restore now detects the broken promise —
installResult.activeVersion newer than the running version on a live process
— and drops the freeze and stale install result while keeping the verified
downloaded release actionable. The installer-artifact freeze (no
activeVersion) keeps its existing restore semantics. Red spec:
'clears a stale payload install freeze when the promised relaunch never
became the running version'.

* test(updater): full-lifecycle acceptance — silent startup, crash rollback, coverage map

Two new packaged acceptance tests on both platform lanes (mac validated
locally; win symmetric for the Windows box):

- 'applies a downloaded payload silently on the next cold start': enables the
  daemon-owned allowSilentUpdates through the production GET+PUT
  /api/app-config path, restarts, and asserts the first scheduler tick
  installs, quits, and relaunches with converged pointers — no updater action
  issued.
- 'rolls back a crashing payload and self-heals on the next good update':
  repacks the real payload archive with a crash-before-bookkeeping stub
  (mac: shebang script via ditto; win: a real executable — System32 where.exe
  — via the installed app's bundled 7z, since a script cannot stand in for an
  exe), drives the update through the popup, asserts the pre-armed attempt
  survives the crash as rollback evidence, cold-starts into the rolled-back
  base version (degraded steady state pinned: broken pointer stays active
  with its attempt), then self-heals via a version-bumped healthy payload —
  real recovery releases ship as version+1.

Spec hygiene: resetPackagedRuntimeState wipes launcher/runtime/daemon state
before each lifecycle test (uninstall keeps runtime data by design, and
leftover pointers silently delegate fresh installs onto stale payloads);
mac waitForHealthyDesktopVersion gains win's requireSettledLauncher opt-out
for rollback waits.

docs/testing/updater-lifecycle.md is the new lifecycle-to-test coverage map
(incl. deliberate manual-only nodes), linked from tools/pack/AGENTS.md and
the e2e coverage status doc.

* fix(updater): harden Windows full-lifecycle acceptance

* test(release): make channel-prepare hermetic against local git tags

prepare-preview derives its latest-stable floor from the clone's real
open-design-v* tags, so the channel-prepare tests were green on tagless CI
checkouts but permanently red on any developer clone once shipped stable
tags advanced past main's package version (0.15.1 vs v0.15.1/v0.16.0 today).
The spawned scripts now run with GIT_DIR pointing at a throwaway repo
holding exactly the tags each test declares, and a new negative case pins
the floor rejection itself (packaged version below a v99.0.0 stable tag).

* test(e2e): model delegated payload launch

Pass the updater's pre-armed payload pointer into the cross-runtime launcher simulation so it represents the spawned payload process instead of a later crash-recovery launch.

Generated-By: looper 0.11.0 (runner=fixer, agent=codex)

* test(web): wait for question form readiness

Wait for the asynchronously derived submit-disabled state before asserting it, removing the suite-order race observed by the Web workspace tests check.

Generated-By: looper 0.11.0 (runner=fixer, agent=codex)

* fix(updater): preserve recovery state across locks and restarts

Generated-By: looper 0.11.0 (runner=fixer, agent=codex)

---------

Co-authored-by: Looper <looper@noreply.github.com>
This commit is contained in:
PerishFire
2026-07-24 05:18:48 +00:00
committed by GitHub
co-authored by Looper
parent 14fcc411cd
commit a2ba3ee2ad
81 changed files with 3855 additions and 144 deletions
+3 -1
View File
@@ -58,6 +58,8 @@ The runtime updater reads `https://releases.open-design.ai/<channel>/latest/meta
- The artifact must have a checksum, preferably `sha256Url`; the updater verifies bytes before exposing an install action.
- `OD_UPDATE_CURRENT_VERSION` may override the packaged version for tests, but user-flow package validation should prefer building the package with the intended `--app-version`.
- Release metadata may include `releaseNote.content`, with a `defaultLocale` and locale descriptors containing `url`, `mediaType`, `sha256`, and `size`. The updater does not currently consume this block; `tools-release` owns its publication and verification independently from updater UI behavior.
- Release metadata may include `control.launcher.version.{min, url}` — the installer-reinstall floor. The updater compares `min` against the **physically installed outer package version** (read at check time from the outer bundle's `open-design-config.json` via the launcher launch path; `OD_UPDATE_INSTALLED_VERSION` overrides it for tests), NOT the running payload version — payload updates never touch the outer bundle, so a broken outer generation must reach the installer path even when its payload is current. When the floor trips (or `min` is set but the outer version is unreadable — conservative), the updater selects the installer artifact and, when no newer release exists, offers a same-version installer reinstall; snapshot field `reinstall` carries `{reason, installedVersion, minVersion, url}` and the web UI presents the optional operator `url` as a jump link with default i18n copy as fallback. Publication: channel policy is managed as one repo-vars pair per channel — `RELEASE_LAUNCHER_VERSION_MIN_<CHANNEL>` + `RELEASE_LAUNCHER_VERSION_MIN_URL_<CHANNEL>` — passed through workflows verbatim (no YAML fallback expressions) and resolved by the shared resolver in `tools/release/src/storage/launcher-version-floor.ts`: a non-stable channel whose own pair is unset falls back to the STABLE pair as a unit, and format/https/floor validation is applied at that single point. `publish-metadata` hard-fails when `min` exceeds the release version — a floor this release cannot satisfy would make the same-version reinstall offer nag forever. `verify-metadata` re-resolves the same channel policy and checks the published block against it; `summary-metadata` surfaces the floor in the step summary.
- The updater exposes a manual disaster-recovery `clear-cache` action (`od:update:clear-cache` IPC, sidecar action `clear-cache`, Settings → About "Clear update cache" row with a two-stage inline confirm). It resets one-shot update state (downloaded release, install freeze) back to `idle`, purges `releases/`, `staging/`, `downloads/`, and `.back/`, removes a stale launcher `attempt.json` plus any non-`confirmed` desktop-handoff journal, and deletes non-retained launcher payload versions. Runtime `active`/`lastSuccessful` versions, explicit `retained` cleanup entries, `install.json`, and a `confirmed` handoff journal are never touched; locked files defer through the existing cleanup.json retry machinery. An installer helper already spawned by a prior install action is not cancelled.
- Release-note source lives at `docs/CHANGELOG/v<full-releaseVersion>/<locale>.md`. All channels use the same publication pipeline, while stable additionally requires `en` and `zh-CN` before platform builds proceed.
- Post-update "what's new" highlights are NOT carried in release `metadata.json`. The daemon's `/api/whats-new` fetches a single hand-curated document on a dedicated R2 bucket (`https://whatsnew.open-design.ai/whats-new.json`, overridable with `OD_WHATS_NEW_URL`); the web home surface shows a one-time card driven by that document's `id`, not the running version. Operators edit that one file after a release — there is no per-version publish tooling.
@@ -161,7 +163,7 @@ pnpm tools-pack win cleanup --dir C:\odtp-beta-release-fixed --namespace release
### Validation matrix for updater changes
Run the narrow tests that match the surface you touched, then the repo checks:
`docs/testing/updater-lifecycle.md` is the full lifecycle-to-test coverage map (including deliberate manual-only nodes); consult it to find the owning tests for the node you touched, then run the narrow tests plus the repo checks:
```bash
pnpm --filter @open-design/desktop test -- tests/main/updater.test.ts tests/main/updater-host-boundary.test.ts tests/main/preload-host-boundary.test.ts
+1 -1
View File
@@ -73,7 +73,7 @@ function addSharedOptions(command: CacCommand) {
.option("--path <path>", "desktop inspect screenshot path")
.option("--status-poll-count <count>", "inspect: poll desktop/daemon/web STATUS this many times")
.option("--status-poll-interval-ms <ms>", "inspect: delay between STATUS poll samples")
.option("--update-action <action>", "desktop update action: status|check|download|install");
.option("--update-action <action>", "desktop update action: status|check|clear-cache|download|install");
}
// Per-platform `--to` help text mirroring resolveToolPackBuildOutput in
+5 -3
View File
@@ -9,9 +9,11 @@ import {
SIDECAR_MESSAGES,
SIDECAR_MODES,
SIDECAR_SOURCES,
isDesktopUpdateAction,
type DesktopEvalResult,
type DesktopScreenshotResult,
type DesktopStatusSnapshot,
type DesktopUpdateAction,
type DesktopUpdateResult,
type SidecarStamp,
} from "@open-design/sidecar-proto";
@@ -691,10 +693,10 @@ export async function readPackedMacLogs(config: ToolPackConfig) {
};
}
function resolveUpdateAction(value: string | undefined): "status" | "check" | "download" | "install" | null {
function resolveUpdateAction(value: string | undefined): DesktopUpdateAction | null {
if (value == null) return null;
if (value === "status" || value === "check" || value === "download" || value === "install") return value;
throw new Error("--update-action must be status, check, download, or install");
if (isDesktopUpdateAction(value)) return value;
throw new Error("--update-action must be status, check, clear-cache, download, or install");
}
export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string; updateAction?: string }): Promise<MacInspectResult> {
+6 -4
View File
@@ -7,10 +7,12 @@ import {
SIDECAR_MESSAGES,
SIDECAR_MODES,
SIDECAR_SOURCES,
isDesktopUpdateAction,
type DaemonStatusSnapshot,
type DesktopEvalResult,
type DesktopScreenshotResult,
type DesktopStatusSnapshot,
type DesktopUpdateAction,
type DesktopUpdateResult,
type SidecarStamp,
type WebStatusSnapshot,
@@ -180,7 +182,7 @@ export async function installPackedWinApp(config: ToolPackConfig): Promise<WinIn
} else {
await measureLifecycleStep(lifecycleTimings, "pre-install remove install dir", async () => removeTree(registeredPaths.installDir));
}
await measureLifecycleStep(lifecycleTimings, "ensure install parent", async () => mkdir(dirname(paths.installDir), { recursive: true }));
await measureLifecycleStep(lifecycleTimings, "ensure install directory", async () => mkdir(paths.installDir, { recursive: true }));
await measureLifecycleStep(lifecycleTimings, "nsis install", async () => runTimed(paths.installTimingPath, "install", async () => {
await invokeNsis(paths, paths.setupPath, installArgs(config, paths), "install");
}));
@@ -475,10 +477,10 @@ export async function resetPackedWinNamespaces(config: ToolPackConfig): Promise<
return { namespaces, results };
}
function resolveUpdateAction(value: string | undefined): "status" | "check" | "download" | "install" | null {
function resolveUpdateAction(value: string | undefined): DesktopUpdateAction | null {
if (value == null) return null;
if (value === "status" || value === "check" || value === "download" || value === "install") return value;
throw new Error("--update-action must be status, check, download, or install");
if (isDesktopUpdateAction(value)) return value;
throw new Error("--update-action must be status, check, clear-cache, download, or install");
}
async function requestDesktopEval(
@@ -374,4 +374,42 @@ describe("release workflows", () => {
expect(stablePrepare).toContain('setOutput("run_prepublish_jobs"');
expect(stablePrepare).toContain('setOutput("publish_side_effects_enabled"');
});
it("passes launcher version floor repo vars through to metadata publish and verify verbatim", async () => {
const [beta, betaSelfHosted, preview, prerelease, stable] = await Promise.all([
readFile(new URL("../../../.github/workflows/release-beta.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-beta-s.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-preview.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"),
readFile(new URL("../../../.github/workflows/release-stable.yml", import.meta.url), "utf8"),
]);
const passthrough = (suffix: string): string[] => [
`RELEASE_LAUNCHER_VERSION_MIN_${suffix}: \${{ vars.RELEASE_LAUNCHER_VERSION_MIN_${suffix} }}`,
`RELEASE_LAUNCHER_VERSION_MIN_URL_${suffix}: \${{ vars.RELEASE_LAUNCHER_VERSION_MIN_URL_${suffix} }}`,
];
// Each channel workflow forwards its own repo-vars pair plus the STABLE
// fallback pair verbatim; channel policy (pair-level stable fallback,
// format/https/floor validation) lives only in
// tools/release/src/storage/launcher-version-floor.ts, never in YAML.
const lanes: Array<{ minSteps: number; suffix: string; workflow: string }> = [
{ minSteps: 2, suffix: "BETA", workflow: beta },
{ minSteps: 1, suffix: "BETAS", workflow: betaSelfHosted },
{ minSteps: 2, suffix: "PREVIEW", workflow: preview },
{ minSteps: 2, suffix: "PRERELEASE", workflow: prerelease },
];
for (const lane of lanes) {
for (const key of [...passthrough(lane.suffix), ...passthrough("STABLE")]) {
// publish-metadata always carries the pair; lanes with a
// verify-metadata step must carry it there too.
expect(countOccurrences(lane.workflow, key)).toBeGreaterThanOrEqual(lane.minSteps);
}
expect(lane.workflow).not.toContain(`vars.RELEASE_LAUNCHER_VERSION_MIN_${lane.suffix} ||`);
}
for (const key of passthrough("STABLE")) {
expect(countOccurrences(stable, key)).toBeGreaterThanOrEqual(2);
}
expect(stable).not.toContain("vars.RELEASE_LAUNCHER_VERSION_MIN_STABLE ||");
});
});
+54 -2
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -16,6 +16,13 @@ const matchesStampedProcess = vi.hoisted(() =>
);
const spawnBackgroundProcess = vi.hoisted(() => vi.fn(async () => ({ pid: 12345 })));
const stopProcesses = vi.hoisted(() => vi.fn(async () => undefined));
const invokeNsis = vi.hoisted(() => vi.fn<typeof import("../src/win/nsis.js").invokeNsis>());
const queryWinRegistryEntries = vi.hoisted(() =>
vi.fn<typeof import("../src/win/registry.js").queryWinRegistryEntries>(async () => []),
);
const resolveWinRegisteredPaths = vi.hoisted(() =>
vi.fn<typeof import("../src/win/registry.js").resolveWinRegisteredPaths>(async (_config, paths) => paths),
);
vi.mock("@open-design/sidecar", async () => {
const actual = await vi.importActual<typeof import("@open-design/sidecar")>("@open-design/sidecar");
@@ -36,7 +43,26 @@ vi.mock("@open-design/platform", async () => {
};
});
const { diagnosePackedWinIpc, inspectPackedWinApp, stopPackedWinApp } = await import("../src/win/lifecycle.js");
vi.mock("../src/win/nsis.js", async () => {
const actual = await vi.importActual<typeof import("../src/win/nsis.js")>("../src/win/nsis.js");
return {
...actual,
invokeNsis,
};
});
vi.mock("../src/win/registry.js", async () => {
const actual = await vi.importActual<typeof import("../src/win/registry.js")>("../src/win/registry.js");
return {
...actual,
queryWinRegistryEntries,
resolveWinRegisteredPaths,
};
});
const { diagnosePackedWinIpc, inspectPackedWinApp, installPackedWinApp, stopPackedWinApp } = await import(
"../src/win/lifecycle.js"
);
const { resolveWinPaths } = await import("../src/win/paths.js");
function createConfig(root: string): ToolPackConfig {
@@ -83,6 +109,32 @@ async function writeFakeUnpackedExe(config: ToolPackConfig): Promise<void> {
await writeFile(paths.unpackedExePath, "", "utf8");
}
describe("installPackedWinApp", () => {
it("creates the exact fresh install directory before invoking transactional NSIS", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-"));
const config = createConfig(root);
const paths = resolveWinPaths(config);
try {
await mkdir(dirname(paths.setupPath), { recursive: true });
await writeFile(paths.setupPath, "", "utf8");
invokeNsis.mockReset();
invokeNsis.mockImplementation(async () => {
await expect(access(paths.installDir)).resolves.toBeUndefined();
await writeFile(paths.installedExePath, "", "utf8");
});
const result = await installPackedWinApp(config);
expect(result.installDir).toBe(paths.installDir);
expect(result.lifecycleTimings.map(({ step }) => step)).toContain("ensure install directory");
expect(invokeNsis).toHaveBeenCalledOnce();
} finally {
await rm(root, { force: true, recursive: true });
}
});
});
describe("inspectPackedWinApp", () => {
it("returns status and diagnostics when eval IPC times out", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-"));