From 2cfe4229a854043d259c50b4975bcced4eb0e2bb Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 14 May 2026 21:22:00 +0300 Subject: [PATCH 1/2] Add provider auto update flow --- .../src/provider/Layers/ProviderHealth.ts | 513 +++++++++++++- .../src/provider/Services/ProviderHealth.ts | 15 +- .../src/provider/providerMaintenance.test.ts | 104 +++ .../src/provider/providerMaintenance.ts | 663 ++++++++++++++++++ .../providerMaintenanceCommandCoordinator.ts | 82 +++ .../src/stream/collectUint8StreamText.ts | 37 + apps/server/src/wsRpc.ts | 1 + apps/web/src/components/ui/toast.tsx | 20 +- apps/web/src/routes/__root.tsx | 182 ++++- apps/web/src/routes/_chat.settings.tsx | 351 +++++++++- apps/web/src/settingsNavigation.ts | 4 +- apps/web/src/wsNativeApi.ts | 1 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/rpc.ts | 10 + packages/contracts/src/server.ts | 46 ++ packages/contracts/src/ws.ts | 3 + 16 files changed, 1971 insertions(+), 64 deletions(-) create mode 100644 apps/server/src/provider/providerMaintenance.test.ts create mode 100644 apps/server/src/provider/providerMaintenance.ts create mode 100644 apps/server/src/provider/providerMaintenanceCommandCoordinator.ts create mode 100644 apps/server/src/stream/collectUint8StreamText.ts diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 057fb9c1..1898f0d0 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -11,10 +11,14 @@ import * as OS from "node:os"; import * as nodePath from "node:path"; import type { + ProviderKind, + ServerSettings, ServerProviderAuthStatus, ServerProviderStatus, ServerProviderStatusState, + ServerProviderUpdateState, } from "@t3tools/contracts"; +import { ServerProviderUpdateError } from "@t3tools/contracts"; import { parseCodexConfigModelProvider } from "@t3tools/shared/codexConfig"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; @@ -22,6 +26,7 @@ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { Array, Cache, + DateTime, Duration, Effect, Exit, @@ -55,6 +60,17 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache"; +import { makeProviderMaintenanceCommandCoordinator } from "../providerMaintenanceCommandCoordinator"; +import { + enrichProviderStatusWithVersionAdvisory, + makeProviderMaintenanceCapabilities, + normalizeCommandPath, + parseGenericCliVersion, + resolveProviderMaintenanceCapabilitiesEffect, + type PackageManagedProviderMaintenanceDefinition, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance"; +import { collectUint8StreamText } from "../../stream/collectUint8StreamText"; const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; @@ -66,6 +82,106 @@ const OPENCODE_PROVIDER = "opencode" as const; const PI_PROVIDER = "pi" as const; type ProviderStatuses = ReadonlyArray; +const PROVIDERS = [ + CODEX_PROVIDER, + CLAUDE_AGENT_PROVIDER, + CURSOR_PROVIDER, + GEMINI_PROVIDER, + KILO_PROVIDER, + OPENCODE_PROVIDER, + PI_PROVIDER, +] as const satisfies ReadonlyArray; + +const UPDATE_OUTPUT_MAX_BYTES = 10_000; +const UPDATE_TIMEOUT_MS = 5 * 60_000; + +function isClaudeNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.local/bin/claude") || + normalized.endsWith("/.local/bin/claude.exe") || + normalized.includes("/.local/share/claude/") + ); +} + +function isOpenCodeNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.opencode/bin/opencode") || + normalized.endsWith("/.opencode/bin/opencode.exe") + ); +} + +const PACKAGE_MANAGED_PROVIDER_UPDATES = { + codex: { + provider: CODEX_PROVIDER, + binaryName: "codex", + npmPackageName: "@openai/codex", + homebrew: { name: "codex", kind: "cask" }, + nativeUpdate: null, + }, + claudeAgent: { + provider: CLAUDE_AGENT_PROVIDER, + binaryName: "claude", + npmPackageName: "@anthropic-ai/claude-code", + homebrew: { name: "claude-code", kind: "cask" }, + nativeUpdate: { + executable: "claude", + args: () => ["update"], + lockKey: "claude-native", + strategy: "matching-path", + isCommandPath: isClaudeNativeCommandPath, + }, + }, + gemini: { + provider: GEMINI_PROVIDER, + binaryName: "gemini", + npmPackageName: "@google/gemini-cli", + homebrew: { name: "gemini-cli", kind: "formula" }, + nativeUpdate: null, + }, + kilo: { + provider: KILO_PROVIDER, + binaryName: "kilo", + npmPackageName: "@kilocode/cli", + homebrew: null, + nativeUpdate: { + executable: "kilo", + args: () => ["upgrade"], + lockKey: "kilo-native", + strategy: "always", + }, + }, + opencode: { + provider: OPENCODE_PROVIDER, + binaryName: "opencode", + npmPackageName: "opencode-ai", + homebrew: { name: "opencode", kind: "formula" }, + nativeUpdate: { + executable: "opencode", + args: (installSource) => + installSource === "unknown" || installSource === "native" + ? ["upgrade"] + : ["upgrade", "--method", installSource === "homebrew" ? "brew" : installSource], + lockKey: "opencode-native", + strategy: "always", + isCommandPath: isOpenCodeNativeCommandPath, + }, + }, + pi: { + provider: PI_PROVIDER, + binaryName: "pi", + npmPackageName: "@earendil-works/pi-coding-agent", + homebrew: null, + nativeUpdate: { + executable: "pi", + args: () => ["update"], + lockKey: "pi-native", + strategy: "always", + }, + }, +} as const satisfies Partial>; + // ── Pure helpers ──────────────────────────────────────────────────── export interface CommandResult { @@ -726,6 +842,32 @@ const runCursorCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +const runPiCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make("pi", [...args], { + shell: process.platform === "win32", + env: process.env, + }); + + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + if (isWindowsShellCommandMissingResult({ code: exitCode, stderr })) { + return yield* Effect.fail(new Error("spawn pi ENOENT")); + } + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + // ── Health check ──────────────────────────────────────────────────── export const checkCodexProviderStatus: Effect.Effect< @@ -805,6 +947,7 @@ export const checkCodexProviderStatus: Effect.Effect< status: "ready" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: "Using a custom Codex model provider; OpenAI login check skipped.", } satisfies ServerProviderStatus; @@ -822,6 +965,7 @@ export const checkCodexProviderStatus: Effect.Effect< status: "warning" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: error instanceof Error @@ -836,6 +980,7 @@ export const checkCodexProviderStatus: Effect.Effect< status: "warning" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: "Could not verify Codex authentication status. Timed out while running command.", }; @@ -861,6 +1006,7 @@ export const checkCodexProviderStatus: Effect.Effect< status: parsed.status, available: true, authStatus: parsed.authStatus, + version: parsedVersion, ...(codexAuthType ? { authType: codexAuthType } : {}), ...(codexLabel ? { authLabel: codexLabel } : {}), ...(parsed.voiceTranscriptionAvailable !== undefined @@ -1007,6 +1153,7 @@ export const makeCheckClaudeProviderStatus = ( : "Claude Agent CLI is installed but failed to run.", }; } + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); // Probe 2: `claude auth status` — is the user authenticated? const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( @@ -1021,6 +1168,7 @@ export const makeCheckClaudeProviderStatus = ( status: "warning" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: error instanceof Error @@ -1035,6 +1183,7 @@ export const makeCheckClaudeProviderStatus = ( status: "warning" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: "Could not verify Claude authentication status. Timed out while running command.", }; @@ -1060,6 +1209,7 @@ export const makeCheckClaudeProviderStatus = ( status: parsed.status, available: true, authStatus: parsed.authStatus, + version: parsedVersion, ...(authMetadata ? { authType: authMetadata.type, authLabel: authMetadata.label } : {}), checkedAt, ...(parsed.message ? { message: parsed.message } : {}), @@ -1119,6 +1269,7 @@ export const checkGeminiProviderStatus: Effect.Effect< : "Gemini CLI is installed but failed to run.", }; } + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); const capabilityProbe = yield* probeGeminiCapabilities({ binaryPath: "gemini", @@ -1132,6 +1283,7 @@ export const checkGeminiProviderStatus: Effect.Effect< status: "warning" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: error instanceof Error @@ -1146,6 +1298,7 @@ export const checkGeminiProviderStatus: Effect.Effect< status: parsed.status, available: true, authStatus: parsed.auth.status, + version: parsedVersion, checkedAt, ...(parsed.message ? { message: parsed.message } : {}), } satisfies ServerProviderStatus; @@ -1204,12 +1357,14 @@ export const checkOpenCodeProviderStatus: Effect.Effect< : "OpenCode CLI is installed but failed to run.", } satisfies ServerProviderStatus; } + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); return { provider: OPENCODE_PROVIDER, status: "ready" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: "OpenCode CLI is installed. Configure provider credentials inside OpenCode as needed.", } satisfies ServerProviderStatus; @@ -1268,12 +1423,14 @@ export const checkKiloProviderStatus: Effect.Effect< : "Kilo CLI is installed but failed to run.", } satisfies ServerProviderStatus; } + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); return { provider: KILO_PROVIDER, status: "ready" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: "Kilo CLI is installed. Configure provider credentials inside Kilo as needed.", } satisfies ServerProviderStatus; @@ -1283,9 +1440,22 @@ export const checkKiloProviderStatus: Effect.Effect< export const checkPiProviderStatus = ( agentDir?: string, -): Effect.Effect => - Effect.sync(() => { +): Effect.Effect => + Effect.gen(function* () { const checkedAt = new Date().toISOString(); + const versionProbe = yield* runPiCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + const version = + Result.isSuccess(versionProbe) && Option.isSome(versionProbe.success) + ? versionProbe.success.value + : null; + const parsedVersion = + version && version.code === 0 + ? parseGenericCliVersion(`${version.stdout}\n${version.stderr}`) + : null; + try { const trimmedAgentDir = nonEmptyTrimmed(agentDir); const authStorage = trimmedAgentDir @@ -1304,6 +1474,7 @@ export const checkPiProviderStatus = ( status: modelCount > 0 ? "ready" : "warning", available: modelCount > 0, authStatus: modelCount > 0 ? "authenticated" : "unknown", + version: parsedVersion, checkedAt, message: modelCount > 0 @@ -1375,12 +1546,14 @@ export const checkCursorProviderStatus: Effect.Effect< : "Cursor Agent CLI is installed but failed to run.", } satisfies ServerProviderStatus; } + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); return { provider: CURSOR_PROVIDER, status: "ready" as const, available: true, authStatus: "unknown" as const, + version: parsedVersion, checkedAt, message: "Cursor Agent CLI is installed. Sign in with Cursor if a session prompts for authentication.", @@ -1404,7 +1577,11 @@ function providerStatusesEqual(left: ProviderStatuses, right: ProviderStatuses): (status.authType ?? null) === (next.authType ?? null) && (status.authLabel ?? null) === (next.authLabel ?? null) && status.voiceTranscriptionAvailable === next.voiceTranscriptionAvailable && - (status.message ?? null) === (next.message ?? null) + (status.version ?? null) === (next.version ?? null) && + (status.message ?? null) === (next.message ?? null) && + JSON.stringify(status.versionAdvisory ?? null) === + JSON.stringify(next.versionAdvisory ?? null) && + JSON.stringify(status.updateState ?? null) === JSON.stringify(next.updateState ?? null) ); }); } @@ -1427,15 +1604,7 @@ export const ProviderHealthLive = Layer.effect( yield* Effect.addFinalizer(() => Scope.close(refreshScope, Exit.void)); const cachePathByProvider = new Map( - [ - CODEX_PROVIDER, - CLAUDE_AGENT_PROVIDER, - CURSOR_PROVIDER, - GEMINI_PROVIDER, - KILO_PROVIDER, - OPENCODE_PROVIDER, - PI_PROVIDER, - ].map( + PROVIDERS.map( (provider) => [ provider, @@ -1448,15 +1617,7 @@ export const ProviderHealthLive = Layer.effect( ); const cachedStatuses: ProviderStatuses = yield* Effect.forEach( - [ - CODEX_PROVIDER, - CLAUDE_AGENT_PROVIDER, - CURSOR_PROVIDER, - GEMINI_PROVIDER, - KILO_PROVIDER, - OPENCODE_PROVIDER, - PI_PROVIDER, - ] as const, + PROVIDERS, (provider) => readProviderStatusCache(cachePathByProvider.get(provider)!).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -1471,7 +1632,17 @@ export const ProviderHealthLive = Layer.effect( ); const statusesRef = yield* Ref.make(cachedStatuses); + const updateStatesRef = yield* Ref.make< + ReadonlyMap + >(new Map()); const refreshFiberRef = yield* Ref.make | null>(null); + const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ + makeAlreadyRunningError: (provider) => + new ServerProviderUpdateError({ + provider: provider as ProviderKind, + reason: "An update is already running for this provider.", + }), + }); // 5-minute TTL cache for the Claude SDK subscription probe. The probe // spawns a short-lived `claude` subprocess to read account metadata @@ -1488,6 +1659,127 @@ export const ProviderHealthLive = Layer.effect( const checkClaude = makeCheckClaudeProviderStatus(resolveClaudeSubscription); + const getProviderBinaryPath = (provider: ProviderKind, settings: ServerSettings) => { + switch (provider) { + case "codex": + return settings.providers.codex.binaryPath; + case "claudeAgent": + return settings.providers.claudeAgent.binaryPath; + case "cursor": + return settings.providers.cursor.binaryPath; + case "gemini": + return settings.providers.gemini.binaryPath; + case "kilo": + return settings.providers.kilo.binaryPath; + case "opencode": + return settings.providers.opencode.binaryPath; + case "pi": + return settings.providers.pi.binaryPath; + } + }; + + const getProviderMaintenanceCapabilities = Effect.fn("getProviderMaintenanceCapabilities")( + function* (provider: ProviderKind) { + const settings = yield* serverSettings.getSettings; + if (provider === "cursor") { + return makeProviderMaintenanceCapabilities({ + provider, + packageName: null, + updateExecutable: getProviderBinaryPath(provider, settings) || "agent", + updateArgs: ["update"], + updateLockKey: "cursor-agent", + }); + } + const definition = PACKAGE_MANAGED_PROVIDER_UPDATES[provider]; + if (!definition) { + return makeProviderMaintenanceCapabilities({ + provider, + packageName: null, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); + } + return yield* resolveProviderMaintenanceCapabilitiesEffect(definition, { + binaryPath: getProviderBinaryPath(provider, settings), + env: process.env, + platform: process.platform, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + }, + ); + + const applyVolatileProviderState = Effect.fn("applyVolatileProviderState")(function* ( + status: ServerProviderStatus, + ) { + const updateStates = yield* Ref.get(updateStatesRef); + const updateState = updateStates.get(status.provider); + if (!updateState) { + const { updateState: _updateState, ...statusWithoutUpdateState } = status; + return statusWithoutUpdateState; + } + return { + ...status, + updateState, + }; + }); + + const setProviderUpdateState = Effect.fn("setProviderUpdateState")(function* ( + provider: ProviderKind, + state: ServerProviderUpdateState | null, + ) { + yield* Ref.update(updateStatesRef, (previous) => { + const next = new Map(previous); + if (!state || state.status === "idle") { + next.delete(provider); + } else { + next.set(provider, state); + } + return next; + }); + + const current = yield* Ref.get(statusesRef); + const next = yield* Effect.forEach(current, applyVolatileProviderState, { + concurrency: "unbounded", + }); + yield* Ref.set(statusesRef, next); + yield* PubSub.publish(changesPubSub, next); + return next; + }); + + const enrichStatuses = Effect.fn("enrichProviderStatuses")(function* ( + statuses: ReadonlyArray, + ) { + const enriched = yield* Effect.forEach( + statuses, + (status) => + getProviderMaintenanceCapabilities(status.provider).pipe( + Effect.flatMap((capabilities) => + enrichProviderStatusWithVersionAdvisory(status, capabilities), + ), + Effect.catch(() => + Effect.succeed({ + ...status, + versionAdvisory: { + status: "unknown" as const, + currentVersion: status.version ?? null, + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt: status.checkedAt, + message: null, + }, + }), + ), + ), + { concurrency: "unbounded" }, + ); + return yield* Effect.forEach(enriched, applyVolatileProviderState, { + concurrency: "unbounded", + }); + }); + const loadProviderStatuses = serverSettings.getSettings.pipe( Effect.flatMap((settings) => Effect.all( @@ -1510,21 +1802,24 @@ export const ProviderHealthLive = Layer.effect( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.map(orderProviderStatuses), + Effect.flatMap(enrichStatuses), ); const persistStatuses = (statuses: ProviderStatuses) => Effect.forEach( statuses, - (status) => - writeProviderStatusCache({ + (status) => { + const { updateState: _updateState, ...statusToPersist } = status; + return writeProviderStatusCache({ filePath: cachePathByProvider.get(status.provider)!, - provider: status, + provider: statusToPersist, }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.tapError(Effect.logError), Effect.ignore, - ), + ); + }, { concurrency: "unbounded", discard: true }, ); @@ -1569,11 +1864,179 @@ export const ProviderHealthLive = Layer.effect( Effect.flatMap(Fiber.join), ); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + + const makeUpdateState = (input: { + readonly status: ServerProviderUpdateState["status"]; + readonly startedAt: string | null; + readonly finishedAt: string | null; + readonly message: string | null; + readonly output?: string | null; + }): ServerProviderUpdateState => ({ + status: input.status, + startedAt: input.startedAt, + finishedAt: input.finishedAt, + message: input.message, + output: input.output ?? null, + }); + + const describeUpdateCommandError = (error: unknown): string => { + if (error instanceof Error && error.message.trim().length > 0) { + if (error.message.includes("initial is not a function")) { + return "Update command failed before producing output. Try running the provider update command from a terminal."; + } + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return "Update command could not be started."; + }; + + const runUpdateCommand = Effect.fn("runProviderUpdateCommand")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; + }) { + const child = yield* spawner.spawn( + ChildProcess.make(input.command, [...input.args], { + shell: process.platform === "win32", + env: process.env, + }), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ + stream: child.stdout, + maxBytes: UPDATE_OUTPUT_MAX_BYTES, + }), + collectUint8StreamText({ + stream: child.stderr, + maxBytes: UPDATE_OUTPUT_MAX_BYTES, + }), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { + stdout: stdout.text, + stderr: stderr.text, + exitCode, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + }; + }); + + const updateProvider: ProviderHealthShape["updateProvider"] = Effect.fn( + "ProviderHealth.updateProvider", + )(function* (input) { + const provider = input.provider; + const capabilities = yield* getProviderMaintenanceCapabilities(provider); + const update = capabilities.update; + if (!update) { + return yield* new ServerProviderUpdateError({ + provider, + reason: "This provider does not support one-click updates.", + }); + } + + const run = Effect.gen(function* () { + const startedAt = yield* nowIso; + yield* setProviderUpdateState( + provider, + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message: "Updating provider.", + }), + ); + + const commandResult = yield* runUpdateCommand({ + command: update.executable, + args: update.args, + }).pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(UPDATE_TIMEOUT_MS)), + Effect.result, + ); + const finishedAt = yield* nowIso; + if (Result.isFailure(commandResult)) { + const providers = yield* setProviderUpdateState( + provider, + makeUpdateState({ + status: "failed", + startedAt, + finishedAt, + message: describeUpdateCommandError(commandResult.failure), + }), + ); + return { providers }; + } + const result = commandResult.success; + const output = + Option.isSome(result) + ? [result.value.stderr, result.value.stdout].filter(Boolean).join("\n\n").trim() || null + : null; + const failed = + Option.isNone(result) || result.value.exitCode !== 0; + if (failed) { + const message = Option.isNone(result) + ? "Update timed out." + : `Update command exited with code ${result.value.exitCode}.`; + const providers = yield* setProviderUpdateState( + provider, + makeUpdateState({ + status: "failed", + startedAt, + finishedAt, + message, + output: output ? output.slice(0, UPDATE_OUTPUT_MAX_BYTES) : null, + }), + ); + return { providers }; + } + + const providers = yield* refreshNow; + const refreshed = providers.find((status) => status.provider === provider); + const stillOutdated = refreshed?.versionAdvisory?.status === "behind_latest"; + const finalProviders = yield* setProviderUpdateState( + provider, + makeUpdateState({ + status: stillOutdated ? "unchanged" : "succeeded", + startedAt, + finishedAt, + message: stillOutdated + ? "Update command completed, but DP Code still detects an outdated provider version." + : "Provider updated.", + output: output ? output.slice(0, UPDATE_OUTPUT_MAX_BYTES) : null, + }), + ); + return { providers: finalProviders }; + }); + + return yield* commandCoordinator.withCommandLock({ + targetKey: provider, + lockKey: update.lockKey, + onQueued: setProviderUpdateState( + provider, + makeUpdateState({ + status: "queued", + startedAt: null, + finishedAt: null, + message: "Waiting for another provider update to finish.", + }), + ).pipe(Effect.asVoid), + run, + }); + }); + return { // Mirror upstream's behavior here: reads consume the latest stable // snapshot, while refreshes happen explicitly or from provider streams. getStatuses: Ref.get(statusesRef), refresh, + updateProvider, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts index 40884d09..5e059ce0 100644 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -6,7 +6,12 @@ * * @module ProviderHealth */ -import type { ServerProviderStatus } from "@t3tools/contracts"; +import type { + ServerProviderStatus, + ServerProviderUpdateInput, + ServerProviderUpdateResult, + ServerProviderUpdateError, +} from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect, Stream } from "effect"; @@ -21,6 +26,14 @@ export interface ProviderHealthShape { */ readonly refresh: Effect.Effect>; + /** + * Run the allowlisted update command for a provider and publish the + * resulting provider snapshots. + */ + readonly updateProvider: ( + input: ServerProviderUpdateInput, + ) => Effect.Effect; + /** * Stream of provider snapshot changes for config consumers. */ diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts new file mode 100644 index 00000000..304e6586 --- /dev/null +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -0,0 +1,104 @@ +import { describe, it, assert } from "@effect/vitest"; + +import { + createProviderVersionAdvisory, + parseGenericCliVersion, + resolvePackageManagedProviderMaintenance, + type PackageManagedProviderMaintenanceDefinition, +} from "./providerMaintenance"; + +const CODEX_DEFINITION = { + provider: "codex", + binaryName: "codex", + npmPackageName: "@openai/codex", + homebrew: { name: "codex", kind: "cask" }, + nativeUpdate: null, +} as const satisfies PackageManagedProviderMaintenanceDefinition; + +const OPENCODE_DEFINITION = { + provider: "opencode", + binaryName: "opencode", + npmPackageName: "opencode-ai", + homebrew: { name: "opencode", kind: "formula" }, + nativeUpdate: { + executable: "opencode", + args: (installSource) => + installSource === "unknown" || installSource === "native" + ? ["upgrade"] + : ["upgrade", "--method", installSource === "homebrew" ? "brew" : installSource], + lockKey: "opencode-native", + strategy: "always", + }, +} as const satisfies PackageManagedProviderMaintenanceDefinition; + +describe("providerMaintenance", () => { + it("parses generic CLI versions", () => { + assert.strictEqual(parseGenericCliVersion("codex-cli 0.130.0\n"), "0.130.0"); + assert.strictEqual(parseGenericCliVersion("claude 2.1\n"), "2.1.0"); + assert.strictEqual(parseGenericCliVersion("no version here"), null); + }); + + it("resolves npm global update commands for unqualified binaries", () => { + const capabilities = resolvePackageManagedProviderMaintenance(CODEX_DEFINITION, { + binaryPath: "codex", + realCommandPath: "/Users/test/.npm-global/lib/node_modules/@openai/codex/bin/codex", + }); + + assert.deepStrictEqual(capabilities.update, { + command: "npm install -g @openai/codex@latest", + executable: "npm", + args: ["install", "-g", "@openai/codex@latest"], + lockKey: "npm-global", + }); + }); + + it("does not guess an update command for unclassified binaries", () => { + const capabilities = resolvePackageManagedProviderMaintenance(CODEX_DEFINITION, { + binaryPath: "/custom/bin/codex", + realCommandPath: "/custom/bin/codex", + }); + + assert.strictEqual(capabilities.update, null); + }); + + it("resolves Homebrew cask update commands", () => { + const capabilities = resolvePackageManagedProviderMaintenance(CODEX_DEFINITION, { + binaryPath: "/opt/homebrew/bin/codex", + realCommandPath: "/opt/homebrew/Caskroom/codex/0.130.0/codex", + }); + + assert.deepStrictEqual(capabilities.update, { + command: "brew upgrade --cask codex", + executable: "brew", + args: ["upgrade", "--cask", "codex"], + lockKey: "homebrew", + }); + assert.strictEqual(capabilities.packageName, null); + }); + + it("uses provider-native update commands with detected install method", () => { + const capabilities = resolvePackageManagedProviderMaintenance(OPENCODE_DEFINITION, { + binaryPath: "opencode", + realCommandPath: "/Users/test/.local/share/pnpm/opencode", + }); + + assert.deepStrictEqual(capabilities.update, { + command: "opencode upgrade --method pnpm", + executable: "opencode", + args: ["upgrade", "--method", "pnpm"], + lockKey: "opencode-native", + }); + }); + + it("marks older semver versions as behind latest", () => { + const advisory = createProviderVersionAdvisory({ + provider: "codex", + currentVersion: "0.129.0", + latestVersion: "0.130.0", + }); + + assert.strictEqual(advisory.status, "behind_latest"); + assert.strictEqual(advisory.currentVersion, "0.129.0"); + assert.strictEqual(advisory.latestVersion, "0.130.0"); + }); +}); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts new file mode 100644 index 00000000..4018d751 --- /dev/null +++ b/apps/server/src/provider/providerMaintenance.ts @@ -0,0 +1,663 @@ +import type { + ProviderKind, + ServerProviderStatus, + ServerProviderVersionAdvisory, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; +const LATEST_VERSION_TIMEOUT_MS = 4_000; +const PROVIDER_UPDATE_ACTION_MESSAGE = "Install the update now or review provider settings."; +const WINDOWS_EXECUTABLE_EXTENSIONS = ["", ".exe", ".cmd", ".bat"] as const; + +type ProviderInstallSource = + | "npm" + | "bun" + | "pnpm" + | "homebrew" + | "native" + | "unknown"; + +interface ParsedSemver { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: ReadonlyArray; +} + +interface ProviderLatestVersionSource { + readonly kind: "npm" | "homebrew"; + readonly name: string; + readonly homebrewKind?: "formula" | "cask"; +} + +export interface ProviderMaintenanceCapabilities { + readonly provider: ProviderKind; + readonly packageName: string | null; + readonly latestVersionSource: ProviderLatestVersionSource | null; + readonly update: ProviderMaintenanceCommandAction | null; +} + +export interface ProviderMaintenanceCommandAction { + readonly command: string; + readonly executable: string; + readonly args: ReadonlyArray; + readonly lockKey: string; +} + +export interface ProviderMaintenanceCapabilityResolutionOptions { + readonly binaryPath?: string | null; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly realCommandPath?: string | null; +} + +export interface PackageManagedProviderMaintenanceDefinition { + readonly provider: ProviderKind; + readonly binaryName: string; + readonly npmPackageName: string; + readonly homebrew: + | { + readonly name: string; + readonly kind: "formula" | "cask"; + } + | null; + readonly nativeUpdate: { + readonly executable: string; + readonly args: (installSource: ProviderInstallSource) => ReadonlyArray; + readonly lockKey: string; + readonly strategy: "always" | "matching-path"; + readonly isCommandPath?: (commandPath: string) => boolean; + } | null; +} + +const latestVersionCache = new Map< + string, + { readonly expiresAt: number; readonly version: string | null } +>(); +const SEMVER_NUMBER_SEGMENT = /^\d+$/; + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeSemverVersion(version: string): string { + const [main, prerelease] = version.trim().replace(/^v/, "").split("-", 2); + const segments = (main ?? "") + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + if (segments.length === 2) { + segments.push("0"); + } + + return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); +} + +function parseSemver(value: string): ParsedSemver | null { + const [main = "", prerelease] = normalizeSemverVersion(value).split("-", 2); + const segments = main.split("."); + if (segments.length !== 3) { + return null; + } + + const [majorSegment, minorSegment, patchSegment] = segments; + if ( + majorSegment === undefined || + minorSegment === undefined || + patchSegment === undefined || + !SEMVER_NUMBER_SEGMENT.test(majorSegment) || + !SEMVER_NUMBER_SEGMENT.test(minorSegment) || + !SEMVER_NUMBER_SEGMENT.test(patchSegment) + ) { + return null; + } + + return { + major: Number.parseInt(majorSegment, 10), + minor: Number.parseInt(minorSegment, 10), + patch: Number.parseInt(patchSegment, 10), + prerelease: + prerelease + ?.split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) ?? [], + }; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftNumeric = SEMVER_NUMBER_SEGMENT.test(left); + const rightNumeric = SEMVER_NUMBER_SEGMENT.test(right); + + if (leftNumeric && rightNumeric) { + return Number.parseInt(left, 10) - Number.parseInt(right, 10); + } + if (leftNumeric) { + return -1; + } + if (rightNumeric) { + return 1; + } + return left.localeCompare(right); +} + +function compareSemverVersions(left: string, right: string): number { + const parsedLeft = parseSemver(left); + const parsedRight = parseSemver(right); + if (!parsedLeft || !parsedRight) { + return left.localeCompare(right); + } + + if (parsedLeft.major !== parsedRight.major) { + return parsedLeft.major - parsedRight.major; + } + if (parsedLeft.minor !== parsedRight.minor) { + return parsedLeft.minor - parsedRight.minor; + } + if (parsedLeft.patch !== parsedRight.patch) { + return parsedLeft.patch - parsedRight.patch; + } + if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { + return 0; + } + if (parsedLeft.prerelease.length === 0) { + return 1; + } + if (parsedRight.prerelease.length === 0) { + return -1; + } + + const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = parsedLeft.prerelease[index]; + const rightIdentifier = parsedRight.prerelease[index]; + if (leftIdentifier === undefined) { + return -1; + } + if (rightIdentifier === undefined) { + return 1; + } + const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + if (comparison !== 0) { + return comparison; + } + } + return 0; +} + +export function parseGenericCliVersion(output: string): string | null { + const match = output.match(/\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/); + return match?.[1] ? normalizeSemverVersion(match[1]) : null; +} + +export function normalizeCommandPath(commandPath: string): string { + return commandPath.replaceAll("\\", "/").toLowerCase(); +} + +function hasPathSeparator(value: string): boolean { + return value.includes("/") || value.includes("\\"); +} + +export function makeProviderMaintenanceCapabilities(input: { + readonly provider: ProviderKind; + readonly packageName: string | null; + readonly latestVersionSource?: ProviderLatestVersionSource | null; + readonly updateExecutable: string | null; + readonly updateArgs: ReadonlyArray; + readonly updateLockKey: string | null; +}): ProviderMaintenanceCapabilities { + const update = + input.updateExecutable === null || input.updateLockKey === null + ? null + : { + command: [input.updateExecutable, ...input.updateArgs].join(" "), + executable: input.updateExecutable, + args: input.updateArgs, + lockKey: input.updateLockKey, + }; + return { + provider: input.provider, + packageName: input.packageName, + latestVersionSource: + input.latestVersionSource ?? + (input.packageName ? { kind: "npm", name: input.packageName } : null), + update, + }; +} + +function makeManualOnlyProviderMaintenanceCapabilities(input: { + readonly provider: ProviderKind; + readonly packageName: string | null; +}): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: input.provider, + packageName: input.packageName, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); +} + +function makeNpmGlobalProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "npm", + updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`], + updateLockKey: "npm-global", + }); +} + +function makeBunGlobalProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "bun", + updateArgs: ["i", "-g", `${definition.npmPackageName}@latest`], + updateLockKey: "bun-global", + }); +} + +function makePnpmGlobalProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "pnpm", + updateArgs: ["add", "-g", `${definition.npmPackageName}@latest`], + updateLockKey: "pnpm-global", + }); +} + +function makeHomebrewProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + if (!definition.homebrew) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); + } + + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: null, + latestVersionSource: { + kind: "homebrew", + name: definition.homebrew.name, + homebrewKind: definition.homebrew.kind, + }, + updateExecutable: "brew", + updateArgs: + definition.homebrew.kind === "cask" + ? ["upgrade", "--cask", definition.homebrew.name] + : ["upgrade", definition.homebrew.name], + updateLockKey: "homebrew", + }); +} + +function makeNativeProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, + installSource: ProviderInstallSource, + executable?: string | null, +): ProviderMaintenanceCapabilities | null { + if (!definition.nativeUpdate) { + return null; + } + + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: installSource === "homebrew" ? null : definition.npmPackageName, + latestVersionSource: + installSource === "homebrew" && definition.homebrew + ? { + kind: "homebrew", + name: definition.homebrew.name, + homebrewKind: definition.homebrew.kind, + } + : { kind: "npm", name: definition.npmPackageName }, + updateExecutable: executable ?? definition.nativeUpdate.executable, + updateArgs: definition.nativeUpdate.args(installSource), + updateLockKey: definition.nativeUpdate.lockKey, + }); +} + +function detectInstallSource( + definition: PackageManagedProviderMaintenanceDefinition, + commandPath: string, +): ProviderInstallSource { + if (definition.nativeUpdate?.isCommandPath?.(commandPath)) { + return "native"; + } + if (isBunGlobalCommandPath(commandPath)) { + return "bun"; + } + if (isPnpmGlobalCommandPath(commandPath)) { + return "pnpm"; + } + if (isNpmGlobalCommandPath(commandPath)) { + return "npm"; + } + if (isHomebrewCommandPath(commandPath)) { + return "homebrew"; + } + return "unknown"; +} + +function makeProviderMaintenanceForInstallSource(input: { + readonly definition: PackageManagedProviderMaintenanceDefinition; + readonly installSource: ProviderInstallSource; + readonly executable?: string | null; +}): ProviderMaintenanceCapabilities { + const { definition, installSource, executable } = input; + if (definition.nativeUpdate?.strategy === "always") { + return ( + makeNativeProviderMaintenanceCapabilities(definition, installSource, executable) ?? + makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }) + ); + } + if (installSource === "native") { + return ( + makeNativeProviderMaintenanceCapabilities(definition, installSource, executable) ?? + makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }) + ); + } + if (installSource === "bun") { + return makeBunGlobalProviderMaintenanceCapabilities(definition); + } + if (installSource === "pnpm") { + return makePnpmGlobalProviderMaintenanceCapabilities(definition); + } + if (installSource === "npm") { + return makeNpmGlobalProviderMaintenanceCapabilities(definition); + } + if (installSource === "homebrew") { + return makeHomebrewProviderMaintenanceCapabilities(definition); + } + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); +} + +function isBunGlobalCommandPath(commandPath: string): boolean { + return normalizeCommandPath(commandPath).includes("/.bun/bin/"); +} + +function isPnpmGlobalCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/.local/share/pnpm/") || + normalized.includes("/library/pnpm/") || + normalized.includes("/local/share/pnpm/") || + normalized.includes("/appdata/local/pnpm/") || + normalized.includes("/pnpm/global/") + ); +} + +function isNpmGlobalCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/node_modules/.bin/") || + normalized.includes("/lib/node_modules/") || + normalized.includes("/npm/node_modules/") + ); +} + +function isHomebrewCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/opt/homebrew/caskroom/") || + normalized.includes("/usr/local/caskroom/") || + normalized.includes("/opt/homebrew/cellar/") || + normalized.includes("/usr/local/cellar/") || + normalized.includes("/homebrew/cellar/") || + normalized.startsWith("/opt/homebrew/bin/") || + normalized.startsWith("/usr/local/bin/") + ); +} + +export function resolvePackageManagedProviderMaintenance( + definition: PackageManagedProviderMaintenanceDefinition, + options?: ProviderMaintenanceCapabilityResolutionOptions, +): ProviderMaintenanceCapabilities { + const binaryPath = nonEmptyString(options?.binaryPath); + if (!binaryPath) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); + } + + const commandPaths = [options?.realCommandPath, binaryPath] + .map(nonEmptyString) + .filter((value): value is string => value !== null); + + for (const commandPath of commandPaths) { + const installSource = detectInstallSource(definition, commandPath); + if (installSource !== "unknown") { + return makeProviderMaintenanceForInstallSource({ + definition, + installSource, + executable: binaryPath, + }); + } + } + + if (!hasPathSeparator(binaryPath)) { + return makeProviderMaintenanceForInstallSource({ + definition, + installSource: "unknown", + executable: binaryPath, + }); + } + + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); +} + +export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( + "resolveProviderMaintenanceCapabilitiesEffect", +)(function* ( + definition: PackageManagedProviderMaintenanceDefinition, + options?: ProviderMaintenanceCapabilityResolutionOptions, +) { + const binaryPath = nonEmptyString(options?.binaryPath) ?? definition.binaryName; + if (hasPathSeparator(binaryPath)) { + return resolvePackageManagedProviderMaintenance(definition, options); + } + + const pathEntries = (options?.env?.PATH ?? process.env.PATH ?? "") + .split(options?.platform === "win32" ? ";" : ":") + .filter(Boolean); + const fileSystem = yield* FileSystem.FileSystem; + const executableCandidates = + options?.platform === "win32" + ? WINDOWS_EXECUTABLE_EXTENSIONS.map((extension) => `${binaryPath}${extension}`) + : [binaryPath]; + for (const entry of pathEntries) { + for (const executableCandidate of executableCandidates) { + const candidate = `${entry}/${executableCandidate}`; + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + continue; + } + const realCommandPath = yield* fileSystem + .realPath(candidate) + .pipe(Effect.catch(() => Effect.succeed(candidate))); + return resolvePackageManagedProviderMaintenance(definition, { + ...options, + binaryPath, + realCommandPath, + }); + } + } + + return resolvePackageManagedProviderMaintenance(definition, { + ...options, + binaryPath, + }); +}); + +function deriveVersionAdvisory(input: { + readonly currentVersion: string | null; + readonly latestVersion: string | null; +}): Pick { + if (!input.currentVersion || !input.latestVersion) { + return { status: "unknown", message: null }; + } + if (compareSemverVersions(input.currentVersion, input.latestVersion) < 0) { + return { + status: "behind_latest", + message: PROVIDER_UPDATE_ACTION_MESSAGE, + }; + } + return { status: "current", message: null }; +} + +export function createProviderVersionAdvisory(input: { + readonly provider: ProviderKind; + readonly currentVersion: string | null; + readonly latestVersion?: string | null; + readonly checkedAt?: string | null; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; +}): ServerProviderVersionAdvisory { + const capabilities = + input.maintenanceCapabilities ?? + makeManualOnlyProviderMaintenanceCapabilities({ provider: input.provider, packageName: null }); + const latestVersion = input.latestVersion ?? null; + const advisory = deriveVersionAdvisory({ + currentVersion: input.currentVersion, + latestVersion, + }); + + return { + status: advisory.status, + currentVersion: input.currentVersion, + latestVersion, + updateCommand: capabilities.update?.command ?? null, + canUpdate: capabilities.update !== null, + checkedAt: input.checkedAt ?? null, + message: advisory.message, + }; +} + +const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) { + return yield* Effect.tryPromise(async () => { + const response = await fetch( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(LATEST_VERSION_TIMEOUT_MS), + }, + ); + if (!response.ok) { + return null; + } + const payload = (await response.json()) as { version?: unknown }; + return nonEmptyString(payload.version); + }).pipe(Effect.catch(() => Effect.succeed(null))); +}); + +const fetchHomebrewLatestVersion = Effect.fn("fetchHomebrewLatestVersion")(function* ( + source: ProviderLatestVersionSource, +) { + if (source.kind !== "homebrew" || !source.homebrewKind) { + return null; + } + return yield* Effect.tryPromise(async () => { + const response = await fetch( + `https://formulae.brew.sh/api/${source.homebrewKind}/${encodeURIComponent(source.name)}.json`, + { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(LATEST_VERSION_TIMEOUT_MS), + }, + ); + if (!response.ok) { + return null; + } + const payload = (await response.json()) as { + version?: unknown; + versions?: { stable?: unknown }; + }; + return nonEmptyString( + source.homebrewKind === "cask" ? payload.version : payload.versions?.stable, + ); + }).pipe(Effect.catch(() => Effect.succeed(null))); +}); + +export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* ( + maintenanceCapabilities: ProviderMaintenanceCapabilities, +) { + const source = maintenanceCapabilities.latestVersionSource; + if (!source) { + return null; + } + + const cacheKey = + source.kind === "homebrew" + ? `homebrew:${source.homebrewKind ?? "unknown"}:${source.name}` + : `npm:${source.name}`; + const cached = latestVersionCache.get(cacheKey); + const now = DateTime.toEpochMillis(yield* DateTime.now); + if (cached && cached.expiresAt > now) { + return cached.version; + } + + const version = + source.kind === "homebrew" + ? yield* fetchHomebrewLatestVersion(source) + : yield* fetchNpmLatestVersion(source.name); + latestVersionCache.set(cacheKey, { + expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, + version, + }); + return version; +}); + +export const enrichProviderStatusWithVersionAdvisory = Effect.fn( + "enrichProviderStatusWithVersionAdvisory", +)(function* ( + status: ServerProviderStatus, + maintenanceCapabilities: ProviderMaintenanceCapabilities, +) { + if (!status.available || !status.version) { + return { + ...status, + versionAdvisory: createProviderVersionAdvisory({ + provider: status.provider, + currentVersion: status.version ?? null, + checkedAt: status.checkedAt, + maintenanceCapabilities, + }), + }; + } + + const latestVersion = yield* resolveLatestProviderVersion(maintenanceCapabilities); + return { + ...status, + versionAdvisory: createProviderVersionAdvisory({ + provider: status.provider, + currentVersion: status.version, + latestVersion, + checkedAt: DateTime.formatIso(yield* DateTime.now), + maintenanceCapabilities, + }), + }; +}); diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts new file mode 100644 index 00000000..1b0fc6d9 --- /dev/null +++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts @@ -0,0 +1,82 @@ +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +export interface ProviderMaintenanceCommandCoordinatorShape { + readonly withCommandLock: (input: { + readonly targetKey: string; + readonly lockKey: string; + readonly onQueued?: Effect.Effect; + readonly run: Effect.Effect; + }) => Effect.Effect; +} + +export const makeProviderMaintenanceCommandCoordinator = Effect.fn( + "makeProviderMaintenanceCommandCoordinator", +)(function* (input: { readonly makeAlreadyRunningError: (targetKey: string) => E }) { + const runningTargetsRef = yield* Ref.make>(new Set()); + const locksRef = yield* Ref.make>(new Map()); + + const acquireTarget = Effect.fn("acquireProviderMaintenanceTarget")(function* ( + targetKey: string, + ) { + return yield* Ref.modify(runningTargetsRef, (runningTargets) => { + if (runningTargets.has(targetKey)) { + return [false, runningTargets] as const; + } + const next = new Set(runningTargets); + next.add(targetKey); + return [true, next] as const; + }); + }); + + const releaseTarget = (targetKey: string) => + Ref.update(runningTargetsRef, (runningTargets) => { + const next = new Set(runningTargets); + next.delete(targetKey); + return next; + }); + + const getLock = Effect.fn("getProviderMaintenanceCommandLock")(function* (lockKey: string) { + const existing = (yield* Ref.get(locksRef)).get(lockKey); + if (existing) { + return existing; + } + + const lock = yield* Semaphore.make(1); + return yield* Ref.modify(locksRef, (locks) => { + const current = locks.get(lockKey); + if (current) { + return [current, locks] as const; + } + const next = new Map(locks); + next.set(lockKey, lock); + return [lock, next] as const; + }); + }); + + const withCommandLock: ProviderMaintenanceCommandCoordinatorShape["withCommandLock"] = ({ + targetKey, + lockKey, + onQueued, + run, + }) => + Effect.gen(function* () { + const acquired = yield* acquireTarget(targetKey); + if (!acquired) { + return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); + } + + return yield* Effect.gen(function* () { + const lock = yield* getLock(lockKey); + if (onQueued) { + yield* onQueued; + } + return yield* lock.withPermits(1)(run); + }).pipe(Effect.ensuring(releaseTarget(targetKey))); + }); + + return { + withCommandLock, + } satisfies ProviderMaintenanceCommandCoordinatorShape; +}); diff --git a/apps/server/src/stream/collectUint8StreamText.ts b/apps/server/src/stream/collectUint8StreamText.ts new file mode 100644 index 00000000..3baafd2b --- /dev/null +++ b/apps/server/src/stream/collectUint8StreamText.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +export interface CollectedUint8StreamText { + readonly text: string; + readonly truncated: boolean; +} + +export function collectUint8StreamText(input: { + readonly stream: Stream.Stream; + readonly maxBytes?: number; +}): Effect.Effect { + const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY; + const decoder = new TextDecoder(); + return Stream.runFold( + input.stream, + () => ({ chunks: [] as Uint8Array[], byteLength: 0, truncated: false }), + (state, chunk) => { + if (state.truncated || state.byteLength >= maxBytes) { + return { ...state, truncated: true }; + } + const remaining = maxBytes - state.byteLength; + const nextChunk = chunk.byteLength <= remaining ? chunk : chunk.slice(0, remaining); + return { + chunks: [...state.chunks, nextChunk], + byteLength: state.byteLength + nextChunk.byteLength, + truncated: chunk.byteLength > remaining, + }; + }, + ).pipe( + Effect.map((state) => ({ + text: state.chunks.map((chunk) => decoder.decode(chunk, { stream: true })).join("") + + decoder.decode(), + truncated: state.truncated, + })), + ); +} diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 0e5adb08..c8b59482 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -490,6 +490,7 @@ export const makeWsRpcLayer = () => providerHealth.refresh.pipe(Effect.map((providers) => ({ providers }))), "Failed to refresh providers", ), + [WS_METHODS.serverUpdateProvider]: (input) => providerHealth.updateProvider(input), [WS_METHODS.serverListWorktrees]: () => Effect.succeed({ worktrees: [] }), [WS_METHODS.serverGetProviderUsageSnapshot]: (input) => rpcEffect(getProviderUsageSnapshot(input), "Failed to load provider usage"), diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index f29b32ce..3be264bc 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -29,6 +29,7 @@ import { type ThreadToastData = { allowCrossThreadVisibility?: boolean; copyText?: string; + secondaryActionProps?: React.ComponentProps; threadId?: ThreadId | null; tooltipStyle?: boolean; dismissAfterVisibleMs?: number; @@ -168,13 +169,15 @@ function ThreadToastVisibleAutoDismiss({ function ToastActions({ actionProps, copyText, + secondaryActionProps, }: { actionProps: ToastObject["actionProps"]; copyText: string | undefined; + secondaryActionProps: ThreadToastData["secondaryActionProps"]; }) { const { copyToClipboard, isCopied } = useCopyToClipboard(); - if (!actionProps && !copyText) return null; + if (!actionProps && !copyText && !secondaryActionProps) return null; return (
@@ -202,6 +205,14 @@ function ToastActions({ {actionProps.children} )} + {secondaryActionProps && ( +
); } @@ -368,7 +379,11 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { className="min-w-0 break-words text-muted-foreground" data-slot="toast-description" /> - + @@ -452,6 +467,7 @@ function AnchoredToasts() { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 16aa456c..7af5cdb7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,10 +1,12 @@ import { + PROVIDER_DISPLAY_NAMES, ThreadId, type OrchestrationEvent, type OrchestrationShellSnapshot, type OrchestrationShellStreamEvent, type OrchestrationThread, type ServerConfig, + type ServerProviderStatus, } from "@t3tools/contracts"; import { defaultTerminalTitleForCliKind } from "@t3tools/shared/terminalThreads"; import { @@ -16,7 +18,7 @@ import { useRouterState, useSearch, } from "@tanstack/react-router"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; @@ -37,7 +39,7 @@ import { serverQueryKeys, serverSettingsQueryOptions, } from "../lib/serverReactQuery"; -import { readNativeApi } from "../nativeApi"; +import { ensureNativeApi, readNativeApi } from "../nativeApi"; import { finalizePromotedDraftThreads, markPromotedDraftThreads, @@ -75,6 +77,7 @@ import { providerDiscoveryQueryKeys } from "../lib/providerDiscoveryReactQuery"; const SHELL_SNAPSHOT_BOOTSTRAP_FALLBACK_DELAY_MS = 1_500; const THREAD_DETAIL_CATCHUP_INTERVAL_MS = 1_500; +const seenProviderUpdateNotificationKeys = new Set(); function shellThreadHasStarted(thread: OrchestrationShellSnapshot["threads"][number]): boolean { return thread.latestTurn !== null || thread.session !== null; @@ -136,6 +139,7 @@ function RootRouteView() { + @@ -143,6 +147,180 @@ function RootRouteView() { ); } +function ProviderUpdateNotifications() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const [isUpdatingAll, setIsUpdatingAll] = useState(false); + const updateToastIdRef = useRef | null>(null); + const outdatedProviders = useMemo( + () => + (serverConfigQuery.data?.providers ?? []).filter( + (provider) => + provider.versionAdvisory?.status === "behind_latest" && + provider.versionAdvisory.canUpdate, + ), + [serverConfigQuery.data?.providers], + ); + + const updateAll = useCallback( + async (providers: ReadonlyArray) => { + if (isUpdatingAll || providers.length === 0) { + return; + } + + setIsUpdatingAll(true); + if (updateToastIdRef.current) { + toastManager.update(updateToastIdRef.current, { + type: "loading", + title: "Updating providers...", + description: + providers.length === 1 + ? `Updating ${PROVIDER_DISPLAY_NAMES[providers[0]!.provider]}.` + : `Updating ${providers.length} providers.`, + actionProps: undefined, + data: undefined, + timeout: 0, + }); + } + + const api = ensureNativeApi(); + const failures: Array<{ provider: ServerProviderStatus; reason: string }> = []; + + for (const provider of providers) { + try { + const result = await api.server.updateProvider({ provider: provider.provider }); + const refreshed = result.providers.find((entry) => entry.provider === provider.provider); + const updateState = refreshed?.updateState; + if (updateState?.status === "failed" || updateState?.status === "unchanged") { + failures.push({ + provider, + reason: updateState.message ?? "The update command did not complete successfully.", + }); + } else if (refreshed?.versionAdvisory?.status === "behind_latest") { + failures.push({ + provider, + reason: "The provider still appears outdated after updating.", + }); + } + } catch (error) { + failures.push({ + provider, + reason: error instanceof Error ? error.message : "The update request failed.", + }); + } + } + + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + setIsUpdatingAll(false); + + if (failures.length > 0) { + if (updateToastIdRef.current) { + toastManager.close(updateToastIdRef.current); + updateToastIdRef.current = null; + } + toastManager.add({ + type: "error", + title: + failures.length === providers.length + ? "Provider updates failed" + : "Some provider updates failed", + description: failures + .map( + ({ provider, reason }) => `${PROVIDER_DISPLAY_NAMES[provider.provider]}: ${reason}`, + ) + .join("\n"), + }); + return; + } + + if (updateToastIdRef.current) { + toastManager.update(updateToastIdRef.current, { + type: "success", + title: + providers.length === 1 + ? `${PROVIDER_DISPLAY_NAMES[providers[0]!.provider]} updated` + : `${providers.length} providers updated`, + description: "New sessions will use the refreshed provider tools.", + timeout: 6000, + }); + updateToastIdRef.current = null; + } else { + toastManager.add({ + type: "success", + title: + providers.length === 1 + ? `${PROVIDER_DISPLAY_NAMES[providers[0]!.provider]} updated` + : `${providers.length} providers updated`, + description: "New sessions will use the refreshed provider tools.", + }); + } + }, + [isUpdatingAll, queryClient], + ); + + useEffect(() => { + if (outdatedProviders.length === 0 || isUpdatingAll) { + return; + } + + const newNotifications = outdatedProviders.filter((provider) => { + const notificationKey = `${provider.provider}:${provider.versionAdvisory?.latestVersion ?? "unknown"}`; + if (seenProviderUpdateNotificationKeys.has(notificationKey)) { + return false; + } + seenProviderUpdateNotificationKeys.add(notificationKey); + return true; + }); + + if (newNotifications.length === 0) { + return; + } + + const firstProvider = outdatedProviders[0]!; + const additionalCount = outdatedProviders.length - 1; + const providerName = PROVIDER_DISPLAY_NAMES[firstProvider.provider]; + const title = + outdatedProviders.length === 1 + ? `${providerName} update available` + : `${outdatedProviders.length} provider updates available`; + const description = + outdatedProviders.length === 1 + ? `${providerName} has a newer version available.` + : `${providerName} and ${additionalCount} more provider${additionalCount === 1 ? "" : "s"} have newer versions available.`; + + updateToastIdRef.current = toastManager.add({ + type: "warning", + title, + description, + timeout: 0, + actionProps: { + children: "Review updates", + onClick: () => { + if (updateToastIdRef.current) { + toastManager.close(updateToastIdRef.current); + updateToastIdRef.current = null; + } + void navigate({ + to: "/settings", + search: { section: "providers", target: "provider-updates" }, + }); + }, + }, + data: { + secondaryActionProps: { + children: "Update all", + onClick: () => { + void updateAll(outdatedProviders); + }, + }, + }, + }); + }, [isUpdatingAll, navigate, outdatedProviders, updateAll]); + + return null; +} + function GlobalShortcutsDialog() { const [open, setOpen] = useState(false); const { focusedThreadId, activeProject } = useFocusedChatContext(); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 0f812077..193ff023 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -6,13 +6,14 @@ import { PROVIDER_DISPLAY_NAMES, type ProviderKind, + type ServerProviderStatus, type ThreadId, DEFAULT_GIT_TEXT_GENERATION_MODEL, } from "@t3tools/contracts"; import { createFileRoute, useSearch } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { closestCenter, DndContext, @@ -74,6 +75,8 @@ import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { ArchiveIcon, ChevronDownIcon, + DownloadIcon, + Loader2Icon, PlusIcon, RotateCcwIcon, Undo2Icon, @@ -437,11 +440,57 @@ function normalizeManagedWorktreePath(value: string | null | undefined): string return trimmed && trimmed.length > 0 ? trimmed : null; } +function formatProviderVersion(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; +} + +function providerUpdateStatusLabel(provider: ServerProviderStatus): string | null { + const state = provider.updateState?.status; + if (state === "queued") { + return "Update queued"; + } + if (state === "running") { + return "Updating"; + } + if (state === "succeeded") { + return "Updated"; + } + if (state === "failed") { + return "Update failed"; + } + if (state === "unchanged") { + return "Still outdated"; + } + const advisory = provider.versionAdvisory; + if (advisory?.status === "behind_latest" && advisory.latestVersion) { + const currentVersion = formatProviderVersion(advisory.currentVersion); + const latestVersion = formatProviderVersion(advisory.latestVersion); + return currentVersion + ? `${currentVersion} -> ${latestVersion}` + : `Latest ${latestVersion}`; + } + const currentVersion = formatProviderVersion(provider.version); + return currentVersion ? `Current ${currentVersion}` : null; +} + +function providerUpdateFailureMessage(provider: ServerProviderStatus | undefined): string | null { + const state = provider?.updateState; + if (!state || (state.status !== "failed" && state.status !== "unchanged")) { + return null; + } + return state.output?.trim() || state.message || "The provider update did not complete."; +} + // ── Route screen ─────────────────────────────────────────────────────────── function SettingsRouteView() { const routeSearch = useSearch({ strict: false }) as Record; const activeSection = normalizeSettingsSection(routeSearch.section); + const settingsTarget = typeof routeSearch.target === "string" ? routeSearch.target : null; const activeSectionItem = SETTINGS_NAV_ITEMS.find((item) => item.id === activeSection)!; const { isDefaultActiveTheme, resetAllThemes, resolvedTheme, theme, setTheme } = useTheme(); @@ -467,6 +516,8 @@ function SettingsRouteView() { const [showRecoveryTools, setShowRecoveryTools] = useState(false); const [releaseHistoryOpen, setReleaseHistoryOpen] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const providerUpdatesRef = useRef(null); + const providerInstallsRef = useRef(null); const [openInstallProviders, setOpenInstallProviders] = useState>({ codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), claudeAgent: Boolean(settings.claudeBinaryPath), @@ -478,6 +529,9 @@ function SettingsRouteView() { ), pi: Boolean(settings.piBinaryPath || settings.piAgentDir), }); + const [updatingProviders, setUpdatingProviders] = useState>( + () => new Set(), + ); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = useState("codex"); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< @@ -543,6 +597,38 @@ function SettingsRouteView() { const piAgentDir = settings.piAgentDir; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const providerStatusByProvider = useMemo( + () => + new Map((serverConfigQuery.data?.providers ?? []).map((status) => [status.provider, status])), + [serverConfigQuery.data?.providers], + ); + const outdatedProviderCount = useMemo( + () => + (serverConfigQuery.data?.providers ?? []).filter( + (status) => status.versionAdvisory?.status === "behind_latest", + ).length, + [serverConfigQuery.data?.providers], + ); + const outdatedProviderStatuses = useMemo( + () => + (serverConfigQuery.data?.providers ?? []).filter( + (status) => status.versionAdvisory?.status === "behind_latest", + ), + [serverConfigQuery.data?.providers], + ); + const shouldFocusProviderUpdates = + activeSection === "providers" && settingsTarget === "provider-updates"; + + useEffect(() => { + if (!shouldFocusProviderUpdates) { + return; + } + + const frame = window.requestAnimationFrame(() => { + providerUpdatesRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [serverConfigQuery.data?.providers, shouldFocusProviderUpdates]); const managedWorktrees = serverWorktreesQuery.data?.worktrees ?? []; const worktreesByWorkspaceRoot = managedWorktrees.reduce< Array<{ @@ -797,6 +883,49 @@ function SettingsRouteView() { [settings.providerOrder, updateSettings], ); + const runProviderUpdate = useCallback( + async (provider: ProviderKind) => { + if (updatingProviders.has(provider)) { + return; + } + setUpdatingProviders((current) => new Set(current).add(provider)); + try { + const result = await ensureNativeApi().server.updateProvider({ provider }); + const refreshedProvider = result.providers.find((status) => status.provider === provider); + const failureMessage = providerUpdateFailureMessage(refreshedProvider); + if (failureMessage) { + toastManager.add({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[provider]}`, + description: failureMessage, + }); + return; + } + toastManager.add({ + type: "success", + title: `${PROVIDER_DISPLAY_NAMES[provider]} update finished`, + description: "New sessions will use the refreshed provider.", + }); + } catch (error) { + toastManager.add({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[provider]}`, + description: error instanceof Error ? error.message : "The provider update failed.", + }); + } finally { + await queryClient + .invalidateQueries({ queryKey: serverQueryKeys.config() }) + .catch(() => undefined); + setUpdatingProviders((current) => { + const next = new Set(current); + next.delete(provider); + return next; + }); + } + }, + [queryClient, updatingProviders], + ); + async function restoreDefaults() { if (changedSettingLabels.length === 0) return; @@ -2223,6 +2352,7 @@ function SettingsRouteView() { const renderProvidersPanel = () => (
+ {renderProviderUpdatesSection()}
+ {renderProviderInstallsSection()}
); - const renderAdvancedPanel = () => ( -
- + const renderProviderUpdatesSection = () => ( +
+
0 + ? `${outdatedProviderCount} update${outdatedProviderCount === 1 ? "" : "s"} available` + : "No provider updates detected" + } + > + {outdatedProviderStatuses.length > 0 ? ( +
+ {outdatedProviderStatuses.map((providerStatus) => { + const updateAdvisory = providerStatus.versionAdvisory; + const updateState = providerStatus.updateState?.status; + const isProviderUpdateActive = + updateState === "queued" || + updateState === "running" || + updatingProviders.has(providerStatus.provider); + const canUpdateProvider = + updateAdvisory?.canUpdate === true && !isProviderUpdateActive; + const updateLabel = providerUpdateStatusLabel(providerStatus); + + return ( +
+
+
+ {PROVIDER_DISPLAY_NAMES[providerStatus.provider]} +
+ {updateLabel ? ( +
+ {updateLabel} +
+ ) : null} +
+ {updateAdvisory?.canUpdate ? ( + + ) : ( + + Manual update + + )} +
+ ); + })} +
+ ) : null} +
+
+
+
+ ); + + const renderProviderInstallsSection = () => ( +
+ +
+ 0 + ? `${outdatedProviderCount} update${outdatedProviderCount === 1 ? "" : "s"} available` + : "No provider updates detected" + } resetAction={ isInstallSettingsDirty ? ( { updateSettings({ claudeBinaryPath: defaults.claudeBinaryPath, @@ -2328,7 +2541,7 @@ function SettingsRouteView() { } >
-
+
{INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { const isOpen = openInstallProviders[providerSettings.provider]; const isDirty = @@ -2366,6 +2579,20 @@ function SettingsRouteView() { : providerSettings.binaryPathKey === "piBinaryPath" ? piBinaryPath : codexBinaryPath; + const providerStatus = providerStatusByProvider.get(providerSettings.provider); + const providerUpdateLabel = providerStatus + ? providerUpdateStatusLabel(providerStatus) + : null; + const updateAdvisory = providerStatus?.versionAdvisory; + const providerUpdateState = providerStatus?.updateState?.status; + const isProviderUpdateActive = + providerUpdateState === "queued" || + providerUpdateState === "running" || + updatingProviders.has(providerSettings.provider); + const canUpdateProvider = + updateAdvisory?.status === "behind_latest" && + updateAdvisory.canUpdate && + !isProviderUpdateActive; return ( -
- + {updateAdvisory?.status === "behind_latest" && + updateAdvisory.canUpdate ? ( + ) : null} - - +
-
+
+ {updateAdvisory?.status === "behind_latest" ? ( +
+ {updateAdvisory.canUpdate && updateAdvisory.updateCommand ? ( + <> + Command: + + {updateAdvisory.updateCommand} + + + ) : ( + "A newer version is available, but DP Code could not identify a safe one-click update command for this installation." + )} +
+ ) : null} +
+
+ ); + const renderAdvancedPanel = () => ( +
transport.request(WS_METHODS.serverRefreshProviders), + updateProvider: (input) => transport.request(WS_METHODS.serverUpdateProvider, input), listWorktrees: () => transport.request(WS_METHODS.serverListWorktrees), getProviderUsageSnapshot: (input) => transport.request(WS_METHODS.serverGetProviderUsageSnapshot, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 396b7241..50297ea3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -63,6 +63,8 @@ import type { ServerGetProviderUsageSnapshotResult, ServerGetSettingsResult, ServerListWorktreesResult, + ServerProviderUpdateInput, + ServerProviderUpdateResult, ServerRefreshProvidersResult, ServerUpdateSettingsInput, ServerUpdateSettingsResult, @@ -395,6 +397,7 @@ export interface NativeApi { revokeAuthClient: (input: AuthRevokeClientSessionInput) => Promise<{ revoked: boolean }>; revokeOtherAuthClients: () => Promise<{ revokedCount: number }>; refreshProviders: () => Promise; + updateProvider: (input: ServerProviderUpdateInput) => Promise; listWorktrees: () => Promise; getProviderUsageSnapshot: ( input: ServerGetProviderUsageSnapshotInput, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6759b5d6..8dfe7c3e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -84,6 +84,9 @@ import { ServerLifecycleStreamEvent, ServerGetSettingsResult, ServerListWorktreesResult, + ServerProviderUpdateError, + ServerProviderUpdateInput, + ServerProviderUpdateResult, ServerRefreshProvidersResult, ServerUpdateSettingsInput, ServerUpdateSettingsResult, @@ -434,6 +437,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: WsRpcError, }); +export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { + payload: ServerProviderUpdateInput, + success: ServerProviderUpdateResult, + error: ServerProviderUpdateError, +}); + export const WsServerListWorktreesRpc = Rpc.make(WS_METHODS.serverListWorktrees, { payload: Schema.Struct({}), success: ServerListWorktreesResult, @@ -594,6 +603,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerRefreshProvidersRpc, + WsServerUpdateProviderRpc, WsServerListWorktreesRpc, WsServerGetProviderUsageSnapshotRpc, WsServerTranscribeVoiceRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 53e106c9..1f47a293 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -51,11 +51,37 @@ export const ServerProviderStatus = Schema.Struct({ authType: Schema.optional(TrimmedNonEmptyString), authLabel: Schema.optional(TrimmedNonEmptyString), voiceTranscriptionAvailable: Schema.optional(Schema.Boolean), + version: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), + versionAdvisory: Schema.optionalKey( + Schema.Struct({ + status: Schema.Literals(["unknown", "current", "behind_latest"]), + currentVersion: Schema.NullOr(TrimmedNonEmptyString), + latestVersion: Schema.NullOr(TrimmedNonEmptyString), + updateCommand: Schema.NullOr(TrimmedNonEmptyString), + canUpdate: Schema.Boolean, + checkedAt: Schema.NullOr(IsoDateTime), + message: Schema.NullOr(TrimmedNonEmptyString), + }), + ), + updateState: Schema.optionalKey( + Schema.Struct({ + status: Schema.Literals(["idle", "queued", "running", "succeeded", "failed", "unchanged"]), + startedAt: Schema.NullOr(IsoDateTime), + finishedAt: Schema.NullOr(IsoDateTime), + message: Schema.NullOr(TrimmedNonEmptyString), + output: Schema.NullOr(Schema.String.check(Schema.isMaxLength(10_000))), + }), + ), }); export type ServerProviderStatus = typeof ServerProviderStatus.Type; +export type ServerProviderVersionAdvisory = NonNullable< + ServerProviderStatus["versionAdvisory"] +>; +export type ServerProviderUpdateState = NonNullable; + const ServerProviderStatuses = Schema.Array(ServerProviderStatus); export const ServerConfig = Schema.Struct({ @@ -218,6 +244,26 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; export const ServerRefreshProvidersResult = ServerProviderStatusesUpdatedPayload; export type ServerRefreshProvidersResult = typeof ServerRefreshProvidersResult.Type; +export const ServerProviderUpdateInput = Schema.Struct({ + provider: ProviderKind, +}); +export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; + +export class ServerProviderUpdateError extends Schema.TaggedErrorClass()( + "ServerProviderUpdateError", + { + provider: ProviderKind, + reason: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Provider update failed for ${this.provider}: ${this.reason}`; + } +} + +export const ServerProviderUpdateResult = ServerProviderStatusesUpdatedPayload; +export type ServerProviderUpdateResult = typeof ServerProviderUpdateResult.Type; + export const ServerGetSettingsResult = ServerSettings; export type ServerGetSettingsResult = typeof ServerGetSettingsResult.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 6f03e7f5..c9026ed8 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -63,6 +63,7 @@ import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload, ServerLifecycleStreamEvent, + ServerProviderUpdateInput, ServerUpdateSettingsInput, ServerGetProviderUsageSnapshotInput, ServerProviderStatusesUpdatedPayload, @@ -133,6 +134,7 @@ export const WS_METHODS = { serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverRefreshProviders: "server.refreshProviders", + serverUpdateProvider: "server.updateProvider", serverListWorktrees: "server.listWorktrees", serverGetProviderUsageSnapshot: "server.getProviderUsageSnapshot", serverTranscribeVoice: "server.transcribeVoice", @@ -247,6 +249,7 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpdateSettings, ServerUpdateSettingsInput), tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverUpdateProvider, ServerProviderUpdateInput), tagRequestBody(WS_METHODS.serverListWorktrees, Schema.Struct({})), tagRequestBody(WS_METHODS.serverGetProviderUsageSnapshot, ServerGetProviderUsageSnapshotInput), tagRequestBody(WS_METHODS.serverTranscribeVoice, ServerVoiceTranscriptionInput), From d8982b1d2cb72f7b57512f5bc7c97451578a35bc Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 15 May 2026 00:26:47 +0300 Subject: [PATCH 2/2] Align provider probes with configured binaries --- .../provider/Layers/ProviderHealth.test.ts | 107 ++++++- .../src/provider/Layers/ProviderHealth.ts | 301 +++++++----------- 2 files changed, 221 insertions(+), 187 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 05a80c83..e4325723 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -10,6 +10,11 @@ import { checkCursorProviderStatus, checkOpenCodeProviderStatus, hasCustomModelProvider, + makeCheckClaudeProviderStatus, + makeCheckCodexProviderStatus, + makeCheckCursorProviderStatus, + makeCheckKiloProviderStatus, + makeCheckOpenCodeProviderStatus, parseAuthStatusFromOutput, parseClaudeAuthStatusFromOutput, readCodexConfigModelProvider, @@ -35,13 +40,17 @@ function mockHandle(result: { stdout: string; stderr: string; code: number }) { } function mockSpawnerLayer( - handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, + handler: (args: ReadonlyArray, command: string) => { + stdout: string; + stderr: string; + code: number; + }, ) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { args: ReadonlyArray }; - return Effect.succeed(mockHandle(handler(cmd.args))); + const cmd = command as unknown as { command: string; args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.args, cmd.command))); }), ); } @@ -126,6 +135,24 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("uses configured codex binary for version and auth probes", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* makeCheckCodexProviderStatus("/custom/bin/codex"); + assert.strictEqual(status.status, "ready"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args, command) => { + assert.strictEqual(command, "/custom/bin/codex"); + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns unavailable when codex is missing", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -496,6 +523,28 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("uses configured claude binary for version and auth probes", () => + Effect.gen(function* () { + const status = yield* makeCheckClaudeProviderStatus(undefined, "/custom/bin/claude"); + assert.strictEqual(status.status, "ready"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args, command) => { + assert.strictEqual(command, "/custom/bin/claude"); + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns unavailable when claude is missing", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus; @@ -619,6 +668,22 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("uses configured opencode binary for version probe", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenCodeProviderStatus("/custom/bin/opencode"); + assert.strictEqual(status.status, "ready"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args, command) => { + assert.strictEqual(command, "/custom/bin/opencode"); + const joined = args.join(" "); + if (joined === "--version") return { stdout: "opencode 1.3.17\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns unavailable when opencode is missing", () => Effect.gen(function* () { const status = yield* checkOpenCodeProviderStatus; @@ -634,6 +699,24 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ); }); + describe("checkKiloProviderStatus", () => { + it.effect("uses configured Kilo binary for version probe", () => + Effect.gen(function* () { + const status = yield* makeCheckKiloProviderStatus("/custom/bin/kilo"); + assert.strictEqual(status.status, "ready"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args, command) => { + assert.strictEqual(command, "/custom/bin/kilo"); + const joined = args.join(" "); + if (joined === "--version") return { stdout: "kilo 7.2.52\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + describe("checkCursorProviderStatus", () => { it.effect("returns ready when Cursor Agent is installed", () => Effect.gen(function* () { @@ -655,6 +738,24 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); + it.effect("uses configured Cursor Agent binary for version probe", () => + Effect.gen(function* () { + const status = yield* makeCheckCursorProviderStatus("/custom/bin/agent"); + assert.strictEqual(status.status, "ready"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args, command) => { + assert.strictEqual(command, "/custom/bin/agent"); + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "agent 2026.04.27\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns unavailable when Cursor Agent is missing", () => Effect.gen(function* () { const status = yield* checkCursorProviderStatus; diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 1898f0d0..6d397ad3 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -686,10 +686,10 @@ const collectStreamAsString = (stream: Stream.Stream): Effect. (acc, chunk) => acc + new TextDecoder().decode(chunk), ); -const runCodexCommand = (args: ReadonlyArray) => +const runProviderCommand = (executable: string, args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { + const command = ChildProcess.make(executable, [...args], { shell: process.platform === "win32", env: process.env, }); @@ -705,180 +705,86 @@ const runCodexCommand = (args: ReadonlyArray) => { concurrency: "unbounded" }, ); - if (isWindowsShellCommandMissingResult({ code: exitCode, stderr })) { - return yield* Effect.fail(new Error("spawn codex ENOENT")); - } - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); - -const runClaudeCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("claude", [...args], { - shell: process.platform === "win32", - env: process.env, - }); - - const child = yield* spawner.spawn(command); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - if (isWindowsShellCommandMissingResult({ code: exitCode, stderr })) { - return yield* Effect.fail(new Error("spawn claude ENOENT")); - } - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); - -const runGeminiCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("gemini", [...args], { - shell: process.platform === "win32", - env: process.env, - }); - - const child = yield* spawner.spawn(command); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - if (isWindowsShellCommandMissingResult({ code: exitCode, stderr })) { - return yield* Effect.fail(new Error("spawn gemini ENOENT")); - } - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); - -const runOpenCodeCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("opencode", [...args], { - shell: process.platform === "win32", - env: process.env, - }); - - const child = yield* spawner.spawn(command); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - if (isWindowsShellCommandMissingResult({ code: exitCode, stderr })) { - return yield* Effect.fail(new Error("spawn opencode ENOENT")); - } - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); - -const runKiloCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("kilo", [...args], { - shell: process.platform === "win32", - env: process.env, - }); - - const child = yield* spawner.spawn(command); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - if (isWindowsShellCommandMissingResult({ code: exitCode, stderr })) { - return yield* Effect.fail(new Error("spawn kilo ENOENT")); - } - return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); -const runCursorCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("agent", [...args], { - shell: process.platform === "win32", - env: process.env, - }); - - const child = yield* spawner.spawn(command); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - if (isWindowsShellCommandMissingResult({ code: exitCode, stderr })) { - return yield* Effect.fail(new Error("spawn agent ENOENT")); - } +const runCodexCommand = (args: ReadonlyArray, executable = "codex") => + runProviderCommand(executable, args).pipe( + Effect.flatMap((result) => + isWindowsShellCommandMissingResult({ code: result.code, stderr: result.stderr }) + ? Effect.fail(new Error(`spawn ${executable} ENOENT`)) + : Effect.succeed(result), + ), + ); - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); +const runClaudeCommand = (args: ReadonlyArray, executable = "claude") => + runProviderCommand(executable, args).pipe( + Effect.flatMap((result) => + isWindowsShellCommandMissingResult({ code: result.code, stderr: result.stderr }) + ? Effect.fail(new Error(`spawn ${executable} ENOENT`)) + : Effect.succeed(result), + ), + ); -const runPiCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("pi", [...args], { - shell: process.platform === "win32", - env: process.env, - }); +const runGeminiCommand = (args: ReadonlyArray, executable = "gemini") => + runProviderCommand(executable, args).pipe( + Effect.flatMap((result) => + isWindowsShellCommandMissingResult({ code: result.code, stderr: result.stderr }) + ? Effect.fail(new Error(`spawn ${executable} ENOENT`)) + : Effect.succeed(result), + ), + ); - const child = yield* spawner.spawn(command); +const runOpenCodeCommand = (args: ReadonlyArray, executable = "opencode") => + runProviderCommand(executable, args).pipe( + Effect.flatMap((result) => + isWindowsShellCommandMissingResult({ code: result.code, stderr: result.stderr }) + ? Effect.fail(new Error(`spawn ${executable} ENOENT`)) + : Effect.succeed(result), + ), + ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); +const runKiloCommand = (args: ReadonlyArray, executable = "kilo") => + runProviderCommand(executable, args).pipe( + Effect.flatMap((result) => + isWindowsShellCommandMissingResult({ code: result.code, stderr: result.stderr }) + ? Effect.fail(new Error(`spawn ${executable} ENOENT`)) + : Effect.succeed(result), + ), + ); - if (isWindowsShellCommandMissingResult({ code: exitCode, stderr })) { - return yield* Effect.fail(new Error("spawn pi ENOENT")); - } +const runCursorCommand = (args: ReadonlyArray, executable = "agent") => + runProviderCommand(executable, args).pipe( + Effect.flatMap((result) => + isWindowsShellCommandMissingResult({ code: result.code, stderr: result.stderr }) + ? Effect.fail(new Error(`spawn ${executable} ENOENT`)) + : Effect.succeed(result), + ), + ); - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); +const runPiCommand = (args: ReadonlyArray, executable = "pi") => + runProviderCommand(executable, args).pipe( + Effect.flatMap((result) => + isWindowsShellCommandMissingResult({ code: result.code, stderr: result.stderr }) + ? Effect.fail(new Error(`spawn ${executable} ENOENT`)) + : Effect.succeed(result), + ), + ); // ── Health check ──────────────────────────────────────────────────── -export const checkCodexProviderStatus: Effect.Effect< +export const makeCheckCodexProviderStatus = ( + binaryPath?: string, +): Effect.Effect< ServerProviderStatus, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path -> = Effect.gen(function* () { +> => Effect.gen(function* () { const checkedAt = new Date().toISOString(); + const executable = nonEmptyTrimmed(binaryPath) ?? "codex"; // Probe 1: `codex --version` — is the CLI reachable? - const versionProbe = yield* runCodexCommand(["--version"]).pipe( + const versionProbe = yield* runCodexCommand(["--version"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -953,7 +859,7 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; } - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( + const authProbe = yield* runCodexCommand(["login", "status"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1017,6 +923,8 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkCodexProviderStatus = makeCheckCodexProviderStatus(); + // ── Claude Agent health check ─────────────────────────────────────── export function parseClaudeAuthStatusFromOutput(result: CommandResult): { @@ -1103,12 +1011,14 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { export const makeCheckClaudeProviderStatus = ( resolveSubscriptionType?: Effect.Effect, + binaryPath?: string, ): Effect.Effect => Effect.gen(function* () { const checkedAt = new Date().toISOString(); + const executable = nonEmptyTrimmed(binaryPath) ?? "claude"; // Probe 1: `claude --version` — is the CLI reachable? - const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + const versionProbe = yield* runClaudeCommand(["--version"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1156,7 +1066,7 @@ export const makeCheckClaudeProviderStatus = ( const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); // Probe 2: `claude auth status` — is the user authenticated? - const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( + const authProbe = yield* runClaudeCommand(["auth", "status"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1218,14 +1128,17 @@ export const makeCheckClaudeProviderStatus = ( export const checkClaudeProviderStatus = makeCheckClaudeProviderStatus(); -export const checkGeminiProviderStatus: Effect.Effect< +export const makeCheckGeminiProviderStatus = ( + binaryPath?: string, +): Effect.Effect< ServerProviderStatus, never, ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { +> => Effect.gen(function* () { const checkedAt = new Date().toISOString(); + const executable = nonEmptyTrimmed(binaryPath) ?? "gemini"; - const versionProbe = yield* runGeminiCommand(["--version"]).pipe( + const versionProbe = yield* runGeminiCommand(["--version"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1272,7 +1185,7 @@ export const checkGeminiProviderStatus: Effect.Effect< const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); const capabilityProbe = yield* probeGeminiCapabilities({ - binaryPath: "gemini", + binaryPath: executable, cwd: OS.homedir(), }).pipe(Effect.result); @@ -1304,16 +1217,21 @@ export const checkGeminiProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkGeminiProviderStatus = makeCheckGeminiProviderStatus(); + // ── OpenCode health check ─────────────────────────────────────────── -export const checkOpenCodeProviderStatus: Effect.Effect< +export const makeCheckOpenCodeProviderStatus = ( + binaryPath?: string, +): Effect.Effect< ServerProviderStatus, never, ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { +> => Effect.gen(function* () { const checkedAt = new Date().toISOString(); + const executable = nonEmptyTrimmed(binaryPath) ?? "opencode"; - const versionProbe = yield* runOpenCodeCommand(["--version"]).pipe( + const versionProbe = yield* runOpenCodeCommand(["--version"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1370,16 +1288,21 @@ export const checkOpenCodeProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkOpenCodeProviderStatus = makeCheckOpenCodeProviderStatus(); + // ── Kilo health check ─────────────────────────────────────────────── -export const checkKiloProviderStatus: Effect.Effect< +export const makeCheckKiloProviderStatus = ( + binaryPath?: string, +): Effect.Effect< ServerProviderStatus, never, ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { +> => Effect.gen(function* () { const checkedAt = new Date().toISOString(); + const executable = nonEmptyTrimmed(binaryPath) ?? "kilo"; - const versionProbe = yield* runKiloCommand(["--version"]).pipe( + const versionProbe = yield* runKiloCommand(["--version"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1436,14 +1359,18 @@ export const checkKiloProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkKiloProviderStatus = makeCheckKiloProviderStatus(); + // ── Pi health check ───────────────────────────────────────────── export const checkPiProviderStatus = ( agentDir?: string, + binaryPath?: string, ): Effect.Effect => Effect.gen(function* () { const checkedAt = new Date().toISOString(); - const versionProbe = yield* runPiCommand(["--version"]).pipe( + const executable = nonEmptyTrimmed(binaryPath) ?? "pi"; + const versionProbe = yield* runPiCommand(["--version"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1495,14 +1422,17 @@ export const checkPiProviderStatus = ( // ── Cursor health check ───────────────────────────────────────────── -export const checkCursorProviderStatus: Effect.Effect< +export const makeCheckCursorProviderStatus = ( + binaryPath?: string, +): Effect.Effect< ServerProviderStatus, never, ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { +> => Effect.gen(function* () { const checkedAt = new Date().toISOString(); + const executable = nonEmptyTrimmed(binaryPath) ?? "agent"; - const versionProbe = yield* runCursorCommand(["--version"]).pipe( + const versionProbe = yield* runCursorCommand(["--version"], executable).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -1560,6 +1490,8 @@ export const checkCursorProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkCursorProviderStatus = makeCheckCursorProviderStatus(); + // ── Snapshot helpers ──────────────────────────────────────────────── function providerStatusesEqual(left: ProviderStatuses, right: ProviderStatuses): boolean { @@ -1657,8 +1589,6 @@ export const ProviderHealthLive = Layer.effect( Effect.map((probe) => probe?.subscriptionType), ); - const checkClaude = makeCheckClaudeProviderStatus(resolveClaudeSubscription); - const getProviderBinaryPath = (provider: ProviderKind, settings: ServerSettings) => { switch (provider) { case "codex": @@ -1784,13 +1714,16 @@ export const ProviderHealthLive = Layer.effect( Effect.flatMap((settings) => Effect.all( [ - checkCodexProviderStatus, - checkClaude, - checkCursorProviderStatus, - checkGeminiProviderStatus, - checkKiloProviderStatus, - checkOpenCodeProviderStatus, - checkPiProviderStatus(settings.providers.pi.agentDir), + makeCheckCodexProviderStatus(settings.providers.codex.binaryPath), + makeCheckClaudeProviderStatus( + resolveClaudeSubscription, + settings.providers.claudeAgent.binaryPath, + ), + makeCheckCursorProviderStatus(settings.providers.cursor.binaryPath), + makeCheckGeminiProviderStatus(settings.providers.gemini.binaryPath), + makeCheckKiloProviderStatus(settings.providers.kilo.binaryPath), + makeCheckOpenCodeProviderStatus(settings.providers.opencode.binaryPath), + checkPiProviderStatus(settings.providers.pi.agentDir, settings.providers.pi.binaryPath), ], { concurrency: "unbounded",