| author | |
| committer | |
| log | 9b604bcae1e2cfe0e0566bc2aff660850e276c6d |
| tree | 45dfdc1e0894228f2acc149b6a49f9da9f0fa438 |
| parent | 35721d9a08b225c4a3752f322ae4daccbeaa564e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>60 files changed, 5723 insertions(+), 593 deletions(-)
apps/desktop/src/clientPersistence.test.ts+1| ... | ... | @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { |
| 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: [], |
apps/server/src/provider/Drivers/ClaudeDriver.ts+40| ... | ... | @@ -14,6 +14,7 @@ |
| 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 { |
| 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> = { |
| 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> = { |
| 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> = { |
| 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( |
apps/server/src/provider/Drivers/CodexDriver.ts+24| ... | ... | @@ -23,6 +23,7 @@ |
| 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"; |
| 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 { |
| 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); |
| 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> = { |
| 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> = { |
| 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> = { |
| 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( |
apps/server/src/provider/Drivers/CursorDriver.ts+24| ... | ... | @@ -14,6 +14,7 @@ |
| 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 { |
| 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> = { |
| 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> = { |
| 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> = { |
| 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> = { |
| 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( |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+39| ... | ... | @@ -14,6 +14,7 @@ |
| 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 { |
| 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> |
| 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> |
| 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> |
| 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( |
apps/server/src/provider/Layers/CodexProvider.ts-1| ... | ... | @@ -15,7 +15,6 @@ import type { |
| 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"; |
apps/server/src/provider/Layers/CursorProvider.ts+55-25| ... | ... | @@ -12,6 +12,7 @@ import type { |
| 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 { |
| 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: { |
| 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 | }; |
apps/server/src/provider/Layers/OpenCodeProvider.ts-1| ... | ... | @@ -7,7 +7,6 @@ import { |
| 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, |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+5| ... | ... | @@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; |
| 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 = ( |
| 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, |
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+10| ... | ... | @@ -34,6 +34,7 @@ import { |
| 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"; |
| 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", () => { |
| 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", () => { |
| 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 |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+201-1| ... | ... | @@ -14,6 +14,7 @@ import { |
| 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 |
| 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"; |
| 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( |
| 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()))( |
| 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()))( |
| 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()))( |
| 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()))( |
| 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()))( |
| 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()))( |
| 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()))( |
| 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( |
apps/server/src/provider/Layers/ProviderRegistry.ts+128-4| ... | ... | @@ -27,6 +27,7 @@ import { |
| 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 { |
| 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 = ( |
| 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 = ( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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 | }, |
apps/server/src/provider/Services/ProviderRegistry.ts+30-1| ... | ... | @@ -6,9 +6,17 @@ |
| 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 { |
| 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. |
apps/server/src/provider/Services/ServerProvider.ts+2| ... | ... | @@ -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>; |
apps/server/src/provider/makeManagedServerProvider.test.ts+18| ... | ... | @@ -20,6 +20,20 @@ interface TestSettings { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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, |
apps/server/src/provider/makeManagedServerProvider.ts+2| ... | ... | @@ -13,6 +13,7 @@ interface ProviderSnapshotState { |
| 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")( |
| 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), |
apps/server/src/provider/providerMaintenance.test.ts created+487| ... | ... | @@ -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 | }); |
apps/server/src/provider/providerMaintenance.ts created+472| ... | ... | @@ -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 | }); |
apps/server/src/provider/providerMaintenanceCommandCoordinator.ts created+79| ... | ... | @@ -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 | }); |
apps/server/src/provider/providerMaintenanceRunner.test.ts created+640| ... | ... | @@ -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 | }); |
apps/server/src/provider/providerMaintenanceRunner.ts created+395| ... | ... | @@ -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()); |
apps/server/src/provider/providerSnapshot.ts+12-7| ... | ... | @@ -12,6 +12,8 @@ import { Effect, Stream } from "effect"; |
| 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: { |
| 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: { |
| 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: { |
| 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)); |
apps/server/src/provider/providerStatusCache.ts+5-3| ... | ... | @@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) => |
| 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 | }; |
apps/server/src/server.test.ts+7| ... | ... | @@ -80,6 +80,7 @@ import { |
| 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?: { |
| 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 | }), |
apps/server/src/stream/collectUint8StreamText.test.ts created+38| ... | ... | @@ -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 | }); |
apps/server/src/stream/collectUint8StreamText.ts created+66| ... | ... | @@ -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 | }; |
apps/server/src/vcs/VcsProcess.ts+6-37| ... | ... | @@ -8,6 +8,7 @@ import { |
| 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: |
| 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* () { |
apps/server/src/ws.ts+11| ... | ... | @@ -40,6 +40,7 @@ import { |
| 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) => |
| 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) => |
| 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( |
| 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( |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts created+673| ... | ... | @@ -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 | }); |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts created+537| ... | ... | @@ -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 | } |
apps/web/src/components/ProviderUpdateLaunchNotification.tsx created+300| ... | ... | @@ -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 | } |
apps/web/src/components/Sidebar.tsx+2| ... | ... | @@ -185,6 +185,7 @@ import { |
| 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() { |
| 2434 | 2435 | |
| 2435 | 2436 | return ( |
| 2436 | 2437 | <SidebarFooter className="p-2"> |
| 2438 | <SidebarProviderUpdatePill /> | |
| 2437 | 2439 | <SidebarUpdatePill /> |
| 2438 | 2440 | <SidebarMenu> |
| 2439 | 2441 | <SidebarMenuItem> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+226-83| ... | ... | @@ -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 { |
| 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"; |
| 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 { |
| 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({ |
| 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({ |
| 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({ |
| 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 |
apps/web/src/components/settings/SettingsPanels.browser.tsx+65-2| ... | ... | @@ -10,7 +10,10 @@ import { |
| 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 |
| 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 { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
apps/web/src/components/settings/SettingsPanels.tsx+433-402| ... | ... | @@ -4,6 +4,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; |
| 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"; |
| 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() { |
| 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) { |
| 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) { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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 | } |
apps/web/src/components/settings/SettingsSidebarNav.tsx+10-1| ... | ... | @@ -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 { |
| 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<{ |
| 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 }, |
apps/web/src/components/settings/providerStatus.ts+27-1| ... | ... | @@ -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) { |
| 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 | } |
apps/web/src/components/settings/settingsLayout.tsx+8-6| ... | ... | @@ -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({ |
| 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({ |
| 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({ |
| 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 ? ( |
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx created+207| ... | ... | @@ -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 | } |
apps/web/src/components/ui/toast.tsx+63-5| ... | ... | @@ -6,6 +6,7 @@ import { |
| 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 { |
| 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( |
| 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 { |
| 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: { |
| 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({ |
| 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({ |
| 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 }) { |
| 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() { |
| 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} /> |
apps/web/src/hooks/useSettings.ts+37-1| ... | ... | @@ -26,6 +26,7 @@ import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/ |
| 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() { |
| 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 { |
| 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 { |
| 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> { |
| 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 { |
| 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 { |
| 193 | 228 | clientSettingsHydrated = false; |
| 194 | 229 | clientSettingsHydrationPromise = null; |
| 195 | 230 | clientSettingsListeners.clear(); |
| 231 | clientSettingsHydrationListeners.clear(); | |
| 196 | 232 | } |
apps/web/src/index.css+13| ... | ... | @@ -485,6 +485,19 @@ label:has(> select#reasoning-effort) select { |
| 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)); |
apps/web/src/localApi.test.ts+31| ... | ... | @@ -85,6 +85,7 @@ const rpcClientMock = { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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: [], |
apps/web/src/localApi.ts+4| ... | ... | @@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { |
| 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) |
apps/web/src/providerUpdateDismissal.test.ts created+23| ... | ... | @@ -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 | }); |
apps/web/src/providerUpdateDismissal.ts created+92| ... | ... | @@ -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 | } |
apps/web/src/routeTree.gen.ts+21| ... | ... | @@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair' |
| 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({ |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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 { |
| 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' { |
| 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 { |
| 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 = { |
| 249 | 269 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 250 | 270 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 251 | 271 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 272 | SettingsProvidersRoute: SettingsProvidersRoute, | |
| 252 | 273 | SettingsSourceControlRoute: SettingsSourceControlRoute, |
| 253 | 274 | } |
| 254 | 275 |
apps/web/src/routes/__root.tsx+2| ... | ... | @@ -14,6 +14,7 @@ import { APP_DISPLAY_NAME } from "../branding"; |
| 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() { |
| 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 ? ( |
apps/web/src/routes/settings.general.tsx+5-1| ... | ... | @@ -2,6 +2,10 @@ import { createFileRoute } from "@tanstack/react-router"; |
| 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 | }); |
apps/web/src/routes/settings.providers.tsx created+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 | }); |
apps/web/src/rpc/wsRpcClient.ts+3| ... | ... | @@ -119,6 +119,7 @@ export interface WsRpcClient { |
| 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 { |
| 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]({})), |
packages/contracts/src/ipc.ts+2| ... | ... | @@ -28,6 +28,7 @@ import type { |
| 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 { |
| 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>; |
packages/contracts/src/rpc.ts+10| ... | ... | @@ -71,6 +71,8 @@ import { |
| 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 = { |
| 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 |
| 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, |
| 415 | 424 | export const WsRpcGroup = RpcGroup.make( |
| 416 | 425 | WsServerGetConfigRpc, |
| 417 | 426 | WsServerRefreshProvidersRpc, |
| 427 | WsServerUpdateProviderRpc, | |
| 418 | 428 | WsServerUpsertKeybindingRpc, |
| 419 | 429 | WsServerGetSettingsRpc, |
| 420 | 430 | WsServerUpdateSettingsRpc, |
packages/contracts/src/server.test.ts+28| ... | ... | @@ -23,6 +23,34 @@ describe("ServerProvider", () => { |
| 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", () => { |
packages/contracts/src/server.ts+58| ... | ... | @@ -109,6 +109,43 @@ export const ServerProviderContinuation = Schema.Struct({ |
| 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({ |
| 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({ |
| 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 | } |
packages/contracts/src/settings.ts+3| ... | ... | @@ -32,6 +32,9 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 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 |
packages/effect-acp/src/client.test.ts-1| ... | ... | @@ -24,7 +24,6 @@ const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeReque |
| 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 | ); |
packages/effect-acp/src/protocol.test.ts-1| ... | ... | @@ -41,7 +41,6 @@ const RequestPermissionRequest = jsonRpcRequest( |
| 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 | ); |
packages/shared/src/shell.test.ts+12| ... | ... | @@ -10,6 +10,7 @@ import { |
| 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", () => { |
| 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( |
packages/shared/src/shell.ts+20-9| ... | ... | @@ -376,23 +376,26 @@ function isExecutableFile( |
| 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( |
| 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> { |