authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-08 05:32:58+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 05:32:58+05:30
logff3a04a8ebaaf6bb74b8b5b996013b82e044c700
tree90c0a9a59c5f6884169e36633504ff212c8589ba
parent3143335fc3a568cbbb5174764961889272135cb9
signature Signed by PGP key B5690EEEBB952194

fix(settings): provider updates show live progress instead of a bare spinner (#16958)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 201 insertions(+), 25 deletions(-)

apps/server/src/provider/providerMaintenanceRunner.test.ts+64-2
......@@ -7,6 +7,8 @@ import {
77} from "@t3tools/contracts";
88import { ServerProviderUpdateError } from "@t3tools/contracts";
99import * as Cause from "effect/Cause";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
1012import * as Effect from "effect/Effect";
1113import * as Exit from "effect/Exit";
1214import * as Fiber from "effect/Fiber";
......@@ -15,6 +17,7 @@ import * as Ref from "effect/Ref";
1517import * as Schema from "effect/Schema";
1618import * as Sink from "effect/Sink";
1719import * as Stream from "effect/Stream";
20import * as TestClock from "effect/testing/TestClock";
1821import { HttpClient, HttpClientResponse } from "effect/http";
1922import { ChildProcessSpawner } from "effect/process";
2023import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
......@@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => {
259262 ]);
260263 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
261264 assert.deepStrictEqual(
262 (yield* Ref.get(updateStatesRef)).map((state) => state.status),
263 ["queued", "running", "succeeded"],
265 (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]),
266 [
267 ["queued", "Waiting for another provider update to finish."],
268 ["running", "Checking for the latest version"],
269 ["running", "Running native-agent update"],
270 ["running", "Verifying the installed version"],
271 ["succeeded", "Provider updated."],
272 ],
264273 );
265274 }).pipe(
266275 Effect.provide(
......@@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => {
406415 );
407416 });
408417
418 it.effect("reports the installer's latest output line while the update runs", () => {
419 const exited = Deferred.makeUnsafe<ChildProcessSpawner.ExitCode>();
420 return Effect.gen(function* () {
421 const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider);
422 const updater = yield* makeTestRunner(registry);
423 const runningMessages = Ref.get(updateStatesRef).pipe(
424 Effect.map((states) =>
425 states.filter((state) => state.status === "running").map((state) => state.message),
426 ),
427 );
428
429 const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild);
430 yield* TestClock.adjust(Duration.seconds(1));
431 assert.deepStrictEqual(yield* runningMessages, [
432 "Checking for the latest version",
433 "Running native-agent update",
434 "80%",
435 ]);
436
437 yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0));
438 const result = yield* Fiber.join(fiber);
439 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
440 }).pipe(
441 Effect.provide(
442 Layer.mergeAll(
443 layerNonWindowsPlatform,
444 layerLatestVersionHttpClient("0.0.0"),
445 layerMockSpawner(() => ({
446 stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%",
447 exitCode: Deferred.await(exited),
448 })),
449 ),
450 ),
451 );
452 });
453
454 it("splits installer output into clean status lines", () => {
455 assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), {
456 lines: ["Downloading"],
457 partialLine: " 5",
458 });
459 assert.strictEqual(
460 ProviderMaintenanceRunner.toProgressLine("\u001b[1m==>\u001b[0m Pouring"),
461 "==> Pouring",
462 );
463 assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(" \t "), null);
464 assert.strictEqual(
465 ProviderMaintenanceRunner.splitOutputLines("x".repeat(5_000), "y").partialLine.length,
466 4_096,
467 );
468 assert.strictEqual(ProviderMaintenanceRunner.toProgressLine("x".repeat(300))?.length, 200);
469 });
470
409471 it.effect("aborts without running when the installation changed since the advisory", () => {
410472 const calls: Array<string> = [];
411473 return Effect.gen(function* () {
apps/server/src/provider/providerMaintenanceRunner.ts+89-12
......@@ -17,9 +17,12 @@ import * as Effect from "effect/Effect";
1717import * as Layer from "effect/Layer";
1818import * as Option from "effect/Option";
1919import * as Ref from "effect/Ref";
20import * as Schedule from "effect/Schedule";
2021import * as Schema from "effect/Schema";
22import * as Stream from "effect/Stream";
2123import { HttpClient } from "effect/http";
2224import { ChildProcess, ChildProcessSpawner } from "effect/process";
25import * as NodeUtil from "node:util";
2326
2427import * as ModelManifest from "./ModelManifest.ts";
2528import { resolveProviderCompatibility } from "./providerCompatibility.ts";
......@@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
3841
3942const UPDATE_TIMEOUT_MS = 5 * 60_000;
4043const UPDATE_OUTPUT_MAX_BYTES = 10_000;
44// Every progress publish resends the provider list to each client, so the
45// installer's output is sampled rather than forwarded line by line.
46const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1);
47const UPDATE_PROGRESS_MAX_LENGTH = 200;
48// An installer may write for minutes without a line ending; only its tail matters.
49const UPDATE_PARTIAL_LINE_MAX_LENGTH = 4_096;
4150
4251export interface ProviderMaintenanceCommandResult {
4352 readonly stdout: string;
......@@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
8392 readonly command: string;
8493 readonly args: ReadonlyArray<string>;
8594 readonly env?: NodeJS.ProcessEnv;
95 readonly onProgress?: (line: string) => Effect.Effect<void>;
8696 }) {
8797 const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
8898 function* () {
......@@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
110120 );
111121 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
112122
123 // Holds the newest output line not yet reported; the sampler takes it.
124 const pendingProgress = yield* Ref.make<string | null>(null);
125 const onProgress = input.onProgress;
126 if (onProgress) {
127 yield* Ref.getAndSet(pendingProgress, null).pipe(
128 Effect.flatMap((line) => (line === null ? Effect.void : onProgress(line))),
129 Effect.repeat(Schedule.spaced(UPDATE_PROGRESS_INTERVAL)),
130 Effect.forkScoped,
131 );
132 }
133 const trackProgress = <E>(stream: Stream.Stream<Uint8Array, E>) => {
134 if (!onProgress) {
135 return stream;
136 }
137 const decoder = new TextDecoder();
138 let partialLine = "";
139 return stream.pipe(
140 Stream.tap((chunk) => {
141 const split = splitOutputLines(partialLine, decoder.decode(chunk, { stream: true }));
142 partialLine = split.partialLine;
143 // A progress bar that redraws with a leading `\r` keeps its newest
144 // frame unterminated, so that frame is the latest status.
145 const line =
146 toProgressLine(split.partialLine) ??
147 split.lines.map(toProgressLine).findLast((value) => value !== null);
148 return line ? Ref.set(pendingProgress, line) : Effect.void;
149 }),
150 );
151 };
152
113153 const [stdout, stderr, exitCode] = yield* Effect.all(
114154 [
115155 collectUint8StreamText({
116 stream: child.stdout,
156 stream: trackProgress(child.stdout),
117157 maxBytes: UPDATE_OUTPUT_MAX_BYTES,
118158 }),
119159 collectUint8StreamText({
120 stream: child.stderr,
160 stream: trackProgress(child.stderr),
121161 maxBytes: UPDATE_OUTPUT_MAX_BYTES,
122162 }),
123163 child.exitCode,
......@@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
165205 },
166206);
167207
208/**
209 * Split a chunk of installer output into complete lines. `\r` ends a line too,
210 * so a redrawn progress bar reports its latest frame.
211 */
212export function splitOutputLines(
213 partialLine: string,
214 text: string,
215): { readonly lines: ReadonlyArray<string>; readonly partialLine: string } {
216 const parts = (partialLine + text).split(/\r\n|\r|\n/);
217 return { partialLine: (parts.pop() ?? "").slice(-UPDATE_PARTIAL_LINE_MAX_LENGTH), lines: parts };
218}
219
220/** Turn one raw output line into a short status message, or null if it has no text. */
221export function toProgressLine(line: string): string | null {
222 const text = NodeUtil.stripVTControlCharacters(line).replace(/\s+/g, " ").trim();
223 if (text.length === 0) {
224 return null;
225 }
226 return text.length <= UPDATE_PROGRESS_MAX_LENGTH
227 ? text
228 : `${text.slice(0, UPDATE_PROGRESS_MAX_LENGTH - 1)}…`;
229}
230
231/** `claude update` reads better in a status line than the full executable path. */
232function describeCommand(command: ProviderMaintenanceCommandAction): string {
233 const executable = command.executable.split(/[\\/]/).pop() || command.executable;
234 return [executable, ...command.args].join(" ");
235}
236
168237function trimNullable(value: string): string | null {
169238 const trimmed = value.trim();
170239 return trimmed.length > 0 ? trimmed : null;
......@@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
223292 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
224293 const httpClient = yield* HttpClient.HttpClient;
225294 const versionCache = yield* ProviderVersionCache;
226 const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) =>
295 const runMaintenanceCommand = (
296 update: ProviderMaintenanceCommandAction,
297 onProgress: (line: string) => Effect.Effect<void>,
298 ) =>
227299 runProviderMaintenanceCommandWithSpawner({
228300 spawner,
229301 command: update.executable,
230302 args: update.args,
303 onProgress,
231304 ...(update.env ? { env: update.env } : {}),
232305 });
233306 const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({
......@@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
355428 function* () {
356429 const startedAt = yield* nowIso;
357430 yield* Ref.set(startedAtRef, startedAt);
358 yield* setUpdateState(
359 makeUpdateState({
360 status: "running",
361 startedAt,
362 finishedAt: null,
363 message: "Updating provider.",
364 }),
365 );
431 const setRunningMessage = (message: string) =>
432 setUpdateState(
433 makeUpdateState({
434 status: "running",
435 startedAt,
436 finishedAt: null,
437 message,
438 }),
439 ).pipe(Effect.asVoid);
440 yield* setRunningMessage("Checking for the latest version");
366441
367442 // The cached capabilities chose the lock; re-derive ownership
368443 // now so the command that runs matches the executable as it is
......@@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
420495 }),
421496 );
422497 }
423 const result = yield* runMaintenanceCommand(command);
498 yield* setRunningMessage(`Running ${describeCommand(command)}`);
499 const result = yield* runMaintenanceCommand(command, setRunningMessage);
424500 const finishedAt = yield* nowIso;
425501 if (result.timedOut || result.exitCode !== 0) {
426502 return yield* finish(
......@@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
434510 );
435511 }
436512
513 yield* setRunningMessage("Verifying the installed version");
437514 // Homebrew's "latest" moves once the upgrade lands; read it again.
438515 const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
439516 instanceId,
apps/web/src/components/settings/ProviderInstanceCard.tsx+48-11
......@@ -590,6 +590,18 @@ export function ProviderInstanceCard({
590590 enabled,
591591 );
592592 const updateCommand = versionAdvisory?.updateCommand ?? null;
593 const updateState = liveProvider?.updateState;
594 // The server reports each update step. `isUpdating` also covers the moment
595 // between the click and the server's first report.
596 const updateProgress = isUpdating
597 ? ((updateState?.status === "queued" || updateState?.status === "running"
598 ? updateState.message
599 : null) ?? "Starting update")
600 : null;
601 const updateProblem =
602 !isUpdating && (updateState?.status === "failed" || updateState?.status === "unchanged")
603 ? updateState.message
604 : null;
593605 const hasCompatibilityWarning =
594606 compatibility !== undefined &&
595607 compatibility.status !== "supported" &&
......@@ -791,20 +803,26 @@ export function ProviderInstanceCard({
791803 size={mode === "list" ? "icon-micro" : "icon-xs"}
792804 variant="ghost-muted"
793805 className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined}
794 aria-label={`${versionAdvisory.title} — view details`}
806 aria-label={`${updateProgress ? "Updating" : versionAdvisory.title} — view details`}
795807 >
796 <VersionAdvisoryIcon
797 className={cn(
798 mode === "list" && "size-3.5",
799 hasCompatibilityWarning && "text-warning",
800 )}
801 />
808 {updateProgress ? (
809 <Spinner tone="muted" {...(mode === "list" ? { size: "sm" as const } : {})} />
810 ) : (
811 <VersionAdvisoryIcon
812 className={cn(
813 mode === "list" && "size-3.5",
814 hasCompatibilityWarning && "text-warning",
815 )}
816 />
817 )}
802818 </Button>
803819 }
804820 />
805821 }
806822 />
807 <TooltipPopup side="top">{versionAdvisory.title}</TooltipPopup>
823 <TooltipPopup side="top">
824 {updateProgress ? "Updating" : versionAdvisory.title}
825 </TooltipPopup>
808826 </Tooltip>
809827 <PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}>
810828 <div className="grid min-w-0 gap-3">
......@@ -838,6 +856,17 @@ export function ProviderInstanceCard({
838856 : "Update now"}
839857 </Button>
840858 ) : null}
859 {updateProgress || updateProblem ? (
860 <p
861 aria-live="polite"
862 className={cn(
863 "text-xs leading-snug [overflow-wrap:anywhere]",
864 updateProblem ? "text-warning" : "text-muted-foreground",
865 )}
866 >
867 {updateProgress ?? updateProblem}
868 </p>
869 ) : null}
841870 {onRunVersionAction && updateCommand ? (
842871 <div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground">
843872 <span aria-hidden className="h-px flex-1 bg-border" />
......@@ -908,16 +937,24 @@ export function ProviderInstanceCard({
908937 {versionAdvisoryNode}
909938 </span>
910939 <span className="mt-0.5 flex items-start gap-1.5 text-xs leading-normal text-muted-foreground/80">
911 {statusDotNode ? (
940 {/* The dot describes provider health, not the update in progress. */}
941 {statusDotNode && !updateProgress ? (
912942 <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span>
913943 ) : null}
914944 <ProviderStatusDiagnostic detail={statusDiagnostic}>
915945 <span
916946 tabIndex={statusDiagnostic ? 0 : undefined}
947 aria-live="polite"
917948 className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]"
918949 >
919 {summary.headline}
920 {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null}
950 {updateProgress ? (
951 `Updating · ${updateProgress}`
952 ) : (
953 <>
954 {summary.headline}
955 {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null}
956 </>
957 )}
921958 </span>
922959 </ProviderStatusDiagnostic>
923960 </span>