From ff3a04a8ebaaf6bb74b8b5b996013b82e044c700 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:32:58 +0530 Subject: [PATCH] fix(settings): provider updates show live progress instead of a bare spinner (#16958) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- .../providerMaintenanceRunner.test.ts | 66 +++++++++++- .../src/provider/providerMaintenanceRunner.ts | 101 +++++++++++++++--- .../settings/ProviderInstanceCard.tsx | 59 ++++++++-- 3 files changed, 201 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index c82ec00143f2e403314fbd85ca6a9205a993783e..b6580c78856da81e631edc80b9328dab134e984c 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -7,6 +7,8 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -15,6 +17,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => { ]); assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); assert.deepStrictEqual( - (yield* Ref.get(updateStatesRef)).map((state) => state.status), - ["queued", "running", "succeeded"], + (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]), + [ + ["queued", "Waiting for another provider update to finish."], + ["running", "Checking for the latest version"], + ["running", "Running native-agent update"], + ["running", "Verifying the installed version"], + ["succeeded", "Provider updated."], + ], ); }).pipe( Effect.provide( @@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("reports the installer's latest output line while the update runs", () => { + const exited = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner(registry); + const runningMessages = Ref.get(updateStatesRef).pipe( + Effect.map((states) => + states.filter((state) => state.status === "running").map((state) => state.message), + ), + ); + + const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(1)); + assert.deepStrictEqual(yield* runningMessages, [ + "Checking for the latest version", + "Running native-agent update", + "80%", + ]); + + yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); + const result = yield* Fiber.join(fiber); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner(() => ({ + stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%", + exitCode: Deferred.await(exited), + })), + ), + ), + ); + }); + + it("splits installer output into clean status lines", () => { + assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), { + lines: ["Downloading"], + partialLine: " 5", + }); + assert.strictEqual( + ProviderMaintenanceRunner.toProgressLine("\u001b[1m==>\u001b[0m Pouring"), + "==> Pouring", + ); + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(" \t "), null); + assert.strictEqual( + ProviderMaintenanceRunner.splitOutputLines("x".repeat(5_000), "y").partialLine.length, + 4_096, + ); + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine("x".repeat(300))?.length, 200); + }); + it.effect("aborts without running when the installation changed since the advisory", () => { const calls: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 1e22ba899bec009dd68ec25ea6e41f4dbb936531..cc8b3728785f29e014abdc6a57e3bc5ce3a4b0e8 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -17,9 +17,12 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as NodeUtil from "node:util"; import * as ModelManifest from "./ModelManifest.ts"; import { resolveProviderCompatibility } from "./providerCompatibility.ts"; @@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; +// Every progress publish resends the provider list to each client, so the +// installer's output is sampled rather than forwarded line by line. +const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); +const UPDATE_PROGRESS_MAX_LENGTH = 200; +// An installer may write for minutes without a line ending; only its tail matters. +const UPDATE_PARTIAL_LINE_MAX_LENGTH = 4_096; export interface ProviderMaintenanceCommandResult { readonly stdout: string; @@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR readonly command: string; readonly args: ReadonlyArray; readonly env?: NodeJS.ProcessEnv; + readonly onProgress?: (line: string) => Effect.Effect; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { @@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR ); yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + // Holds the newest output line not yet reported; the sampler takes it. + const pendingProgress = yield* Ref.make(null); + const onProgress = input.onProgress; + if (onProgress) { + yield* Ref.getAndSet(pendingProgress, null).pipe( + Effect.flatMap((line) => (line === null ? Effect.void : onProgress(line))), + Effect.repeat(Schedule.spaced(UPDATE_PROGRESS_INTERVAL)), + Effect.forkScoped, + ); + } + const trackProgress = (stream: Stream.Stream) => { + if (!onProgress) { + return stream; + } + const decoder = new TextDecoder(); + let partialLine = ""; + return stream.pipe( + Stream.tap((chunk) => { + const split = splitOutputLines(partialLine, decoder.decode(chunk, { stream: true })); + partialLine = split.partialLine; + // A progress bar that redraws with a leading `\r` keeps its newest + // frame unterminated, so that frame is the latest status. + const line = + toProgressLine(split.partialLine) ?? + split.lines.map(toProgressLine).findLast((value) => value !== null); + return line ? Ref.set(pendingProgress, line) : Effect.void; + }), + ); + }; + const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ - stream: child.stdout, + stream: trackProgress(child.stdout), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), collectUint8StreamText({ - stream: child.stderr, + stream: trackProgress(child.stderr), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), child.exitCode, @@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }, ); +/** + * Split a chunk of installer output into complete lines. `\r` ends a line too, + * so a redrawn progress bar reports its latest frame. + */ +export function splitOutputLines( + partialLine: string, + text: string, +): { readonly lines: ReadonlyArray; readonly partialLine: string } { + const parts = (partialLine + text).split(/\r\n|\r|\n/); + return { partialLine: (parts.pop() ?? "").slice(-UPDATE_PARTIAL_LINE_MAX_LENGTH), lines: parts }; +} + +/** Turn one raw output line into a short status message, or null if it has no text. */ +export function toProgressLine(line: string): string | null { + const text = NodeUtil.stripVTControlCharacters(line).replace(/\s+/g, " ").trim(); + if (text.length === 0) { + return null; + } + return text.length <= UPDATE_PROGRESS_MAX_LENGTH + ? text + : `${text.slice(0, UPDATE_PROGRESS_MAX_LENGTH - 1)}…`; +} + +/** `claude update` reads better in a status line than the full executable path. */ +function describeCommand(command: ProviderMaintenanceCommandAction): string { + const executable = command.executable.split(/[\\/]/).pop() || command.executable; + return [executable, ...command.args].join(" "); +} + function trimNullable(value: string): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; @@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; - const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) => + const runMaintenanceCommand = ( + update: ProviderMaintenanceCommandAction, + onProgress: (line: string) => Effect.Effect, + ) => runProviderMaintenanceCommandWithSpawner({ spawner, command: update.executable, args: update.args, + onProgress, ...(update.env ? { env: update.env } : {}), }); const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ @@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { function* () { const startedAt = yield* nowIso; yield* Ref.set(startedAtRef, startedAt); - yield* setUpdateState( - makeUpdateState({ - status: "running", - startedAt, - finishedAt: null, - message: "Updating provider.", - }), - ); + const setRunningMessage = (message: string) => + setUpdateState( + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message, + }), + ).pipe(Effect.asVoid); + yield* setRunningMessage("Checking for the latest version"); // The cached capabilities chose the lock; re-derive ownership // now so the command that runs matches the executable as it is @@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); } - const result = yield* runMaintenanceCommand(command); + yield* setRunningMessage(`Running ${describeCommand(command)}`); + const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } + yield* setRunningMessage("Verifying the installed version"); // Homebrew's "latest" moves once the upgrade lands; read it again. const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 381dcd864e425a115dab6c2ea8380920daceb223..eab2fba04cb28a9b4e3df086eb744bf273cbca3f 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -590,6 +590,18 @@ export function ProviderInstanceCard({ enabled, ); const updateCommand = versionAdvisory?.updateCommand ?? null; + const updateState = liveProvider?.updateState; + // The server reports each update step. `isUpdating` also covers the moment + // between the click and the server's first report. + const updateProgress = isUpdating + ? ((updateState?.status === "queued" || updateState?.status === "running" + ? updateState.message + : null) ?? "Starting update") + : null; + const updateProblem = + !isUpdating && (updateState?.status === "failed" || updateState?.status === "unchanged") + ? updateState.message + : null; const hasCompatibilityWarning = compatibility !== undefined && compatibility.status !== "supported" && @@ -791,20 +803,26 @@ export function ProviderInstanceCard({ size={mode === "list" ? "icon-micro" : "icon-xs"} variant="ghost-muted" className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined} - aria-label={`${versionAdvisory.title} — view details`} + aria-label={`${updateProgress ? "Updating" : versionAdvisory.title} — view details`} > - + {updateProgress ? ( + + ) : ( + + )} } /> } /> - {versionAdvisory.title} + + {updateProgress ? "Updating" : versionAdvisory.title} +
@@ -838,6 +856,17 @@ export function ProviderInstanceCard({ : "Update now"} ) : null} + {updateProgress || updateProblem ? ( +

+ {updateProgress ?? updateProblem} +

+ ) : null} {onRunVersionAction && updateCommand ? (
@@ -908,16 +937,24 @@ export function ProviderInstanceCard({ {versionAdvisoryNode} - {statusDotNode ? ( + {/* The dot describes provider health, not the update in progress. */} + {statusDotNode && !updateProgress ? ( {statusDotNode} ) : null} - {summary.headline} - {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} + {updateProgress ? ( + `Updating · ${updateProgress}` + ) : ( + <> + {summary.headline} + {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} + + )} -- 2.54.0