Unified · Split
| @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { · 51 unmodified lines | ||
| 52 | 52 | autoOpenPlanSidebar: false, |
| 53 | 53 | confirmThreadArchive: true, |
| 54 | 54 | confirmThreadDelete: false, |
| 55 | + dismissedProviderUpdateNotificationKeys: [], | |
| 55 | 56 | diffIgnoreWhitespace: true, |
| 56 | 57 | diffWordWrap: true, |
| 57 | 58 | favorites: [], |
| @@ -14,6 +14,7 @@ · 13 unmodified lines | ||
| 14 | 14 | */ |
| 15 | 15 | import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 16 | 16 | import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | +import { HttpClient } from "effect/unstable/http"; | |
| 17 | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | |
| 19 | 20 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; |
| @@ -34,15 +35,43 @@ import { · 14 unmodified lines | ||
| 34 | 35 | } from "../ProviderDriver.ts"; |
| 35 | 36 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 36 | 37 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 38 | +import { | |
| 39 | + enrichProviderSnapshotWithVersionAdvisory, | |
| 40 | + makePackageManagedProviderMaintenanceResolver, | |
| 41 | + normalizeCommandPath, | |
| 42 | + resolveProviderMaintenanceCapabilitiesEffect, | |
| 43 | +} from "../providerMaintenance.ts"; | |
| 37 | 44 | import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; |
| 38 | 45 | |
| 39 | 46 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 40 | 47 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); |
| 41 | 48 | const CAPABILITIES_PROBE_TTL = Duration.minutes(5); |
| 42 | 49 | |
| 50 | +function isClaudeNativeCommandPath(commandPath: string): boolean { | |
| 51 | + const normalized = normalizeCommandPath(commandPath); | |
| 52 | + return ( | |
| 53 | + normalized.endsWith("/.local/bin/claude") || | |
| 54 | + normalized.endsWith("/.local/bin/claude.exe") || | |
| 55 | + normalized.includes("/.local/share/claude/") | |
| 56 | + ); | |
| 57 | +} | |
| 58 | + | |
| 59 | +const UPDATE = makePackageManagedProviderMaintenanceResolver({ | |
| 60 | + provider: DRIVER_KIND, | |
| 61 | + npmPackageName: "@anthropic-ai/claude-code", | |
| 62 | + homebrewFormula: "claude-code", | |
| 63 | + nativeUpdate: { | |
| 64 | + executable: "claude", | |
| 65 | + args: ["update"], | |
| 66 | + lockKey: "claude-native", | |
| 67 | + isCommandPath: isClaudeNativeCommandPath, | |
| 68 | + }, | |
| 69 | +}); | |
| 70 | + | |
| 43 | 71 | export type ClaudeDriverEnv = |
| 44 | 72 | | ChildProcessSpawner.ChildProcessSpawner |
| 45 | 73 | | FileSystem.FileSystem |
| 74 | + | HttpClient.HttpClient | |
| 46 | 75 | | Path.Path |
| 47 | 76 | | ProviderEventLoggers |
| 48 | 77 | | ServerConfig; |
| @@ -75,6 +104,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 26 unmodified lines | ||
| 75 | 104 | Effect.gen(function* () { |
| 76 | 105 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 77 | 106 | const path = yield* Path.Path; |
| 107 | + const httpClient = yield* HttpClient.HttpClient; | |
| 78 | 108 | const eventLoggers = yield* ProviderEventLoggers; |
| 79 | 109 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 80 | 110 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ |
| @@ -82,6 +112,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 1 unmodified line | ||
| 82 | 112 | instanceId, |
| 83 | 113 | }); |
| 84 | 114 | const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; |
| 115 | + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | |
| 116 | + binaryPath: effectiveConfig.binaryPath, | |
| 117 | + env: processEnv, | |
| 118 | + }); | |
| 85 | 119 | const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); |
| 86 | 120 | const stampIdentity = withInstanceIdentity({ |
| 87 | 121 | instanceId, |
| @@ -121,11 +155,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 33 unmodified lines | ||
| 121 | 155 | ); |
| 122 | 156 | |
| 123 | 157 | const snapshot = yield* makeManagedServerProvider<ClaudeSettings>({ |
| 158 | + maintenanceCapabilities, | |
| 124 | 159 | getSettings: Effect.succeed(effectiveConfig), |
| 125 | 160 | streamSettings: Stream.never, |
| 126 | 161 | haveSettingsChanged: () => false, |
| 127 | 162 | initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), |
| 128 | 163 | checkProvider, |
| 164 | + enrichSnapshot: ({ snapshot, publishSnapshot }) => | |
| 165 | + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( | |
| 166 | + Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 167 | + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | |
| 168 | + ), | |
| 129 | 169 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, |
| 130 | 170 | }).pipe( |
| 131 | 171 | Effect.mapError( |
| @@ -23,6 +23,7 @@ · 22 unmodified lines | ||
| 23 | 23 | */ |
| 24 | 24 | import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 25 | 25 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 26 | +import { HttpClient } from "effect/unstable/http"; | |
| 26 | 27 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 27 | 28 | |
| 28 | 29 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; |
| @@ -35,6 +36,11 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; · 6 unmodified lines | ||
| 35 | 36 | import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; |
| 36 | 37 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 37 | 38 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 39 | +import { | |
| 40 | + enrichProviderSnapshotWithVersionAdvisory, | |
| 41 | + makePackageManagedProviderMaintenanceResolver, | |
| 42 | + resolveProviderMaintenanceCapabilitiesEffect, | |
| 43 | +} from "../providerMaintenance.ts"; | |
| 38 | 44 | import { |
| 39 | 45 | codexContinuationIdentity, |
| 40 | 46 | materializeCodexShadowHome, |
| @@ -43,6 +49,12 @@ import { · 2 unmodified lines | ||
| 43 | 49 | |
| 44 | 50 | const DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 45 | 51 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); |
| 52 | +const UPDATE = makePackageManagedProviderMaintenanceResolver({ | |
| 53 | + provider: DRIVER_KIND, | |
| 54 | + npmPackageName: "@openai/codex", | |
| 55 | + homebrewFormula: "codex", | |
| 56 | + nativeUpdate: null, | |
| 57 | +}); | |
| 46 | 58 | |
| 47 | 59 | /** |
| 48 | 60 | * Services the driver needs to materialize an instance. Surfaced as the |
| @@ -52,6 +64,7 @@ const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); · 3 unmodified lines | ||
| 52 | 64 | export type CodexDriverEnv = |
| 53 | 65 | | ChildProcessSpawner.ChildProcessSpawner |
| 54 | 66 | | FileSystem.FileSystem |
| 67 | + | HttpClient.HttpClient | |
| 55 | 68 | | Path.Path |
| 56 | 69 | | ProviderEventLoggers |
| 57 | 70 | | ServerConfig; |
| @@ -89,6 +102,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 31 unmodified lines | ||
| 89 | 102 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 90 | 103 | Effect.gen(function* () { |
| 91 | 104 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 105 | + const httpClient = yield* HttpClient.HttpClient; | |
| 92 | 106 | const eventLoggers = yield* ProviderEventLoggers; |
| 93 | 107 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 94 | 108 | const homeLayout = yield* resolveCodexHomeLayout(config); |
| @@ -115,6 +129,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 20 unmodified lines | ||
| 115 | 129 | enabled, |
| 116 | 130 | homePath: homeLayout.effectiveHomePath ?? "", |
| 117 | 131 | } satisfies CodexSettings; |
| 132 | + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | |
| 133 | + binaryPath: effectiveConfig.binaryPath, | |
| 134 | + env: processEnv, | |
| 135 | + }); | |
| 118 | 136 | |
| 119 | 137 | // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error |
| 120 | 138 | // channels at construction time — their failure modes are all on the |
| @@ -138,11 +156,17 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 17 unmodified lines | ||
| 138 | 156 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 139 | 157 | ); |
| 140 | 158 | const snapshot = yield* makeManagedServerProvider<CodexSettings>({ |
| 159 | + maintenanceCapabilities, | |
| 141 | 160 | getSettings: Effect.succeed(effectiveConfig), |
| 142 | 161 | streamSettings: Stream.never, |
| 143 | 162 | haveSettingsChanged: () => false, |
| 144 | 163 | initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), |
| 145 | 164 | checkProvider, |
| 165 | + enrichSnapshot: ({ snapshot, publishSnapshot }) => | |
| 166 | + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( | |
| 167 | + Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 168 | + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | |
| 169 | + ), | |
| 146 | 170 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, |
| 147 | 171 | }).pipe( |
| 148 | 172 | Effect.mapError( |
| @@ -14,6 +14,7 @@ · 13 unmodified lines | ||
| 14 | 14 | */ |
| 15 | 15 | import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 16 | 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | +import { HttpClient } from "effect/unstable/http"; | |
| 17 | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | |
| 19 | 20 | import { ServerConfig } from "../../config.ts"; |
| @@ -34,13 +35,28 @@ import { · 14 unmodified lines | ||
| 34 | 35 | } from "../ProviderDriver.ts"; |
| 35 | 36 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 36 | 37 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 38 | +import { | |
| 39 | + makeProviderMaintenanceCapabilities, | |
| 40 | + makeStaticProviderMaintenanceResolver, | |
| 41 | + resolveProviderMaintenanceCapabilitiesEffect, | |
| 42 | +} from "../providerMaintenance.ts"; | |
| 37 | 43 | |
| 38 | 44 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 39 | 45 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); |
| 46 | +const UPDATE = makeStaticProviderMaintenanceResolver( | |
| 47 | + makeProviderMaintenanceCapabilities({ | |
| 48 | + provider: DRIVER_KIND, | |
| 49 | + packageName: null, | |
| 50 | + updateExecutable: "agent", | |
| 51 | + updateArgs: ["update"], | |
| 52 | + updateLockKey: "cursor-agent", | |
| 53 | + }), | |
| 54 | +); | |
| 40 | 55 | |
| 41 | 56 | export type CursorDriverEnv = |
| 42 | 57 | | ChildProcessSpawner.ChildProcessSpawner |
| 43 | 58 | | FileSystem.FileSystem |
| 59 | + | HttpClient.HttpClient | |
| 44 | 60 | | Path.Path |
| 45 | 61 | | ProviderEventLoggers |
| 46 | 62 | | ServerConfig; |
| @@ -74,6 +90,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { · 27 unmodified lines | ||
| 74 | 90 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 75 | 91 | const fileSystem = yield* FileSystem.FileSystem; |
| 76 | 92 | const path = yield* Path.Path; |
| 93 | + const httpClient = yield* HttpClient.HttpClient; | |
| 77 | 94 | const eventLoggers = yield* ProviderEventLoggers; |
| 78 | 95 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 79 | 96 | const continuationIdentity = defaultProviderContinuationIdentity({ |
| @@ -87,6 +104,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { · 7 unmodified lines | ||
| 87 | 104 | continuationGroupKey: continuationIdentity.continuationKey, |
| 88 | 105 | }); |
| 89 | 106 | const effectiveConfig = { ...config, enabled } satisfies CursorSettings; |
| 107 | + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | |
| 108 | + binaryPath: effectiveConfig.binaryPath, | |
| 109 | + env: processEnv, | |
| 110 | + }); | |
| 90 | 111 | |
| 91 | 112 | const adapter = yield* makeCursorAdapter(effectiveConfig, { |
| 92 | 113 | environment: processEnv, |
| @@ -103,6 +124,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { · 10 unmodified lines | ||
| 103 | 124 | ); |
| 104 | 125 | |
| 105 | 126 | const snapshot = yield* makeManagedServerProvider<CursorSettings>({ |
| 127 | + maintenanceCapabilities, | |
| 106 | 128 | getSettings: Effect.succeed(effectiveConfig), |
| 107 | 129 | streamSettings: Stream.never, |
| 108 | 130 | haveSettingsChanged: () => false, |
| @@ -117,8 +139,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { · 8 unmodified lines | ||
| 117 | 139 | settings, |
| 118 | 140 | environment: processEnv, |
| 119 | 141 | snapshot: currentSnapshot, |
| 142 | + maintenanceCapabilities, | |
| 120 | 143 | publishSnapshot, |
| 121 | 144 | stampIdentity, |
| 145 | + httpClient, | |
| 122 | 146 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), |
| 123 | 147 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, |
| 124 | 148 | }).pipe( |
| @@ -14,6 +14,7 @@ · 13 unmodified lines | ||
| 14 | 14 | */ |
| 15 | 15 | import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 16 | 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | +import { HttpClient } from "effect/unstable/http"; | |
| 17 | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | |
| 19 | 20 | import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; |
| @@ -34,13 +35,40 @@ import { · 14 unmodified lines | ||
| 34 | 35 | } from "../ProviderDriver.ts"; |
| 35 | 36 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 36 | 37 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 38 | +import { | |
| 39 | + enrichProviderSnapshotWithVersionAdvisory, | |
| 40 | + makePackageManagedProviderMaintenanceResolver, | |
| 41 | + normalizeCommandPath, | |
| 42 | + resolveProviderMaintenanceCapabilitiesEffect, | |
| 43 | +} from "../providerMaintenance.ts"; | |
| 37 | 44 | |
| 38 | 45 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 39 | 46 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); |
| 40 | 47 | |
| 48 | +function isOpenCodeNativeCommandPath(commandPath: string): boolean { | |
| 49 | + const normalized = normalizeCommandPath(commandPath); | |
| 50 | + return ( | |
| 51 | + normalized.endsWith("/.opencode/bin/opencode") || | |
| 52 | + normalized.endsWith("/.opencode/bin/opencode.exe") | |
| 53 | + ); | |
| 54 | +} | |
| 55 | + | |
| 56 | +const UPDATE = makePackageManagedProviderMaintenanceResolver({ | |
| 57 | + provider: DRIVER_KIND, | |
| 58 | + npmPackageName: "opencode-ai", | |
| 59 | + homebrewFormula: "anomalyco/tap/opencode", | |
| 60 | + nativeUpdate: { | |
| 61 | + executable: "opencode", | |
| 62 | + args: ["upgrade"], | |
| 63 | + lockKey: "opencode-native", | |
| 64 | + isCommandPath: isOpenCodeNativeCommandPath, | |
| 65 | + }, | |
| 66 | +}); | |
| 67 | + | |
| 41 | 68 | export type OpenCodeDriverEnv = |
| 42 | 69 | | ChildProcessSpawner.ChildProcessSpawner |
| 43 | 70 | | FileSystem.FileSystem |
| 71 | + | HttpClient.HttpClient | |
| 44 | 72 | | OpenCodeRuntime |
| 45 | 73 | | Path.Path |
| 46 | 74 | | ProviderEventLoggers |
| @@ -74,6 +102,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 27 unmodified lines | ||
| 74 | 102 | Effect.gen(function* () { |
| 75 | 103 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 76 | 104 | const serverConfig = yield* ServerConfig; |
| 105 | + const httpClient = yield* HttpClient.HttpClient; | |
| 77 | 106 | const eventLoggers = yield* ProviderEventLoggers; |
| 78 | 107 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 79 | 108 | const continuationIdentity = defaultProviderContinuationIdentity({ |
| @@ -87,6 +116,10 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 7 unmodified lines | ||
| 87 | 116 | continuationGroupKey: continuationIdentity.continuationKey, |
| 88 | 117 | }); |
| 89 | 118 | const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; |
| 119 | + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | |
| 120 | + binaryPath: effectiveConfig.binaryPath, | |
| 121 | + env: processEnv, | |
| 122 | + }); | |
| 90 | 123 | |
| 91 | 124 | const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { |
| 92 | 125 | instanceId, |
| @@ -102,11 +135,17 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 9 unmodified lines | ||
| 102 | 135 | ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); |
| 103 | 136 | |
| 104 | 137 | const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({ |
| 138 | + maintenanceCapabilities, | |
| 105 | 139 | getSettings: Effect.succeed(effectiveConfig), |
| 106 | 140 | streamSettings: Stream.never, |
| 107 | 141 | haveSettingsChanged: () => false, |
| 108 | 142 | initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), |
| 109 | 143 | checkProvider, |
| 144 | + enrichSnapshot: ({ snapshot, publishSnapshot }) => | |
| 145 | + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( | |
| 146 | + Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 147 | + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | |
| 148 | + ), | |
| 110 | 149 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, |
| 111 | 150 | }).pipe( |
| 112 | 151 | Effect.mapError( |
| @@ -15,7 +15,6 @@ import type { · 14 unmodified lines | ||
| 15 | 15 | import { ServerSettingsError } from "@t3tools/contracts"; |
| 16 | 16 | |
| 17 | 17 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 18 | − | |
| 19 | 18 | import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; |
| 20 | 19 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 21 | 20 | import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; |
| @@ -12,6 +12,7 @@ import type { · 11 unmodified lines | ||
| 12 | 12 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 13 | 13 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 14 | 14 | import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect"; |
| 15 | +import { HttpClient } from "effect/unstable/http"; | |
| 15 | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 16 | 17 | import { |
| 17 | 18 | createModelCapabilities, |
| @@ -29,6 +30,10 @@ import { · 11 unmodified lines | ||
| 29 | 30 | type CommandResult, |
| 30 | 31 | type ServerProviderDraft, |
| 31 | 32 | } from "../providerSnapshot.ts"; |
| 33 | +import { | |
| 34 | + enrichProviderSnapshotWithVersionAdvisory, | |
| 35 | + type ProviderMaintenanceCapabilities, | |
| 36 | +} from "../providerMaintenance.ts"; | |
| 32 | 37 | import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; |
| 33 | 38 | |
| 34 | 39 | const PROVIDER = ProviderDriverKind.make("cursor"); |
| @@ -1216,42 +1221,67 @@ export const enrichCursorSnapshot = (input: { · 1181 unmodified lines | ||
| 1216 | 1221 | readonly settings: CursorSettings; |
| 1217 | 1222 | readonly environment?: NodeJS.ProcessEnv; |
| 1218 | 1223 | readonly snapshot: ServerProvider; |
| 1224 | + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | |
| 1219 | 1225 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; |
| 1220 | 1226 | readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; |
| 1227 | + readonly httpClient: HttpClient.HttpClient; | |
| 1221 | 1228 | }): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => { |
| 1222 | 1229 | const { settings, snapshot, publishSnapshot } = input; |
| 1223 | 1230 | const stampIdentity = input.stampIdentity ?? ((value) => value); |
| 1224 | 1231 | |
| 1225 | − if ( | |
| 1226 | − !settings.enabled || | |
| 1227 | − snapshot.auth.status === "unauthenticated" || | |
| 1228 | − !hasUncapturedCursorModels(snapshot) | |
| 1229 | − ) { | |
| 1230 | − return Effect.void; | |
| 1231 | − } | |
| 1232 | + const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory( | |
| 1233 | + snapshot, | |
| 1234 | + input.maintenanceCapabilities, | |
| 1235 | + ).pipe( | |
| 1236 | + Effect.provideService(HttpClient.HttpClient, input.httpClient), | |
| 1237 | + Effect.flatMap((enrichedSnapshot) => | |
| 1238 | + publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), | |
| 1239 | + ), | |
| 1240 | + Effect.catchCause((cause) => | |
| 1241 | + Effect.logWarning("Cursor version advisory enrichment failed", { | |
| 1242 | + cause: Cause.pretty(cause), | |
| 1243 | + }).pipe(Effect.as(snapshot)), | |
| 1244 | + ), | |
| 1245 | + ); | |
| 1232 | 1246 | |
| 1233 | − return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe( | |
| 1234 | − Effect.flatMap((discoveredModels) => { | |
| 1235 | − if (discoveredModels.length === 0) { | |
| 1247 | + return enrichVersionAdvisory.pipe( | |
| 1248 | + Effect.flatMap((baseSnapshot) => { | |
| 1249 | + if ( | |
| 1250 | + !settings.enabled || | |
| 1251 | + baseSnapshot.auth.status === "unauthenticated" || | |
| 1252 | + !hasUncapturedCursorModels(baseSnapshot) | |
| 1253 | + ) { | |
| 1236 | 1254 | return Effect.void; |
| 1237 | 1255 | } |
| 1238 | − return publishSnapshot( | |
| 1239 | − stampIdentity({ | |
| 1240 | − ...snapshot, | |
| 1241 | − models: providerModelsFromSettings( | |
| 1242 | − discoveredModels, | |
| 1243 | − PROVIDER, | |
| 1244 | − settings.customModels, | |
| 1245 | − EMPTY_CAPABILITIES, | |
| 1246 | − ), | |
| 1256 | + | |
| 1257 | + return discoverCursorModelCapabilitiesViaAcp( | |
| 1258 | + settings, | |
| 1259 | + baseSnapshot.models, | |
| 1260 | + input.environment, | |
| 1261 | + ).pipe( | |
| 1262 | + Effect.flatMap((discoveredModels) => { | |
| 1263 | + if (discoveredModels.length === 0) { | |
| 1264 | + return Effect.void; | |
| 1265 | + } | |
| 1266 | + return publishSnapshot( | |
| 1267 | + stampIdentity({ | |
| 1268 | + ...baseSnapshot, | |
| 1269 | + models: providerModelsFromSettings( | |
| 1270 | + discoveredModels, | |
| 1271 | + PROVIDER, | |
| 1272 | + settings.customModels, | |
| 1273 | + EMPTY_CAPABILITIES, | |
| 1274 | + ), | |
| 1275 | + }), | |
| 1276 | + ); | |
| 1247 | 1277 | }), |
| 1278 | + Effect.catchCause((cause) => | |
| 1279 | + Effect.logWarning("Cursor ACP background capability enrichment failed", { | |
| 1280 | + models: baseSnapshot.models.map((model) => model.slug), | |
| 1281 | + cause: Cause.pretty(cause), | |
| 1282 | + }).pipe(Effect.asVoid), | |
| 1283 | + ), | |
| 1248 | 1284 | ); |
| 1249 | 1285 | }), |
| 1250 | − Effect.catchCause((cause) => | |
| 1251 | − Effect.logWarning("Cursor ACP background capability enrichment failed", { | |
| 1252 | − models: snapshot.models.map((model) => model.slug), | |
| 1253 | − cause: Cause.pretty(cause), | |
| 1254 | − }).pipe(Effect.asVoid), | |
| 1255 | − ), | |
| 1256 | 1286 | ); |
| 1257 | 1287 | }; |
| @@ -7,7 +7,6 @@ import { · 6 unmodified lines | ||
| 7 | 7 | import { Cause, Data, Effect } from "effect"; |
| 8 | 8 | |
| 9 | 9 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 10 | − | |
| 11 | 10 | import { |
| 12 | 11 | buildServerProvider, |
| 13 | 12 | nonEmptyTrimmed, |
| @@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; · 13 unmodified lines | ||
| 14 | 14 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 15 | 15 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 16 | 16 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 17 | +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | |
| 17 | 18 | import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; |
| 18 | 19 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; |
| 19 | 20 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| @@ -111,6 +112,10 @@ const makeFakeInstance = ( · 91 unmodified lines | ||
| 111 | 112 | displayName: undefined, |
| 112 | 113 | enabled: true, |
| 113 | 114 | snapshot: { |
| 115 | + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ | |
| 116 | + provider: driverKind, | |
| 117 | + packageName: null, | |
| 118 | + }), | |
| 114 | 119 | getSnapshot: Effect.succeed({} as unknown as ServerProvider), |
| 115 | 120 | refresh: Effect.succeed({} as unknown as ServerProvider), |
| 116 | 121 | streamChanges: Stream.empty, |
| @@ -34,6 +34,7 @@ import { · 33 unmodified lines | ||
| 34 | 34 | ProviderInstanceId, |
| 35 | 35 | } from "@t3tools/contracts"; |
| 36 | 36 | import { Effect, Layer } from "effect"; |
| 37 | +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 37 | 38 | |
| 38 | 39 | import { ServerConfig } from "../../config.ts"; |
| 39 | 40 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; |
| @@ -44,6 +45,13 @@ import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; · 4 unmodified lines | ||
| 44 | 45 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 45 | 46 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; |
| 46 | 47 | |
| 48 | +const TestHttpClientLive = Layer.succeed( | |
| 49 | + HttpClient.HttpClient, | |
| 50 | + HttpClient.make((request) => | |
| 51 | + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), | |
| 52 | + ), | |
| 53 | +); | |
| 54 | + | |
| 47 | 55 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ |
| 48 | 56 | enabled: false, |
| 49 | 57 | binaryPath: "codex", |
| @@ -89,6 +97,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { · 39 unmodified lines | ||
| 89 | 97 | prefix: "provider-instance-registry-test", |
| 90 | 98 | }).pipe( |
| 91 | 99 | Layer.provideMerge(NodeServices.layer), |
| 100 | + Layer.provideMerge(TestHttpClientLive), | |
| 92 | 101 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 93 | 102 | ); |
| 94 | 103 | |
| @@ -225,6 +234,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { · 130 unmodified lines | ||
| 225 | 234 | prefix: "provider-instance-registry-all-drivers-test", |
| 226 | 235 | }).pipe( |
| 227 | 236 | Layer.provideMerge(infraLayer), |
| 237 | + Layer.provideMerge(TestHttpClientLive), | |
| 228 | 238 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 229 | 239 | ); |
| 230 | 240 | |
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | ||
| 14 | 14 | type ServerSettings as ContractServerSettings, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | 16 | import * as PlatformError from "effect/PlatformError"; |
| 17 | +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 17 | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | import { deepMerge } from "@t3tools/shared/Struct"; |
| 19 | 20 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| @@ -26,13 +27,17 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr · 6 unmodified lines | ||
| 26 | 27 | import { |
| 27 | 28 | haveProvidersChanged, |
| 28 | 29 | mergeProviderSnapshot, |
| 30 | + mergeProviderSnapshots, | |
| 29 | 31 | ProviderRegistryLive, |
| 32 | + selectProvidersByKind, | |
| 30 | 33 | } from "./ProviderRegistry.ts"; |
| 31 | 34 | import { ServerConfig } from "../../config.ts"; |
| 32 | 35 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; |
| 36 | +import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; | |
| 33 | 37 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 34 | 38 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 35 | 39 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; |
| 40 | +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | |
| 36 | 41 | |
| 37 | 42 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); |
| 38 | 43 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); |
| @@ -46,6 +51,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; · 7 unmodified lines | ||
| 46 | 51 | |
| 47 | 52 | const encoder = new TextEncoder(); |
| 48 | 53 | |
| 54 | +const TestHttpClientLive = Layer.succeed( | |
| 55 | + HttpClient.HttpClient, | |
| 56 | + HttpClient.make((request) => | |
| 57 | + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), | |
| 58 | + ), | |
| 59 | +); | |
| 60 | + | |
| 49 | 61 | function selectDescriptor( |
| 50 | 62 | id: string, |
| 51 | 63 | label: string, |
| @@ -246,7 +258,7 @@ function makeMutableServerSettingsService( · 194 unmodified lines | ||
| 246 | 258 | }); |
| 247 | 259 | } |
| 248 | 260 | |
| 249 | −it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | |
| 261 | +it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), TestHttpClientLive))( | |
| 250 | 262 | "ProviderRegistry", |
| 251 | 263 | (it) => { |
| 252 | 264 | describe("checkCodexProviderStatus", () => { |
| @@ -500,6 +512,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 247 unmodified lines | ||
| 500 | 512 | ]); |
| 501 | 513 | }); |
| 502 | 514 | |
| 515 | + it("persists merged provider snapshots for the providers that were refreshed", () => { | |
| 516 | + const previousProviders = [ | |
| 517 | + { | |
| 518 | + instanceId: ProviderInstanceId.make("cursor"), | |
| 519 | + driver: ProviderDriverKind.make("cursor"), | |
| 520 | + status: "ready", | |
| 521 | + enabled: true, | |
| 522 | + installed: true, | |
| 523 | + auth: { status: "authenticated" }, | |
| 524 | + checkedAt: "2026-04-14T00:00:00.000Z", | |
| 525 | + version: "2026.04.09-f2b0fcd", | |
| 526 | + models: [ | |
| 527 | + { | |
| 528 | + slug: "claude-opus-4-6", | |
| 529 | + name: "Opus 4.6", | |
| 530 | + isCustom: false, | |
| 531 | + capabilities: createModelCapabilities({ | |
| 532 | + optionDescriptors: [ | |
| 533 | + selectDescriptor("reasoning", "Reasoning", [ | |
| 534 | + { id: "high", label: "High", isDefault: true }, | |
| 535 | + ]), | |
| 536 | + booleanDescriptor("fastMode", "Fast Mode"), | |
| 537 | + booleanDescriptor("thinking", "Thinking"), | |
| 538 | + ], | |
| 539 | + }), | |
| 540 | + }, | |
| 541 | + ], | |
| 542 | + slashCommands: [], | |
| 543 | + skills: [], | |
| 544 | + }, | |
| 545 | + { | |
| 546 | + instanceId: ProviderInstanceId.make("codex"), | |
| 547 | + driver: ProviderDriverKind.make("codex"), | |
| 548 | + status: "ready", | |
| 549 | + enabled: true, | |
| 550 | + installed: true, | |
| 551 | + auth: { status: "authenticated" }, | |
| 552 | + checkedAt: "2026-04-14T00:00:00.000Z", | |
| 553 | + version: "1.0.0", | |
| 554 | + models: [], | |
| 555 | + slashCommands: [], | |
| 556 | + skills: [], | |
| 557 | + }, | |
| 558 | + ] as const satisfies ReadonlyArray<ServerProvider>; | |
| 559 | + const refreshedCursor = { | |
| 560 | + ...previousProviders[0], | |
| 561 | + checkedAt: "2026-04-14T00:01:00.000Z", | |
| 562 | + models: [], | |
| 563 | + } satisfies ServerProvider; | |
| 564 | + | |
| 565 | + const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); | |
| 566 | + const persistedProviders = selectProvidersByKind( | |
| 567 | + mergedProviders, | |
| 568 | + new Set([ProviderDriverKind.make("cursor")]), | |
| 569 | + ); | |
| 570 | + | |
| 571 | + assert.deepStrictEqual(persistedProviders, [ | |
| 572 | + { | |
| 573 | + ...refreshedCursor, | |
| 574 | + models: [...previousProviders[0].models], | |
| 575 | + }, | |
| 576 | + ]); | |
| 577 | + }); | |
| 578 | + | |
| 579 | + it.effect("persists the merged snapshot when a live update has empty models", () => | |
| 580 | + Effect.gen(function* () { | |
| 581 | + const cursorDriver = ProviderDriverKind.make("cursor"); | |
| 582 | + const cursorInstanceId = ProviderInstanceId.make("cursor"); | |
| 583 | + const initialProvider = { | |
| 584 | + instanceId: cursorInstanceId, | |
| 585 | + driver: cursorDriver, | |
| 586 | + status: "ready", | |
| 587 | + enabled: true, | |
| 588 | + installed: true, | |
| 589 | + auth: { status: "authenticated" }, | |
| 590 | + checkedAt: "2026-04-14T00:00:00.000Z", | |
| 591 | + version: "2026.04.09-f2b0fcd", | |
| 592 | + models: [ | |
| 593 | + { | |
| 594 | + slug: "claude-opus-4-6", | |
| 595 | + name: "Opus 4.6", | |
| 596 | + isCustom: false, | |
| 597 | + capabilities: createModelCapabilities({ | |
| 598 | + optionDescriptors: [ | |
| 599 | + selectDescriptor("reasoning", "Reasoning", [ | |
| 600 | + { id: "high", label: "High", isDefault: true }, | |
| 601 | + ]), | |
| 602 | + ], | |
| 603 | + }), | |
| 604 | + }, | |
| 605 | + ], | |
| 606 | + slashCommands: [], | |
| 607 | + skills: [], | |
| 608 | + } as const satisfies ServerProvider; | |
| 609 | + const refreshedProvider = { | |
| 610 | + ...initialProvider, | |
| 611 | + checkedAt: "2026-04-14T00:01:00.000Z", | |
| 612 | + models: [], | |
| 613 | + } satisfies ServerProvider; | |
| 614 | + const changes = yield* PubSub.unbounded<ServerProvider>(); | |
| 615 | + const instance = { | |
| 616 | + instanceId: cursorInstanceId, | |
| 617 | + driverKind: cursorDriver, | |
| 618 | + continuationIdentity: { | |
| 619 | + driverKind: cursorDriver, | |
| 620 | + continuationKey: "cursor:instance:cursor", | |
| 621 | + }, | |
| 622 | + displayName: undefined, | |
| 623 | + enabled: true, | |
| 624 | + snapshot: { | |
| 625 | + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ | |
| 626 | + provider: cursorDriver, | |
| 627 | + packageName: null, | |
| 628 | + }), | |
| 629 | + getSnapshot: Effect.succeed(initialProvider), | |
| 630 | + refresh: Effect.succeed(refreshedProvider), | |
| 631 | + streamChanges: Stream.fromPubSub(changes), | |
| 632 | + }, | |
| 633 | + adapter: {} as ProviderInstance["adapter"], | |
| 634 | + textGeneration: {} as ProviderInstance["textGeneration"], | |
| 635 | + } satisfies ProviderInstance; | |
| 636 | + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { | |
| 637 | + getInstance: (instanceId) => | |
| 638 | + Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), | |
| 639 | + listInstances: Effect.succeed([instance]), | |
| 640 | + listUnavailable: Effect.succeed([]), | |
| 641 | + streamChanges: Stream.empty, | |
| 642 | + subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | |
| 643 | + PubSub.subscribe(pubsub), | |
| 644 | + ), | |
| 645 | + }); | |
| 646 | + const scope = yield* Scope.make(); | |
| 647 | + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | |
| 648 | + const runtimeServices = yield* Layer.build( | |
| 649 | + ProviderRegistryLive.pipe( | |
| 650 | + Layer.provideMerge(instanceRegistryLayer), | |
| 651 | + Layer.provideMerge( | |
| 652 | + ServerConfig.layerTest(process.cwd(), { | |
| 653 | + prefix: "t3-provider-registry-merged-persist-", | |
| 654 | + }), | |
| 655 | + ), | |
| 656 | + Layer.provideMerge(NodeServices.layer), | |
| 657 | + ), | |
| 658 | + ).pipe(Scope.provide(scope)); | |
| 659 | + | |
| 660 | + yield* Effect.gen(function* () { | |
| 661 | + const registry = yield* ProviderRegistry; | |
| 662 | + const config = yield* ServerConfig; | |
| 663 | + const filePath = yield* resolveProviderStatusCachePath({ | |
| 664 | + cacheDir: config.providerStatusCacheDir, | |
| 665 | + instanceId: cursorInstanceId, | |
| 666 | + }); | |
| 667 | + | |
| 668 | + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ | |
| 669 | + ...initialProvider.models, | |
| 670 | + ]); | |
| 671 | + yield* PubSub.publish(changes, refreshedProvider); | |
| 672 | + | |
| 673 | + let cachedProvider = yield* readProviderStatusCache(filePath); | |
| 674 | + for ( | |
| 675 | + let attempt = 0; | |
| 676 | + attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; | |
| 677 | + attempt += 1 | |
| 678 | + ) { | |
| 679 | + yield* Effect.sleep("10 millis"); | |
| 680 | + cachedProvider = yield* readProviderStatusCache(filePath); | |
| 681 | + } | |
| 682 | + | |
| 683 | + assert.deepStrictEqual(cachedProvider, { | |
| 684 | + ...refreshedProvider, | |
| 685 | + models: [...initialProvider.models], | |
| 686 | + }); | |
| 687 | + }).pipe(Effect.provide(runtimeServices)); | |
| 688 | + }), | |
| 689 | + ); | |
| 690 | + | |
| 503 | 691 | it.effect("returns the cached provider list when a manual refresh fails", () => |
| 504 | 692 | Effect.gen(function* () { |
| 505 | 693 | const codexDriver = ProviderDriverKind.make("codex"); |
| @@ -527,6 +715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 21 unmodified lines | ||
| 527 | 715 | displayName: undefined, |
| 528 | 716 | enabled: true, |
| 529 | 717 | snapshot: { |
| 718 | + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ | |
| 719 | + provider: codexDriver, | |
| 720 | + packageName: null, | |
| 721 | + }), | |
| 530 | 722 | getSnapshot: Effect.succeed(cachedProvider), |
| 531 | 723 | refresh: Effect.die(new Error("simulated refresh failure")), |
| 532 | 724 | streamChanges: Stream.empty, |
| @@ -612,6 +804,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 79 unmodified lines | ||
| 612 | 804 | displayName: undefined, |
| 613 | 805 | enabled: true, |
| 614 | 806 | snapshot: { |
| 807 | + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ | |
| 808 | + provider: provider.driver, | |
| 809 | + packageName: null, | |
| 810 | + }), | |
| 615 | 811 | getSnapshot: Effect.succeed(provider), |
| 616 | 812 | refresh: Effect.succeed(provider), |
| 617 | 813 | streamChanges: Stream.empty, |
| @@ -748,6 +944,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 130 unmodified lines | ||
| 748 | 944 | prefix: "t3-provider-registry-", |
| 749 | 945 | }), |
| 750 | 946 | ), |
| 947 | + Layer.provideMerge(TestHttpClientLive), | |
| 751 | 948 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 752 | 949 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 753 | 950 | // NO spawner mock — `ChildProcessSpawner` is supplied by the |
| @@ -832,6 +1029,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 78 unmodified lines | ||
| 832 | 1029 | prefix: "t3-provider-registry-", |
| 833 | 1030 | }), |
| 834 | 1031 | ), |
| 1032 | + Layer.provideMerge(TestHttpClientLive), | |
| 835 | 1033 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 836 | 1034 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 837 | 1035 | // `it.live` does not inherit layers from the outer `it.layer` |
| @@ -936,6 +1134,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 98 unmodified lines | ||
| 936 | 1134 | prefix: "t3-provider-registry-", |
| 937 | 1135 | }), |
| 938 | 1136 | ), |
| 1137 | + Layer.provideMerge(TestHttpClientLive), | |
| 939 | 1138 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 940 | 1139 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 941 | 1140 | Layer.provideMerge(NodeServices.layer), |
| @@ -986,6 +1185,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 44 unmodified lines | ||
| 986 | 1185 | prefix: "t3-provider-registry-", |
| 987 | 1186 | }), |
| 988 | 1187 | ), |
| 1188 | + Layer.provideMerge(TestHttpClientLive), | |
| 989 | 1189 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 990 | 1190 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 991 | 1191 | Layer.provideMerge( |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | ||
| 27 | 27 | ProviderDriverKind, |
| 28 | 28 | type ProviderInstanceId, |
| 29 | 29 | type ServerProvider, |
| 30 | + type ServerProviderUpdateState, | |
| 30 | 31 | } from "@t3tools/contracts"; |
| 31 | 32 | import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; |
| 32 | 33 | import * as Semaphore from "effect/Semaphore"; |
| @@ -43,6 +44,7 @@ import { · 10 unmodified lines | ||
| 43 | 44 | writeProviderStatusCache, |
| 44 | 45 | } from "../providerStatusCache.ts"; |
| 45 | 46 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 47 | +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | |
| 46 | 48 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; |
| 47 | 49 | |
| 48 | 50 | const loadProviders = ( |
| @@ -59,6 +61,12 @@ const loadProviders = ( · 10 unmodified lines | ||
| 59 | 61 | }, |
| 60 | 62 | ); |
| 61 | 63 | |
| 64 | +const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) => | |
| 65 | + makeManualOnlyProviderMaintenanceCapabilities({ | |
| 66 | + provider, | |
| 67 | + packageName: null, | |
| 68 | + }); | |
| 69 | + | |
| 62 | 70 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => |
| 63 | 71 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; |
| 64 | 72 | |
| @@ -96,6 +104,30 @@ export const mergeProviderSnapshot = ( · 31 unmodified lines | ||
| 96 | 104 | models: mergeProviderModels(previousProvider.models, nextProvider.models), |
| 97 | 105 | }; |
| 98 | 106 | |
| 107 | +export const mergeProviderSnapshots = ( | |
| 108 | + previousProviders: ReadonlyArray<ServerProvider>, | |
| 109 | + nextProviders: ReadonlyArray<ServerProvider>, | |
| 110 | +): ReadonlyArray<ServerProvider> => { | |
| 111 | + const mergedProviders = new Map( | |
| 112 | + previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), | |
| 113 | + ); | |
| 114 | + | |
| 115 | + for (const provider of nextProviders) { | |
| 116 | + mergedProviders.set( | |
| 117 | + snapshotInstanceKey(provider), | |
| 118 | + mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), | |
| 119 | + ); | |
| 120 | + } | |
| 121 | + | |
| 122 | + return orderProviderSnapshots([...mergedProviders.values()]); | |
| 123 | +}; | |
| 124 | + | |
| 125 | +export const selectProvidersByKind = ( | |
| 126 | + providers: ReadonlyArray<ServerProvider>, | |
| 127 | + providerKinds: ReadonlySet<ProviderDriverKind>, | |
| 128 | +): ReadonlyArray<ServerProvider> => | |
| 129 | + providers.filter((provider) => providerKinds.has(provider.driver)); | |
| 130 | + | |
| 99 | 131 | export const haveProvidersChanged = ( |
| 100 | 132 | previousProviders: ReadonlyArray<ServerProvider>, |
| 101 | 133 | nextProviders: ReadonlyArray<ServerProvider>, |
| @@ -227,6 +259,9 @@ export const ProviderRegistryLive = Layer.effect( · 125 unmodified lines | ||
| 227 | 259 | ), |
| 228 | 260 | ); |
| 229 | 261 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); |
| 262 | + const maintenanceActionStatesRef = yield* Ref.make< | |
| 263 | + ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }> | |
| 264 | + >(new Map()); | |
| 230 | 265 | |
| 231 | 266 | // Live-source registry — the dynamic counterpart to the boot-time |
| 232 | 267 | // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` |
| @@ -264,6 +299,21 @@ export const ProviderRegistryLive = Layer.effect( · 31 unmodified lines | ||
| 264 | 299 | ); |
| 265 | 300 | }); |
| 266 | 301 | |
| 302 | + const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( | |
| 303 | + provider: ServerProvider, | |
| 304 | + ) { | |
| 305 | + const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); | |
| 306 | + const updateState = maintenanceActionStates.get(provider.instanceId)?.update; | |
| 307 | + if (!updateState) { | |
| 308 | + const { updateState: _updateState, ...providerWithoutUpdateState } = provider; | |
| 309 | + return providerWithoutUpdateState; | |
| 310 | + } | |
| 311 | + return { | |
| 312 | + ...provider, | |
| 313 | + updateState, | |
| 314 | + }; | |
| 315 | + }); | |
| 316 | + | |
| 267 | 317 | const upsertProviders = Effect.fn("upsertProviders")(function* ( |
| 268 | 318 | nextProviders: ReadonlyArray<ServerProvider>, |
| 269 | 319 | options?: { |
| @@ -272,15 +322,24 @@ export const ProviderRegistryLive = Layer.effect( · 2 unmodified lines | ||
| 272 | 322 | readonly replace?: boolean; |
| 273 | 323 | }, |
| 274 | 324 | ) { |
| 275 | − const [previousProviders, providers] = yield* Ref.modify( | |
| 325 | + const nextProvidersWithUpdateState = yield* Effect.forEach( | |
| 326 | + nextProviders, | |
| 327 | + applyProviderUpdateState, | |
| 328 | + { | |
| 329 | + concurrency: "unbounded", | |
| 330 | + }, | |
| 331 | + ); | |
| 332 | + const [previousProviders, providers, providersToPersist] = yield* Ref.modify( | |
| 276 | 333 | providersRef, |
| 277 | 334 | (previousProviders) => { |
| 278 | 335 | const mergedProviders = new Map( |
| 279 | 336 | previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), |
| 280 | 337 | ); |
| 338 | + const updatedKeys = new Set<ProviderInstanceId>(); | |
| 281 | 339 | |
| 282 | − for (const provider of nextProviders) { | |
| 340 | + for (const provider of nextProvidersWithUpdateState) { | |
| 283 | 341 | const key = snapshotInstanceKey(provider); |
| 342 | + updatedKeys.add(key); | |
| 284 | 343 | mergedProviders.set( |
| 285 | 344 | key, |
| 286 | 345 | options?.replace === true |
| @@ -290,13 +349,16 @@ export const ProviderRegistryLive = Layer.effect( · 3 unmodified lines | ||
| 290 | 349 | } |
| 291 | 350 | |
| 292 | 351 | const providers = orderProviderSnapshots([...mergedProviders.values()]); |
| 293 | − return [[previousProviders, providers] as const, providers]; | |
| 352 | + const providersToPersist = providers.filter((provider) => | |
| 353 | + updatedKeys.has(snapshotInstanceKey(provider)), | |
| 354 | + ); | |
| 355 | + return [[previousProviders, providers, providersToPersist] as const, providers]; | |
| 294 | 356 | }, |
| 295 | 357 | ); |
| 296 | 358 | |
| 297 | 359 | if (haveProvidersChanged(previousProviders, providers)) { |
| 298 | 360 | if (options?.persist !== false) { |
| 299 | − yield* Effect.forEach(nextProviders, persistProvider, { | |
| 361 | + yield* Effect.forEach(providersToPersist, persistProvider, { | |
| 300 | 362 | concurrency: "unbounded", |
| 301 | 363 | discard: true, |
| 302 | 364 | }); |
| @@ -318,6 +380,45 @@ export const ProviderRegistryLive = Layer.effect( · 15 unmodified lines | ||
| 318 | 380 | return yield* upsertProviders([provider], options); |
| 319 | 381 | }); |
| 320 | 382 | |
| 383 | + const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( | |
| 384 | + function* (input: { | |
| 385 | + readonly instanceId: ProviderInstanceId; | |
| 386 | + readonly action: "update"; | |
| 387 | + readonly state: ServerProviderUpdateState | null; | |
| 388 | + }) { | |
| 389 | + yield* Ref.update(maintenanceActionStatesRef, (previous) => { | |
| 390 | + const previousActions = previous.get(input.instanceId); | |
| 391 | + const nextActions = { ...previousActions }; | |
| 392 | + if (input.state === null || input.state.status === "idle") { | |
| 393 | + delete nextActions[input.action]; | |
| 394 | + } else { | |
| 395 | + nextActions[input.action] = input.state; | |
| 396 | + } | |
| 397 | + | |
| 398 | + const next = new Map(previous); | |
| 399 | + if (Object.keys(nextActions).length === 0) { | |
| 400 | + next.delete(input.instanceId); | |
| 401 | + } else { | |
| 402 | + next.set(input.instanceId, nextActions); | |
| 403 | + } | |
| 404 | + return next; | |
| 405 | + }); | |
| 406 | + | |
| 407 | + const existingProviders = yield* Ref.get(providersRef); | |
| 408 | + const matchingProvider = existingProviders.find( | |
| 409 | + (candidate) => candidate.instanceId === input.instanceId, | |
| 410 | + ); | |
| 411 | + if (!matchingProvider) { | |
| 412 | + return existingProviders; | |
| 413 | + } | |
| 414 | + | |
| 415 | + const nextProvider = yield* applyProviderUpdateState(matchingProvider); | |
| 416 | + return yield* upsertProviders([nextProvider], { | |
| 417 | + persist: false, | |
| 418 | + }); | |
| 419 | + }, | |
| 420 | + ); | |
| 421 | + | |
| 321 | 422 | const refreshOneSource = Effect.fn("refreshOneSource")(function* ( |
| 322 | 423 | providerSource: ProviderSnapshotSource, |
| 323 | 424 | ) { |
| @@ -365,6 +466,18 @@ export const ProviderRegistryLive = Layer.effect( · 41 unmodified lines | ||
| 365 | 466 | return yield* refreshOneSource(providerSource); |
| 366 | 467 | }); |
| 367 | 468 | |
| 469 | + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( | |
| 470 | + "getProviderMaintenanceCapabilitiesForInstance", | |
| 471 | + )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { | |
| 472 | + const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find( | |
| 473 | + (candidate) => candidate.instanceId === instanceId, | |
| 474 | + ); | |
| 475 | + return ( | |
| 476 | + instance?.snapshot.maintenanceCapabilities ?? | |
| 477 | + makeManualProviderMaintenanceCapabilities(provider) | |
| 478 | + ); | |
| 479 | + }); | |
| 480 | + | |
| 368 | 481 | /** |
| 369 | 482 | * Diff the aggregator's live-source set against the current |
| 370 | 483 | * `ProviderInstanceRegistry` and: |
| @@ -472,6 +585,15 @@ export const ProviderRegistryLive = Layer.effect( · 101 unmodified lines | ||
| 472 | 585 | if (haveProvidersChanged(previousProviders, providers)) { |
| 473 | 586 | yield* PubSub.publish(changesPubSub, providers); |
| 474 | 587 | } |
| 588 | + yield* Ref.update(maintenanceActionStatesRef, (previous) => { | |
| 589 | + const next = new Map(previous); | |
| 590 | + for (const instanceId of previous.keys()) { | |
| 591 | + if (!knownInstanceIds.has(instanceId)) { | |
| 592 | + next.delete(instanceId); | |
| 593 | + } | |
| 594 | + } | |
| 595 | + return next; | |
| 596 | + }); | |
| 475 | 597 | }), |
| 476 | 598 | ); |
| 477 | 599 | const syncLiveSourcesAndContinue = syncLiveSources.pipe( |
| @@ -555,6 +677,8 @@ export const ProviderRegistryLive = Layer.effect( · 77 unmodified lines | ||
| 555 | 677 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 556 | 678 | refreshInstance: (instanceId: ProviderInstanceId) => |
| 557 | 679 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 680 | + getProviderMaintenanceCapabilitiesForInstance, | |
| 681 | + setProviderMaintenanceActionState, | |
| 558 | 682 | get streamChanges() { |
| 559 | 683 | return Stream.fromPubSub(changesPubSub); |
| 560 | 684 | }, |
| @@ -6,9 +6,17 @@ · 5 unmodified lines | ||
| 6 | 6 | * |
| 7 | 7 | * @module ProviderRegistry |
| 8 | 8 | */ |
| 9 | −import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; | |
| 9 | +import type { | |
| 10 | + ProviderInstanceId, | |
| 11 | + ProviderDriverKind, | |
| 12 | + ServerProvider, | |
| 13 | + ServerProviderUpdateState, | |
| 14 | +} from "@t3tools/contracts"; | |
| 10 | 15 | import { Context } from "effect"; |
| 11 | 16 | import type { Effect, Stream } from "effect"; |
| 17 | +import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | |
| 18 | + | |
| 19 | +export type ProviderMaintenanceActionKind = "update"; | |
| 12 | 20 | |
| 13 | 21 | export interface ProviderRegistryShape { |
| 14 | 22 | /** |
| @@ -39,6 +47,27 @@ export interface ProviderRegistryShape { · 24 unmodified lines | ||
| 39 | 47 | instanceId: ProviderInstanceId, |
| 40 | 48 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; |
| 41 | 49 | |
| 50 | + /** | |
| 51 | + * Resolve the maintenance capabilities owned by one live provider instance. | |
| 52 | + * Falls back to manual-only capabilities when the instance is not live. | |
| 53 | + */ | |
| 54 | + readonly getProviderMaintenanceCapabilitiesForInstance: ( | |
| 55 | + instanceId: ProviderInstanceId, | |
| 56 | + provider: ProviderDriverKind, | |
| 57 | + ) => Effect.Effect<ProviderMaintenanceCapabilities>; | |
| 58 | + | |
| 59 | + /** | |
| 60 | + * Apply volatile maintenance-action state to one configured instance. | |
| 61 | + * This state is never persisted to disk. Today only update actions are | |
| 62 | + * projected onto `ServerProvider.updateState`; install/auth actions can | |
| 63 | + * extend this action map without adding driver-scoped APIs. | |
| 64 | + */ | |
| 65 | + readonly setProviderMaintenanceActionState: (input: { | |
| 66 | + readonly instanceId: ProviderInstanceId; | |
| 67 | + readonly action: ProviderMaintenanceActionKind; | |
| 68 | + readonly state: ServerProviderUpdateState | null; | |
| 69 | + }) => Effect.Effect<ReadonlyArray<ServerProvider>>; | |
| 70 | + | |
| 42 | 71 | /** |
| 43 | 72 | * Stream of provider snapshot updates — one emission per aggregated |
| 44 | 73 | * change. The array contains the full current state. |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | import type { ServerProvider } from "@t3tools/contracts"; |
| 2 | 2 | import type { Effect, Stream } from "effect"; |
| 3 | +import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | |
| 3 | 4 | |
| 4 | 5 | export interface ServerProviderShape { |
| 6 | + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | |
| 5 | 7 | readonly getSnapshot: Effect.Effect<ServerProvider>; |
| 6 | 8 | readonly refresh: Effect.Effect<ServerProvider>; |
| 7 | 9 | readonly streamChanges: Stream.Stream<ServerProvider>; |
| @@ -20,6 +20,20 @@ interface TestSettings { · 19 unmodified lines | ||
| 20 | 20 | readonly enabled: boolean; |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | +const maintenanceCapabilities = { | |
| 24 | + provider: ProviderDriverKind.make("codex"), | |
| 25 | + packageName: "@openai/codex", | |
| 26 | + update: { | |
| 27 | + command: "npm install -g @openai/codex@latest", | |
| 28 | + | |
| 29 | + executable: "npm", | |
| 30 | + | |
| 31 | + args: ["install", "-g", "@openai/codex@latest"], | |
| 32 | + | |
| 33 | + lockKey: "npm-global", | |
| 34 | + }, | |
| 35 | +} as const; | |
| 36 | + | |
| 23 | 37 | const initialSnapshot: ServerProvider = { |
| 24 | 38 | instanceId: ProviderInstanceId.make("codex"), |
| 25 | 39 | driver: ProviderDriverKind.make("codex"), |
| @@ -90,6 +104,7 @@ describe("makeManagedServerProvider", () => { · 64 unmodified lines | ||
| 90 | 104 | const checkCalls = yield* Ref.make(0); |
| 91 | 105 | const releaseCheck = yield* Deferred.make<void>(); |
| 92 | 106 | const provider = yield* makeManagedServerProvider<TestSettings>({ |
| 107 | + maintenanceCapabilities, | |
| 93 | 108 | getSettings: Effect.succeed({ enabled: true }), |
| 94 | 109 | streamSettings: Stream.empty, |
| 95 | 110 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, |
| @@ -131,6 +146,7 @@ describe("makeManagedServerProvider", () => { · 35 unmodified lines | ||
| 131 | 146 | const releaseInitialCheck = yield* Deferred.make<void>(); |
| 132 | 147 | const releaseSettingsCheck = yield* Deferred.make<void>(); |
| 133 | 148 | const provider = yield* makeManagedServerProvider<TestSettings>({ |
| 149 | + maintenanceCapabilities, | |
| 134 | 150 | getSettings: Ref.get(settingsRef), |
| 135 | 151 | streamSettings: Stream.fromPubSub(settingsChanges), |
| 136 | 152 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, |
| @@ -172,6 +188,7 @@ describe("makeManagedServerProvider", () => { · 35 unmodified lines | ||
| 172 | 188 | const releaseEnrichment = yield* Deferred.make<void>(); |
| 173 | 189 | const releaseCheck = yield* Deferred.make<void>(); |
| 174 | 190 | const provider = yield* makeManagedServerProvider<TestSettings>({ |
| 191 | + maintenanceCapabilities, | |
| 175 | 192 | getSettings: Effect.succeed({ enabled: true }), |
| 176 | 193 | streamSettings: Stream.empty, |
| 177 | 194 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, |
| @@ -212,6 +229,7 @@ describe("makeManagedServerProvider", () => { · 34 unmodified lines | ||
| 212 | 229 | const secondCallbackReady = yield* Deferred.make<void>(); |
| 213 | 230 | const allowFirstRefresh = yield* Deferred.make<void>(); |
| 214 | 231 | const provider = yield* makeManagedServerProvider<TestSettings>({ |
| 232 | + maintenanceCapabilities, | |
| 215 | 233 | getSettings: Effect.succeed({ enabled: true }), |
| 216 | 234 | streamSettings: Stream.empty, |
| 217 | 235 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, |
| @@ -13,6 +13,7 @@ interface ProviderSnapshotState { · 12 unmodified lines | ||
| 13 | 13 | export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < |
| 14 | 14 | Settings, |
| 15 | 15 | >(input: { |
| 16 | + readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"]; | |
| 16 | 17 | readonly getSettings: Effect.Effect<Settings>; |
| 17 | 18 | readonly streamSettings: Stream.Stream<Settings>; |
| 18 | 19 | readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; |
| @@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 124 unmodified lines | ||
| 143 | 144 | ); |
| 144 | 145 | |
| 145 | 146 | return { |
| 147 | + maintenanceCapabilities: input.maintenanceCapabilities, | |
| 146 | 148 | getSnapshot: input.getSettings.pipe( |
| 147 | 149 | Effect.flatMap(applySnapshot), |
| 148 | 150 | Effect.tapError(Effect.logError), |
| @@ -0,0 +1,487 @@ | ||
| 1 | +import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; | |
| 3 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | +import os from "node:os"; | |
| 5 | +import path from "node:path"; | |
| 6 | +import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 7 | +import { Effect, Random } from "effect"; | |
| 8 | +import { | |
| 9 | + clearLatestProviderVersionCacheForTests, | |
| 10 | + createProviderVersionAdvisory, | |
| 11 | + makePackageManagedProviderMaintenanceResolver, | |
| 12 | + makeProviderMaintenanceCapabilities, | |
| 13 | + makeStaticProviderMaintenanceResolver, | |
| 14 | + normalizeCommandPath, | |
| 15 | + resolveProviderMaintenanceCapabilitiesEffect, | |
| 16 | +} from "./providerMaintenance.ts"; | |
| 17 | + | |
| 18 | +const driver = (value: string) => ProviderDriverKind.make(value); | |
| 19 | +const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) { | |
| 20 | + const id = yield* Random.nextUUIDv4; | |
| 21 | + return path.join(os.tmpdir(), `${name}-${id}`); | |
| 22 | +}); | |
| 23 | +const isNativeTestCommandPath = | |
| 24 | + (expectedPathSegment: string) => | |
| 25 | + (commandPath: string): boolean => | |
| 26 | + normalizeCommandPath(commandPath).includes(expectedPathSegment); | |
| 27 | +const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({ | |
| 28 | + provider: driver("packageTool"), | |
| 29 | + npmPackageName: "@example/package-tool", | |
| 30 | + homebrewFormula: "package-tool", | |
| 31 | + nativeUpdate: null, | |
| 32 | +}); | |
| 33 | +const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ | |
| 34 | + provider: driver("nativePackageTool"), | |
| 35 | + npmPackageName: "@example/native-package-tool", | |
| 36 | + homebrewFormula: "native-package-tool", | |
| 37 | + nativeUpdate: { | |
| 38 | + executable: "native-package-tool", | |
| 39 | + args: ["update"], | |
| 40 | + lockKey: "native-package-tool-native", | |
| 41 | + isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), | |
| 42 | + }, | |
| 43 | +}); | |
| 44 | +const scopedPackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ | |
| 45 | + provider: driver("scopedPackageTool"), | |
| 46 | + npmPackageName: "@example/scoped-package-tool", | |
| 47 | + homebrewFormula: "example/tap/scoped-package-tool", | |
| 48 | + nativeUpdate: { | |
| 49 | + executable: "scoped-package-tool", | |
| 50 | + args: ["upgrade"], | |
| 51 | + lockKey: "scoped-package-tool-native", | |
| 52 | + isCommandPath: isNativeTestCommandPath("/.scoped-package-tool/bin/scoped-package-tool"), | |
| 53 | + }, | |
| 54 | +}); | |
| 55 | +const staticToolUpdate = makeStaticProviderMaintenanceResolver( | |
| 56 | + makeProviderMaintenanceCapabilities({ | |
| 57 | + provider: driver("staticTool"), | |
| 58 | + packageName: null, | |
| 59 | + updateExecutable: "static-tool", | |
| 60 | + updateArgs: ["update"], | |
| 61 | + updateLockKey: "static-tool", | |
| 62 | + }), | |
| 63 | +); | |
| 64 | + | |
| 65 | +afterEach(() => { | |
| 66 | + clearLatestProviderVersionCacheForTests(); | |
| 67 | +}); | |
| 68 | + | |
| 69 | +describe("providerMaintenance", () => { | |
| 70 | + it("marks providers with unknown current versions as unknown", () => { | |
| 71 | + expect( | |
| 72 | + createProviderVersionAdvisory({ | |
| 73 | + driver: driver("packageTool"), | |
| 74 | + currentVersion: null, | |
| 75 | + latestVersion: "9.9.9", | |
| 76 | + }), | |
| 77 | + ).toMatchObject({ | |
| 78 | + status: "unknown", | |
| 79 | + currentVersion: null, | |
| 80 | + latestVersion: "9.9.9", | |
| 81 | + }); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + it("marks providers with unknown latest versions as unknown", () => { | |
| 85 | + expect( | |
| 86 | + createProviderVersionAdvisory({ | |
| 87 | + driver: driver("packageTool"), | |
| 88 | + currentVersion: "1.0.0", | |
| 89 | + latestVersion: null, | |
| 90 | + }), | |
| 91 | + ).toMatchObject({ | |
| 92 | + status: "unknown", | |
| 93 | + currentVersion: "1.0.0", | |
| 94 | + latestVersion: null, | |
| 95 | + message: null, | |
| 96 | + }); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + it("marks installed providers behind latest when a newer provider version is available", () => { | |
| 100 | + expect( | |
| 101 | + createProviderVersionAdvisory({ | |
| 102 | + driver: driver("nativePackageTool"), | |
| 103 | + currentVersion: "2.1.110", | |
| 104 | + latestVersion: "2.1.117", | |
| 105 | + maintenanceCapabilities: nativePackageToolUpdate.resolve(), | |
| 106 | + }), | |
| 107 | + ).toMatchObject({ | |
| 108 | + status: "behind_latest", | |
| 109 | + currentVersion: "2.1.110", | |
| 110 | + latestVersion: "2.1.117", | |
| 111 | + updateCommand: "npm install -g @example/native-package-tool@latest", | |
| 112 | + canUpdate: true, | |
| 113 | + message: "Install the update now or review provider settings.", | |
| 114 | + }); | |
| 115 | + }); | |
| 116 | + | |
| 117 | + it("keeps update commands owned by provider maintenance capabilities", () => { | |
| 118 | + expect(staticToolUpdate.resolve()).toEqual({ | |
| 119 | + provider: driver("staticTool"), | |
| 120 | + packageName: null, | |
| 121 | + update: { | |
| 122 | + command: "static-tool update", | |
| 123 | + | |
| 124 | + executable: "static-tool", | |
| 125 | + | |
| 126 | + args: ["update"], | |
| 127 | + | |
| 128 | + lockKey: "static-tool", | |
| 129 | + }, | |
| 130 | + }); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + it.effect( | |
| 134 | + "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin", | |
| 135 | + () => | |
| 136 | + Effect.gen(function* () { | |
| 137 | + const tempDir = yield* makeTempDir("t3-vite-plus-capabilities"); | |
| 138 | + const vitePlusBinDir = path.join(tempDir, ".vite-plus", "bin"); | |
| 139 | + mkdirSync(vitePlusBinDir, { recursive: true }); | |
| 140 | + const packageToolPath = path.join(vitePlusBinDir, "package-tool"); | |
| 141 | + writeFileSync(packageToolPath, "#!/bin/sh\n"); | |
| 142 | + chmodSync(packageToolPath, 0o755); | |
| 143 | + | |
| 144 | + expect( | |
| 145 | + packageToolUpdate.resolve({ | |
| 146 | + binaryPath: "package-tool", | |
| 147 | + platform: "darwin", | |
| 148 | + env: { | |
| 149 | + PATH: vitePlusBinDir, | |
| 150 | + }, | |
| 151 | + }), | |
| 152 | + ).toEqual({ | |
| 153 | + provider: driver("packageTool"), | |
| 154 | + packageName: "@example/package-tool", | |
| 155 | + update: { | |
| 156 | + command: "vp i -g @example/package-tool", | |
| 157 | + | |
| 158 | + executable: "vp", | |
| 159 | + | |
| 160 | + args: ["i", "-g", "@example/package-tool"], | |
| 161 | + | |
| 162 | + lockKey: "vite-plus-global", | |
| 163 | + }, | |
| 164 | + }); | |
| 165 | + }), | |
| 166 | + ); | |
| 167 | + | |
| 168 | + it.effect( | |
| 169 | + "switches package-managed providers to bun updates when the resolved binary lives in bun's global bin", | |
| 170 | + () => | |
| 171 | + Effect.gen(function* () { | |
| 172 | + const tempDir = yield* makeTempDir("t3-bun-capabilities"); | |
| 173 | + const bunBinDir = path.join(tempDir, ".bun", "bin"); | |
| 174 | + mkdirSync(bunBinDir, { recursive: true }); | |
| 175 | + writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); | |
| 176 | + | |
| 177 | + expect( | |
| 178 | + nativePackageToolUpdate.resolve({ | |
| 179 | + binaryPath: "native-package-tool", | |
| 180 | + platform: "win32", | |
| 181 | + env: { | |
| 182 | + PATH: bunBinDir, | |
| 183 | + PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 184 | + }, | |
| 185 | + }), | |
| 186 | + ).toEqual({ | |
| 187 | + provider: driver("nativePackageTool"), | |
| 188 | + packageName: "@example/native-package-tool", | |
| 189 | + update: { | |
| 190 | + command: "bun i -g @example/native-package-tool@latest", | |
| 191 | + | |
| 192 | + executable: "bun", | |
| 193 | + | |
| 194 | + args: ["i", "-g", "@example/native-package-tool@latest"], | |
| 195 | + | |
| 196 | + lockKey: "bun-global", | |
| 197 | + }, | |
| 198 | + }); | |
| 199 | + }), | |
| 200 | + ); | |
| 201 | + | |
| 202 | + it.effect( | |
| 203 | + "switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin", | |
| 204 | + () => | |
| 205 | + Effect.gen(function* () { | |
| 206 | + const tempDir = yield* makeTempDir("t3-pnpm-capabilities"); | |
| 207 | + const pnpmHomeDir = path.join(tempDir, ".local", "share", "pnpm"); | |
| 208 | + mkdirSync(pnpmHomeDir, { recursive: true }); | |
| 209 | + const scopedPackageToolPath = path.join(pnpmHomeDir, "scoped-package-tool"); | |
| 210 | + writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); | |
| 211 | + chmodSync(scopedPackageToolPath, 0o755); | |
| 212 | + | |
| 213 | + expect( | |
| 214 | + scopedPackageToolUpdate.resolve({ | |
| 215 | + binaryPath: "scoped-package-tool", | |
| 216 | + platform: "darwin", | |
| 217 | + env: { | |
| 218 | + PATH: pnpmHomeDir, | |
| 219 | + }, | |
| 220 | + }), | |
| 221 | + ).toEqual({ | |
| 222 | + provider: driver("scopedPackageTool"), | |
| 223 | + packageName: "@example/scoped-package-tool", | |
| 224 | + update: { | |
| 225 | + command: "pnpm add -g @example/scoped-package-tool@latest", | |
| 226 | + | |
| 227 | + executable: "pnpm", | |
| 228 | + | |
| 229 | + args: ["add", "-g", "@example/scoped-package-tool@latest"], | |
| 230 | + | |
| 231 | + lockKey: "pnpm-global", | |
| 232 | + }, | |
| 233 | + }); | |
| 234 | + }), | |
| 235 | + ); | |
| 236 | + | |
| 237 | + it("switches package-tool to Homebrew updates when the binary resolves through Homebrew", () => { | |
| 238 | + expect( | |
| 239 | + packageToolUpdate.resolve({ | |
| 240 | + binaryPath: "/opt/homebrew/bin/package-tool", | |
| 241 | + platform: "darwin", | |
| 242 | + env: { | |
| 243 | + PATH: "", | |
| 244 | + }, | |
| 245 | + }), | |
| 246 | + ).toEqual({ | |
| 247 | + provider: driver("packageTool"), | |
| 248 | + packageName: "@example/package-tool", | |
| 249 | + update: { | |
| 250 | + command: "brew upgrade package-tool", | |
| 251 | + | |
| 252 | + executable: "brew", | |
| 253 | + | |
| 254 | + args: ["upgrade", "package-tool"], | |
| 255 | + | |
| 256 | + lockKey: "homebrew", | |
| 257 | + }, | |
| 258 | + }); | |
| 259 | + }); | |
| 260 | + | |
| 261 | + it.effect( | |
| 262 | + "switches native-package-tool to native updates when the binary resolves through the native installer", | |
| 263 | + () => | |
| 264 | + Effect.gen(function* () { | |
| 265 | + const tempDir = yield* makeTempDir("t3-native-package-tool-native-capabilities"); | |
| 266 | + const nativeBinDir = path.join(tempDir, ".local", "bin"); | |
| 267 | + mkdirSync(nativeBinDir, { recursive: true }); | |
| 268 | + const nativePackageToolPath = path.join(nativeBinDir, "native-package-tool"); | |
| 269 | + writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); | |
| 270 | + chmodSync(nativePackageToolPath, 0o755); | |
| 271 | + | |
| 272 | + expect( | |
| 273 | + nativePackageToolUpdate.resolve({ | |
| 274 | + binaryPath: "native-package-tool", | |
| 275 | + platform: "darwin", | |
| 276 | + env: { | |
| 277 | + PATH: nativeBinDir, | |
| 278 | + }, | |
| 279 | + }), | |
| 280 | + ).toEqual({ | |
| 281 | + provider: driver("nativePackageTool"), | |
| 282 | + packageName: "@example/native-package-tool", | |
| 283 | + update: { | |
| 284 | + command: "native-package-tool update", | |
| 285 | + | |
| 286 | + executable: "native-package-tool", | |
| 287 | + | |
| 288 | + args: ["update"], | |
| 289 | + | |
| 290 | + lockKey: "native-package-tool-native", | |
| 291 | + }, | |
| 292 | + }); | |
| 293 | + }), | |
| 294 | + ); | |
| 295 | + | |
| 296 | + it.effect( | |
| 297 | + "switches scoped-package-tool to native upgrades when the binary resolves through the standalone installer", | |
| 298 | + () => | |
| 299 | + Effect.gen(function* () { | |
| 300 | + const tempDir = yield* makeTempDir("t3-scoped-package-tool-native-capabilities"); | |
| 301 | + const nativeBinDir = path.join(tempDir, ".scoped-package-tool", "bin"); | |
| 302 | + mkdirSync(nativeBinDir, { recursive: true }); | |
| 303 | + const scopedPackageToolPath = path.join(nativeBinDir, "scoped-package-tool"); | |
| 304 | + writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); | |
| 305 | + chmodSync(scopedPackageToolPath, 0o755); | |
| 306 | + | |
| 307 | + expect( | |
| 308 | + scopedPackageToolUpdate.resolve({ | |
| 309 | + binaryPath: "scoped-package-tool", | |
| 310 | + platform: "darwin", | |
| 311 | + env: { | |
| 312 | + PATH: nativeBinDir, | |
| 313 | + }, | |
| 314 | + }), | |
| 315 | + ).toEqual({ | |
| 316 | + provider: driver("scopedPackageTool"), | |
| 317 | + packageName: "@example/scoped-package-tool", | |
| 318 | + update: { | |
| 319 | + command: "scoped-package-tool upgrade", | |
| 320 | + | |
| 321 | + executable: "scoped-package-tool", | |
| 322 | + | |
| 323 | + args: ["upgrade"], | |
| 324 | + | |
| 325 | + lockKey: "scoped-package-tool-native", | |
| 326 | + }, | |
| 327 | + }); | |
| 328 | + }), | |
| 329 | + ); | |
| 330 | + | |
| 331 | + it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { | |
| 332 | + expect( | |
| 333 | + nativePackageToolUpdate.resolve({ | |
| 334 | + binaryPath: "/opt/homebrew/bin/native-package-tool", | |
| 335 | + platform: "darwin", | |
| 336 | + env: { | |
| 337 | + PATH: "", | |
| 338 | + }, | |
| 339 | + }), | |
| 340 | + ).toEqual({ | |
| 341 | + provider: driver("nativePackageTool"), | |
| 342 | + packageName: "@example/native-package-tool", | |
| 343 | + update: { | |
| 344 | + command: "brew upgrade native-package-tool", | |
| 345 | + | |
| 346 | + executable: "brew", | |
| 347 | + | |
| 348 | + args: ["upgrade", "native-package-tool"], | |
| 349 | + | |
| 350 | + lockKey: "homebrew", | |
| 351 | + }, | |
| 352 | + }); | |
| 353 | + }); | |
| 354 | + | |
| 355 | + it("switches scoped-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { | |
| 356 | + expect( | |
| 357 | + scopedPackageToolUpdate.resolve({ | |
| 358 | + binaryPath: "/opt/homebrew/bin/scoped-package-tool", | |
| 359 | + platform: "darwin", | |
| 360 | + env: { | |
| 361 | + PATH: "", | |
| 362 | + }, | |
| 363 | + }), | |
| 364 | + ).toEqual({ | |
| 365 | + provider: driver("scopedPackageTool"), | |
| 366 | + packageName: "@example/scoped-package-tool", | |
| 367 | + update: { | |
| 368 | + command: "brew upgrade example/tap/scoped-package-tool", | |
| 369 | + | |
| 370 | + executable: "brew", | |
| 371 | + | |
| 372 | + args: ["upgrade", "example/tap/scoped-package-tool"], | |
| 373 | + | |
| 374 | + lockKey: "homebrew", | |
| 375 | + }, | |
| 376 | + }); | |
| 377 | + }); | |
| 378 | + | |
| 379 | + it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () => | |
| 380 | + Effect.gen(function* () { | |
| 381 | + const tempDir = yield* makeTempDir("t3-npm-capabilities"); | |
| 382 | + const binDir = path.join(tempDir, "bin"); | |
| 383 | + const packageBinDir = path.join( | |
| 384 | + tempDir, | |
| 385 | + "lib", | |
| 386 | + "node_modules", | |
| 387 | + "@example", | |
| 388 | + "package-tool", | |
| 389 | + "bin", | |
| 390 | + ); | |
| 391 | + mkdirSync(binDir, { recursive: true }); | |
| 392 | + mkdirSync(packageBinDir, { recursive: true }); | |
| 393 | + const packageBinPath = path.join(packageBinDir, "package-tool.js"); | |
| 394 | + const symlinkPath = path.join(binDir, "package-tool"); | |
| 395 | + writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); | |
| 396 | + chmodSync(packageBinPath, 0o755); | |
| 397 | + symlinkSync(packageBinPath, symlinkPath); | |
| 398 | + | |
| 399 | + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { | |
| 400 | + binaryPath: symlinkPath, | |
| 401 | + platform: "darwin", | |
| 402 | + env: { | |
| 403 | + PATH: "", | |
| 404 | + }, | |
| 405 | + }).pipe(Effect.provide(NodeServices.layer)); | |
| 406 | + | |
| 407 | + expect(capabilities).toEqual({ | |
| 408 | + provider: driver("packageTool"), | |
| 409 | + packageName: "@example/package-tool", | |
| 410 | + update: { | |
| 411 | + command: "npm install -g @example/package-tool@latest", | |
| 412 | + | |
| 413 | + executable: "npm", | |
| 414 | + | |
| 415 | + args: ["install", "-g", "@example/package-tool@latest"], | |
| 416 | + | |
| 417 | + lockKey: "npm-global", | |
| 418 | + }, | |
| 419 | + }); | |
| 420 | + }), | |
| 421 | + ); | |
| 422 | + | |
| 423 | + it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () => | |
| 424 | + Effect.gen(function* () { | |
| 425 | + const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities"); | |
| 426 | + const binDir = path.join(tempDir, "bin"); | |
| 427 | + const packageBinDir = path.join( | |
| 428 | + tempDir, | |
| 429 | + ".local", | |
| 430 | + "share", | |
| 431 | + "pnpm", | |
| 432 | + "global", | |
| 433 | + "5", | |
| 434 | + "node_modules", | |
| 435 | + "@example", | |
| 436 | + "package-tool", | |
| 437 | + "bin", | |
| 438 | + ); | |
| 439 | + mkdirSync(binDir, { recursive: true }); | |
| 440 | + mkdirSync(packageBinDir, { recursive: true }); | |
| 441 | + const packageBinPath = path.join(packageBinDir, "package-tool.js"); | |
| 442 | + const symlinkPath = path.join(binDir, "package-tool"); | |
| 443 | + writeFileSync(packageBinPath, "#!/usr/bin/env node\n"); | |
| 444 | + chmodSync(packageBinPath, 0o755); | |
| 445 | + symlinkSync(packageBinPath, symlinkPath); | |
| 446 | + | |
| 447 | + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { | |
| 448 | + binaryPath: symlinkPath, | |
| 449 | + platform: "darwin", | |
| 450 | + env: { | |
| 451 | + PATH: "", | |
| 452 | + }, | |
| 453 | + }).pipe(Effect.provide(NodeServices.layer)); | |
| 454 | + | |
| 455 | + expect(capabilities).toEqual({ | |
| 456 | + provider: driver("packageTool"), | |
| 457 | + packageName: "@example/package-tool", | |
| 458 | + update: { | |
| 459 | + command: "pnpm add -g @example/package-tool@latest", | |
| 460 | + | |
| 461 | + executable: "pnpm", | |
| 462 | + | |
| 463 | + args: ["add", "-g", "@example/package-tool@latest"], | |
| 464 | + | |
| 465 | + lockKey: "pnpm-global", | |
| 466 | + }, | |
| 467 | + }); | |
| 468 | + }), | |
| 469 | + ); | |
| 470 | + | |
| 471 | + it("disables one-click updates for explicit custom binary paths it cannot safely map", () => { | |
| 472 | + expect( | |
| 473 | + packageToolUpdate.resolve({ | |
| 474 | + binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", | |
| 475 | + platform: "win32", | |
| 476 | + env: { | |
| 477 | + PATH: "", | |
| 478 | + PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 479 | + }, | |
| 480 | + }), | |
| 481 | + ).toEqual({ | |
| 482 | + provider: driver("packageTool"), | |
| 483 | + packageName: "@example/package-tool", | |
| 484 | + update: null, | |
| 485 | + }); | |
| 486 | + }); | |
| 487 | +}); | |
| @@ -0,0 +1,472 @@ | ||
| 1 | +import { | |
| 2 | + ProviderDriverKind, | |
| 3 | + type ServerProvider, | |
| 4 | + type ServerProviderVersionAdvisory, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import { resolveCommandPath } from "@t3tools/shared/shell"; | |
| 7 | +import { DateTime, Effect, FileSystem, Option, Schema } from "effect"; | |
| 8 | +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 9 | + | |
| 10 | +import { compareCliVersions } from "./cliVersion.ts"; | |
| 11 | + | |
| 12 | +const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; | |
| 13 | +const LATEST_VERSION_TIMEOUT_MS = 4_000; | |
| 14 | +const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; | |
| 15 | + | |
| 16 | +export interface ProviderMaintenanceCapabilities { | |
| 17 | + readonly provider: ProviderDriverKind; | |
| 18 | + readonly packageName: string | null; | |
| 19 | + readonly update: ProviderMaintenanceCommandAction | null; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface ProviderMaintenanceCommandAction { | |
| 23 | + readonly command: string; | |
| 24 | + readonly executable: string; | |
| 25 | + readonly args: ReadonlyArray<string>; | |
| 26 | + readonly lockKey: string; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface ProviderMaintenanceCapabilityResolutionOptions { | |
| 30 | + readonly binaryPath?: string | null; | |
| 31 | + readonly env?: NodeJS.ProcessEnv; | |
| 32 | + readonly platform?: NodeJS.Platform; | |
| 33 | + readonly realCommandPath?: string | null; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export interface ProviderMaintenanceCapabilitiesResolver { | |
| 37 | + readonly resolve: ( | |
| 38 | + options?: ProviderMaintenanceCapabilityResolutionOptions, | |
| 39 | + ) => ProviderMaintenanceCapabilities; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface PackageManagedProviderMaintenanceDefinition { | |
| 43 | + readonly provider: ProviderDriverKind; | |
| 44 | + readonly npmPackageName: string; | |
| 45 | + readonly homebrewFormula: string | null; | |
| 46 | + readonly nativeUpdate: { | |
| 47 | + readonly executable: string; | |
| 48 | + readonly args: ReadonlyArray<string>; | |
| 49 | + readonly lockKey: string; | |
| 50 | + readonly isCommandPath: (commandPath: string) => boolean; | |
| 51 | + } | null; | |
| 52 | +} | |
| 53 | + | |
| 54 | +interface LatestVersionCacheEntry { | |
| 55 | + readonly expiresAt: number; | |
| 56 | + readonly version: string | null; | |
| 57 | +} | |
| 58 | + | |
| 59 | +const latestVersionCache = new Map<string, LatestVersionCacheEntry>(); | |
| 60 | +const NpmLatestVersionResponse = Schema.Struct({ | |
| 61 | + version: Schema.optional(Schema.String), | |
| 62 | +}); | |
| 63 | + | |
| 64 | +export function clearLatestProviderVersionCacheForTests(): void { | |
| 65 | + latestVersionCache.clear(); | |
| 66 | +} | |
| 67 | + | |
| 68 | +function nonEmptyString(value: unknown): string | null { | |
| 69 | + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export function makeProviderMaintenanceCapabilities(input: { | |
| 73 | + readonly provider: ProviderDriverKind; | |
| 74 | + readonly packageName: string | null; | |
| 75 | + readonly updateExecutable: string | null; | |
| 76 | + readonly updateArgs: ReadonlyArray<string>; | |
| 77 | + readonly updateLockKey: string | null; | |
| 78 | +}): ProviderMaintenanceCapabilities { | |
| 79 | + const update = | |
| 80 | + input.updateExecutable === null || input.updateLockKey === null | |
| 81 | + ? null | |
| 82 | + : { | |
| 83 | + command: [input.updateExecutable, ...input.updateArgs].join(" "), | |
| 84 | + executable: input.updateExecutable, | |
| 85 | + args: input.updateArgs, | |
| 86 | + lockKey: input.updateLockKey, | |
| 87 | + }; | |
| 88 | + return { | |
| 89 | + provider: input.provider, | |
| 90 | + packageName: input.packageName, | |
| 91 | + update, | |
| 92 | + }; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function makeManualOnlyProviderMaintenanceCapabilities(input: { | |
| 96 | + readonly provider: ProviderDriverKind; | |
| 97 | + readonly packageName: string | null; | |
| 98 | +}): ProviderMaintenanceCapabilities { | |
| 99 | + return makeProviderMaintenanceCapabilities({ | |
| 100 | + provider: input.provider, | |
| 101 | + packageName: input.packageName, | |
| 102 | + updateExecutable: null, | |
| 103 | + updateArgs: [], | |
| 104 | + updateLockKey: null, | |
| 105 | + }); | |
| 106 | +} | |
| 107 | + | |
| 108 | +function makeNpmGlobalProviderMaintenanceCapabilities( | |
| 109 | + definition: PackageManagedProviderMaintenanceDefinition, | |
| 110 | +): ProviderMaintenanceCapabilities { | |
| 111 | + return makeProviderMaintenanceCapabilities({ | |
| 112 | + provider: definition.provider, | |
| 113 | + packageName: definition.npmPackageName, | |
| 114 | + updateExecutable: "npm", | |
| 115 | + updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`], | |
| 116 | + updateLockKey: "npm-global", | |
| 117 | + }); | |
| 118 | +} | |
| 119 | + | |
| 120 | +function makeBunGlobalProviderMaintenanceCapabilities( | |
| 121 | + definition: PackageManagedProviderMaintenanceDefinition, | |
| 122 | +): ProviderMaintenanceCapabilities { | |
| 123 | + return makeProviderMaintenanceCapabilities({ | |
| 124 | + provider: definition.provider, | |
| 125 | + packageName: definition.npmPackageName, | |
| 126 | + updateExecutable: "bun", | |
| 127 | + updateArgs: ["i", "-g", `${definition.npmPackageName}@latest`], | |
| 128 | + updateLockKey: "bun-global", | |
| 129 | + }); | |
| 130 | +} | |
| 131 | + | |
| 132 | +function makePnpmGlobalProviderMaintenanceCapabilities( | |
| 133 | + definition: PackageManagedProviderMaintenanceDefinition, | |
| 134 | +): ProviderMaintenanceCapabilities { | |
| 135 | + return makeProviderMaintenanceCapabilities({ | |
| 136 | + provider: definition.provider, | |
| 137 | + packageName: definition.npmPackageName, | |
| 138 | + updateExecutable: "pnpm", | |
| 139 | + updateArgs: ["add", "-g", `${definition.npmPackageName}@latest`], | |
| 140 | + updateLockKey: "pnpm-global", | |
| 141 | + }); | |
| 142 | +} | |
| 143 | + | |
| 144 | +function makeVitePlusGlobalProviderMaintenanceCapabilities( | |
| 145 | + definition: PackageManagedProviderMaintenanceDefinition, | |
| 146 | +): ProviderMaintenanceCapabilities { | |
| 147 | + return makeProviderMaintenanceCapabilities({ | |
| 148 | + provider: definition.provider, | |
| 149 | + packageName: definition.npmPackageName, | |
| 150 | + updateExecutable: "vp", | |
| 151 | + updateArgs: ["i", "-g", definition.npmPackageName], | |
| 152 | + updateLockKey: "vite-plus-global", | |
| 153 | + }); | |
| 154 | +} | |
| 155 | + | |
| 156 | +function makeHomebrewProviderMaintenanceCapabilities( | |
| 157 | + definition: PackageManagedProviderMaintenanceDefinition, | |
| 158 | +): ProviderMaintenanceCapabilities { | |
| 159 | + if (!definition.homebrewFormula) { | |
| 160 | + return makeManualOnlyProviderMaintenanceCapabilities({ | |
| 161 | + provider: definition.provider, | |
| 162 | + packageName: definition.npmPackageName, | |
| 163 | + }); | |
| 164 | + } | |
| 165 | + | |
| 166 | + return makeProviderMaintenanceCapabilities({ | |
| 167 | + provider: definition.provider, | |
| 168 | + packageName: definition.npmPackageName, | |
| 169 | + updateExecutable: "brew", | |
| 170 | + updateArgs: ["upgrade", definition.homebrewFormula], | |
| 171 | + updateLockKey: "homebrew", | |
| 172 | + }); | |
| 173 | +} | |
| 174 | + | |
| 175 | +function makeNativeProviderMaintenanceCapabilities( | |
| 176 | + definition: PackageManagedProviderMaintenanceDefinition, | |
| 177 | +): ProviderMaintenanceCapabilities | null { | |
| 178 | + if (!definition.nativeUpdate) { | |
| 179 | + return null; | |
| 180 | + } | |
| 181 | + | |
| 182 | + return makeProviderMaintenanceCapabilities({ | |
| 183 | + provider: definition.provider, | |
| 184 | + packageName: definition.npmPackageName, | |
| 185 | + updateExecutable: definition.nativeUpdate.executable, | |
| 186 | + updateArgs: definition.nativeUpdate.args, | |
| 187 | + updateLockKey: definition.nativeUpdate.lockKey, | |
| 188 | + }); | |
| 189 | +} | |
| 190 | + | |
| 191 | +export function hasPathSeparator(value: string): boolean { | |
| 192 | + return value.includes("/") || value.includes("\\"); | |
| 193 | +} | |
| 194 | + | |
| 195 | +export function normalizeCommandPath(commandPath: string): string { | |
| 196 | + return commandPath.replaceAll("\\", "/").toLowerCase(); | |
| 197 | +} | |
| 198 | + | |
| 199 | +function isBunGlobalCommandPath(commandPath: string): boolean { | |
| 200 | + return normalizeCommandPath(commandPath).includes("/.bun/bin/"); | |
| 201 | +} | |
| 202 | + | |
| 203 | +function isVitePlusGlobalCommandPath(commandPath: string): boolean { | |
| 204 | + return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/"); | |
| 205 | +} | |
| 206 | + | |
| 207 | +function isPnpmGlobalCommandPath(commandPath: string): boolean { | |
| 208 | + const normalized = normalizeCommandPath(commandPath); | |
| 209 | + return ( | |
| 210 | + normalized.includes("/.local/share/pnpm/") || | |
| 211 | + normalized.includes("/library/pnpm/") || | |
| 212 | + normalized.includes("/local/share/pnpm/") || | |
| 213 | + normalized.includes("/appdata/local/pnpm/") || | |
| 214 | + normalized.includes("/pnpm/global/") | |
| 215 | + ); | |
| 216 | +} | |
| 217 | + | |
| 218 | +function isNpmGlobalCommandPath(commandPath: string): boolean { | |
| 219 | + const normalized = normalizeCommandPath(commandPath); | |
| 220 | + return ( | |
| 221 | + normalized.includes("/node_modules/.bin/") || | |
| 222 | + normalized.includes("/lib/node_modules/") || | |
| 223 | + normalized.includes("/npm/node_modules/") | |
| 224 | + ); | |
| 225 | +} | |
| 226 | + | |
| 227 | +function isHomebrewCommandPath(commandPath: string): boolean { | |
| 228 | + const normalized = normalizeCommandPath(commandPath); | |
| 229 | + return ( | |
| 230 | + normalized.includes("/opt/homebrew/cellar/") || | |
| 231 | + normalized.includes("/usr/local/cellar/") || | |
| 232 | + normalized.includes("/homebrew/cellar/") || | |
| 233 | + normalized.includes("/opt/homebrew/caskroom/") || | |
| 234 | + normalized.includes("/usr/local/caskroom/") || | |
| 235 | + normalized.includes("/homebrew/caskroom/") || | |
| 236 | + normalized.startsWith("/opt/homebrew/bin/") || | |
| 237 | + normalized.startsWith("/usr/local/bin/") | |
| 238 | + ); | |
| 239 | +} | |
| 240 | + | |
| 241 | +export function resolvePackageManagedProviderMaintenance( | |
| 242 | + definition: PackageManagedProviderMaintenanceDefinition, | |
| 243 | + options?: ProviderMaintenanceCapabilityResolutionOptions, | |
| 244 | +): ProviderMaintenanceCapabilities { | |
| 245 | + const binaryPath = nonEmptyString(options?.binaryPath); | |
| 246 | + if (!binaryPath) { | |
| 247 | + return makeNpmGlobalProviderMaintenanceCapabilities(definition); | |
| 248 | + } | |
| 249 | + | |
| 250 | + const resolvedCommandPath = | |
| 251 | + resolveCommandPath(binaryPath, { | |
| 252 | + ...(options?.platform ? { platform: options.platform } : {}), | |
| 253 | + ...(options?.env ? { env: options.env } : {}), | |
| 254 | + }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); | |
| 255 | + | |
| 256 | + if (resolvedCommandPath) { | |
| 257 | + const commandPaths = [ | |
| 258 | + resolvedCommandPath, | |
| 259 | + ...(options?.realCommandPath ? [options.realCommandPath] : []), | |
| 260 | + ]; | |
| 261 | + | |
| 262 | + const nativeUpdate = definition.nativeUpdate; | |
| 263 | + if ( | |
| 264 | + nativeUpdate && | |
| 265 | + commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath)) | |
| 266 | + ) { | |
| 267 | + return ( | |
| 268 | + makeNativeProviderMaintenanceCapabilities(definition) ?? | |
| 269 | + makeNpmGlobalProviderMaintenanceCapabilities(definition) | |
| 270 | + ); | |
| 271 | + } | |
| 272 | + if (commandPaths.some(isVitePlusGlobalCommandPath)) { | |
| 273 | + return makeVitePlusGlobalProviderMaintenanceCapabilities(definition); | |
| 274 | + } | |
| 275 | + if (commandPaths.some(isBunGlobalCommandPath)) { | |
| 276 | + return makeBunGlobalProviderMaintenanceCapabilities(definition); | |
| 277 | + } | |
| 278 | + if (commandPaths.some(isPnpmGlobalCommandPath)) { | |
| 279 | + return makePnpmGlobalProviderMaintenanceCapabilities(definition); | |
| 280 | + } | |
| 281 | + if (commandPaths.some(isNpmGlobalCommandPath)) { | |
| 282 | + return makeNpmGlobalProviderMaintenanceCapabilities(definition); | |
| 283 | + } | |
| 284 | + if (commandPaths.some(isHomebrewCommandPath)) { | |
| 285 | + return makeHomebrewProviderMaintenanceCapabilities(definition); | |
| 286 | + } | |
| 287 | + } | |
| 288 | + | |
| 289 | + if (!hasPathSeparator(binaryPath)) { | |
| 290 | + return makeNpmGlobalProviderMaintenanceCapabilities(definition); | |
| 291 | + } | |
| 292 | + | |
| 293 | + return makeManualOnlyProviderMaintenanceCapabilities({ | |
| 294 | + provider: definition.provider, | |
| 295 | + packageName: definition.npmPackageName, | |
| 296 | + }); | |
| 297 | +} | |
| 298 | + | |
| 299 | +export function makePackageManagedProviderMaintenanceResolver( | |
| 300 | + definition: PackageManagedProviderMaintenanceDefinition, | |
| 301 | +): ProviderMaintenanceCapabilitiesResolver { | |
| 302 | + return { | |
| 303 | + resolve: (options) => resolvePackageManagedProviderMaintenance(definition, options), | |
| 304 | + }; | |
| 305 | +} | |
| 306 | + | |
| 307 | +export function makeStaticProviderMaintenanceResolver( | |
| 308 | + capabilities: ProviderMaintenanceCapabilities, | |
| 309 | +): ProviderMaintenanceCapabilitiesResolver { | |
| 310 | + return { | |
| 311 | + resolve: () => capabilities, | |
| 312 | + }; | |
| 313 | +} | |
| 314 | + | |
| 315 | +function makeManualProviderMaintenanceCapabilities( | |
| 316 | + provider: ProviderDriverKind, | |
| 317 | +): ProviderMaintenanceCapabilities { | |
| 318 | + return makeManualOnlyProviderMaintenanceCapabilities({ | |
| 319 | + provider, | |
| 320 | + packageName: null, | |
| 321 | + }); | |
| 322 | +} | |
| 323 | + | |
| 324 | +export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( | |
| 325 | + "resolveProviderMaintenanceCapabilitiesEffect", | |
| 326 | +)(function* ( | |
| 327 | + resolver: ProviderMaintenanceCapabilitiesResolver, | |
| 328 | + options?: Omit<ProviderMaintenanceCapabilityResolutionOptions, "realCommandPath">, | |
| 329 | +) { | |
| 330 | + const binaryPath = nonEmptyString(options?.binaryPath); | |
| 331 | + if (!binaryPath) { | |
| 332 | + return resolver.resolve(options); | |
| 333 | + } | |
| 334 | + | |
| 335 | + const resolvedCommandPath = | |
| 336 | + resolveCommandPath(binaryPath, { | |
| 337 | + ...(options?.platform ? { platform: options.platform } : {}), | |
| 338 | + ...(options?.env ? { env: options.env } : {}), | |
| 339 | + }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); | |
| 340 | + if (!resolvedCommandPath) { | |
| 341 | + return resolver.resolve(options); | |
| 342 | + } | |
| 343 | + | |
| 344 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 345 | + const realCommandPath = yield* fileSystem | |
| 346 | + .realPath(resolvedCommandPath) | |
| 347 | + .pipe(Effect.catch(() => Effect.succeed(resolvedCommandPath))); | |
| 348 | + return resolver.resolve({ | |
| 349 | + ...options, | |
| 350 | + realCommandPath, | |
| 351 | + }); | |
| 352 | +}); | |
| 353 | + | |
| 354 | +function deriveVersionAdvisory(input: { | |
| 355 | + readonly currentVersion: string | null; | |
| 356 | + readonly latestVersion: string | null; | |
| 357 | +}): Pick<ServerProviderVersionAdvisory, "status" | "message"> { | |
| 358 | + if (!input.currentVersion) { | |
| 359 | + return { status: "unknown", message: null }; | |
| 360 | + } | |
| 361 | + if (!input.latestVersion) { | |
| 362 | + return { status: "unknown", message: null }; | |
| 363 | + } | |
| 364 | + if (compareCliVersions(input.currentVersion, input.latestVersion) < 0) { | |
| 365 | + return { | |
| 366 | + status: "behind_latest", | |
| 367 | + message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE, | |
| 368 | + }; | |
| 369 | + } | |
| 370 | + return { status: "current", message: null }; | |
| 371 | +} | |
| 372 | + | |
| 373 | +export function createProviderVersionAdvisory(input: { | |
| 374 | + readonly driver: ProviderDriverKind; | |
| 375 | + readonly currentVersion: string | null; | |
| 376 | + readonly latestVersion?: string | null; | |
| 377 | + readonly checkedAt?: string | null; | |
| 378 | + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; | |
| 379 | +}): ServerProviderVersionAdvisory { | |
| 380 | + const capabilities = | |
| 381 | + input.maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(input.driver); | |
| 382 | + const latestVersion = input.latestVersion ?? null; | |
| 383 | + const advisory = deriveVersionAdvisory({ | |
| 384 | + currentVersion: input.currentVersion, | |
| 385 | + latestVersion, | |
| 386 | + }); | |
| 387 | + | |
| 388 | + return { | |
| 389 | + status: advisory.status, | |
| 390 | + currentVersion: input.currentVersion, | |
| 391 | + latestVersion, | |
| 392 | + updateCommand: capabilities.update?.command ?? null, | |
| 393 | + canUpdate: capabilities.update !== null, | |
| 394 | + checkedAt: input.checkedAt ?? null, | |
| 395 | + message: advisory.message, | |
| 396 | + }; | |
| 397 | +} | |
| 398 | + | |
| 399 | +const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) { | |
| 400 | + const client = yield* HttpClient.HttpClient; | |
| 401 | + const request = HttpClientRequest.get( | |
| 402 | + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, | |
| 403 | + ).pipe(HttpClientRequest.setHeader("accept", "application/json")); | |
| 404 | + const response = yield* client.execute(request).pipe( | |
| 405 | + Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), | |
| 406 | + Effect.catch(() => Effect.succeed(Option.none())), | |
| 407 | + ); | |
| 408 | + if (Option.isNone(response)) { | |
| 409 | + return null; | |
| 410 | + } | |
| 411 | + const httpResponse = response.value; | |
| 412 | + if (httpResponse.status < 200 || httpResponse.status >= 300) { | |
| 413 | + return null; | |
| 414 | + } | |
| 415 | + const payload = yield* httpResponse.json.pipe( | |
| 416 | + Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)), | |
| 417 | + Effect.catch(() => Effect.succeed(null)), | |
| 418 | + ); | |
| 419 | + return payload ? nonEmptyString(payload.version) : null; | |
| 420 | +}); | |
| 421 | + | |
| 422 | +export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* ( | |
| 423 | + maintenanceCapabilities: ProviderMaintenanceCapabilities, | |
| 424 | +) { | |
| 425 | + const packageName = maintenanceCapabilities.packageName; | |
| 426 | + if (!packageName) { | |
| 427 | + return null; | |
| 428 | + } | |
| 429 | + | |
| 430 | + const cached = latestVersionCache.get(packageName); | |
| 431 | + const now = DateTime.toEpochMillis(yield* DateTime.now); | |
| 432 | + if (cached && cached.expiresAt > now) { | |
| 433 | + return cached.version; | |
| 434 | + } | |
| 435 | + | |
| 436 | + const version = yield* fetchNpmLatestVersion(packageName); | |
| 437 | + latestVersionCache.set(packageName, { | |
| 438 | + expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, | |
| 439 | + version, | |
| 440 | + }); | |
| 441 | + return version; | |
| 442 | +}); | |
| 443 | + | |
| 444 | +export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( | |
| 445 | + "enrichProviderSnapshotWithVersionAdvisory", | |
| 446 | +)(function* (snapshot: ServerProvider, maintenanceCapabilities?: ProviderMaintenanceCapabilities) { | |
| 447 | + const capabilities = | |
| 448 | + maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver); | |
| 449 | + if (!snapshot.enabled || !snapshot.installed || !snapshot.version) { | |
| 450 | + return { | |
| 451 | + ...snapshot, | |
| 452 | + versionAdvisory: createProviderVersionAdvisory({ | |
| 453 | + driver: snapshot.driver, | |
| 454 | + currentVersion: snapshot.version, | |
| 455 | + checkedAt: snapshot.checkedAt, | |
| 456 | + maintenanceCapabilities: capabilities, | |
| 457 | + }), | |
| 458 | + }; | |
| 459 | + } | |
| 460 | + | |
| 461 | + const latestVersion = yield* resolveLatestProviderVersion(capabilities); | |
| 462 | + return { | |
| 463 | + ...snapshot, | |
| 464 | + versionAdvisory: createProviderVersionAdvisory({ | |
| 465 | + driver: snapshot.driver, | |
| 466 | + currentVersion: snapshot.version, | |
| 467 | + latestVersion, | |
| 468 | + checkedAt: DateTime.formatIso(yield* DateTime.now), | |
| 469 | + maintenanceCapabilities: capabilities, | |
| 470 | + }), | |
| 471 | + }; | |
| 472 | +}); | |
| @@ -0,0 +1,79 @@ | ||
| 1 | +import { Effect, Ref } from "effect"; | |
| 2 | +import * as Semaphore from "effect/Semaphore"; | |
| 3 | + | |
| 4 | +export interface ProviderMaintenanceCommandCoordinatorShape<E> { | |
| 5 | + readonly withCommandLock: <A, R>(input: { | |
| 6 | + readonly targetKey: string; | |
| 7 | + readonly lockKey: string; | |
| 8 | + readonly onQueued?: Effect.Effect<void, E, R>; | |
| 9 | + readonly run: Effect.Effect<A, E, R>; | |
| 10 | + }) => Effect.Effect<A, E, R>; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export const makeProviderMaintenanceCommandCoordinator = Effect.fn( | |
| 14 | + "makeProviderMaintenanceCommandCoordinator", | |
| 15 | +)(function* <E>(input: { readonly makeAlreadyRunningError: (targetKey: string) => E }) { | |
| 16 | + const runningTargetsRef = yield* Ref.make<ReadonlySet<string>>(new Set()); | |
| 17 | + const locksRef = yield* Ref.make<ReadonlyMap<string, Semaphore.Semaphore>>(new Map()); | |
| 18 | + | |
| 19 | + const acquireTarget = Effect.fn("acquireTarget")(function* (targetKey: string) { | |
| 20 | + return yield* Ref.modify(runningTargetsRef, (runningTargets) => { | |
| 21 | + if (runningTargets.has(targetKey)) { | |
| 22 | + return [false, runningTargets] as const; | |
| 23 | + } | |
| 24 | + const next = new Set(runningTargets); | |
| 25 | + next.add(targetKey); | |
| 26 | + return [true, next] as const; | |
| 27 | + }); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + const releaseTarget = (targetKey: string) => | |
| 31 | + Ref.update(runningTargetsRef, (runningTargets) => { | |
| 32 | + const next = new Set(runningTargets); | |
| 33 | + next.delete(targetKey); | |
| 34 | + return next; | |
| 35 | + }); | |
| 36 | + | |
| 37 | + const getLock = Effect.fn("getProviderMaintenanceCommandLock")(function* (lockKey: string) { | |
| 38 | + const existing = (yield* Ref.get(locksRef)).get(lockKey); | |
| 39 | + if (existing) { | |
| 40 | + return existing; | |
| 41 | + } | |
| 42 | + | |
| 43 | + const lock = yield* Semaphore.make(1); | |
| 44 | + return yield* Ref.modify(locksRef, (locks) => { | |
| 45 | + const current = locks.get(lockKey); | |
| 46 | + if (current) { | |
| 47 | + return [current, locks] as const; | |
| 48 | + } | |
| 49 | + const next = new Map(locks); | |
| 50 | + next.set(lockKey, lock); | |
| 51 | + return [lock, next] as const; | |
| 52 | + }); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + const withCommandLock: ProviderMaintenanceCommandCoordinatorShape<E>["withCommandLock"] = ({ | |
| 56 | + targetKey, | |
| 57 | + lockKey, | |
| 58 | + onQueued, | |
| 59 | + run, | |
| 60 | + }) => | |
| 61 | + Effect.gen(function* () { | |
| 62 | + const acquired = yield* acquireTarget(targetKey); | |
| 63 | + if (!acquired) { | |
| 64 | + return yield* Effect.fail(input.makeAlreadyRunningError(targetKey)); | |
| 65 | + } | |
| 66 | + | |
| 67 | + return yield* Effect.gen(function* () { | |
| 68 | + const lock = yield* getLock(lockKey); | |
| 69 | + if (onQueued) { | |
| 70 | + yield* onQueued; | |
| 71 | + } | |
| 72 | + return yield* lock.withPermits(1)(run); | |
| 73 | + }).pipe(Effect.ensuring(releaseTarget(targetKey))); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + return { | |
| 77 | + withCommandLock, | |
| 78 | + } satisfies ProviderMaintenanceCommandCoordinatorShape<E>; | |
| 79 | +}); | |
| @@ -0,0 +1,640 @@ | ||
| 1 | +import { afterEach, describe, it, assert } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + ProviderDriverKind, | |
| 4 | + ProviderInstanceId, | |
| 5 | + type ServerProvider, | |
| 6 | + type ServerProviderUpdateState, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import { ServerProviderUpdateError } from "@t3tools/contracts"; | |
| 9 | +import { Cause, Effect, Exit, Fiber, Layer, Ref, Schema, Sink, Stream } from "effect"; | |
| 10 | +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 11 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 12 | + | |
| 13 | +import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; | |
| 14 | +import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; | |
| 15 | +import { | |
| 16 | + clearLatestProviderVersionCacheForTests, | |
| 17 | + makeProviderMaintenanceCapabilities, | |
| 18 | + type ProviderMaintenanceCapabilities, | |
| 19 | +} from "./providerMaintenance.ts"; | |
| 20 | + | |
| 21 | +const CODEX_DRIVER = ProviderDriverKind.make("codex"); | |
| 22 | +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); | |
| 23 | +const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); | |
| 24 | +const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex"); | |
| 25 | +const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); | |
| 26 | +const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); | |
| 27 | +const encoder = new TextEncoder(); | |
| 28 | + | |
| 29 | +afterEach(() => { | |
| 30 | + clearLatestProviderVersionCacheForTests(); | |
| 31 | +}); | |
| 32 | + | |
| 33 | +function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { | |
| 34 | + if (provider === CURSOR_DRIVER) { | |
| 35 | + return makeProviderMaintenanceCapabilities({ | |
| 36 | + provider, | |
| 37 | + packageName: null, | |
| 38 | + updateExecutable: "agent", | |
| 39 | + updateArgs: ["update"], | |
| 40 | + updateLockKey: "cursor-agent", | |
| 41 | + }); | |
| 42 | + } | |
| 43 | + return makeProviderMaintenanceCapabilities({ | |
| 44 | + provider, | |
| 45 | + packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", | |
| 46 | + updateExecutable: "npm", | |
| 47 | + updateArgs: | |
| 48 | + provider === OPENCODE_DRIVER | |
| 49 | + ? ["install", "-g", "opencode-ai@latest"] | |
| 50 | + : ["install", "-g", "@openai/codex@latest"], | |
| 51 | + updateLockKey: "npm-global", | |
| 52 | + }); | |
| 53 | +} | |
| 54 | + | |
| 55 | +const baseProvider: ServerProvider = { | |
| 56 | + instanceId: CODEX_INSTANCE_ID, | |
| 57 | + driver: CODEX_DRIVER, | |
| 58 | + enabled: true, | |
| 59 | + installed: true, | |
| 60 | + version: null, | |
| 61 | + status: "ready", | |
| 62 | + auth: { status: "authenticated" }, | |
| 63 | + checkedAt: "2026-04-10T00:00:00.000Z", | |
| 64 | + models: [], | |
| 65 | + slashCommands: [], | |
| 66 | + skills: [], | |
| 67 | +}; | |
| 68 | + | |
| 69 | +const baseCursorProvider: ServerProvider = { | |
| 70 | + ...baseProvider, | |
| 71 | + instanceId: CURSOR_INSTANCE_ID, | |
| 72 | + driver: CURSOR_DRIVER, | |
| 73 | +}; | |
| 74 | + | |
| 75 | +const baseOpenCodeProvider: ServerProvider = { | |
| 76 | + ...baseProvider, | |
| 77 | + instanceId: OPENCODE_INSTANCE_ID, | |
| 78 | + driver: OPENCODE_DRIVER, | |
| 79 | +}; | |
| 80 | + | |
| 81 | +const latestVersionHttpClient = (version: string) => | |
| 82 | + Layer.succeed( | |
| 83 | + HttpClient.HttpClient, | |
| 84 | + HttpClient.make((request) => | |
| 85 | + Effect.succeed( | |
| 86 | + HttpClientResponse.fromWeb( | |
| 87 | + request, | |
| 88 | + Response.json({ version }, { headers: { "content-type": "application/json" } }), | |
| 89 | + ), | |
| 90 | + ), | |
| 91 | + ), | |
| 92 | + ); | |
| 93 | + | |
| 94 | +function mockHandle(result: { | |
| 95 | + readonly stdout?: string; | |
| 96 | + readonly stderr?: string; | |
| 97 | + readonly code?: number; | |
| 98 | + readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; | |
| 99 | +}) { | |
| 100 | + return ChildProcessSpawner.makeHandle({ | |
| 101 | + pid: ChildProcessSpawner.ProcessId(1), | |
| 102 | + exitCode: result.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), | |
| 103 | + isRunning: Effect.succeed(false), | |
| 104 | + kill: () => Effect.void, | |
| 105 | + unref: Effect.succeed(Effect.void), | |
| 106 | + stdin: Sink.drain, | |
| 107 | + stdout: Stream.make(encoder.encode(result.stdout ?? "")), | |
| 108 | + stderr: Stream.make(encoder.encode(result.stderr ?? "")), | |
| 109 | + all: Stream.empty, | |
| 110 | + getInputFd: () => Sink.drain, | |
| 111 | + getOutputFd: () => Stream.empty, | |
| 112 | + }); | |
| 113 | +} | |
| 114 | + | |
| 115 | +function mockSpawnerLayer( | |
| 116 | + handler: ( | |
| 117 | + command: string, | |
| 118 | + args: ReadonlyArray<string>, | |
| 119 | + ) => { | |
| 120 | + readonly stdout?: string; | |
| 121 | + readonly stderr?: string; | |
| 122 | + readonly code?: number; | |
| 123 | + readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; | |
| 124 | + }, | |
| 125 | +) { | |
| 126 | + return Layer.succeed( | |
| 127 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 128 | + ChildProcessSpawner.make((command) => { | |
| 129 | + const childProcess = command as unknown as { | |
| 130 | + readonly command: string; | |
| 131 | + readonly args: ReadonlyArray<string>; | |
| 132 | + }; | |
| 133 | + return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); | |
| 134 | + }), | |
| 135 | + ); | |
| 136 | +} | |
| 137 | + | |
| 138 | +function makeRegistry( | |
| 139 | + initialProviders: ServerProvider | ReadonlyArray<ServerProvider> = baseProvider, | |
| 140 | +) { | |
| 141 | + return Effect.gen(function* () { | |
| 142 | + const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>( | |
| 143 | + Array.isArray(initialProviders) ? initialProviders : [initialProviders], | |
| 144 | + ); | |
| 145 | + const updateStatesRef = yield* Ref.make<ReadonlyArray<ServerProviderUpdateState>>([]); | |
| 146 | + | |
| 147 | + const setProviderMaintenanceActionState = Effect.fn( | |
| 148 | + "providerMaintenanceRunner.test.setProviderMaintenanceActionState", | |
| 149 | + )(function* (input: { | |
| 150 | + readonly instanceId: ProviderInstanceId; | |
| 151 | + readonly action: "update"; | |
| 152 | + readonly state: ServerProviderUpdateState | null; | |
| 153 | + }) { | |
| 154 | + const updateState = input.state; | |
| 155 | + if (updateState) { | |
| 156 | + yield* Ref.update(updateStatesRef, (states) => [...states, updateState]); | |
| 157 | + } | |
| 158 | + return yield* Ref.updateAndGet(providersRef, (providers) => | |
| 159 | + providers.map((candidate) => { | |
| 160 | + if (candidate.instanceId !== input.instanceId) { | |
| 161 | + return candidate; | |
| 162 | + } | |
| 163 | + if (!updateState) { | |
| 164 | + const { updateState: _updateState, ...providerWithoutUpdateState } = candidate; | |
| 165 | + return providerWithoutUpdateState; | |
| 166 | + } | |
| 167 | + return { | |
| 168 | + ...candidate, | |
| 169 | + updateState, | |
| 170 | + }; | |
| 171 | + }), | |
| 172 | + ); | |
| 173 | + }); | |
| 174 | + | |
| 175 | + const registry: ProviderRegistryShape = { | |
| 176 | + getProviders: Ref.get(providersRef), | |
| 177 | + refresh: () => Ref.get(providersRef), | |
| 178 | + refreshInstance: () => Ref.get(providersRef), | |
| 179 | + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => | |
| 180 | + Effect.succeed(lifecycleFor(provider)), | |
| 181 | + setProviderMaintenanceActionState, | |
| 182 | + streamChanges: Stream.empty, | |
| 183 | + }; | |
| 184 | + | |
| 185 | + return { | |
| 186 | + registry, | |
| 187 | + updateStatesRef, | |
| 188 | + }; | |
| 189 | + }); | |
| 190 | +} | |
| 191 | + | |
| 192 | +const makeTestRunner = (registry: ProviderRegistryShape) => | |
| 193 | + Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( | |
| 194 | + Effect.provide( | |
| 195 | + ProviderMaintenanceRunner.layer.pipe( | |
| 196 | + Layer.provide(Layer.succeed(ProviderRegistry, registry)), | |
| 197 | + ), | |
| 198 | + ), | |
| 199 | + ); | |
| 200 | + | |
| 201 | +describe("providerMaintenanceRunner", () => { | |
| 202 | + it.effect("runs the allowlisted provider update command and records success", () => { | |
| 203 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 204 | + return Effect.gen(function* () { | |
| 205 | + const { registry, updateStatesRef } = yield* makeRegistry(baseCursorProvider); | |
| 206 | + const updater = yield* makeTestRunner(registry); | |
| 207 | + | |
| 208 | + const result = yield* updater.updateProvider(CURSOR_DRIVER); | |
| 209 | + assert.deepStrictEqual(calls, [ | |
| 210 | + { | |
| 211 | + command: "agent", | |
| 212 | + args: ["update"], | |
| 213 | + }, | |
| 214 | + ]); | |
| 215 | + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); | |
| 216 | + assert.deepStrictEqual( | |
| 217 | + (yield* Ref.get(updateStatesRef)).map((state) => state.status), | |
| 218 | + ["queued", "running", "succeeded"], | |
| 219 | + ); | |
| 220 | + }).pipe( | |
| 221 | + Effect.provide( | |
| 222 | + Layer.mergeAll( | |
| 223 | + latestVersionHttpClient("0.0.0"), | |
| 224 | + mockSpawnerLayer((command, args) => { | |
| 225 | + calls.push({ command, args }); | |
| 226 | + return { stdout: "updated" }; | |
| 227 | + }), | |
| 228 | + ), | |
| 229 | + ), | |
| 230 | + ); | |
| 231 | + }); | |
| 232 | + | |
| 233 | + it.effect("uses the resolved provider capabilities when choosing the update executable", () => { | |
| 234 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 235 | + return Effect.gen(function* () { | |
| 236 | + const { registry } = yield* makeRegistry({ | |
| 237 | + ...baseProvider, | |
| 238 | + versionAdvisory: { | |
| 239 | + status: "behind_latest", | |
| 240 | + currentVersion: "2.0.14", | |
| 241 | + latestVersion: "2.1.123", | |
| 242 | + updateCommand: "bun i -g @anthropic-ai/claude-code@latest", | |
| 243 | + canUpdate: true, | |
| 244 | + checkedAt: "2026-04-30T12:00:00.000Z", | |
| 245 | + message: "Update available.", | |
| 246 | + }, | |
| 247 | + }); | |
| 248 | + const updater = yield* makeTestRunner({ | |
| 249 | + ...registry, | |
| 250 | + getProviderMaintenanceCapabilitiesForInstance: () => | |
| 251 | + Effect.succeed( | |
| 252 | + makeProviderMaintenanceCapabilities({ | |
| 253 | + provider: CODEX_DRIVER, | |
| 254 | + packageName: "@openai/codex", | |
| 255 | + updateExecutable: "bun", | |
| 256 | + updateArgs: ["i", "-g", "@openai/codex@latest"], | |
| 257 | + updateLockKey: "bun-global", | |
| 258 | + }), | |
| 259 | + ), | |
| 260 | + }); | |
| 261 | + | |
| 262 | + yield* updater.updateProvider(CODEX_DRIVER); | |
| 263 | + assert.deepStrictEqual(calls, [ | |
| 264 | + { | |
| 265 | + command: "bun", | |
| 266 | + args: ["i", "-g", "@openai/codex@latest"], | |
| 267 | + }, | |
| 268 | + ]); | |
| 269 | + }).pipe( | |
| 270 | + Effect.provide( | |
| 271 | + Layer.mergeAll( | |
| 272 | + latestVersionHttpClient("0.0.0"), | |
| 273 | + mockSpawnerLayer((command, args) => { | |
| 274 | + calls.push({ command, args }); | |
| 275 | + return { stdout: "updated" }; | |
| 276 | + }), | |
| 277 | + ), | |
| 278 | + ), | |
| 279 | + ); | |
| 280 | + }); | |
| 281 | + | |
| 282 | + it.effect( | |
| 283 | + "runs update commands through Effect ChildProcess when no test runner is injected", | |
| 284 | + () => { | |
| 285 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 286 | + return Effect.gen(function* () { | |
| 287 | + const { registry } = yield* makeRegistry(baseProvider); | |
| 288 | + const runner = yield* makeTestRunner(registry); | |
| 289 | + | |
| 290 | + const result = yield* runner.updateProvider(CODEX_DRIVER); | |
| 291 | + | |
| 292 | + assert.deepStrictEqual(calls, [ | |
| 293 | + { | |
| 294 | + command: "npm", | |
| 295 | + args: ["install", "-g", "@openai/codex@latest"], | |
| 296 | + }, | |
| 297 | + ]); | |
| 298 | + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); | |
| 299 | + }).pipe( | |
| 300 | + Effect.provide( | |
| 301 | + Layer.mergeAll( | |
| 302 | + latestVersionHttpClient("0.0.0"), | |
| 303 | + mockSpawnerLayer((command, args) => { | |
| 304 | + calls.push({ command, args }); | |
| 305 | + return { stdout: "updated" }; | |
| 306 | + }), | |
| 307 | + ), | |
| 308 | + ), | |
| 309 | + ); | |
| 310 | + }, | |
| 311 | + ); | |
| 312 | + | |
| 313 | + it.effect("updates a single provider instance without touching sibling instances", () => { | |
| 314 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 315 | + return Effect.gen(function* () { | |
| 316 | + const personalInstanceId = ProviderInstanceId.make("codex_personal"); | |
| 317 | + const workInstanceId = ProviderInstanceId.make("codex_work"); | |
| 318 | + const refreshedInstanceIds: Array<ProviderInstanceId> = []; | |
| 319 | + const { registry } = yield* makeRegistry([ | |
| 320 | + { | |
| 321 | + ...baseProvider, | |
| 322 | + instanceId: personalInstanceId, | |
| 323 | + version: "0.124.0-alpha.3", | |
| 324 | + }, | |
| 325 | + { | |
| 326 | + ...baseProvider, | |
| 327 | + instanceId: workInstanceId, | |
| 328 | + version: "0.124.0-alpha.3", | |
| 329 | + }, | |
| 330 | + ]); | |
| 331 | + const updater = yield* makeTestRunner({ | |
| 332 | + ...registry, | |
| 333 | + getProviderMaintenanceCapabilitiesForInstance: (instanceId, provider) => | |
| 334 | + Effect.succeed( | |
| 335 | + makeProviderMaintenanceCapabilities({ | |
| 336 | + provider, | |
| 337 | + packageName: "@openai/codex-instance-test", | |
| 338 | + updateExecutable: "vp", | |
| 339 | + updateArgs: ["i", "-g", "@openai/codex"], | |
| 340 | + updateLockKey: "vite-plus-global", | |
| 341 | + }), | |
| 342 | + ).pipe( | |
| 343 | + Effect.tap(() => Effect.sync(() => assert.strictEqual(instanceId, personalInstanceId))), | |
| 344 | + ), | |
| 345 | + refreshInstance: (instanceId) => | |
| 346 | + registry.refreshInstance(instanceId).pipe( | |
| 347 | + Effect.tap(() => | |
| 348 | + Effect.sync(() => { | |
| 349 | + refreshedInstanceIds.push(instanceId); | |
| 350 | + }), | |
| 351 | + ), | |
| 352 | + ), | |
| 353 | + }); | |
| 354 | + | |
| 355 | + const result = yield* updater.updateProvider({ | |
| 356 | + provider: CODEX_DRIVER, | |
| 357 | + instanceId: personalInstanceId, | |
| 358 | + }); | |
| 359 | + | |
| 360 | + assert.deepStrictEqual(calls, [ | |
| 361 | + { | |
| 362 | + command: "vp", | |
| 363 | + args: ["i", "-g", "@openai/codex"], | |
| 364 | + }, | |
| 365 | + ]); | |
| 366 | + assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId]); | |
| 367 | + assert.strictEqual(result.providers[0]?.instanceId, personalInstanceId); | |
| 368 | + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); | |
| 369 | + assert.strictEqual(result.providers[1]?.instanceId, workInstanceId); | |
| 370 | + assert.strictEqual(result.providers[1]?.updateState, undefined); | |
| 371 | + }).pipe( | |
| 372 | + Effect.provide( | |
| 373 | + Layer.mergeAll( | |
| 374 | + latestVersionHttpClient("0.124.0-alpha.3"), | |
| 375 | + mockSpawnerLayer((command, args) => { | |
| 376 | + calls.push({ command, args }); | |
| 377 | + return { stdout: "updated" }; | |
| 378 | + }), | |
| 379 | + ), | |
| 380 | + ), | |
| 381 | + ); | |
| 382 | + }); | |
| 383 | + | |
| 384 | + it.effect("records command failure output in provider update state", () => | |
| 385 | + Effect.gen(function* () { | |
| 386 | + const { registry } = yield* makeRegistry(); | |
| 387 | + const updater = yield* makeTestRunner(registry); | |
| 388 | + | |
| 389 | + const result = yield* updater.updateProvider(CODEX_DRIVER); | |
| 390 | + const updateState = result.providers[0]?.updateState; | |
| 391 | + | |
| 392 | + assert.strictEqual(updateState?.status, "failed"); | |
| 393 | + assert.strictEqual(updateState?.message, "Update command exited with code 1."); | |
| 394 | + assert.include(updateState?.output ?? "", "permission denied"); | |
| 395 | + }).pipe( | |
| 396 | + Effect.provide( | |
| 397 | + Layer.mergeAll( | |
| 398 | + latestVersionHttpClient("0.0.0"), | |
| 399 | + mockSpawnerLayer(() => ({ stderr: "permission denied", code: 1 })), | |
| 400 | + ), | |
| 401 | + ), | |
| 402 | + ), | |
| 403 | + ); | |
| 404 | + | |
| 405 | + it.effect( | |
| 406 | + "marks successful commands as unchanged when the refreshed provider is still outdated", | |
| 407 | + () => | |
| 408 | + Effect.gen(function* () { | |
| 409 | + const { registry } = yield* makeRegistry({ | |
| 410 | + ...baseProvider, | |
| 411 | + installed: true, | |
| 412 | + version: "0.1.0", | |
| 413 | + }); | |
| 414 | + const updater = yield* makeTestRunner(registry); | |
| 415 | + | |
| 416 | + const result = yield* updater.updateProvider(CODEX_DRIVER); | |
| 417 | + | |
| 418 | + assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); | |
| 419 | + assert.include(result.providers[0]?.updateState?.message ?? "", "still detects"); | |
| 420 | + }).pipe( | |
| 421 | + Effect.provide( | |
| 422 | + Layer.mergeAll( | |
| 423 | + latestVersionHttpClient("9.9.9"), | |
| 424 | + mockSpawnerLayer(() => ({ stdout: "updated" })), | |
| 425 | + ), | |
| 426 | + ), | |
| 427 | + ), | |
| 428 | + ); | |
| 429 | + | |
| 430 | + it.effect("prevents concurrent updates for the same provider", () => { | |
| 431 | + const startedLatch: { resolve: () => void } = { resolve: () => {} }; | |
| 432 | + const releaseLatch: { resolve: () => void } = { resolve: () => {} }; | |
| 433 | + const started = new Promise<void>((resolve) => { | |
| 434 | + startedLatch.resolve = resolve; | |
| 435 | + }); | |
| 436 | + const release = new Promise<void>((resolve) => { | |
| 437 | + releaseLatch.resolve = resolve; | |
| 438 | + }); | |
| 439 | + return Effect.gen(function* () { | |
| 440 | + const { registry } = yield* makeRegistry(); | |
| 441 | + const updater = yield* makeTestRunner(registry); | |
| 442 | + | |
| 443 | + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); | |
| 444 | + yield* Effect.promise(() => started); | |
| 445 | + | |
| 446 | + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); | |
| 447 | + assert.strictEqual(Exit.isFailure(second), true); | |
| 448 | + if (Exit.isFailure(second)) { | |
| 449 | + const error = Cause.squash(second.cause); | |
| 450 | + assert.strictEqual(Schema.is(ServerProviderUpdateError)(error), true); | |
| 451 | + if (Schema.is(ServerProviderUpdateError)(error)) { | |
| 452 | + assert.include(error.reason, "already running"); | |
| 453 | + } | |
| 454 | + } | |
| 455 | + | |
| 456 | + releaseLatch.resolve(); | |
| 457 | + yield* Fiber.join(first); | |
| 458 | + }).pipe( | |
| 459 | + Effect.provide( | |
| 460 | + Layer.mergeAll( | |
| 461 | + latestVersionHttpClient("0.0.0"), | |
| 462 | + mockSpawnerLayer(() => { | |
| 463 | + startedLatch.resolve(); | |
| 464 | + return { | |
| 465 | + stdout: "updated", | |
| 466 | + exitCode: Effect.promise(() => release).pipe( | |
| 467 | + Effect.as(ChildProcessSpawner.ExitCode(0)), | |
| 468 | + ), | |
| 469 | + }; | |
| 470 | + }), | |
| 471 | + ), | |
| 472 | + ), | |
| 473 | + ); | |
| 474 | + }); | |
| 475 | + | |
| 476 | + it.effect("serializes different providers that share the same update lock key", () => { | |
| 477 | + const firstStartedLatch: { resolve: () => void } = { resolve: () => {} }; | |
| 478 | + const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} }; | |
| 479 | + const firstStarted = new Promise<void>((resolve) => { | |
| 480 | + firstStartedLatch.resolve = resolve; | |
| 481 | + }); | |
| 482 | + const releaseFirst = new Promise<void>((resolve) => { | |
| 483 | + releaseFirstLatch.resolve = resolve; | |
| 484 | + }); | |
| 485 | + const calls: Array<string> = []; | |
| 486 | + return Effect.gen(function* () { | |
| 487 | + const { registry } = yield* makeRegistry([baseProvider, baseOpenCodeProvider]); | |
| 488 | + const updater = yield* makeTestRunner({ | |
| 489 | + ...registry, | |
| 490 | + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => | |
| 491 | + Effect.succeed( | |
| 492 | + makeProviderMaintenanceCapabilities({ | |
| 493 | + provider, | |
| 494 | + packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", | |
| 495 | + updateExecutable: "npm", | |
| 496 | + updateArgs: | |
| 497 | + provider === OPENCODE_DRIVER | |
| 498 | + ? ["install", "-g", "opencode-ai@latest"] | |
| 499 | + : ["install", "-g", "@openai/codex@latest"], | |
| 500 | + updateLockKey: "npm-global", | |
| 501 | + }), | |
| 502 | + ), | |
| 503 | + }); | |
| 504 | + | |
| 505 | + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); | |
| 506 | + yield* Effect.promise(() => firstStarted); | |
| 507 | + | |
| 508 | + const second = yield* updater.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped); | |
| 509 | + let providersWhileQueued: ReadonlyArray<ServerProvider> = []; | |
| 510 | + for (let attempt = 0; attempt < 20; attempt += 1) { | |
| 511 | + providersWhileQueued = yield* registry.getProviders; | |
| 512 | + const queuedStatus = providersWhileQueued.find( | |
| 513 | + (provider) => provider.instanceId === OPENCODE_INSTANCE_ID, | |
| 514 | + )?.updateState?.status; | |
| 515 | + if (queuedStatus === "queued") { | |
| 516 | + break; | |
| 517 | + } | |
| 518 | + yield* Effect.yieldNow; | |
| 519 | + } | |
| 520 | + assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); | |
| 521 | + assert.strictEqual( | |
| 522 | + providersWhileQueued.find((provider) => provider.instanceId === OPENCODE_INSTANCE_ID) | |
| 523 | + ?.updateState?.status, | |
| 524 | + "queued", | |
| 525 | + ); | |
| 526 | + | |
| 527 | + releaseFirstLatch.resolve(); | |
| 528 | + yield* Fiber.join(first); | |
| 529 | + yield* Fiber.join(second); | |
| 530 | + assert.deepStrictEqual(calls, [ | |
| 531 | + "install -g @openai/codex@latest", | |
| 532 | + "install -g opencode-ai@latest", | |
| 533 | + ]); | |
| 534 | + }).pipe( | |
| 535 | + Effect.provide( | |
| 536 | + Layer.mergeAll( | |
| 537 | + latestVersionHttpClient("0.0.0"), | |
| 538 | + mockSpawnerLayer((_command, args) => { | |
| 539 | + calls.push(args.join(" ")); | |
| 540 | + if (calls.length === 1) { | |
| 541 | + firstStartedLatch.resolve(); | |
| 542 | + return { | |
| 543 | + stdout: "updated", | |
| 544 | + exitCode: Effect.promise(() => releaseFirst).pipe( | |
| 545 | + Effect.as(ChildProcessSpawner.ExitCode(0)), | |
| 546 | + ), | |
| 547 | + }; | |
| 548 | + } | |
| 549 | + return { stdout: "updated" }; | |
| 550 | + }), | |
| 551 | + ), | |
| 552 | + ), | |
| 553 | + ); | |
| 554 | + }); | |
| 555 | + | |
| 556 | + it.effect("accepts arbitrary driver-provided update lock keys", () => { | |
| 557 | + const calls: Array<string> = []; | |
| 558 | + return Effect.gen(function* () { | |
| 559 | + const { registry } = yield* makeRegistry(baseProvider); | |
| 560 | + const updater = yield* makeTestRunner({ | |
| 561 | + ...registry, | |
| 562 | + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => | |
| 563 | + Effect.succeed( | |
| 564 | + makeProviderMaintenanceCapabilities({ | |
| 565 | + provider, | |
| 566 | + packageName: "@openai/codex", | |
| 567 | + updateExecutable: "npm", | |
| 568 | + updateArgs: ["install", "-g", "@openai/codex@latest"], | |
| 569 | + updateLockKey: "unknown-lock-key", | |
| 570 | + }), | |
| 571 | + ), | |
| 572 | + }); | |
| 573 | + | |
| 574 | + const result = yield* updater.updateProvider(CODEX_DRIVER); | |
| 575 | + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); | |
| 576 | + assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); | |
| 577 | + }).pipe( | |
| 578 | + Effect.provide( | |
| 579 | + Layer.mergeAll( | |
| 580 | + latestVersionHttpClient("0.0.0"), | |
| 581 | + mockSpawnerLayer((_command, args) => { | |
| 582 | + calls.push(args.join(" ")); | |
| 583 | + return { stdout: "updated" }; | |
| 584 | + }), | |
| 585 | + ), | |
| 586 | + ), | |
| 587 | + ); | |
| 588 | + }); | |
| 589 | + | |
| 590 | + it.effect( | |
| 591 | + "releases the running-provider marker when interrupted after queuing but before the lock run starts", | |
| 592 | + () => | |
| 593 | + Effect.gen(function* () { | |
| 594 | + const { registry } = yield* makeRegistry(baseProvider); | |
| 595 | + let blockQueuedState = true; | |
| 596 | + const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} }; | |
| 597 | + const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} }; | |
| 598 | + const queuedStateWritten = new Promise<void>((resolve) => { | |
| 599 | + queuedStateWrittenLatch.resolve = resolve; | |
| 600 | + }); | |
| 601 | + const releaseQueuedState = new Promise<void>((resolve) => { | |
| 602 | + releaseQueuedStateLatch.resolve = resolve; | |
| 603 | + }); | |
| 604 | + | |
| 605 | + const updater = yield* makeTestRunner({ | |
| 606 | + ...registry, | |
| 607 | + setProviderMaintenanceActionState: Effect.fn( | |
| 608 | + "providerMaintenanceRunner.test.blockQueuedState", | |
| 609 | + )(function* (input) { | |
| 610 | + const providers = yield* registry.setProviderMaintenanceActionState(input); | |
| 611 | + if (input.state?.status === "queued" && blockQueuedState) { | |
| 612 | + queuedStateWrittenLatch.resolve(); | |
| 613 | + yield* Effect.promise(() => releaseQueuedState); | |
| 614 | + } | |
| 615 | + return providers; | |
| 616 | + }), | |
| 617 | + }); | |
| 618 | + | |
| 619 | + const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); | |
| 620 | + yield* Effect.promise(() => queuedStateWritten); | |
| 621 | + blockQueuedState = false; | |
| 622 | + | |
| 623 | + yield* Fiber.interrupt(first); | |
| 624 | + releaseQueuedStateLatch.resolve(); | |
| 625 | + | |
| 626 | + const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); | |
| 627 | + assert.strictEqual(Exit.isSuccess(second), true); | |
| 628 | + if (Exit.isSuccess(second)) { | |
| 629 | + assert.strictEqual(second.value.providers[0]?.updateState?.status, "succeeded"); | |
| 630 | + } | |
| 631 | + }).pipe( | |
| 632 | + Effect.provide( | |
| 633 | + Layer.mergeAll( | |
| 634 | + latestVersionHttpClient("0.0.0"), | |
| 635 | + mockSpawnerLayer(() => ({ stdout: "updated" })), | |
| 636 | + ), | |
| 637 | + ), | |
| 638 | + ), | |
| 639 | + ); | |
| 640 | +}); | |
| @@ -0,0 +1,395 @@ | ||
| 1 | +import { | |
| 2 | + defaultInstanceIdForDriver, | |
| 3 | + ProviderDriverKind, | |
| 4 | + ServerProviderUpdateError, | |
| 5 | + type ProviderInstanceId, | |
| 6 | + type ServerProvider, | |
| 7 | + type ServerProviderUpdatedPayload, | |
| 8 | + type ServerProviderUpdateState, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | +import { Cause, Context, DateTime, Duration, Effect, Layer, Option, Ref, Schema } from "effect"; | |
| 11 | +import { HttpClient } from "effect/unstable/http"; | |
| 12 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 13 | + | |
| 14 | +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; | |
| 15 | +import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; | |
| 16 | +import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts"; | |
| 17 | +import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | |
| 18 | +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | |
| 19 | + | |
| 20 | +const UPDATE_TIMEOUT_MS = 5 * 60_000; | |
| 21 | +const UPDATE_OUTPUT_MAX_BYTES = 10_000; | |
| 22 | + | |
| 23 | +export interface ProviderMaintenanceCommandResult { | |
| 24 | + readonly stdout: string; | |
| 25 | + readonly stderr: string; | |
| 26 | + readonly exitCode: number | null; | |
| 27 | + readonly timedOut: boolean; | |
| 28 | + readonly stdoutTruncated: boolean; | |
| 29 | + readonly stderrTruncated: boolean; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export interface ProviderMaintenanceRunnerShape { | |
| 33 | + readonly updateProvider: ( | |
| 34 | + target: | |
| 35 | + | ProviderDriverKind | |
| 36 | + | { | |
| 37 | + readonly provider: ProviderDriverKind; | |
| 38 | + readonly instanceId?: ProviderInstanceId | undefined; | |
| 39 | + }, | |
| 40 | + ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export class ProviderMaintenanceRunner extends Context.Service< | |
| 44 | + ProviderMaintenanceRunner, | |
| 45 | + ProviderMaintenanceRunnerShape | |
| 46 | +>()("t3/provider/ProviderMaintenanceRunner") {} | |
| 47 | + | |
| 48 | +interface VerifiedProviderRefresh { | |
| 49 | + readonly providers: ReadonlyArray<ServerProvider>; | |
| 50 | + readonly verifiedProviders: ReadonlyArray<ServerProvider>; | |
| 51 | +} | |
| 52 | + | |
| 53 | +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | |
| 54 | + | |
| 55 | +const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")( | |
| 56 | + function* (input: { | |
| 57 | + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 58 | + readonly command: string; | |
| 59 | + readonly args: ReadonlyArray<string>; | |
| 60 | + }) { | |
| 61 | + const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( | |
| 62 | + function* () { | |
| 63 | + const child = yield* input.spawner | |
| 64 | + .spawn(ChildProcess.make(input.command, [...input.args])) | |
| 65 | + .pipe( | |
| 66 | + Effect.mapError( | |
| 67 | + (cause) => | |
| 68 | + new Error(`Failed to run update command ${input.command}: ${cause.message}`), | |
| 69 | + ), | |
| 70 | + ); | |
| 71 | + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); | |
| 72 | + | |
| 73 | + const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 74 | + [ | |
| 75 | + collectUint8StreamText({ | |
| 76 | + stream: child.stdout, | |
| 77 | + maxBytes: UPDATE_OUTPUT_MAX_BYTES, | |
| 78 | + }), | |
| 79 | + collectUint8StreamText({ | |
| 80 | + stream: child.stderr, | |
| 81 | + maxBytes: UPDATE_OUTPUT_MAX_BYTES, | |
| 82 | + }), | |
| 83 | + child.exitCode, | |
| 84 | + ], | |
| 85 | + { concurrency: "unbounded" }, | |
| 86 | + ).pipe( | |
| 87 | + Effect.mapError( | |
| 88 | + (cause) => | |
| 89 | + new Error(cause instanceof Error ? cause.message : "Update command failed to run."), | |
| 90 | + ), | |
| 91 | + ); | |
| 92 | + | |
| 93 | + return { | |
| 94 | + stdout: stdout.text, | |
| 95 | + stderr: stderr.text, | |
| 96 | + exitCode: Number(exitCode), | |
| 97 | + timedOut: false, | |
| 98 | + stdoutTruncated: stdout.truncated, | |
| 99 | + stderrTruncated: stderr.truncated, | |
| 100 | + } satisfies ProviderMaintenanceCommandResult; | |
| 101 | + }, | |
| 102 | + ); | |
| 103 | + | |
| 104 | + return yield* collectCommandResult().pipe( | |
| 105 | + Effect.scoped, | |
| 106 | + Effect.timeoutOption(Duration.millis(UPDATE_TIMEOUT_MS)), | |
| 107 | + Effect.map((result) => | |
| 108 | + Option.match(result, { | |
| 109 | + onSome: (value) => value, | |
| 110 | + onNone: () => | |
| 111 | + ({ | |
| 112 | + stdout: "", | |
| 113 | + stderr: "", | |
| 114 | + exitCode: null, | |
| 115 | + timedOut: true, | |
| 116 | + stdoutTruncated: false, | |
| 117 | + stderrTruncated: false, | |
| 118 | + }) satisfies ProviderMaintenanceCommandResult, | |
| 119 | + }), | |
| 120 | + ), | |
| 121 | + ); | |
| 122 | + }, | |
| 123 | +); | |
| 124 | + | |
| 125 | +function trimNullable(value: string): string | null { | |
| 126 | + const trimmed = value.trim(); | |
| 127 | + return trimmed.length > 0 ? trimmed : null; | |
| 128 | +} | |
| 129 | + | |
| 130 | +function truncateText(value: string, maxLength: number): string { | |
| 131 | + return value.length <= maxLength ? value : value.slice(0, maxLength); | |
| 132 | +} | |
| 133 | + | |
| 134 | +function commandOutput(result: ProviderMaintenanceCommandResult): string | null { | |
| 135 | + const output = trimNullable([result.stderr, result.stdout].filter(Boolean).join("\n\n")); | |
| 136 | + if (!output) { | |
| 137 | + return null; | |
| 138 | + } | |
| 139 | + return truncateText(output, UPDATE_OUTPUT_MAX_BYTES); | |
| 140 | +} | |
| 141 | + | |
| 142 | +function failureMessage(result: ProviderMaintenanceCommandResult): string { | |
| 143 | + if (result.timedOut) { | |
| 144 | + return "Update timed out."; | |
| 145 | + } | |
| 146 | + if (result.exitCode !== null && result.exitCode !== 0) { | |
| 147 | + return `Update command exited with code ${result.exitCode}.`; | |
| 148 | + } | |
| 149 | + return "Update command failed."; | |
| 150 | +} | |
| 151 | + | |
| 152 | +function isOutdatedProvider(provider: ServerProvider | undefined): boolean { | |
| 153 | + return provider?.versionAdvisory?.status === "behind_latest"; | |
| 154 | +} | |
| 155 | + | |
| 156 | +function makeUpdateState(input: { | |
| 157 | + readonly status: ServerProviderUpdateState["status"]; | |
| 158 | + readonly startedAt: string | null; | |
| 159 | + readonly finishedAt: string | null; | |
| 160 | + readonly message: string | null; | |
| 161 | + readonly output?: string | null; | |
| 162 | +}): ServerProviderUpdateState { | |
| 163 | + return { | |
| 164 | + status: input.status, | |
| 165 | + startedAt: input.startedAt, | |
| 166 | + finishedAt: input.finishedAt, | |
| 167 | + message: input.message, | |
| 168 | + output: input.output ?? null, | |
| 169 | + }; | |
| 170 | +} | |
| 171 | + | |
| 172 | +export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | |
| 173 | + const providerRegistry = yield* ProviderRegistry; | |
| 174 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 175 | + const httpClient = yield* HttpClient.HttpClient; | |
| 176 | + const runMaintenanceCommand = (command: string, args: ReadonlyArray<string>) => | |
| 177 | + runProviderMaintenanceCommandWithSpawner({ | |
| 178 | + spawner, | |
| 179 | + command, | |
| 180 | + args, | |
| 181 | + }); | |
| 182 | + const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ | |
| 183 | + makeAlreadyRunningError: () => | |
| 184 | + new ServerProviderUpdateError({ | |
| 185 | + provider: ProviderDriverKind.make("unknown"), | |
| 186 | + reason: "An update is already running for this provider.", | |
| 187 | + }), | |
| 188 | + }); | |
| 189 | + | |
| 190 | + const verifyRefreshedProvider = ( | |
| 191 | + provider: ProviderDriverKind, | |
| 192 | + maintenanceCapabilities: ProviderMaintenanceCapabilities, | |
| 193 | + instanceId: ProviderInstanceId, | |
| 194 | + ): Effect.Effect<VerifiedProviderRefresh> => | |
| 195 | + providerRegistry.getProviders.pipe( | |
| 196 | + Effect.map((providers) => | |
| 197 | + providers | |
| 198 | + .filter( | |
| 199 | + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, | |
| 200 | + ) | |
| 201 | + .map((candidate) => candidate.instanceId), | |
| 202 | + ), | |
| 203 | + Effect.flatMap((instanceIds) => | |
| 204 | + instanceIds.length === 0 | |
| 205 | + ? providerRegistry.refreshInstance(instanceId) | |
| 206 | + : Effect.forEach( | |
| 207 | + instanceIds, | |
| 208 | + (instanceId) => providerRegistry.refreshInstance(instanceId), | |
| 209 | + { | |
| 210 | + concurrency: "unbounded", | |
| 211 | + discard: true, | |
| 212 | + }, | |
| 213 | + ).pipe(Effect.andThen(providerRegistry.getProviders)), | |
| 214 | + ), | |
| 215 | + Effect.flatMap((providers) => { | |
| 216 | + const refreshedProviders = providers.filter( | |
| 217 | + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, | |
| 218 | + ); | |
| 219 | + if (refreshedProviders.length === 0) { | |
| 220 | + return Effect.succeed<VerifiedProviderRefresh>({ | |
| 221 | + providers, | |
| 222 | + verifiedProviders: [], | |
| 223 | + }); | |
| 224 | + } | |
| 225 | + return Effect.forEach( | |
| 226 | + refreshedProviders, | |
| 227 | + (refreshedProvider) => | |
| 228 | + enrichProviderSnapshotWithVersionAdvisory( | |
| 229 | + refreshedProvider, | |
| 230 | + maintenanceCapabilities, | |
| 231 | + ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), | |
| 232 | + { | |
| 233 | + concurrency: "unbounded", | |
| 234 | + }, | |
| 235 | + ).pipe( | |
| 236 | + Effect.map( | |
| 237 | + (verifiedProviders): VerifiedProviderRefresh => ({ | |
| 238 | + providers, | |
| 239 | + verifiedProviders, | |
| 240 | + }), | |
| 241 | + ), | |
| 242 | + Effect.catchCause((cause) => | |
| 243 | + Effect.logWarning("Provider post-update version verification failed", { | |
| 244 | + provider, | |
| 245 | + cause: Cause.pretty(cause), | |
| 246 | + }).pipe( | |
| 247 | + Effect.as<VerifiedProviderRefresh>({ | |
| 248 | + providers, | |
| 249 | + verifiedProviders: refreshedProviders, | |
| 250 | + }), | |
| 251 | + ), | |
| 252 | + ), | |
| 253 | + ); | |
| 254 | + }), | |
| 255 | + ); | |
| 256 | + | |
| 257 | + const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn( | |
| 258 | + "ProviderMaintenanceRunner.updateProvider", | |
| 259 | + )(function* (target) { | |
| 260 | + const provider = typeof target === "string" ? target : target.provider; | |
| 261 | + const instanceId = | |
| 262 | + typeof target === "string" | |
| 263 | + ? defaultInstanceIdForDriver(provider) | |
| 264 | + : (target.instanceId ?? defaultInstanceIdForDriver(provider)); | |
| 265 | + const targetKey = `instance:${instanceId}`; | |
| 266 | + const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( | |
| 267 | + instanceId, | |
| 268 | + provider, | |
| 269 | + ); | |
| 270 | + const update = capabilities.update; | |
| 271 | + if (!update) { | |
| 272 | + return yield* new ServerProviderUpdateError({ | |
| 273 | + provider, | |
| 274 | + reason: "This provider does not support one-click updates.", | |
| 275 | + }); | |
| 276 | + } | |
| 277 | + | |
| 278 | + const setUpdateState = (state: ServerProviderUpdateState | null) => | |
| 279 | + providerRegistry.setProviderMaintenanceActionState({ | |
| 280 | + instanceId, | |
| 281 | + action: "update", | |
| 282 | + state, | |
| 283 | + }); | |
| 284 | + const setQueuedState = setUpdateState( | |
| 285 | + makeUpdateState({ | |
| 286 | + status: "queued", | |
| 287 | + startedAt: null, | |
| 288 | + finishedAt: null, | |
| 289 | + message: "Waiting for another provider update to finish.", | |
| 290 | + }), | |
| 291 | + ).pipe(Effect.asVoid); | |
| 292 | + | |
| 293 | + const runProviderUpdate = Effect.fn("ProviderMaintenanceRunner.runProviderUpdate")( | |
| 294 | + function* () { | |
| 295 | + const finish = (state: ServerProviderUpdateState) => | |
| 296 | + setUpdateState(state).pipe(Effect.map((providers) => ({ providers }))); | |
| 297 | + const startedAtRef = yield* Ref.make<string | null>(null); | |
| 298 | + | |
| 299 | + const runCommandAndVerify = Effect.fn("ProviderMaintenanceRunner.runCommandAndVerify")( | |
| 300 | + function* () { | |
| 301 | + const startedAt = yield* nowIso; | |
| 302 | + yield* Ref.set(startedAtRef, startedAt); | |
| 303 | + yield* setUpdateState( | |
| 304 | + makeUpdateState({ | |
| 305 | + status: "running", | |
| 306 | + startedAt, | |
| 307 | + finishedAt: null, | |
| 308 | + message: "Updating provider.", | |
| 309 | + }), | |
| 310 | + ); | |
| 311 | + | |
| 312 | + const result = yield* runMaintenanceCommand(update.executable, update.args); | |
| 313 | + const finishedAt = yield* nowIso; | |
| 314 | + if (result.timedOut || result.exitCode !== 0) { | |
| 315 | + return yield* finish( | |
| 316 | + makeUpdateState({ | |
| 317 | + status: "failed", | |
| 318 | + startedAt, | |
| 319 | + finishedAt, | |
| 320 | + message: failureMessage(result), | |
| 321 | + output: commandOutput(result), | |
| 322 | + }), | |
| 323 | + ); | |
| 324 | + } | |
| 325 | + | |
| 326 | + const { verifiedProviders } = yield* verifyRefreshedProvider( | |
| 327 | + provider, | |
| 328 | + capabilities, | |
| 329 | + instanceId, | |
| 330 | + ); | |
| 331 | + const couldNotVerify = verifiedProviders.length === 0; | |
| 332 | + const stillOutdated = | |
| 333 | + couldNotVerify || | |
| 334 | + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); | |
| 335 | + return yield* finish( | |
| 336 | + makeUpdateState({ | |
| 337 | + status: stillOutdated ? "unchanged" : "succeeded", | |
| 338 | + startedAt, | |
| 339 | + finishedAt, | |
| 340 | + message: couldNotVerify | |
| 341 | + ? "Update command completed, but T3 Code could not verify the provider version." | |
| 342 | + : stillOutdated | |
| 343 | + ? "Update command completed, but T3 Code still detects an outdated provider version." | |
| 344 | + : "Provider updated.", | |
| 345 | + output: commandOutput(result), | |
| 346 | + }), | |
| 347 | + ); | |
| 348 | + }, | |
| 349 | + ); | |
| 350 | + | |
| 351 | + const recordFailedUpdate = Effect.fn("ProviderMaintenanceRunner.recordFailedUpdate")( | |
| 352 | + function* (cause: Cause.Cause<unknown>) { | |
| 353 | + const failure = Cause.squash(cause); | |
| 354 | + const startedAt = yield* Ref.get(startedAtRef); | |
| 355 | + return yield* finish( | |
| 356 | + makeUpdateState({ | |
| 357 | + status: "failed", | |
| 358 | + startedAt, | |
| 359 | + finishedAt: yield* nowIso, | |
| 360 | + message: failure instanceof Error ? failure.message : "Update command failed.", | |
| 361 | + output: null, | |
| 362 | + }), | |
| 363 | + ); | |
| 364 | + }, | |
| 365 | + ); | |
| 366 | + | |
| 367 | + return yield* runCommandAndVerify().pipe(Effect.catchCause(recordFailedUpdate)); | |
| 368 | + }, | |
| 369 | + ); | |
| 370 | + | |
| 371 | + return yield* commandCoordinator | |
| 372 | + .withCommandLock({ | |
| 373 | + targetKey, | |
| 374 | + lockKey: update.lockKey, | |
| 375 | + onQueued: setQueuedState, | |
| 376 | + run: runProviderUpdate(), | |
| 377 | + }) | |
| 378 | + .pipe( | |
| 379 | + Effect.mapError((error) => | |
| 380 | + Schema.is(ServerProviderUpdateError)(error) | |
| 381 | + ? new ServerProviderUpdateError({ | |
| 382 | + provider, | |
| 383 | + reason: error.reason, | |
| 384 | + }) | |
| 385 | + : error, | |
| 386 | + ), | |
| 387 | + ); | |
| 388 | + }); | |
| 389 | + | |
| 390 | + return ProviderMaintenanceRunner.of({ | |
| 391 | + updateProvider, | |
| 392 | + }); | |
| 393 | +}); | |
| 394 | + | |
| 395 | +export const layer = Layer.effect(ProviderMaintenanceRunner, make()); | |
| @@ -12,6 +12,8 @@ import { Effect, Stream } from "effect"; · 11 unmodified lines | ||
| 12 | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | 13 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 14 | 14 | import { isWindowsCommandNotFound } from "../processRunner.ts"; |
| 15 | +import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; | |
| 16 | +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | |
| 15 | 17 | |
| 16 | 18 | export const DEFAULT_TIMEOUT_MS = 4_000; |
| 17 | 19 | // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) |
| @@ -182,6 +184,7 @@ export function buildBooleanOptionDescriptor(input: { · 164 unmodified lines | ||
| 182 | 184 | } |
| 183 | 185 | |
| 184 | 186 | export function buildServerProvider(input: { |
| 187 | + driver?: ProviderDriverKind; | |
| 185 | 188 | presentation: ServerProviderPresentation; |
| 186 | 189 | enabled: boolean; |
| 187 | 190 | checkedAt: string; |
| @@ -190,6 +193,13 @@ export function buildServerProvider(input: { · 2 unmodified lines | ||
| 190 | 193 | skills?: ReadonlyArray<ServerProviderSkill>; |
| 191 | 194 | probe: ProviderProbeResult; |
| 192 | 195 | }): ServerProviderDraft { |
| 196 | + const versionAdvisory = input.driver | |
| 197 | + ? createProviderVersionAdvisory({ | |
| 198 | + driver: input.driver, | |
| 199 | + currentVersion: input.probe.version, | |
| 200 | + checkedAt: input.checkedAt, | |
| 201 | + }) | |
| 202 | + : undefined; | |
| 193 | 203 | return { |
| 194 | 204 | displayName: input.presentation.displayName, |
| 195 | 205 | ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), |
| @@ -206,16 +216,11 @@ export function buildServerProvider(input: { · 10 unmodified lines | ||
| 206 | 216 | models: input.models, |
| 207 | 217 | slashCommands: [...(input.slashCommands ?? [])], |
| 208 | 218 | skills: [...(input.skills ?? [])], |
| 219 | + ...(versionAdvisory ? { versionAdvisory } : {}), | |
| 209 | 220 | }; |
| 210 | 221 | } |
| 211 | 222 | |
| 212 | 223 | export const collectStreamAsString = <E>( |
| 213 | 224 | stream: Stream.Stream<Uint8Array, E>, |
| 214 | 225 | ): Effect.Effect<string, E> => |
| 215 | − stream.pipe( | |
| 216 | − Stream.decodeText(), | |
| 217 | − Stream.runFold( | |
| 218 | − () => "", | |
| 219 | − (acc, chunk) => acc + chunk, | |
| 220 | − ), | |
| 221 | − ); | |
| 226 | + collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); | |
| @@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) => · 139 unmodified lines | ||
| 140 | 140 | export const writeProviderStatusCache = (input: { |
| 141 | 141 | readonly filePath: string; |
| 142 | 142 | readonly provider: ServerProvider; |
| 143 | −}) => | |
| 144 | − writeFileStringAtomically({ | |
| 143 | +}) => { | |
| 144 | + const { updateState: _updateState, ...cacheableProvider } = input.provider; | |
| 145 | + return writeFileStringAtomically({ | |
| 145 | 146 | filePath: input.filePath, |
| 146 | − contents: `${JSON.stringify(input.provider, null, 2)}\n`, | |
| 147 | + contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`, | |
| 147 | 148 | }); |
| 149 | +}; | |
| @@ -80,6 +80,7 @@ import { · 79 unmodified lines | ||
| 80 | 80 | ProviderRegistry, |
| 81 | 81 | type ProviderRegistryShape, |
| 82 | 82 | } from "./provider/Services/ProviderRegistry.ts"; |
| 83 | +import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; | |
| 83 | 84 | import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; |
| 84 | 85 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; |
| 85 | 86 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| @@ -517,6 +518,12 @@ const buildAppUnderTest = (options?: { · 431 unmodified lines | ||
| 517 | 518 | Layer.mock(ProviderRegistry)({ |
| 518 | 519 | getProviders: Effect.succeed([]), |
| 519 | 520 | refresh: () => Effect.succeed([]), |
| 521 | + refreshInstance: () => Effect.succeed([]), | |
| 522 | + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => | |
| 523 | + Effect.succeed( | |
| 524 | + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), | |
| 525 | + ), | |
| 526 | + setProviderMaintenanceActionState: () => Effect.succeed([]), | |
| 520 | 527 | streamChanges: Stream.empty, |
| 521 | 528 | ...options?.layers?.providerRegistry, |
| 522 | 529 | }), |
| @@ -0,0 +1,38 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Stream } from "effect"; | |
| 3 | + | |
| 4 | +import { collectUint8StreamText } from "./collectUint8StreamText.ts"; | |
| 5 | + | |
| 6 | +const encoder = new TextEncoder(); | |
| 7 | + | |
| 8 | +describe("collectUint8StreamText", () => { | |
| 9 | + it.effect("collects Uint8Array chunks into decoded text", () => | |
| 10 | + Effect.gen(function* () { | |
| 11 | + const result = yield* collectUint8StreamText({ | |
| 12 | + stream: Stream.make(encoder.encode("hello "), encoder.encode("world")), | |
| 13 | + }); | |
| 14 | + | |
| 15 | + assert.deepStrictEqual(result, { | |
| 16 | + text: "hello world", | |
| 17 | + bytes: 11, | |
| 18 | + truncated: false, | |
| 19 | + }); | |
| 20 | + }), | |
| 21 | + ); | |
| 22 | + | |
| 23 | + it.effect("truncates by bytes and appends an optional marker once", () => | |
| 24 | + Effect.gen(function* () { | |
| 25 | + const result = yield* collectUint8StreamText({ | |
| 26 | + stream: Stream.make(encoder.encode("abcdef"), encoder.encode("ghij")), | |
| 27 | + maxBytes: 5, | |
| 28 | + truncatedMarker: "[truncated]", | |
| 29 | + }); | |
| 30 | + | |
| 31 | + assert.deepStrictEqual(result, { | |
| 32 | + text: "abcde[truncated]", | |
| 33 | + bytes: 5, | |
| 34 | + truncated: true, | |
| 35 | + }); | |
| 36 | + }), | |
| 37 | + ); | |
| 38 | +}); | |
| @@ -0,0 +1,66 @@ | ||
| 1 | +import { Effect, Stream } from "effect"; | |
| 2 | + | |
| 3 | +export interface CollectedUint8StreamText { | |
| 4 | + readonly text: string; | |
| 5 | + readonly truncated: boolean; | |
| 6 | + readonly bytes: number; | |
| 7 | +} | |
| 8 | + | |
| 9 | +interface CollectState { | |
| 10 | + readonly text: string; | |
| 11 | + readonly bytes: number; | |
| 12 | + readonly truncated: boolean; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export const collectUint8StreamText = <E>(input: { | |
| 16 | + readonly stream: Stream.Stream<Uint8Array, E>; | |
| 17 | + readonly maxBytes?: number | undefined; | |
| 18 | + readonly truncatedMarker?: string | null | undefined; | |
| 19 | +}): Effect.Effect<CollectedUint8StreamText, E> => { | |
| 20 | + const decoder = new TextDecoder(); | |
| 21 | + const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY; | |
| 22 | + const truncatedMarker = input.truncatedMarker ?? ""; | |
| 23 | + | |
| 24 | + return input.stream.pipe( | |
| 25 | + Stream.runFold( | |
| 26 | + (): CollectState => ({ | |
| 27 | + text: "", | |
| 28 | + bytes: 0, | |
| 29 | + truncated: false, | |
| 30 | + }), | |
| 31 | + (state, chunk): CollectState => { | |
| 32 | + if (state.truncated) { | |
| 33 | + return state; | |
| 34 | + } | |
| 35 | + | |
| 36 | + const remainingBytes = maxBytes - state.bytes; | |
| 37 | + if (remainingBytes <= 0) { | |
| 38 | + return { | |
| 39 | + ...state, | |
| 40 | + text: `${state.text}${truncatedMarker}`, | |
| 41 | + truncated: true, | |
| 42 | + }; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const nextChunk = | |
| 46 | + chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; | |
| 47 | + const text = `${state.text}${decoder.decode(nextChunk, { stream: true })}`; | |
| 48 | + const bytes = state.bytes + nextChunk.byteLength; | |
| 49 | + const truncated = chunk.byteLength > remainingBytes; | |
| 50 | + | |
| 51 | + return { | |
| 52 | + text: truncated ? `${text}${truncatedMarker}` : text, | |
| 53 | + bytes, | |
| 54 | + truncated, | |
| 55 | + }; | |
| 56 | + }, | |
| 57 | + ), | |
| 58 | + Effect.map( | |
| 59 | + (state): CollectedUint8StreamText => ({ | |
| 60 | + text: state.truncated ? state.text : `${state.text}${decoder.decode()}`, | |
| 61 | + bytes: state.bytes, | |
| 62 | + truncated: state.truncated, | |
| 63 | + }), | |
| 64 | + ), | |
| 65 | + ); | |
| 66 | +}; | |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | VcsProcessSpawnError, |
| 9 | 9 | VcsProcessTimeoutError, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | |
| 11 | 12 | |
| 12 | 13 | export interface VcsProcessInput { |
| 13 | 14 | readonly operation: string; |
| @@ -86,44 +87,12 @@ export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: · 72 unmodified lines | ||
| 86 | 87 | readonly maxOutputBytes?: number; |
| 87 | 88 | readonly truncateOutputAtMaxBytes?: boolean; |
| 88 | 89 | }) { |
| 89 | − const decoder = new TextDecoder(); | |
| 90 | − let text = ""; | |
| 91 | − let bytes = 0; | |
| 92 | − let truncated = false; | |
| 93 | 90 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 94 | − const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; | |
| 95 | − | |
| 96 | − yield* Stream.runForEach(input.stream, (chunk) => | |
| 97 | − Effect.sync(() => { | |
| 98 | − if (truncated) return; | |
| 99 | − | |
| 100 | − const remainingBytes = maxOutputBytes - bytes; | |
| 101 | − if (remainingBytes <= 0) { | |
| 102 | − truncated = true; | |
| 103 | − if (truncateOutputAtMaxBytes) { | |
| 104 | − text += OUTPUT_TRUNCATED_MARKER; | |
| 105 | − } | |
| 106 | − return; | |
| 107 | − } | |
| 108 | − | |
| 109 | − const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; | |
| 110 | − text += decoder.decode(nextChunk, { stream: true }); | |
| 111 | − bytes += nextChunk.byteLength; | |
| 112 | − | |
| 113 | − if (chunk.byteLength > remainingBytes) { | |
| 114 | − truncated = true; | |
| 115 | − if (truncateOutputAtMaxBytes) { | |
| 116 | − text += OUTPUT_TRUNCATED_MARKER; | |
| 117 | − } | |
| 118 | − } | |
| 119 | − }), | |
| 120 | − ); | |
| 121 | − | |
| 122 | − if (!truncated) { | |
| 123 | − text += decoder.decode(); | |
| 124 | − } | |
| 125 | − | |
| 126 | − return { text, truncated } satisfies VcsProcessCollectedText; | |
| 91 | + return yield* collectUint8StreamText({ | |
| 92 | + stream: input.stream, | |
| 93 | + maxBytes: maxOutputBytes, | |
| 94 | + truncatedMarker: input.truncateOutputAtMaxBytes ? OUTPUT_TRUNCATED_MARKER : null, | |
| 95 | + }); | |
| 127 | 96 | }); |
| 128 | 97 | |
| 129 | 98 | export const make = Effect.fn("makeVcsProcess")(function* () { |
| @@ -40,6 +40,7 @@ import { · 39 unmodified lines | ||
| 40 | 40 | observeRpcStreamEffect, |
| 41 | 41 | } from "./observability/RpcInstrumentation.ts"; |
| 42 | 42 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; |
| 43 | +import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; | |
| 43 | 44 | import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 44 | 45 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 45 | 46 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| @@ -152,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 106 unmodified lines | ||
| 152 | 153 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 153 | 154 | const terminalManager = yield* TerminalManager; |
| 154 | 155 | const providerRegistry = yield* ProviderRegistry; |
| 156 | + const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; | |
| 155 | 157 | const config = yield* ServerConfig; |
| 156 | 158 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 157 | 159 | const serverSettings = yield* ServerSettingsService; |
| @@ -785,6 +787,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 627 unmodified lines | ||
| 785 | 787 | ).pipe(Effect.map((providers) => ({ providers }))), |
| 786 | 788 | { "rpc.aggregate": "server" }, |
| 787 | 789 | ), |
| 790 | + [WS_METHODS.serverUpdateProvider]: (input) => | |
| 791 | + observeRpcEffect( | |
| 792 | + WS_METHODS.serverUpdateProvider, | |
| 793 | + providerMaintenanceRunner.updateProvider(input), | |
| 794 | + { | |
| 795 | + "rpc.aggregate": "server", | |
| 796 | + }, | |
| 797 | + ), | |
| 788 | 798 | [WS_METHODS.serverUpsertKeybinding]: (rule) => |
| 789 | 799 | observeRpcEffect( |
| 790 | 800 | WS_METHODS.serverUpsertKeybinding, |
| @@ -1152,6 +1162,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( · 361 unmodified lines | ||
| 1152 | 1162 | Effect.provide( |
| 1153 | 1163 | makeWsRpcLayer(session.sessionId).pipe( |
| 1154 | 1164 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1165 | + Layer.provide(ProviderMaintenanceRunner.layer), | |
| 1155 | 1166 | Layer.provide( |
| 1156 | 1167 | SourceControlDiscoveryLayer.layer.pipe( |
| 1157 | 1168 | Layer.provide( |
| @@ -0,0 +1,673 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + canOneClickUpdateProviderCandidate, | |
| 6 | + collectProviderUpdateCandidates, | |
| 7 | + collectUpdatedProviderSnapshots, | |
| 8 | + firstRejectedProviderUpdateMessage, | |
| 9 | + getProviderUpdateInitialToastView, | |
| 10 | + getProviderUpdateProgressToastView, | |
| 11 | + getProviderUpdateRejectedToastView, | |
| 12 | + getProviderUpdateSidebarPillView, | |
| 13 | + getSingleProviderUpdateProgressToastView, | |
| 14 | + hasOneClickUpdateProviderCandidate, | |
| 15 | + isProviderUpdateCandidate, | |
| 16 | + providerUpdateNotificationKey, | |
| 17 | + type ProviderUpdateCandidate, | |
| 18 | +} from "./ProviderUpdateLaunchNotification.logic"; | |
| 19 | + | |
| 20 | +const checkedAt = "2026-04-23T10:00:00.000Z"; | |
| 21 | +const sessionStartedAt = "2026-04-23T09:59:00.000Z"; | |
| 22 | +const laterCheckedAt = "2026-04-23T10:01:00.000Z"; | |
| 23 | + | |
| 24 | +const driver = (value: string) => ProviderDriverKind.make(value); | |
| 25 | +const instanceId = (value: string) => ProviderInstanceId.make(value); | |
| 26 | + | |
| 27 | +function provider(input: { | |
| 28 | + readonly driver: ReturnType<typeof ProviderDriverKind.make>; | |
| 29 | + readonly instanceId?: ReturnType<typeof ProviderInstanceId.make>; | |
| 30 | + readonly enabled?: boolean; | |
| 31 | + readonly version?: string | null; | |
| 32 | + readonly latestVersion?: string | null; | |
| 33 | + readonly canUpdate?: boolean; | |
| 34 | + readonly updateCommand?: string | null; | |
| 35 | + readonly updateState?: ServerProvider["updateState"]; | |
| 36 | + readonly advisoryStatus?: NonNullable<ServerProvider["versionAdvisory"]>["status"]; | |
| 37 | +}): ServerProvider { | |
| 38 | + const result: ServerProvider = { | |
| 39 | + instanceId: input.instanceId ?? instanceId(String(input.driver)), | |
| 40 | + driver: input.driver, | |
| 41 | + enabled: input.enabled ?? true, | |
| 42 | + installed: true, | |
| 43 | + version: input.version ?? "1.0.0", | |
| 44 | + status: "ready", | |
| 45 | + auth: { status: "authenticated" }, | |
| 46 | + checkedAt, | |
| 47 | + models: [], | |
| 48 | + slashCommands: [], | |
| 49 | + skills: [], | |
| 50 | + versionAdvisory: { | |
| 51 | + status: input.advisoryStatus ?? "behind_latest", | |
| 52 | + currentVersion: input.version ?? "1.0.0", | |
| 53 | + latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0", | |
| 54 | + updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider", | |
| 55 | + canUpdate: input.canUpdate ?? true, | |
| 56 | + checkedAt, | |
| 57 | + message: "Update available.", | |
| 58 | + }, | |
| 59 | + }; | |
| 60 | + | |
| 61 | + if (input.updateState) { | |
| 62 | + return { ...result, updateState: input.updateState }; | |
| 63 | + } | |
| 64 | + | |
| 65 | + return result; | |
| 66 | +} | |
| 67 | + | |
| 68 | +function updateCandidate(input: Parameters<typeof provider>[0]): ProviderUpdateCandidate { | |
| 69 | + return provider(input) as ProviderUpdateCandidate; | |
| 70 | +} | |
| 71 | + | |
| 72 | +describe("provider update launch notification logic", () => { | |
| 73 | + it("detects enabled providers with a latest-version advisory", () => { | |
| 74 | + expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true); | |
| 75 | + expect(isProviderUpdateCandidate(provider({ driver: driver("codex"), enabled: false }))).toBe( | |
| 76 | + false, | |
| 77 | + ); | |
| 78 | + expect( | |
| 79 | + isProviderUpdateCandidate( | |
| 80 | + provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), | |
| 81 | + ), | |
| 82 | + ).toBe(false); | |
| 83 | + expect( | |
| 84 | + isProviderUpdateCandidate(provider({ driver: driver("codex"), latestVersion: null })), | |
| 85 | + ).toBe(false); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + it("deduplicates multi-instance provider candidates by driver", () => { | |
| 89 | + expect( | |
| 90 | + collectProviderUpdateCandidates([ | |
| 91 | + provider({ | |
| 92 | + driver: driver("codex"), | |
| 93 | + instanceId: instanceId("codex_personal"), | |
| 94 | + latestVersion: "1.1.0", | |
| 95 | + }), | |
| 96 | + provider({ | |
| 97 | + driver: driver("codex"), | |
| 98 | + instanceId: instanceId("codex"), | |
| 99 | + latestVersion: "1.1.0", | |
| 100 | + }), | |
| 101 | + provider({ driver: driver("cursor"), latestVersion: "0.3.0" }), | |
| 102 | + ]), | |
| 103 | + ).toHaveLength(2); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + it("disables one-click updates when provider instances disagree on the update command", () => { | |
| 107 | + const candidate = updateCandidate({ | |
| 108 | + driver: driver("claudeAgent"), | |
| 109 | + instanceId: instanceId("claude_personal"), | |
| 110 | + latestVersion: "2.1.123", | |
| 111 | + }); | |
| 112 | + | |
| 113 | + expect( | |
| 114 | + canOneClickUpdateProviderCandidate(candidate, [ | |
| 115 | + candidate, | |
| 116 | + provider({ | |
| 117 | + driver: driver("claudeAgent"), | |
| 118 | + instanceId: instanceId("claude_work"), | |
| 119 | + latestVersion: "2.1.123", | |
| 120 | + canUpdate: true, | |
| 121 | + updateCommand: "bun add -g @anthropic-ai/claude-code@latest", | |
| 122 | + }), | |
| 123 | + ]), | |
| 124 | + ).toBe(false); | |
| 125 | + }); | |
| 126 | + | |
| 127 | + it("keeps one-click updates enabled when sibling instances are already current", () => { | |
| 128 | + const candidate = updateCandidate({ | |
| 129 | + driver: driver("claudeAgent"), | |
| 130 | + instanceId: instanceId("claude_personal"), | |
| 131 | + latestVersion: "2.1.123", | |
| 132 | + updateCommand: "npm install -g @anthropic-ai/claude-code@latest", | |
| 133 | + }); | |
| 134 | + | |
| 135 | + expect( | |
| 136 | + hasOneClickUpdateProviderCandidate(candidate, [ | |
| 137 | + candidate, | |
| 138 | + provider({ | |
| 139 | + driver: driver("claudeAgent"), | |
| 140 | + instanceId: instanceId("claude_work"), | |
| 141 | + version: "2.1.123", | |
| 142 | + latestVersion: "2.1.123", | |
| 143 | + advisoryStatus: "current", | |
| 144 | + canUpdate: false, | |
| 145 | + updateCommand: null, | |
| 146 | + }), | |
| 147 | + ]), | |
| 148 | + ).toBe(true); | |
| 149 | + expect( | |
| 150 | + canOneClickUpdateProviderCandidate(candidate, [ | |
| 151 | + candidate, | |
| 152 | + provider({ | |
| 153 | + driver: driver("claudeAgent"), | |
| 154 | + instanceId: instanceId("claude_work"), | |
| 155 | + version: "2.1.123", | |
| 156 | + latestVersion: "2.1.123", | |
| 157 | + advisoryStatus: "current", | |
| 158 | + canUpdate: false, | |
| 159 | + updateCommand: null, | |
| 160 | + }), | |
| 161 | + ]), | |
| 162 | + ).toBe(true); | |
| 163 | + }); | |
| 164 | + | |
| 165 | + it("keeps the inline update action available while a provider update is already running", () => { | |
| 166 | + const candidate = updateCandidate({ | |
| 167 | + driver: driver("codex"), | |
| 168 | + updateState: { | |
| 169 | + status: "running", | |
| 170 | + startedAt: checkedAt, | |
| 171 | + finishedAt: null, | |
| 172 | + message: "Updating provider.", | |
| 173 | + output: null, | |
| 174 | + }, | |
| 175 | + }); | |
| 176 | + | |
| 177 | + expect(hasOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(true); | |
| 178 | + expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false); | |
| 179 | + }); | |
| 180 | + | |
| 181 | + it("builds a notification key from provider latest versions", () => { | |
| 182 | + const codex = updateCandidate({ | |
| 183 | + driver: driver("codex"), | |
| 184 | + version: "1.0.0", | |
| 185 | + latestVersion: "1.1.0", | |
| 186 | + }); | |
| 187 | + const cursor = updateCandidate({ | |
| 188 | + driver: driver("cursor"), | |
| 189 | + version: "0.2.0", | |
| 190 | + latestVersion: "0.3.0", | |
| 191 | + }); | |
| 192 | + | |
| 193 | + expect(providerUpdateNotificationKey([codex, cursor])).toBe("codex:1.1.0|cursor:0.3.0"); | |
| 194 | + expect(providerUpdateNotificationKey([])).toBeNull(); | |
| 195 | + }); | |
| 196 | + | |
| 197 | + it("keeps the same notification key while the published update version is unchanged", () => { | |
| 198 | + const first = updateCandidate({ | |
| 199 | + driver: driver("codex"), | |
| 200 | + version: "1.0.0", | |
| 201 | + latestVersion: "1.2.0", | |
| 202 | + }); | |
| 203 | + const second = updateCandidate({ | |
| 204 | + driver: driver("codex"), | |
| 205 | + version: "1.1.0", | |
| 206 | + latestVersion: "1.2.0", | |
| 207 | + }); | |
| 208 | + const nextPublishedVersion = updateCandidate({ | |
| 209 | + driver: driver("codex"), | |
| 210 | + version: "1.1.0", | |
| 211 | + latestVersion: "1.3.0", | |
| 212 | + }); | |
| 213 | + | |
| 214 | + expect(providerUpdateNotificationKey([first])).toBe(providerUpdateNotificationKey([second])); | |
| 215 | + expect(providerUpdateNotificationKey([nextPublishedVersion])).not.toBe( | |
| 216 | + providerUpdateNotificationKey([first]), | |
| 217 | + ); | |
| 218 | + }); | |
| 219 | + | |
| 220 | + it("tracks updated provider snapshots by instance instead of collapsing to a sibling driver", () => { | |
| 221 | + const targetInstanceId = instanceId("codex_personal"); | |
| 222 | + const siblingInstanceId = instanceId("codex"); | |
| 223 | + const updatedPersonal = provider({ | |
| 224 | + driver: driver("codex"), | |
| 225 | + instanceId: targetInstanceId, | |
| 226 | + version: "1.1.0", | |
| 227 | + latestVersion: "1.1.0", | |
| 228 | + advisoryStatus: "current", | |
| 229 | + updateState: { | |
| 230 | + status: "succeeded", | |
| 231 | + startedAt: checkedAt, | |
| 232 | + finishedAt: checkedAt, | |
| 233 | + message: "Provider updated.", | |
| 234 | + output: null, | |
| 235 | + }, | |
| 236 | + }); | |
| 237 | + const currentDefaultSibling = provider({ | |
| 238 | + driver: driver("codex"), | |
| 239 | + instanceId: siblingInstanceId, | |
| 240 | + version: "1.1.0", | |
| 241 | + latestVersion: "1.1.0", | |
| 242 | + advisoryStatus: "current", | |
| 243 | + updateState: undefined, | |
| 244 | + }); | |
| 245 | + | |
| 246 | + expect( | |
| 247 | + collectUpdatedProviderSnapshots({ | |
| 248 | + results: [ | |
| 249 | + { | |
| 250 | + status: "fulfilled", | |
| 251 | + value: { | |
| 252 | + providers: [updatedPersonal, currentDefaultSibling], | |
| 253 | + }, | |
| 254 | + }, | |
| 255 | + ], | |
| 256 | + providerInstanceIds: new Set([targetInstanceId]), | |
| 257 | + }), | |
| 258 | + ).toEqual([updatedPersonal]); | |
| 259 | + }); | |
| 260 | + | |
| 261 | + it("describes a single one-click update", () => { | |
| 262 | + const view = getProviderUpdateInitialToastView({ | |
| 263 | + updateProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], | |
| 264 | + oneClickProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], | |
| 265 | + }); | |
| 266 | + | |
| 267 | + expect(view).toMatchObject({ | |
| 268 | + phase: "initial", | |
| 269 | + type: "warning", | |
| 270 | + title: "Update Available: Codex v1.1.0", | |
| 271 | + description: "Install the update now or review provider settings.", | |
| 272 | + }); | |
| 273 | + }); | |
| 274 | + | |
| 275 | + it("describes settings-only updates without one-click support", () => { | |
| 276 | + const view = getProviderUpdateInitialToastView({ | |
| 277 | + updateProviders: [ | |
| 278 | + updateCandidate({ driver: driver("codex"), canUpdate: false }), | |
| 279 | + updateCandidate({ driver: driver("cursor"), canUpdate: false }), | |
| 280 | + ], | |
| 281 | + oneClickProviders: [], | |
| 282 | + }); | |
| 283 | + | |
| 284 | + expect(view.description).toBe("Codex and Cursor can be updated from provider settings."); | |
| 285 | + }); | |
| 286 | + | |
| 287 | + it("uses server update state for running progress", () => { | |
| 288 | + const view = getProviderUpdateProgressToastView({ | |
| 289 | + providers: [ | |
| 290 | + provider({ | |
| 291 | + driver: driver("codex"), | |
| 292 | + updateState: { | |
| 293 | + status: "running", | |
| 294 | + startedAt: checkedAt, | |
| 295 | + finishedAt: null, | |
| 296 | + message: "Updating provider.", | |
| 297 | + output: null, | |
| 298 | + }, | |
| 299 | + }), | |
| 300 | + ], | |
| 301 | + providerCount: 1, | |
| 302 | + }); | |
| 303 | + | |
| 304 | + expect(view).toMatchObject({ | |
| 305 | + phase: "running", | |
| 306 | + type: "loading", | |
| 307 | + title: "Updating provider", | |
| 308 | + }); | |
| 309 | + }); | |
| 310 | + | |
| 311 | + it("uses server failure state for failed progress", () => { | |
| 312 | + const view = getProviderUpdateProgressToastView({ | |
| 313 | + providers: [ | |
| 314 | + provider({ | |
| 315 | + driver: driver("codex"), | |
| 316 | + updateState: { | |
| 317 | + status: "failed", | |
| 318 | + startedAt: checkedAt, | |
| 319 | + finishedAt: checkedAt, | |
| 320 | + message: "command failed", | |
| 321 | + output: "stderr", | |
| 322 | + }, | |
| 323 | + }), | |
| 324 | + ], | |
| 325 | + providerCount: 1, | |
| 326 | + }); | |
| 327 | + | |
| 328 | + expect(view).toMatchObject({ | |
| 329 | + phase: "failed", | |
| 330 | + type: "error", | |
| 331 | + title: "Provider update failed", | |
| 332 | + description: "command failed", | |
| 333 | + }); | |
| 334 | + }); | |
| 335 | + | |
| 336 | + it("resolves a single-provider completion view from the returned provider snapshot", () => { | |
| 337 | + const view = getSingleProviderUpdateProgressToastView( | |
| 338 | + provider({ | |
| 339 | + driver: driver("codex"), | |
| 340 | + updateState: { | |
| 341 | + status: "failed", | |
| 342 | + startedAt: checkedAt, | |
| 343 | + finishedAt: checkedAt, | |
| 344 | + message: "command failed", | |
| 345 | + output: "stderr", | |
| 346 | + }, | |
| 347 | + }), | |
| 348 | + ); | |
| 349 | + | |
| 350 | + expect(view).toMatchObject({ | |
| 351 | + phase: "failed", | |
| 352 | + type: "error", | |
| 353 | + title: "Codex v1.1.0 update failed", | |
| 354 | + description: "command failed", | |
| 355 | + }); | |
| 356 | + }); | |
| 357 | + | |
| 358 | + it("keeps unchanged providers actionable from settings", () => { | |
| 359 | + const view = getProviderUpdateProgressToastView({ | |
| 360 | + providers: [ | |
| 361 | + provider({ | |
| 362 | + driver: driver("cursor"), | |
| 363 | + updateState: { | |
| 364 | + status: "unchanged", | |
| 365 | + startedAt: checkedAt, | |
| 366 | + finishedAt: checkedAt, | |
| 367 | + message: "still old", | |
| 368 | + output: null, | |
| 369 | + }, | |
| 370 | + }), | |
| 371 | + ], | |
| 372 | + providerCount: 1, | |
| 373 | + }); | |
| 374 | + | |
| 375 | + expect(view).toMatchObject({ | |
| 376 | + phase: "unchanged", | |
| 377 | + type: "warning", | |
| 378 | + title: "Provider still needs an update", | |
| 379 | + description: "Cursor still appears outdated. Check provider settings for details.", | |
| 380 | + }); | |
| 381 | + }); | |
| 382 | + | |
| 383 | + it("marks progress succeeded once every attempted provider is no longer outdated", () => { | |
| 384 | + const view = getProviderUpdateProgressToastView({ | |
| 385 | + providers: [ | |
| 386 | + provider({ | |
| 387 | + driver: driver("codex"), | |
| 388 | + version: "1.1.0", | |
| 389 | + latestVersion: "1.1.0", | |
| 390 | + advisoryStatus: "current", | |
| 391 | + updateState: { | |
| 392 | + status: "succeeded", | |
| 393 | + startedAt: checkedAt, | |
| 394 | + finishedAt: checkedAt, | |
| 395 | + message: "Provider updated.", | |
| 396 | + output: null, | |
| 397 | + }, | |
| 398 | + }), | |
| 399 | + ], | |
| 400 | + providerCount: 1, | |
| 401 | + }); | |
| 402 | + | |
| 403 | + expect(view).toMatchObject({ | |
| 404 | + phase: "succeeded", | |
| 405 | + type: "success", | |
| 406 | + title: "Provider updated", | |
| 407 | + description: "New sessions will use the updated provider.", | |
| 408 | + dismissAfterVisibleMs: 3_000, | |
| 409 | + }); | |
| 410 | + }); | |
| 411 | + | |
| 412 | + it("uses the updated version in the single-provider success toast title", () => { | |
| 413 | + const view = getSingleProviderUpdateProgressToastView( | |
| 414 | + provider({ | |
| 415 | + driver: driver("codex"), | |
| 416 | + version: "1.1.0", | |
| 417 | + latestVersion: "1.1.0", | |
| 418 | + advisoryStatus: "current", | |
| 419 | + updateState: { | |
| 420 | + status: "succeeded", | |
| 421 | + startedAt: checkedAt, | |
| 422 | + finishedAt: checkedAt, | |
| 423 | + message: "Provider updated.", | |
| 424 | + output: null, | |
| 425 | + }, | |
| 426 | + }), | |
| 427 | + ); | |
| 428 | + | |
| 429 | + expect(view).toMatchObject({ | |
| 430 | + phase: "succeeded", | |
| 431 | + type: "success", | |
| 432 | + title: "Codex updated: v1.1.0", | |
| 433 | + description: "New sessions will use the updated provider.", | |
| 434 | + }); | |
| 435 | + }); | |
| 436 | + | |
| 437 | + it("falls back to a rejected RPC message for transport-level failures", () => { | |
| 438 | + const results: PromiseSettledResult<unknown>[] = [ | |
| 439 | + { status: "rejected", reason: new Error("WebSocket closed") }, | |
| 440 | + ]; | |
| 441 | + | |
| 442 | + expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed"); | |
| 443 | + expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({ | |
| 444 | + phase: "failed", | |
| 445 | + title: "Provider updates failed", | |
| 446 | + description: "WebSocket closed", | |
| 447 | + }); | |
| 448 | + }); | |
| 449 | + | |
| 450 | + it("collects only attempted provider snapshots from update responses", () => { | |
| 451 | + const codex = provider({ driver: driver("codex") }); | |
| 452 | + const cursor = provider({ driver: driver("cursor") }); | |
| 453 | + const results: PromiseSettledResult<{ readonly providers: ReadonlyArray<ServerProvider> }>[] = [ | |
| 454 | + { status: "fulfilled", value: { providers: [codex, cursor] } }, | |
| 455 | + ]; | |
| 456 | + | |
| 457 | + expect( | |
| 458 | + collectUpdatedProviderSnapshots({ | |
| 459 | + results, | |
| 460 | + providerInstanceIds: new Set([cursor.instanceId]), | |
| 461 | + }), | |
| 462 | + ).toEqual([cursor]); | |
| 463 | + }); | |
| 464 | + | |
| 465 | + it("summarizes active provider updates for the sidebar pill", () => { | |
| 466 | + const view = getProviderUpdateSidebarPillView([ | |
| 467 | + provider({ | |
| 468 | + driver: driver("codex"), | |
| 469 | + updateState: { | |
| 470 | + status: "running", | |
| 471 | + startedAt: checkedAt, | |
| 472 | + finishedAt: null, | |
| 473 | + message: "Updating provider.", | |
| 474 | + output: null, | |
| 475 | + }, | |
| 476 | + }), | |
| 477 | + provider({ | |
| 478 | + driver: driver("cursor"), | |
| 479 | + updateState: { | |
| 480 | + status: "queued", | |
| 481 | + startedAt: null, | |
| 482 | + finishedAt: null, | |
| 483 | + message: "Waiting for another provider update to finish.", | |
| 484 | + output: null, | |
| 485 | + }, | |
| 486 | + }), | |
| 487 | + ]); | |
| 488 | + | |
| 489 | + expect(view).toMatchObject({ | |
| 490 | + tone: "loading", | |
| 491 | + title: "Updating 2 providers", | |
| 492 | + description: "Codex and Cursor updates are in progress.", | |
| 493 | + }); | |
| 494 | + }); | |
| 495 | + | |
| 496 | + it("uses the provider name for single active sidebar pill updates", () => { | |
| 497 | + const view = getProviderUpdateSidebarPillView([ | |
| 498 | + provider({ | |
| 499 | + driver: driver("codex"), | |
| 500 | + updateState: { | |
| 501 | + status: "running", | |
| 502 | + startedAt: checkedAt, | |
| 503 | + finishedAt: null, | |
| 504 | + message: "Updating provider.", | |
| 505 | + output: null, | |
| 506 | + }, | |
| 507 | + }), | |
| 508 | + ]); | |
| 509 | + | |
| 510 | + expect(view).toMatchObject({ | |
| 511 | + key: "loading:codex:running", | |
| 512 | + tone: "loading", | |
| 513 | + title: "Updating Codex", | |
| 514 | + description: "Codex update in progress.", | |
| 515 | + }); | |
| 516 | + }); | |
| 517 | + | |
| 518 | + it("uses the provider name for single failed sidebar pill updates", () => { | |
| 519 | + const view = getProviderUpdateSidebarPillView( | |
| 520 | + [ | |
| 521 | + provider({ | |
| 522 | + driver: driver("claudeAgent"), | |
| 523 | + updateState: { | |
| 524 | + status: "failed", | |
| 525 | + startedAt: checkedAt, | |
| 526 | + finishedAt: checkedAt, | |
| 527 | + message: "Update command exited with code 1.", | |
| 528 | + output: null, | |
| 529 | + }, | |
| 530 | + }), | |
| 531 | + ], | |
| 532 | + { visibleAfterIso: sessionStartedAt }, | |
| 533 | + ); | |
| 534 | + | |
| 535 | + expect(view).toMatchObject({ | |
| 536 | + key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", | |
| 537 | + tone: "error", | |
| 538 | + title: "Claude v1.1.0 update failed", | |
| 539 | + description: "Update command exited with code 1.", | |
| 540 | + dismissible: true, | |
| 541 | + }); | |
| 542 | + }); | |
| 543 | + | |
| 544 | + it("shows a short-lived success sidebar pill after a single provider update succeeds", () => { | |
| 545 | + const view = getProviderUpdateSidebarPillView( | |
| 546 | + [ | |
| 547 | + provider({ | |
| 548 | + driver: driver("codex"), | |
| 549 | + version: "1.1.0", | |
| 550 | + latestVersion: "1.1.0", | |
| 551 | + advisoryStatus: "current", | |
| 552 | + updateState: { | |
| 553 | + status: "succeeded", | |
| 554 | + startedAt: checkedAt, | |
| 555 | + finishedAt: checkedAt, | |
| 556 | + message: "Provider updated.", | |
| 557 | + output: null, | |
| 558 | + }, | |
| 559 | + }), | |
| 560 | + ], | |
| 561 | + { visibleAfterIso: sessionStartedAt }, | |
| 562 | + ); | |
| 563 | + | |
| 564 | + expect(view).toMatchObject({ | |
| 565 | + key: "succeeded:codex:2026-04-23T10:00:00.000Z:Provider updated.", | |
| 566 | + tone: "success", | |
| 567 | + title: "Codex updated: v1.1.0", | |
| 568 | + description: "New sessions will use the updated provider.", | |
| 569 | + dismissAfterVisibleMs: 3_000, | |
| 570 | + }); | |
| 571 | + }); | |
| 572 | + | |
| 573 | + it("keeps unchanged sidebar pill states dismissible", () => { | |
| 574 | + const view = getProviderUpdateSidebarPillView( | |
| 575 | + [ | |
| 576 | + provider({ | |
| 577 | + driver: driver("cursor"), | |
| 578 | + updateState: { | |
| 579 | + status: "unchanged", | |
| 580 | + startedAt: checkedAt, | |
| 581 | + finishedAt: checkedAt, | |
| 582 | + message: "still old", | |
| 583 | + output: null, | |
| 584 | + }, | |
| 585 | + }), | |
| 586 | + ], | |
| 587 | + { visibleAfterIso: sessionStartedAt }, | |
| 588 | + ); | |
| 589 | + | |
| 590 | + expect(view).toMatchObject({ | |
| 591 | + key: "unchanged:cursor:2026-04-23T10:00:00.000Z:still old", | |
| 592 | + tone: "warning", | |
| 593 | + title: "Cursor still needs an update", | |
| 594 | + dismissible: true, | |
| 595 | + }); | |
| 596 | + }); | |
| 597 | + | |
| 598 | + it("does not show sidebar terminal states from before the current app session", () => { | |
| 599 | + expect( | |
| 600 | + getProviderUpdateSidebarPillView( | |
| 601 | + [ | |
| 602 | + provider({ | |
| 603 | + driver: driver("codex"), | |
| 604 | + updateState: { | |
| 605 | + status: "failed", | |
| 606 | + startedAt: checkedAt, | |
| 607 | + finishedAt: checkedAt, | |
| 608 | + message: "command failed", | |
| 609 | + output: "stderr", | |
| 610 | + }, | |
| 611 | + }), | |
| 612 | + ], | |
| 613 | + { visibleAfterIso: "2026-04-23T10:00:01.000Z" }, | |
| 614 | + ), | |
| 615 | + ).toBeNull(); | |
| 616 | + }); | |
| 617 | + | |
| 618 | + it("shows a newer success before falling back to an older failure", () => { | |
| 619 | + const providers = [ | |
| 620 | + provider({ | |
| 621 | + driver: driver("claudeAgent"), | |
| 622 | + updateState: { | |
| 623 | + status: "failed", | |
| 624 | + startedAt: checkedAt, | |
| 625 | + finishedAt: checkedAt, | |
| 626 | + message: "Update command exited with code 1.", | |
| 627 | + output: null, | |
| 628 | + }, | |
| 629 | + }), | |
| 630 | + provider({ | |
| 631 | + driver: driver("codex"), | |
| 632 | + version: "1.2.0", | |
| 633 | + latestVersion: "1.2.0", | |
| 634 | + advisoryStatus: "current", | |
| 635 | + updateState: { | |
| 636 | + status: "succeeded", | |
| 637 | + startedAt: laterCheckedAt, | |
| 638 | + finishedAt: laterCheckedAt, | |
| 639 | + message: "Provider updated.", | |
| 640 | + output: null, | |
| 641 | + }, | |
| 642 | + }), | |
| 643 | + ] satisfies ReadonlyArray<ServerProvider>; | |
| 644 | + | |
| 645 | + const successView = getProviderUpdateSidebarPillView(providers, { | |
| 646 | + visibleAfterIso: sessionStartedAt, | |
| 647 | + }); | |
| 648 | + expect(successView).toMatchObject({ | |
| 649 | + key: "succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated.", | |
| 650 | + tone: "success", | |
| 651 | + title: "Codex updated: v1.2.0", | |
| 652 | + }); | |
| 653 | + | |
| 654 | + const failureView = getProviderUpdateSidebarPillView(providers, { | |
| 655 | + visibleAfterIso: sessionStartedAt, | |
| 656 | + dismissedKeys: new Set(["succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated."]), | |
| 657 | + }); | |
| 658 | + expect(failureView).toMatchObject({ | |
| 659 | + key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", | |
| 660 | + tone: "error", | |
| 661 | + title: "Claude v1.1.0 update failed", | |
| 662 | + }); | |
| 663 | + }); | |
| 664 | + | |
| 665 | + it("does not show a sidebar pill for passive update availability", () => { | |
| 666 | + expect( | |
| 667 | + getProviderUpdateSidebarPillView([ | |
| 668 | + provider({ driver: driver("codex"), canUpdate: true }), | |
| 669 | + provider({ driver: driver("cursor"), canUpdate: false }), | |
| 670 | + ]), | |
| 671 | + ).toBeNull(); | |
| 672 | + }); | |
| 673 | +}); | |
| @@ -0,0 +1,537 @@ | ||
| 1 | +import { | |
| 2 | + defaultInstanceIdForDriver, | |
| 3 | + PROVIDER_DISPLAY_NAMES, | |
| 4 | + type ProviderDriverKind, | |
| 5 | + type ProviderInstanceId, | |
| 6 | + type ServerProvider, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | + | |
| 9 | +export type ProviderUpdateCandidate = ServerProvider & { | |
| 10 | + readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & { | |
| 11 | + readonly status: "behind_latest"; | |
| 12 | + readonly latestVersion: string; | |
| 13 | + }; | |
| 14 | +}; | |
| 15 | + | |
| 16 | +export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success"; | |
| 17 | +export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded"; | |
| 18 | + | |
| 19 | +export interface ProviderUpdateToastView { | |
| 20 | + readonly phase: ProviderUpdateToastPhase; | |
| 21 | + readonly type: ProviderUpdateToastType; | |
| 22 | + readonly title: string; | |
| 23 | + readonly description: string; | |
| 24 | + readonly dismissAfterVisibleMs?: number; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success"; | |
| 28 | + | |
| 29 | +export interface ProviderUpdateSidebarPillView { | |
| 30 | + readonly key: string; | |
| 31 | + readonly tone: ProviderUpdateSidebarPillTone; | |
| 32 | + readonly title: string; | |
| 33 | + readonly description: string; | |
| 34 | + readonly dismissible?: boolean; | |
| 35 | + readonly dismissAfterVisibleMs?: number; | |
| 36 | +} | |
| 37 | + | |
| 38 | +interface ProviderUpdateSidebarPillOptions { | |
| 39 | + readonly visibleAfterIso?: string; | |
| 40 | + readonly dismissedKeys?: ReadonlySet<string>; | |
| 41 | +} | |
| 42 | + | |
| 43 | +const PROVIDER_UPDATE_SUCCESS_VISIBLE_MS = 3_000; | |
| 44 | + | |
| 45 | +function formatVersion(value: string): string { | |
| 46 | + return value.startsWith("v") ? value : `v${value}`; | |
| 47 | +} | |
| 48 | + | |
| 49 | +function chooseRepresentativeProvider( | |
| 50 | + current: ServerProvider | undefined, | |
| 51 | + candidate: ServerProvider, | |
| 52 | +): ServerProvider { | |
| 53 | + if (!current) { | |
| 54 | + return candidate; | |
| 55 | + } | |
| 56 | + const defaultInstanceId = defaultInstanceIdForDriver(candidate.driver); | |
| 57 | + if (candidate.instanceId === defaultInstanceId) { | |
| 58 | + return candidate; | |
| 59 | + } | |
| 60 | + if (current.instanceId === defaultInstanceId) { | |
| 61 | + return current; | |
| 62 | + } | |
| 63 | + return candidate.checkedAt.localeCompare(current.checkedAt) >= 0 ? candidate : current; | |
| 64 | +} | |
| 65 | + | |
| 66 | +function dedupeProvidersByDriver<T extends ServerProvider>(providers: ReadonlyArray<T>): T[] { | |
| 67 | + const latestProviderByDriver = new Map<ProviderDriverKind, T>(); | |
| 68 | + | |
| 69 | + for (const provider of providers) { | |
| 70 | + latestProviderByDriver.set( | |
| 71 | + provider.driver, | |
| 72 | + chooseRepresentativeProvider(latestProviderByDriver.get(provider.driver), provider) as T, | |
| 73 | + ); | |
| 74 | + } | |
| 75 | + | |
| 76 | + return [...latestProviderByDriver.values()]; | |
| 77 | +} | |
| 78 | + | |
| 79 | +function dedupeProvidersByInstanceId<T extends ServerProvider>(providers: ReadonlyArray<T>): T[] { | |
| 80 | + const latestProviderByInstanceId = new Map<ProviderInstanceId, T>(); | |
| 81 | + | |
| 82 | + for (const provider of providers) { | |
| 83 | + const current = latestProviderByInstanceId.get(provider.instanceId); | |
| 84 | + if (!current || provider.checkedAt.localeCompare(current.checkedAt) >= 0) { | |
| 85 | + latestProviderByInstanceId.set(provider.instanceId, provider); | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + return [...latestProviderByInstanceId.values()]; | |
| 90 | +} | |
| 91 | + | |
| 92 | +function getProviderUpdatedTitle(provider: Pick<ServerProvider, "driver" | "version">): string { | |
| 93 | + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; | |
| 94 | + return provider.version | |
| 95 | + ? `${providerName} updated: ${formatVersion(provider.version)}` | |
| 96 | + : `${providerName} updated`; | |
| 97 | +} | |
| 98 | + | |
| 99 | +function getProviderUpdatedDescription(providerCount: number): string { | |
| 100 | + return providerCount === 1 | |
| 101 | + ? "New sessions will use the updated provider." | |
| 102 | + : "New sessions will use the updated providers."; | |
| 103 | +} | |
| 104 | + | |
| 105 | +function getProviderFailedUpdateTitle( | |
| 106 | + provider: Pick<ServerProvider, "driver" | "versionAdvisory">, | |
| 107 | +): string { | |
| 108 | + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; | |
| 109 | + const attemptedVersion = provider.versionAdvisory?.latestVersion; | |
| 110 | + return attemptedVersion | |
| 111 | + ? `${providerName} ${formatVersion(attemptedVersion)} update failed` | |
| 112 | + : `${providerName} update failed`; | |
| 113 | +} | |
| 114 | + | |
| 115 | +export function isProviderUpdateCandidate( | |
| 116 | + provider: ServerProvider, | |
| 117 | +): provider is ProviderUpdateCandidate { | |
| 118 | + return ( | |
| 119 | + provider.enabled && | |
| 120 | + provider.versionAdvisory?.status === "behind_latest" && | |
| 121 | + provider.versionAdvisory.latestVersion !== null | |
| 122 | + ); | |
| 123 | +} | |
| 124 | + | |
| 125 | +export function isProviderUpdateActive(provider: Pick<ServerProvider, "updateState">): boolean { | |
| 126 | + return provider.updateState?.status === "queued" || provider.updateState?.status === "running"; | |
| 127 | +} | |
| 128 | + | |
| 129 | +export function collectProviderUpdateCandidates( | |
| 130 | + providers: ReadonlyArray<ServerProvider>, | |
| 131 | +): ProviderUpdateCandidate[] { | |
| 132 | + return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate)); | |
| 133 | +} | |
| 134 | + | |
| 135 | +export function hasOneClickUpdateProviderCandidate( | |
| 136 | + candidate: ProviderUpdateCandidate, | |
| 137 | + providers: ReadonlyArray<ServerProvider>, | |
| 138 | +): boolean { | |
| 139 | + if ( | |
| 140 | + candidate.versionAdvisory.canUpdate !== true || | |
| 141 | + candidate.versionAdvisory.updateCommand === null | |
| 142 | + ) { | |
| 143 | + return false; | |
| 144 | + } | |
| 145 | + | |
| 146 | + const driverProviders = providers.filter((provider) => provider.driver === candidate.driver); | |
| 147 | + if (driverProviders.length === 0) { | |
| 148 | + return false; | |
| 149 | + } | |
| 150 | + | |
| 151 | + const updateCommands = new Set<string>(); | |
| 152 | + for (const provider of driverProviders) { | |
| 153 | + if (!isProviderUpdateCandidate(provider)) { | |
| 154 | + continue; | |
| 155 | + } | |
| 156 | + const advisory = provider.versionAdvisory; | |
| 157 | + if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) { | |
| 158 | + return false; | |
| 159 | + } | |
| 160 | + updateCommands.add(advisory.updateCommand); | |
| 161 | + } | |
| 162 | + | |
| 163 | + return updateCommands.size === 1; | |
| 164 | +} | |
| 165 | + | |
| 166 | +export function canOneClickUpdateProviderCandidate( | |
| 167 | + candidate: ProviderUpdateCandidate, | |
| 168 | + providers: ReadonlyArray<ServerProvider>, | |
| 169 | +): boolean { | |
| 170 | + return ( | |
| 171 | + !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers) | |
| 172 | + ); | |
| 173 | +} | |
| 174 | + | |
| 175 | +export function providerUpdateNotificationKey( | |
| 176 | + providers: ReadonlyArray<ProviderUpdateCandidate>, | |
| 177 | +): string | null { | |
| 178 | + const parts = dedupeProvidersByDriver(providers) | |
| 179 | + .map((provider) => { | |
| 180 | + const advisory = provider.versionAdvisory; | |
| 181 | + return [provider.driver, advisory.latestVersion].join(":"); | |
| 182 | + }) | |
| 183 | + .toSorted(); | |
| 184 | + | |
| 185 | + return parts.length > 0 ? parts.join("|") : null; | |
| 186 | +} | |
| 187 | + | |
| 188 | +export function providerUpdateCandidateKey(provider: ProviderUpdateCandidate): string { | |
| 189 | + return providerUpdateNotificationKey([provider])!; | |
| 190 | +} | |
| 191 | + | |
| 192 | +export function formatProviderList(providers: ReadonlyArray<Pick<ServerProvider, "driver">>) { | |
| 193 | + const names = providers.map( | |
| 194 | + (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver, | |
| 195 | + ); | |
| 196 | + if (names.length <= 2) { | |
| 197 | + return names.join(" and "); | |
| 198 | + } | |
| 199 | + return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; | |
| 200 | +} | |
| 201 | + | |
| 202 | +export function getProviderUpdateInitialToastView(input: { | |
| 203 | + readonly updateProviders: ReadonlyArray<ProviderUpdateCandidate>; | |
| 204 | + readonly oneClickProviders: ReadonlyArray<ProviderUpdateCandidate>; | |
| 205 | +}): ProviderUpdateToastView { | |
| 206 | + return { | |
| 207 | + phase: "initial", | |
| 208 | + type: "warning", | |
| 209 | + title: getProviderUpdateInitialToastTitle(input.updateProviders), | |
| 210 | + description: | |
| 211 | + input.oneClickProviders.length > 0 | |
| 212 | + ? "Install the update now or review provider settings." | |
| 213 | + : `${formatProviderList(input.updateProviders)} can be updated from provider settings.`, | |
| 214 | + }; | |
| 215 | +} | |
| 216 | + | |
| 217 | +export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { | |
| 218 | + return { | |
| 219 | + phase: "running", | |
| 220 | + type: "loading", | |
| 221 | + title: providerCount === 1 ? "Updating provider" : "Updating providers", | |
| 222 | + description: "Running provider update command.", | |
| 223 | + }; | |
| 224 | +} | |
| 225 | + | |
| 226 | +export function getProviderUpdateRejectedToastView( | |
| 227 | + providerCount: number, | |
| 228 | + message: string, | |
| 229 | +): ProviderUpdateToastView { | |
| 230 | + return { | |
| 231 | + phase: "failed", | |
| 232 | + type: "error", | |
| 233 | + title: providerCount === 1 ? "Provider update failed" : "Provider updates failed", | |
| 234 | + description: message, | |
| 235 | + }; | |
| 236 | +} | |
| 237 | + | |
| 238 | +export function getProviderUpdateProgressToastView(input: { | |
| 239 | + readonly providers: ReadonlyArray<ServerProvider>; | |
| 240 | + readonly providerCount: number; | |
| 241 | +}): ProviderUpdateToastView { | |
| 242 | + const providers = dedupeProvidersByDriver(input.providers); | |
| 243 | + const failedProviders = providers.filter((provider) => provider.updateState?.status === "failed"); | |
| 244 | + if (failedProviders.length > 0) { | |
| 245 | + return { | |
| 246 | + phase: "failed", | |
| 247 | + type: "error", | |
| 248 | + title: failedProviders.length === 1 ? "Provider update failed" : "Provider updates failed", | |
| 249 | + description: getFailedProviderUpdateDescription(failedProviders), | |
| 250 | + }; | |
| 251 | + } | |
| 252 | + | |
| 253 | + const unchangedProviders = providers.filter( | |
| 254 | + (provider) => provider.updateState?.status === "unchanged", | |
| 255 | + ); | |
| 256 | + if (unchangedProviders.length > 0) { | |
| 257 | + return { | |
| 258 | + phase: "unchanged", | |
| 259 | + type: "warning", | |
| 260 | + title: | |
| 261 | + unchangedProviders.length === 1 | |
| 262 | + ? "Provider still needs an update" | |
| 263 | + : "Providers still need updates", | |
| 264 | + description: `${formatProviderList(unchangedProviders)} ${ | |
| 265 | + unchangedProviders.length === 1 ? "still appears" : "still appear" | |
| 266 | + } outdated. Check provider settings for details.`, | |
| 267 | + }; | |
| 268 | + } | |
| 269 | + | |
| 270 | + if (providers.some(isProviderUpdateActive)) { | |
| 271 | + return getProviderUpdateRunningToastView(input.providerCount); | |
| 272 | + } | |
| 273 | + | |
| 274 | + const hasCompleteProviderSnapshots = providers.length >= input.providerCount; | |
| 275 | + const allProvidersUpdated = | |
| 276 | + hasCompleteProviderSnapshots && | |
| 277 | + providers.every( | |
| 278 | + (provider) => | |
| 279 | + provider.updateState?.status === "succeeded" || !isProviderUpdateCandidate(provider), | |
| 280 | + ); | |
| 281 | + if (allProvidersUpdated) { | |
| 282 | + return { | |
| 283 | + phase: "succeeded", | |
| 284 | + type: "success", | |
| 285 | + title: input.providerCount === 1 ? "Provider updated" : "Provider updates finished", | |
| 286 | + description: getProviderUpdatedDescription(input.providerCount), | |
| 287 | + dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, | |
| 288 | + }; | |
| 289 | + } | |
| 290 | + | |
| 291 | + return getProviderUpdateRunningToastView(input.providerCount); | |
| 292 | +} | |
| 293 | + | |
| 294 | +export function getSingleProviderUpdateProgressToastView( | |
| 295 | + provider: ServerProvider, | |
| 296 | +): ProviderUpdateToastView { | |
| 297 | + const view = getProviderUpdateProgressToastView({ | |
| 298 | + providers: [provider], | |
| 299 | + providerCount: 1, | |
| 300 | + }); | |
| 301 | + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; | |
| 302 | + | |
| 303 | + switch (view.phase) { | |
| 304 | + case "running": | |
| 305 | + return { | |
| 306 | + ...view, | |
| 307 | + title: `Updating ${providerName}`, | |
| 308 | + }; | |
| 309 | + case "failed": | |
| 310 | + return { | |
| 311 | + ...view, | |
| 312 | + title: getProviderFailedUpdateTitle(provider), | |
| 313 | + }; | |
| 314 | + case "unchanged": | |
| 315 | + return { | |
| 316 | + ...view, | |
| 317 | + title: `${providerName} still needs an update`, | |
| 318 | + }; | |
| 319 | + case "succeeded": | |
| 320 | + return { | |
| 321 | + ...view, | |
| 322 | + title: getProviderUpdatedTitle(provider), | |
| 323 | + }; | |
| 324 | + default: | |
| 325 | + return view; | |
| 326 | + } | |
| 327 | +} | |
| 328 | + | |
| 329 | +export function collectUpdatedProviderSnapshots(input: { | |
| 330 | + readonly results: ReadonlyArray< | |
| 331 | + PromiseSettledResult<{ readonly providers: ReadonlyArray<ServerProvider> }> | |
| 332 | + >; | |
| 333 | + readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>; | |
| 334 | +}): ServerProvider[] { | |
| 335 | + const matchedProviders: ServerProvider[] = []; | |
| 336 | + | |
| 337 | + for (const result of input.results) { | |
| 338 | + if (result.status !== "fulfilled") { | |
| 339 | + continue; | |
| 340 | + } | |
| 341 | + for (const provider of result.value.providers) { | |
| 342 | + if (input.providerInstanceIds.has(provider.instanceId)) { | |
| 343 | + matchedProviders.push(provider); | |
| 344 | + } | |
| 345 | + } | |
| 346 | + } | |
| 347 | + | |
| 348 | + return dedupeProvidersByInstanceId(matchedProviders); | |
| 349 | +} | |
| 350 | + | |
| 351 | +export function firstRejectedProviderUpdateMessage( | |
| 352 | + results: ReadonlyArray<PromiseSettledResult<unknown>>, | |
| 353 | +): string | null { | |
| 354 | + const rejected = results.find((result) => result.status === "rejected"); | |
| 355 | + if (!rejected) { | |
| 356 | + return null; | |
| 357 | + } | |
| 358 | + return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed."; | |
| 359 | +} | |
| 360 | + | |
| 361 | +function getUpdateFinishedAt(provider: ServerProvider): string | null { | |
| 362 | + return provider.updateState?.finishedAt ?? null; | |
| 363 | +} | |
| 364 | + | |
| 365 | +function isRecentTerminalProvider( | |
| 366 | + provider: ServerProvider, | |
| 367 | + visibleAfterIso: string | undefined, | |
| 368 | +): boolean { | |
| 369 | + const status = provider.updateState?.status; | |
| 370 | + if (status !== "failed" && status !== "unchanged" && status !== "succeeded") { | |
| 371 | + return false; | |
| 372 | + } | |
| 373 | + if (visibleAfterIso === undefined) { | |
| 374 | + return true; | |
| 375 | + } | |
| 376 | + const finishedAt = getUpdateFinishedAt(provider); | |
| 377 | + return finishedAt !== null && finishedAt >= visibleAfterIso; | |
| 378 | +} | |
| 379 | + | |
| 380 | +function latestFinishedAtForProviders(providers: ReadonlyArray<ServerProvider>): string | null { | |
| 381 | + return providers.reduce<string | null>((latest, provider) => { | |
| 382 | + const finishedAt = getUpdateFinishedAt(provider); | |
| 383 | + if (finishedAt === null) { | |
| 384 | + return latest; | |
| 385 | + } | |
| 386 | + return latest === null || finishedAt > latest ? finishedAt : latest; | |
| 387 | + }, null); | |
| 388 | +} | |
| 389 | + | |
| 390 | +export function getProviderUpdateSidebarPillView( | |
| 391 | + providers: ReadonlyArray<ServerProvider>, | |
| 392 | + options?: ProviderUpdateSidebarPillOptions, | |
| 393 | +): ProviderUpdateSidebarPillView | null { | |
| 394 | + const dedupedProviders = dedupeProvidersByDriver(providers); | |
| 395 | + const activeProviders = dedupedProviders.filter(isProviderUpdateActive); | |
| 396 | + if (activeProviders.length > 0) { | |
| 397 | + const activeProvider = activeProviders[0]!; | |
| 398 | + const activeProviderName = | |
| 399 | + PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver; | |
| 400 | + return { | |
| 401 | + key: `loading:${activeProviders | |
| 402 | + .map((provider) => `${provider.driver}:${provider.updateState?.status ?? "idle"}`) | |
| 403 | + .toSorted() | |
| 404 | + .join("|")}`, | |
| 405 | + tone: "loading", | |
| 406 | + title: | |
| 407 | + activeProviders.length === 1 | |
| 408 | + ? `Updating ${activeProviderName}` | |
| 409 | + : `Updating ${activeProviders.length} providers`, | |
| 410 | + description: | |
| 411 | + activeProviders.length === 1 | |
| 412 | + ? `${formatProviderList(activeProviders)} update in progress.` | |
| 413 | + : `${formatProviderList(activeProviders)} updates are in progress.`, | |
| 414 | + }; | |
| 415 | + } | |
| 416 | + | |
| 417 | + const recentTerminalProviders = dedupedProviders.filter((provider) => | |
| 418 | + isRecentTerminalProvider(provider, options?.visibleAfterIso), | |
| 419 | + ); | |
| 420 | + const terminalCandidates: ProviderUpdateSidebarPillView[] = []; | |
| 421 | + | |
| 422 | + const failedProviders = recentTerminalProviders.filter( | |
| 423 | + (provider) => provider.updateState?.status === "failed", | |
| 424 | + ); | |
| 425 | + if (failedProviders.length > 0) { | |
| 426 | + const failedProvider = failedProviders[0]!; | |
| 427 | + terminalCandidates.push({ | |
| 428 | + key: `failed:${failedProviders | |
| 429 | + .map( | |
| 430 | + (provider) => | |
| 431 | + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, | |
| 432 | + ) | |
| 433 | + .toSorted() | |
| 434 | + .join("|")}`, | |
| 435 | + tone: "error", | |
| 436 | + title: | |
| 437 | + failedProviders.length === 1 | |
| 438 | + ? getProviderFailedUpdateTitle(failedProvider) | |
| 439 | + : `${failedProviders.length} provider updates failed`, | |
| 440 | + description: getFailedProviderUpdateDescription(failedProviders), | |
| 441 | + dismissible: true, | |
| 442 | + }); | |
| 443 | + } | |
| 444 | + | |
| 445 | + const unchangedProviders = recentTerminalProviders.filter( | |
| 446 | + (provider) => provider.updateState?.status === "unchanged", | |
| 447 | + ); | |
| 448 | + if (unchangedProviders.length > 0) { | |
| 449 | + const unchangedProvider = unchangedProviders[0]!; | |
| 450 | + const unchangedProviderName = | |
| 451 | + PROVIDER_DISPLAY_NAMES[unchangedProvider.driver] ?? unchangedProvider.driver; | |
| 452 | + terminalCandidates.push({ | |
| 453 | + key: `unchanged:${unchangedProviders | |
| 454 | + .map( | |
| 455 | + (provider) => | |
| 456 | + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, | |
| 457 | + ) | |
| 458 | + .toSorted() | |
| 459 | + .join("|")}`, | |
| 460 | + tone: "warning", | |
| 461 | + title: | |
| 462 | + unchangedProviders.length === 1 | |
| 463 | + ? `${unchangedProviderName} still needs an update` | |
| 464 | + : `${unchangedProviders.length} providers still need updates`, | |
| 465 | + description: `${formatProviderList(unchangedProviders)} ${ | |
| 466 | + unchangedProviders.length === 1 ? "still appears" : "still appear" | |
| 467 | + } outdated. Review provider settings for details.`, | |
| 468 | + dismissible: true, | |
| 469 | + }); | |
| 470 | + } | |
| 471 | + | |
| 472 | + const succeededProviders = recentTerminalProviders.filter( | |
| 473 | + (provider) => provider.updateState?.status === "succeeded", | |
| 474 | + ); | |
| 475 | + if (succeededProviders.length > 0) { | |
| 476 | + const succeededProvider = succeededProviders[0]!; | |
| 477 | + terminalCandidates.push({ | |
| 478 | + key: `succeeded:${succeededProviders | |
| 479 | + .map( | |
| 480 | + (provider) => | |
| 481 | + `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`, | |
| 482 | + ) | |
| 483 | + .toSorted() | |
| 484 | + .join("|")}`, | |
| 485 | + tone: "success", | |
| 486 | + title: | |
| 487 | + succeededProviders.length === 1 | |
| 488 | + ? getProviderUpdatedTitle(succeededProvider) | |
| 489 | + : `${succeededProviders.length} providers updated`, | |
| 490 | + description: getProviderUpdatedDescription(succeededProviders.length), | |
| 491 | + dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, | |
| 492 | + }); | |
| 493 | + } | |
| 494 | + | |
| 495 | + return ( | |
| 496 | + terminalCandidates | |
| 497 | + .toSorted((left, right) => { | |
| 498 | + const leftProviders = | |
| 499 | + left.tone === "error" | |
| 500 | + ? failedProviders | |
| 501 | + : left.tone === "warning" | |
| 502 | + ? unchangedProviders | |
| 503 | + : succeededProviders; | |
| 504 | + const rightProviders = | |
| 505 | + right.tone === "error" | |
| 506 | + ? failedProviders | |
| 507 | + : right.tone === "warning" | |
| 508 | + ? unchangedProviders | |
| 509 | + : succeededProviders; | |
| 510 | + const leftFinishedAt = latestFinishedAtForProviders(leftProviders) ?? ""; | |
| 511 | + const rightFinishedAt = latestFinishedAtForProviders(rightProviders) ?? ""; | |
| 512 | + return rightFinishedAt.localeCompare(leftFinishedAt); | |
| 513 | + }) | |
| 514 | + .find((candidate) => !options?.dismissedKeys?.has(candidate.key)) ?? null | |
| 515 | + ); | |
| 516 | +} | |
| 517 | + | |
| 518 | +function getProviderUpdateInitialToastTitle( | |
| 519 | + providers: ReadonlyArray<ProviderUpdateCandidate>, | |
| 520 | +): string { | |
| 521 | + if (providers.length === 1) { | |
| 522 | + const provider = providers[0]!; | |
| 523 | + const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; | |
| 524 | + return `Update Available: ${providerName} ${formatVersion(provider.versionAdvisory.latestVersion)}`; | |
| 525 | + } | |
| 526 | + return `Updates Available: ${providers.length} providers`; | |
| 527 | +} | |
| 528 | + | |
| 529 | +function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvider>): string { | |
| 530 | + if (providers.length === 1) { | |
| 531 | + const provider = providers[0]!; | |
| 532 | + if (provider.updateState?.message) { | |
| 533 | + return provider.updateState.message; | |
| 534 | + } | |
| 535 | + } | |
| 536 | + return `${formatProviderList(providers)} failed to update. Check provider settings for details.`; | |
| 537 | +} | |
| @@ -0,0 +1,300 @@ | ||
| 1 | +import { useNavigate } from "@tanstack/react-router"; | |
| 2 | +import { DownloadIcon } from "lucide-react"; | |
| 3 | +import { useCallback, useEffect, useMemo, useRef } from "react"; | |
| 4 | +import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; | |
| 5 | + | |
| 6 | +import { ensureLocalApi } from "../localApi"; | |
| 7 | +import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; | |
| 8 | +import { useServerProviders } from "../rpc/serverState"; | |
| 9 | +import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; | |
| 10 | +import { | |
| 11 | + canOneClickUpdateProviderCandidate, | |
| 12 | + collectProviderUpdateCandidates, | |
| 13 | + collectUpdatedProviderSnapshots, | |
| 14 | + firstRejectedProviderUpdateMessage, | |
| 15 | + getProviderUpdateInitialToastView, | |
| 16 | + getProviderUpdateProgressToastView, | |
| 17 | + getProviderUpdateRejectedToastView, | |
| 18 | + getProviderUpdateRunningToastView, | |
| 19 | + providerUpdateNotificationKey, | |
| 20 | + type ProviderUpdateToastView, | |
| 21 | +} from "./ProviderUpdateLaunchNotification.logic"; | |
| 22 | +import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 23 | + | |
| 24 | +const seenProviderUpdateNotificationKeys = new Set<string>(); | |
| 25 | +type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; | |
| 26 | + | |
| 27 | +type ActiveProviderUpdateToast = | |
| 28 | + | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } | |
| 29 | + | { | |
| 30 | + readonly kind: "update"; | |
| 31 | + readonly key: string; | |
| 32 | + readonly toastId: ProviderUpdateToastId; | |
| 33 | + readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>; | |
| 34 | + readonly providerCount: number; | |
| 35 | + }; | |
| 36 | + | |
| 37 | +function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) { | |
| 38 | + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[provider]; | |
| 39 | + | |
| 40 | + if (!ProviderIcon) { | |
| 41 | + return ( | |
| 42 | + <span className="relative inline-flex size-4 shrink-0 items-center justify-center"> | |
| 43 | + <DownloadIcon aria-hidden="true" className="size-4 text-success" strokeWidth={2.5} /> | |
| 44 | + </span> | |
| 45 | + ); | |
| 46 | + } | |
| 47 | + | |
| 48 | + return ( | |
| 49 | + <span className="relative inline-flex size-4 shrink-0 items-center justify-center"> | |
| 50 | + <ProviderIcon aria-hidden="true" className="size-4" /> | |
| 51 | + <span className="absolute -right-1 -bottom-1 inline-flex size-3 items-center justify-center rounded-full bg-popover"> | |
| 52 | + <DownloadIcon aria-hidden="true" className="size-2.5 text-success" strokeWidth={2.5} /> | |
| 53 | + </span> | |
| 54 | + </span> | |
| 55 | + ); | |
| 56 | +} | |
| 57 | + | |
| 58 | +function updateProviderUpdateToast(input: { | |
| 59 | + readonly toastId: ProviderUpdateToastId; | |
| 60 | + readonly view: ProviderUpdateToastView; | |
| 61 | + readonly openSettings: () => void; | |
| 62 | +}) { | |
| 63 | + if (input.view.type === "loading" || input.view.type === "success") { | |
| 64 | + toastManager.update(input.toastId, { | |
| 65 | + type: input.view.type, | |
| 66 | + title: input.view.title, | |
| 67 | + description: input.view.description, | |
| 68 | + timeout: 0, | |
| 69 | + data: { | |
| 70 | + hideCopyButton: true, | |
| 71 | + ...(input.view.dismissAfterVisibleMs !== undefined | |
| 72 | + ? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs } | |
| 73 | + : {}), | |
| 74 | + }, | |
| 75 | + }); | |
| 76 | + return; | |
| 77 | + } | |
| 78 | + | |
| 79 | + toastManager.update( | |
| 80 | + input.toastId, | |
| 81 | + stackedThreadToast({ | |
| 82 | + type: input.view.type, | |
| 83 | + title: input.view.title, | |
| 84 | + description: input.view.description, | |
| 85 | + timeout: 0, | |
| 86 | + actionProps: { | |
| 87 | + children: "Settings", | |
| 88 | + onClick: input.openSettings, | |
| 89 | + }, | |
| 90 | + actionVariant: "outline", | |
| 91 | + data: { | |
| 92 | + hideCopyButton: true, | |
| 93 | + }, | |
| 94 | + }), | |
| 95 | + ); | |
| 96 | +} | |
| 97 | + | |
| 98 | +function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { | |
| 99 | + return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; | |
| 100 | +} | |
| 101 | + | |
| 102 | +export function ProviderUpdateLaunchNotification() { | |
| 103 | + const navigate = useNavigate(); | |
| 104 | + const providers = useServerProviders(); | |
| 105 | + const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null); | |
| 106 | + const { dismissedNotificationKeys, dismissNotificationKey } = | |
| 107 | + useDismissedProviderUpdateNotificationKeys(); | |
| 108 | + | |
| 109 | + const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); | |
| 110 | + const notificationKey = useMemo( | |
| 111 | + () => providerUpdateNotificationKey(updateProviders), | |
| 112 | + [updateProviders], | |
| 113 | + ); | |
| 114 | + const oneClickProviders = useMemo( | |
| 115 | + () => | |
| 116 | + updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)), | |
| 117 | + [providers, updateProviders], | |
| 118 | + ); | |
| 119 | + | |
| 120 | + const openProviderSettings = useCallback( | |
| 121 | + (toastId?: ProviderUpdateToastId) => { | |
| 122 | + const activeToast = activeToastRef.current; | |
| 123 | + if (toastId !== undefined) { | |
| 124 | + toastManager.close(toastId); | |
| 125 | + } else if (activeToast) { | |
| 126 | + toastManager.close(activeToast.toastId); | |
| 127 | + } | |
| 128 | + if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { | |
| 129 | + activeToastRef.current = null; | |
| 130 | + } | |
| 131 | + void navigate({ to: "/settings/providers" }); | |
| 132 | + }, | |
| 133 | + [navigate], | |
| 134 | + ); | |
| 135 | + | |
| 136 | + useEffect(() => { | |
| 137 | + const activeToast = activeToastRef.current; | |
| 138 | + if (activeToast?.kind !== "update") { | |
| 139 | + return; | |
| 140 | + } | |
| 141 | + | |
| 142 | + const activeProviders = providers.filter((provider) => | |
| 143 | + activeToast.providerInstanceIds.has(provider.instanceId), | |
| 144 | + ); | |
| 145 | + const view = getProviderUpdateProgressToastView({ | |
| 146 | + providers: activeProviders, | |
| 147 | + providerCount: activeToast.providerCount, | |
| 148 | + }); | |
| 149 | + updateProviderUpdateToast({ | |
| 150 | + toastId: activeToast.toastId, | |
| 151 | + view, | |
| 152 | + openSettings: () => openProviderSettings(activeToast.toastId), | |
| 153 | + }); | |
| 154 | + | |
| 155 | + if (isTerminalProviderUpdateToastView(view)) { | |
| 156 | + activeToastRef.current = null; | |
| 157 | + } | |
| 158 | + }, [providers, openProviderSettings]); | |
| 159 | + | |
| 160 | + useEffect(() => { | |
| 161 | + const activeToast = activeToastRef.current; | |
| 162 | + if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) { | |
| 163 | + toastManager.close(activeToast.toastId); | |
| 164 | + activeToastRef.current = null; | |
| 165 | + } | |
| 166 | + | |
| 167 | + if ( | |
| 168 | + !notificationKey || | |
| 169 | + dismissedNotificationKeys.has(notificationKey) || | |
| 170 | + seenProviderUpdateNotificationKeys.has(notificationKey) || | |
| 171 | + activeToastRef.current | |
| 172 | + ) { | |
| 173 | + return; | |
| 174 | + } | |
| 175 | + | |
| 176 | + seenProviderUpdateNotificationKeys.add(notificationKey); | |
| 177 | + | |
| 178 | + const initialView = getProviderUpdateInitialToastView({ updateProviders, oneClickProviders }); | |
| 179 | + | |
| 180 | + let toastId!: ProviderUpdateToastId; | |
| 181 | + let updateStarted = false; | |
| 182 | + const openSettings = () => openProviderSettings(toastId); | |
| 183 | + const dismissPrompt = () => { | |
| 184 | + dismissNotificationKey(notificationKey); | |
| 185 | + }; | |
| 186 | + | |
| 187 | + const runUpdates = () => { | |
| 188 | + if (updateStarted || oneClickProviders.length === 0) { | |
| 189 | + return; | |
| 190 | + } | |
| 191 | + updateStarted = true; | |
| 192 | + | |
| 193 | + const providerCount = oneClickProviders.length; | |
| 194 | + const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); | |
| 195 | + activeToastRef.current = { | |
| 196 | + kind: "update", | |
| 197 | + key: notificationKey, | |
| 198 | + toastId, | |
| 199 | + providerInstanceIds, | |
| 200 | + providerCount, | |
| 201 | + }; | |
| 202 | + | |
| 203 | + updateProviderUpdateToast({ | |
| 204 | + toastId, | |
| 205 | + view: getProviderUpdateRunningToastView(providerCount), | |
| 206 | + openSettings, | |
| 207 | + }); | |
| 208 | + | |
| 209 | + void Promise.allSettled( | |
| 210 | + oneClickProviders.map(async (provider) => | |
| 211 | + ensureLocalApi().server.updateProvider({ | |
| 212 | + provider: provider.driver, | |
| 213 | + instanceId: provider.instanceId, | |
| 214 | + }), | |
| 215 | + ), | |
| 216 | + ).then((results) => { | |
| 217 | + const activeUpdateToast = activeToastRef.current; | |
| 218 | + if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { | |
| 219 | + return; | |
| 220 | + } | |
| 221 | + | |
| 222 | + const rejectedMessage = firstRejectedProviderUpdateMessage(results); | |
| 223 | + if (rejectedMessage) { | |
| 224 | + updateProviderUpdateToast({ | |
| 225 | + toastId, | |
| 226 | + view: getProviderUpdateRejectedToastView(providerCount, rejectedMessage), | |
| 227 | + openSettings, | |
| 228 | + }); | |
| 229 | + activeToastRef.current = null; | |
| 230 | + return; | |
| 231 | + } | |
| 232 | + | |
| 233 | + const updatedProviderSnapshots = collectUpdatedProviderSnapshots({ | |
| 234 | + results, | |
| 235 | + providerInstanceIds, | |
| 236 | + }); | |
| 237 | + const view = getProviderUpdateProgressToastView({ | |
| 238 | + providers: updatedProviderSnapshots, | |
| 239 | + providerCount, | |
| 240 | + }); | |
| 241 | + updateProviderUpdateToast({ | |
| 242 | + toastId, | |
| 243 | + view, | |
| 244 | + openSettings, | |
| 245 | + }); | |
| 246 | + | |
| 247 | + if (isTerminalProviderUpdateToastView(view)) { | |
| 248 | + activeToastRef.current = null; | |
| 249 | + } | |
| 250 | + }); | |
| 251 | + }; | |
| 252 | + | |
| 253 | + toastId = toastManager.add( | |
| 254 | + stackedThreadToast({ | |
| 255 | + type: initialView.type, | |
| 256 | + title: initialView.title, | |
| 257 | + description: initialView.description, | |
| 258 | + timeout: 0, | |
| 259 | + actionProps: | |
| 260 | + oneClickProviders.length > 0 | |
| 261 | + ? { | |
| 262 | + children: "Update", | |
| 263 | + onClick: runUpdates, | |
| 264 | + } | |
| 265 | + : { | |
| 266 | + children: "Settings", | |
| 267 | + onClick: openSettings, | |
| 268 | + }, | |
| 269 | + actionVariant: oneClickProviders.length > 0 ? "default" : "outline", | |
| 270 | + data: { | |
| 271 | + leadingIcon: | |
| 272 | + updateProviders.length === 1 ? ( | |
| 273 | + <ProviderUpdateToastIcon provider={updateProviders[0]!.driver} /> | |
| 274 | + ) : undefined, | |
| 275 | + hideCopyButton: true, | |
| 276 | + onClose: dismissPrompt, | |
| 277 | + ...(oneClickProviders.length > 0 | |
| 278 | + ? { | |
| 279 | + secondaryActionProps: { | |
| 280 | + children: "Settings", | |
| 281 | + onClick: openSettings, | |
| 282 | + }, | |
| 283 | + secondaryActionVariant: "outline" as const, | |
| 284 | + } | |
| 285 | + : {}), | |
| 286 | + }, | |
| 287 | + }), | |
| 288 | + ); | |
| 289 | + activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; | |
| 290 | + }, [ | |
| 291 | + dismissNotificationKey, | |
| 292 | + dismissedNotificationKeys, | |
| 293 | + notificationKey, | |
| 294 | + oneClickProviders, | |
| 295 | + openProviderSettings, | |
| 296 | + updateProviders, | |
| 297 | + ]); | |
| 298 | + | |
| 299 | + return null; | |
| 300 | +} | |
| @@ -185,6 +185,7 @@ import { · 184 unmodified lines | ||
| 185 | 185 | type SidebarProjectGroupMember, |
| 186 | 186 | type SidebarProjectSnapshot, |
| 187 | 187 | } from "../sidebarProjectGrouping"; |
| 188 | +import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; | |
| 188 | 189 | const THREAD_PREVIEW_LIMIT = 6; |
| 189 | 190 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 190 | 191 | updated_at: "Last user message", |
| @@ -2434,6 +2435,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { · 2243 unmodified lines | ||
| 2434 | 2435 | |
| 2435 | 2436 | return ( |
| 2436 | 2437 | <SidebarFooter className="p-2"> |
| 2438 | + <SidebarProviderUpdatePill /> | |
| 2437 | 2439 | <SidebarUpdatePill /> |
| 2438 | 2440 | <SidebarMenu> |
| 2439 | 2441 | <SidebarMenuItem> |
| @@ -1,6 +1,15 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; | |
| 3 | +import { | |
| 4 | + ArrowUpCircleIcon, | |
| 5 | + ChevronDownIcon, | |
| 6 | + CopyIcon, | |
| 7 | + DownloadIcon, | |
| 8 | + LoaderIcon, | |
| 9 | + PlusIcon, | |
| 10 | + Trash2Icon, | |
| 11 | + XIcon, | |
| 12 | +} from "lucide-react"; | |
| 4 | 13 | import { useEffect, useState, type ReactNode } from "react"; |
| 5 | 14 | import { |
| 6 | 15 | isProviderDriverKind, |
| @@ -13,12 +22,15 @@ import { · 6 unmodified lines | ||
| 13 | 22 | } from "@t3tools/contracts"; |
| 14 | 23 | |
| 15 | 24 | import { cn } from "../../lib/utils"; |
| 25 | +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 16 | 26 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 17 | 27 | import { Badge } from "../ui/badge"; |
| 18 | 28 | import { Button } from "../ui/button"; |
| 19 | 29 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; |
| 20 | 30 | import { DraftInput } from "../ui/draft-input"; |
| 31 | +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 21 | 32 | import { Switch } from "../ui/switch"; |
| 33 | +import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 22 | 34 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 23 | 35 | import type { DriverOption } from "./providerDriverMeta"; |
| 24 | 36 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; |
| @@ -26,6 +38,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 1 unmodified line | ||
| 26 | 38 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 27 | 39 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 28 | 40 | import { |
| 41 | + getProviderVersionAdvisoryPresentation, | |
| 29 | 42 | PROVIDER_STATUS_STYLES, |
| 30 | 43 | getProviderSummary, |
| 31 | 44 | getProviderVersionLabel, |
| @@ -405,6 +418,8 @@ interface ProviderInstanceCardProps { · 373 unmodified lines | ||
| 405 | 418 | readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void; |
| 406 | 419 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; |
| 407 | 420 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; |
| 421 | + readonly onRunUpdate?: (() => void) | undefined; | |
| 422 | + readonly isUpdating?: boolean | undefined; | |
| 408 | 423 | } |
| 409 | 424 | |
| 410 | 425 | /** |
| @@ -447,6 +462,8 @@ export function ProviderInstanceCard({ · 36 unmodified lines | ||
| 447 | 462 | onHiddenModelsChange, |
| 448 | 463 | onFavoriteModelsChange, |
| 449 | 464 | onModelOrderChange, |
| 465 | + onRunUpdate, | |
| 466 | + isUpdating = false, | |
| 450 | 467 | }: ProviderInstanceCardProps) { |
| 451 | 468 | const enabled = instance.enabled ?? true; |
| 452 | 469 | // The server-reported status wins when present; otherwise fall back to |
| @@ -464,10 +481,30 @@ export function ProviderInstanceCard({ · 11 unmodified lines | ||
| 464 | 481 | : null; |
| 465 | 482 | const summary = rawSummary; |
| 466 | 483 | const versionLabel = getProviderVersionLabel(liveProvider?.version); |
| 484 | + const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); | |
| 485 | + const updateCommand = versionAdvisory?.updateCommand ?? null; | |
| 467 | 486 | const FallbackIconComponent = driverOption?.icon; |
| 468 | 487 | const displayName = |
| 469 | 488 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); |
| 470 | 489 | const accentColor = normalizeProviderAccentColor(instance.accentColor); |
| 490 | + const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ | |
| 491 | + onCopy: ({ providerName }) => { | |
| 492 | + toastManager.add({ | |
| 493 | + type: "success", | |
| 494 | + title: `${providerName} update command copied`, | |
| 495 | + description: "Run it in a terminal when you are ready to update.", | |
| 496 | + }); | |
| 497 | + }, | |
| 498 | + onError: (error, { providerName }) => { | |
| 499 | + toastManager.add( | |
| 500 | + stackedThreadToast({ | |
| 501 | + type: "error", | |
| 502 | + title: `Could not copy ${providerName} update command`, | |
| 503 | + description: error.message, | |
| 504 | + }), | |
| 505 | + ); | |
| 506 | + }, | |
| 507 | + }); | |
| 471 | 508 | |
| 472 | 509 | // Narrow `instance.driver` for callers that key on the closed |
| 473 | 510 | // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom |
| @@ -535,97 +572,203 @@ export function ProviderInstanceCard({ · 61 unmodified lines | ||
| 535 | 572 | ); |
| 536 | 573 | }; |
| 537 | 574 | |
| 575 | + const titleIconNode = driverKind ? ( | |
| 576 | + <ProviderInstanceIcon | |
| 577 | + driverKind={driverKind} | |
| 578 | + displayName={displayName} | |
| 579 | + accentColor={accentColor} | |
| 580 | + showBadge={Boolean(accentColor)} | |
| 581 | + statusDotClassName={statusStyle.dot} | |
| 582 | + className="size-5" | |
| 583 | + iconClassName="size-4 text-foreground/80" | |
| 584 | + badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" | |
| 585 | + /> | |
| 586 | + ) : FallbackIconComponent ? ( | |
| 587 | + <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> | |
| 588 | + <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden /> | |
| 589 | + <span | |
| 590 | + className={cn( | |
| 591 | + "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", | |
| 592 | + statusStyle.dot, | |
| 593 | + )} | |
| 594 | + aria-hidden | |
| 595 | + /> | |
| 596 | + </span> | |
| 597 | + ) : ( | |
| 598 | + <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} /> | |
| 599 | + ); | |
| 600 | + | |
| 601 | + const titleHeadNode = ( | |
| 602 | + <> | |
| 603 | + {titleIconNode} | |
| 604 | + <h3 className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground"> | |
| 605 | + {displayName} | |
| 606 | + </h3> | |
| 607 | + {String(instanceId) !== String(instance.driver) ? ( | |
| 608 | + <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground"> | |
| 609 | + {instanceId} | |
| 610 | + </code> | |
| 611 | + ) : null} | |
| 612 | + {driverOption?.badgeLabel ? ( | |
| 613 | + <Badge variant="warning" size="sm" className="shrink-0"> | |
| 614 | + {driverOption.badgeLabel} | |
| 615 | + </Badge> | |
| 616 | + ) : null} | |
| 617 | + </> | |
| 618 | + ); | |
| 619 | + | |
| 620 | + const titleTailNode = ( | |
| 621 | + <> | |
| 622 | + {headerAction ? ( | |
| 623 | + <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | |
| 624 | + {headerAction} | |
| 625 | + </span> | |
| 626 | + ) : null} | |
| 627 | + {onDelete ? ( | |
| 628 | + <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | |
| 629 | + <Tooltip> | |
| 630 | + <TooltipTrigger | |
| 631 | + render={ | |
| 632 | + <Button | |
| 633 | + size="icon-xs" | |
| 634 | + variant="ghost" | |
| 635 | + className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive" | |
| 636 | + onClick={onDelete} | |
| 637 | + aria-label={`Delete provider instance ${instanceId}`} | |
| 638 | + > | |
| 639 | + <Trash2Icon className="size-3" /> | |
| 640 | + </Button> | |
| 641 | + } | |
| 642 | + /> | |
| 643 | + <TooltipPopup side="top">Delete instance</TooltipPopup> | |
| 644 | + </Tooltip> | |
| 645 | + </span> | |
| 646 | + ) : null} | |
| 647 | + </> | |
| 648 | + ); | |
| 649 | + | |
| 650 | + const authRowNode = ( | |
| 651 | + <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground"> | |
| 652 | + {hasAuthenticatedEmail ? ( | |
| 653 | + <> | |
| 654 | + <span>Authenticated as</span> | |
| 655 | + <ProviderAuthEmail email={authEmail} /> | |
| 656 | + {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null} | |
| 657 | + </> | |
| 658 | + ) : ( | |
| 659 | + <> | |
| 660 | + <span>{summary.headline}</span> | |
| 661 | + <ProviderAuthEmail email={authEmail} separator prefix="Email" /> | |
| 662 | + </> | |
| 663 | + )} | |
| 664 | + {summary.detail ? <span>- {summary.detail}</span> : null} | |
| 665 | + </p> | |
| 666 | + ); | |
| 667 | + | |
| 668 | + const versionCodeNode = versionLabel ? ( | |
| 669 | + <code className="text-xs text-muted-foreground">{versionLabel}</code> | |
| 670 | + ) : null; | |
| 671 | + | |
| 538 | 672 | return ( |
| 539 | − <div className="border-t border-border first:border-t-0"> | |
| 540 | − <div className="px-4 py-4 sm:px-5"> | |
| 673 | + <div className="border-t border-border/60 first:border-t-0"> | |
| 674 | + <div className="px-4 py-3.5 sm:px-5"> | |
| 541 | 675 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 542 | 676 | <div className="min-w-0 flex-1 space-y-1"> |
| 543 | − <div className="flex min-h-5 items-center gap-1.5"> | |
| 544 | − {driverKind ? ( | |
| 545 | − <ProviderInstanceIcon | |
| 546 | − driverKind={driverKind} | |
| 547 | − displayName={displayName} | |
| 548 | − accentColor={accentColor} | |
| 549 | − showBadge={Boolean(accentColor)} | |
| 550 | − statusDotClassName={statusStyle.dot} | |
| 551 | − className="size-5" | |
| 552 | − iconClassName="size-4 text-foreground/80" | |
| 553 | − badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" | |
| 554 | − /> | |
| 555 | − ) : FallbackIconComponent ? ( | |
| 556 | − <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> | |
| 557 | − <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden /> | |
| 558 | − <span | |
| 559 | − className={cn( | |
| 560 | − "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", | |
| 561 | − statusStyle.dot, | |
| 562 | − )} | |
| 563 | − aria-hidden | |
| 677 | + <div className="flex min-w-0 flex-wrap items-center gap-2"> | |
| 678 | + {titleHeadNode} | |
| 679 | + {versionCodeNode} | |
| 680 | + {versionAdvisory ? ( | |
| 681 | + <Popover> | |
| 682 | + <PopoverTrigger | |
| 683 | + render={ | |
| 684 | + <Button | |
| 685 | + type="button" | |
| 686 | + size="icon-xs" | |
| 687 | + variant="ghost" | |
| 688 | + className={cn( | |
| 689 | + "size-5 rounded-sm p-0", | |
| 690 | + versionAdvisory.emphasis === "strong" | |
| 691 | + ? "text-warning hover:text-warning" | |
| 692 | + : "text-primary hover:text-primary", | |
| 693 | + )} | |
| 694 | + aria-label="Update available — view details" | |
| 695 | + > | |
| 696 | + <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" /> | |
| 697 | + </Button> | |
| 698 | + } | |
| 564 | 699 | /> |
| 565 | − </span> | |
| 566 | − ) : ( | |
| 567 | − <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} /> | |
| 568 | − )} | |
| 569 | − <h3 className="truncate text-sm font-medium text-foreground">{displayName}</h3> | |
| 570 | − {String(instanceId) !== String(instance.driver) ? ( | |
| 571 | − // Hide the id chip on a default slot whose id === the | |
| 572 | − // driver slug — it's redundant with the driver icon + | |
| 573 | − // label. Custom instances (and any instance the user has | |
| 574 | − // since renamed) keep the chip so their slug stays | |
| 575 | − // visible for copy/paste + disambiguation. | |
| 576 | − <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground"> | |
| 577 | − {instanceId} | |
| 578 | − </code> | |
| 579 | − ) : null} | |
| 580 | − {driverOption?.badgeLabel ? ( | |
| 581 | − <Badge variant="warning" size="sm" className="shrink-0"> | |
| 582 | − {driverOption.badgeLabel} | |
| 583 | − </Badge> | |
| 584 | − ) : null} | |
| 585 | − {versionLabel ? ( | |
| 586 | − <code className="text-xs text-muted-foreground">{versionLabel}</code> | |
| 587 | − ) : null} | |
| 588 | − {headerAction ? ( | |
| 589 | − <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | |
| 590 | − {headerAction} | |
| 591 | − </span> | |
| 592 | − ) : null} | |
| 593 | − {onDelete ? ( | |
| 594 | − <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | |
| 595 | − <Tooltip> | |
| 596 | − <TooltipTrigger | |
| 597 | − render={ | |
| 700 | + <PopoverPopup side="bottom" align="start" className="w-84"> | |
| 701 | + <div className="grid gap-3"> | |
| 702 | + <div className="grid gap-0.5"> | |
| 703 | + <p className="text-[13px] font-semibold leading-tight text-foreground"> | |
| 704 | + Update available | |
| 705 | + </p> | |
| 706 | + <p | |
| 707 | + className={cn( | |
| 708 | + "text-xs leading-snug", | |
| 709 | + versionAdvisory.emphasis === "strong" | |
| 710 | + ? "text-warning" | |
| 711 | + : "text-muted-foreground", | |
| 712 | + )} | |
| 713 | + > | |
| 714 | + {versionAdvisory.detail} | |
| 715 | + </p> | |
| 716 | + </div> | |
| 717 | + {onRunUpdate ? ( | |
| 598 | 718 | <Button |
| 599 | − size="icon-xs" | |
| 600 | − variant="ghost" | |
| 601 | − className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive" | |
| 602 | − onClick={onDelete} | |
| 603 | − aria-label={`Delete provider instance ${instanceId}`} | |
| 719 | + type="button" | |
| 720 | + size="xs" | |
| 721 | + variant="default" | |
| 722 | + className="w-full" | |
| 723 | + disabled={isUpdating} | |
| 724 | + onClick={onRunUpdate} | |
| 604 | 725 | > |
| 605 | − <Trash2Icon className="size-3" /> | |
| 726 | + {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />} | |
| 727 | + {isUpdating ? "Updating" : "Update now"} | |
| 606 | 728 | </Button> |
| 607 | − } | |
| 608 | − /> | |
| 609 | − <TooltipPopup side="top">Delete instance</TooltipPopup> | |
| 610 | − </Tooltip> | |
| 611 | − </span> | |
| 729 | + ) : null} | |
| 730 | + {onRunUpdate && updateCommand ? ( | |
| 731 | + <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> | |
| 732 | + <span aria-hidden className="h-px flex-1 bg-border" /> | |
| 733 | + or, update manually using | |
| 734 | + <span aria-hidden className="h-px flex-1 bg-border" /> | |
| 735 | + </div> | |
| 736 | + ) : null} | |
| 737 | + {updateCommand ? ( | |
| 738 | + <div className="flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2"> | |
| 739 | + <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | |
| 740 | + {updateCommand} | |
| 741 | + </code> | |
| 742 | + <Tooltip> | |
| 743 | + <TooltipTrigger | |
| 744 | + render={ | |
| 745 | + <Button | |
| 746 | + type="button" | |
| 747 | + size="icon-xs" | |
| 748 | + variant="ghost" | |
| 749 | + className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 750 | + onClick={() => | |
| 751 | + copyToClipboard(updateCommand, { | |
| 752 | + providerName: displayName, | |
| 753 | + }) | |
| 754 | + } | |
| 755 | + aria-label="Copy update command" | |
| 756 | + > | |
| 757 | + <CopyIcon className="size-3" /> | |
| 758 | + </Button> | |
| 759 | + } | |
| 760 | + /> | |
| 761 | + <TooltipPopup side="top">Copy command</TooltipPopup> | |
| 762 | + </Tooltip> | |
| 763 | + </div> | |
| 764 | + ) : null} | |
| 765 | + </div> | |
| 766 | + </PopoverPopup> | |
| 767 | + </Popover> | |
| 612 | 768 | ) : null} |
| 769 | + {titleTailNode} | |
| 613 | 770 | </div> |
| 614 | − <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground"> | |
| 615 | − {hasAuthenticatedEmail ? ( | |
| 616 | − <> | |
| 617 | − <span>Authenticated as</span> | |
| 618 | − <ProviderAuthEmail email={authEmail} /> | |
| 619 | − {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null} | |
| 620 | − </> | |
| 621 | − ) : ( | |
| 622 | − <> | |
| 623 | − <span>{summary.headline}</span> | |
| 624 | − <ProviderAuthEmail email={authEmail} separator prefix="Email" /> | |
| 625 | − </> | |
| 626 | − )} | |
| 627 | − {summary.detail ? <span>- {summary.detail}</span> : null} | |
| 628 | − </p> | |
| 771 | + {authRowNode} | |
| 629 | 772 | </div> |
| 630 | 773 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 631 | 774 | <Button |
| @@ -10,7 +10,10 @@ import { · 9 unmodified lines | ||
| 10 | 10 | type DesktopUpdateChannel, |
| 11 | 11 | type DesktopUpdateState, |
| 12 | 12 | type LocalApi, |
| 13 | + ProviderDriverKind, | |
| 14 | + ProviderInstanceId, | |
| 13 | 15 | type ServerConfig, |
| 16 | + type ServerProvider, | |
| 14 | 17 | type SourceControlDiscoveryResult, |
| 15 | 18 | } from "@t3tools/contracts"; |
| 16 | 19 | import { DateTime, Option } from "effect"; |
| @@ -23,7 +26,7 @@ import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc · 6 unmodified lines | ||
| 23 | 26 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; |
| 24 | 27 | import { useUiStateStore } from "../../uiStateStore"; |
| 25 | 28 | import { ConnectionsSettings } from "./ConnectionsSettings"; |
| 26 | −import { GeneralSettingsPanel } from "./SettingsPanels"; | |
| 29 | +import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; | |
| 27 | 30 | import { SourceControlSettingsPanel } from "./SourceControlSettings"; |
| 28 | 31 | |
| 29 | 32 | const authAccessHarness = vi.hoisted(() => { |
| @@ -207,6 +210,31 @@ function createBaseServerConfig(): ServerConfig { · 177 unmodified lines | ||
| 207 | 210 | }; |
| 208 | 211 | } |
| 209 | 212 | |
| 213 | +function createOutdatedProvider(driver: string): ServerProvider { | |
| 214 | + return { | |
| 215 | + instanceId: ProviderInstanceId.make(driver), | |
| 216 | + driver: ProviderDriverKind.make(driver), | |
| 217 | + enabled: true, | |
| 218 | + installed: true, | |
| 219 | + version: "1.0.0", | |
| 220 | + status: "ready", | |
| 221 | + auth: { status: "authenticated" }, | |
| 222 | + checkedAt: "2026-05-04T10:00:00.000Z", | |
| 223 | + models: [], | |
| 224 | + slashCommands: [], | |
| 225 | + skills: [], | |
| 226 | + versionAdvisory: { | |
| 227 | + status: "behind_latest", | |
| 228 | + currentVersion: "1.0.0", | |
| 229 | + latestVersion: "1.1.0", | |
| 230 | + message: "Update available.", | |
| 231 | + checkedAt: "2026-05-04T10:00:00.000Z", | |
| 232 | + updateCommand: "npm install -g openai/codex@latest", | |
| 233 | + canUpdate: true, | |
| 234 | + }, | |
| 235 | + }; | |
| 236 | +} | |
| 237 | + | |
| 210 | 238 | function makeUtc(value: string) { |
| 211 | 239 | return DateTime.makeUnsafe(Date.parse(value)); |
| 212 | 240 | } |
| @@ -1016,7 +1044,7 @@ describe("GeneralSettingsPanel observability", () => { · 803 unmodified lines | ||
| 1016 | 1044 | |
| 1017 | 1045 | mounted = await render( |
| 1018 | 1046 | <AppAtomRegistryProvider> |
| 1019 | − <GeneralSettingsPanel /> | |
| 1047 | + <ProviderSettingsPanel /> | |
| 1020 | 1048 | </AppAtomRegistryProvider>, |
| 1021 | 1049 | ); |
| 1022 | 1050 | |
| @@ -1031,6 +1059,41 @@ describe("GeneralSettingsPanel observability", () => { · 8 unmodified lines | ||
| 1031 | 1059 | await expect.element(page.getByText("Server password")).toBeInTheDocument(); |
| 1032 | 1060 | await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); |
| 1033 | 1061 | }); |
| 1062 | + | |
| 1063 | + it("runs one-click provider updates from the provider card", async () => { | |
| 1064 | + const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({ | |
| 1065 | + providers: [createOutdatedProvider("codex")], | |
| 1066 | + }); | |
| 1067 | + window.nativeApi = { | |
| 1068 | + persistence: { | |
| 1069 | + getClientSettings: vi.fn().mockResolvedValue(null), | |
| 1070 | + setClientSettings: vi.fn().mockResolvedValue(undefined), | |
| 1071 | + }, | |
| 1072 | + server: { | |
| 1073 | + updateProvider, | |
| 1074 | + }, | |
| 1075 | + } as unknown as LocalApi; | |
| 1076 | + | |
| 1077 | + setServerConfigSnapshot({ | |
| 1078 | + ...createBaseServerConfig(), | |
| 1079 | + providers: [createOutdatedProvider("codex")], | |
| 1080 | + }); | |
| 1081 | + | |
| 1082 | + mounted = await render( | |
| 1083 | + <AppAtomRegistryProvider> | |
| 1084 | + <ProviderSettingsPanel /> | |
| 1085 | + </AppAtomRegistryProvider>, | |
| 1086 | + ); | |
| 1087 | + | |
| 1088 | + await page.getByRole("button", { name: "Update available — view details" }).click(); | |
| 1089 | + await expect.element(page.getByRole("button", { name: "Update now" })).toBeInTheDocument(); | |
| 1090 | + await page.getByRole("button", { name: "Update now" }).click(); | |
| 1091 | + | |
| 1092 | + expect(updateProvider).toHaveBeenCalledWith({ | |
| 1093 | + provider: ProviderDriverKind.make("codex"), | |
| 1094 | + instanceId: ProviderInstanceId.make("codex"), | |
| 1095 | + }); | |
| 1096 | + }); | |
| 1034 | 1097 | }); |
| 1035 | 1098 | |
| 1036 | 1099 | describe("SourceControlSettingsPanel discovery states", () => { |
| @@ -4,6 +4,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | defaultInstanceIdForDriver, |
| 6 | 6 | type DesktopUpdateChannel, |
| 7 | + PROVIDER_DISPLAY_NAMES, | |
| 7 | 8 | ProviderDriverKind, |
| 8 | 9 | type ProviderInstanceConfig, |
| 9 | 10 | type ProviderInstanceId, |
| @@ -56,6 +57,13 @@ import { Switch } from "../ui/switch"; · 46 unmodified lines | ||
| 56 | 57 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 57 | 58 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 58 | 59 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 60 | +import { | |
| 61 | + canOneClickUpdateProviderCandidate, | |
| 62 | + collectProviderUpdateCandidates, | |
| 63 | + hasOneClickUpdateProviderCandidate, | |
| 64 | + isProviderUpdateActive, | |
| 65 | + type ProviderUpdateCandidate, | |
| 66 | +} from "../ProviderUpdateLaunchNotification.logic"; | |
| 59 | 67 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 60 | 68 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 61 | 69 | import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; |
| @@ -345,37 +353,12 @@ function AboutVersionSection() { · 283 unmodified lines | ||
| 345 | 353 | export function useSettingsRestore(onRestored?: () => void) { |
| 346 | 354 | const { theme, setTheme } = useTheme(); |
| 347 | 355 | const settings = useSettings(); |
| 348 | − const { resetSettings } = useUpdateSettings(); | |
| 356 | + const { updateSettings } = useUpdateSettings(); | |
| 349 | 357 | |
| 350 | 358 | const isGitWritingModelDirty = !Equal.equals( |
| 351 | 359 | settings.textGenerationModelSelection ?? null, |
| 352 | 360 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 353 | 361 | ); |
| 354 | − // A provider surface is "dirty" if either the legacy per-kind | |
| 355 | − // `settings.providers[kind]` struct differs from defaults (for users | |
| 356 | − // on pre-migration data) or the new `settings.providerInstances` map | |
| 357 | − // has any entries (every edit to a default slot promotes it into an | |
| 358 | − // explicit entry, so any key in that map represents user intent to | |
| 359 | − // diverge from factory defaults). Checking both keeps the Restore | |
| 360 | − // Defaults chip accurate throughout the legacy→instance migration. | |
| 361 | − const areProviderSettingsDirty = | |
| 362 | − PROVIDER_SETTINGS.some((providerSettings) => { | |
| 363 | − type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 364 | − const currentProviders = settings.providers as Record< | |
| 365 | − string, | |
| 366 | − LegacyProviderSettings | undefined | |
| 367 | − >; | |
| 368 | − const defaultProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 369 | − string, | |
| 370 | − LegacyProviderSettings | undefined | |
| 371 | − >; | |
| 372 | − const currentSettings = currentProviders[providerSettings.provider]; | |
| 373 | − const defaultSettings = defaultProviders[providerSettings.provider]; | |
| 374 | − return !Equal.equals(currentSettings, defaultSettings); | |
| 375 | − }) || | |
| 376 | − Object.keys(settings.providerInstances ?? {}).length > 0 || | |
| 377 | − Object.keys(settings.providerModelPreferences ?? {}).length > 0 || | |
| 378 | − (settings.favorites ?? []).length > 0; | |
| 379 | 362 | |
| 380 | 363 | const changedSettingLabels = useMemo( |
| 381 | 364 | () => [ |
| @@ -408,10 +391,8 @@ export function useSettingsRestore(onRestored?: () => void) { · 26 unmodified lines | ||
| 408 | 391 | ? ["Delete confirmation"] |
| 409 | 392 | : []), |
| 410 | 393 | ...(isGitWritingModelDirty ? ["Git writing model"] : []), |
| 411 | − ...(areProviderSettingsDirty ? ["Providers"] : []), | |
| 412 | 394 | ], |
| 413 | 395 | [ |
| 414 | − areProviderSettingsDirty, | |
| 415 | 396 | isGitWritingModelDirty, |
| 416 | 397 | settings.autoOpenPlanSidebar, |
| 417 | 398 | settings.confirmThreadArchive, |
| @@ -437,9 +418,20 @@ export function useSettingsRestore(onRestored?: () => void) { · 19 unmodified lines | ||
| 437 | 418 | if (!confirmed) return; |
| 438 | 419 | |
| 439 | 420 | setTheme("system"); |
| 440 | − resetSettings(); | |
| 421 | + updateSettings({ | |
| 422 | + timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, | |
| 423 | + diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, | |
| 424 | + diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, | |
| 425 | + autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, | |
| 426 | + enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, | |
| 427 | + defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, | |
| 428 | + addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, | |
| 429 | + confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, | |
| 430 | + confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, | |
| 431 | + textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | |
| 432 | + }); | |
| 441 | 433 | onRestored?.(); |
| 442 | − }, [changedSettingLabels, onRestored, resetSettings, setTheme]); | |
| 434 | + }, [changedSettingLabels, onRestored, setTheme, updateSettings]); | |
| 443 | 435 | |
| 444 | 436 | return { |
| 445 | 437 | changedSettingLabels, |
| @@ -458,44 +450,10 @@ export function GeneralSettingsPanel() { · 12 unmodified lines | ||
| 458 | 450 | const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< |
| 459 | 451 | Partial<Record<"keybindings" | "logsDirectory", string | null>> |
| 460 | 452 | >({}); |
| 461 | − const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | |
| 462 | − const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); | |
| 463 | − // Collapsible state per provider-instance card, keyed by the instance id. | |
| 464 | − // `Record<string, boolean>` so we don't need to preseed an entry for every | |
| 465 | − // configured instance — an absent key reads as collapsed. Default-slot | |
| 466 | − // rows share this state: their id is the driver slug | |
| 467 | − // (`defaultInstanceIdForDriver(driver)`), which is also `ProviderDriverKind` at | |
| 468 | − // runtime, so a pre-existing open key for e.g. "codex" persists across | |
| 469 | − // the legacy/unified render swap. | |
| 470 | − const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({}); | |
| 471 | − const refreshingRef = useRef(false); | |
| 472 | − const refreshProviders = useCallback(() => { | |
| 473 | − if (refreshingRef.current) return; | |
| 474 | − refreshingRef.current = true; | |
| 475 | − setIsRefreshingProviders(true); | |
| 476 | − void ensureLocalApi() | |
| 477 | − .server.refreshProviders() | |
| 478 | − .catch((error: unknown) => { | |
| 479 | − console.warn("Failed to refresh providers", error); | |
| 480 | − }) | |
| 481 | − .finally(() => { | |
| 482 | − refreshingRef.current = false; | |
| 483 | − setIsRefreshingProviders(false); | |
| 484 | − }); | |
| 485 | − }, []); | |
| 486 | − | |
| 487 | 453 | const keybindingsConfigPath = useServerKeybindingsConfigPath(); |
| 488 | 454 | const availableEditors = useServerAvailableEditors(); |
| 489 | 455 | const observability = useServerObservability(); |
| 490 | 456 | const serverProviders = useServerProviders(); |
| 491 | − const visibleProviderSettings = PROVIDER_SETTINGS.filter( | |
| 492 | − (providerSettings) => | |
| 493 | − providerSettings.provider !== "cursor" || | |
| 494 | − serverProviders.some( | |
| 495 | − (provider) => | |
| 496 | − provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), | |
| 497 | − ), | |
| 498 | − ); | |
| 499 | 457 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; |
| 500 | 458 | const diagnosticsDescription = (() => { |
| 501 | 459 | const exports: string[] = []; |
| @@ -576,221 +534,6 @@ export function GeneralSettingsPanel() { · 74 unmodified lines | ||
| 576 | 534 | const isOpeningKeybindings = openingPathByTarget.keybindings; |
| 577 | 535 | const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; |
| 578 | 536 | |
| 579 | − const lastCheckedAt = | |
| 580 | − serverProviders.length > 0 | |
| 581 | − ? serverProviders.reduce( | |
| 582 | − (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), | |
| 583 | − serverProviders[0]!.checkedAt, | |
| 584 | − ) | |
| 585 | − : null; | |
| 586 | − | |
| 587 | − /** | |
| 588 | − * Build the list of rows to render, one per configured instance. Each | |
| 589 | − * row carries enough context to drive `ProviderInstanceCard` without | |
| 590 | − * threading storage concerns: whether it's a built-in default slot (in | |
| 591 | − * which case `isDefault` is true, deletion is gated off, and the | |
| 592 | − * effective envelope may have been synthesized from legacy just for | |
| 593 | − * this render), the driver kind narrow for the in-card model-slug | |
| 594 | − * normalization, and whether a reset-to-factory action is warranted. | |
| 595 | − * | |
| 596 | − * Ordering mirrors the prior split: visible built-in default slots | |
| 597 | − * first (one per visible kind), then user-authored custom instances | |
| 598 | − * grouped by driver after their default sibling, then orphan instances | |
| 599 | − * whose driver isn't in the visible-defaults set. | |
| 600 | − */ | |
| 601 | − interface InstanceRow { | |
| 602 | − readonly instanceId: ProviderInstanceId; | |
| 603 | − readonly instance: ProviderInstanceConfig; | |
| 604 | − readonly driver: ProviderDriverKind; | |
| 605 | − /** True for the slot whose id is `defaultInstanceIdForDriver(driver)`. */ | |
| 606 | − readonly isDefault: boolean; | |
| 607 | − /** | |
| 608 | − * True when this default slot differs from the factory defaults — | |
| 609 | − * either through an explicit `providerInstances[defaultId]` entry, | |
| 610 | − * or through a non-default legacy `settings.providers[kind]` struct | |
| 611 | − * that we're still bridging. Used to show the reset-to-factory | |
| 612 | − * affordance. Undefined for custom rows (they have a delete button | |
| 613 | − * instead; "factory defaults" isn't meaningful). | |
| 614 | − */ | |
| 615 | − readonly isDirty?: boolean; | |
| 616 | − } | |
| 617 | − | |
| 618 | − const instancesByDriver = new Map< | |
| 619 | − ProviderDriverKind, | |
| 620 | − Array<[ProviderInstanceId, ProviderInstanceConfig]> | |
| 621 | − >(); | |
| 622 | − for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { | |
| 623 | − const driver = instance.driver; | |
| 624 | − const list = instancesByDriver.get(driver) ?? []; | |
| 625 | − list.push([rawId as ProviderInstanceId, instance]); | |
| 626 | − instancesByDriver.set(driver, list); | |
| 627 | − } | |
| 628 | − | |
| 629 | − const defaultSlotIdsBySource = new Set<string>( | |
| 630 | − visibleProviderSettings.map((providerSettings) => | |
| 631 | − String(defaultInstanceIdForDriver(providerSettings.provider)), | |
| 632 | − ), | |
| 633 | − ); | |
| 634 | − | |
| 635 | − const rows: InstanceRow[] = []; | |
| 636 | − const visibleDriverKinds = new Set<ProviderDriverKind>( | |
| 637 | − visibleProviderSettings.map((providerSettings) => providerSettings.provider), | |
| 638 | − ); | |
| 639 | − | |
| 640 | − for (const providerSettings of visibleProviderSettings) { | |
| 641 | − type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 642 | − const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; | |
| 643 | − const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 644 | − string, | |
| 645 | − LegacyProviderSettings | |
| 646 | − >; | |
| 647 | − const driver = providerSettings.provider; | |
| 648 | − const defaultInstanceId = defaultInstanceIdForDriver(driver); | |
| 649 | − // Prefer an explicit `providerInstances[defaultId]` entry when one | |
| 650 | − // exists (every edit via this UI promotes the default slot into | |
| 651 | − // that map); fall back to synthesizing one from the legacy | |
| 652 | − // `settings.providers[kind]` struct so first-time viewers still see | |
| 653 | − // their persisted config. | |
| 654 | − const explicitInstance = settings.providerInstances?.[defaultInstanceId]; | |
| 655 | − const legacyConfig = legacyProviders[providerSettings.provider]!; | |
| 656 | − const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; | |
| 657 | − const effectiveInstance: ProviderInstanceConfig = | |
| 658 | − explicitInstance ?? | |
| 659 | − ({ | |
| 660 | − driver, | |
| 661 | − enabled: legacyConfig.enabled, | |
| 662 | − config: legacyConfig, | |
| 663 | − } satisfies ProviderInstanceConfig); | |
| 664 | − const isDirty = | |
| 665 | − explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); | |
| 666 | − rows.push({ | |
| 667 | − instanceId: defaultInstanceId, | |
| 668 | − instance: effectiveInstance, | |
| 669 | − driver, | |
| 670 | − isDefault: true, | |
| 671 | − isDirty, | |
| 672 | − }); | |
| 673 | − // Non-default customs for this driver kind follow their default. | |
| 674 | − for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { | |
| 675 | − if (id === defaultInstanceId) continue; | |
| 676 | − rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); | |
| 677 | − } | |
| 678 | − } | |
| 679 | − // Orphan instances: drivers the visible-defaults list doesn't cover | |
| 680 | − // (e.g. Cursor when the server hasn't reported it but the user has | |
| 681 | − // authored a Cursor instance anyway, or fork drivers not shipped by | |
| 682 | − // this build). Preserve insertion order within each driver. | |
| 683 | − for (const [driver, list] of instancesByDriver) { | |
| 684 | − if (visibleDriverKinds.has(driver)) continue; | |
| 685 | − for (const [id, instance] of list) { | |
| 686 | − const isDefaultSlot = defaultSlotIdsBySource.has(String(id)); | |
| 687 | − rows.push({ | |
| 688 | − instanceId: id, | |
| 689 | − instance, | |
| 690 | − driver: instance.driver, | |
| 691 | − isDefault: isDefaultSlot, | |
| 692 | − }); | |
| 693 | − } | |
| 694 | − } | |
| 695 | − | |
| 696 | − const updateProviderInstance = ( | |
| 697 | − row: InstanceRow, | |
| 698 | − next: ProviderInstanceConfig, | |
| 699 | − options?: { | |
| 700 | − readonly textGenerationModelSelection?: Parameters< | |
| 701 | − typeof buildProviderInstanceUpdatePatch | |
| 702 | − >[0]["textGenerationModelSelection"]; | |
| 703 | − }, | |
| 704 | − ) => { | |
| 705 | − updateSettings( | |
| 706 | − buildProviderInstanceUpdatePatch({ | |
| 707 | − settings, | |
| 708 | − instanceId: row.instanceId, | |
| 709 | − instance: next, | |
| 710 | − driver: row.driver, | |
| 711 | − isDefault: row.isDefault, | |
| 712 | − textGenerationModelSelection: options?.textGenerationModelSelection, | |
| 713 | − }), | |
| 714 | − ); | |
| 715 | − }; | |
| 716 | − | |
| 717 | − const deleteProviderInstance = (id: ProviderInstanceId) => { | |
| 718 | − updateSettings({ | |
| 719 | − providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), | |
| 720 | − providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), | |
| 721 | − favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), | |
| 722 | − }); | |
| 723 | − }; | |
| 724 | − | |
| 725 | − const updateProviderModelPreferences = ( | |
| 726 | − instanceId: ProviderInstanceId, | |
| 727 | − next: { | |
| 728 | − readonly hiddenModels: ReadonlyArray<string>; | |
| 729 | − readonly modelOrder: ReadonlyArray<string>; | |
| 730 | − }, | |
| 731 | − ) => { | |
| 732 | − const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; | |
| 733 | − const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; | |
| 734 | − const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); | |
| 735 | − updateSettings({ | |
| 736 | − providerModelPreferences: | |
| 737 | − hiddenModels.length === 0 && modelOrder.length === 0 | |
| 738 | − ? rest | |
| 739 | − : { | |
| 740 | − ...rest, | |
| 741 | − [instanceId]: { | |
| 742 | − hiddenModels, | |
| 743 | − modelOrder, | |
| 744 | − }, | |
| 745 | − }, | |
| 746 | − }); | |
| 747 | − }; | |
| 748 | − | |
| 749 | − const updateProviderFavoriteModels = ( | |
| 750 | − instanceId: ProviderInstanceId, | |
| 751 | − nextFavoriteModels: ReadonlyArray<string>, | |
| 752 | − ) => { | |
| 753 | − const favoriteModels = [ | |
| 754 | − ...new Set(nextFavoriteModels.map((slug) => slug.trim()).filter((slug) => slug.length > 0)), | |
| 755 | − ]; | |
| 756 | − updateSettings({ | |
| 757 | − favorites: [ | |
| 758 | − ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), | |
| 759 | − ...favoriteModels.map((model) => ({ provider: instanceId, model })), | |
| 760 | − ], | |
| 761 | − }); | |
| 762 | − }; | |
| 763 | − | |
| 764 | − /** | |
| 765 | − * Reset a built-in default slot back to factory defaults. Clears both | |
| 766 | − * the legacy `settings.providers[kind]` struct and any explicit | |
| 767 | − * `providerInstances[defaultId]` entry that has promoted legacy into | |
| 768 | − * the new map, so hydration re-synthesizes a clean envelope on next | |
| 769 | − * load. Safe to call on drivers that have never been edited. | |
| 770 | − */ | |
| 771 | − const resetDefaultInstance = (driverKind: ProviderDriverKind) => { | |
| 772 | − type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 773 | − const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 774 | − string, | |
| 775 | − LegacyProviderSettings | undefined | |
| 776 | − >; | |
| 777 | − const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | |
| 778 | − const defaultLegacyProvider = defaultLegacyProviders[driverKind]; | |
| 779 | − if (defaultLegacyProvider === undefined) return; | |
| 780 | − updateSettings({ | |
| 781 | − providers: { | |
| 782 | − ...settings.providers, | |
| 783 | − [driverKind]: defaultLegacyProvider, | |
| 784 | − } as typeof settings.providers, | |
| 785 | − providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), | |
| 786 | − providerModelPreferences: withoutProviderInstanceKey( | |
| 787 | − settings.providerModelPreferences, | |
| 788 | − defaultInstanceId, | |
| 789 | − ), | |
| 790 | − favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), | |
| 791 | − }); | |
| 792 | − }; | |
| 793 | − | |
| 794 | 537 | return ( |
| 795 | 538 | <SettingsPageContainer> |
| 796 | 539 | <SettingsSection title="General"> |
| @@ -1168,58 +911,406 @@ export function GeneralSettingsPanel() { · 371 unmodified lines | ||
| 1168 | 911 | /> |
| 1169 | 912 | </SettingsSection> |
| 1170 | 913 | |
| 1171 | − <SettingsSection | |
| 1172 | − title="Providers" | |
| 1173 | − headerAction={ | |
| 1174 | − <div className="flex items-center gap-1.5"> | |
| 1175 | − <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 1176 | − <Tooltip> | |
| 1177 | − <TooltipTrigger | |
| 1178 | − render={ | |
| 1179 | − <Button | |
| 1180 | − size="icon-xs" | |
| 1181 | − variant="ghost" | |
| 1182 | − className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 1183 | − onClick={() => setIsAddInstanceDialogOpen(true)} | |
| 1184 | − aria-label="Add provider instance" | |
| 1185 | − > | |
| 1186 | − <PlusIcon className="size-3" /> | |
| 1187 | − </Button> | |
| 1188 | − } | |
| 1189 | − /> | |
| 1190 | − <TooltipPopup side="top">Add provider instance</TooltipPopup> | |
| 1191 | − </Tooltip> | |
| 1192 | − <Tooltip> | |
| 1193 | − <TooltipTrigger | |
| 1194 | − render={ | |
| 1195 | − <Button | |
| 1196 | − size="icon-xs" | |
| 1197 | − variant="ghost" | |
| 1198 | − className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 1199 | − disabled={isRefreshingProviders} | |
| 1200 | − onClick={() => void refreshProviders()} | |
| 1201 | − aria-label="Refresh provider status" | |
| 1202 | − > | |
| 1203 | − {isRefreshingProviders ? ( | |
| 1204 | − <LoaderIcon className="size-3 animate-spin" /> | |
| 1205 | − ) : ( | |
| 1206 | − <RefreshCwIcon className="size-3" /> | |
| 1207 | − )} | |
| 1208 | − </Button> | |
| 1209 | − } | |
| 1210 | − /> | |
| 1211 | − <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 1212 | − </Tooltip> | |
| 1213 | − </div> | |
| 1214 | − } | |
| 1215 | − > | |
| 1216 | − {rows.map((row) => { | |
| 1217 | − const driverOption = getDriverOption(row.driver); | |
| 1218 | − const liveProvider = serverProviders.find( | |
| 1219 | − (candidate) => candidate.instanceId === row.instanceId, | |
| 1220 | − ); | |
| 1221 | − const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { | |
| 1222 | − hiddenModels: [], | |
| 914 | + <SettingsSection title="Advanced"> | |
| 915 | + <SettingsRow | |
| 916 | + title="Keybindings" | |
| 917 | + description="Open the persisted `keybindings.json` file to edit advanced bindings directly." | |
| 918 | + status={ | |
| 919 | + <> | |
| 920 | + <span className="block break-all font-mono text-[11px] text-foreground"> | |
| 921 | + {keybindingsConfigPath ?? "Resolving keybindings path..."} | |
| 922 | + </span> | |
| 923 | + {openKeybindingsError ? ( | |
| 924 | + <span className="mt-1 block text-destructive">{openKeybindingsError}</span> | |
| 925 | + ) : ( | |
| 926 | + <span className="mt-1 block">Opens in your preferred editor.</span> | |
| 927 | + )} | |
| 928 | + </> | |
| 929 | + } | |
| 930 | + control={ | |
| 931 | + <Button | |
| 932 | + size="xs" | |
| 933 | + variant="outline" | |
| 934 | + disabled={!keybindingsConfigPath || isOpeningKeybindings} | |
| 935 | + onClick={openKeybindingsFile} | |
| 936 | + > | |
| 937 | + {isOpeningKeybindings ? "Opening..." : "Open file"} | |
| 938 | + </Button> | |
| 939 | + } | |
| 940 | + /> | |
| 941 | + </SettingsSection> | |
| 942 | + | |
| 943 | + <SettingsSection title="About"> | |
| 944 | + {isElectron ? ( | |
| 945 | + <AboutVersionSection /> | |
| 946 | + ) : ( | |
| 947 | + <SettingsRow | |
| 948 | + title={<AboutVersionTitle />} | |
| 949 | + description="Current version of the application." | |
| 950 | + /> | |
| 951 | + )} | |
| 952 | + <SettingsRow | |
| 953 | + title="Diagnostics" | |
| 954 | + description={diagnosticsDescription} | |
| 955 | + status={ | |
| 956 | + <> | |
| 957 | + <span className="block break-all font-mono text-[11px] text-foreground"> | |
| 958 | + {logsDirectoryPath ?? "Resolving logs directory..."} | |
| 959 | + </span> | |
| 960 | + {openDiagnosticsError ? ( | |
| 961 | + <span className="mt-1 block text-destructive">{openDiagnosticsError}</span> | |
| 962 | + ) : null} | |
| 963 | + </> | |
| 964 | + } | |
| 965 | + control={ | |
| 966 | + <Button | |
| 967 | + size="xs" | |
| 968 | + variant="outline" | |
| 969 | + disabled={!logsDirectoryPath || isOpeningLogsDirectory} | |
| 970 | + onClick={openLogsDirectory} | |
| 971 | + > | |
| 972 | + {isOpeningLogsDirectory ? "Opening..." : "Open logs folder"} | |
| 973 | + </Button> | |
| 974 | + } | |
| 975 | + /> | |
| 976 | + </SettingsSection> | |
| 977 | + </SettingsPageContainer> | |
| 978 | + ); | |
| 979 | +} | |
| 980 | + | |
| 981 | +export function ProviderSettingsPanel() { | |
| 982 | + const settings = useSettings(); | |
| 983 | + const { updateSettings } = useUpdateSettings(); | |
| 984 | + const serverProviders = useServerProviders(); | |
| 985 | + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | |
| 986 | + const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); | |
| 987 | + const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< | |
| 988 | + ReadonlySet<ProviderDriverKind> | |
| 989 | + >(() => new Set()); | |
| 990 | + const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({}); | |
| 991 | + const refreshingRef = useRef(false); | |
| 992 | + | |
| 993 | + const providerUpdateCandidates = useMemo( | |
| 994 | + () => collectProviderUpdateCandidates(serverProviders), | |
| 995 | + [serverProviders], | |
| 996 | + ); | |
| 997 | + const providerUpdateCandidateByInstanceId = useMemo( | |
| 998 | + () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), | |
| 999 | + [providerUpdateCandidates], | |
| 1000 | + ); | |
| 1001 | + const visibleProviderSettings = PROVIDER_SETTINGS.filter( | |
| 1002 | + (providerSettings) => | |
| 1003 | + providerSettings.provider !== "cursor" || | |
| 1004 | + serverProviders.some( | |
| 1005 | + (provider) => | |
| 1006 | + provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), | |
| 1007 | + ), | |
| 1008 | + ); | |
| 1009 | + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); | |
| 1010 | + const textGenInstanceId = textGenerationModelSelection.instanceId; | |
| 1011 | + const lastCheckedAt = | |
| 1012 | + serverProviders.length > 0 | |
| 1013 | + ? serverProviders.reduce( | |
| 1014 | + (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), | |
| 1015 | + serverProviders[0]!.checkedAt, | |
| 1016 | + ) | |
| 1017 | + : null; | |
| 1018 | + | |
| 1019 | + const refreshProviders = useCallback(() => { | |
| 1020 | + if (refreshingRef.current) return; | |
| 1021 | + refreshingRef.current = true; | |
| 1022 | + setIsRefreshingProviders(true); | |
| 1023 | + void ensureLocalApi() | |
| 1024 | + .server.refreshProviders() | |
| 1025 | + .catch((error: unknown) => { | |
| 1026 | + console.warn("Failed to refresh providers", error); | |
| 1027 | + }) | |
| 1028 | + .finally(() => { | |
| 1029 | + refreshingRef.current = false; | |
| 1030 | + setIsRefreshingProviders(false); | |
| 1031 | + }); | |
| 1032 | + }, []); | |
| 1033 | + | |
| 1034 | + const runProviderUpdate = useCallback(async (candidate: ProviderUpdateCandidate) => { | |
| 1035 | + let started = false; | |
| 1036 | + setUpdatingProviderDrivers((previous) => { | |
| 1037 | + if (previous.has(candidate.driver)) { | |
| 1038 | + return previous; | |
| 1039 | + } | |
| 1040 | + started = true; | |
| 1041 | + const next = new Set(previous); | |
| 1042 | + next.add(candidate.driver); | |
| 1043 | + return next; | |
| 1044 | + }); | |
| 1045 | + if (!started) { | |
| 1046 | + return; | |
| 1047 | + } | |
| 1048 | + | |
| 1049 | + try { | |
| 1050 | + await ensureLocalApi().server.updateProvider({ | |
| 1051 | + provider: candidate.driver, | |
| 1052 | + instanceId: candidate.instanceId, | |
| 1053 | + }); | |
| 1054 | + } catch (error) { | |
| 1055 | + toastManager.add( | |
| 1056 | + stackedThreadToast({ | |
| 1057 | + type: "error", | |
| 1058 | + title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, | |
| 1059 | + description: | |
| 1060 | + error instanceof Error | |
| 1061 | + ? error.message | |
| 1062 | + : "The provider update command could not be started.", | |
| 1063 | + }), | |
| 1064 | + ); | |
| 1065 | + } finally { | |
| 1066 | + setUpdatingProviderDrivers((previous) => { | |
| 1067 | + if (!previous.has(candidate.driver)) { | |
| 1068 | + return previous; | |
| 1069 | + } | |
| 1070 | + const next = new Set(previous); | |
| 1071 | + next.delete(candidate.driver); | |
| 1072 | + return next; | |
| 1073 | + }); | |
| 1074 | + } | |
| 1075 | + }, []); | |
| 1076 | + | |
| 1077 | + interface InstanceRow { | |
| 1078 | + readonly instanceId: ProviderInstanceId; | |
| 1079 | + readonly instance: ProviderInstanceConfig; | |
| 1080 | + readonly driver: ProviderDriverKind; | |
| 1081 | + readonly isDefault: boolean; | |
| 1082 | + readonly isDirty?: boolean; | |
| 1083 | + } | |
| 1084 | + | |
| 1085 | + const instancesByDriver = new Map< | |
| 1086 | + ProviderDriverKind, | |
| 1087 | + Array<[ProviderInstanceId, ProviderInstanceConfig]> | |
| 1088 | + >(); | |
| 1089 | + for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { | |
| 1090 | + const driver = instance.driver; | |
| 1091 | + const list = instancesByDriver.get(driver) ?? []; | |
| 1092 | + list.push([rawId as ProviderInstanceId, instance]); | |
| 1093 | + instancesByDriver.set(driver, list); | |
| 1094 | + } | |
| 1095 | + | |
| 1096 | + const defaultSlotIdsBySource = new Set<string>( | |
| 1097 | + visibleProviderSettings.map((providerSettings) => | |
| 1098 | + String(defaultInstanceIdForDriver(providerSettings.provider)), | |
| 1099 | + ), | |
| 1100 | + ); | |
| 1101 | + | |
| 1102 | + const rows: InstanceRow[] = []; | |
| 1103 | + const visibleDriverKinds = new Set<ProviderDriverKind>( | |
| 1104 | + visibleProviderSettings.map((providerSettings) => providerSettings.provider), | |
| 1105 | + ); | |
| 1106 | + | |
| 1107 | + for (const providerSettings of visibleProviderSettings) { | |
| 1108 | + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 1109 | + const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; | |
| 1110 | + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 1111 | + string, | |
| 1112 | + LegacyProviderSettings | |
| 1113 | + >; | |
| 1114 | + const driver = providerSettings.provider; | |
| 1115 | + const defaultInstanceId = defaultInstanceIdForDriver(driver); | |
| 1116 | + const explicitInstance = settings.providerInstances?.[defaultInstanceId]; | |
| 1117 | + const legacyConfig = legacyProviders[providerSettings.provider]!; | |
| 1118 | + const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; | |
| 1119 | + const effectiveInstance: ProviderInstanceConfig = | |
| 1120 | + explicitInstance ?? | |
| 1121 | + ({ | |
| 1122 | + driver, | |
| 1123 | + enabled: legacyConfig.enabled, | |
| 1124 | + config: legacyConfig, | |
| 1125 | + } satisfies ProviderInstanceConfig); | |
| 1126 | + const isDirty = | |
| 1127 | + explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); | |
| 1128 | + rows.push({ | |
| 1129 | + instanceId: defaultInstanceId, | |
| 1130 | + instance: effectiveInstance, | |
| 1131 | + driver, | |
| 1132 | + isDefault: true, | |
| 1133 | + isDirty, | |
| 1134 | + }); | |
| 1135 | + for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { | |
| 1136 | + if (id === defaultInstanceId) continue; | |
| 1137 | + rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); | |
| 1138 | + } | |
| 1139 | + } | |
| 1140 | + for (const [driver, list] of instancesByDriver) { | |
| 1141 | + if (visibleDriverKinds.has(driver)) continue; | |
| 1142 | + for (const [id, instance] of list) { | |
| 1143 | + rows.push({ | |
| 1144 | + instanceId: id, | |
| 1145 | + instance, | |
| 1146 | + driver: instance.driver, | |
| 1147 | + isDefault: defaultSlotIdsBySource.has(String(id)), | |
| 1148 | + }); | |
| 1149 | + } | |
| 1150 | + } | |
| 1151 | + | |
| 1152 | + const updateProviderInstance = ( | |
| 1153 | + row: InstanceRow, | |
| 1154 | + next: ProviderInstanceConfig, | |
| 1155 | + options?: { | |
| 1156 | + readonly textGenerationModelSelection?: Parameters< | |
| 1157 | + typeof buildProviderInstanceUpdatePatch | |
| 1158 | + >[0]["textGenerationModelSelection"]; | |
| 1159 | + }, | |
| 1160 | + ) => { | |
| 1161 | + updateSettings( | |
| 1162 | + buildProviderInstanceUpdatePatch({ | |
| 1163 | + settings, | |
| 1164 | + instanceId: row.instanceId, | |
| 1165 | + instance: next, | |
| 1166 | + driver: row.driver, | |
| 1167 | + isDefault: row.isDefault, | |
| 1168 | + textGenerationModelSelection: options?.textGenerationModelSelection, | |
| 1169 | + }), | |
| 1170 | + ); | |
| 1171 | + }; | |
| 1172 | + | |
| 1173 | + const deleteProviderInstance = (id: ProviderInstanceId) => { | |
| 1174 | + updateSettings({ | |
| 1175 | + providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), | |
| 1176 | + providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), | |
| 1177 | + favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), | |
| 1178 | + }); | |
| 1179 | + }; | |
| 1180 | + | |
| 1181 | + const updateProviderModelPreferences = ( | |
| 1182 | + instanceId: ProviderInstanceId, | |
| 1183 | + next: { | |
| 1184 | + readonly hiddenModels: ReadonlyArray<string>; | |
| 1185 | + readonly modelOrder: ReadonlyArray<string>; | |
| 1186 | + }, | |
| 1187 | + ) => { | |
| 1188 | + const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; | |
| 1189 | + const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; | |
| 1190 | + const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); | |
| 1191 | + updateSettings({ | |
| 1192 | + providerModelPreferences: | |
| 1193 | + hiddenModels.length === 0 && modelOrder.length === 0 | |
| 1194 | + ? rest | |
| 1195 | + : { | |
| 1196 | + ...rest, | |
| 1197 | + [instanceId]: { | |
| 1198 | + hiddenModels, | |
| 1199 | + modelOrder, | |
| 1200 | + }, | |
| 1201 | + }, | |
| 1202 | + }); | |
| 1203 | + }; | |
| 1204 | + | |
| 1205 | + const updateProviderFavoriteModels = ( | |
| 1206 | + instanceId: ProviderInstanceId, | |
| 1207 | + nextFavoriteModels: ReadonlyArray<string>, | |
| 1208 | + ) => { | |
| 1209 | + const favoriteModels = [ | |
| 1210 | + ...new Set(nextFavoriteModels.map((slug) => slug.trim()).filter((slug) => slug.length > 0)), | |
| 1211 | + ]; | |
| 1212 | + updateSettings({ | |
| 1213 | + favorites: [ | |
| 1214 | + ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), | |
| 1215 | + ...favoriteModels.map((model) => ({ provider: instanceId, model })), | |
| 1216 | + ], | |
| 1217 | + }); | |
| 1218 | + }; | |
| 1219 | + | |
| 1220 | + const resetDefaultInstance = (driverKind: ProviderDriverKind) => { | |
| 1221 | + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 1222 | + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 1223 | + string, | |
| 1224 | + LegacyProviderSettings | undefined | |
| 1225 | + >; | |
| 1226 | + const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | |
| 1227 | + const defaultLegacyProvider = defaultLegacyProviders[driverKind]; | |
| 1228 | + if (defaultLegacyProvider === undefined) return; | |
| 1229 | + updateSettings({ | |
| 1230 | + providers: { | |
| 1231 | + ...settings.providers, | |
| 1232 | + [driverKind]: defaultLegacyProvider, | |
| 1233 | + } as typeof settings.providers, | |
| 1234 | + providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), | |
| 1235 | + providerModelPreferences: withoutProviderInstanceKey( | |
| 1236 | + settings.providerModelPreferences, | |
| 1237 | + defaultInstanceId, | |
| 1238 | + ), | |
| 1239 | + favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), | |
| 1240 | + }); | |
| 1241 | + }; | |
| 1242 | + | |
| 1243 | + return ( | |
| 1244 | + <SettingsPageContainer> | |
| 1245 | + <SettingsSection | |
| 1246 | + title="Providers" | |
| 1247 | + headerAction={ | |
| 1248 | + <div className="flex items-center gap-1.5"> | |
| 1249 | + <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 1250 | + <Tooltip> | |
| 1251 | + <TooltipTrigger | |
| 1252 | + render={ | |
| 1253 | + <Button | |
| 1254 | + size="icon-xs" | |
| 1255 | + variant="ghost" | |
| 1256 | + className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 1257 | + onClick={() => setIsAddInstanceDialogOpen(true)} | |
| 1258 | + aria-label="Add provider instance" | |
| 1259 | + > | |
| 1260 | + <PlusIcon className="size-3" /> | |
| 1261 | + </Button> | |
| 1262 | + } | |
| 1263 | + /> | |
| 1264 | + <TooltipPopup side="top">Add provider instance</TooltipPopup> | |
| 1265 | + </Tooltip> | |
| 1266 | + <Tooltip> | |
| 1267 | + <TooltipTrigger | |
| 1268 | + render={ | |
| 1269 | + <Button | |
| 1270 | + size="icon-xs" | |
| 1271 | + variant="ghost" | |
| 1272 | + className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 1273 | + disabled={isRefreshingProviders} | |
| 1274 | + onClick={() => void refreshProviders()} | |
| 1275 | + aria-label="Refresh provider status" | |
| 1276 | + > | |
| 1277 | + {isRefreshingProviders ? ( | |
| 1278 | + <LoaderIcon className="size-3 animate-spin" /> | |
| 1279 | + ) : ( | |
| 1280 | + <RefreshCwIcon className="size-3" /> | |
| 1281 | + )} | |
| 1282 | + </Button> | |
| 1283 | + } | |
| 1284 | + /> | |
| 1285 | + <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 1286 | + </Tooltip> | |
| 1287 | + </div> | |
| 1288 | + } | |
| 1289 | + > | |
| 1290 | + {rows.map((row) => { | |
| 1291 | + const driverOption = getDriverOption(row.driver); | |
| 1292 | + const liveProvider = serverProviders.find( | |
| 1293 | + (candidate) => candidate.instanceId === row.instanceId, | |
| 1294 | + ); | |
| 1295 | + const updateCandidate = liveProvider | |
| 1296 | + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) | |
| 1297 | + : undefined; | |
| 1298 | + const isDriverUpdateRunning = | |
| 1299 | + updateCandidate !== undefined && | |
| 1300 | + (updatingProviderDrivers.has(updateCandidate.driver) || | |
| 1301 | + serverProviders.some( | |
| 1302 | + (provider) => | |
| 1303 | + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), | |
| 1304 | + )); | |
| 1305 | + const showInlineUpdateButton = | |
| 1306 | + updateCandidate !== undefined && | |
| 1307 | + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); | |
| 1308 | + const canRunInlineUpdate = | |
| 1309 | + updateCandidate !== undefined && | |
| 1310 | + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && | |
| 1311 | + !updatingProviderDrivers.has(updateCandidate.driver); | |
| 1312 | + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { | |
| 1313 | + hiddenModels: [], | |
| 1223 | 1314 | modelOrder: [], |
| 1224 | 1315 | }; |
| 1225 | 1316 | const favoriteModels = (settings.favorites ?? []) |
| @@ -1248,13 +1339,6 @@ export function GeneralSettingsPanel() { · 22 unmodified lines | ||
| 1248 | 1339 | })) |
| 1249 | 1340 | } |
| 1250 | 1341 | onUpdate={(next) => { |
| 1251 | − // When the user disables the exact instance the text-gen | |
| 1252 | − // selection points at, fall back to the global default so we | |
| 1253 | − // don't leave the selection dangling on a disabled instance. | |
| 1254 | − // Prior kind-level behavior cleared on any kind-matching | |
| 1255 | − // disable; instance-level addressing makes this narrower and | |
| 1256 | − // more accurate (other instances of the same kind stay | |
| 1257 | − // untouched). | |
| 1258 | 1342 | const wasEnabled = row.instance.enabled ?? true; |
| 1259 | 1343 | const isDisabling = next.enabled === false && wasEnabled; |
| 1260 | 1344 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; |
| @@ -1287,6 +1371,17 @@ export function GeneralSettingsPanel() { · 26 unmodified lines | ||
| 1287 | 1371 | modelOrder, |
| 1288 | 1372 | }) |
| 1289 | 1373 | } |
| 1374 | + onRunUpdate={ | |
| 1375 | + showInlineUpdateButton && updateCandidate | |
| 1376 | + ? () => { | |
| 1377 | + if (!canRunInlineUpdate) { | |
| 1378 | + return; | |
| 1379 | + } | |
| 1380 | + void runProviderUpdate(updateCandidate); | |
| 1381 | + } | |
| 1382 | + : undefined | |
| 1383 | + } | |
| 1384 | + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} | |
| 1290 | 1385 | /> |
| 1291 | 1386 | ); |
| 1292 | 1387 | })} |
| @@ -1296,70 +1391,6 @@ export function GeneralSettingsPanel() { · 3 unmodified lines | ||
| 1296 | 1391 | open={isAddInstanceDialogOpen} |
| 1297 | 1392 | onOpenChange={setIsAddInstanceDialogOpen} |
| 1298 | 1393 | /> |
| 1299 | − | |
| 1300 | − <SettingsSection title="Advanced"> | |
| 1301 | − <SettingsRow | |
| 1302 | − title="Keybindings" | |
| 1303 | − description="Open the persisted `keybindings.json` file to edit advanced bindings directly." | |
| 1304 | − status={ | |
| 1305 | − <> | |
| 1306 | − <span className="block break-all font-mono text-[11px] text-foreground"> | |
| 1307 | − {keybindingsConfigPath ?? "Resolving keybindings path..."} | |
| 1308 | − </span> | |
| 1309 | − {openKeybindingsError ? ( | |
| 1310 | − <span className="mt-1 block text-destructive">{openKeybindingsError}</span> | |
| 1311 | − ) : ( | |
| 1312 | − <span className="mt-1 block">Opens in your preferred editor.</span> | |
| 1313 | − )} | |
| 1314 | − </> | |
| 1315 | − } | |
| 1316 | − control={ | |
| 1317 | − <Button | |
| 1318 | − size="xs" | |
| 1319 | − variant="outline" | |
| 1320 | − disabled={!keybindingsConfigPath || isOpeningKeybindings} | |
| 1321 | − onClick={openKeybindingsFile} | |
| 1322 | − > | |
| 1323 | − {isOpeningKeybindings ? "Opening..." : "Open file"} | |
| 1324 | − </Button> | |
| 1325 | − } | |
| 1326 | − /> | |
| 1327 | − </SettingsSection> | |
| 1328 | − | |
| 1329 | − <SettingsSection title="About"> | |
| 1330 | − {isElectron ? ( | |
| 1331 | − <AboutVersionSection /> | |
| 1332 | − ) : ( | |
| 1333 | − <SettingsRow | |
| 1334 | − title={<AboutVersionTitle />} | |
| 1335 | − description="Current version of the application." | |
| 1336 | − /> | |
| 1337 | − )} | |
| 1338 | − <SettingsRow | |
| 1339 | − title="Diagnostics" | |
| 1340 | − description={diagnosticsDescription} | |
| 1341 | − status={ | |
| 1342 | − <> | |
| 1343 | − <span className="block break-all font-mono text-[11px] text-foreground"> | |
| 1344 | − {logsDirectoryPath ?? "Resolving logs directory..."} | |
| 1345 | − </span> | |
| 1346 | − {openDiagnosticsError ? ( | |
| 1347 | − <span className="mt-1 block text-destructive">{openDiagnosticsError}</span> | |
| 1348 | − ) : null} | |
| 1349 | − </> | |
| 1350 | − } | |
| 1351 | − control={ | |
| 1352 | − <Button | |
| 1353 | − size="xs" | |
| 1354 | − variant="outline" | |
| 1355 | − disabled={!logsDirectoryPath || isOpeningLogsDirectory} | |
| 1356 | − onClick={openLogsDirectory} | |
| 1357 | − > | |
| 1358 | − {isOpeningLogsDirectory ? "Opening..." : "Open logs folder"} | |
| 1359 | − </Button> | |
| 1360 | − } | |
| 1361 | − /> | |
| 1362 | − </SettingsSection> | |
| 1363 | 1394 | </SettingsPageContainer> |
| 1364 | 1395 | ); |
| 1365 | 1396 | } |
| @@ -1,5 +1,12 @@ | ||
| 1 | 1 | import { useCallback, type ComponentType } from "react"; |
| 2 | −import { ArchiveIcon, ArrowLeftIcon, GitBranchIcon, Link2Icon, Settings2Icon } from "lucide-react"; | |
| 2 | +import { | |
| 3 | + ArchiveIcon, | |
| 4 | + ArrowLeftIcon, | |
| 5 | + BotIcon, | |
| 6 | + GitBranchIcon, | |
| 7 | + Link2Icon, | |
| 8 | + Settings2Icon, | |
| 9 | +} from "lucide-react"; | |
| 3 | 10 | import { useCanGoBack, useNavigate } from "@tanstack/react-router"; |
| 4 | 11 | |
| 5 | 12 | import { |
| @@ -15,6 +22,7 @@ import { · 9 unmodified lines | ||
| 15 | 22 | |
| 16 | 23 | export type SettingsSectionPath = |
| 17 | 24 | | "/settings/general" |
| 25 | + | "/settings/providers" | |
| 18 | 26 | | "/settings/source-control" |
| 19 | 27 | | "/settings/connections" |
| 20 | 28 | | "/settings/archived"; |
| @@ -25,6 +33,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ · 4 unmodified lines | ||
| 25 | 33 | icon: ComponentType<{ className?: string }>; |
| 26 | 34 | }> = [ |
| 27 | 35 | { label: "General", to: "/settings/general", icon: Settings2Icon }, |
| 36 | + { label: "Providers", to: "/settings/providers", icon: BotIcon }, | |
| 28 | 37 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, |
| 29 | 38 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, |
| 30 | 39 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { ServerProvider } from "@t3tools/contracts"; | |
| 1 | +import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * Visual treatment for each server-reported provider status. Centralized so |
| @@ -89,3 +89,29 @@ export function getProviderVersionLabel(version: string | null | undefined) { · 84 unmodified lines | ||
| 89 | 89 | if (!version) return null; |
| 90 | 90 | return version.startsWith("v") ? version : `v${version}`; |
| 91 | 91 | } |
| 92 | + | |
| 93 | +export function getProviderVersionAdvisoryPresentation( | |
| 94 | + advisory: ServerProviderVersionAdvisory | undefined, | |
| 95 | +): { | |
| 96 | + readonly detail: string; | |
| 97 | + readonly updateCommand: string | null; | |
| 98 | + readonly emphasis: "normal" | "strong"; | |
| 99 | +} | null { | |
| 100 | + if (!advisory || advisory.status === "current" || advisory.status === "unknown") { | |
| 101 | + return null; | |
| 102 | + } | |
| 103 | + | |
| 104 | + const label = "Update available"; | |
| 105 | + const version = advisory.latestVersion; | |
| 106 | + const versionLabel = getProviderVersionLabel(version); | |
| 107 | + | |
| 108 | + return { | |
| 109 | + detail: | |
| 110 | + advisory.message ?? | |
| 111 | + (versionLabel | |
| 112 | + ? `${label}: install ${versionLabel}.` | |
| 113 | + : `${label}: install the latest provider version.`), | |
| 114 | + updateCommand: advisory.updateCommand, | |
| 115 | + emphasis: "normal" as const, | |
| 116 | + }; | |
| 117 | +} | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { Undo2Icon } from "lucide-react"; |
| 2 | −import { type ReactNode, useEffect, useState } from "react"; | |
| 2 | +import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from "react"; | |
| 3 | 3 | |
| 4 | 4 | import { cn } from "../../lib/utils"; |
| 5 | 5 | import { Button } from "../ui/button"; |
| @@ -20,15 +20,17 @@ export function SettingsSection({ · 14 unmodified lines | ||
| 20 | 20 | icon, |
| 21 | 21 | headerAction, |
| 22 | 22 | children, |
| 23 | −}: { | |
| 23 | + className, | |
| 24 | + ...sectionProps | |
| 25 | +}: ComponentPropsWithoutRef<"section"> & { | |
| 24 | 26 | title: string; |
| 25 | 27 | icon?: ReactNode; |
| 26 | 28 | headerAction?: ReactNode; |
| 27 | 29 | children: ReactNode; |
| 28 | 30 | }) { |
| 29 | 31 | return ( |
| 30 | − <section className="space-y-2.5"> | |
| 31 | − <div className="flex h-5 items-center justify-between px-1"> | |
| 32 | + <section {...sectionProps} className={cn("space-y-2.5", className)}> | |
| 33 | + <div className="flex items-center justify-between px-1"> | |
| 32 | 34 | <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50"> |
| 33 | 35 | <span className="inline-block h-px w-3 bg-border" aria-hidden /> |
| 34 | 36 | {icon} |
| @@ -62,7 +64,7 @@ export function SettingsRow({ · 27 unmodified lines | ||
| 62 | 64 | <div |
| 63 | 65 | className={cn( |
| 64 | 66 | "border-t border-border/60 px-4 first:border-t-0 sm:px-5", |
| 65 | − children ? "pt-4 pb-0" : "py-4", | |
| 67 | + children ? "pt-3.5 pb-0" : "py-3.5", | |
| 66 | 68 | )} |
| 67 | 69 | > |
| 68 | 70 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| @@ -75,7 +77,7 @@ export function SettingsRow({ · 6 unmodified lines | ||
| 75 | 77 | {resetAction} |
| 76 | 78 | </span> |
| 77 | 79 | </div> |
| 78 | − <p className="text-xs leading-relaxed text-muted-foreground/80">{description}</p> | |
| 80 | + <p className="text-xs text-muted-foreground/80">{description}</p> | |
| 79 | 81 | {status ? <div className="pt-0.5 text-[11px] text-muted-foreground">{status}</div> : null} |
| 80 | 82 | </div> |
| 81 | 83 | {control ? ( |
| @@ -0,0 +1,207 @@ | ||
| 1 | +import { useNavigate } from "@tanstack/react-router"; | |
| 2 | +import type { ServerProvider } from "@t3tools/contracts"; | |
| 3 | +import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | |
| 4 | +import { useCallback, useEffect, useState, type CSSProperties } from "react"; | |
| 5 | + | |
| 6 | +import { useServerProviders } from "../../rpc/serverState"; | |
| 7 | +import { | |
| 8 | + getProviderUpdateSidebarPillView, | |
| 9 | + type ProviderUpdateSidebarPillView, | |
| 10 | +} from "../ProviderUpdateLaunchNotification.logic"; | |
| 11 | +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 12 | + | |
| 13 | +const PROVIDER_UPDATE_PILL_STYLES = { | |
| 14 | + loading: | |
| 15 | + "bg-primary/15 text-primary group-has-[button.provider-update-main:hover]/provider-update:bg-primary/22", | |
| 16 | + success: | |
| 17 | + "bg-success/12 text-success group-has-[button.provider-update-main:hover]/provider-update:bg-success/18", | |
| 18 | + warning: | |
| 19 | + "bg-warning/12 text-warning group-has-[button.provider-update-main:hover]/provider-update:bg-warning/18", | |
| 20 | + error: | |
| 21 | + "bg-destructive/12 text-destructive group-has-[button.provider-update-main:hover]/provider-update:bg-destructive/18", | |
| 22 | +} as const; | |
| 23 | + | |
| 24 | +const PROVIDER_UPDATE_PILL_PROGRESS_STYLES = { | |
| 25 | + success: "bg-success/18", | |
| 26 | + warning: "bg-warning/14", | |
| 27 | + error: "bg-destructive/14", | |
| 28 | +} as const; | |
| 29 | + | |
| 30 | +function latestProviderCheckedAt( | |
| 31 | + providers: ReadonlyArray<Pick<ServerProvider, "checkedAt">>, | |
| 32 | +): string | undefined { | |
| 33 | + return providers.reduce<string | undefined>( | |
| 34 | + (latest, provider) => | |
| 35 | + latest === undefined || provider.checkedAt > latest ? provider.checkedAt : latest, | |
| 36 | + undefined, | |
| 37 | + ); | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function SidebarProviderUpdatePill() { | |
| 41 | + const navigate = useNavigate(); | |
| 42 | + const providers = useServerProviders(); | |
| 43 | + const [dismissedKeys, setDismissedKeys] = useState<ReadonlySet<string>>(() => new Set()); | |
| 44 | + const [renderedView, setRenderedView] = useState<ProviderUpdateSidebarPillView | null>(null); | |
| 45 | + const [pendingView, setPendingView] = useState<ProviderUpdateSidebarPillView | null>(null); | |
| 46 | + const [exitingKey, setExitingKey] = useState<string | null>(null); | |
| 47 | + const [dismissAfterExitKey, setDismissAfterExitKey] = useState<string | null>(null); | |
| 48 | + const [visibleAfterIso, setVisibleAfterIso] = useState<string | undefined>(); | |
| 49 | + const effectiveVisibleAfterIso = visibleAfterIso ?? latestProviderCheckedAt(providers); | |
| 50 | + const view = getProviderUpdateSidebarPillView(providers, { | |
| 51 | + ...(effectiveVisibleAfterIso !== undefined | |
| 52 | + ? { visibleAfterIso: effectiveVisibleAfterIso } | |
| 53 | + : {}), | |
| 54 | + dismissedKeys, | |
| 55 | + }); | |
| 56 | + | |
| 57 | + useEffect(() => { | |
| 58 | + if (visibleAfterIso === undefined && effectiveVisibleAfterIso !== undefined) { | |
| 59 | + setVisibleAfterIso(effectiveVisibleAfterIso); | |
| 60 | + } | |
| 61 | + }, [effectiveVisibleAfterIso, visibleAfterIso]); | |
| 62 | + | |
| 63 | + const openProviderSettings = useCallback(() => { | |
| 64 | + void navigate({ to: "/settings/providers" }); | |
| 65 | + }, [navigate]); | |
| 66 | + const displayedView = renderedView ?? view; | |
| 67 | + const dismissAfterVisibleMs = displayedView?.dismissAfterVisibleMs; | |
| 68 | + const viewKey = displayedView?.key ?? null; | |
| 69 | + const showDismissProgress = | |
| 70 | + dismissAfterVisibleMs !== undefined && | |
| 71 | + displayedView?.tone !== "loading" && | |
| 72 | + exitingKey !== viewKey; | |
| 73 | + | |
| 74 | + const startExit = useCallback( | |
| 75 | + (key: string, nextView: ProviderUpdateSidebarPillView | null, dismissKey?: string) => { | |
| 76 | + if (exitingKey === key) { | |
| 77 | + return; | |
| 78 | + } | |
| 79 | + setPendingView(nextView); | |
| 80 | + setExitingKey(key); | |
| 81 | + setDismissAfterExitKey(dismissKey ?? null); | |
| 82 | + }, | |
| 83 | + [exitingKey], | |
| 84 | + ); | |
| 85 | + | |
| 86 | + useEffect(() => { | |
| 87 | + if (exitingKey !== null) { | |
| 88 | + return; | |
| 89 | + } | |
| 90 | + if (!renderedView) { | |
| 91 | + if (view) { | |
| 92 | + setRenderedView(view); | |
| 93 | + } | |
| 94 | + return; | |
| 95 | + } | |
| 96 | + if (!view) { | |
| 97 | + startExit(renderedView.key, null); | |
| 98 | + return; | |
| 99 | + } | |
| 100 | + if (view.key !== renderedView.key) { | |
| 101 | + startExit(renderedView.key, view); | |
| 102 | + return; | |
| 103 | + } | |
| 104 | + }, [exitingKey, renderedView, startExit, view]); | |
| 105 | + | |
| 106 | + useEffect(() => { | |
| 107 | + if (!dismissAfterVisibleMs || !viewKey) { | |
| 108 | + return; | |
| 109 | + } | |
| 110 | + if (exitingKey === viewKey) { | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + const timeoutId = window.setTimeout(() => { | |
| 114 | + startExit(viewKey, null, viewKey); | |
| 115 | + }, dismissAfterVisibleMs); | |
| 116 | + | |
| 117 | + return () => window.clearTimeout(timeoutId); | |
| 118 | + }, [dismissAfterVisibleMs, exitingKey, startExit, viewKey]); | |
| 119 | + | |
| 120 | + if (!displayedView) { | |
| 121 | + return null; | |
| 122 | + } | |
| 123 | + | |
| 124 | + return ( | |
| 125 | + <div | |
| 126 | + className={`group/provider-update relative flex h-7 w-full items-center overflow-hidden rounded-lg text-xs font-medium transform-gpu transition-all duration-180 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform ${ | |
| 127 | + PROVIDER_UPDATE_PILL_STYLES[displayedView.tone] | |
| 128 | + } ${ | |
| 129 | + exitingKey === displayedView.key | |
| 130 | + ? "pointer-events-none translate-y-1.5 opacity-0" | |
| 131 | + : "translate-y-0 opacity-100" | |
| 132 | + }`} | |
| 133 | + onTransitionEnd={(event) => { | |
| 134 | + if (event.target !== event.currentTarget) { | |
| 135 | + return; | |
| 136 | + } | |
| 137 | + if (!displayedView || exitingKey !== displayedView.key) { | |
| 138 | + return; | |
| 139 | + } | |
| 140 | + if (dismissAfterExitKey === displayedView.key) { | |
| 141 | + setDismissedKeys((previous) => new Set(previous).add(displayedView.key)); | |
| 142 | + } | |
| 143 | + setRenderedView(pendingView); | |
| 144 | + setPendingView(null); | |
| 145 | + setExitingKey(null); | |
| 146 | + setDismissAfterExitKey(null); | |
| 147 | + }} | |
| 148 | + > | |
| 149 | + {showDismissProgress ? ( | |
| 150 | + <div | |
| 151 | + key={displayedView.key} | |
| 152 | + aria-hidden="true" | |
| 153 | + className={`provider-update-pill-progress pointer-events-none absolute inset-y-0 left-0 w-full origin-left border-r border-current/15 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.08)] ${ | |
| 154 | + PROVIDER_UPDATE_PILL_PROGRESS_STYLES[displayedView.tone] | |
| 155 | + }`} | |
| 156 | + style={ | |
| 157 | + { | |
| 158 | + "--provider-update-pill-dismiss-ms": `${dismissAfterVisibleMs}ms`, | |
| 159 | + } as CSSProperties | |
| 160 | + } | |
| 161 | + /> | |
| 162 | + ) : null} | |
| 163 | + <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors" /> | |
| 164 | + <Tooltip> | |
| 165 | + <TooltipTrigger | |
| 166 | + render={ | |
| 167 | + <button | |
| 168 | + type="button" | |
| 169 | + aria-label={displayedView.description} | |
| 170 | + className="provider-update-main relative z-[1] flex h-full flex-1 items-center gap-2 px-2 text-left" | |
| 171 | + onClick={openProviderSettings} | |
| 172 | + > | |
| 173 | + {displayedView.tone === "loading" ? ( | |
| 174 | + <LoaderIcon className="size-3.5 animate-spin" /> | |
| 175 | + ) : displayedView.tone === "success" ? ( | |
| 176 | + <CircleCheckIcon className="size-3.5" /> | |
| 177 | + ) : displayedView.tone === "error" ? ( | |
| 178 | + <TriangleAlertIcon className="size-3.5" /> | |
| 179 | + ) : ( | |
| 180 | + <DownloadIcon className="size-3.5" /> | |
| 181 | + )} | |
| 182 | + <span>{displayedView.title}</span> | |
| 183 | + </button> | |
| 184 | + } | |
| 185 | + /> | |
| 186 | + <TooltipPopup side="top">{displayedView.description}</TooltipPopup> | |
| 187 | + </Tooltip> | |
| 188 | + {displayedView.dismissible && ( | |
| 189 | + <Tooltip> | |
| 190 | + <TooltipTrigger | |
| 191 | + render={ | |
| 192 | + <button | |
| 193 | + type="button" | |
| 194 | + aria-label="Dismiss provider update notice" | |
| 195 | + className="relative z-[1] mr-1 inline-flex size-5 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100" | |
| 196 | + onClick={() => startExit(displayedView.key, null, displayedView.key)} | |
| 197 | + > | |
| 198 | + <XIcon className="size-3.5" /> | |
| 199 | + </button> | |
| 200 | + } | |
| 201 | + /> | |
| 202 | + <TooltipPopup side="top">Dismiss until provider status changes</TooltipPopup> | |
| 203 | + </Tooltip> | |
| 204 | + )} | |
| 205 | + </div> | |
| 206 | + ); | |
| 207 | +} | |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | useMemo, |
| 7 | 7 | useState, |
| 8 | 8 | type CSSProperties, |
| 9 | + type ComponentPropsWithoutRef, | |
| 9 | 10 | type KeyboardEvent, |
| 10 | 11 | type ReactNode, |
| 11 | 12 | } from "react"; |
| @@ -38,9 +39,20 @@ import { · 26 unmodified lines | ||
| 38 | 39 | export type ThreadToastData = { |
| 39 | 40 | threadRef?: ScopedThreadRef | null; |
| 40 | 41 | threadId?: ThreadId | null; |
| 42 | + leadingIcon?: ReactNode; | |
| 41 | 43 | tooltipStyle?: boolean; |
| 44 | + onClose?: (() => void) | undefined; | |
| 42 | 45 | dismissAfterVisibleMs?: number; |
| 43 | 46 | hideCopyButton?: boolean; |
| 47 | + secondaryActionProps?: ComponentPropsWithoutRef<"button">; | |
| 48 | + secondaryActionVariant?: | |
| 49 | + | "default" | |
| 50 | + | "destructive" | |
| 51 | + | "destructive-outline" | |
| 52 | + | "ghost" | |
| 53 | + | "link" | |
| 54 | + | "outline" | |
| 55 | + | "secondary"; | |
| 44 | 56 | /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */ |
| 45 | 57 | expandableContent?: ReactNode; |
| 46 | 58 | expandableLabels?: { expand?: string; collapse?: string }; |
| @@ -90,6 +102,15 @@ const toastCornerOrbClass = cn( · 43 unmodified lines | ||
| 90 | 102 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", |
| 91 | 103 | ); |
| 92 | 104 | |
| 105 | +function handleToastDismissClick( | |
| 106 | + manager: typeof toastManager | typeof anchoredToastManager, | |
| 107 | + toastId: ToastId, | |
| 108 | + onClose: (() => void) | undefined, | |
| 109 | +) { | |
| 110 | + onClose?.(); | |
| 111 | + manager.close(toastId); | |
| 112 | +} | |
| 113 | + | |
| 93 | 114 | function CopyErrorButton({ text }: { text: string }) { |
| 94 | 115 | const { copyToClipboard, isCopied } = useCopyToClipboard(); |
| 95 | 116 | |
| @@ -232,6 +253,7 @@ interface ToastBodyDescriptor { · 136 unmodified lines | ||
| 232 | 253 | readonly Icon: ToastIconComponent | null | undefined; |
| 233 | 254 | readonly stackedActionLayout: boolean; |
| 234 | 255 | readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>; |
| 256 | + readonly secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]>; | |
| 235 | 257 | readonly copyErrorText: string | null; |
| 236 | 258 | readonly hasTrailingControls: boolean; |
| 237 | 259 | readonly inlineContentEndPad: string; |
| @@ -248,16 +270,21 @@ function deriveToastBodyDescriptor(toast: { · 10 unmodified lines | ||
| 248 | 270 | toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; |
| 249 | 271 | const actionVariant: NonNullable<ThreadToastData["actionVariant"]> = |
| 250 | 272 | toast.data?.actionVariant ?? "default"; |
| 273 | + const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> = | |
| 274 | + toast.data?.secondaryActionVariant ?? "outline"; | |
| 251 | 275 | const copyErrorText = |
| 252 | 276 | toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton |
| 253 | 277 | ? toast.description |
| 254 | 278 | : null; |
| 255 | − const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined; | |
| 279 | + const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; | |
| 280 | + const hasTrailingControls = | |
| 281 | + copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction; | |
| 256 | 282 | const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; |
| 257 | 283 | return { |
| 258 | 284 | Icon, |
| 259 | 285 | stackedActionLayout, |
| 260 | 286 | actionVariant, |
| 287 | + secondaryActionVariant, | |
| 261 | 288 | copyErrorText, |
| 262 | 289 | hasTrailingControls, |
| 263 | 290 | inlineContentEndPad, |
| @@ -277,22 +304,35 @@ function ToastBodyContent({ · 13 unmodified lines | ||
| 277 | 304 | copyErrorText, |
| 278 | 305 | actionProps, |
| 279 | 306 | actionVariant, |
| 307 | + secondaryActionVariant, | |
| 280 | 308 | hasTrailingControls, |
| 281 | 309 | toastData, |
| 282 | 310 | toastDescription, |
| 283 | 311 | toastType, |
| 284 | 312 | }: ToastBodyContentProps) { |
| 313 | + const secondaryActionProps = toastData?.secondaryActionProps; | |
| 314 | + const leadingIcon = toastData?.leadingIcon; | |
| 315 | + const { className: secondaryActionClassName, ...secondaryActionRest } = | |
| 316 | + secondaryActionProps ?? {}; | |
| 317 | + | |
| 285 | 318 | return ( |
| 286 | 319 | <> |
| 287 | 320 | <div className={cn("flex min-w-0 gap-2", !stackedActionLayout && "flex-1")}> |
| 288 | − {Icon && ( | |
| 321 | + {leadingIcon ? ( | |
| 322 | + <div | |
| 323 | + className="flex h-lh w-4 shrink-0 items-center justify-center" | |
| 324 | + data-slot="toast-icon" | |
| 325 | + > | |
| 326 | + {leadingIcon} | |
| 327 | + </div> | |
| 328 | + ) : Icon ? ( | |
| 289 | 329 | <div |
| 290 | 330 | className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" |
| 291 | 331 | data-slot="toast-icon" |
| 292 | 332 | > |
| 293 | 333 | <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" /> |
| 294 | 334 | </div> |
| 295 | − )} | |
| 335 | + ) : null} | |
| 296 | 336 | <div |
| 297 | 337 | className={cn( |
| 298 | 338 | "flex min-h-0 min-w-0 flex-1 flex-col gap-0.5", |
| @@ -315,6 +355,16 @@ function ToastBodyContent({ · 16 unmodified lines | ||
| 315 | 355 | )} |
| 316 | 356 | > |
| 317 | 357 | {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null} |
| 358 | + {secondaryActionProps ? ( | |
| 359 | + <button | |
| 360 | + {...secondaryActionRest} | |
| 361 | + className={cn( | |
| 362 | + buttonVariants({ size: "xs", variant: secondaryActionVariant }), | |
| 363 | + secondaryActionClassName, | |
| 364 | + )} | |
| 365 | + type="button" | |
| 366 | + /> | |
| 367 | + ) : null} | |
| 318 | 368 | {actionProps ? ( |
| 319 | 369 | <Toast.Action |
| 320 | 370 | className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")} |
| @@ -579,7 +629,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 258 unmodified lines | ||
| 579 | 629 | aria-label="Dismiss notification" |
| 580 | 630 | className={toastCornerOrbClass} |
| 581 | 631 | data-slot="toast-close" |
| 582 | − onClick={() => toastManager.close(toast.id)} | |
| 632 | + onClick={() => | |
| 633 | + handleToastDismissClick(toastManager, toast.id, toast.data?.onClose) | |
| 634 | + } | |
| 583 | 635 | type="button" |
| 584 | 636 | > |
| 585 | 637 | <XIcon className="size-3" strokeWidth={2.25} /> |
| @@ -670,7 +722,13 @@ function AnchoredToasts() { · 84 unmodified lines | ||
| 670 | 722 | aria-label="Dismiss notification" |
| 671 | 723 | className={toastCornerOrbClass} |
| 672 | 724 | data-slot="toast-close" |
| 673 | − onClick={() => anchoredToastManager.close(toast.id)} | |
| 725 | + onClick={() => | |
| 726 | + handleToastDismissClick( | |
| 727 | + anchoredToastManager, | |
| 728 | + toast.id, | |
| 729 | + toast.data?.onClose, | |
| 730 | + ) | |
| 731 | + } | |
| 674 | 732 | type="button" |
| 675 | 733 | > |
| 676 | 734 | <XIcon className="size-3" strokeWidth={2.25} /> |
| @@ -26,6 +26,7 @@ import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/ · 25 unmodified lines | ||
| 26 | 26 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| 27 | 27 | |
| 28 | 28 | const clientSettingsListeners = new Set<() => void>(); |
| 29 | +const clientSettingsHydrationListeners = new Set<() => void>(); | |
| 29 | 30 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; |
| 30 | 31 | let clientSettingsHydrated = false; |
| 31 | 32 | let clientSettingsHydrationPromise: Promise<void> | null = null; |
| @@ -36,6 +37,12 @@ function emitClientSettingsChange() { · 4 unmodified lines | ||
| 36 | 37 | } |
| 37 | 38 | } |
| 38 | 39 | |
| 40 | +function emitClientSettingsHydrationChange() { | |
| 41 | + for (const listener of clientSettingsHydrationListeners) { | |
| 42 | + listener(); | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 39 | 46 | function getClientSettingsSnapshot(): ClientSettings { |
| 40 | 47 | return clientSettingsSnapshot; |
| 41 | 48 | } |
| @@ -45,6 +52,14 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { · 3 unmodified lines | ||
| 45 | 52 | emitClientSettingsChange(); |
| 46 | 53 | } |
| 47 | 54 | |
| 55 | +function setClientSettingsHydrated(nextHydrated: boolean): void { | |
| 56 | + if (clientSettingsHydrated === nextHydrated) { | |
| 57 | + return; | |
| 58 | + } | |
| 59 | + clientSettingsHydrated = nextHydrated; | |
| 60 | + emitClientSettingsHydrationChange(); | |
| 61 | +} | |
| 62 | + | |
| 48 | 63 | function subscribeClientSettings(listener: () => void): () => void { |
| 49 | 64 | clientSettingsListeners.add(listener); |
| 50 | 65 | void hydrateClientSettings(); |
| @@ -53,6 +68,18 @@ function subscribeClientSettings(listener: () => void): () => void { · 2 unmodified lines | ||
| 53 | 68 | }; |
| 54 | 69 | } |
| 55 | 70 | |
| 71 | +function getClientSettingsHydratedSnapshot(): boolean { | |
| 72 | + return clientSettingsHydrated; | |
| 73 | +} | |
| 74 | + | |
| 75 | +function subscribeClientSettingsHydration(listener: () => void): () => void { | |
| 76 | + clientSettingsHydrationListeners.add(listener); | |
| 77 | + void hydrateClientSettings(); | |
| 78 | + return () => { | |
| 79 | + clientSettingsHydrationListeners.delete(listener); | |
| 80 | + }; | |
| 81 | +} | |
| 82 | + | |
| 56 | 83 | async function hydrateClientSettings(): Promise<void> { |
| 57 | 84 | if (clientSettingsHydrated) { |
| 58 | 85 | return; |
| @@ -70,7 +97,7 @@ async function hydrateClientSettings(): Promise<void> { · 11 unmodified lines | ||
| 70 | 97 | } catch (error) { |
| 71 | 98 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); |
| 72 | 99 | } finally { |
| 73 | − clientSettingsHydrated = true; | |
| 100 | + setClientSettingsHydrated(true); | |
| 74 | 101 | } |
| 75 | 102 | })(); |
| 76 | 103 | |
| @@ -132,6 +159,14 @@ export function getClientSettings(): ClientSettings { · 55 unmodified lines | ||
| 132 | 159 | return getClientSettingsSnapshot(); |
| 133 | 160 | } |
| 134 | 161 | |
| 162 | +export function useClientSettingsHydrated(): boolean { | |
| 163 | + return useSyncExternalStore( | |
| 164 | + subscribeClientSettingsHydration, | |
| 165 | + getClientSettingsHydratedSnapshot, | |
| 166 | + () => false, | |
| 167 | + ); | |
| 168 | +} | |
| 169 | + | |
| 135 | 170 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { |
| 136 | 171 | const serverSettings = useServerSettings(); |
| 137 | 172 | const clientSettings = useSyncExternalStore( |
| @@ -193,4 +228,5 @@ export function __resetClientSettingsPersistenceForTests(): void { · 55 unmodified lines | ||
| 193 | 228 | clientSettingsHydrated = false; |
| 194 | 229 | clientSettingsHydrationPromise = null; |
| 195 | 230 | clientSettingsListeners.clear(); |
| 231 | + clientSettingsHydrationListeners.clear(); | |
| 196 | 232 | } |
| @@ -485,6 +485,19 @@ label:has(> select#reasoning-effort) select { · 484 unmodified lines | ||
| 485 | 485 | text-align: left; |
| 486 | 486 | } |
| 487 | 487 | |
| 488 | +@keyframes provider-update-pill-countdown { | |
| 489 | + from { | |
| 490 | + transform: scaleX(1); | |
| 491 | + } | |
| 492 | + to { | |
| 493 | + transform: scaleX(0); | |
| 494 | + } | |
| 495 | +} | |
| 496 | + | |
| 497 | +.provider-update-pill-progress { | |
| 498 | + animation: provider-update-pill-countdown var(--provider-update-pill-dismiss-ms) linear forwards; | |
| 499 | +} | |
| 500 | + | |
| 488 | 501 | /* Diffs theme bridge (match diff surfaces to app palette) */ |
| 489 | 502 | .diff-panel-viewport { |
| 490 | 503 | background: color-mix(in srgb, var(--background) 94%, var(--card)); |
| @@ -85,6 +85,7 @@ const rpcClientMock = { · 84 unmodified lines | ||
| 85 | 85 | server: { |
| 86 | 86 | getConfig: vi.fn(), |
| 87 | 87 | refreshProviders: vi.fn(), |
| 88 | + updateProvider: vi.fn(), | |
| 88 | 89 | upsertKeybinding: vi.fn(), |
| 89 | 90 | getSettings: vi.fn(), |
| 90 | 91 | updateSettings: vi.fn(), |
| @@ -514,6 +515,34 @@ describe("wsApi", () => { · 423 unmodified lines | ||
| 514 | 515 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); |
| 515 | 516 | }); |
| 516 | 517 | |
| 518 | + it("forwards provider updates directly to the RPC client", async () => { | |
| 519 | + const nextProviders: ReadonlyArray<ServerProvider> = [ | |
| 520 | + { | |
| 521 | + ...defaultProviders[0]!, | |
| 522 | + updateState: { | |
| 523 | + status: "succeeded", | |
| 524 | + startedAt: "2026-01-03T00:00:00.000Z", | |
| 525 | + finishedAt: "2026-01-03T00:00:01.000Z", | |
| 526 | + message: "Provider updated.", | |
| 527 | + output: null, | |
| 528 | + }, | |
| 529 | + }, | |
| 530 | + ]; | |
| 531 | + rpcClientMock.server.updateProvider.mockResolvedValue({ providers: nextProviders }); | |
| 532 | + const { createLocalApi } = await import("./localApi"); | |
| 533 | + | |
| 534 | + const api = createLocalApi(rpcClientMock as never); | |
| 535 | + | |
| 536 | + await expect( | |
| 537 | + api.server.updateProvider({ provider: ProviderDriverKind.make("codex") }), | |
| 538 | + ).resolves.toEqual({ | |
| 539 | + providers: nextProviders, | |
| 540 | + }); | |
| 541 | + expect(rpcClientMock.server.updateProvider).toHaveBeenCalledWith({ | |
| 542 | + provider: ProviderDriverKind.make("codex"), | |
| 543 | + }); | |
| 544 | + }); | |
| 545 | + | |
| 517 | 546 | it("forwards server settings updates directly to the RPC client", async () => { |
| 518 | 547 | const nextSettings = { |
| 519 | 548 | ...DEFAULT_SERVER_SETTINGS, |
| @@ -573,6 +602,7 @@ describe("wsApi", () => { · 53 unmodified lines | ||
| 573 | 602 | autoOpenPlanSidebar: false, |
| 574 | 603 | confirmThreadArchive: true, |
| 575 | 604 | confirmThreadDelete: false, |
| 605 | + dismissedProviderUpdateNotificationKeys: [], | |
| 576 | 606 | diffIgnoreWhitespace: true, |
| 577 | 607 | diffWordWrap: true, |
| 578 | 608 | favorites: [], |
| @@ -634,6 +664,7 @@ describe("wsApi", () => { · 55 unmodified lines | ||
| 634 | 664 | autoOpenPlanSidebar: false, |
| 635 | 665 | confirmThreadArchive: true, |
| 636 | 666 | confirmThreadDelete: false, |
| 667 | + dismissedProviderUpdateNotificationKeys: [], | |
| 637 | 668 | diffIgnoreWhitespace: true, |
| 638 | 669 | diffWordWrap: true, |
| 639 | 670 | favorites: [], |
| @@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { · 124 unmodified lines | ||
| 125 | 125 | rpcClient |
| 126 | 126 | ? rpcClient.server.refreshProviders() |
| 127 | 127 | : Promise.reject(unavailableLocalBackendError()), |
| 128 | + updateProvider: (input) => | |
| 129 | + rpcClient | |
| 130 | + ? rpcClient.server.updateProvider(input) | |
| 131 | + : Promise.reject(unavailableLocalBackendError()), | |
| 128 | 132 | upsertKeybinding: (input) => |
| 129 | 133 | rpcClient |
| 130 | 134 | ? rpcClient.server.upsertKeybinding(input) |
| @@ -0,0 +1,23 @@ | ||
| 1 | +import { beforeEach, describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { removeLocalStorageItem } from "./hooks/useLocalStorage"; | |
| 4 | +import { | |
| 5 | + dismissProviderUpdateNotification, | |
| 6 | + isProviderUpdateNotificationDismissed, | |
| 7 | + PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | |
| 8 | +} from "./providerUpdateDismissal"; | |
| 9 | + | |
| 10 | +describe("providerUpdateDismissal", () => { | |
| 11 | + beforeEach(() => { | |
| 12 | + removeLocalStorageItem(PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY); | |
| 13 | + }); | |
| 14 | + | |
| 15 | + it("persists provider update dismissals by notification key", () => { | |
| 16 | + expect(isProviderUpdateNotificationDismissed("opencode:1.14.33")).toBe(false); | |
| 17 | + | |
| 18 | + dismissProviderUpdateNotification("opencode:1.14.33"); | |
| 19 | + | |
| 20 | + expect(isProviderUpdateNotificationDismissed("opencode:1.14.33")).toBe(true); | |
| 21 | + expect(isProviderUpdateNotificationDismissed("opencode:1.14.34")).toBe(false); | |
| 22 | + }); | |
| 23 | +}); | |
| @@ -0,0 +1,92 @@ | ||
| 1 | +import { useCallback, useMemo } from "react"; | |
| 2 | +import * as Schema from "effect/Schema"; | |
| 3 | + | |
| 4 | +import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; | |
| 5 | + | |
| 6 | +export const PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY = "t3code:provider-update-dismissals:v1"; | |
| 7 | + | |
| 8 | +const ProviderUpdateDismissalsSchema = Schema.Struct({ | |
| 9 | + keys: Schema.Array(Schema.String), | |
| 10 | +}); | |
| 11 | + | |
| 12 | +type ProviderUpdateDismissals = typeof ProviderUpdateDismissalsSchema.Type; | |
| 13 | + | |
| 14 | +function readProviderUpdateDismissals(): ProviderUpdateDismissals { | |
| 15 | + try { | |
| 16 | + return ( | |
| 17 | + getLocalStorageItem( | |
| 18 | + PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | |
| 19 | + ProviderUpdateDismissalsSchema, | |
| 20 | + ) ?? { | |
| 21 | + keys: [], | |
| 22 | + } | |
| 23 | + ); | |
| 24 | + } catch { | |
| 25 | + return { keys: [] }; | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void { | |
| 30 | + try { | |
| 31 | + setLocalStorageItem( | |
| 32 | + PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | |
| 33 | + document, | |
| 34 | + ProviderUpdateDismissalsSchema, | |
| 35 | + ); | |
| 36 | + } catch { | |
| 37 | + // Dismissal state is best-effort UI state; a storage failure should not block the toast. | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +export function isProviderUpdateNotificationDismissed( | |
| 42 | + dismissalKey: string | null | undefined, | |
| 43 | +): boolean { | |
| 44 | + if (!dismissalKey) { | |
| 45 | + return false; | |
| 46 | + } | |
| 47 | + return readProviderUpdateDismissals().keys.includes(dismissalKey); | |
| 48 | +} | |
| 49 | + | |
| 50 | +export function dismissProviderUpdateNotification(dismissalKey: string | null | undefined): void { | |
| 51 | + const trimmedKey = dismissalKey?.trim(); | |
| 52 | + if (!trimmedKey) { | |
| 53 | + return; | |
| 54 | + } | |
| 55 | + const document = readProviderUpdateDismissals(); | |
| 56 | + if (document.keys.includes(trimmedKey)) { | |
| 57 | + return; | |
| 58 | + } | |
| 59 | + writeProviderUpdateDismissals({ | |
| 60 | + keys: [...document.keys, trimmedKey], | |
| 61 | + }); | |
| 62 | +} | |
| 63 | + | |
| 64 | +export function useDismissedProviderUpdateNotificationKeys() { | |
| 65 | + const [dismissals, setDismissals] = useLocalStorage( | |
| 66 | + PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | |
| 67 | + { keys: [] }, | |
| 68 | + ProviderUpdateDismissalsSchema, | |
| 69 | + ); | |
| 70 | + const dismissedKeys = dismissals.keys; | |
| 71 | + | |
| 72 | + const dismissedKeySet = useMemo(() => new Set(dismissedKeys), [dismissedKeys]); | |
| 73 | + | |
| 74 | + const dismissNotificationKey = useCallback( | |
| 75 | + (key: string) => { | |
| 76 | + const trimmedKey = key.trim(); | |
| 77 | + if (trimmedKey.length === 0 || dismissedKeySet.has(trimmedKey)) { | |
| 78 | + return; | |
| 79 | + } | |
| 80 | + | |
| 81 | + setDismissals({ | |
| 82 | + keys: [...dismissedKeys, trimmedKey], | |
| 83 | + }); | |
| 84 | + }, | |
| 85 | + [dismissedKeySet, dismissedKeys, setDismissals], | |
| 86 | + ); | |
| 87 | + | |
| 88 | + return { | |
| 89 | + dismissedNotificationKeys: dismissedKeySet, | |
| 90 | + dismissNotificationKey, | |
| 91 | + }; | |
| 92 | +} | |
| @@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair' · 13 unmodified lines | ||
| 14 | 14 | import { Route as ChatRouteImport } from './routes/_chat' |
| 15 | 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 16 | 16 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' |
| 17 | +import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' | |
| 17 | 18 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 18 | 19 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' |
| 19 | 20 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| @@ -44,6 +45,11 @@ const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ · 24 unmodified lines | ||
| 44 | 45 | path: '/source-control', |
| 45 | 46 | getParentRoute: () => SettingsRoute, |
| 46 | 47 | } as any) |
| 48 | +const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ | |
| 49 | + id: '/providers', | |
| 50 | + path: '/providers', | |
| 51 | + getParentRoute: () => SettingsRoute, | |
| 52 | +} as any) | |
| 47 | 53 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ |
| 48 | 54 | id: '/general', |
| 49 | 55 | path: '/general', |
| @@ -78,6 +84,7 @@ export interface FileRoutesByFullPath { · 28 unmodified lines | ||
| 78 | 84 | '/settings/archived': typeof SettingsArchivedRoute |
| 79 | 85 | '/settings/connections': typeof SettingsConnectionsRoute |
| 80 | 86 | '/settings/general': typeof SettingsGeneralRoute |
| 87 | + '/settings/providers': typeof SettingsProvidersRoute | |
| 81 | 88 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 82 | 89 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 83 | 90 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| @@ -88,6 +95,7 @@ export interface FileRoutesByTo { · 4 unmodified lines | ||
| 88 | 95 | '/settings/archived': typeof SettingsArchivedRoute |
| 89 | 96 | '/settings/connections': typeof SettingsConnectionsRoute |
| 90 | 97 | '/settings/general': typeof SettingsGeneralRoute |
| 98 | + '/settings/providers': typeof SettingsProvidersRoute | |
| 91 | 99 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 92 | 100 | '/': typeof ChatIndexRoute |
| 93 | 101 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| @@ -101,6 +109,7 @@ export interface FileRoutesById { · 7 unmodified lines | ||
| 101 | 109 | '/settings/archived': typeof SettingsArchivedRoute |
| 102 | 110 | '/settings/connections': typeof SettingsConnectionsRoute |
| 103 | 111 | '/settings/general': typeof SettingsGeneralRoute |
| 112 | + '/settings/providers': typeof SettingsProvidersRoute | |
| 104 | 113 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 105 | 114 | '/_chat/': typeof ChatIndexRoute |
| 106 | 115 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| @@ -115,6 +124,7 @@ export interface FileRouteTypes { · 8 unmodified lines | ||
| 115 | 124 | | '/settings/archived' |
| 116 | 125 | | '/settings/connections' |
| 117 | 126 | | '/settings/general' |
| 127 | + | '/settings/providers' | |
| 118 | 128 | | '/settings/source-control' |
| 119 | 129 | | '/$environmentId/$threadId' |
| 120 | 130 | | '/draft/$draftId' |
| @@ -125,6 +135,7 @@ export interface FileRouteTypes { · 4 unmodified lines | ||
| 125 | 135 | | '/settings/archived' |
| 126 | 136 | | '/settings/connections' |
| 127 | 137 | | '/settings/general' |
| 138 | + | '/settings/providers' | |
| 128 | 139 | | '/settings/source-control' |
| 129 | 140 | | '/' |
| 130 | 141 | | '/$environmentId/$threadId' |
| @@ -137,6 +148,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 137 | 148 | | '/settings/archived' |
| 138 | 149 | | '/settings/connections' |
| 139 | 150 | | '/settings/general' |
| 151 | + | '/settings/providers' | |
| 140 | 152 | | '/settings/source-control' |
| 141 | 153 | | '/_chat/' |
| 142 | 154 | | '/_chat/$environmentId/$threadId' |
| @@ -186,6 +198,13 @@ declare module '@tanstack/react-router' { · 43 unmodified lines | ||
| 186 | 198 | preLoaderRoute: typeof SettingsSourceControlRouteImport |
| 187 | 199 | parentRoute: typeof SettingsRoute |
| 188 | 200 | } |
| 201 | + '/settings/providers': { | |
| 202 | + id: '/settings/providers' | |
| 203 | + path: '/providers' | |
| 204 | + fullPath: '/settings/providers' | |
| 205 | + preLoaderRoute: typeof SettingsProvidersRouteImport | |
| 206 | + parentRoute: typeof SettingsRoute | |
| 207 | + } | |
| 189 | 208 | '/settings/general': { |
| 190 | 209 | id: '/settings/general' |
| 191 | 210 | path: '/general' |
| @@ -242,6 +261,7 @@ interface SettingsRouteChildren { · 50 unmodified lines | ||
| 242 | 261 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 243 | 262 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute |
| 244 | 263 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 264 | + SettingsProvidersRoute: typeof SettingsProvidersRoute | |
| 245 | 265 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute |
| 246 | 266 | } |
| 247 | 267 | |
| @@ -249,6 +269,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { · 1 unmodified line | ||
| 249 | 269 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 250 | 270 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 251 | 271 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 272 | + SettingsProvidersRoute: SettingsProvidersRoute, | |
| 252 | 273 | SettingsSourceControlRoute: SettingsSourceControlRoute, |
| 253 | 274 | } |
| 254 | 275 | |
| @@ -14,6 +14,7 @@ import { APP_DISPLAY_NAME } from "../branding"; · 13 unmodified lines | ||
| 14 | 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 15 | 15 | import { CommandPalette } from "../components/CommandPalette"; |
| 16 | 16 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 17 | +import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | |
| 17 | 18 | import { |
| 18 | 19 | SlowRpcAckToastCoordinator, |
| 19 | 20 | WebSocketConnectionCoordinator, |
| @@ -136,6 +137,7 @@ function RootRouteView() { · 116 unmodified lines | ||
| 136 | 137 | <SshPasswordPromptDialog /> |
| 137 | 138 | <HostedStaticEnvironmentBootstrap /> |
| 138 | 139 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 140 | + {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} | |
| 139 | 141 | {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} |
| 140 | 142 | {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} |
| 141 | 143 | {primaryEnvironmentAuthenticated ? ( |
| @@ -2,6 +2,10 @@ import { createFileRoute } from "@tanstack/react-router"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { GeneralSettingsPanel } from "../components/settings/SettingsPanels"; |
| 4 | 4 | |
| 5 | +function SettingsGeneralRoute() { | |
| 6 | + return <GeneralSettingsPanel />; | |
| 7 | +} | |
| 8 | + | |
| 5 | 9 | export const Route = createFileRoute("/settings/general")({ |
| 6 | − component: GeneralSettingsPanel, | |
| 10 | + component: SettingsGeneralRoute, | |
| 7 | 11 | }); |
| @@ -0,0 +1,11 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | + | |
| 3 | +import { ProviderSettingsPanel } from "../components/settings/SettingsPanels"; | |
| 4 | + | |
| 5 | +function SettingsProvidersRoute() { | |
| 6 | + return <ProviderSettingsPanel />; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export const Route = createFileRoute("/settings/providers")({ | |
| 10 | + component: SettingsProvidersRoute, | |
| 11 | +}); | |
| @@ -119,6 +119,7 @@ export interface WsRpcClient { · 118 unmodified lines | ||
| 119 | 119 | readonly refreshProviders: ( |
| 120 | 120 | input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>, |
| 121 | 121 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; |
| 122 | + readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>; | |
| 122 | 123 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; |
| 123 | 124 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; |
| 124 | 125 | readonly updateSettings: ( |
| @@ -234,6 +235,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { · 109 unmodified lines | ||
| 234 | 235 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), |
| 235 | 236 | refreshProviders: (input) => |
| 236 | 237 | transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), |
| 238 | + updateProvider: (input) => | |
| 239 | + transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), | |
| 237 | 240 | upsertKeybinding: (input) => |
| 238 | 241 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), |
| 239 | 242 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), |
| @@ -28,6 +28,7 @@ import type { · 27 unmodified lines | ||
| 28 | 28 | import type { ProviderInstanceId } from "./providerInstance.ts"; |
| 29 | 29 | import type { |
| 30 | 30 | ServerConfig, |
| 31 | + ServerProviderUpdateInput, | |
| 31 | 32 | ServerProviderUpdatedPayload, |
| 32 | 33 | ServerUpsertKeybindingResult, |
| 33 | 34 | } from "./server.ts"; |
| @@ -296,6 +297,7 @@ export interface LocalApi { · 262 unmodified lines | ||
| 296 | 297 | refreshProviders: (input?: { |
| 297 | 298 | readonly instanceId?: ProviderInstanceId; |
| 298 | 299 | }) => Promise<ServerProviderUpdatedPayload>; |
| 300 | + updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>; | |
| 299 | 301 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 300 | 302 | getSettings: () => Promise<ServerSettings>; |
| 301 | 303 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; |
| @@ -71,6 +71,8 @@ import { · 70 unmodified lines | ||
| 71 | 71 | import { |
| 72 | 72 | ServerConfigStreamEvent, |
| 73 | 73 | ServerConfig, |
| 74 | + ServerProviderUpdateError, | |
| 75 | + ServerProviderUpdateInput, | |
| 74 | 76 | ServerLifecycleStreamEvent, |
| 75 | 77 | ServerProviderUpdatedPayload, |
| 76 | 78 | ServerUpsertKeybindingInput, |
| @@ -129,6 +131,7 @@ export const WS_METHODS = { · 52 unmodified lines | ||
| 129 | 131 | // Server meta |
| 130 | 132 | serverGetConfig: "server.getConfig", |
| 131 | 133 | serverRefreshProviders: "server.refreshProviders", |
| 134 | + serverUpdateProvider: "server.updateProvider", | |
| 132 | 135 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 133 | 136 | serverGetSettings: "server.getSettings", |
| 134 | 137 | serverUpdateSettings: "server.updateSettings", |
| @@ -172,6 +175,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv · 37 unmodified lines | ||
| 172 | 175 | success: ServerProviderUpdatedPayload, |
| 173 | 176 | }); |
| 174 | 177 | |
| 178 | +export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { | |
| 179 | + payload: ServerProviderUpdateInput, | |
| 180 | + success: ServerProviderUpdatedPayload, | |
| 181 | + error: ServerProviderUpdateError, | |
| 182 | +}); | |
| 183 | + | |
| 175 | 184 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { |
| 176 | 185 | payload: Schema.Struct({}), |
| 177 | 186 | success: ServerSettings, |
| @@ -415,6 +424,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, · 237 unmodified lines | ||
| 415 | 424 | export const WsRpcGroup = RpcGroup.make( |
| 416 | 425 | WsServerGetConfigRpc, |
| 417 | 426 | WsServerRefreshProvidersRpc, |
| 427 | + WsServerUpdateProviderRpc, | |
| 418 | 428 | WsServerUpsertKeybindingRpc, |
| 419 | 429 | WsServerGetSettingsRpc, |
| 420 | 430 | WsServerUpdateSettingsRpc, |
| @@ -23,6 +23,34 @@ describe("ServerProvider", () => { · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | expect(parsed.slashCommands).toEqual([]); |
| 25 | 25 | expect(parsed.skills).toEqual([]); |
| 26 | + expect(parsed.versionAdvisory).toBeUndefined(); | |
| 27 | + expect(parsed.updateState).toBeUndefined(); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it("defaults one-click update support when decoding older advisory snapshots", () => { | |
| 31 | + const parsed = decodeServerProvider({ | |
| 32 | + instanceId: "codex", | |
| 33 | + driver: "codex", | |
| 34 | + enabled: true, | |
| 35 | + installed: true, | |
| 36 | + version: "1.0.0", | |
| 37 | + status: "ready", | |
| 38 | + auth: { | |
| 39 | + status: "authenticated", | |
| 40 | + }, | |
| 41 | + checkedAt: "2026-04-10T00:00:00.000Z", | |
| 42 | + models: [], | |
| 43 | + versionAdvisory: { | |
| 44 | + status: "behind_latest", | |
| 45 | + currentVersion: "1.0.0", | |
| 46 | + latestVersion: "1.0.1", | |
| 47 | + updateCommand: "npm install -g @openai/codex@latest", | |
| 48 | + checkedAt: "2026-04-10T00:00:00.000Z", | |
| 49 | + message: "Update available.", | |
| 50 | + }, | |
| 51 | + }); | |
| 52 | + | |
| 53 | + expect(parsed.versionAdvisory?.canUpdate).toBe(false); | |
| 26 | 54 | }); |
| 27 | 55 | |
| 28 | 56 | it("decodes continuation group metadata", () => { |
| @@ -109,6 +109,43 @@ export const ServerProviderContinuation = Schema.Struct({ · 108 unmodified lines | ||
| 109 | 109 | }); |
| 110 | 110 | export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; |
| 111 | 111 | |
| 112 | +export const ServerProviderVersionAdvisoryStatus = Schema.Literals([ | |
| 113 | + "unknown", | |
| 114 | + "current", | |
| 115 | + "behind_latest", | |
| 116 | +]); | |
| 117 | +export type ServerProviderVersionAdvisoryStatus = typeof ServerProviderVersionAdvisoryStatus.Type; | |
| 118 | + | |
| 119 | +export const ServerProviderVersionAdvisory = Schema.Struct({ | |
| 120 | + status: ServerProviderVersionAdvisoryStatus, | |
| 121 | + currentVersion: Schema.NullOr(TrimmedNonEmptyString), | |
| 122 | + latestVersion: Schema.NullOr(TrimmedNonEmptyString), | |
| 123 | + updateCommand: Schema.NullOr(TrimmedNonEmptyString), | |
| 124 | + canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 125 | + checkedAt: Schema.NullOr(IsoDateTime), | |
| 126 | + message: Schema.NullOr(TrimmedNonEmptyString), | |
| 127 | +}); | |
| 128 | +export type ServerProviderVersionAdvisory = typeof ServerProviderVersionAdvisory.Type; | |
| 129 | + | |
| 130 | +export const ServerProviderUpdateStatus = Schema.Literals([ | |
| 131 | + "idle", | |
| 132 | + "queued", | |
| 133 | + "running", | |
| 134 | + "succeeded", | |
| 135 | + "failed", | |
| 136 | + "unchanged", | |
| 137 | +]); | |
| 138 | +export type ServerProviderUpdateStatus = typeof ServerProviderUpdateStatus.Type; | |
| 139 | + | |
| 140 | +export const ServerProviderUpdateState = Schema.Struct({ | |
| 141 | + status: ServerProviderUpdateStatus, | |
| 142 | + startedAt: Schema.NullOr(IsoDateTime), | |
| 143 | + finishedAt: Schema.NullOr(IsoDateTime), | |
| 144 | + message: Schema.NullOr(TrimmedNonEmptyString), | |
| 145 | + output: Schema.NullOr(Schema.String.check(Schema.isMaxLength(10_000))), | |
| 146 | +}); | |
| 147 | +export type ServerProviderUpdateState = typeof ServerProviderUpdateState.Type; | |
| 148 | + | |
| 112 | 149 | export const ServerProvider = Schema.Struct({ |
| 113 | 150 | // Routing key for the configured instance this snapshot represents. This |
| 114 | 151 | // is the only stable identity consumers may use for provider routing. |
| @@ -142,6 +179,8 @@ export const ServerProvider = Schema.Struct({ · 27 unmodified lines | ||
| 142 | 179 | Schema.withDecodingDefault(Effect.succeed([])), |
| 143 | 180 | ), |
| 144 | 181 | skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 182 | + versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), | |
| 183 | + updateState: Schema.optionalKey(ServerProviderUpdateState), | |
| 145 | 184 | }); |
| 146 | 185 | export type ServerProvider = typeof ServerProvider.Type; |
| 147 | 186 | |
| @@ -294,3 +333,22 @@ export const ServerProviderUpdatedPayload = Schema.Struct({ · 146 unmodified lines | ||
| 294 | 333 | providers: ServerProviders, |
| 295 | 334 | }); |
| 296 | 335 | export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type; |
| 336 | + | |
| 337 | +export const ServerProviderUpdateInput = Schema.Struct({ | |
| 338 | + provider: ProviderDriverKind, | |
| 339 | + instanceId: Schema.optionalKey(ProviderInstanceId), | |
| 340 | +}); | |
| 341 | +export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; | |
| 342 | + | |
| 343 | +export class ServerProviderUpdateError extends Schema.TaggedErrorClass<ServerProviderUpdateError>()( | |
| 344 | + "ServerProviderUpdateError", | |
| 345 | + { | |
| 346 | + provider: ProviderDriverKind, | |
| 347 | + reason: TrimmedNonEmptyString, | |
| 348 | + cause: Schema.optional(Schema.Defect), | |
| 349 | + }, | |
| 350 | +) { | |
| 351 | + override get message(): string { | |
| 352 | + return `Provider update failed for ${this.provider}: ${this.reason}`; | |
| 353 | + } | |
| 354 | +} | |
| @@ -32,6 +32,9 @@ export const ClientSettingsSchema = Schema.Struct({ · 31 unmodified lines | ||
| 32 | 32 | autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 33 | 33 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 34 | 34 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 35 | + dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( | |
| 36 | + Schema.withDecodingDefault(Effect.succeed([])), | |
| 37 | + ), | |
| 35 | 38 | diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 36 | 39 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 37 | 40 | // Model favorites. Historically keyed by provider kind, now |
| @@ -24,7 +24,6 @@ const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeReque · 23 unmodified lines | ||
| 24 | 24 | const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse); |
| 25 | 25 | const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); |
| 26 | 26 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); |
| 27 | − | |
| 28 | 27 | const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => |
| 29 | 28 | path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), |
| 30 | 29 | ); |
| @@ -41,7 +41,6 @@ const RequestPermissionRequest = jsonRpcRequest( · 40 unmodified lines | ||
| 41 | 41 | const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); |
| 42 | 42 | const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); |
| 43 | 43 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); |
| 44 | − | |
| 45 | 44 | const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => |
| 46 | 45 | path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), |
| 47 | 46 | ); |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | readEnvironmentFromWindowsShell, |
| 11 | 11 | readPathFromLaunchctl, |
| 12 | 12 | readPathFromLoginShell, |
| 13 | + resolveCommandPath, | |
| 13 | 14 | resolveKnownWindowsCliDirs, |
| 14 | 15 | resolveWindowsEnvironment, |
| 15 | 16 | } from "./shell.ts"; |
| @@ -332,6 +333,17 @@ describe("isCommandAvailable", () => { · 316 unmodified lines | ||
| 332 | 333 | }); |
| 333 | 334 | }); |
| 334 | 335 | |
| 336 | +describe("resolveCommandPath", () => { | |
| 337 | + it("returns the first executable resolved from PATH", () => { | |
| 338 | + expect( | |
| 339 | + resolveCommandPath("definitely-not-installed", { | |
| 340 | + platform: "win32", | |
| 341 | + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | |
| 342 | + }), | |
| 343 | + ).toBeNull(); | |
| 344 | + }); | |
| 345 | +}); | |
| 346 | + | |
| 335 | 347 | describe("resolveWindowsEnvironment", () => { |
| 336 | 348 | it("returns the baseline no-profile PATH patch when node is already available", () => { |
| 337 | 349 | const readEnvironment = vi.fn( |
| @@ -376,23 +376,26 @@ function isExecutableFile( · 375 unmodified lines | ||
| 376 | 376 | } |
| 377 | 377 | } |
| 378 | 378 | |
| 379 | −export function isCommandAvailable( | |
| 379 | +export function resolveCommandPath( | |
| 380 | 380 | command: string, |
| 381 | 381 | options: CommandAvailabilityOptions = {}, |
| 382 | −): boolean { | |
| 382 | +): string | null { | |
| 383 | 383 | const platform = options.platform ?? process.platform; |
| 384 | 384 | const env = options.env ?? process.env; |
| 385 | 385 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; |
| 386 | 386 | const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); |
| 387 | 387 | |
| 388 | 388 | if (command.includes("/") || command.includes("\\")) { |
| 389 | − return commandCandidates.some((candidate) => | |
| 390 | − isExecutableFile(candidate, platform, windowsPathExtensions), | |
| 391 | − ); | |
| 389 | + for (const candidate of commandCandidates) { | |
| 390 | + if (isExecutableFile(candidate, platform, windowsPathExtensions)) { | |
| 391 | + return candidate; | |
| 392 | + } | |
| 393 | + } | |
| 394 | + return null; | |
| 392 | 395 | } |
| 393 | 396 | |
| 394 | 397 | const pathValue = resolvePathEnvironmentVariable(env); |
| 395 | − if (pathValue.length === 0) return false; | |
| 398 | + if (pathValue.length === 0) return null; | |
| 396 | 399 | const pathEntries = pathValue |
| 397 | 400 | .split(pathDelimiterForPlatform(platform)) |
| 398 | 401 | .map((entry) => stripWrappingQuotes(entry.trim())) |
| @@ -400,12 +403,20 @@ export function isCommandAvailable( · 1 unmodified line | ||
| 400 | 403 | |
| 401 | 404 | for (const pathEntry of pathEntries) { |
| 402 | 405 | for (const candidate of commandCandidates) { |
| 403 | − if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { | |
| 404 | − return true; | |
| 406 | + const candidatePath = join(pathEntry, candidate); | |
| 407 | + if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) { | |
| 408 | + return candidatePath; | |
| 405 | 409 | } |
| 406 | 410 | } |
| 407 | 411 | } |
| 408 | − return false; | |
| 412 | + return null; | |
| 413 | +} | |
| 414 | + | |
| 415 | +export function isCommandAvailable( | |
| 416 | + command: string, | |
| 417 | + options: CommandAvailabilityOptions = {}, | |
| 418 | +): boolean { | |
| 419 | + return resolveCommandPath(command, options) !== null; | |
| 409 | 420 | } |
| 410 | 421 | |
| 411 | 422 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { |