| author | |
| committer | |
| log | ff3a04a8ebaaf6bb74b8b5b996013b82e044c700 |
| tree | 90c0a9a59c5f6884169e36633504ff212c8589ba |
| parent | 3143335fc3a568cbbb5174764961889272135cb9 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { ServerProviderUpdateError } from "@t3tools/contracts"; |
| 9 | 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Deferred from "effect/Deferred"; | |
| 11 | import * as Duration from "effect/Duration"; | |
| 10 | 12 | import * as Effect from "effect/Effect"; |
| 11 | 13 | import * as Exit from "effect/Exit"; |
| 12 | 14 | import * as Fiber from "effect/Fiber"; |
| ... | ... | @@ -15,6 +17,7 @@ import * as Ref from "effect/Ref"; |
| 15 | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | import * as Sink from "effect/Sink"; |
| 17 | 19 | import * as Stream from "effect/Stream"; |
| 20 | import * as TestClock from "effect/testing/TestClock"; | |
| 18 | 21 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 19 | 22 | import { ChildProcessSpawner } from "effect/process"; |
| 20 | 23 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| ... | ... | @@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => { |
| 259 | 262 | ]); |
| 260 | 263 | assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); |
| 261 | 264 | 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 | ], | |
| 264 | 273 | ); |
| 265 | 274 | }).pipe( |
| 266 | 275 | Effect.provide( |
| ... | ... | @@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => { |
| 406 | 415 | ); |
| 407 | 416 | }); |
| 408 | 417 | |
| 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 | 471 | it.effect("aborts without running when the installation changed since the advisory", () => { |
| 410 | 472 | const calls: Array<string> = []; |
| 411 | 473 | return Effect.gen(function* () { |
apps/server/src/provider/providerMaintenanceRunner.ts+89-12| ... | ... | @@ -17,9 +17,12 @@ import * as Effect from "effect/Effect"; |
| 17 | 17 | import * as Layer from "effect/Layer"; |
| 18 | 18 | import * as Option from "effect/Option"; |
| 19 | 19 | import * as Ref from "effect/Ref"; |
| 20 | import * as Schedule from "effect/Schedule"; | |
| 20 | 21 | import * as Schema from "effect/Schema"; |
| 22 | import * as Stream from "effect/Stream"; | |
| 21 | 23 | import { HttpClient } from "effect/http"; |
| 22 | 24 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 25 | import * as NodeUtil from "node:util"; | |
| 23 | 26 | |
| 24 | 27 | import * as ModelManifest from "./ModelManifest.ts"; |
| 25 | 28 | import { resolveProviderCompatibility } from "./providerCompatibility.ts"; |
| ... | ... | @@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); |
| 38 | 41 | |
| 39 | 42 | const UPDATE_TIMEOUT_MS = 5 * 60_000; |
| 40 | 43 | const 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. | |
| 46 | const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); | |
| 47 | const UPDATE_PROGRESS_MAX_LENGTH = 200; | |
| 48 | // An installer may write for minutes without a line ending; only its tail matters. | |
| 49 | const UPDATE_PARTIAL_LINE_MAX_LENGTH = 4_096; | |
| 41 | 50 | |
| 42 | 51 | export interface ProviderMaintenanceCommandResult { |
| 43 | 52 | readonly stdout: string; |
| ... | ... | @@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR |
| 83 | 92 | readonly command: string; |
| 84 | 93 | readonly args: ReadonlyArray<string>; |
| 85 | 94 | readonly env?: NodeJS.ProcessEnv; |
| 95 | readonly onProgress?: (line: string) => Effect.Effect<void>; | |
| 86 | 96 | }) { |
| 87 | 97 | const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( |
| 88 | 98 | function* () { |
| ... | ... | @@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR |
| 110 | 120 | ); |
| 111 | 121 | yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); |
| 112 | 122 | |
| 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 | 153 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 114 | 154 | [ |
| 115 | 155 | collectUint8StreamText({ |
| 116 | stream: child.stdout, | |
| 156 | stream: trackProgress(child.stdout), | |
| 117 | 157 | maxBytes: UPDATE_OUTPUT_MAX_BYTES, |
| 118 | 158 | }), |
| 119 | 159 | collectUint8StreamText({ |
| 120 | stream: child.stderr, | |
| 160 | stream: trackProgress(child.stderr), | |
| 121 | 161 | maxBytes: UPDATE_OUTPUT_MAX_BYTES, |
| 122 | 162 | }), |
| 123 | 163 | child.exitCode, |
| ... | ... | @@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR |
| 165 | 205 | }, |
| 166 | 206 | ); |
| 167 | 207 | |
| 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 | */ | |
| 212 | export 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. */ | |
| 221 | export 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. */ | |
| 232 | function describeCommand(command: ProviderMaintenanceCommandAction): string { | |
| 233 | const executable = command.executable.split(/[\\/]/).pop() || command.executable; | |
| 234 | return [executable, ...command.args].join(" "); | |
| 235 | } | |
| 236 | ||
| 168 | 237 | function trimNullable(value: string): string | null { |
| 169 | 238 | const trimmed = value.trim(); |
| 170 | 239 | return trimmed.length > 0 ? trimmed : null; |
| ... | ... | @@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 223 | 292 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 224 | 293 | const httpClient = yield* HttpClient.HttpClient; |
| 225 | 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 | 299 | runProviderMaintenanceCommandWithSpawner({ |
| 228 | 300 | spawner, |
| 229 | 301 | command: update.executable, |
| 230 | 302 | args: update.args, |
| 303 | onProgress, | |
| 231 | 304 | ...(update.env ? { env: update.env } : {}), |
| 232 | 305 | }); |
| 233 | 306 | const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ |
| ... | ... | @@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 355 | 428 | function* () { |
| 356 | 429 | const startedAt = yield* nowIso; |
| 357 | 430 | 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"); | |
| 366 | 441 | |
| 367 | 442 | // The cached capabilities chose the lock; re-derive ownership |
| 368 | 443 | // 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 | 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 | 500 | const finishedAt = yield* nowIso; |
| 425 | 501 | if (result.timedOut || result.exitCode !== 0) { |
| 426 | 502 | return yield* finish( |
| ... | ... | @@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 434 | 510 | ); |
| 435 | 511 | } |
| 436 | 512 | |
| 513 | yield* setRunningMessage("Verifying the installed version"); | |
| 437 | 514 | // Homebrew's "latest" moves once the upgrade lands; read it again. |
| 438 | 515 | const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( |
| 439 | 516 | instanceId, |
apps/web/src/components/settings/ProviderInstanceCard.tsx+48-11| ... | ... | @@ -590,6 +590,18 @@ export function ProviderInstanceCard({ |
| 590 | 590 | enabled, |
| 591 | 591 | ); |
| 592 | 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 | 605 | const hasCompatibilityWarning = |
| 594 | 606 | compatibility !== undefined && |
| 595 | 607 | compatibility.status !== "supported" && |
| ... | ... | @@ -791,20 +803,26 @@ export function ProviderInstanceCard({ |
| 791 | 803 | size={mode === "list" ? "icon-micro" : "icon-xs"} |
| 792 | 804 | variant="ghost-muted" |
| 793 | 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 | <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 | )} | |
| 802 | 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 | 826 | </Tooltip> |
| 809 | 827 | <PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}> |
| 810 | 828 | <div className="grid min-w-0 gap-3"> |
| ... | ... | @@ -838,6 +856,17 @@ export function ProviderInstanceCard({ |
| 838 | 856 | : "Update now"} |
| 839 | 857 | </Button> |
| 840 | 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 | 870 | {onRunVersionAction && updateCommand ? ( |
| 842 | 871 | <div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground"> |
| 843 | 872 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| ... | ... | @@ -908,16 +937,24 @@ export function ProviderInstanceCard({ |
| 908 | 937 | {versionAdvisoryNode} |
| 909 | 938 | </span> |
| 910 | 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 | 942 | <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span> |
| 913 | 943 | ) : null} |
| 914 | 944 | <ProviderStatusDiagnostic detail={statusDiagnostic}> |
| 915 | 945 | <span |
| 916 | 946 | tabIndex={statusDiagnostic ? 0 : undefined} |
| 947 | aria-live="polite" | |
| 917 | 948 | className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]" |
| 918 | 949 | > |
| 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 | )} | |
| 921 | 958 | </span> |
| 922 | 959 | </ProviderStatusDiagnostic> |
| 923 | 960 | </span> |