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 {...@@ -7,6 +7,8 @@ import {
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { ServerProviderUpdateError } from "@t3tools/contracts";8import { ServerProviderUpdateError } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
...@@ -15,6 +17,7 @@ import * as Ref from "effect/Ref";...@@ -15,6 +17,7 @@ import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
16import * as Sink from "effect/Sink";18import * as Sink from "effect/Sink";
17import * as Stream from "effect/Stream";19import * as Stream from "effect/Stream";
20import * as TestClock from "effect/testing/TestClock";
18import { HttpClient, HttpClientResponse } from "effect/http";21import { HttpClient, HttpClientResponse } from "effect/http";
19import { ChildProcessSpawner } from "effect/process";22import { ChildProcessSpawner } from "effect/process";
20import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";23import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
...@@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => {...@@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => {
259 ]);262 ]);
260 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");263 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
261 assert.deepStrictEqual(264 assert.deepStrictEqual(
262 (yield* Ref.get(updateStatesRef)).map((state) => state.status),265 (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]),
263 ["queued", "running", "succeeded"],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 ],
264 );273 );
265 }).pipe(274 }).pipe(
266 Effect.provide(275 Effect.provide(
...@@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => {...@@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => {
406 );415 );
407 });416 });
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
409 it.effect("aborts without running when the installation changed since the advisory", () => {471 it.effect("aborts without running when the installation changed since the advisory", () => {
410 const calls: Array<string> = [];472 const calls: Array<string> = [];
411 return Effect.gen(function* () {473 return Effect.gen(function* () {
apps/server/src/provider/providerMaintenanceRunner.ts+89-12
...@@ -17,9 +17,12 @@ import * as Effect from "effect/Effect";...@@ -17,9 +17,12 @@ import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";18import * as Option from "effect/Option";
19import * as Ref from "effect/Ref";19import * as Ref from "effect/Ref";
20import * as Schedule from "effect/Schedule";
20import * as Schema from "effect/Schema";21import * as Schema from "effect/Schema";
22import * as Stream from "effect/Stream";
21import { HttpClient } from "effect/http";23import { HttpClient } from "effect/http";
22import { ChildProcess, ChildProcessSpawner } from "effect/process";24import { ChildProcess, ChildProcessSpawner } from "effect/process";
25import * as NodeUtil from "node:util";
2326
24import * as ModelManifest from "./ModelManifest.ts";27import * as ModelManifest from "./ModelManifest.ts";
25import { resolveProviderCompatibility } from "./providerCompatibility.ts";28import { resolveProviderCompatibility } from "./providerCompatibility.ts";
...@@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);...@@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
3841
39const UPDATE_TIMEOUT_MS = 5 * 60_000;42const UPDATE_TIMEOUT_MS = 5 * 60_000;
40const UPDATE_OUTPUT_MAX_BYTES = 10_000;43const 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
42export interface ProviderMaintenanceCommandResult {51export interface ProviderMaintenanceCommandResult {
43 readonly stdout: string;52 readonly stdout: string;
...@@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR...@@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
83 readonly command: string;92 readonly command: string;
84 readonly args: ReadonlyArray<string>;93 readonly args: ReadonlyArray<string>;
85 readonly env?: NodeJS.ProcessEnv;94 readonly env?: NodeJS.ProcessEnv;
95 readonly onProgress?: (line: string) => Effect.Effect<void>;
86 }) {96 }) {
87 const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(97 const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
88 function* () {98 function* () {
...@@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR...@@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
110 );120 );
111 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));121 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
113 const [stdout, stderr, exitCode] = yield* Effect.all(153 const [stdout, stderr, exitCode] = yield* Effect.all(
114 [154 [
115 collectUint8StreamText({155 collectUint8StreamText({
116 stream: child.stdout,156 stream: trackProgress(child.stdout),
117 maxBytes: UPDATE_OUTPUT_MAX_BYTES,157 maxBytes: UPDATE_OUTPUT_MAX_BYTES,
118 }),158 }),
119 collectUint8StreamText({159 collectUint8StreamText({
120 stream: child.stderr,160 stream: trackProgress(child.stderr),
121 maxBytes: UPDATE_OUTPUT_MAX_BYTES,161 maxBytes: UPDATE_OUTPUT_MAX_BYTES,
122 }),162 }),
123 child.exitCode,163 child.exitCode,
...@@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR...@@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
165 },205 },
166);206);
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
168function trimNullable(value: string): string | null {237function trimNullable(value: string): string | null {
169 const trimmed = value.trim();238 const trimmed = value.trim();
170 return trimmed.length > 0 ? trimmed : null;239 return trimmed.length > 0 ? trimmed : null;
...@@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {...@@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
223 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;292 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
224 const httpClient = yield* HttpClient.HttpClient;293 const httpClient = yield* HttpClient.HttpClient;
225 const versionCache = yield* ProviderVersionCache;294 const versionCache = yield* ProviderVersionCache;
226 const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) =>295 const runMaintenanceCommand = (
296 update: ProviderMaintenanceCommandAction,
297 onProgress: (line: string) => Effect.Effect<void>,
298 ) =>
227 runProviderMaintenanceCommandWithSpawner({299 runProviderMaintenanceCommandWithSpawner({
228 spawner,300 spawner,
229 command: update.executable,301 command: update.executable,
230 args: update.args,302 args: update.args,
303 onProgress,
231 ...(update.env ? { env: update.env } : {}),304 ...(update.env ? { env: update.env } : {}),
232 });305 });
233 const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({306 const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({
...@@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {...@@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
355 function* () {428 function* () {
356 const startedAt = yield* nowIso;429 const startedAt = yield* nowIso;
357 yield* Ref.set(startedAtRef, startedAt);430 yield* Ref.set(startedAtRef, startedAt);
358 yield* setUpdateState(431 const setRunningMessage = (message: string) =>
359 makeUpdateState({432 setUpdateState(
360 status: "running",433 makeUpdateState({
361 startedAt,434 status: "running",
362 finishedAt: null,435 startedAt,
363 message: "Updating provider.",436 finishedAt: null,
364 }),437 message,
365 );438 }),
439 ).pipe(Effect.asVoid);
440 yield* setRunningMessage("Checking for the latest version");
366441
367 // The cached capabilities chose the lock; re-derive ownership442 // The cached capabilities chose the lock; re-derive ownership
368 // now so the command that runs matches the executable as it is443 // now so the command that runs matches the executable as it is
...@@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {...@@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
420 }),495 }),
421 );496 );
422 }497 }
423 const result = yield* runMaintenanceCommand(command);498 yield* setRunningMessage(`Running ${describeCommand(command)}`);
499 const result = yield* runMaintenanceCommand(command, setRunningMessage);
424 const finishedAt = yield* nowIso;500 const finishedAt = yield* nowIso;
425 if (result.timedOut || result.exitCode !== 0) {501 if (result.timedOut || result.exitCode !== 0) {
426 return yield* finish(502 return yield* finish(
...@@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {...@@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
434 );510 );
435 }511 }
436512
513 yield* setRunningMessage("Verifying the installed version");
437 // Homebrew's "latest" moves once the upgrade lands; read it again.514 // Homebrew's "latest" moves once the upgrade lands; read it again.
438 const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(515 const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
439 instanceId,516 instanceId,
apps/web/src/components/settings/ProviderInstanceCard.tsx+48-11
...@@ -590,6 +590,18 @@ export function ProviderInstanceCard({...@@ -590,6 +590,18 @@ export function ProviderInstanceCard({
590 enabled,590 enabled,
591 );591 );
592 const updateCommand = versionAdvisory?.updateCommand ?? null;592 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;
593 const hasCompatibilityWarning =605 const hasCompatibilityWarning =
594 compatibility !== undefined &&606 compatibility !== undefined &&
595 compatibility.status !== "supported" &&607 compatibility.status !== "supported" &&
...@@ -791,20 +803,26 @@ export function ProviderInstanceCard({...@@ -791,20 +803,26 @@ export function ProviderInstanceCard({
791 size={mode === "list" ? "icon-micro" : "icon-xs"}803 size={mode === "list" ? "icon-micro" : "icon-xs"}
792 variant="ghost-muted"804 variant="ghost-muted"
793 className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined}805 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`}
795 >807 >
796 <VersionAdvisoryIcon808 {updateProgress ? (
797 className={cn(809 <Spinner tone="muted" {...(mode === "list" ? { size: "sm" as const } : {})} />
798 mode === "list" && "size-3.5",810 ) : (
799 hasCompatibilityWarning && "text-warning",811 <VersionAdvisoryIcon
800 )}812 className={cn(
801 />813 mode === "list" && "size-3.5",
814 hasCompatibilityWarning && "text-warning",
815 )}
816 />
817 )}
802 </Button>818 </Button>
803 }819 }
804 />820 />
805 }821 }
806 />822 />
807 <TooltipPopup side="top">{versionAdvisory.title}</TooltipPopup>823 <TooltipPopup side="top">
824 {updateProgress ? "Updating" : versionAdvisory.title}
825 </TooltipPopup>
808 </Tooltip>826 </Tooltip>
809 <PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}>827 <PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}>
810 <div className="grid min-w-0 gap-3">828 <div className="grid min-w-0 gap-3">
...@@ -838,6 +856,17 @@ export function ProviderInstanceCard({...@@ -838,6 +856,17 @@ export function ProviderInstanceCard({
838 : "Update now"}856 : "Update now"}
839 </Button>857 </Button>
840 ) : null}858 ) : 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}
841 {onRunVersionAction && updateCommand ? (870 {onRunVersionAction && updateCommand ? (
842 <div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground">871 <div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground">
843 <span aria-hidden className="h-px flex-1 bg-border" />872 <span aria-hidden className="h-px flex-1 bg-border" />
...@@ -908,16 +937,24 @@ export function ProviderInstanceCard({...@@ -908,16 +937,24 @@ export function ProviderInstanceCard({
908 {versionAdvisoryNode}937 {versionAdvisoryNode}
909 </span>938 </span>
910 <span className="mt-0.5 flex items-start gap-1.5 text-xs leading-normal text-muted-foreground/80">939 <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 ? (
912 <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span>942 <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span>
913 ) : null}943 ) : null}
914 <ProviderStatusDiagnostic detail={statusDiagnostic}>944 <ProviderStatusDiagnostic detail={statusDiagnostic}>
915 <span945 <span
916 tabIndex={statusDiagnostic ? 0 : undefined}946 tabIndex={statusDiagnostic ? 0 : undefined}
947 aria-live="polite"
917 className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]"948 className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]"
918 >949 >
919 {summary.headline}950 {updateProgress ? (
920 {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null}951 `Updating · ${updateProgress}`
952 ) : (
953 <>
954 {summary.headline}
955 {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null}
956 </>
957 )}
921 </span>958 </span>
922 </ProviderStatusDiagnostic>959 </ProviderStatusDiagnostic>
923 </span>960 </span>