Skip to content

feat(ENG-849, ENG-671): in-app software updates (shell reinstall notice + check-for-updates) - #453

Open
pnewsam wants to merge 10 commits into
stagingfrom
paul/eng-849-surface-shell-installer-updates-in-app
Open

feat(ENG-849, ENG-671): in-app software updates (shell reinstall notice + check-for-updates)#453
pnewsam wants to merge 10 commits into
stagingfrom
paul/eng-849-surface-shell-installer-updates-in-app

Conversation

@pnewsam

@pnewsam pnewsam commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Description

Currently the hot/OTA updates flow is enabled for UI code at cowork/src/renderer and server code at cowork-server. However there is no mechanism for hot-updating the electron shell code at src/main. The only way for an end-user to update the electron shell code is to redownload manually.

This limitation is okay if we commit to keeping the electron shell thin, and updating it only rarely. But in practice there is still a lot of churn in the shell code. The proper long-term fix would be to introduce Electron autoUpdater which can provide auto-updates for the whole application, rather than just pieces of it here and there. That work is underway with this PR and this ticket, but as an interim fix we can make the user aware when there is a new shell version that needs to be downloaded. That is the purpose of this PR.

It includes two pieces:

  • Add a "Check for updates" action in Settings -> Updates so user can preempt the 4-hour poll, and get updates on demand
  • Rework the update banner that appears in the Sidebar to cover two cases:
    • UI/Server updates: Restart the app (restarting will automatically pull ui/server updates)
    • Shell updates: Redownload the app (updates shell code, as well as ui/server code due to restart)

If both "restart" and a "download" are both applicable, then the "download" banner takes precedence, because it will update both shell and ui/server.

Screenshots

New section
Screenshot 2026-07-28 at 3 36 58 PM

Demo of "Check for updates" flow
https://github.com/user-attachments/assets/23ea0085-a39b-4c40-b193-5d86b55b1e2c

Demo of "Download" banner flow
https://github.com/user-attachments/assets/d0fd0c76-0b1b-4bdd-849b-41805d510b5d

Demo of "Restart" banner flow
https://github.com/user-attachments/assets/832df5f0-7d4d-4316-b748-7e193d04468e

Testing

Tested the following behaviors manually, with Claude's guidance.

  • Shell reinstall notice — appears for a newer shellVersion (prod-only; fails closed otherwise), survives an OTA reload, dismissal is per-version.
  • Supersede — a pending shell reinstall hides the OTA "Restart" banner; dismissing it reveals the banner.
  • OTA apply (success) — served a real bundle with a matching sha256; Restart downloads → verifies → activates → reloads into the new UI.
  • New in this PR — download-then-reinstall guidance in the Settings card, and a "Update failed / Try again" retry so the sidebar no longer goes silent on a failed apply.

The Electron shell (src/main, preload, runtime, native deps) isn't covered by
OTA — it only updates via a manual reinstall — so all we can do is notice a
newer published shell and point the user at the installer. Detection only; no
download/install (that's ENG-850).

Behavior
- The boot/periodic poll compares the installed shell CalVer against a new
  `shellVersion` field in latest.json; if strictly newer it pushes a
  `shell-available` status over the existing UI_UPDATE_STATUS channel.
- Sidebar: a dismissible "New version available — Download" banner opening the
  installer URL; dismissal is per-version (localStorage) so it re-notifies when
  a newer shell ships.
- Settings → Updates: an "App update available" card (current → latest +
  Download), shown regardless of banner dismissal.
- Download targets the per-platform CloudFront installer (prod
  mindshub-cowork-latest.{pkg,exe}).

Two deliberate decisions
- Prod-only: the manifest is prod-only and a non-prod build must never be sent
  to a prod installer (ENG-676), so checkForShellUpdate self-gates to
  buildKindStrict() === 'prod'. shellDownloadUrl still maps stable → -staging
  (tested) so it stays correct if non-prod is ever enabled.
- Explicit shellVersion (not the manifest's UI-bundle `version`): publish-ui.yml
  emits it ONLY on the auto-release path (via a new shell_version input from
  release.yml), where a prod installer actually ships — so a UI-only re-publish
  can't fabricate a phantom reinstall notice. Absent shellVersion → no notice.

- update-logic.ts: UIManifest.shellVersion + parse, shellUpdateIsNewer,
  shellDownloadUrl (+ 11 unit tests).
- updater.ts: checkForShellUpdate() orchestrator + poll wiring.
- host.ts / global.d.ts: shell fields on the update-status payload.
- App.jsx / Sidebar.jsx / SettingsView.jsx: state, banner, and Settings card.
- publish-ui.yml / release.yml: emit shellVersion on the release path.
- docs/update-behavior.md: shell-notice section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pnewsam
pnewsam force-pushed the paul/eng-849-surface-shell-installer-updates-in-app branch from f86e718 to c46da81 Compare July 27, 2026 16:23
pnewsam and others added 2 commits July 27, 2026 10:24
The shell-available notice is pushed on the boot/periodic poll, but an OTA
auto-apply reloads the window and the re-mounted renderer loses that push —
and the next one is up to a poll interval (4h) away. So on exactly the boot
where UI/server also auto-update, the reinstall notice flashes and vanishes,
hiding it from the users most likely to be on a stale shell.

Cache the last shell status in main and let the renderer re-pull it on mount
via a new UI_SHELL_UPDATE_GET channel (host.getShellUpdate). The pull is race-
free (the renderer asks when its own effect runs) and degrades to null on web
or an older shell whose main lacks the handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the OTA update

When both a shell reinstall and an OTA (UI/server) update are pending, the
sidebar stacked two near-identical sage banners with different versions and
verbs (Install vs Download), which reads as a confusing double-ask. Reinstalling
ships a current UI/server bundle anyway, so the shell notice supersedes the OTA
one. Suppress the OTA banner while a shell reinstall is pending; dismissing the
shell notice (its prop goes null upstream) brings the OTA banner back for anyone
who can't reinstall now but could still apply the hot update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pnewsam

This comment was marked as resolved.

…ell-aware

Combines PR #449 (ENG-671) into this PR and integrates it with the shell notice
so the two update paths agree (previously #449's manual check reported only
UI + server, and could say "up to date" while a newer shell was available).

- checkForUpdates() now runs the shell check alongside UI/server; a shell-check
  failure is swallowed to "no update" so it can't sink the whole check.
- summarizeUpdateCheck folds the shell in: it counts toward updateAvailable (so
  "up to date" can't lie) but stays a distinct shellUpdateAvailable flag, since
  the shell is download-applied, not apply-in-place.
- Settings: one unified "Software updates" section replaces the two separate
  ones. UI/server show an "Update now" apply card; a pending shell shows a
  "Download update" card, sourced from the fresh check or the background poll
  (so it still surfaces on a plain Settings visit, ENG-849). handleDownloadShell
  Update takes an optional explicit URL for the check-sourced case.

update-logic.ts stays at 100%; added shell coverage to summarizeUpdateCheck and
updated the exact-match assertions for the new field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pnewsam pnewsam changed the title feat(ENG-849): surface shell (installer) updates as an in-app notice feat(ENG-849, ENG-671): in-app software updates (shell reinstall notice + check-for-updates) Jul 27, 2026
@pnewsam

This comment was marked as resolved.

pnewsam

This comment was marked as resolved.

…e release manifest

Codex review on #453 found two blocking renderer bugs and two release-workflow
gaps:

- host.ts: getShellUpdate was a named export but missing from the curated `host`
  object App.jsx imports, so host.getShellUpdate() threw a TypeError at mount
  before the .catch could run. Add it to the object; add a test that guards the
  curated surface (not just the named export).
- App.jsx: only the mobile SettingsView instance got shellUpdate /
  onDownloadShellUpdate; the desktop (two-column) instance didn't — so the
  Settings notice never showed on desktop and Download would call undefined.
  Pass both props to the desktop instance; cover the desktop download path with
  a render test.
- release.yml: publish-ui depended only on auto-release, racing build-deploy-prod
  — it could write shellVersion before (or without) the installer uploading.
  Depend on build-deploy-prod too, so the manifest lands only after the installer.
- publish-ui.yml: a UI-only publish (empty SHELL_VERSION) rewrote latest.json
  without shellVersion, erasing the last-published installer version and killing
  the reinstall notice. Carry the existing shellVersion forward when none is
  supplied.

typecheck + purity + full suite (616) green; verified the manifest preserve
logic across release / UI-only / first-publish / malformed-prior cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pnewsam

This comment was marked as resolved.

pnewsam and others added 2 commits July 27, 2026 13:58
…ly try/catch

Concision pass on the Settings "Software updates" section:
- The apply-available and shell-reinstall cards shared an identical sage-card
  container + body style; hoist to UPDATE_CARD_STYLE / UPDATE_CARD_BODY_STYLE so
  they can't drift.
- handleApplyUpdateNow had a try/catch whose branches both mapped the outcome +
  set the same two flags; normalize applyUpdate()'s throw to `false` via .catch
  so one mapping covers both the resolved-false and thrown cases.

Roughly line-neutral — the win is de-duplication, not line count. Behavior
unchanged; typecheck + purity + full suite (616) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… version" for the shell

The Settings check surfaced two look-alike cards that read as "two app versions
to manage", when the two are really different actions: UI/server updates apply
themselves by restarting the app, while only the shell is a genuine new version
the user downloads and reinstalls by hand.

Split the language to match the behavior (copy-only; detection and actions
unchanged):
- UI/server card → "Update ready" + "Restart the app to apply it" + "Restart
  now" (was "Update available" / "Update now"). The sidebar OTA banner chip
  likewise becomes "Update ready" / "Restart" (was "Update available" / "Install").
- Shell card keeps the "New app version X" + "Download update" / reinstall
  wording — the one place version-management language belongs.
- Section subtitle now states the model: UI/server apply automatically on
  restart; only a new app version is downloaded and reinstalled by hand.

Interim step toward ENG-850 (shell self-update), which removes the distinction
entirely. Tests updated + a new one locks the restart framing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pnewsam

pnewsam commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Preserving Claude's PR description here for reference:

Description

  1. Shell reinstall notice (ENG-849) — the Electron shell (src/main, preload, runtime, native deps) isn't covered by OTA and only updates via reinstall, yet today nothing signals a newer shell exists. That strands users on old shells now that UI OTA is prod-only (ENG-670). This detects a newer installer and surfaces a Download notice (no auto-install; that's ENG-850).
  2. "Check for updates" control (ENG-671) — an on-demand check in Settings, since updates otherwise only apply at boot or on the 4h poll.

How this relates to the existing OTA updates

The crux for review: shell and UI/server OTA are two independent mechanisms sharing one 4h poll and one status channel (UI_UPDATE_STATUS). UI/server are hot updates that auto-apply silently at boot (banner only on the periodic poll); the shell is detection-only and never auto-applied. Three decisions keep them from colliding:

  • One banner, not two. A pending shell reinstall supersedes the OTA "Install" banner (reinstalling ships a current UI/server bundle anyway). Dismissing it restores the OTA banner.
  • The notice survives an OTA reload. An OTA auto-apply reloads the window and drops the shell-available push, so main caches it and the renderer re-pulls on mount via a new UI_SHELL_UPDATE_GET channel.
  • The manual check reports all three. It folds the shell into updateAvailable so it can't say "up to date" while the app is behind. UI/server get in-place Update now; the shell gets Download update. One unified Settings → Updates section presents both.

Two deliberate design decisions (shell)

  1. Prod-only. A non-prod build must never be pointed at a prod installer (ENG-676), so checkForShellUpdate self-gates to buildKindStrict() === 'prod'. Fails closed everywhere (unknown kind, no manifest, non-CalVer versions).
  2. Explicit shellVersion, not the manifest version (which is the UI-bundle version). publish-ui.yml emits it only on the auto-release path where an installer actually ships, so a UI-only publish can't fabricate a phantom notice; absent shellVersion → no notice.

Type of change

  • ⚡ New feature (non-breaking; the new IPC channel is additive)
  • 📄 Documentation update (docs/update-behavior.md)

Verification

  • typecheck · check:cowork-purity · both workflow YAMLs parse
  • test:coverage616 passed, floors met (update-logic.ts at 100%, incl. shell coverage)
  • ✅ Manifest logic exercised standalone: a UI-only publish preserves the last real installer version instead of erasing it
  • Pending: interactive smoke + screenshots in a packaged prod build carrying shellVersion (none published yet; exercisable via the COWORK_OTA_MANIFEST_URL fixture seam)

Notes for reviewers

Fixes ENG-849. Closes ENG-671.

🤖 Generated with [Claude Code](https://claude.com/claude-code

pnewsam and others added 2 commits July 28, 2026 16:50
Manual QA of the update banners surfaced that the sidebar "Restart" apply path
went silent on failure: handleApplyUpdate's catch set the update status to
phase 'error', but nothing in the renderer renders that phase — so a failed
apply (network drop mid-download, corrupt bundle, SHA mismatch) just made the
banner vanish until the next poll (up to 4h). The Settings "Software updates"
apply path already surfaced "Couldn't apply / Try again"; the sidebar did not.

Preserve the version on the error status and render an "Update failed / Try
again" banner in the sidebar that re-triggers the apply, mirroring Settings. A
pending shell reinstall supersedes it, same as the "Update ready" banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shell-reinstall notice handed the user an installer download with no
next-step guidance: the Settings card only said "the app updates by
downloading and reinstalling" and the Download button gave no hint that the
app must be quit and the installer opened by hand. Manual QA hit exactly this
"now what?" gap once the file landed in Downloads.

- Settings subtitle now states the sequence ("Download the installer, then
  quit MindsHub Cowork and open it to finish updating"); button reads
  "Download installer" so it's clearly a file, not an in-app apply.
- After the click, the card flips to "Installer downloading — when it's done,
  quit ... and open the installer" with a de-emphasized "Download again"
  retry. Keyed by version so a newer notice later in the session starts fresh.
- Sidebar banner tooltip extended with the quit-and-open step.

Copy + one small click-state only; detection/gating unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@ZoranPandovski ZoranPandovski left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I've added one comment

return null;
}

export async function checkForUpdates(): Promise<UpdateCheckSummary> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

in host.checkForUpdates(), if the reply has no ok field, assume it came from an old shell and translate it to the new format instead of passing it through:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants