diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index b22126090..27c361fa6 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -43,11 +43,13 @@ export const downloadUpdate = DesktopIpc.makeIpcMethod({ export const installUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_INSTALL_CHANNEL, - payload: Schema.Void, + payload: Schema.Union([Schema.Void, Schema.Struct({ expectedVersion: Schema.String })]), result: DesktopUpdateActionResultSchema, - handler: Effect.fn("desktop.ipc.updates.install")(function* () { + handler: Effect.fn("desktop.ipc.updates.install")(function* (input) { const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.install; + return yield* input === undefined + ? updates.install + : updates.installPrepared(input.expectedVersion, { waitForCheck: false }); }), }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 453879d37..07df4861a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -230,7 +230,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), - installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + installUpdate: (input) => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL, input), onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 509778521..ee095db7b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -656,6 +656,34 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("refuses an automatic install immediately while a check is active", () => + Effect.gen(function* () { + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const harness = makeHarness({ + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCheck)), + ), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + const checking = yield* updates.check("test").pipe(Effect.forkChild); + yield* Deferred.await(checkStarted); + const result = yield* updates.installPrepared("1.2.4", { waitForCheck: false }); + assert.isFalse(result.accepted); + assert.equal(result.state.status, "checking"); + assert.equal(harness.quitAndInstalls(), 0); + yield* Deferred.succeed(releaseCheck, undefined); + yield* Fiber.join(checking); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + it.effect("rejects a prepared install when the downloaded version changed", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 51584e3c7..0095f5795 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -185,6 +185,7 @@ export class DesktopUpdates extends Context.Service< readonly install: Effect.Effect; readonly installPrepared: ( expectedVersion: string, + options?: { readonly waitForCheck: boolean }, ) => Effect.Effect; } >()("@t3tools/desktop/updates/DesktopUpdates") {} @@ -535,7 +536,7 @@ export const make = Effect.gen(function* () { ); }); - const installDownloadedUpdate = (expectedVersion?: string) => + const installDownloadedUpdate = (expectedVersion?: string, waitForCheck = true) => Effect.scoped( Effect.gen(function* () { const actionCompletions = yield* PubSub.subscribe(finishedUpdateActions); @@ -556,7 +557,9 @@ export const make = Effect.gen(function* () { return "refused" as const; } if (Option.isSome(activeAction)) { - return activeAction.value === "check" && expectedVersion !== undefined + return waitForCheck && + activeAction.value === "check" && + expectedVersion !== undefined ? ("wait-for-check" as const) : ("refused" as const); } @@ -638,7 +641,7 @@ export const make = Effect.gen(function* () { }), ).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); - const installWithExpectedVersion = (expectedVersion?: string) => + const installWithExpectedVersion = (expectedVersion?: string, waitForCheck = true) => Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) { return { @@ -648,7 +651,7 @@ export const make = Effect.gen(function* () { state: yield* Ref.get(updateStateRef), }; } - const result = yield* installDownloadedUpdate(expectedVersion); + const result = yield* installDownloadedUpdate(expectedVersion, waitForCheck); return { accepted: result.accepted, completed: result.completed, @@ -989,7 +992,8 @@ export const make = Effect.gen(function* () { install: installWithExpectedVersion().pipe( Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })), ), - installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion), + installPrepared: (expectedVersion, options) => + installWithExpectedVersion(expectedVersion, options?.waitForCheck ?? true), }); }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index ca55e4e95..d5ecb6329 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -28,6 +28,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.awaitRestartReady]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts index d36afbc1d..05bf180b5 100644 --- a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts @@ -28,7 +28,7 @@ export interface ProviderCommandReactorShape { /** * Resolves when the internal processing queue is empty and idle. - * Intended for test use to replace timing-sensitive sleeps. + * Used by restart readiness and tests to wait for processing to finish. */ readonly drain: Effect.Effect; } diff --git a/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts index b6fa2711b..3c2aa429b 100644 --- a/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts @@ -27,7 +27,7 @@ export interface ProviderRuntimeIngestionShape { /** * Resolves when the internal processing queue is empty and idle. - * Intended for test use to replace timing-sensitive sleeps. + * Used by restart readiness and tests to wait for processing to finish. */ readonly drain: Effect.Effect; } diff --git a/apps/server/src/orchestration/restartReadiness.test.ts b/apps/server/src/orchestration/restartReadiness.test.ts new file mode 100644 index 000000000..7e2cffda7 --- /dev/null +++ b/apps/server/src/orchestration/restartReadiness.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "@effect/vitest"; +import { + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { awaitRestartReady } from "./restartReadiness.ts"; + +const ready: OrchestrationThreadShell = { + id: ThreadId.make("thread"), + projectId: ProjectId.make("project"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + pullRequests: [], + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-09-14T00:00:00Z", + updatedAt: "2026-09-14T00:00:00Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +}; + +describe("restart readiness", () => { + it.effect("waits for message finalization and checkpoint capture despite an idle summary", () => + Effect.gen(function* () { + const ingestionEntered = yield* Deferred.make(); + const messagesPersisted = yield* Deferred.make(); + const checkpointEntered = yield* Deferred.make(); + const checkpointCaptured = yield* Deferred.make(); + const returned = yield* Deferred.make(); + const check = yield* awaitRestartReady({ + readThreads: Effect.succeed([ready]), + drainCommands: Effect.void, + drainIngestion: Deferred.succeed(ingestionEntered, undefined).pipe( + Effect.andThen(Deferred.await(messagesPersisted)), + ), + drainCheckpoints: Deferred.succeed(checkpointEntered, undefined).pipe( + Effect.andThen(Deferred.await(checkpointCaptured)), + ), + }).pipe( + Effect.tap(() => Deferred.succeed(returned, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(ingestionEntered); + expect(yield* Deferred.isDone(returned)).toBe(false); + expect(yield* Deferred.isDone(checkpointEntered)).toBe(false); + yield* Deferred.succeed(messagesPersisted, undefined); + yield* Deferred.await(checkpointEntered); + expect(yield* Deferred.isDone(returned)).toBe(false); + yield* Deferred.succeed(checkpointCaptured, undefined); + expect(yield* Fiber.join(check)).toBe(true); + }), + ); + it.effect("rechecks authoritative state after draining", () => + Effect.gen(function* () { + let reads = 0; + const result = yield* awaitRestartReady({ + readThreads: Effect.sync(() => [ + reads++ === 0 ? ready : { ...ready, backgroundLiveness: "working" as const }, + ]), + drainCommands: Effect.void, + drainIngestion: Effect.void, + drainCheckpoints: Effect.void, + }); + expect(result).toBe(false); + expect(reads).toBe(2); + }), + ); + it.effect("does not wait on workers while a thread still needs input", () => + Effect.gen(function* () { + const result = yield* awaitRestartReady({ + readThreads: Effect.succeed([{ ...ready, hasPendingUserInput: true }]), + drainCommands: Effect.die("must not drain"), + drainIngestion: Effect.die("must not drain"), + drainCheckpoints: Effect.die("must not drain"), + }); + expect(result).toBe(false); + }), + ); +}); diff --git a/apps/server/src/orchestration/restartReadiness.ts b/apps/server/src/orchestration/restartReadiness.ts new file mode 100644 index 000000000..9939eaf90 --- /dev/null +++ b/apps/server/src/orchestration/restartReadiness.ts @@ -0,0 +1,16 @@ +import { isThreadBlockingRestart, type OrchestrationThreadShell } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +/** Waits for persisted turn finalization, then checks authoritative thread state again. */ +export const awaitRestartReady = Effect.fn("orchestration.awaitRestartReady")(function* (input: { + readonly readThreads: Effect.Effect, E>; + readonly drainCommands: Effect.Effect; + readonly drainIngestion: Effect.Effect; + readonly drainCheckpoints: Effect.Effect; +}) { + if ((yield* input.readThreads).some(isThreadBlockingRestart)) return false; + yield* input.drainCommands; + yield* input.drainIngestion; + yield* input.drainCheckpoints; + return !(yield* input.readThreads).some(isThreadBlockingRestart); +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3eb40a1b9..b66e452c2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,3 +1,6 @@ +import { ProviderCommandReactor } from "./orchestration/Services/ProviderCommandReactor.ts"; +import { ProviderRuntimeIngestionService } from "./orchestration/Services/ProviderRuntimeIngestion.ts"; +import { CheckpointReactor } from "./orchestration/Services/CheckpointReactor.ts"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -972,6 +975,12 @@ const buildAppUnderTest = (options?: { latestSequence: Effect.succeed(0), ...options?.layers?.orchestrationEngine, }), + Layer.mock(ProviderCommandReactor)({ start: () => Effect.void, drain: Effect.void }), + Layer.mock(ProviderRuntimeIngestionService)({ + start: () => Effect.void, + drain: Effect.void, + }), + Layer.mock(CheckpointReactor)({ start: () => Effect.void, drain: Effect.void }), Layer.mock(ThreadDeletionReactor)({ start: () => Effect.void, drainThrough: () => Effect.void, @@ -8139,6 +8148,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes restart readiness through the authenticated websocket", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + const ready = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.awaitRestartReady]({})), + ); + assert.isTrue(ready); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration methods", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6a16640f7..16d53dda8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,7 @@ +import { awaitRestartReady } from "./orchestration/restartReadiness.ts"; +import { ProviderCommandReactor } from "./orchestration/Services/ProviderCommandReactor.ts"; +import { ProviderRuntimeIngestionService } from "./orchestration/Services/ProviderRuntimeIngestion.ts"; +import { CheckpointReactor } from "./orchestration/Services/CheckpointReactor.ts"; import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, @@ -1897,6 +1901,35 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.awaitRestartReady]: () => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.awaitRestartReady, + Effect.gen(function* () { + const commands = yield* ProviderCommandReactor; + const ingestion = yield* ProviderRuntimeIngestionService; + const checkpoints = yield* CheckpointReactor; + return yield* awaitRestartReady({ + readThreads: Effect.all([ + projectionSnapshotQuery.getShellSnapshot(), + projectionSnapshotQuery.getArchivedShellSnapshot(), + ]).pipe( + Effect.map(([active, archived]) => [...active.threads, ...archived.threads]), + ), + drainCommands: commands.drain, + drainIngestion: ingestion.drain, + drainCheckpoints: checkpoints.drain, + }); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to establish restart readiness", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: (_input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, diff --git a/apps/web/src/components/DesktopUpdateInstallCoordinator.tsx b/apps/web/src/components/DesktopUpdateInstallCoordinator.tsx new file mode 100644 index 000000000..81f49edc8 --- /dev/null +++ b/apps/web/src/components/DesktopUpdateInstallCoordinator.tsx @@ -0,0 +1,109 @@ +import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { useAtomCommand } from "../state/use-atom-command"; +import { orchestrationEnvironment } from "../state/orchestration"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { useContext, useEffect } from "react"; +import { environmentCatalog } from "../connection/catalog"; +import { isElectron } from "../env"; +import { useDesktopUpdateState } from "../state/desktopUpdate"; +import { createDesktopUpdateIdleAtom } from "../state/desktopUpdateIdle"; +import { desktopUpdateInstall, useDesktopUpdateInstallState } from "../state/desktopUpdateInstall"; +import { environmentShell } from "../state/shell"; +import { + AlertDialog, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Button } from "./ui/button"; +import { toastManager } from "./ui/toast"; + +const idleAtom = createDesktopUpdateIdleAtom({ + requiresPrimaryEnvironment: !isLocalEnvironmentDisabled(), + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, +}); + +function reportInstallError(description: string) { + toastManager.add({ type: "error", title: "Automatic restart cancelled", description }); +} + +function ScheduledRestart() { + const idle = useAtomValue(idleAtom); + const awaitReady = useAtomCommand(orchestrationEnvironment.awaitRestartReady, { + reportFailure: false, + reportDefect: false, + }); + const registry = useContext(RegistryContext); + const update = useDesktopUpdateState(); + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge || !update) return; + void desktopUpdateInstall.tryInstall( + bridge, + () => idle && registry.get(idleAtom), + reportInstallError, + async () => { + const ids = [...enabledEnvironmentIds(registry.get(environmentCatalog.catalogValueAtom))]; + const results = await Promise.all( + ids.map((environmentId) => awaitReady({ environmentId, input: {} })), + ); + if (results.some((result) => result._tag === "Failure")) { + throw new Error( + "Could not verify that all environments finished their work. Reconnect and schedule the restart again.", + ); + } + return results.every((result) => result._tag === "Success" && result.value); + }, + ); + }, [idle, registry, update, awaitReady]); + return null; +} + +export function DesktopUpdateInstallCoordinator() { + const update = useDesktopUpdateState(); + useEffect(() => { + if (update) desktopUpdateInstall.observeUpdate(update, reportInstallError); + }, [update]); + const state = useDesktopUpdateInstallState(); + useEffect(() => () => desktopUpdateInstall.cancel(), []); + if (!isElectron) return null; + return ( + <> + {state.status === "waiting" ? : null} + { + if (!open) desktopUpdateInstall.respond("cancel"); + }} + > + + + + Install update{state.status !== "idle" ? ` ${state.version}` : ""} and restart? + + + Restart now to interrupt running threads, or restart automatically once threads + finish. Automatic restart waits for live connections, pending approvals, and + background work to finish. You can cancel it before restarting. + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 81fd6047f..e01cad79e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,4 @@ +import { requestDesktopUpdateInstall } from "../state/desktopUpdateInstall"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { GitPullRequestIcon } from "lucide-react"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; @@ -110,7 +111,7 @@ import { } from "../keybindings"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { ensureLocalApi, readLocalApi } from "../localApi"; +import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; @@ -130,7 +131,6 @@ import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -3703,9 +3703,7 @@ export default function LegacySidebar() { if (desktopUpdateButtonAction === "install") { let confirmed = false; try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), - ); + confirmed = await requestDesktopUpdateInstall(desktopUpdateState); } catch (error) { setDesktopUpdateActionPending(false); toastManager.add( diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index fcc97825b..5dc865b09 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -6,7 +6,6 @@ import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, - getDesktopUpdateInstallConfirmationMessage, getDesktopUpdateReleaseHistoryUrl, getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, @@ -252,35 +251,6 @@ describe("desktop update UI helpers", () => { expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); }); - - it("includes the downloaded version in the install confirmation copy", () => { - expect( - getDesktopUpdateInstallConfirmationMessage({ - availableVersion: "1.1.0", - downloadedVersion: "1.1.1", - }), - ).toContain("Install update 1.1.1 and restart T3 Code?"); - }); - - it("falls back to generic install confirmation copy when no version is available", () => { - expect( - getDesktopUpdateInstallConfirmationMessage({ - availableVersion: null, - downloadedVersion: null, - }), - ).toContain("Install update and restart T3 Code?"); - }); - - it("keeps the same install confirmation copy across desktop platforms", () => { - expect( - getDesktopUpdateInstallConfirmationMessage({ - availableVersion: "1.1.0", - downloadedVersion: "1.1.0", - }), - ).toBe( - "Install update 1.1.0 and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.", - ); - }); }); describe("canCheckForUpdate", () => { diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 4a169cb3e..6af566362 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -97,13 +97,6 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string return "Up to date"; } -export function getDesktopUpdateInstallConfirmationMessage( - state: Pick, -): string { - const version = state.downloadedVersion ?? state.availableVersion; - return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`; -} - export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { if (!result.accepted || result.completed) return null; if (typeof result.state.message !== "string") return null; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6baa561a9..f1184fe30 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,3 +1,8 @@ +import { + desktopUpdateInstall, + requestDesktopUpdateInstall, + useDesktopUpdateInstallState, +} from "../../state/desktopUpdateInstall"; import { Spinner } from "~/components/ui/spinner"; import { NotificationSettings } from "./NotificationSettings"; import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; @@ -51,7 +56,6 @@ import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../.. import { canCheckForUpdate, getDesktopUpdateButtonTooltip, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; @@ -268,6 +272,9 @@ function AboutVersionTitle() { } function AboutVersionSection() { + const installState = useDesktopUpdateInstallState(); + const isWaiting = installState.status === "waiting"; + const isInstalling = installState.status === "installing"; const updateState = useDesktopUpdateState(); const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); const [isUpdateActionPending, setIsUpdateActionPending] = useState(false); @@ -307,6 +314,11 @@ function AboutVersionSection() { ); const handleButtonClick = useCallback(async () => { + if (isInstalling) return; + if (isWaiting) { + desktopUpdateInstall.cancel(); + return; + } const bridge = window.desktopBridge; if (!bridge) return; @@ -330,11 +342,7 @@ function AboutVersionSection() { setIsUpdateActionPending(true); let confirmed = false; try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage( - updateState ?? { availableVersion: null, downloadedVersion: null }, - ), - ); + confirmed = await requestDesktopUpdateInstall(updateState ?? { downloadedVersion: null }); } catch (error) { setIsUpdateActionPending(false); toastManager.add( @@ -389,14 +397,23 @@ function AboutVersionSection() { }), ); }); - }, [isUpdateActionPending, updateState]); + }, [isInstalling, isWaiting, isUpdateActionPending, updateState]); const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; - const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; - const buttonDisabled = - action === "none" - ? !canCheckForUpdate(updateState) - : isDesktopUpdateButtonDisabled(updateState); + const buttonTooltip = isWaiting + ? "Cancel the scheduled restart" + : isInstalling + ? "Restarting to install update…" + : updateState + ? getDesktopUpdateButtonTooltip(updateState) + : null; + const buttonDisabled = isInstalling + ? true + : isWaiting + ? false + : action === "none" + ? !canCheckForUpdate(updateState) + : isDesktopUpdateButtonDisabled(updateState); const actionLabel: Record = { download: "Download", install: "Install" }; const statusLabel: Record = { @@ -404,12 +421,18 @@ function AboutVersionSection() { downloading: "Downloading…", "up-to-date": "Up to Date", }; - const buttonLabel = - actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; - const description = - action === "download" || action === "install" - ? "Update available." - : "Current version of the application."; + const buttonLabel = isWaiting + ? "Cancel restart" + : isInstalling + ? "Restarting…" + : (actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"); + const description = isWaiting + ? "The update will install when threads finish. Keep this window open." + : isInstalling + ? "Restarting to install the update." + : action === "download" || action === "install" + ? "Update available." + : "Current version of the application."; return ( <> diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 833559e1c..e0cf29903 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -1,5 +1,5 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; +import { CheckIcon, ClockIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; import type { AnimationEventHandler } from "react"; const DOWNLOAD_PROGRESS_RADIUS = 14; @@ -10,7 +10,8 @@ export type DesktopUpdateStatusIconState = | "checking" | "available" | "downloading" - | "downloaded"; + | "downloaded" + | "scheduled"; function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { if (percent === null || !Number.isFinite(percent)) return 0; @@ -88,12 +89,16 @@ function DesktopUpdateDownloadingIcon({ percent }: { readonly percent: number | ); } -function DesktopUpdateDownloadedIcon() { +function DesktopUpdateDownloadedIcon({ scheduled = false }: { readonly scheduled?: boolean }) { return ( - + {scheduled ? ( + + ) : ( + + )} ); @@ -114,6 +119,7 @@ export function DesktopUpdateStatusIcon({ if (status === "downloading") { return ; } + if (status === "scheduled") return ; if (status === "downloaded") return ; return ( diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index a94b7801e..6ef1c0042 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,3 +1,8 @@ +import { + desktopUpdateInstall, + requestDesktopUpdateInstall, + useDesktopUpdateInstallState, +} from "../../state/desktopUpdateInstall"; import type { DesktopUpdateState } from "@t3tools/contracts"; import { TriangleAlertIcon } from "lucide-react"; import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react"; @@ -5,7 +10,6 @@ import { flushSync } from "react-dom"; import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; -import { ensureLocalApi } from "../../localApi"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { @@ -13,7 +17,6 @@ import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -114,6 +117,9 @@ export function SidebarUpdatePill() { function SidebarUpdateControl() { const state = useDesktopUpdateState(); + const installState = useDesktopUpdateInstallState(); + const isWaiting = installState.status === "waiting"; + const isInstalling = installState.status === "installing"; const [isActionPending, setIsActionPending] = useState(false); const [checkAnimationKey, setCheckAnimationKey] = useState(0); const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); @@ -143,23 +149,31 @@ function SidebarUpdateControl() { isDownloading, showCheckIcon, }); - const tooltip = showUpdateDetails - ? state - ? getDesktopUpdateButtonTooltip(state) - : "Update available" - : showCheckIcon - ? "Checking for updates…" - : "Check for updates"; - const disabled = showCheckIcon + const tooltip = isWaiting + ? `Update ${installState.version} will install when threads finish. Click to cancel restart.` + : isInstalling + ? "Restarting to install update…" + : showUpdateDetails + ? state + ? getDesktopUpdateButtonTooltip(state) + : "Update available" + : showCheckIcon + ? "Checking for updates…" + : "Check for updates"; + const disabled = isInstalling ? true - : showUpdateDetails - ? isDesktopUpdateButtonDisabled(state) - : !canCheckForUpdate(state); + : isWaiting + ? false + : showCheckIcon + ? true + : showUpdateDetails + ? isDesktopUpdateButtonDisabled(state) + : !canCheckForUpdate(state); const isInteractionDisabled = disabled || isActionPending; - const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover( - showUpdateDetails, - state, - ); + const showReleaseNotesPopover = + !isWaiting && + !isInstalling && + shouldUseSidebarUpdateReleaseNotesPopover(showUpdateDetails, state); useEffect(() => { if (!showReleaseNotesPopover) { @@ -178,6 +192,11 @@ function SidebarUpdateControl() { if (!bridge || !state) return; if (isInteractionDisabled) return; + if (isWaiting) { + desktopUpdateInstall.cancel(); + return; + } + setIsActionPending(true); if (action === "download") { @@ -214,9 +233,7 @@ function SidebarUpdateControl() { if (action === "install") { let confirmed = false; try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage(state), - ); + confirmed = await requestDesktopUpdateInstall(state); } catch (error) { setIsActionPending(false); toastManager.add( @@ -286,7 +303,7 @@ function SidebarUpdateControl() { ); }) .finally(() => setIsActionPending(false)); - }, [action, isInteractionDisabled, prefersReducedMotion, state]); + }, [action, isInteractionDisabled, isWaiting, prefersReducedMotion, state]); const handleCheckAnimationIteration = useCallback(() => { setIsCheckAnimationLatched( @@ -340,9 +357,9 @@ function SidebarUpdateControl() { ); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 89a1ff23c..0bf37c236 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { DesktopUpdateInstallCoordinator } from "../components/DesktopUpdateInstallCoordinator"; import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -221,6 +222,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? ( diff --git a/apps/web/src/state/desktopUpdateIdle.test.ts b/apps/web/src/state/desktopUpdateIdle.test.ts new file mode 100644 index 000000000..581a30734 --- /dev/null +++ b/apps/web/src/state/desktopUpdateIdle.test.ts @@ -0,0 +1,161 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { createDesktopUpdateIdleAtom } from "./desktopUpdateIdle"; +import { isThreadBlockingRestart } from "@t3tools/contracts"; + +const LOCAL = EnvironmentId.make("local"); +const REMOTE = EnvironmentId.make("remote"); + +function shellState(status: EnvironmentShellState["status"]): EnvironmentShellState { + return { + status, + snapshot: + status === "empty" + ? Option.none() + : Option.some({ + snapshotSequence: 1, + updatedAt: "2026-09-04T00:00:00.000Z", + projects: [], + threads: [], + }), + error: Option.none(), + }; +} + +function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCatalogState { + return { + isReady: true, + entries: new Map( + environmentIds.map((environmentId) => [ + environmentId, + { + target: + environmentId === LOCAL + ? new PrimaryConnectionTarget({ + environmentId, + label: environmentId, + httpBaseUrl: `https://${environmentId}.example.test`, + wsBaseUrl: `wss://${environmentId}.example.test`, + }) + : new BearerConnectionTarget({ + environmentId, + connectionId: environmentId, + label: environmentId, + }), + profile: Option.none(), + enabled: true, + }, + ]), + ), + }; +} + +describe("desktop restart readiness", () => { + it("requires live snapshots for primary and remote environments", () => { + const catalog = Atom.make({ isReady: false, entries: new Map() }); + const shells = Atom.family((_id: EnvironmentId) => + Atom.make(shellState("empty")), + ); + const idle = createDesktopUpdateIdleAtom({ + catalogValueAtom: catalog, + shellStateValueAtom: shells, + }); + const registry = AtomRegistry.make(); + expect(registry.get(idle)).toBe(false); + registry.set(catalog, catalogState([REMOTE])); + registry.set(shells(REMOTE), shellState("live")); + expect(registry.get(idle)).toBe(false); + registry.set(catalog, catalogState([LOCAL, REMOTE])); + registry.set(shells(LOCAL), shellState("live")); + expect(registry.get(idle)).toBe(true); + registry.set(shells(REMOTE), shellState("cached")); + expect(registry.get(idle)).toBe(false); + registry.set(shells(REMOTE), shellState("live")); + expect(registry.get(idle)).toBe(true); + registry.dispose(); + }); + it("allows remote-only desktop once its catalog and live snapshots are ready", () => { + const catalog = Atom.make({ isReady: false, entries: new Map() }); + const shells = Atom.family((_id: EnvironmentId) => + Atom.make(shellState("empty")), + ); + const idle = createDesktopUpdateIdleAtom({ + catalogValueAtom: catalog, + shellStateValueAtom: shells, + requiresPrimaryEnvironment: false, + }); + const registry = AtomRegistry.make(); + expect(registry.get(idle)).toBe(false); + registry.set(catalog, catalogState([REMOTE])); + expect(registry.get(idle)).toBe(false); + registry.set(shells(REMOTE), shellState("live")); + expect(registry.get(idle)).toBe(true); + registry.set(shells(REMOTE), shellState("cached")); + expect(registry.get(idle)).toBe(false); + registry.set(catalog, catalogState([])); + expect(registry.get(idle)).toBe(true); + registry.dispose(); + }); + const ready = { + session: null, + latestTurn: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + backgroundLiveness: null, + }; + it("allows settled threads and monitoring-only activity", () => { + expect(isThreadBlockingRestart(ready)).toBe(false); + expect(isThreadBlockingRestart({ ...ready, backgroundLiveness: "monitoring" })).toBe(false); + }); + it("waits for background work and pending requests", () => { + expect(isThreadBlockingRestart({ ...ready, backgroundLiveness: "working" })).toBe(true); + expect(isThreadBlockingRestart({ ...ready, hasPendingApprovals: true })).toBe(true); + expect(isThreadBlockingRestart({ ...ready, hasPendingUserInput: true })).toBe(true); + }); + it("waits for active sessions and turns even without pending requests", () => { + const session = { + threadId: ThreadId.make("thread"), + status: "ready" as const, + providerName: null, + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: "2026-09-14T00:00:00Z", + }; + expect(isThreadBlockingRestart({ ...ready, session })).toBe(false); + expect(isThreadBlockingRestart({ ...ready, session: { ...session, status: "starting" } })).toBe( + true, + ); + expect(isThreadBlockingRestart({ ...ready, session: { ...session, status: "running" } })).toBe( + true, + ); + expect( + isThreadBlockingRestart({ + ...ready, + session: { ...session, activeTurnId: TurnId.make("turn") }, + }), + ).toBe(true); + expect( + isThreadBlockingRestart({ + ...ready, + latestTurn: { + turnId: TurnId.make("turn"), + state: "running", + requestedAt: "2026-09-14T00:00:00Z", + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/state/desktopUpdateIdle.ts b/apps/web/src/state/desktopUpdateIdle.ts new file mode 100644 index 000000000..2ebd3617b --- /dev/null +++ b/apps/web/src/state/desktopUpdateIdle.ts @@ -0,0 +1,31 @@ +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { isThreadBlockingRestart, type EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +/** Derives a boolean from live summaries, without subscribing to message streams. */ +export function createDesktopUpdateIdleAtom(input: { + readonly requiresPrimaryEnvironment?: boolean; + readonly catalogValueAtom: Atom.Atom; + readonly shellStateValueAtom: (id: EnvironmentId) => Atom.Atom; +}) { + return Atom.make((get) => { + const catalog = get(input.catalogValueAtom); + if ( + !catalog.isReady || + (input.requiresPrimaryEnvironment !== false && + !Array.from(catalog.entries.values()).some( + (entry) => entry.enabled && entry.target._tag === "PrimaryConnectionTarget", + )) + ) + return false; + for (const id of enabledEnvironmentIds(catalog)) { + const shell = get(input.shellStateValueAtom(id)); + if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false; + if (shell.snapshot.value.threads.some(isThreadBlockingRestart)) return false; + } + return true; + }); +} diff --git a/apps/web/src/state/desktopUpdateInstall.test.ts b/apps/web/src/state/desktopUpdateInstall.test.ts new file mode 100644 index 000000000..93a70beb1 --- /dev/null +++ b/apps/web/src/state/desktopUpdateInstall.test.ts @@ -0,0 +1,275 @@ +import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { createDesktopUpdateInstallController } from "./desktopUpdateInstall"; + +const update: DesktopUpdateState = { + enabled: true, + status: "downloaded", + channel: "latest", + currentVersion: "1.0.0", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: 100, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function harness() { + const controller = createDesktopUpdateInstallController(); + const bridge = { + getUpdateState: vi.fn(async () => update), + installUpdate: vi.fn(async (): Promise => ({ + accepted: true, + completed: false, + state: update, + })), + }; + const onError = vi.fn(); + const schedule = async () => { + const confirmation = controller.request(update); + controller.respond("when-idle"); + expect(await confirmation).toBe(false); + }; + return { controller, bridge, onError, schedule }; +} + +describe("scheduled desktop restart", () => { + it("keeps immediate restart opt-in and dismissing does not install", async () => { + const { controller, bridge } = harness(); + const first = controller.request(update); + controller.respond("cancel"); + expect(await first).toBe(false); + const second = controller.request(update); + controller.respond("now"); + expect(await second).toBe(true); + expect(bridge.installUpdate).not.toHaveBeenCalled(); + }); + it("waits for idle and installs only once", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + await controller.tryInstall( + bridge, + () => false, + onError, + async () => true, + ); + expect(bridge.installUpdate).not.toHaveBeenCalled(); + await Promise.all([ + controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ), + controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ), + ]); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + expect(bridge.installUpdate).toHaveBeenCalledTimes(1); + }); + it("rechecks live activity after reading the updater", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + let idle = true; + bridge.getUpdateState.mockImplementation(async () => { + idle = false; + return update; + }); + await controller.tryInstall( + bridge, + () => idle, + onError, + async () => true, + ); + expect(bridge.installUpdate).not.toHaveBeenCalled(); + }); + it("honors cancellation while the updater read is in flight", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + bridge.getUpdateState.mockImplementation(async () => { + controller.cancel(); + return update; + }); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + expect(bridge.installUpdate).not.toHaveBeenCalled(); + expect(controller.getSnapshot().status).toBe("idle"); + }); + it("does not silently install a different downloaded version", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + bridge.getUpdateState.mockResolvedValue({ ...update, downloadedVersion: "1.2.0" }); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + expect(bridge.installUpdate).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(controller.getSnapshot().status).toBe("idle"); + }); + it.each(["rejected", "failed", "throw"])( + "cancels a %s install without retrying", + async (failure) => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + if (failure === "throw") bridge.installUpdate.mockRejectedValue(new Error("IPC failed")); + else + bridge.installUpdate.mockResolvedValue({ + accepted: failure !== "rejected", + completed: false, + state: + failure === "failed" ? { ...update, status: "error", errorContext: "install" } : update, + }); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + expect(bridge.installUpdate).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledOnce(); + expect(controller.getSnapshot().status).toBe("idle"); + }, + ); + it("keeps waiting through a background update check", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + bridge.getUpdateState.mockResolvedValue({ ...update, status: "checking" }); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + expect(controller.getSnapshot().status).toBe("waiting"); + expect(bridge.installUpdate).not.toHaveBeenCalled(); + bridge.getUpdateState.mockResolvedValue(update); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + expect(bridge.installUpdate).toHaveBeenCalledOnce(); + expect(onError).not.toHaveBeenCalled(); + }); + it("recovers from a native error after installation was accepted", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + expect(controller.getSnapshot().status).toBe("installing"); + controller.observeUpdate( + { ...update, status: "error", errorContext: "install", message: "Native installer refused" }, + onError, + ); + expect(controller.getSnapshot().status).toBe("idle"); + expect(onError).toHaveBeenCalledWith("Native installer refused"); + const retry = controller.request(update); + expect(controller.getSnapshot().status).toBe("confirming"); + controller.respond("cancel"); + await retry; + }); + it("waits for server finalization and honors cancellation during it", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + const entered = deferred(); + const finished = deferred(); + const installing = controller.tryInstall( + bridge, + () => true, + onError, + () => { + entered.resolve(); + return finished.promise; + }, + ); + await entered.promise; + expect(bridge.installUpdate).not.toHaveBeenCalled(); + controller.cancel(); + finished.resolve(true); + await installing; + expect(bridge.installUpdate).not.toHaveBeenCalled(); + }); + it("does not install when the server finds more work", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => false, + ); + expect(bridge.installUpdate).not.toHaveBeenCalled(); + expect(controller.getSnapshot().status).toBe("waiting"); + await controller.tryInstall( + bridge, + () => true, + onError, + async () => true, + ); + expect(bridge.installUpdate).toHaveBeenCalledWith({ expectedVersion: "1.1.0" }); + }); + it("rechecks server readiness if a native update check wins admission", async () => { + const { controller, bridge, onError, schedule } = harness(); + await schedule(); + const prepare = vi.fn(async () => true); + bridge.installUpdate.mockResolvedValueOnce({ + accepted: false, + completed: false, + state: { ...update, status: "checking" }, + }); + await controller.tryInstall(bridge, () => true, onError, prepare); + expect(controller.getSnapshot().status).toBe("waiting"); + expect(onError).not.toHaveBeenCalled(); + await controller.tryInstall(bridge, () => true, onError, prepare); + expect(prepare).toHaveBeenCalledTimes(2); + expect(controller.getSnapshot().status).toBe("installing"); + }); + it("does not replace an existing confirmation", async () => { + const { controller } = harness(); + const original = controller.request(update); + expect(await controller.request(update)).toBe(false); + controller.cancel(); + expect(await original).toBe(false); + }); +}); diff --git a/apps/web/src/state/desktopUpdateInstall.ts b/apps/web/src/state/desktopUpdateInstall.ts new file mode 100644 index 000000000..fde4df116 --- /dev/null +++ b/apps/web/src/state/desktopUpdateInstall.ts @@ -0,0 +1,118 @@ +import { useSyncExternalStore } from "react"; +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import { resolveDesktopUpdateButtonAction } from "../components/desktopUpdate.logic"; + +export type DesktopUpdateInstallState = + | { readonly status: "idle" } + | { readonly status: "confirming" | "waiting" | "installing"; readonly version: string }; + +/** Owns one restart request for this window; closing or reloading cancels it. */ +export function createDesktopUpdateInstallController() { + let state: DesktopUpdateInstallState = { status: "idle" }; + let resolveConfirmation: ((restartNow: boolean) => void) | undefined; + const listeners = new Set<() => void>(); + const publish = (next: DesktopUpdateInstallState) => { + state = next; + for (const listener of listeners) listener(); + }; + const cancel = () => { + const resolve = resolveConfirmation; + resolveConfirmation = undefined; + publish({ status: "idle" }); + resolve?.(false); + }; + return { + getSnapshot: () => state, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + request: (update: Pick): Promise => { + const version = update.downloadedVersion; + if (state.status !== "idle" || !version) return Promise.resolve(false); + return new Promise((resolve) => { + resolveConfirmation = resolve; + publish({ status: "confirming", version }); + }); + }, + respond: (choice: "now" | "when-idle" | "cancel") => { + if (state.status !== "confirming") return; + const resolve = resolveConfirmation; + resolveConfirmation = undefined; + publish( + choice === "when-idle" ? { status: "waiting", version: state.version } : { status: "idle" }, + ); + resolve?.(choice === "now"); + }, + cancel, + observeUpdate: (update: DesktopUpdateState, onError: (message: string) => void) => { + if (state.status !== "installing" || update.errorContext !== "install") return; + cancel(); + onError(update.message ?? "The update could not be installed. Please try again."); + }, + tryInstall: async ( + bridge: Pick, + isIdle: () => boolean, + onError: (message: string) => void, + prepare: () => Promise, + ) => { + if (state.status !== "waiting") return; + const request = state; + let installation: DesktopUpdateInstallState | undefined; + try { + const update = await bridge.getUpdateState(); + if (state !== request) return; + if (update.downloadedVersion === request.version && update.status === "checking") return; + if ( + update.downloadedVersion !== request.version || + resolveDesktopUpdateButtonAction(update) !== "install" + ) { + cancel(); + onError( + "The downloaded update changed or is no longer ready. Schedule the restart again.", + ); + return; + } + if (!isIdle()) return; + if (!(await prepare()) || state !== request || !isIdle()) return; + installation = { status: "installing", version: request.version }; + publish(installation); + const result = await bridge.installUpdate({ expectedVersion: request.version }); + if (state !== installation) return; + // A competing check invalidates the readiness admission. Wait for its + // state event, then ask the servers again before attempting installation. + if ( + !result.accepted && + result.state.status === "checking" && + result.state.downloadedVersion === request.version + ) { + publish(request); + return; + } + // Installation normally closes the window. A rejected/failed attempt must + // never automatically retry and repeatedly interrupt the user's work. + if (!result.accepted || result.state.errorContext === "install") { + cancel(); + onError(result.state.message ?? "The update could not be installed. Please try again."); + } + } catch (error) { + if (state !== request && state !== installation) return; + cancel(); + onError(error instanceof Error ? error.message : "The update could not be installed."); + } + }, + }; +} + +export const desktopUpdateInstall = createDesktopUpdateInstallController(); +export const requestDesktopUpdateInstall = desktopUpdateInstall.request; + +export function useDesktopUpdateInstallState() { + return useSyncExternalStore( + desktopUpdateInstall.subscribe, + desktopUpdateInstall.getSnapshot, + desktopUpdateInstall.getSnapshot, + ); +} diff --git a/docs/user/install.md b/docs/user/install.md index a4ed171bd..31ee5dbd7 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -128,3 +128,18 @@ For provider-specific setup and accounts, see [Codex](./providers-codex.md), - [Remote access](./remote-access.md): connect from another device. - [Running in the background](./background-service.md): keep a Linux or macOS host available. - [Updating T3 Code](./updating.md): update the app and connected servers. + +## Restart automatically after an update + +After a desktop update downloads, choose **Restart when threads finish** from the +restart dialog in the sidebar or Settings. T3 Code waits until threads across +enabled environments finish, including pending approval or input requests and +working background agents. Monitoring-only activity does not hold up the restart. +Disconnected environments pause the wait until their live state is available. +T3 Code also waits for final messages and checkpoints to be saved. Remote-only +desktop mode supports automatic restart too. If an environment cannot verify +readiness, the restart is cancelled; update that environment before trying again. + +Keep the desktop window open. The update icon shows a clock while a restart is +scheduled. Click it or choose **Cancel restart** in Settings to cancel. Closing or reloading the window cancels the request. You can +also choose **Restart now** in the dialog to install immediately. diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index ba80275bf..e8d740e0c 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -1,13 +1,17 @@ import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; export function createOrchestrationEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { return { + awaitRestartReady: createEnvironmentRpcCommand(runtime, { + label: "environment:await-restart-ready", + tag: ORCHESTRATION_WS_METHODS.awaitRestartReady, + }), turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:turn-diff", tag: ORCHESTRATION_WS_METHODS.getTurnDiff, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dd972b7aa..681a8b4fa 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1327,7 +1327,9 @@ export interface DesktopBridge { setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise; downloadUpdate: () => Promise; - installUpdate: () => Promise; + installUpdate: (input?: { + readonly expectedVersion: string; + }) => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; /** Present when the desktop shell accepts `t3 app` activation requests. */ appActivation?: { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7b1fd2f96..153f5c7b0 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -39,6 +39,7 @@ export const ORCHESTRATION_WS_METHODS = { getFullThreadDiff: "orchestration.getFullThreadDiff", searchThreads: "orchestration.searchThreads", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", + awaitRestartReady: "orchestration.awaitRestartReady", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", } as const; @@ -2217,6 +2218,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationSearchThreadsInput, output: OrchestrationSearchThreadsResult, }, + awaitRestartReady: { + input: Schema.Struct({}), + output: Schema.Boolean, + }, getArchivedShellSnapshot: { input: Schema.Struct({}), output: OrchestrationShellSnapshot, @@ -2271,3 +2276,21 @@ export class OrchestrationSearchThreadsError extends Schema.TaggedError, +): boolean { + return ( + thread.session?.status === "starting" || + thread.session?.status === "running" || + thread.session?.activeTurnId != null || + thread.latestTurn?.state === "running" || + thread.hasPendingApprovals || + thread.hasPendingUserInput || + thread.backgroundLiveness === "working" + ); +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d792a3188..67a244b92 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1225,6 +1225,12 @@ const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.search error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), }); +const WsOrchestrationAwaitRestartReadyRpc = Rpc.make(ORCHESTRATION_WS_METHODS.awaitRestartReady, { + payload: OrchestrationRpcSchemas.awaitRestartReady.input, + success: OrchestrationRpcSchemas.awaitRestartReady.output, + error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, { @@ -1449,6 +1455,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationSearchThreadsRpc, WsOrchestrationGetArchivedShellSnapshotRpc, + WsOrchestrationAwaitRestartReadyRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, );