diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index d4c4768d2c..f0c7c30e20 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 311f495865..3d59ff2724 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -14,6 +14,7 @@ */ import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; @@ -34,12 +35,39 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); +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/") + ); +} + +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@anthropic-ai/claude-code", + homebrewFormula: "claude-code", + nativeUpdate: { + executable: "claude", + args: ["update"], + lockKey: "claude-native", + isCommandPath: isClaudeNativeCommandPath, + }, +}); + export type ClaudeDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem @@ -75,6 +103,9 @@ export const ClaudeDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const httpClient = yield* Effect.service(HttpClient.HttpClient).pipe( + Effect.provide(FetchHttpClient.layer), + ); const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ @@ -82,6 +113,10 @@ export const ClaudeDriver: ProviderDriver = { instanceId, }); const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); const stampIdentity = withInstanceIdentity({ instanceId, @@ -121,11 +156,17 @@ export const ClaudeDriver: ProviderDriver = { ); const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 26fffd5e21..d822e025e8 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -23,6 +23,7 @@ */ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; @@ -35,6 +36,11 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; import { codexContinuationIdentity, materializeCodexShadowHome, @@ -43,6 +49,12 @@ import { const DRIVER_KIND = ProviderDriverKind.make("codex"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@openai/codex", + homebrewFormula: "codex", + nativeUpdate: null, +}); /** * Services the driver needs to materialize an instance. Surfaced as the @@ -89,6 +101,9 @@ export const CodexDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* Effect.service(HttpClient.HttpClient).pipe( + Effect.provide(FetchHttpClient.layer), + ); const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); @@ -115,6 +130,10 @@ export const CodexDriver: ProviderDriver = { enabled, homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error // channels at construction time — their failure modes are all on the @@ -138,11 +157,17 @@ export const CodexDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index cd058800f2..31aa3f1a97 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -14,6 +14,7 @@ */ import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; @@ -34,9 +35,23 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; const DRIVER_KIND = ProviderDriverKind.make("cursor"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: "agent", + updateArgs: ["update"], + updateLockKey: "cursor-agent", + }), +); export type CursorDriverEnv = | ChildProcessSpawner.ChildProcessSpawner @@ -74,6 +89,9 @@ export const CursorDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const httpClient = yield* Effect.service(HttpClient.HttpClient).pipe( + Effect.provide(FetchHttpClient.layer), + ); const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -87,6 +105,10 @@ export const CursorDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies CursorSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const adapter = yield* makeCursorAdapter(effectiveConfig, { environment: processEnv, @@ -103,6 +125,7 @@ export const CursorDriver: ProviderDriver = { ); const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, @@ -117,8 +140,10 @@ export const CursorDriver: ProviderDriver = { settings, environment: processEnv, snapshot: currentSnapshot, + maintenanceCapabilities, publishSnapshot, stampIdentity, + httpClient, }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 27f98a9830..e5cc075e7a 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -14,6 +14,7 @@ */ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; @@ -34,10 +35,36 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; const DRIVER_KIND = ProviderDriverKind.make("opencode"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +function isOpenCodeNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.opencode/bin/opencode") || + normalized.endsWith("/.opencode/bin/opencode.exe") + ); +} + +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "opencode-ai", + homebrewFormula: "anomalyco/tap/opencode", + nativeUpdate: { + executable: "opencode", + args: ["upgrade"], + lockKey: "opencode-native", + isCommandPath: isOpenCodeNativeCommandPath, + }, +}); + export type OpenCodeDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem @@ -74,6 +101,9 @@ export const OpenCodeDriver: ProviderDriver Effect.gen(function* () { const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; + const httpClient = yield* Effect.service(HttpClient.HttpClient).pipe( + Effect.provide(FetchHttpClient.layer), + ); const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -87,6 +117,10 @@ export const OpenCodeDriver: ProviderDriver continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { instanceId, @@ -102,11 +136,17 @@ export const OpenCodeDriver: ProviderDriver ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, getSettings: Effect.succeed(effectiveConfig), streamSettings: Stream.never, haveSettingsChanged: () => false, initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 0917d842a6..618103883a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -15,7 +15,6 @@ import type { import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; - import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index ad52f63fbb..4a8b1cdcf9 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -12,6 +12,7 @@ import type { import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect"; +import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, @@ -29,6 +30,10 @@ import { type CommandResult, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; const PROVIDER = ProviderDriverKind.make("cursor"); @@ -1216,42 +1221,67 @@ export const enrichCursorSnapshot = (input: { readonly settings: CursorSettings; readonly environment?: NodeJS.ProcessEnv; readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; }): Effect.Effect => { const { settings, snapshot, publishSnapshot } = input; const stampIdentity = input.stampIdentity ?? ((value) => value); - if ( - !settings.enabled || - snapshot.auth.status === "unauthenticated" || - !hasUncapturedCursorModels(snapshot) - ) { - return Effect.void; - } + const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory( + snapshot, + input.maintenanceCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => + publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), + ), + Effect.catchCause((cause) => + Effect.logWarning("Cursor version advisory enrichment failed", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(snapshot)), + ), + ); - return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe( - Effect.flatMap((discoveredModels) => { - if (discoveredModels.length === 0) { + return enrichVersionAdvisory.pipe( + Effect.flatMap((baseSnapshot) => { + if ( + !settings.enabled || + baseSnapshot.auth.status === "unauthenticated" || + !hasUncapturedCursorModels(baseSnapshot) + ) { return Effect.void; } - return publishSnapshot( - stampIdentity({ - ...snapshot, - models: providerModelsFromSettings( - discoveredModels, - PROVIDER, - settings.customModels, - EMPTY_CAPABILITIES, - ), + + return discoverCursorModelCapabilitiesViaAcp( + settings, + baseSnapshot.models, + input.environment, + ).pipe( + Effect.flatMap((discoveredModels) => { + if (discoveredModels.length === 0) { + return Effect.void; + } + return publishSnapshot( + stampIdentity({ + ...baseSnapshot, + models: providerModelsFromSettings( + discoveredModels, + PROVIDER, + settings.customModels, + EMPTY_CAPABILITIES, + ), + }), + ); }), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP background capability enrichment failed", { + models: baseSnapshot.models.map((model) => model.slug), + cause: Cause.pretty(cause), + }).pipe(Effect.asVoid), + ), ); }), - Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP background capability enrichment failed", { - models: snapshot.models.map((model) => model.slug), - cause: Cause.pretty(cause), - }).pipe(Effect.asVoid), - ), ); }; diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index c7487d7d52..6431282d63 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -7,7 +7,6 @@ import { import { Cause, Data, Effect } from "effect"; import { createModelCapabilities } from "@t3tools/shared/model"; - import { buildServerProvider, nonEmptyTrimmed, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index eeba158ab4..a37eeae6b8 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -111,6 +112,10 @@ const makeFakeInstance = ( displayName: undefined, enabled: true, snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driverKind, + packageName: null, + }), getSnapshot: Effect.succeed({} as unknown as ServerProvider), refresh: Effect.succeed({} as unknown as ServerProvider), streamChanges: Stream.empty, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 75f9c42936..9296aedf0c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -26,13 +26,16 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr import { haveProvidersChanged, mergeProviderSnapshot, + mergeProviderSnapshots, ProviderRegistryLive, + selectProvidersByKind, } from "./ProviderRegistry.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); @@ -500,6 +503,70 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ]); }); + it("persists merged provider snapshots for the providers that were refreshed", () => { + const previousProviders = [ + { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoning", "Reasoning", [ + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("thinking", "Thinking"), + ], + }), + }, + ], + slashCommands: [], + skills: [], + }, + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + ] as const satisfies ReadonlyArray; + const refreshedCursor = { + ...previousProviders[0], + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); + const persistedProviders = selectProvidersByKind( + mergedProviders, + new Set([ProviderDriverKind.make("cursor")]), + ); + + assert.deepStrictEqual(persistedProviders, [ + { + ...refreshedCursor, + models: [...previousProviders[0].models], + }, + ]); + }); + it.effect("returns the cached provider list when a manual refresh fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); @@ -527,6 +594,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( displayName: undefined, enabled: true, snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), getSnapshot: Effect.succeed(cachedProvider), refresh: Effect.die(new Error("simulated refresh failure")), streamChanges: Stream.empty, @@ -612,6 +683,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( displayName: undefined, enabled: true, snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: provider.driver, + packageName: null, + }), getSnapshot: Effect.succeed(provider), refresh: Effect.succeed(provider), streamChanges: Stream.empty, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 4f586d881e..c337615c95 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -27,6 +27,7 @@ import { ProviderDriverKind, type ProviderInstanceId, type ServerProvider, + type ServerProviderUpdateState, } from "@t3tools/contracts"; import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; import * as Semaphore from "effect/Semaphore"; @@ -43,6 +44,7 @@ import { writeProviderStatusCache, } from "../providerStatusCache.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; const loadProviders = ( @@ -59,6 +61,12 @@ const loadProviders = ( }, ); +const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) => + makeManualOnlyProviderMaintenanceCapabilities({ + provider, + packageName: null, + }); + const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; @@ -96,6 +104,30 @@ export const mergeProviderSnapshot = ( models: mergeProviderModels(previousProvider.models, nextProvider.models), }; +export const mergeProviderSnapshots = ( + previousProviders: ReadonlyArray, + nextProviders: ReadonlyArray, +): ReadonlyArray => { + const mergedProviders = new Map( + previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), + ); + + for (const provider of nextProviders) { + mergedProviders.set( + snapshotInstanceKey(provider), + mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), + ); + } + + return orderProviderSnapshots([...mergedProviders.values()]); +}; + +export const selectProvidersByKind = ( + providers: ReadonlyArray, + providerKinds: ReadonlySet, +): ReadonlyArray => + providers.filter((provider) => providerKinds.has(provider.driver)); + export const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, @@ -227,6 +259,9 @@ export const ProviderRegistryLive = Layer.effect( ), ); const providersRef = yield* Ref.make>(cachedProviders); + const maintenanceActionStatesRef = yield* Ref.make< + ReadonlyMap + >(new Map()); // Live-source registry — the dynamic counterpart to the boot-time // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` @@ -264,6 +299,21 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( + provider: ServerProvider, + ) { + const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); + const updateState = maintenanceActionStates.get(provider.instanceId)?.update; + if (!updateState) { + const { updateState: _updateState, ...providerWithoutUpdateState } = provider; + return providerWithoutUpdateState; + } + return { + ...provider, + updateState, + }; + }); + const upsertProviders = Effect.fn("upsertProviders")(function* ( nextProviders: ReadonlyArray, options?: { @@ -272,6 +322,13 @@ export const ProviderRegistryLive = Layer.effect( readonly replace?: boolean; }, ) { + const nextProvidersWithUpdateState = yield* Effect.forEach( + nextProviders, + applyProviderUpdateState, + { + concurrency: "unbounded", + }, + ); const [previousProviders, providers] = yield* Ref.modify( providersRef, (previousProviders) => { @@ -279,7 +336,7 @@ export const ProviderRegistryLive = Layer.effect( previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), ); - for (const provider of nextProviders) { + for (const provider of nextProvidersWithUpdateState) { const key = snapshotInstanceKey(provider); mergedProviders.set( key, @@ -296,7 +353,7 @@ export const ProviderRegistryLive = Layer.effect( if (haveProvidersChanged(previousProviders, providers)) { if (options?.persist !== false) { - yield* Effect.forEach(nextProviders, persistProvider, { + yield* Effect.forEach(nextProvidersWithUpdateState, persistProvider, { concurrency: "unbounded", discard: true, }); @@ -318,6 +375,45 @@ export const ProviderRegistryLive = Layer.effect( return yield* upsertProviders([provider], options); }); + const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly action: "update"; + readonly state: ServerProviderUpdateState | null; + }) { + yield* Ref.update(maintenanceActionStatesRef, (previous) => { + const previousActions = previous.get(input.instanceId); + const nextActions = { ...previousActions }; + if (input.state === null || input.state.status === "idle") { + delete nextActions[input.action]; + } else { + nextActions[input.action] = input.state; + } + + const next = new Map(previous); + if (Object.keys(nextActions).length === 0) { + next.delete(input.instanceId); + } else { + next.set(input.instanceId, nextActions); + } + return next; + }); + + const existingProviders = yield* Ref.get(providersRef); + const matchingProvider = existingProviders.find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!matchingProvider) { + return existingProviders; + } + + const nextProvider = yield* applyProviderUpdateState(matchingProvider); + return yield* upsertProviders([nextProvider], { + persist: false, + }); + }, + ); + const refreshOneSource = Effect.fn("refreshOneSource")(function* ( providerSource: ProviderSnapshotSource, ) { @@ -365,6 +461,18 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshOneSource(providerSource); }); + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( + "getProviderMaintenanceCapabilitiesForInstance", + )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( + (candidate) => candidate.instanceId === instanceId, + ); + return ( + instance?.snapshot.maintenanceCapabilities ?? + makeManualProviderMaintenanceCapabilities(provider) + ); + }); + /** * Diff the aggregator's live-source set against the current * `ProviderInstanceRegistry` and: @@ -472,6 +580,15 @@ export const ProviderRegistryLive = Layer.effect( if (haveProvidersChanged(previousProviders, providers)) { yield* PubSub.publish(changesPubSub, providers); } + yield* Ref.update(maintenanceActionStatesRef, (previous) => { + const next = new Map(previous); + for (const instanceId of previous.keys()) { + if (!knownInstanceIds.has(instanceId)) { + next.delete(instanceId); + } + } + return next; + }); }), ); const syncLiveSourcesAndContinue = syncLiveSources.pipe( @@ -555,6 +672,8 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + getProviderMaintenanceCapabilitiesForInstance, + setProviderMaintenanceActionState, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index 13a87bd873..b131c1c5b8 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -6,9 +6,17 @@ * * @module ProviderRegistry */ -import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; +import type { + ProviderInstanceId, + ProviderDriverKind, + ServerProvider, + ServerProviderUpdateState, +} from "@t3tools/contracts"; import { Context } from "effect"; import type { Effect, Stream } from "effect"; +import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; + +export type ProviderMaintenanceActionKind = "update"; export interface ProviderRegistryShape { /** @@ -39,6 +47,27 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** + * Resolve the maintenance capabilities owned by one live provider instance. + * Falls back to manual-only capabilities when the instance is not live. + */ + readonly getProviderMaintenanceCapabilitiesForInstance: ( + instanceId: ProviderInstanceId, + provider: ProviderDriverKind, + ) => Effect.Effect; + + /** + * Apply volatile maintenance-action state to one configured instance. + * This state is never persisted to disk. Today only update actions are + * projected onto `ServerProvider.updateState`; install/auth actions can + * extend this action map without adding driver-scoped APIs. + */ + readonly setProviderMaintenanceActionState: (input: { + readonly instanceId: ProviderInstanceId; + readonly action: ProviderMaintenanceActionKind; + readonly state: ServerProviderUpdateState | null; + }) => Effect.Effect>; + /** * Stream of provider snapshot updates — one emission per aggregated * change. The array contains the full current state. diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts index 4df0bc8fc2..fb52080d50 100644 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -1,7 +1,9 @@ import type { ServerProvider } from "@t3tools/contracts"; import type { Effect, Stream } from "effect"; +import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; export interface ServerProviderShape { + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; readonly streamChanges: Stream.Stream; diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index ff66476380..8595a56d5b 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -20,6 +20,20 @@ interface TestSettings { readonly enabled: boolean; } +const maintenanceCapabilities = { + provider: ProviderDriverKind.make("codex"), + packageName: "@openai/codex", + update: { + command: "npm install -g @openai/codex@latest", + + executable: "npm", + + args: ["install", "-g", "@openai/codex@latest"], + + lockKey: "npm-global", + }, +} as const; + const initialSnapshot: ServerProvider = { instanceId: ProviderInstanceId.make("codex"), driver: ProviderDriverKind.make("codex"), @@ -90,6 +104,7 @@ describe("makeManagedServerProvider", () => { const checkCalls = yield* Ref.make(0); const releaseCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, @@ -131,6 +146,7 @@ describe("makeManagedServerProvider", () => { const releaseInitialCheck = yield* Deferred.make(); const releaseSettingsCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, getSettings: Ref.get(settingsRef), streamSettings: Stream.fromPubSub(settingsChanges), haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, @@ -172,6 +188,7 @@ describe("makeManagedServerProvider", () => { const releaseEnrichment = yield* Deferred.make(); const releaseCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, @@ -212,6 +229,7 @@ describe("makeManagedServerProvider", () => { const secondCallbackReady = yield* Deferred.make(); const allowFirstRefresh = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 4787a9f9cb..a6800da065 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -13,6 +13,7 @@ interface ProviderSnapshotState { export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < Settings, >(input: { + readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"]; readonly getSettings: Effect.Effect; readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; @@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); return { + maintenanceCapabilities: input.maintenanceCapabilities, getSnapshot: input.getSettings.pipe( Effect.flatMap(applySnapshot), Effect.tapError(Effect.logError), diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts new file mode 100644 index 0000000000..20c47dd5f5 --- /dev/null +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -0,0 +1,487 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import os from "node:os"; +import path from "node:path"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { Effect, Random } from "effect"; +import { + clearLatestProviderVersionCacheForTests, + createProviderVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + makeProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "./providerMaintenance.ts"; + +const driver = (value: string) => ProviderDriverKind.make(value); +const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) { + const id = yield* Random.nextUUIDv4; + return path.join(os.tmpdir(), `${name}-${id}`); +}); +const isNativeTestCommandPath = + (expectedPathSegment: string) => + (commandPath: string): boolean => + normalizeCommandPath(commandPath).includes(expectedPathSegment); +const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + homebrewFormula: "package-tool", + nativeUpdate: null, +}); +const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("nativePackageTool"), + npmPackageName: "@example/native-package-tool", + homebrewFormula: "native-package-tool", + nativeUpdate: { + executable: "native-package-tool", + args: ["update"], + lockKey: "native-package-tool-native", + isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), + }, +}); +const scopedPackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("scopedPackageTool"), + npmPackageName: "@example/scoped-package-tool", + homebrewFormula: "example/tap/scoped-package-tool", + nativeUpdate: { + executable: "scoped-package-tool", + args: ["upgrade"], + lockKey: "scoped-package-tool-native", + isCommandPath: isNativeTestCommandPath("/.scoped-package-tool/bin/scoped-package-tool"), + }, +}); +const staticToolUpdate = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: driver("staticTool"), + packageName: null, + updateExecutable: "static-tool", + updateArgs: ["update"], + updateLockKey: "static-tool", + }), +); + +afterEach(() => { + clearLatestProviderVersionCacheForTests(); +}); + +describe("providerMaintenance", () => { + it("marks providers with unknown current versions as unknown", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("packageTool"), + currentVersion: null, + latestVersion: "9.9.9", + }), + ).toMatchObject({ + status: "unknown", + currentVersion: null, + latestVersion: "9.9.9", + }); + }); + + it("marks providers with unknown latest versions as unknown", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("packageTool"), + currentVersion: "1.0.0", + latestVersion: null, + }), + ).toMatchObject({ + status: "unknown", + currentVersion: "1.0.0", + latestVersion: null, + message: null, + }); + }); + + it("marks installed providers behind latest when a newer provider version is available", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("nativePackageTool"), + currentVersion: "2.1.110", + latestVersion: "2.1.117", + maintenanceCapabilities: nativePackageToolUpdate.resolve(), + }), + ).toMatchObject({ + status: "behind_latest", + currentVersion: "2.1.110", + latestVersion: "2.1.117", + updateCommand: "npm install -g @example/native-package-tool@latest", + canUpdate: true, + message: "Install the update now or review provider settings.", + }); + }); + + it("keeps update commands owned by provider maintenance capabilities", () => { + expect(staticToolUpdate.resolve()).toEqual({ + provider: driver("staticTool"), + packageName: null, + update: { + command: "static-tool update", + + executable: "static-tool", + + args: ["update"], + + lockKey: "static-tool", + }, + }); + }); + + it.effect( + "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-vite-plus-capabilities"); + const vitePlusBinDir = path.join(tempDir, ".vite-plus", "bin"); + mkdirSync(vitePlusBinDir, { recursive: true }); + const packageToolPath = path.join(vitePlusBinDir, "package-tool"); + writeFileSync(packageToolPath, "#!/bin/sh\n"); + chmodSync(packageToolPath, 0o755); + + expect( + packageToolUpdate.resolve({ + binaryPath: "package-tool", + platform: "darwin", + env: { + PATH: vitePlusBinDir, + }, + }), + ).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "vp i -g @example/package-tool", + + executable: "vp", + + args: ["i", "-g", "@example/package-tool"], + + lockKey: "vite-plus-global", + }, + }); + }), + ); + + it.effect( + "switches package-managed providers to bun updates when the resolved binary lives in bun's global bin", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-bun-capabilities"); + const bunBinDir = path.join(tempDir, ".bun", "bin"); + mkdirSync(bunBinDir, { recursive: true }); + writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); + + expect( + nativePackageToolUpdate.resolve({ + binaryPath: "native-package-tool", + platform: "win32", + env: { + PATH: bunBinDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + update: { + command: "bun i -g @example/native-package-tool@latest", + + executable: "bun", + + args: ["i", "-g", "@example/native-package-tool@latest"], + + lockKey: "bun-global", + }, + }); + }), + ); + + it.effect( + "switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-pnpm-capabilities"); + const pnpmHomeDir = path.join(tempDir, ".local", "share", "pnpm"); + mkdirSync(pnpmHomeDir, { recursive: true }); + const scopedPackageToolPath = path.join(pnpmHomeDir, "scoped-package-tool"); + writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); + chmodSync(scopedPackageToolPath, 0o755); + + expect( + scopedPackageToolUpdate.resolve({ + binaryPath: "scoped-package-tool", + platform: "darwin", + env: { + PATH: pnpmHomeDir, + }, + }), + ).toEqual({ + provider: driver("scopedPackageTool"), + packageName: "@example/scoped-package-tool", + update: { + command: "pnpm add -g @example/scoped-package-tool@latest", + + executable: "pnpm", + + args: ["add", "-g", "@example/scoped-package-tool@latest"], + + lockKey: "pnpm-global", + }, + }); + }), + ); + + it("switches package-tool to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + packageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/package-tool", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "brew upgrade package-tool", + + executable: "brew", + + args: ["upgrade", "package-tool"], + + lockKey: "homebrew", + }, + }); + }); + + it.effect( + "switches native-package-tool to native updates when the binary resolves through the native installer", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-native-package-tool-native-capabilities"); + const nativeBinDir = path.join(tempDir, ".local", "bin"); + mkdirSync(nativeBinDir, { recursive: true }); + const nativePackageToolPath = path.join(nativeBinDir, "native-package-tool"); + writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); + chmodSync(nativePackageToolPath, 0o755); + + expect( + nativePackageToolUpdate.resolve({ + binaryPath: "native-package-tool", + platform: "darwin", + env: { + PATH: nativeBinDir, + }, + }), + ).toEqual({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + update: { + command: "native-package-tool update", + + executable: "native-package-tool", + + args: ["update"], + + lockKey: "native-package-tool-native", + }, + }); + }), + ); + + it.effect( + "switches scoped-package-tool to native upgrades when the binary resolves through the standalone installer", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-scoped-package-tool-native-capabilities"); + const nativeBinDir = path.join(tempDir, ".scoped-package-tool", "bin"); + mkdirSync(nativeBinDir, { recursive: true }); + const scopedPackageToolPath = path.join(nativeBinDir, "scoped-package-tool"); + writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); + chmodSync(scopedPackageToolPath, 0o755); + + expect( + scopedPackageToolUpdate.resolve({ + binaryPath: "scoped-package-tool", + platform: "darwin", + env: { + PATH: nativeBinDir, + }, + }), + ).toEqual({ + provider: driver("scopedPackageTool"), + packageName: "@example/scoped-package-tool", + update: { + command: "scoped-package-tool upgrade", + + executable: "scoped-package-tool", + + args: ["upgrade"], + + lockKey: "scoped-package-tool-native", + }, + }); + }), + ); + + it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + nativePackageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/native-package-tool", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + update: { + command: "brew upgrade native-package-tool", + + executable: "brew", + + args: ["upgrade", "native-package-tool"], + + lockKey: "homebrew", + }, + }); + }); + + it("switches scoped-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { + expect( + scopedPackageToolUpdate.resolve({ + binaryPath: "/opt/homebrew/bin/scoped-package-tool", + platform: "darwin", + env: { + PATH: "", + }, + }), + ).toEqual({ + provider: driver("scopedPackageTool"), + packageName: "@example/scoped-package-tool", + update: { + command: "brew upgrade example/tap/scoped-package-tool", + + executable: "brew", + + args: ["upgrade", "example/tap/scoped-package-tool"], + + lockKey: "homebrew", + }, + }); + }); + + it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-capabilities"); + const binDir = path.join(tempDir, "bin"); + const packageBinDir = path.join( + tempDir, + "lib", + "node_modules", + "@example", + "package-tool", + "bin", + ); + mkdirSync(binDir, { recursive: true }); + mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = path.join(packageBinDir, "package-tool.js"); + const symlinkPath = path.join(binDir, "package-tool"); + writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + chmodSync(packageBinPath, 0o755); + symlinkSync(packageBinPath, symlinkPath); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: symlinkPath, + platform: "darwin", + env: { + PATH: "", + }, + }).pipe(Effect.provide(NodeServices.layer)); + + expect(capabilities).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "npm install -g @example/package-tool@latest", + + executable: "npm", + + args: ["install", "-g", "@example/package-tool@latest"], + + lockKey: "npm-global", + }, + }); + }), + ); + + it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities"); + const binDir = path.join(tempDir, "bin"); + const packageBinDir = path.join( + tempDir, + ".local", + "share", + "pnpm", + "global", + "5", + "node_modules", + "@example", + "package-tool", + "bin", + ); + mkdirSync(binDir, { recursive: true }); + mkdirSync(packageBinDir, { recursive: true }); + const packageBinPath = path.join(packageBinDir, "package-tool.js"); + const symlinkPath = path.join(binDir, "package-tool"); + writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); + chmodSync(packageBinPath, 0o755); + symlinkSync(packageBinPath, symlinkPath); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: symlinkPath, + platform: "darwin", + env: { + PATH: "", + }, + }).pipe(Effect.provide(NodeServices.layer)); + + expect(capabilities).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: { + command: "pnpm add -g @example/package-tool@latest", + + executable: "pnpm", + + args: ["add", "-g", "@example/package-tool@latest"], + + lockKey: "pnpm-global", + }, + }); + }), + ); + + it("disables one-click updates for explicit custom binary paths it cannot safely map", () => { + expect( + packageToolUpdate.resolve({ + binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", + platform: "win32", + env: { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + update: null, + }); + }); +}); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts new file mode 100644 index 0000000000..5a44a5ec21 --- /dev/null +++ b/apps/server/src/provider/providerMaintenance.ts @@ -0,0 +1,472 @@ +import { + ProviderDriverKind, + type ServerProvider, + type ServerProviderVersionAdvisory, +} from "@t3tools/contracts"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import { DateTime, Effect, FileSystem, Option, Schema } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { compareCliVersions } from "./cliVersion.ts"; + +const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; +const LATEST_VERSION_TIMEOUT_MS = 4_000; +const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; + +export interface ProviderMaintenanceCapabilities { + readonly provider: ProviderDriverKind; + readonly packageName: string | 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 ProviderMaintenanceCapabilitiesResolver { + readonly resolve: ( + options?: ProviderMaintenanceCapabilityResolutionOptions, + ) => ProviderMaintenanceCapabilities; +} + +export interface PackageManagedProviderMaintenanceDefinition { + readonly provider: ProviderDriverKind; + readonly npmPackageName: string; + readonly homebrewFormula: string | null; + readonly nativeUpdate: { + readonly executable: string; + readonly args: ReadonlyArray; + readonly lockKey: string; + readonly isCommandPath: (commandPath: string) => boolean; + } | null; +} + +interface LatestVersionCacheEntry { + readonly expiresAt: number; + readonly version: string | null; +} + +const latestVersionCache = new Map(); +const NpmLatestVersionResponse = Schema.Struct({ + version: Schema.optional(Schema.String), +}); + +export function clearLatestProviderVersionCacheForTests(): void { + latestVersionCache.clear(); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export function makeProviderMaintenanceCapabilities(input: { + readonly provider: ProviderDriverKind; + readonly packageName: string | 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, + update, + }; +} + +export function makeManualOnlyProviderMaintenanceCapabilities(input: { + readonly provider: ProviderDriverKind; + 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 makeVitePlusGlobalProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "vp", + updateArgs: ["i", "-g", definition.npmPackageName], + updateLockKey: "vite-plus-global", + }); +} + +function makeHomebrewProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities { + if (!definition.homebrewFormula) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); + } + + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: "brew", + updateArgs: ["upgrade", definition.homebrewFormula], + updateLockKey: "homebrew", + }); +} + +function makeNativeProviderMaintenanceCapabilities( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilities | null { + if (!definition.nativeUpdate) { + return null; + } + + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + updateExecutable: definition.nativeUpdate.executable, + updateArgs: definition.nativeUpdate.args, + updateLockKey: definition.nativeUpdate.lockKey, + }); +} + +export function hasPathSeparator(value: string): boolean { + return value.includes("/") || value.includes("\\"); +} + +export function normalizeCommandPath(commandPath: string): string { + return commandPath.replaceAll("\\", "/").toLowerCase(); +} + +function isBunGlobalCommandPath(commandPath: string): boolean { + return normalizeCommandPath(commandPath).includes("/.bun/bin/"); +} + +function isVitePlusGlobalCommandPath(commandPath: string): boolean { + return normalizeCommandPath(commandPath).includes("/.vite-plus/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/cellar/") || + normalized.includes("/usr/local/cellar/") || + normalized.includes("/homebrew/cellar/") || + normalized.includes("/opt/homebrew/caskroom/") || + normalized.includes("/usr/local/caskroom/") || + normalized.includes("/homebrew/caskroom/") || + 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 makeNpmGlobalProviderMaintenanceCapabilities(definition); + } + + const resolvedCommandPath = + resolveCommandPath(binaryPath, { + ...(options?.platform ? { platform: options.platform } : {}), + ...(options?.env ? { env: options.env } : {}), + }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + + if (resolvedCommandPath) { + const commandPaths = [ + resolvedCommandPath, + ...(options?.realCommandPath ? [options.realCommandPath] : []), + ]; + + const nativeUpdate = definition.nativeUpdate; + if ( + nativeUpdate && + commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath)) + ) { + return ( + makeNativeProviderMaintenanceCapabilities(definition) ?? + makeNpmGlobalProviderMaintenanceCapabilities(definition) + ); + } + if (commandPaths.some(isVitePlusGlobalCommandPath)) { + return makeVitePlusGlobalProviderMaintenanceCapabilities(definition); + } + if (commandPaths.some(isBunGlobalCommandPath)) { + return makeBunGlobalProviderMaintenanceCapabilities(definition); + } + if (commandPaths.some(isPnpmGlobalCommandPath)) { + return makePnpmGlobalProviderMaintenanceCapabilities(definition); + } + if (commandPaths.some(isNpmGlobalCommandPath)) { + return makeNpmGlobalProviderMaintenanceCapabilities(definition); + } + if (commandPaths.some(isHomebrewCommandPath)) { + return makeHomebrewProviderMaintenanceCapabilities(definition); + } + } + + if (!hasPathSeparator(binaryPath)) { + return makeNpmGlobalProviderMaintenanceCapabilities(definition); + } + + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName: definition.npmPackageName, + }); +} + +export function makePackageManagedProviderMaintenanceResolver( + definition: PackageManagedProviderMaintenanceDefinition, +): ProviderMaintenanceCapabilitiesResolver { + return { + resolve: (options) => resolvePackageManagedProviderMaintenance(definition, options), + }; +} + +export function makeStaticProviderMaintenanceResolver( + capabilities: ProviderMaintenanceCapabilities, +): ProviderMaintenanceCapabilitiesResolver { + return { + resolve: () => capabilities, + }; +} + +function makeManualProviderMaintenanceCapabilities( + provider: ProviderDriverKind, +): ProviderMaintenanceCapabilities { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider, + packageName: null, + }); +} + +export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( + "resolveProviderMaintenanceCapabilitiesEffect", +)(function* ( + resolver: ProviderMaintenanceCapabilitiesResolver, + options?: Omit, +) { + const binaryPath = nonEmptyString(options?.binaryPath); + if (!binaryPath) { + return resolver.resolve(options); + } + + const resolvedCommandPath = + resolveCommandPath(binaryPath, { + ...(options?.platform ? { platform: options.platform } : {}), + ...(options?.env ? { env: options.env } : {}), + }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + if (!resolvedCommandPath) { + return resolver.resolve(options); + } + + const fileSystem = yield* FileSystem.FileSystem; + const realCommandPath = yield* fileSystem + .realPath(resolvedCommandPath) + .pipe(Effect.catch(() => Effect.succeed(resolvedCommandPath))); + return resolver.resolve({ + ...options, + realCommandPath, + }); +}); + +function deriveVersionAdvisory(input: { + readonly currentVersion: string | null; + readonly latestVersion: string | null; +}): Pick { + if (!input.currentVersion) { + return { status: "unknown", message: null }; + } + if (!input.latestVersion) { + return { status: "unknown", message: null }; + } + if (compareCliVersions(input.currentVersion, input.latestVersion) < 0) { + return { + status: "behind_latest", + message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE, + }; + } + return { status: "current", message: null }; +} + +export function createProviderVersionAdvisory(input: { + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; + readonly latestVersion?: string | null; + readonly checkedAt?: string | null; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; +}): ServerProviderVersionAdvisory { + const capabilities = + input.maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(input.driver); + 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) { + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + ).pipe(HttpClientRequest.setHeader("accept", "application/json")); + const response = yield* client.execute(request).pipe( + Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), + Effect.catch(() => Effect.succeed(Option.none())), + ); + if (Option.isNone(response)) { + return null; + } + const httpResponse = response.value; + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return null; + } + const payload = yield* httpResponse.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)), + Effect.catch(() => Effect.succeed(null)), + ); + return payload ? nonEmptyString(payload.version) : null; +}); + +export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* ( + maintenanceCapabilities: ProviderMaintenanceCapabilities, +) { + const packageName = maintenanceCapabilities.packageName; + if (!packageName) { + return null; + } + + const cached = latestVersionCache.get(packageName); + const now = DateTime.toEpochMillis(yield* DateTime.now); + if (cached && cached.expiresAt > now) { + return cached.version; + } + + const version = yield* fetchNpmLatestVersion(packageName); + latestVersionCache.set(packageName, { + expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, + version, + }); + return version; +}); + +export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( + "enrichProviderSnapshotWithVersionAdvisory", +)(function* (snapshot: ServerProvider, maintenanceCapabilities?: ProviderMaintenanceCapabilities) { + const capabilities = + maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver); + if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { + return { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + checkedAt: snapshot.checkedAt, + maintenanceCapabilities: capabilities, + }), + }; + } + + const latestVersion = yield* resolveLatestProviderVersion(capabilities); + return { + ...snapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + latestVersion, + checkedAt: DateTime.formatIso(yield* DateTime.now), + maintenanceCapabilities: capabilities, + }), + }; +}); diff --git a/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts new file mode 100644 index 0000000000..875102c836 --- /dev/null +++ b/apps/server/src/provider/providerMaintenanceCommandCoordinator.ts @@ -0,0 +1,79 @@ +import { Effect, Ref } from "effect"; +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("acquireTarget")(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/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts new file mode 100644 index 0000000000..4c0a751a62 --- /dev/null +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -0,0 +1,640 @@ +import { afterEach, describe, it, assert } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUpdateState, +} from "@t3tools/contracts"; +import { ServerProviderUpdateError } from "@t3tools/contracts"; +import { Cause, Effect, Exit, Fiber, Layer, Ref, Schema, Sink, Stream } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; +import { + clearLatestProviderVersionCacheForTests, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; + +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); +const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); +const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); +const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); +const encoder = new TextEncoder(); + +afterEach(() => { + clearLatestProviderVersionCacheForTests(); +}); + +function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { + if (provider === CURSOR_DRIVER) { + return makeProviderMaintenanceCapabilities({ + provider, + packageName: null, + updateExecutable: "agent", + updateArgs: ["update"], + updateLockKey: "cursor-agent", + }); + } + return makeProviderMaintenanceCapabilities({ + provider, + packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", + updateExecutable: "npm", + updateArgs: + provider === OPENCODE_DRIVER + ? ["install", "-g", "opencode-ai@latest"] + : ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + }); +} + +const baseProvider: ServerProvider = { + instanceId: CODEX_INSTANCE_ID, + driver: CODEX_DRIVER, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +const baseCursorProvider: ServerProvider = { + ...baseProvider, + instanceId: CURSOR_INSTANCE_ID, + driver: CURSOR_DRIVER, +}; + +const baseOpenCodeProvider: ServerProvider = { + ...baseProvider, + instanceId: OPENCODE_INSTANCE_ID, + driver: OPENCODE_DRIVER, +}; + +const latestVersionHttpClient = (version: string) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ version }, { headers: { "content-type": "application/json" } }), + ), + ), + ), + ); + +function mockHandle(result: { + readonly stdout?: string; + readonly stderr?: string; + readonly code?: number; + readonly exitCode?: Effect.Effect; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: result.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout ?? "")), + stderr: Stream.make(encoder.encode(result.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawnerLayer( + handler: ( + command: string, + args: ReadonlyArray, + ) => { + readonly stdout?: string; + readonly stderr?: string; + readonly code?: number; + readonly exitCode?: Effect.Effect; + }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); + }), + ); +} + +function makeRegistry( + initialProviders: ServerProvider | ReadonlyArray = baseProvider, +) { + return Effect.gen(function* () { + const providersRef = yield* Ref.make>( + Array.isArray(initialProviders) ? initialProviders : [initialProviders], + ); + const updateStatesRef = yield* Ref.make>([]); + + const setProviderMaintenanceActionState = Effect.fn( + "providerMaintenanceRunner.test.setProviderMaintenanceActionState", + )(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly action: "update"; + readonly state: ServerProviderUpdateState | null; + }) { + const updateState = input.state; + if (updateState) { + yield* Ref.update(updateStatesRef, (states) => [...states, updateState]); + } + return yield* Ref.updateAndGet(providersRef, (providers) => + providers.map((candidate) => { + if (candidate.instanceId !== input.instanceId) { + return candidate; + } + if (!updateState) { + const { updateState: _updateState, ...providerWithoutUpdateState } = candidate; + return providerWithoutUpdateState; + } + return { + ...candidate, + updateState, + }; + }), + ); + }); + + const registry: ProviderRegistryShape = { + getProviders: Ref.get(providersRef), + refresh: () => Ref.get(providersRef), + refreshInstance: () => Ref.get(providersRef), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed(lifecycleFor(provider)), + setProviderMaintenanceActionState, + streamChanges: Stream.empty, + }; + + return { + registry, + updateStatesRef, + }; + }); +} + +const makeTestRunner = (registry: ProviderRegistryShape) => + Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( + Effect.provide( + ProviderMaintenanceRunner.layer.pipe( + Layer.provide(Layer.succeed(ProviderRegistry, registry)), + ), + ), + ); + +describe("providerMaintenanceRunner", () => { + it.effect("runs the allowlisted provider update command and records success", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseCursorProvider); + const updater = yield* makeTestRunner(registry); + + const result = yield* updater.updateProvider(CURSOR_DRIVER); + assert.deepStrictEqual(calls, [ + { + command: "agent", + args: ["update"], + }, + ]); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.deepStrictEqual( + (yield* Ref.get(updateStatesRef)).map((state) => state.status), + ["queued", "running", "succeeded"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("uses the resolved provider capabilities when choosing the update executable", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.0.14", + latestVersion: "2.1.123", + updateCommand: "bun i -g @anthropic-ai/claude-code@latest", + canUpdate: true, + checkedAt: "2026-04-30T12:00:00.000Z", + message: "Update available.", + }, + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "bun", + updateArgs: ["i", "-g", "@openai/codex@latest"], + updateLockKey: "bun-global", + }), + ), + }); + + yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [ + { + command: "bun", + args: ["i", "-g", "@openai/codex@latest"], + }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect( + "runs update commands through Effect ChildProcess when no test runner is injected", + () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const runner = yield* makeTestRunner(registry); + + const result = yield* runner.updateProvider(CODEX_DRIVER); + + assert.deepStrictEqual(calls, [ + { + command: "npm", + args: ["install", "-g", "@openai/codex@latest"], + }, + ]); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }, + ); + + it.effect("updates a single provider instance without touching sibling instances", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const personalInstanceId = ProviderInstanceId.make("codex_personal"); + const workInstanceId = ProviderInstanceId.make("codex_work"); + const refreshedInstanceIds: Array = []; + const { registry } = yield* makeRegistry([ + { + ...baseProvider, + instanceId: personalInstanceId, + version: "0.124.0-alpha.3", + }, + { + ...baseProvider, + instanceId: workInstanceId, + version: "0.124.0-alpha.3", + }, + ]); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (instanceId, provider) => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider, + packageName: "@openai/codex-instance-test", + updateExecutable: "vp", + updateArgs: ["i", "-g", "@openai/codex"], + updateLockKey: "vite-plus-global", + }), + ).pipe( + Effect.tap(() => Effect.sync(() => assert.strictEqual(instanceId, personalInstanceId))), + ), + refreshInstance: (instanceId) => + registry.refreshInstance(instanceId).pipe( + Effect.tap(() => + Effect.sync(() => { + refreshedInstanceIds.push(instanceId); + }), + ), + ), + }); + + const result = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + instanceId: personalInstanceId, + }); + + assert.deepStrictEqual(calls, [ + { + command: "vp", + args: ["i", "-g", "@openai/codex"], + }, + ]); + assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId]); + assert.strictEqual(result.providers[0]?.instanceId, personalInstanceId); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.strictEqual(result.providers[1]?.instanceId, workInstanceId); + assert.strictEqual(result.providers[1]?.updateState, undefined); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.124.0-alpha.3"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("records command failure output in provider update state", () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(); + const updater = yield* makeTestRunner(registry); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + const updateState = result.providers[0]?.updateState; + + assert.strictEqual(updateState?.status, "failed"); + assert.strictEqual(updateState?.message, "Update command exited with code 1."); + assert.include(updateState?.output ?? "", "permission denied"); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => ({ stderr: "permission denied", code: 1 })), + ), + ), + ), + ); + + it.effect( + "marks successful commands as unchanged when the refreshed provider is still outdated", + () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + installed: true, + version: "0.1.0", + }); + const updater = yield* makeTestRunner(registry); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); + assert.include(result.providers[0]?.updateState?.message ?? "", "still detects"); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("9.9.9"), + mockSpawnerLayer(() => ({ stdout: "updated" })), + ), + ), + ), + ); + + it.effect("prevents concurrent updates for the same provider", () => { + const startedLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseLatch: { resolve: () => void } = { resolve: () => {} }; + const started = new Promise((resolve) => { + startedLatch.resolve = resolve; + }); + const release = new Promise((resolve) => { + releaseLatch.resolve = resolve; + }); + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(); + const updater = yield* makeTestRunner(registry); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => started); + + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); + assert.strictEqual(Exit.isFailure(second), true); + if (Exit.isFailure(second)) { + const error = Cause.squash(second.cause); + assert.strictEqual(Schema.is(ServerProviderUpdateError)(error), true); + if (Schema.is(ServerProviderUpdateError)(error)) { + assert.include(error.reason, "already running"); + } + } + + releaseLatch.resolve(); + yield* Fiber.join(first); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => { + startedLatch.resolve(); + return { + stdout: "updated", + exitCode: Effect.promise(() => release).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; + }), + ), + ), + ); + }); + + it.effect("serializes different providers that share the same update lock key", () => { + const firstStartedLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} }; + const firstStarted = new Promise((resolve) => { + firstStartedLatch.resolve = resolve; + }); + const releaseFirst = new Promise((resolve) => { + releaseFirstLatch.resolve = resolve; + }); + const calls: Array = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry([baseProvider, baseOpenCodeProvider]); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider, + packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", + updateExecutable: "npm", + updateArgs: + provider === OPENCODE_DRIVER + ? ["install", "-g", "opencode-ai@latest"] + : ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + }), + ), + }); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => firstStarted); + + const second = yield* updater.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped); + let providersWhileQueued: ReadonlyArray = []; + for (let attempt = 0; attempt < 20; attempt += 1) { + providersWhileQueued = yield* registry.getProviders; + const queuedStatus = providersWhileQueued.find( + (provider) => provider.instanceId === OPENCODE_INSTANCE_ID, + )?.updateState?.status; + if (queuedStatus === "queued") { + break; + } + yield* Effect.yieldNow; + } + assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); + assert.strictEqual( + providersWhileQueued.find((provider) => provider.instanceId === OPENCODE_INSTANCE_ID) + ?.updateState?.status, + "queued", + ); + + releaseFirstLatch.resolve(); + yield* Fiber.join(first); + yield* Fiber.join(second); + assert.deepStrictEqual(calls, [ + "install -g @openai/codex@latest", + "install -g opencode-ai@latest", + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + if (calls.length === 1) { + firstStartedLatch.resolve(); + return { + stdout: "updated", + exitCode: Effect.promise(() => releaseFirst).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; + } + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("accepts arbitrary driver-provided update lock keys", () => { + const calls: Array = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "unknown-lock-key", + }), + ), + }); + + const result = yield* updater.updateProvider(CODEX_DRIVER); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect( + "releases the running-provider marker when interrupted after queuing but before the lock run starts", + () => + Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + let blockQueuedState = true; + const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} }; + const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} }; + const queuedStateWritten = new Promise((resolve) => { + queuedStateWrittenLatch.resolve = resolve; + }); + const releaseQueuedState = new Promise((resolve) => { + releaseQueuedStateLatch.resolve = resolve; + }); + + const updater = yield* makeTestRunner({ + ...registry, + setProviderMaintenanceActionState: Effect.fn( + "providerMaintenanceRunner.test.blockQueuedState", + )(function* (input) { + const providers = yield* registry.setProviderMaintenanceActionState(input); + if (input.state?.status === "queued" && blockQueuedState) { + queuedStateWrittenLatch.resolve(); + yield* Effect.promise(() => releaseQueuedState); + } + return providers; + }), + }); + + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); + yield* Effect.promise(() => queuedStateWritten); + blockQueuedState = false; + + yield* Fiber.interrupt(first); + releaseQueuedStateLatch.resolve(); + + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); + assert.strictEqual(Exit.isSuccess(second), true); + if (Exit.isSuccess(second)) { + assert.strictEqual(second.value.providers[0]?.updateState?.status, "succeeded"); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer(() => ({ stdout: "updated" })), + ), + ), + ), + ); +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts new file mode 100644 index 0000000000..36657bac99 --- /dev/null +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -0,0 +1,395 @@ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + ServerProviderUpdateError, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderUpdatedPayload, + type ServerProviderUpdateState, +} from "@t3tools/contracts"; +import { Cause, Context, DateTime, Duration, Effect, Layer, Option, Ref, Schema } from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; +import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts"; +import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; + +const UPDATE_TIMEOUT_MS = 5 * 60_000; +const UPDATE_OUTPUT_MAX_BYTES = 10_000; + +export interface ProviderMaintenanceCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface ProviderMaintenanceRunnerShape { + readonly updateProvider: ( + target: + | ProviderDriverKind + | { + readonly provider: ProviderDriverKind; + readonly instanceId?: ProviderInstanceId | undefined; + }, + ) => Effect.Effect; +} + +export class ProviderMaintenanceRunner extends Context.Service< + ProviderMaintenanceRunner, + ProviderMaintenanceRunnerShape +>()("t3/provider/ProviderMaintenanceRunner") {} + +interface VerifiedProviderRefresh { + readonly providers: ReadonlyArray; + readonly verifiedProviders: ReadonlyArray; +} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")( + function* (input: { + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly command: string; + readonly args: ReadonlyArray; + }) { + const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( + function* () { + const child = yield* input.spawner + .spawn(ChildProcess.make(input.command, [...input.args])) + .pipe( + Effect.mapError( + (cause) => + new Error(`Failed to run update command ${input.command}: ${cause.message}`), + ), + ); + 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, + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + (cause) => + new Error(cause instanceof Error ? cause.message : "Update command failed to run."), + ), + ); + + return { + stdout: stdout.text, + stderr: stderr.text, + exitCode: Number(exitCode), + timedOut: false, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies ProviderMaintenanceCommandResult; + }, + ); + + return yield* collectCommandResult().pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(UPDATE_TIMEOUT_MS)), + Effect.map((result) => + Option.match(result, { + onSome: (value) => value, + onNone: () => + ({ + stdout: "", + stderr: "", + exitCode: null, + timedOut: true, + stdoutTruncated: false, + stderrTruncated: false, + }) satisfies ProviderMaintenanceCommandResult, + }), + ), + ); + }, +); + +function trimNullable(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function truncateText(value: string, maxLength: number): string { + return value.length <= maxLength ? value : value.slice(0, maxLength); +} + +function commandOutput(result: ProviderMaintenanceCommandResult): string | null { + const output = trimNullable([result.stderr, result.stdout].filter(Boolean).join("\n\n")); + if (!output) { + return null; + } + return truncateText(output, UPDATE_OUTPUT_MAX_BYTES); +} + +function failureMessage(result: ProviderMaintenanceCommandResult): string { + if (result.timedOut) { + return "Update timed out."; + } + if (result.exitCode !== null && result.exitCode !== 0) { + return `Update command exited with code ${result.exitCode}.`; + } + return "Update command failed."; +} + +function isOutdatedProvider(provider: ServerProvider | undefined): boolean { + return provider?.versionAdvisory?.status === "behind_latest"; +} + +function makeUpdateState(input: { + readonly status: ServerProviderUpdateState["status"]; + readonly startedAt: string | null; + readonly finishedAt: string | null; + readonly message: string | null; + readonly output?: string | null; +}): ServerProviderUpdateState { + return { + status: input.status, + startedAt: input.startedAt, + finishedAt: input.finishedAt, + message: input.message, + output: input.output ?? null, + }; +} + +export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { + const providerRegistry = yield* ProviderRegistry; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const runMaintenanceCommand = (command: string, args: ReadonlyArray) => + runProviderMaintenanceCommandWithSpawner({ + spawner, + command, + args, + }); + const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ + makeAlreadyRunningError: () => + new ServerProviderUpdateError({ + provider: ProviderDriverKind.make("unknown"), + reason: "An update is already running for this provider.", + }), + }); + + const verifyRefreshedProvider = ( + provider: ProviderDriverKind, + maintenanceCapabilities: ProviderMaintenanceCapabilities, + instanceId: ProviderInstanceId, + ): Effect.Effect => + providerRegistry.getProviders.pipe( + Effect.map((providers) => + providers + .filter( + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, + ) + .map((candidate) => candidate.instanceId), + ), + Effect.flatMap((instanceIds) => + instanceIds.length === 0 + ? providerRegistry.refreshInstance(instanceId) + : Effect.forEach( + instanceIds, + (instanceId) => providerRegistry.refreshInstance(instanceId), + { + concurrency: "unbounded", + discard: true, + }, + ).pipe(Effect.andThen(providerRegistry.getProviders)), + ), + Effect.flatMap((providers) => { + const refreshedProviders = providers.filter( + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, + ); + if (refreshedProviders.length === 0) { + return Effect.succeed({ + providers, + verifiedProviders: [], + }); + } + return Effect.forEach( + refreshedProviders, + (refreshedProvider) => + enrichProviderSnapshotWithVersionAdvisory( + refreshedProvider, + maintenanceCapabilities, + ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + { + concurrency: "unbounded", + }, + ).pipe( + Effect.map( + (verifiedProviders): VerifiedProviderRefresh => ({ + providers, + verifiedProviders, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("Provider post-update version verification failed", { + provider, + cause: Cause.pretty(cause), + }).pipe( + Effect.as({ + providers, + verifiedProviders: refreshedProviders, + }), + ), + ), + ); + }), + ); + + const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn( + "ProviderMaintenanceRunner.updateProvider", + )(function* (target) { + const provider = typeof target === "string" ? target : target.provider; + const instanceId = + typeof target === "string" + ? defaultInstanceIdForDriver(provider) + : (target.instanceId ?? defaultInstanceIdForDriver(provider)); + const targetKey = `instance:${instanceId}`; + const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( + instanceId, + provider, + ); + const update = capabilities.update; + if (!update) { + return yield* new ServerProviderUpdateError({ + provider, + reason: "This provider does not support one-click updates.", + }); + } + + const setUpdateState = (state: ServerProviderUpdateState | null) => + providerRegistry.setProviderMaintenanceActionState({ + instanceId, + action: "update", + state, + }); + const setQueuedState = setUpdateState( + makeUpdateState({ + status: "queued", + startedAt: null, + finishedAt: null, + message: "Waiting for another provider update to finish.", + }), + ).pipe(Effect.asVoid); + + const runProviderUpdate = Effect.fn("ProviderMaintenanceRunner.runProviderUpdate")( + function* () { + const finish = (state: ServerProviderUpdateState) => + setUpdateState(state).pipe(Effect.map((providers) => ({ providers }))); + const startedAtRef = yield* Ref.make(null); + + const runCommandAndVerify = Effect.fn("ProviderMaintenanceRunner.runCommandAndVerify")( + function* () { + const startedAt = yield* nowIso; + yield* Ref.set(startedAtRef, startedAt); + yield* setUpdateState( + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message: "Updating provider.", + }), + ); + + const result = yield* runMaintenanceCommand(update.executable, update.args); + const finishedAt = yield* nowIso; + if (result.timedOut || result.exitCode !== 0) { + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt, + message: failureMessage(result), + output: commandOutput(result), + }), + ); + } + + const { verifiedProviders } = yield* verifyRefreshedProvider( + provider, + capabilities, + instanceId, + ); + const couldNotVerify = verifiedProviders.length === 0; + const stillOutdated = + couldNotVerify || + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); + return yield* finish( + makeUpdateState({ + status: stillOutdated ? "unchanged" : "succeeded", + startedAt, + finishedAt, + message: couldNotVerify + ? "Update command completed, but T3 Code could not verify the provider version." + : stillOutdated + ? "Update command completed, but T3 Code still detects an outdated provider version." + : "Provider updated.", + output: commandOutput(result), + }), + ); + }, + ); + + const recordFailedUpdate = Effect.fn("ProviderMaintenanceRunner.recordFailedUpdate")( + function* (cause: Cause.Cause) { + const failure = Cause.squash(cause); + const startedAt = yield* Ref.get(startedAtRef); + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt: yield* nowIso, + message: failure instanceof Error ? failure.message : "Update command failed.", + output: null, + }), + ); + }, + ); + + return yield* runCommandAndVerify().pipe(Effect.catchCause(recordFailedUpdate)); + }, + ); + + return yield* commandCoordinator + .withCommandLock({ + targetKey, + lockKey: update.lockKey, + onQueued: setQueuedState, + run: runProviderUpdate(), + }) + .pipe( + Effect.mapError((error) => + Schema.is(ServerProviderUpdateError)(error) + ? new ServerProviderUpdateError({ + provider, + reason: error.reason, + }) + : error, + ), + ); + }); + + return ProviderMaintenanceRunner.of({ + updateProvider, + }); +}); + +export const layer = Layer.effect(ProviderMaintenanceRunner, make()); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index af0c91274c..ce9eb950f1 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -12,6 +12,8 @@ import { Effect, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; export const DEFAULT_TIMEOUT_MS = 4_000; // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) @@ -182,6 +184,7 @@ export function buildBooleanOptionDescriptor(input: { } export function buildServerProvider(input: { + driver?: ProviderDriverKind; presentation: ServerProviderPresentation; enabled: boolean; checkedAt: string; @@ -190,6 +193,13 @@ export function buildServerProvider(input: { skills?: ReadonlyArray; probe: ProviderProbeResult; }): ServerProviderDraft { + const versionAdvisory = input.driver + ? createProviderVersionAdvisory({ + driver: input.driver, + currentVersion: input.probe.version, + checkedAt: input.checkedAt, + }) + : undefined; return { displayName: input.presentation.displayName, ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), @@ -206,16 +216,11 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(versionAdvisory ? { versionAdvisory } : {}), }; } export const collectStreamAsString = ( stream: Stream.Stream, ): Effect.Effect => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ); + collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index d6c051fd56..db6c358c99 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) => export const writeProviderStatusCache = (input: { readonly filePath: string; readonly provider: ServerProvider; -}) => - writeFileStringAtomically({ +}) => { + const { updateState: _updateState, ...cacheableProvider } = input.provider; + return writeFileStringAtomically({ filePath: input.filePath, - contents: `${JSON.stringify(input.provider, null, 2)}\n`, + contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`, }); +}; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 65cb638f7e..195667693b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -80,6 +80,7 @@ import { ProviderRegistry, type ProviderRegistryShape, } from "./provider/Services/ProviderRegistry.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; @@ -517,6 +518,12 @@ const buildAppUnderTest = (options?: { Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), streamChanges: Stream.empty, ...options?.layers?.providerRegistry, }), diff --git a/apps/server/src/stream/collectUint8StreamText.test.ts b/apps/server/src/stream/collectUint8StreamText.test.ts new file mode 100644 index 0000000000..727d14dd09 --- /dev/null +++ b/apps/server/src/stream/collectUint8StreamText.test.ts @@ -0,0 +1,38 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Stream } from "effect"; + +import { collectUint8StreamText } from "./collectUint8StreamText.ts"; + +const encoder = new TextEncoder(); + +describe("collectUint8StreamText", () => { + it.effect("collects Uint8Array chunks into decoded text", () => + Effect.gen(function* () { + const result = yield* collectUint8StreamText({ + stream: Stream.make(encoder.encode("hello "), encoder.encode("world")), + }); + + assert.deepStrictEqual(result, { + text: "hello world", + bytes: 11, + truncated: false, + }); + }), + ); + + it.effect("truncates by bytes and appends an optional marker once", () => + Effect.gen(function* () { + const result = yield* collectUint8StreamText({ + stream: Stream.make(encoder.encode("abcdef"), encoder.encode("ghij")), + maxBytes: 5, + truncatedMarker: "[truncated]", + }); + + assert.deepStrictEqual(result, { + text: "abcde[truncated]", + bytes: 5, + truncated: true, + }); + }), + ); +}); diff --git a/apps/server/src/stream/collectUint8StreamText.ts b/apps/server/src/stream/collectUint8StreamText.ts new file mode 100644 index 0000000000..88f654908d --- /dev/null +++ b/apps/server/src/stream/collectUint8StreamText.ts @@ -0,0 +1,66 @@ +import { Effect, Stream } from "effect"; + +export interface CollectedUint8StreamText { + readonly text: string; + readonly truncated: boolean; + readonly bytes: number; +} + +interface CollectState { + readonly text: string; + readonly bytes: number; + readonly truncated: boolean; +} + +export const collectUint8StreamText = (input: { + readonly stream: Stream.Stream; + readonly maxBytes?: number | undefined; + readonly truncatedMarker?: string | null | undefined; +}): Effect.Effect => { + const decoder = new TextDecoder(); + const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY; + const truncatedMarker = input.truncatedMarker ?? ""; + + return input.stream.pipe( + Stream.runFold( + (): CollectState => ({ + text: "", + bytes: 0, + truncated: false, + }), + (state, chunk): CollectState => { + if (state.truncated) { + return state; + } + + const remainingBytes = maxBytes - state.bytes; + if (remainingBytes <= 0) { + return { + ...state, + text: `${state.text}${truncatedMarker}`, + truncated: true, + }; + } + + const nextChunk = + chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; + const text = `${state.text}${decoder.decode(nextChunk, { stream: true })}`; + const bytes = state.bytes + nextChunk.byteLength; + const truncated = chunk.byteLength > remainingBytes; + + return { + text: truncated ? `${text}${truncatedMarker}` : text, + bytes, + truncated, + }; + }, + ), + Effect.map( + (state): CollectedUint8StreamText => ({ + text: state.truncated ? state.text : `${state.text}${decoder.decode()}`, + bytes: state.bytes, + truncated: state.truncated, + }), + ), + ); +}; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 33e03a2551..639621ceee 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -8,6 +8,7 @@ import { VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; export interface VcsProcessInput { readonly operation: string; @@ -86,44 +87,12 @@ export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: readonly maxOutputBytes?: number; readonly truncateOutputAtMaxBytes?: boolean; }) { - const decoder = new TextDecoder(); - let text = ""; - let bytes = 0; - let truncated = false; const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; - - yield* Stream.runForEach(input.stream, (chunk) => - Effect.sync(() => { - if (truncated) return; - - const remainingBytes = maxOutputBytes - bytes; - if (remainingBytes <= 0) { - truncated = true; - if (truncateOutputAtMaxBytes) { - text += OUTPUT_TRUNCATED_MARKER; - } - return; - } - - const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; - text += decoder.decode(nextChunk, { stream: true }); - bytes += nextChunk.byteLength; - - if (chunk.byteLength > remainingBytes) { - truncated = true; - if (truncateOutputAtMaxBytes) { - text += OUTPUT_TRUNCATED_MARKER; - } - } - }), - ); - - if (!truncated) { - text += decoder.decode(); - } - - return { text, truncated } satisfies VcsProcessCollectedText; + return yield* collectUint8StreamText({ + stream: input.stream, + maxBytes: maxOutputBytes, + truncatedMarker: input.truncateOutputAtMaxBytes ? OUTPUT_TRUNCATED_MARKER : null, + }); }); export const make = Effect.fn("makeVcsProcess")(function* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 592097b6d9..f25392268a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -40,6 +40,7 @@ import { observeRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; @@ -152,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; + const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; @@ -785,6 +787,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverUpdateProvider]: (input) => + observeRpcEffect( + WS_METHODS.serverUpdateProvider, + providerMaintenanceRunner.updateProvider(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, @@ -1152,6 +1162,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session.sessionId).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts new file mode 100644 index 0000000000..defb6eb20b --- /dev/null +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -0,0 +1,673 @@ +import { describe, expect, it } from "vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; + +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + collectUpdatedProviderSnapshots, + firstRejectedProviderUpdateMessage, + getProviderUpdateInitialToastView, + getProviderUpdateProgressToastView, + getProviderUpdateRejectedToastView, + getProviderUpdateSidebarPillView, + getSingleProviderUpdateProgressToastView, + hasOneClickUpdateProviderCandidate, + isProviderUpdateCandidate, + providerUpdateNotificationKey, + type ProviderUpdateCandidate, +} from "./ProviderUpdateLaunchNotification.logic"; + +const checkedAt = "2026-04-23T10:00:00.000Z"; +const sessionStartedAt = "2026-04-23T09:59:00.000Z"; +const laterCheckedAt = "2026-04-23T10:01:00.000Z"; + +const driver = (value: string) => ProviderDriverKind.make(value); +const instanceId = (value: string) => ProviderInstanceId.make(value); + +function provider(input: { + readonly driver: ReturnType; + readonly instanceId?: ReturnType; + readonly enabled?: boolean; + readonly version?: string | null; + readonly latestVersion?: string | null; + readonly canUpdate?: boolean; + readonly updateCommand?: string | null; + readonly updateState?: ServerProvider["updateState"]; + readonly advisoryStatus?: NonNullable["status"]; +}): ServerProvider { + const result: ServerProvider = { + instanceId: input.instanceId ?? instanceId(String(input.driver)), + driver: input.driver, + enabled: input.enabled ?? true, + installed: true, + version: input.version ?? "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: input.advisoryStatus ?? "behind_latest", + currentVersion: input.version ?? "1.0.0", + latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0", + updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider", + canUpdate: input.canUpdate ?? true, + checkedAt, + message: "Update available.", + }, + }; + + if (input.updateState) { + return { ...result, updateState: input.updateState }; + } + + return result; +} + +function updateCandidate(input: Parameters[0]): ProviderUpdateCandidate { + return provider(input) as ProviderUpdateCandidate; +} + +describe("provider update launch notification logic", () => { + it("detects enabled providers with a latest-version advisory", () => { + expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true); + expect(isProviderUpdateCandidate(provider({ driver: driver("codex"), enabled: false }))).toBe( + false, + ); + expect( + isProviderUpdateCandidate( + provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), + ), + ).toBe(false); + expect( + isProviderUpdateCandidate(provider({ driver: driver("codex"), latestVersion: null })), + ).toBe(false); + }); + + it("deduplicates multi-instance provider candidates by driver", () => { + expect( + collectProviderUpdateCandidates([ + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_personal"), + latestVersion: "1.1.0", + }), + provider({ + driver: driver("codex"), + instanceId: instanceId("codex"), + latestVersion: "1.1.0", + }), + provider({ driver: driver("cursor"), latestVersion: "0.3.0" }), + ]), + ).toHaveLength(2); + }); + + it("disables one-click updates when provider instances disagree on the update command", () => { + const candidate = updateCandidate({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_personal"), + latestVersion: "2.1.123", + }); + + expect( + canOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + latestVersion: "2.1.123", + canUpdate: true, + updateCommand: "bun add -g @anthropic-ai/claude-code@latest", + }), + ]), + ).toBe(false); + }); + + it("keeps one-click updates enabled when sibling instances are already current", () => { + const candidate = updateCandidate({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_personal"), + latestVersion: "2.1.123", + updateCommand: "npm install -g @anthropic-ai/claude-code@latest", + }); + + expect( + hasOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + version: "2.1.123", + latestVersion: "2.1.123", + advisoryStatus: "current", + canUpdate: false, + updateCommand: null, + }), + ]), + ).toBe(true); + expect( + canOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("claudeAgent"), + instanceId: instanceId("claude_work"), + version: "2.1.123", + latestVersion: "2.1.123", + advisoryStatus: "current", + canUpdate: false, + updateCommand: null, + }), + ]), + ).toBe(true); + }); + + it("keeps the inline update action available while a provider update is already running", () => { + const candidate = updateCandidate({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }); + + expect(hasOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(true); + expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false); + }); + + it("builds a notification key from provider latest versions", () => { + const codex = updateCandidate({ + driver: driver("codex"), + version: "1.0.0", + latestVersion: "1.1.0", + }); + const cursor = updateCandidate({ + driver: driver("cursor"), + version: "0.2.0", + latestVersion: "0.3.0", + }); + + expect(providerUpdateNotificationKey([codex, cursor])).toBe("codex:1.1.0|cursor:0.3.0"); + expect(providerUpdateNotificationKey([])).toBeNull(); + }); + + it("keeps the same notification key while the published update version is unchanged", () => { + const first = updateCandidate({ + driver: driver("codex"), + version: "1.0.0", + latestVersion: "1.2.0", + }); + const second = updateCandidate({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.2.0", + }); + const nextPublishedVersion = updateCandidate({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.3.0", + }); + + expect(providerUpdateNotificationKey([first])).toBe(providerUpdateNotificationKey([second])); + expect(providerUpdateNotificationKey([nextPublishedVersion])).not.toBe( + providerUpdateNotificationKey([first]), + ); + }); + + it("tracks updated provider snapshots by instance instead of collapsing to a sibling driver", () => { + const targetInstanceId = instanceId("codex_personal"); + const siblingInstanceId = instanceId("codex"); + const updatedPersonal = provider({ + driver: driver("codex"), + instanceId: targetInstanceId, + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Provider updated.", + output: null, + }, + }); + const currentDefaultSibling = provider({ + driver: driver("codex"), + instanceId: siblingInstanceId, + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: undefined, + }); + + expect( + collectUpdatedProviderSnapshots({ + results: [ + { + status: "fulfilled", + value: { + providers: [updatedPersonal, currentDefaultSibling], + }, + }, + ], + providerInstanceIds: new Set([targetInstanceId]), + }), + ).toEqual([updatedPersonal]); + }); + + it("describes a single one-click update", () => { + const view = getProviderUpdateInitialToastView({ + updateProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], + oneClickProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], + }); + + expect(view).toMatchObject({ + phase: "initial", + type: "warning", + title: "Update Available: Codex v1.1.0", + description: "Install the update now or review provider settings.", + }); + }); + + it("describes settings-only updates without one-click support", () => { + const view = getProviderUpdateInitialToastView({ + updateProviders: [ + updateCandidate({ driver: driver("codex"), canUpdate: false }), + updateCandidate({ driver: driver("cursor"), canUpdate: false }), + ], + oneClickProviders: [], + }); + + expect(view.description).toBe("Codex and Cursor can be updated from provider settings."); + }); + + it("uses server update state for running progress", () => { + const view = getProviderUpdateProgressToastView({ + providers: [ + provider({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }), + ], + providerCount: 1, + }); + + expect(view).toMatchObject({ + phase: "running", + type: "loading", + title: "Updating provider", + }); + }); + + it("uses server failure state for failed progress", () => { + const view = getProviderUpdateProgressToastView({ + providers: [ + provider({ + driver: driver("codex"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "command failed", + output: "stderr", + }, + }), + ], + providerCount: 1, + }); + + expect(view).toMatchObject({ + phase: "failed", + type: "error", + title: "Provider update failed", + description: "command failed", + }); + }); + + it("resolves a single-provider completion view from the returned provider snapshot", () => { + const view = getSingleProviderUpdateProgressToastView( + provider({ + driver: driver("codex"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "command failed", + output: "stderr", + }, + }), + ); + + expect(view).toMatchObject({ + phase: "failed", + type: "error", + title: "Codex v1.1.0 update failed", + description: "command failed", + }); + }); + + it("keeps unchanged providers actionable from settings", () => { + const view = getProviderUpdateProgressToastView({ + providers: [ + provider({ + driver: driver("cursor"), + updateState: { + status: "unchanged", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "still old", + output: null, + }, + }), + ], + providerCount: 1, + }); + + expect(view).toMatchObject({ + phase: "unchanged", + type: "warning", + title: "Provider still needs an update", + description: "Cursor still appears outdated. Check provider settings for details.", + }); + }); + + it("marks progress succeeded once every attempted provider is no longer outdated", () => { + const view = getProviderUpdateProgressToastView({ + providers: [ + provider({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Provider updated.", + output: null, + }, + }), + ], + providerCount: 1, + }); + + expect(view).toMatchObject({ + phase: "succeeded", + type: "success", + title: "Provider updated", + description: "New sessions will use the updated provider.", + dismissAfterVisibleMs: 3_000, + }); + }); + + it("uses the updated version in the single-provider success toast title", () => { + const view = getSingleProviderUpdateProgressToastView( + provider({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Provider updated.", + output: null, + }, + }), + ); + + expect(view).toMatchObject({ + phase: "succeeded", + type: "success", + title: "Codex updated: v1.1.0", + description: "New sessions will use the updated provider.", + }); + }); + + it("falls back to a rejected RPC message for transport-level failures", () => { + const results: PromiseSettledResult[] = [ + { status: "rejected", reason: new Error("WebSocket closed") }, + ]; + + expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed"); + expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({ + phase: "failed", + title: "Provider updates failed", + description: "WebSocket closed", + }); + }); + + it("collects only attempted provider snapshots from update responses", () => { + const codex = provider({ driver: driver("codex") }); + const cursor = provider({ driver: driver("cursor") }); + const results: PromiseSettledResult<{ readonly providers: ReadonlyArray }>[] = [ + { status: "fulfilled", value: { providers: [codex, cursor] } }, + ]; + + expect( + collectUpdatedProviderSnapshots({ + results, + providerInstanceIds: new Set([cursor.instanceId]), + }), + ).toEqual([cursor]); + }); + + it("summarizes active provider updates for the sidebar pill", () => { + const view = getProviderUpdateSidebarPillView([ + provider({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }), + provider({ + driver: driver("cursor"), + updateState: { + status: "queued", + startedAt: null, + finishedAt: null, + message: "Waiting for another provider update to finish.", + output: null, + }, + }), + ]); + + expect(view).toMatchObject({ + tone: "loading", + title: "Updating 2 providers", + description: "Codex and Cursor updates are in progress.", + }); + }); + + it("uses the provider name for single active sidebar pill updates", () => { + const view = getProviderUpdateSidebarPillView([ + provider({ + driver: driver("codex"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }), + ]); + + expect(view).toMatchObject({ + key: "loading:codex:running", + tone: "loading", + title: "Updating Codex", + description: "Codex update in progress.", + }); + }); + + it("uses the provider name for single failed sidebar pill updates", () => { + const view = getProviderUpdateSidebarPillView( + [ + provider({ + driver: driver("claudeAgent"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Update command exited with code 1.", + output: null, + }, + }), + ], + { visibleAfterIso: sessionStartedAt }, + ); + + expect(view).toMatchObject({ + key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", + tone: "error", + title: "Claude v1.1.0 update failed", + description: "Update command exited with code 1.", + dismissible: true, + }); + }); + + it("shows a short-lived success sidebar pill after a single provider update succeeds", () => { + const view = getProviderUpdateSidebarPillView( + [ + provider({ + driver: driver("codex"), + version: "1.1.0", + latestVersion: "1.1.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Provider updated.", + output: null, + }, + }), + ], + { visibleAfterIso: sessionStartedAt }, + ); + + expect(view).toMatchObject({ + key: "succeeded:codex:2026-04-23T10:00:00.000Z:Provider updated.", + tone: "success", + title: "Codex updated: v1.1.0", + description: "New sessions will use the updated provider.", + dismissAfterVisibleMs: 3_000, + }); + }); + + it("keeps unchanged sidebar pill states dismissible", () => { + const view = getProviderUpdateSidebarPillView( + [ + provider({ + driver: driver("cursor"), + updateState: { + status: "unchanged", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "still old", + output: null, + }, + }), + ], + { visibleAfterIso: sessionStartedAt }, + ); + + expect(view).toMatchObject({ + key: "unchanged:cursor:2026-04-23T10:00:00.000Z:still old", + tone: "warning", + title: "Cursor still needs an update", + dismissible: true, + }); + }); + + it("does not show sidebar terminal states from before the current app session", () => { + expect( + getProviderUpdateSidebarPillView( + [ + provider({ + driver: driver("codex"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "command failed", + output: "stderr", + }, + }), + ], + { visibleAfterIso: "2026-04-23T10:00:01.000Z" }, + ), + ).toBeNull(); + }); + + it("shows a newer success before falling back to an older failure", () => { + const providers = [ + provider({ + driver: driver("claudeAgent"), + updateState: { + status: "failed", + startedAt: checkedAt, + finishedAt: checkedAt, + message: "Update command exited with code 1.", + output: null, + }, + }), + provider({ + driver: driver("codex"), + version: "1.2.0", + latestVersion: "1.2.0", + advisoryStatus: "current", + updateState: { + status: "succeeded", + startedAt: laterCheckedAt, + finishedAt: laterCheckedAt, + message: "Provider updated.", + output: null, + }, + }), + ] satisfies ReadonlyArray; + + const successView = getProviderUpdateSidebarPillView(providers, { + visibleAfterIso: sessionStartedAt, + }); + expect(successView).toMatchObject({ + key: "succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated.", + tone: "success", + title: "Codex updated: v1.2.0", + }); + + const failureView = getProviderUpdateSidebarPillView(providers, { + visibleAfterIso: sessionStartedAt, + dismissedKeys: new Set(["succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated."]), + }); + expect(failureView).toMatchObject({ + key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", + tone: "error", + title: "Claude v1.1.0 update failed", + }); + }); + + it("does not show a sidebar pill for passive update availability", () => { + expect( + getProviderUpdateSidebarPillView([ + provider({ driver: driver("codex"), canUpdate: true }), + provider({ driver: driver("cursor"), canUpdate: false }), + ]), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts new file mode 100644 index 0000000000..f45b2916ce --- /dev/null +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -0,0 +1,537 @@ +import { + defaultInstanceIdForDriver, + PROVIDER_DISPLAY_NAMES, + type ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; + +export type ProviderUpdateCandidate = ServerProvider & { + readonly versionAdvisory: NonNullable & { + readonly status: "behind_latest"; + readonly latestVersion: string; + }; +}; + +export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success"; +export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded"; + +export interface ProviderUpdateToastView { + readonly phase: ProviderUpdateToastPhase; + readonly type: ProviderUpdateToastType; + readonly title: string; + readonly description: string; + readonly dismissAfterVisibleMs?: number; +} + +export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success"; + +export interface ProviderUpdateSidebarPillView { + readonly key: string; + readonly tone: ProviderUpdateSidebarPillTone; + readonly title: string; + readonly description: string; + readonly dismissible?: boolean; + readonly dismissAfterVisibleMs?: number; +} + +interface ProviderUpdateSidebarPillOptions { + readonly visibleAfterIso?: string; + readonly dismissedKeys?: ReadonlySet; +} + +const PROVIDER_UPDATE_SUCCESS_VISIBLE_MS = 3_000; + +function formatVersion(value: string): string { + return value.startsWith("v") ? value : `v${value}`; +} + +function chooseRepresentativeProvider( + current: ServerProvider | undefined, + candidate: ServerProvider, +): ServerProvider { + if (!current) { + return candidate; + } + const defaultInstanceId = defaultInstanceIdForDriver(candidate.driver); + if (candidate.instanceId === defaultInstanceId) { + return candidate; + } + if (current.instanceId === defaultInstanceId) { + return current; + } + return candidate.checkedAt.localeCompare(current.checkedAt) >= 0 ? candidate : current; +} + +function dedupeProvidersByDriver(providers: ReadonlyArray): T[] { + const latestProviderByDriver = new Map(); + + for (const provider of providers) { + latestProviderByDriver.set( + provider.driver, + chooseRepresentativeProvider(latestProviderByDriver.get(provider.driver), provider) as T, + ); + } + + return [...latestProviderByDriver.values()]; +} + +function dedupeProvidersByInstanceId(providers: ReadonlyArray): T[] { + const latestProviderByInstanceId = new Map(); + + for (const provider of providers) { + const current = latestProviderByInstanceId.get(provider.instanceId); + if (!current || provider.checkedAt.localeCompare(current.checkedAt) >= 0) { + latestProviderByInstanceId.set(provider.instanceId, provider); + } + } + + return [...latestProviderByInstanceId.values()]; +} + +function getProviderUpdatedTitle(provider: Pick): string { + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + return provider.version + ? `${providerName} updated: ${formatVersion(provider.version)}` + : `${providerName} updated`; +} + +function getProviderUpdatedDescription(providerCount: number): string { + return providerCount === 1 + ? "New sessions will use the updated provider." + : "New sessions will use the updated providers."; +} + +function getProviderFailedUpdateTitle( + provider: Pick, +): string { + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + const attemptedVersion = provider.versionAdvisory?.latestVersion; + return attemptedVersion + ? `${providerName} ${formatVersion(attemptedVersion)} update failed` + : `${providerName} update failed`; +} + +export function isProviderUpdateCandidate( + provider: ServerProvider, +): provider is ProviderUpdateCandidate { + return ( + provider.enabled && + provider.versionAdvisory?.status === "behind_latest" && + provider.versionAdvisory.latestVersion !== null + ); +} + +export function isProviderUpdateActive(provider: Pick): boolean { + return provider.updateState?.status === "queued" || provider.updateState?.status === "running"; +} + +export function collectProviderUpdateCandidates( + providers: ReadonlyArray, +): ProviderUpdateCandidate[] { + return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate)); +} + +export function hasOneClickUpdateProviderCandidate( + candidate: ProviderUpdateCandidate, + providers: ReadonlyArray, +): boolean { + if ( + candidate.versionAdvisory.canUpdate !== true || + candidate.versionAdvisory.updateCommand === null + ) { + return false; + } + + const driverProviders = providers.filter((provider) => provider.driver === candidate.driver); + if (driverProviders.length === 0) { + return false; + } + + const updateCommands = new Set(); + for (const provider of driverProviders) { + if (!isProviderUpdateCandidate(provider)) { + continue; + } + const advisory = provider.versionAdvisory; + if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) { + return false; + } + updateCommands.add(advisory.updateCommand); + } + + return updateCommands.size === 1; +} + +export function canOneClickUpdateProviderCandidate( + candidate: ProviderUpdateCandidate, + providers: ReadonlyArray, +): boolean { + return ( + !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers) + ); +} + +export function providerUpdateNotificationKey( + providers: ReadonlyArray, +): string | null { + const parts = dedupeProvidersByDriver(providers) + .map((provider) => { + const advisory = provider.versionAdvisory; + return [provider.driver, advisory.latestVersion].join(":"); + }) + .toSorted(); + + return parts.length > 0 ? parts.join("|") : null; +} + +export function providerUpdateCandidateKey(provider: ProviderUpdateCandidate): string { + return providerUpdateNotificationKey([provider])!; +} + +export function formatProviderList(providers: ReadonlyArray>) { + const names = providers.map( + (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver, + ); + if (names.length <= 2) { + return names.join(" and "); + } + return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; +} + +export function getProviderUpdateInitialToastView(input: { + readonly updateProviders: ReadonlyArray; + readonly oneClickProviders: ReadonlyArray; +}): ProviderUpdateToastView { + return { + phase: "initial", + type: "warning", + title: getProviderUpdateInitialToastTitle(input.updateProviders), + description: + input.oneClickProviders.length > 0 + ? "Install the update now or review provider settings." + : `${formatProviderList(input.updateProviders)} can be updated from provider settings.`, + }; +} + +export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { + return { + phase: "running", + type: "loading", + title: providerCount === 1 ? "Updating provider" : "Updating providers", + description: "Running provider update command.", + }; +} + +export function getProviderUpdateRejectedToastView( + providerCount: number, + message: string, +): ProviderUpdateToastView { + return { + phase: "failed", + type: "error", + title: providerCount === 1 ? "Provider update failed" : "Provider updates failed", + description: message, + }; +} + +export function getProviderUpdateProgressToastView(input: { + readonly providers: ReadonlyArray; + readonly providerCount: number; +}): ProviderUpdateToastView { + const providers = dedupeProvidersByDriver(input.providers); + const failedProviders = providers.filter((provider) => provider.updateState?.status === "failed"); + if (failedProviders.length > 0) { + return { + phase: "failed", + type: "error", + title: failedProviders.length === 1 ? "Provider update failed" : "Provider updates failed", + description: getFailedProviderUpdateDescription(failedProviders), + }; + } + + const unchangedProviders = providers.filter( + (provider) => provider.updateState?.status === "unchanged", + ); + if (unchangedProviders.length > 0) { + return { + phase: "unchanged", + type: "warning", + title: + unchangedProviders.length === 1 + ? "Provider still needs an update" + : "Providers still need updates", + description: `${formatProviderList(unchangedProviders)} ${ + unchangedProviders.length === 1 ? "still appears" : "still appear" + } outdated. Check provider settings for details.`, + }; + } + + if (providers.some(isProviderUpdateActive)) { + return getProviderUpdateRunningToastView(input.providerCount); + } + + const hasCompleteProviderSnapshots = providers.length >= input.providerCount; + const allProvidersUpdated = + hasCompleteProviderSnapshots && + providers.every( + (provider) => + provider.updateState?.status === "succeeded" || !isProviderUpdateCandidate(provider), + ); + if (allProvidersUpdated) { + return { + phase: "succeeded", + type: "success", + title: input.providerCount === 1 ? "Provider updated" : "Provider updates finished", + description: getProviderUpdatedDescription(input.providerCount), + dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, + }; + } + + return getProviderUpdateRunningToastView(input.providerCount); +} + +export function getSingleProviderUpdateProgressToastView( + provider: ServerProvider, +): ProviderUpdateToastView { + const view = getProviderUpdateProgressToastView({ + providers: [provider], + providerCount: 1, + }); + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + + switch (view.phase) { + case "running": + return { + ...view, + title: `Updating ${providerName}`, + }; + case "failed": + return { + ...view, + title: getProviderFailedUpdateTitle(provider), + }; + case "unchanged": + return { + ...view, + title: `${providerName} still needs an update`, + }; + case "succeeded": + return { + ...view, + title: getProviderUpdatedTitle(provider), + }; + default: + return view; + } +} + +export function collectUpdatedProviderSnapshots(input: { + readonly results: ReadonlyArray< + PromiseSettledResult<{ readonly providers: ReadonlyArray }> + >; + readonly providerInstanceIds: ReadonlySet; +}): ServerProvider[] { + const matchedProviders: ServerProvider[] = []; + + for (const result of input.results) { + if (result.status !== "fulfilled") { + continue; + } + for (const provider of result.value.providers) { + if (input.providerInstanceIds.has(provider.instanceId)) { + matchedProviders.push(provider); + } + } + } + + return dedupeProvidersByInstanceId(matchedProviders); +} + +export function firstRejectedProviderUpdateMessage( + results: ReadonlyArray>, +): string | null { + const rejected = results.find((result) => result.status === "rejected"); + if (!rejected) { + return null; + } + return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed."; +} + +function getUpdateFinishedAt(provider: ServerProvider): string | null { + return provider.updateState?.finishedAt ?? null; +} + +function isRecentTerminalProvider( + provider: ServerProvider, + visibleAfterIso: string | undefined, +): boolean { + const status = provider.updateState?.status; + if (status !== "failed" && status !== "unchanged" && status !== "succeeded") { + return false; + } + if (visibleAfterIso === undefined) { + return true; + } + const finishedAt = getUpdateFinishedAt(provider); + return finishedAt !== null && finishedAt >= visibleAfterIso; +} + +function latestFinishedAtForProviders(providers: ReadonlyArray): string | null { + return providers.reduce((latest, provider) => { + const finishedAt = getUpdateFinishedAt(provider); + if (finishedAt === null) { + return latest; + } + return latest === null || finishedAt > latest ? finishedAt : latest; + }, null); +} + +export function getProviderUpdateSidebarPillView( + providers: ReadonlyArray, + options?: ProviderUpdateSidebarPillOptions, +): ProviderUpdateSidebarPillView | null { + const dedupedProviders = dedupeProvidersByDriver(providers); + const activeProviders = dedupedProviders.filter(isProviderUpdateActive); + if (activeProviders.length > 0) { + const activeProvider = activeProviders[0]!; + const activeProviderName = + PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver; + return { + key: `loading:${activeProviders + .map((provider) => `${provider.driver}:${provider.updateState?.status ?? "idle"}`) + .toSorted() + .join("|")}`, + tone: "loading", + title: + activeProviders.length === 1 + ? `Updating ${activeProviderName}` + : `Updating ${activeProviders.length} providers`, + description: + activeProviders.length === 1 + ? `${formatProviderList(activeProviders)} update in progress.` + : `${formatProviderList(activeProviders)} updates are in progress.`, + }; + } + + const recentTerminalProviders = dedupedProviders.filter((provider) => + isRecentTerminalProvider(provider, options?.visibleAfterIso), + ); + const terminalCandidates: ProviderUpdateSidebarPillView[] = []; + + const failedProviders = recentTerminalProviders.filter( + (provider) => provider.updateState?.status === "failed", + ); + if (failedProviders.length > 0) { + const failedProvider = failedProviders[0]!; + terminalCandidates.push({ + key: `failed:${failedProviders + .map( + (provider) => + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, + ) + .toSorted() + .join("|")}`, + tone: "error", + title: + failedProviders.length === 1 + ? getProviderFailedUpdateTitle(failedProvider) + : `${failedProviders.length} provider updates failed`, + description: getFailedProviderUpdateDescription(failedProviders), + dismissible: true, + }); + } + + const unchangedProviders = recentTerminalProviders.filter( + (provider) => provider.updateState?.status === "unchanged", + ); + if (unchangedProviders.length > 0) { + const unchangedProvider = unchangedProviders[0]!; + const unchangedProviderName = + PROVIDER_DISPLAY_NAMES[unchangedProvider.driver] ?? unchangedProvider.driver; + terminalCandidates.push({ + key: `unchanged:${unchangedProviders + .map( + (provider) => + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, + ) + .toSorted() + .join("|")}`, + tone: "warning", + title: + unchangedProviders.length === 1 + ? `${unchangedProviderName} still needs an update` + : `${unchangedProviders.length} providers still need updates`, + description: `${formatProviderList(unchangedProviders)} ${ + unchangedProviders.length === 1 ? "still appears" : "still appear" + } outdated. Review provider settings for details.`, + dismissible: true, + }); + } + + const succeededProviders = recentTerminalProviders.filter( + (provider) => provider.updateState?.status === "succeeded", + ); + if (succeededProviders.length > 0) { + const succeededProvider = succeededProviders[0]!; + terminalCandidates.push({ + key: `succeeded:${succeededProviders + .map( + (provider) => + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, + ) + .toSorted() + .join("|")}`, + tone: "success", + title: + succeededProviders.length === 1 + ? getProviderUpdatedTitle(succeededProvider) + : `${succeededProviders.length} providers updated`, + description: getProviderUpdatedDescription(succeededProviders.length), + dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, + }); + } + + return ( + terminalCandidates + .toSorted((left, right) => { + const leftProviders = + left.tone === "error" + ? failedProviders + : left.tone === "warning" + ? unchangedProviders + : succeededProviders; + const rightProviders = + right.tone === "error" + ? failedProviders + : right.tone === "warning" + ? unchangedProviders + : succeededProviders; + const leftFinishedAt = latestFinishedAtForProviders(leftProviders) ?? ""; + const rightFinishedAt = latestFinishedAtForProviders(rightProviders) ?? ""; + return rightFinishedAt.localeCompare(leftFinishedAt); + }) + .find((candidate) => !options?.dismissedKeys?.has(candidate.key)) ?? null + ); +} + +function getProviderUpdateInitialToastTitle( + providers: ReadonlyArray, +): string { + if (providers.length === 1) { + const provider = providers[0]!; + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + return `Update Available: ${providerName} ${formatVersion(provider.versionAdvisory.latestVersion)}`; + } + return `Updates Available: ${providers.length} providers`; +} + +function getFailedProviderUpdateDescription(providers: ReadonlyArray): string { + if (providers.length === 1) { + const provider = providers[0]!; + if (provider.updateState?.message) { + return provider.updateState.message; + } + } + return `${formatProviderList(providers)} failed to update. Check provider settings for details.`; +} diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx new file mode 100644 index 0000000000..8ad8ef15bf --- /dev/null +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -0,0 +1,300 @@ +import { useNavigate } from "@tanstack/react-router"; +import { DownloadIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; + +import { ensureLocalApi } from "../localApi"; +import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; +import { useServerProviders } from "../rpc/serverState"; +import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + collectUpdatedProviderSnapshots, + firstRejectedProviderUpdateMessage, + getProviderUpdateInitialToastView, + getProviderUpdateProgressToastView, + getProviderUpdateRejectedToastView, + getProviderUpdateRunningToastView, + providerUpdateNotificationKey, + type ProviderUpdateToastView, +} from "./ProviderUpdateLaunchNotification.logic"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +const seenProviderUpdateNotificationKeys = new Set(); +type ProviderUpdateToastId = ReturnType; + +type ActiveProviderUpdateToast = + | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } + | { + readonly kind: "update"; + readonly key: string; + readonly toastId: ProviderUpdateToastId; + readonly providerInstanceIds: ReadonlySet; + readonly providerCount: number; + }; + +function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) { + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[provider]; + + if (!ProviderIcon) { + return ( + + + ); + } + + return ( + + + ); +} + +function updateProviderUpdateToast(input: { + readonly toastId: ProviderUpdateToastId; + readonly view: ProviderUpdateToastView; + readonly openSettings: () => void; +}) { + if (input.view.type === "loading" || input.view.type === "success") { + toastManager.update(input.toastId, { + type: input.view.type, + title: input.view.title, + description: input.view.description, + timeout: 0, + data: { + hideCopyButton: true, + ...(input.view.dismissAfterVisibleMs !== undefined + ? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs } + : {}), + }, + }); + return; + } + + toastManager.update( + input.toastId, + stackedThreadToast({ + type: input.view.type, + title: input.view.title, + description: input.view.description, + timeout: 0, + actionProps: { + children: "Settings", + onClick: input.openSettings, + }, + actionVariant: "outline", + data: { + hideCopyButton: true, + }, + }), + ); +} + +function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { + return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; +} + +export function ProviderUpdateLaunchNotification() { + const navigate = useNavigate(); + const providers = useServerProviders(); + const activeToastRef = useRef(null); + const { dismissedNotificationKeys, dismissNotificationKey } = + useDismissedProviderUpdateNotificationKeys(); + + const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); + const notificationKey = useMemo( + () => providerUpdateNotificationKey(updateProviders), + [updateProviders], + ); + const oneClickProviders = useMemo( + () => + updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)), + [providers, updateProviders], + ); + + const openProviderSettings = useCallback( + (toastId?: ProviderUpdateToastId) => { + const activeToast = activeToastRef.current; + if (toastId !== undefined) { + toastManager.close(toastId); + } else if (activeToast) { + toastManager.close(activeToast.toastId); + } + if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { + activeToastRef.current = null; + } + void navigate({ to: "/settings/general", hash: "providers" }); + }, + [navigate], + ); + + useEffect(() => { + const activeToast = activeToastRef.current; + if (activeToast?.kind !== "update") { + return; + } + + const activeProviders = providers.filter((provider) => + activeToast.providerInstanceIds.has(provider.instanceId), + ); + const view = getProviderUpdateProgressToastView({ + providers: activeProviders, + providerCount: activeToast.providerCount, + }); + updateProviderUpdateToast({ + toastId: activeToast.toastId, + view, + openSettings: () => openProviderSettings(activeToast.toastId), + }); + + if (isTerminalProviderUpdateToastView(view)) { + activeToastRef.current = null; + } + }, [providers, openProviderSettings]); + + useEffect(() => { + const activeToast = activeToastRef.current; + if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) { + toastManager.close(activeToast.toastId); + activeToastRef.current = null; + } + + if ( + !notificationKey || + dismissedNotificationKeys.has(notificationKey) || + seenProviderUpdateNotificationKeys.has(notificationKey) || + activeToastRef.current + ) { + return; + } + + seenProviderUpdateNotificationKeys.add(notificationKey); + + const initialView = getProviderUpdateInitialToastView({ updateProviders, oneClickProviders }); + + let toastId!: ProviderUpdateToastId; + let updateStarted = false; + const openSettings = () => openProviderSettings(toastId); + const dismissPrompt = () => { + dismissNotificationKey(notificationKey); + }; + + const runUpdates = () => { + if (updateStarted || oneClickProviders.length === 0) { + return; + } + updateStarted = true; + + const providerCount = oneClickProviders.length; + const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); + activeToastRef.current = { + kind: "update", + key: notificationKey, + toastId, + providerInstanceIds, + providerCount, + }; + + updateProviderUpdateToast({ + toastId, + view: getProviderUpdateRunningToastView(providerCount), + openSettings, + }); + + void Promise.allSettled( + oneClickProviders.map(async (provider) => + ensureLocalApi().server.updateProvider({ + provider: provider.driver, + instanceId: provider.instanceId, + }), + ), + ).then((results) => { + const activeUpdateToast = activeToastRef.current; + if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { + return; + } + + const rejectedMessage = firstRejectedProviderUpdateMessage(results); + if (rejectedMessage) { + updateProviderUpdateToast({ + toastId, + view: getProviderUpdateRejectedToastView(providerCount, rejectedMessage), + openSettings, + }); + activeToastRef.current = null; + return; + } + + const updatedProviderSnapshots = collectUpdatedProviderSnapshots({ + results, + providerInstanceIds, + }); + const view = getProviderUpdateProgressToastView({ + providers: updatedProviderSnapshots, + providerCount, + }); + updateProviderUpdateToast({ + toastId, + view, + openSettings, + }); + + if (isTerminalProviderUpdateToastView(view)) { + activeToastRef.current = null; + } + }); + }; + + toastId = toastManager.add( + stackedThreadToast({ + type: initialView.type, + title: initialView.title, + description: initialView.description, + timeout: 0, + actionProps: + oneClickProviders.length > 0 + ? { + children: "Update", + onClick: runUpdates, + } + : { + children: "Settings", + onClick: openSettings, + }, + actionVariant: oneClickProviders.length > 0 ? "default" : "outline", + data: { + leadingIcon: + updateProviders.length === 1 ? ( + + ) : undefined, + hideCopyButton: true, + onClose: dismissPrompt, + ...(oneClickProviders.length > 0 + ? { + secondaryActionProps: { + children: "Settings", + onClick: openSettings, + }, + secondaryActionVariant: "outline" as const, + } + : {}), + }, + }), + ); + activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; + }, [ + dismissNotificationKey, + dismissedNotificationKeys, + notificationKey, + oneClickProviders, + openProviderSettings, + updateProviders, + ]); + + return null; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1e740e5f38..ccac141943 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -185,6 +185,7 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; +import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -2434,6 +2435,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( + diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 236e1db565..25ec33c855 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,6 +1,15 @@ "use client"; -import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; +import { + ArrowUpCircleIcon, + ChevronDownIcon, + CopyIcon, + DownloadIcon, + LoaderIcon, + PlusIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { isProviderDriverKind, @@ -13,12 +22,15 @@ import { } from "@t3tools/contracts"; import { cn } from "../../lib/utils"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { DraftInput } from "../ui/draft-input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { DriverOption } from "./providerDriverMeta"; import { ProviderSettingsForm } from "./ProviderSettingsForm"; @@ -26,6 +38,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { + getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, getProviderSummary, getProviderVersionLabel, @@ -405,6 +418,8 @@ interface ProviderInstanceCardProps { readonly onHiddenModelsChange: (next: ReadonlyArray) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; + readonly onRunUpdate?: (() => void) | undefined; + readonly isUpdating?: boolean | undefined; } /** @@ -447,6 +462,8 @@ export function ProviderInstanceCard({ onHiddenModelsChange, onFavoriteModelsChange, onModelOrderChange, + onRunUpdate, + isUpdating = false, }: ProviderInstanceCardProps) { const enabled = instance.enabled ?? true; // The server-reported status wins when present; otherwise fall back to @@ -464,10 +481,30 @@ export function ProviderInstanceCard({ : null; const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); + const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); + const updateCommand = versionAdvisory?.updateCommand ?? null; const FallbackIconComponent = driverOption?.icon; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); const accentColor = normalizeProviderAccentColor(instance.accentColor); + const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ + onCopy: ({ providerName }) => { + toastManager.add({ + type: "success", + title: `${providerName} update command copied`, + description: "Run it in a terminal when you are ready to update.", + }); + }, + onError: (error, { providerName }) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not copy ${providerName} update command`, + description: error.message, + }), + ); + }, + }); // Narrow `instance.driver` for callers that key on the closed // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom @@ -535,97 +572,201 @@ export function ProviderInstanceCard({ ); }; + const titleIconNode = driverKind ? ( + + ) : FallbackIconComponent ? ( + + + + + ) : ( + + ); + + const titleHeadNode = ( + <> + {titleIconNode} +

{displayName}

+ {String(instanceId) !== String(instance.driver) ? ( + + {instanceId} + + ) : null} + {driverOption?.badgeLabel ? ( + + {driverOption.badgeLabel} + + ) : null} + + ); + + const titleTailNode = ( + <> + {headerAction ? ( + + {headerAction} + + ) : null} + {onDelete ? ( + + + + + + } + /> + Delete instance + + + ) : null} + + ); + + const authRowNode = ( +

+ {hasAuthenticatedEmail ? ( + <> + Authenticated as + + {authenticatedDetail ? · {authenticatedDetail} : null} + + ) : ( + <> + {summary.headline} + + + )} + {summary.detail ? - {summary.detail} : null} +

+ ); + + const versionCodeNode = versionLabel ? ( + {versionLabel} + ) : null; + return (
- {driverKind ? ( - - ) : FallbackIconComponent ? ( - - - + + + + } /> - - ) : ( - - )} -

{displayName}

- {String(instanceId) !== String(instance.driver) ? ( - // Hide the id chip on a default slot whose id === the - // driver slug — it's redundant with the driver icon + - // label. Custom instances (and any instance the user has - // since renamed) keep the chip so their slug stays - // visible for copy/paste + disambiguation. - - {instanceId} - - ) : null} - {driverOption?.badgeLabel ? ( - - {driverOption.badgeLabel} - - ) : null} - {versionLabel ? ( - {versionLabel} - ) : null} - {headerAction ? ( - - {headerAction} - - ) : null} - {onDelete ? ( - - - +
+
+

+ Update available +

+

+ {versionAdvisory.detail} +

+
+ {onRunUpdate ? ( - } - /> - Delete instance - - + ) : null} + {onRunUpdate && updateCommand ? ( +
+ + or, update manually using + +
+ ) : null} + {updateCommand ? ( +
+ + {updateCommand} + + + + copyToClipboard(updateCommand, { + providerName: displayName, + }) + } + aria-label="Copy update command" + > + + + } + /> + Copy command + +
+ ) : null} +
+ + ) : null} + {titleTailNode}
-

- {hasAuthenticatedEmail ? ( - <> - Authenticated as - - {authenticatedDetail ? · {authenticatedDetail} : null} - - ) : ( - <> - {summary.headline} - - - )} - {summary.detail ? - {summary.detail} : null} -

+ {authRowNode}
+ } + /> + {displayedView.description} + + {displayedView.dismissible && ( + + startExit(displayedView.key, null, displayedView.key)} + > + + + } + /> + Dismiss until provider status changes + + )} +
+ ); +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index dd0eba432b..0ebf0c8a7f 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -6,6 +6,7 @@ import { useMemo, useState, type CSSProperties, + type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode, } from "react"; @@ -38,9 +39,20 @@ import { export type ThreadToastData = { threadRef?: ScopedThreadRef | null; threadId?: ThreadId | null; + leadingIcon?: ReactNode; tooltipStyle?: boolean; + onClose?: (() => void) | undefined; dismissAfterVisibleMs?: number; hideCopyButton?: boolean; + secondaryActionProps?: ComponentPropsWithoutRef<"button">; + secondaryActionVariant?: + | "default" + | "destructive" + | "destructive-outline" + | "ghost" + | "link" + | "outline" + | "secondary"; /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */ expandableContent?: ReactNode; expandableLabels?: { expand?: string; collapse?: string }; @@ -90,6 +102,15 @@ const toastCornerOrbClass = cn( "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", ); +function handleToastDismissClick( + manager: typeof toastManager | typeof anchoredToastManager, + toastId: ToastId, + onClose: (() => void) | undefined, +) { + onClose?.(); + manager.close(toastId); +} + function CopyErrorButton({ text }: { text: string }) { const { copyToClipboard, isCopied } = useCopyToClipboard(); @@ -232,6 +253,7 @@ interface ToastBodyDescriptor { readonly Icon: ToastIconComponent | null | undefined; readonly stackedActionLayout: boolean; readonly actionVariant: NonNullable; + readonly secondaryActionVariant: NonNullable; readonly copyErrorText: string | null; readonly hasTrailingControls: boolean; readonly inlineContentEndPad: string; @@ -248,16 +270,21 @@ function deriveToastBodyDescriptor(toast: { toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; const actionVariant: NonNullable = toast.data?.actionVariant ?? "default"; + const secondaryActionVariant: NonNullable = + toast.data?.secondaryActionVariant ?? "outline"; const copyErrorText = toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton ? toast.description : null; - const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined; + const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; + const hasTrailingControls = + copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction; const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; return { Icon, stackedActionLayout, actionVariant, + secondaryActionVariant, copyErrorText, hasTrailingControls, inlineContentEndPad, @@ -277,22 +304,35 @@ function ToastBodyContent({ copyErrorText, actionProps, actionVariant, + secondaryActionVariant, hasTrailingControls, toastData, toastDescription, toastType, }: ToastBodyContentProps) { + const secondaryActionProps = toastData?.secondaryActionProps; + const leadingIcon = toastData?.leadingIcon; + const { className: secondaryActionClassName, ...secondaryActionRest } = + secondaryActionProps ?? {}; + return ( <>
- {Icon && ( + {leadingIcon ? ( +
+ {leadingIcon} +
+ ) : Icon ? (
- )} + ) : null}
{copyErrorText !== null ? : null} + {secondaryActionProps ? ( +