From c35d8a9636da7a2765274c7248340a1d59d4fa50 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 20:53:46 -0700 Subject: [PATCH 01/21] feat(updates): switch the whole fleet between Stable and Nightly Co-Authored-By: Claude Opus 5.5 --- .../src/settings/DesktopAppSettings.ts | 4 +- .../src/updates/DesktopRemoteUpdates.test.ts | 164 ++++++++++++---- .../src/updates/DesktopRemoteUpdates.ts | 174 +++++++++++++---- .../src/updates/DesktopUpdates.test.ts | 59 ++++++ apps/desktop/src/updates/DesktopUpdates.ts | 51 ++++- apps/desktop/src/updates/releaseNotes.ts | 4 +- apps/desktop/src/updates/updateChannels.ts | 7 - .../desktop/src/updates/updatesTestHarness.ts | 8 +- apps/server/src/cloud/selfUpdate.ts | 7 +- .../server/src/cloud/servicePreflight.test.ts | 36 ++++ apps/server/src/cloud/servicePreflight.ts | 39 ++++ .../src/desktopUpdate/DesktopAppUpdate.ts | 5 +- .../src/environment/ServerEnvironment.ts | 2 +- .../server/src/persistence/Migrations.test.ts | 20 ++ .../DesktopTelemetryReceiver.ts | 11 +- .../web/src/components/ServerUpdateAction.tsx | 8 +- .../components/settings/SettingsPanels.tsx | 98 ++++++---- .../settings/UpdateTrackSwitchDialog.tsx | 179 ++++++++++++++++++ .../settings/updateTrackSwitch.logic.test.ts | 100 ++++++++++ .../settings/updateTrackSwitch.logic.ts | 67 +++++++ .../components/sidebar/SidebarUpdatePill.tsx | 4 +- .../sidebar/UpdateEverythingDialog.tsx | 32 +++- .../sidebar/updateEverything.logic.test.ts | 127 ++++++++++++- .../sidebar/updateEverything.logic.ts | 168 ++++++++++++++-- .../components/sidebar/useUpdateEverything.ts | 53 ++++-- apps/web/src/state/npmRelease.ts | 57 ++++-- docs/user/updating.md | 21 ++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/resourceTelemetry.ts | 5 +- packages/contracts/src/server.ts | 3 + packages/shared/src/cliRelease.ts | 19 ++ scripts/build-npm-launcher.test.ts | 3 + scripts/build-npm-launcher.ts | 23 ++- 33 files changed, 1376 insertions(+), 184 deletions(-) create mode 100644 apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx create mode 100644 apps/web/src/components/settings/updateTrackSwitch.logic.test.ts create mode 100644 apps/web/src/components/settings/updateTrackSwitch.logic.ts diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 5767057b4a35d..a0624ced669ea 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -22,7 +22,7 @@ import { normalizeLinuxPasswordStorePreference, type LinuxPasswordStorePreference, } from "../linuxSecretStorage.ts"; -import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { @@ -180,7 +180,7 @@ export class DesktopAppSettings extends Context.Service< export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { return { ...DEFAULT_DESKTOP_SETTINGS, - updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + updateChannel: desktopUpdateChannelOf(appVersion), }; } diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 253a015d8ec9e..c02346113402f 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -119,8 +119,73 @@ describe("DesktopRemoteUpdates", () => { ); }); - it.effect("reports a failed outcome when quitAndInstall fails", () => { + it.effect("switches to the requested track before preparing its release", () => { + // In-memory settings keep the channel write off real disk I/O. + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-switch"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + assert.equal((yield* updates.getState).channel, "nightly"); + + harness.emit("update-available", { version: "1.2.4-nightly.20261027.1" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); + yield* settle; + + const terminals = terminalReports(reports); + assert.deepEqual( + terminals.map((report) => [report.requestId, report.outcome]), + [["req-switch", "ready-to-install"]], + ); + assert.equal(terminals[0]?.state.downloadedVersion, "1.2.4-nightly.20261027.1"); + }), + ); + }); + + it.effect( + "refuses a track switch whose feed offers another release and restores the track", + () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-mismatch"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + // The first mismatch triggers this request's own check; the refusal follows if it agrees. + harness.emit("update-available", { version: "1.2.4-nightly.20261028.2" }); + yield* settle; + assert.deepEqual(terminalReports(reports), []); + harness.emit("update-available", { version: "1.2.4-nightly.20261028.2" }); + yield* settle; + + const terminals = terminalReports(reports); + assert.deepEqual( + terminals.map((report) => report.outcome), + ["failed"], + ); + assert.include(terminals[0]?.reason ?? "", "found 1.2.4-nightly.20261028.2"); + assert.equal(harness.downloadCount(), 0); + assert.equal((yield* updates.getState).channel, "latest"); + }), + ); + }, + ); + + it.effect("reports a failed install and restores the track a switch left", () => { const harness = makeHarness({ + beforeSetUpdateChannel: Effect.void, quitAndInstall: Effect.fail( new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ channel: "latest", @@ -133,11 +198,17 @@ describe("DesktopRemoteUpdates", () => { return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => Effect.gen(function* () { - yield* Queue.offer(requests, request("req-4")); + const updates = yield* DesktopUpdates.DesktopUpdates; + const nightly = "1.2.4-nightly.20261027.1"; + yield* Queue.offer(requests, { + ...request("req-4"), + channel: "nightly", + targetVersion: nightly, + }); yield* settle; - harness.emit("update-available", { version: "1.2.4" }); + harness.emit("update-available", { version: nightly }); yield* settle; - harness.emit("update-downloaded", { version: "1.2.4" }); + harness.emit("update-downloaded", { version: nightly }); yield* settle; yield* Queue.offer(commits, { @@ -153,6 +224,9 @@ describe("DesktopRemoteUpdates", () => { ["ready-to-install", "failed"], ); assert.equal(terminals[1]?.state.errorContext, "install"); + yield* TestClock.adjust(Duration.seconds(2)); + yield* settle; + assert.equal((yield* updates.getState).channel, "latest"); }), ); }); @@ -570,41 +644,53 @@ describe("DesktopRemoteUpdates", () => { ); }); - it.effect("does not install after cancellation wins the commit claim", () => { - const harness = makeHarness(); - - return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) => - Effect.gen(function* () { - yield* Queue.offer(requests, request("req-cancel-before-commit")); - yield* settle; - harness.emit("update-available", { version: "1.2.4" }); - yield* settle; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* settle; - - yield* Queue.offer(cancellations, { - version: 1, - type: "cancelDesktopUpdate", - requestId: "req-cancel-before-commit", - }); - yield* settle; - yield* Queue.offer(commits, { - version: 1, - type: "commitDesktopUpdate", - requestId: "req-cancel-before-commit", - }); - yield* settle; - - assert.equal(harness.quitAndInstalls(), 0); - assert.deepEqual( - terminalReports(reports) - .filter((report) => report.requestId === "req-cancel-before-commit") - .map((report) => report.outcome), - ["ready-to-install", "failed"], - ); - }), - ); - }); + it.effect( + "does not install after cancellation wins the commit claim, and restores the track", + () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const nightly = "1.2.4-nightly.20261027.1"; + yield* Queue.offer(requests, { + ...request("req-cancel-before-commit"), + channel: "nightly", + targetVersion: nightly, + }); + yield* settle; + harness.emit("update-available", { version: nightly }); + yield* settle; + harness.emit("update-downloaded", { version: nightly }); + yield* settle; + + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-cancel-before-commit", + }); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-cancel-before-commit", + }); + yield* settle; + + assert.equal(harness.quitAndInstalls(), 0); + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-cancel-before-commit") + .map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + yield* TestClock.adjust(Duration.seconds(2)); + yield* settle; + assert.equal((yield* updates.getState).channel, "latest"); + }), + ); + }, + ); it.effect("retains an install failure after cancellation loses the commit claim", () => { const installStarted = Deferred.makeUnsafe(); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index cb606d8fbd253..71cf9a20c575f 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -1,5 +1,6 @@ import type { DesktopTelemetryRequestDesktopUpdate, + DesktopUpdateChannel, DesktopUpdateRemoteOutcome, DesktopUpdateState, } from "@t3tools/contracts"; @@ -7,9 +8,12 @@ import * as Duration from "effect/Duration"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -26,6 +30,8 @@ const { logInfo, logError } = DesktopObservability.makeComponentLogger("desktop- const ACTION_RETRY_DELAY = Duration.millis(250); const PREPARED_UPDATE_TTL = Duration.minutes(5); +const CHANNEL_SWITCH_RETRY_DELAY = Duration.seconds(1); +const CHANNEL_SWITCH_RETRIES = 30; interface PreparedUpdate { readonly requestId: string; @@ -33,10 +39,12 @@ interface PreparedUpdate { readonly status: "prepared" | "committing" | "failed"; readonly failureReason?: string; readonly expiresAt: number; + /** The track a switching request left; restored if this install fails. */ + readonly restoreChannel?: DesktopUpdateChannel; } type CommitClaim = - | { readonly _tag: "invalid" } + | { readonly _tag: "invalid"; readonly expired?: PreparedUpdate } | { readonly _tag: "failed"; readonly prepared: PreparedUpdate } | { readonly _tag: "committing" } | { readonly _tag: "claimed"; readonly prepared: PreparedUpdate }; @@ -57,6 +65,7 @@ export const listen: Effect.Effect< readonly cancelledBeforeStart: ReadonlyArray; }>({ active: Option.none(), cancelledBeforeStart: [] }); const preparedUpdateRef = yield* Ref.make(Option.none()); + const pendingRestoreRef = yield* Ref.make(Option.none>()); const publishReport = ( state: DesktopUpdateState, @@ -82,20 +91,52 @@ export const listen: Effect.Effect< ); }; - const recordPreparedFailure = (requestId: string, reason: string) => - Ref.modify(preparedUpdateRef, (prepared) => { + // Updater actions hold the channel briefly; wait them out instead of failing. + const setChannelWhenIdle = (channel: DesktopUpdateChannel) => + updates.setChannel(channel).pipe( + Effect.retry({ + while: (error) => error._tag === "DesktopUpdateActionInProgressError", + schedule: Schedule.spaced(CHANNEL_SWITCH_RETRY_DELAY), + times: CHANNEL_SWITCH_RETRIES, + }), + ); + + // A switch whose build never installs puts back the track it left. + const restoreTrack = (prepared: PreparedUpdate) => + prepared.restoreChannel === undefined + ? Effect.void + : setChannelWhenIdle(prepared.restoreChannel).pipe(Effect.ignore); + + // Restores run in the background (a failed install still holds the updater). The next request + // waits for the latest one, so a late restore can never overwrite that request's track. + const scheduleRestore = (prepared: PreparedUpdate) => + Effect.forkDetach(restoreTrack(prepared)).pipe( + Effect.flatMap((fiber) => Ref.set(pendingRestoreRef, Option.some(fiber))), + ); + + const failPrepared = Effect.fn("DesktopRemoteUpdates.failPrepared")(function* ( + requestId: string, + reason: string, + state: DesktopUpdateState, + ) { + const failed = yield* Ref.modify(preparedUpdateRef, (prepared) => { if ( Option.isNone(prepared) || prepared.value.requestId !== requestId || prepared.value.status === "failed" ) { - return [false, prepared] as const; + return [Option.none(), prepared] as const; } return [ - true, + prepared, Option.some({ ...prepared.value, status: "failed" as const, failureReason: reason }), ] as const; }); + if (Option.isNone(failed)) return; + // Scheduled before the failure is reported, so the next request already waits for it. + yield* scheduleRestore(failed.value); + yield* publishReport(state, { outcome: "failed", reason }, requestId); + }); const clearActiveRequest = (requestId: string) => Ref.update(activeRequestIdRef, (active) => @@ -116,9 +157,7 @@ export const listen: Effect.Effect< state.errorContext === "install" ) { const reason = state.message ?? "The desktop app failed to install the update."; - if (yield* recordPreparedFailure(prepared.value.requestId, reason)) { - yield* publishReport(state, { outcome: "failed", reason }, prepared.value.requestId); - } + yield* failPrepared(prepared.value.requestId, reason, state); } }), ); @@ -151,14 +190,18 @@ export const listen: Effect.Effect< }); if (!shouldStart) return; + const pendingRestore = yield* Ref.getAndSet(pendingRestoreRef, Option.none()); + if (Option.isSome(pendingRestore)) yield* Fiber.await(pendingRestore.value); const now = yield* Clock.currentTimeMillis; - const prepared = yield* Ref.modify(preparedUpdateRef, (current) => + const [expired, prepared] = yield* Ref.modify(preparedUpdateRef, (current) => Option.isSome(current) && current.value.status === "prepared" && current.value.expiresAt <= now - ? ([Option.none(), Option.none()] as const) - : ([current, current] as const), + ? ([[current, Option.none()], Option.none()] as const) + : ([[Option.none(), current], current] as const), ); + // Restore before this request picks its own track, so the two cannot race. + if (Option.isSome(expired)) yield* restoreTrack(expired.value); if (Option.isSome(prepared) && prepared.value.status !== "failed") { yield* publishReport( yield* updates.getState, @@ -171,7 +214,32 @@ export const listen: Effect.Effect< return; } yield* Ref.set(activeRequestIdRef, Option.some(request.requestId)); - yield* logInfo("remote update requested", { requestId: request.requestId }); + yield* logInfo("remote update requested", { + requestId: request.requestId, + channel: request.channel, + }); + // Switch before subscribing so the step machine never sees the old track's offer. + const previousChannel = (yield* updates.getState).channel; + // Set once this request prepares its build; otherwise a track switch is undone. + let keepsTrack = request.channel === undefined; + if (request.channel !== undefined) { + const switched = yield* setChannelWhenIdle(request.channel).pipe( + Effect.as(true), + Effect.catch((error) => + updates.getState.pipe( + Effect.flatMap((state) => + publishReport( + state, + { outcome: "failed", reason: error.message }, + request.requestId, + ), + ), + Effect.as(false), + ), + ), + ); + if (!switched) return; + } const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; @@ -184,16 +252,39 @@ export const listen: Effect.Effect< Effect.forkScoped, ); + // Only a host crossing tracks needs the exact database-checked release, or an older one. + const crossing = (state: DesktopUpdateState) => + request.targetVersion !== undefined && + request.channel !== undefined && + desktopUpdateChannelOf(state.currentVersion) !== request.channel; const step = Effect.fn("DesktopRemoteUpdates.step")(function* ( state: DesktopUpdateState, ): Effect.fn.Return { - const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); + // A track switch was checked against one release; refuse any other before downloading, + // but only after this request's own check, since a cached offer can be stale. + const found = state.downloadedVersion ?? state.availableVersion; + const mismatched = crossing(state) && found !== null && found !== request.targetVersion; + if (mismatched && attempts.checks > 0 && state.status !== "checking") { + yield* publishReport( + state, + { + outcome: "failed", + reason: `The desktop app found ${found}, not ${request.targetVersion}. Try again in a few minutes.`, + }, + request.requestId, + ); + return true; + } + const next = mismatched + ? ({ action: attempts.checks === 0 ? "check" : "wait" } as const) + : nextRemoteDesktopUpdateStep(state, attempts, disabledReason); switch (next.action) { case "wait": return false; case "check": attempts = { ...attempts, checks: attempts.checks + 1 }; - yield* updates.check("remote-update").pipe( + // Only a guarded crossing may be offered an older release. + yield* updates.check("remote-update", { allowDowngrade: crossing(state) }).pipe( Effect.flatMap((result) => { if (result.checked) return Effect.void; attempts = { ...attempts, checks: attempts.checks - 1 }; @@ -240,27 +331,40 @@ export const listen: Effect.Effect< status: "prepared", expiresAt: (yield* Clock.currentTimeMillis) + Duration.toMillis(PREPARED_UPDATE_TTL), + ...(keepsTrack || previousChannel === request.channel + ? {} + : { restoreChannel: previousChannel }), }), ); + keepsTrack = true; yield* publishReport(state, { outcome: "ready-to-install" }, request.requestId); yield* logInfo("remote update prepared", { requestId: request.requestId }); return true; } - case "done": + case "done": { + // For a switch, "up to date" means the feed has no build of the checked release. + const done = + request.targetVersion !== undefined && next.outcome === "up-to-date" + ? { + outcome: "failed" as const, + reason: `The desktop app found no build of ${request.targetVersion}.`, + } + : next; yield* publishReport( state, { - outcome: next.outcome, - ...(next.reason === undefined ? {} : { reason: next.reason }), + outcome: done.outcome, + ...(done.reason === undefined ? {} : { reason: done.reason }), }, request.requestId, ); yield* logInfo("remote update finished", { requestId: request.requestId, - outcome: next.outcome, - reason: next.reason ?? null, + outcome: done.outcome, + reason: done.reason ?? null, }); return true; + } } }); @@ -275,8 +379,13 @@ export const listen: Effect.Effect< }), Deferred.await(cancellation), ); + // Restored after the scope closes, once this request's own check and download stop. + return !keepsTrack && previousChannel !== request.channel ? previousChannel : undefined; }), ).pipe( + Effect.flatMap((restore) => + restore === undefined ? Effect.void : setChannelWhenIdle(restore).pipe(Effect.ignore), + ), Effect.ensuring( Effect.all( [ @@ -330,11 +439,14 @@ export const listen: Effect.Effect< } const matchedPrepared = yield* Ref.modify(preparedUpdateRef, (prepared) => { if (Option.isNone(prepared) || prepared.value.requestId !== cancellation.requestId) { - return [false, prepared] as const; + return [Option.none(), prepared] as const; } - return [true, prepared.value.status === "prepared" ? Option.none() : prepared] as const; + return [prepared, prepared.value.status === "prepared" ? Option.none() : prepared] as const; }); - if (matchedPrepared) { + if (Option.isSome(matchedPrepared)) { + if (matchedPrepared.value.status === "prepared") { + yield* scheduleRestore(matchedPrepared.value); + } yield* Ref.update(requestControlRef, (control) => { return { ...control, @@ -354,13 +466,12 @@ export const listen: Effect.Effect< const claim = yield* Ref.modify( preparedUpdateRef, (prepared): readonly [CommitClaim, Option.Option] => { - if ( - Option.isNone(prepared) || - prepared.value.requestId !== commit.requestId || - (prepared.value.status === "prepared" && prepared.value.expiresAt <= now) - ) { + if (Option.isNone(prepared) || prepared.value.requestId !== commit.requestId) { return [{ _tag: "invalid" as const }, prepared] as const; } + if (prepared.value.status === "prepared" && prepared.value.expiresAt <= now) { + return [{ _tag: "invalid" as const, expired: prepared.value }, Option.none()] as const; + } if (prepared.value.status === "failed") { return [{ _tag: "failed" as const, prepared: prepared.value }, prepared] as const; } @@ -374,6 +485,7 @@ export const listen: Effect.Effect< }, ); if (claim._tag === "invalid") { + if (claim.expired !== undefined) yield* scheduleRestore(claim.expired); yield* publishReport( yield* updates.getState, { @@ -401,9 +513,7 @@ export const listen: Effect.Effect< yield* Ref.set(activeRequestIdRef, Option.some(commit.requestId)); if (current.downloadedVersion !== claim.prepared.downloadedVersion) { const reason = "This desktop update is no longer prepared."; - if (yield* recordPreparedFailure(commit.requestId, reason)) { - yield* publishReport(current, { outcome: "failed", reason }, commit.requestId); - } + yield* failPrepared(commit.requestId, reason, current); return; } const result = yield* updates.installPrepared(claim.prepared.downloadedVersion); @@ -420,9 +530,7 @@ export const listen: Effect.Effect< } if (!result.accepted || result.failed) { const reason = result.state.message ?? "The desktop app could not start the install."; - if (yield* recordPreparedFailure(commit.requestId, reason)) { - yield* publishReport(result.state, { outcome: "failed", reason }, commit.requestId); - } + yield* failPrepared(commit.requestId, reason, result.state); return; } // Installation kills this backend; only transport loss followed by the target version proves handoff. diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index d2f24143fc209..c83b536d86530 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -694,6 +694,65 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("ignores a finished download from a track it no longer follows", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + // A backed-out switch's download keeps reporting progress until it finishes. + harness.emit("download-progress", { percent: 40 }); + yield* flushCallbacks; + harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.downloadedVersion, null); + assert.notEqual(state.status, "downloading"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("offers older releases only to checks that leave the running build's track", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const before = harness.checkAllowedDowngrade.length; + yield* updates.setChannel("nightly"); + yield* updates.setChannel("latest"); + yield* updates.check("remote-update", { allowDowngrade: true }); + + // Back on its own Stable track the build never needs an older release. + assert.deepEqual(harness.checkAllowedDowngrade.slice(before), [true, false, true]); + assert.isFalse(harness.allowDowngrade()); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("re-checks a saved track that differs from the running build's track", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.setChannel("latest"); + const checks = harness.checkCount(); + + yield* updates.setChannel("latest"); + + assert.equal(harness.checkCount(), checks + 1); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("does not persist an unchanged update channel as a user preference", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aeda73f2bfad3..66dcfd0aa40d0 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -34,7 +34,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -182,7 +182,11 @@ export class DesktopUpdates extends Context.Service< readonly setChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; - readonly check: (reason: string) => Effect.Effect; + /** `allowDowngrade` is for a guarded track switch, which checked that older release. */ + readonly check: ( + reason: string, + options?: { readonly allowDowngrade?: boolean }, + ) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; readonly installPrepared: ( @@ -467,6 +471,7 @@ export const make = Effect.gen(function* () { const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( reason: string, actionReservation: "acquire" | "held" = "acquire", + allowDowngrade = false, ) { yield* Effect.annotateCurrentSpan({ reason }); if (yield* Ref.get(desktopState.quitting)) return false; @@ -488,7 +493,11 @@ export const make = Effect.gen(function* () { yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); yield* logUpdaterInfo("checking for updates", { reason }); + // The flag is shared updater state, so it flips only while this check holds the reservation. + const restoreDowngrade = yield* electronUpdater.allowDowngrade; + if (allowDowngrade) yield* electronUpdater.setAllowDowngrade(true); return yield* electronUpdater.checkForUpdates.pipe( + Effect.ensuring(electronUpdater.setAllowDowngrade(restoreDowngrade).pipe(Effect.ignore)), Effect.as(true), Effect.catchTags({ ElectronUpdaterCheckForUpdatesError: Effect.fn( @@ -849,7 +858,7 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { const state = yield* Ref.get(updateStateRef); - if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { + if (desktopUpdateChannelOf(info.version) !== state.channel) { yield* logUpdaterInfo("ignoring update that does not match selected channel", { version: info.version, channel: state.channel, @@ -986,6 +995,20 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { const state = yield* Ref.get(updateStateRef); + // A download can finish after its track switch was backed out; never offer that build. + if (desktopUpdateChannelOf(info.version) !== state.channel) { + yield* logUpdaterInfo("ignoring download that does not match selected channel", { + version: info.version, + channel: state.channel, + }); + // Its progress events left the updater "downloading", which would block every check. + if (state.status === "downloading") { + yield* setState( + reduceDesktopUpdateStateOnNoUpdate(state, yield* currentIsoTimestamp), + ); + } + return; + } yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); }), @@ -1104,7 +1127,11 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); - if (nextChannel === state.channel) { + // A build from another track re-checks, so retrying a track switch finds an older release. + if ( + nextChannel === state.channel && + nextChannel === desktopUpdateChannelOf(environment.appVersion) + ) { return state; } @@ -1124,15 +1151,19 @@ export const make = Effect.gen(function* () { } yield* applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = yield* electronUpdater.allowDowngrade; - yield* electronUpdater.setAllowDowngrade(true); - yield* checkForUpdates("channel-change", "held").pipe( - Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), + // Only leaving the running build's track can need an older release. + yield* checkForUpdates( + "channel-change", + "held", + nextChannel !== desktopUpdateChannelOf(environment.appVersion), ); return yield* Ref.get(updateStateRef); }).pipe(Effect.ensuring(finishUpdateAction("channel"))); }), - check: Effect.fn("desktop.updates.check")(function* (reason: string) { + check: Effect.fn("desktop.updates.check")(function* ( + reason: string, + options?: { readonly allowDowngrade?: boolean }, + ) { yield* Effect.annotateCurrentSpan({ reason }); if (!(yield* Ref.get(updaterConfiguredRef))) { return { @@ -1140,7 +1171,7 @@ export const make = Effect.gen(function* () { state: yield* Ref.get(updateStateRef), }; } - const checked = yield* checkForUpdates(reason); + const checked = yield* checkForUpdates(reason, "acquire", options?.allowDowngrade === true); return { checked, state: yield* Ref.get(updateStateRef), diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 69d710ff7b269..8698d8d499118 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,6 +1,6 @@ import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -134,7 +134,7 @@ export function normalizeDesktopUpdateReleaseNotes( : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) : [] - ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel); + ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { const { items, totalItems } = extractReleaseNoteItems(entry.note); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index d79a374a668a5..b07735ceb7a29 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,13 +1,6 @@ -import type { DesktopUpdateChannel } from "@t3tools/contracts"; - -const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; // Preview builds share nightly branding but ship without an update feed. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { return PRERELEASE_VERSION_PATTERN.test(version); } - -export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { - return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest"; -} diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 1d8c21a42dfe5..fcb1f196b274e 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -35,6 +35,7 @@ export interface UpdatesHarnessOptions { readonly platform?: NodeJS.Platform; readonly packageType?: string | undefined; readonly installRecord?: string | undefined; + readonly appVersion?: string; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -42,6 +43,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let quitAndInstallCount = 0; let downloadCount = 0; let allowDowngrade = false; + const checkAllowedDowngrade: boolean[] = []; let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); @@ -86,6 +88,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; + checkAllowedDowngrade.push(allowDowngrade); }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.sync(() => { downloadCount += 1; @@ -157,7 +160,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, platform: options.platform ?? "darwin", processArch: "x64", - appVersion: "1.2.3", + appVersion: options.appVersion ?? "1.2.3", appPath: "/repo", isPackaged: true, resourcesPath: "/missing/resources", @@ -279,6 +282,9 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { downloadCount: () => downloadCount, feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, fullChangelog: () => fullChangelog, + /** Whether each feed check could offer an older release, and the flag now. */ + checkAllowedDowngrade, + allowDowngrade: () => allowDowngrade, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index ab6a69fc50a83..f7c4e1a251dd2 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -124,7 +124,12 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) { if (capability === "desktop-managed") { if (desktopAppUpdate.available) { - return yield* desktopAppUpdate.run(reportProgress); + return yield* desktopAppUpdate.run( + reportProgress, + input.channel === undefined + ? undefined + : { channel: input.channel, targetVersion: input.targetVersion }, + ); } return yield* failWith( "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 18a5323cd590c..eaee52ace3c49 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -1,4 +1,12 @@ +import * as NodeSqlite from "node:sqlite"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { migrationManifest } from "../persistence/Migrations.ts"; import { runServicePreflight } from "./servicePreflight.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; @@ -20,3 +28,31 @@ it.each([1])("blocks legacy launcher protocol %i with exact recovery steps", (la expect(reason).toContain("t3 --version"); expect(reason).toContain("which -a t3"); }); + +it.layer(NodeServices.layer)("service preflight database", (it) => { + it.effect("blocks a database that a newer build migrated", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const databasePath = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-preflight-" }), + "state.sqlite", + ); + const db = new NodeSqlite.DatabaseSync(databasePath); + db.exec("CREATE TABLE effect_sql_migrations (migration_id INTEGER PRIMARY KEY, name TEXT)"); + const insert = db.prepare("INSERT INTO effect_sql_migrations VALUES (?, ?)"); + for (const [id, name] of migrationManifest) insert.run(id, name); + const preflight = () => + runServicePreflight({ databasePath, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }); + + expect(preflight().status).toBe("ready"); + + const newerId = Math.max(...migrationManifest.map(([id]) => id)) + 1; + insert.run(newerId, "FromNightly"); + db.close(); + const result = preflight(); + expect(result.status).toBe("blocked"); + expect(result.status === "blocked" ? result.reason : "").toContain(`${newerId}_FromNightly`); + }), + ); +}); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index 8865ce7b3d11f..19b5c86c5953f 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -1,4 +1,7 @@ +import * as NodeSqlite from "node:sqlite"; + import packageJson from "../../package.json" with { type: "json" }; +import { migrationManifest } from "../persistence/Migrations.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; export type ServicePreflightResult = @@ -34,9 +37,45 @@ export function runServicePreflight(input: { }; } + const newer = newerRecordedMigrations(input.databasePath); + if (newer.length > 0) { + return { + status: "blocked", + version, + reason: `This database has migrations that t3@${version} does not know: ${newer.join(", ")}. A newer build wrote them, and running t3@${version} on this database is not safe. Stay on the newer release, or wait for a release that includes these migrations.`, + }; + } + return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; } +/** Migrations a newer build recorded. Startup already handles renamed ids this build knows. */ +function newerRecordedMigrations(databasePath: string): ReadonlyArray { + let db: NodeSqlite.DatabaseSync; + try { + db = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + } catch { + return []; + } + try { + const hasTable = db + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations'", + ) + .get(); + if (hasTable === undefined) return []; + const latestKnownId = Math.max(...migrationManifest.map(([id]) => id)); + return db + .prepare( + "SELECT migration_id, name FROM effect_sql_migrations WHERE migration_id > ? ORDER BY migration_id", + ) + .all(latestKnownId) + .map((row) => `${String(row.migration_id)}_${String(row.name)}`); + } finally { + db.close(); + } +} + export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined { if (typeof value !== "object" || value === null) { return undefined; diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index d02c3108777dd..535e95cc22211 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -44,6 +44,7 @@ export class DesktopAppUpdate extends Context.Service< reportProgress: ( stage: ServerSelfUpdateProgressStage, ) => Effect.Effect, + trackSwitch?: DesktopTelemetryReceiver.DesktopTrackSwitch, ) => Effect.Effect; /** Success stops the server; returns only on installation failure or timeout. */ readonly commit: ( @@ -132,7 +133,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( }); const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")( - function* (reportProgress) { + function* (reportProgress, trackSwitch) { if (!available) { return yield* failWith( "This server was not started by the T3 Code desktop app, so it cannot drive a desktop update.", @@ -154,7 +155,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( // Subscribe before requesting to catch fast reports. const { changes } = yield* receiver.desktopUpdates; yield* receiver - .requestDesktopUpdate(requestId) + .requestDesktopUpdate(requestId, trackSwitch) .pipe( Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app on this machine.", error), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 90ae4519bed92..450680b0b0fe9 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -266,7 +266,7 @@ export const make = Effect.gen(function* () { ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { serverSelfUpdateProgress: true } : {}), - ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), + ...(desktopAppUpdate ? { desktopAppUpdate: true, desktopAppChannelSwitch: true } : {}), serverBrowser: true, peerLinks: true, mcpModeLimitHeader: true, diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 67f238b7484f5..54e4a711aa8a1 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -1,7 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; import { migrationManifest, runMigrations } from "./Migrations.ts"; @@ -123,3 +126,20 @@ describe("runMigrations", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); }); + +// The npm launcher reads the highest migration id from these file names. +it.effect("registers every migration file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(import.meta.dirname, "Migrations"); + const files = (yield* fs.readDirectory(dir)).flatMap((name) => { + const match = /^(\d+)_(\w+)\.ts$/.exec(name); + return match ? [[Number(match[1]), match[2]] as const] : []; + }); + assert.deepStrictEqual( + files.toSorted(([a], [b]) => a - b), + migrationManifest, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 770359b3e9307..70dc2e8d750ef 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -8,6 +8,7 @@ import { type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, type DesktopHostTelemetrySnapshot, DesktopTelemetryControlMessage, + type DesktopUpdateChannel, type DesktopUpdateStatusReport, type ResourceTelemetrySourceStatus, } from "@t3tools/contracts"; @@ -149,6 +150,12 @@ export interface DesktopTelemetryReceiverHealth { readonly lastError: Option.Option; } +/** Moves the desktop app to `channel` and installs only `targetVersion`. */ +export interface DesktopTrackSwitch { + readonly channel: DesktopUpdateChannel; + readonly targetVersion: string; +} + export class DesktopTelemetryReceiver extends Context.Service< DesktopTelemetryReceiver, { @@ -176,6 +183,7 @@ export class DesktopTelemetryReceiver extends Context.Service< ) => Effect.Effect; readonly requestDesktopUpdate: ( requestId: string, + trackSwitch?: DesktopTrackSwitch, ) => Effect.Effect; readonly commitDesktopUpdate: ( requestId: string, @@ -633,11 +641,12 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")( - function (requestId) { + function (requestId, trackSwitch) { return sendControlMessage({ version: 1, type: "requestDesktopUpdate", requestId, + ...trackSwitch, }); }, ), diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 3f7675cd96631..c50dc0b96fad6 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -7,6 +7,7 @@ import { import type { AsyncResult } from "effect/reactivity"; import { environmentSession } from "~/state/session"; import type { + DesktopUpdateChannel, EnvironmentId, ServerInstallation, ServerSelfUpdateCapability, @@ -52,6 +53,8 @@ export interface ServerUpdateTarget { readonly installation?: ServerInstallation | undefined; readonly desktopAppUpdate?: boolean; readonly targetVersion: string; + /** Set only by a track switch; the host's desktop app moves to this track. */ + readonly channel?: DesktopUpdateChannel; } type UpdateButtonProps = Pick, "variant" | "size" | "className"> & { @@ -79,7 +82,10 @@ export function useServerUpdate() { pendingUpdateEnvironmentIds.add(environmentId); const result = await updateServer({ environmentId, - input: { targetVersion }, + input: { + targetVersion, + ...(target.channel === undefined ? {} : { channel: target.channel }), + }, }); pendingUpdateEnvironmentIds.delete(environmentId); if (result._tag === "Failure") { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 0cb00b86ac352..0d6dc25dd4f98 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { CheckIcon } from "lucide-react"; @@ -20,6 +21,7 @@ import { type QuitConfirmationMode, SidebarProjectSortOrder, } from "@t3tools/contracts/settings"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { createModelSelection } from "@t3tools/shared/model"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; @@ -64,7 +66,7 @@ import { import { providerInstanceEntriesFor } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { useEnvironmentProviders } from "../../state/environments"; +import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; @@ -95,6 +97,13 @@ import { BackgroundActivitySettingsRow } from "./BackgroundActivitySettings"; import { AdvancedSettings } from "./AdvancedSettings"; import { PullRequestLinksOpenInAppSetting } from "./IntegrationsSettings"; import { StorageCleanupSections } from "./StorageSettings"; +import { UpdateTrackSwitchDialog } from "./UpdateTrackSwitchDialog"; +import { environmentIdsKey } from "~/state/environmentAtomList"; +import { environmentSessionStatesAtom } from "~/state/session"; +import { canUpdateServer } from "../ServerUpdateAction"; +import { useUpdateEverythingRun } from "../sidebar/updateEverythingRun"; +import { awaitsTrackSwitch, channelSwitchLabel } from "../sidebar/updateEverything.logic"; +import { isHostedByThisApp } from "../sidebar/useUpdateEverything"; const RESPONSE_STREAMING_MODE_LABELS: Record = { turn: "Wait for the full response", @@ -142,39 +151,38 @@ function AboutVersionTitle() { function AboutVersionSection() { const updateState = useDesktopUpdateState(); - const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); + const [switchChannel, setSwitchChannel] = useState(null); const [isUpdateActionPending, setIsUpdateActionPending] = useState(false); + const { environments } = useEnvironments(); + const sessionStates = useAtomValue( + environmentSessionStatesAtom( + environmentIdsKey(environments.map(({ environmentId }) => environmentId)), + ), + ); + // A second run would replace the first run's progress and local download. + const updateRun = useUpdateEverythingRun(); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; - - const handleUpdateChannelChange = (channel: DesktopUpdateChannel) => { - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.setUpdateChannel !== "function" || - channel === selectedUpdateChannel - ) { - return; - } - - setIsChangingUpdateChannel(true); - void bridge - .setUpdateChannel(channel) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not change update track", - description: error instanceof Error ? error.message : "Update track change failed.", - }), - ); - }) - .finally(() => { - setIsChangingUpdateChannel(false); - }); - }; + // A switch that failed part way leaves this app or a server on the other track. + const unswitchedMachines = + // With its updater off, a switch cannot move this app, so it is not counted. + (updateState?.enabled === false || desktopUpdateChannelOf(APP_VERSION) === selectedUpdateChannel + ? 0 + : 1) + + environments.filter( + (environment, index) => + environment.connection.phase === "connected" && + canUpdateServer(sessionStates[index]!) && + awaitsTrackSwitch( + { + serverConfig: environment.serverConfig, + hostedByThisApp: isHostedByThisApp(environment), + }, + selectedUpdateChannel, + ), + ).length; const handleButtonClick = async () => { const bridge = window.desktopBridge; @@ -308,23 +316,40 @@ function AboutVersionSection() { {hasDesktopBridge ? ( 0 ? ( + <> + {unswitchedMachines === 1 ? "1 machine is" : `${unswitchedMachines} machines are`}{" "} + not on {channelSwitchLabel(selectedUpdateChannel)} yet.{" "} + + + ) : ( + "Switches this app and every connected machine. Leaving Nightly is blocked until Stable supports its database changes." + ) + } control={