Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => {
);
});

it.effect("reports the installer's latest output line while the update runs", () => {
const exited = Deferred.makeUnsafe<ChildProcessSpawner.ExitCode>();
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<string> = [];
return Effect.gen(function* () {
Expand Down
101 changes: 89 additions & 12 deletions apps/server/src/provider/providerMaintenanceRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly env?: NodeJS.ProcessEnv;
readonly onProgress?: (line: string) => Effect.Effect<void>;
}) {
const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
function* () {
Expand Down Expand Up @@ -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<string | null>(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 = <E>(stream: Stream.Stream<Uint8Array, E>) => {
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
// 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,
Expand Down Expand Up @@ -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<string>; 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;
Expand Down Expand Up @@ -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<void>,
) =>
runProviderMaintenanceCommandWithSpawner({
spawner,
command: update.executable,
args: update.args,
onProgress,
...(update.env ? { env: update.env } : {}),
});
const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
59 changes: 48 additions & 11 deletions apps/web/src/components/settings/ProviderInstanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand Down Expand Up @@ -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`}
>
<VersionAdvisoryIcon
className={cn(
mode === "list" && "size-3.5",
hasCompatibilityWarning && "text-warning",
)}
/>
{updateProgress ? (
<Spinner tone="muted" {...(mode === "list" ? { size: "sm" as const } : {})} />
) : (
<VersionAdvisoryIcon
className={cn(
mode === "list" && "size-3.5",
hasCompatibilityWarning && "text-warning",
)}
/>
)}
</Button>
}
/>
}
/>
<TooltipPopup side="top">{versionAdvisory.title}</TooltipPopup>
<TooltipPopup side="top">
{updateProgress ? "Updating" : versionAdvisory.title}
</TooltipPopup>
</Tooltip>
<PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}>
<div className="grid min-w-0 gap-3">
Expand Down Expand Up @@ -838,6 +856,17 @@ export function ProviderInstanceCard({
: "Update now"}
</Button>
) : null}
{updateProgress || updateProblem ? (
<p
aria-live="polite"
className={cn(
"text-xs leading-snug [overflow-wrap:anywhere]",
updateProblem ? "text-warning" : "text-muted-foreground",
)}
>
{updateProgress ?? updateProblem}
</p>
) : null}
{onRunVersionAction && updateCommand ? (
<div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground">
<span aria-hidden className="h-px flex-1 bg-border" />
Expand Down Expand Up @@ -908,16 +937,24 @@ export function ProviderInstanceCard({
{versionAdvisoryNode}
</span>
<span className="mt-0.5 flex items-start gap-1.5 text-xs leading-normal text-muted-foreground/80">
{statusDotNode ? (
{/* The dot describes provider health, not the update in progress. */}
{statusDotNode && !updateProgress ? (
<span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span>
) : null}
<ProviderStatusDiagnostic detail={statusDiagnostic}>
<span
tabIndex={statusDiagnostic ? 0 : undefined}
aria-live="polite"
className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]"
>
{summary.headline}
{needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null}
{updateProgress ? (
`Updating · ${updateProgress}`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
) : (
<>
{summary.headline}
{needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null}
</>
)}
</span>
</ProviderStatusDiagnostic>
</span>
Expand Down
Loading