| 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,6 +52,7 @@ const clientSettings: ClientSettings = { |
| 52 | autoOpenPlanSidebar: false, | 52 | autoOpenPlanSidebar: false, |
| 53 | confirmThreadArchive: true, | 53 | confirmThreadArchive: true, |
| 54 | confirmThreadDelete: false, | 54 | confirmThreadDelete: false, |
| 55 | dismissedProviderUpdateNotificationKeys: [], | ||
| 55 | diffIgnoreWhitespace: true, | 56 | diffIgnoreWhitespace: true, |
| 56 | diffWordWrap: true, | 57 | diffWordWrap: true, |
| 57 | favorites: [], | 58 | favorites: [], |
apps/server/src/provider/Drivers/ClaudeDriver.ts+40| ... | @@ -14,6 +14,7 @@ | ... | @@ -14,6 +14,7 @@ |
| 14 | */ | 14 | */ |
| 15 | import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | 15 | import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 16 | import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | 16 | import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | import { HttpClient } from "effect/unstable/http"; | ||
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | ||
| 19 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; | 20 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; |
| ... | @@ -34,15 +35,43 @@ import { | ... | @@ -34,15 +35,43 @@ import { |
| 34 | } from "../ProviderDriver.ts"; | 35 | } from "../ProviderDriver.ts"; |
| 35 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | 36 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 36 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | 37 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 38 | import { | ||
| 39 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 40 | makePackageManagedProviderMaintenanceResolver, | ||
| 41 | normalizeCommandPath, | ||
| 42 | resolveProviderMaintenanceCapabilitiesEffect, | ||
| 43 | } from "../providerMaintenance.ts"; | ||
| 37 | import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; | 44 | import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; |
| 38 | 45 | ||
| 39 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); | 46 | const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 40 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | 47 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); |
| 41 | const CAPABILITIES_PROBE_TTL = Duration.minutes(5); | 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 | export type ClaudeDriverEnv = | 71 | export type ClaudeDriverEnv = |
| 44 | | ChildProcessSpawner.ChildProcessSpawner | 72 | | ChildProcessSpawner.ChildProcessSpawner |
| 45 | | FileSystem.FileSystem | 73 | | FileSystem.FileSystem |
| 74 | | HttpClient.HttpClient | ||
| 46 | | Path.Path | 75 | | Path.Path |
| 47 | | ProviderEventLoggers | 76 | | ProviderEventLoggers |
| 48 | | ServerConfig; | 77 | | ServerConfig; |
| ... | @@ -75,6 +104,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -75,6 +104,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 75 | Effect.gen(function* () { | 104 | Effect.gen(function* () { |
| 76 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 105 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 77 | const path = yield* Path.Path; | 106 | const path = yield* Path.Path; |
| 107 | const httpClient = yield* HttpClient.HttpClient; | ||
| 78 | const eventLoggers = yield* ProviderEventLoggers; | 108 | const eventLoggers = yield* ProviderEventLoggers; |
| 79 | const processEnv = mergeProviderInstanceEnvironment(environment); | 109 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 80 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ | 110 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ |
| ... | @@ -82,6 +112,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -82,6 +112,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 82 | instanceId, | 112 | instanceId, |
| 83 | }); | 113 | }); |
| 84 | const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; | 114 | const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; |
| 115 | const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | ||
| 116 | binaryPath: effectiveConfig.binaryPath, | ||
| 117 | env: processEnv, | ||
| 118 | }); | ||
| 85 | const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); | 119 | const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); |
| 86 | const stampIdentity = withInstanceIdentity({ | 120 | const stampIdentity = withInstanceIdentity({ |
| 87 | instanceId, | 121 | instanceId, |
| ... | @@ -121,11 +155,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -121,11 +155,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 121 | ); | 155 | ); |
| 122 | 156 | ||
| 123 | const snapshot = yield* makeManagedServerProvider<ClaudeSettings>({ | 157 | const snapshot = yield* makeManagedServerProvider<ClaudeSettings>({ |
| 158 | maintenanceCapabilities, | ||
| 124 | getSettings: Effect.succeed(effectiveConfig), | 159 | getSettings: Effect.succeed(effectiveConfig), |
| 125 | streamSettings: Stream.never, | 160 | streamSettings: Stream.never, |
| 126 | haveSettingsChanged: () => false, | 161 | haveSettingsChanged: () => false, |
| 127 | initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), | 162 | initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)), |
| 128 | checkProvider, | 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 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | 169 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, |
| 130 | }).pipe( | 170 | }).pipe( |
| 131 | Effect.mapError( | 171 | Effect.mapError( |
apps/server/src/provider/Drivers/CodexDriver.ts+24| ... | @@ -23,6 +23,7 @@ | ... | @@ -23,6 +23,7 @@ |
| 23 | */ | 23 | */ |
| 24 | import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | 24 | import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 25 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | 25 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 26 | import { HttpClient } from "effect/unstable/http"; | ||
| 26 | import { ChildProcessSpawner } from "effect/unstable/process"; | 27 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 27 | 28 | ||
| 28 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; | 29 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; |
| ... | @@ -35,6 +36,11 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ... | @@ -35,6 +36,11 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 35 | import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; | 36 | import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; |
| 36 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | 37 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 37 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | 38 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 39 | import { | ||
| 40 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 41 | makePackageManagedProviderMaintenanceResolver, | ||
| 42 | resolveProviderMaintenanceCapabilitiesEffect, | ||
| 43 | } from "../providerMaintenance.ts"; | ||
| 38 | import { | 44 | import { |
| 39 | codexContinuationIdentity, | 45 | codexContinuationIdentity, |
| 40 | materializeCodexShadowHome, | 46 | materializeCodexShadowHome, |
| ... | @@ -43,6 +49,12 @@ import { | ... | @@ -43,6 +49,12 @@ import { |
| 43 | 49 | ||
| 44 | const DRIVER_KIND = ProviderDriverKind.make("codex"); | 50 | const DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 45 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | 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 | * Services the driver needs to materialize an instance. Surfaced as the | 60 | * Services the driver needs to materialize an instance. Surfaced as the |
| ... | @@ -52,6 +64,7 @@ const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ... | @@ -52,6 +64,7 @@ const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); |
| 52 | export type CodexDriverEnv = | 64 | export type CodexDriverEnv = |
| 53 | | ChildProcessSpawner.ChildProcessSpawner | 65 | | ChildProcessSpawner.ChildProcessSpawner |
| 54 | | FileSystem.FileSystem | 66 | | FileSystem.FileSystem |
| 67 | | HttpClient.HttpClient | ||
| 55 | | Path.Path | 68 | | Path.Path |
| 56 | | ProviderEventLoggers | 69 | | ProviderEventLoggers |
| 57 | | ServerConfig; | 70 | | ServerConfig; |
| ... | @@ -89,6 +102,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -89,6 +102,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 89 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | 102 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 90 | Effect.gen(function* () { | 103 | Effect.gen(function* () { |
| 91 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 104 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 105 | const httpClient = yield* HttpClient.HttpClient; | ||
| 92 | const eventLoggers = yield* ProviderEventLoggers; | 106 | const eventLoggers = yield* ProviderEventLoggers; |
| 93 | const processEnv = mergeProviderInstanceEnvironment(environment); | 107 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 94 | const homeLayout = yield* resolveCodexHomeLayout(config); | 108 | const homeLayout = yield* resolveCodexHomeLayout(config); |
| ... | @@ -115,6 +129,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -115,6 +129,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 115 | enabled, | 129 | enabled, |
| 116 | homePath: homeLayout.effectiveHomePath ?? "", | 130 | homePath: homeLayout.effectiveHomePath ?? "", |
| 117 | } satisfies CodexSettings; | 131 | } satisfies CodexSettings; |
| 132 | const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | ||
| 133 | binaryPath: effectiveConfig.binaryPath, | ||
| 134 | env: processEnv, | ||
| 135 | }); | ||
| 118 | 136 | ||
| 119 | // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error | 137 | // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error |
| 120 | // channels at construction time — their failure modes are all on the | 138 | // channels at construction time — their failure modes are all on the |
| ... | @@ -138,11 +156,17 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -138,11 +156,17 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 138 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 156 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 139 | ); | 157 | ); |
| 140 | const snapshot = yield* makeManagedServerProvider<CodexSettings>({ | 158 | const snapshot = yield* makeManagedServerProvider<CodexSettings>({ |
| 159 | maintenanceCapabilities, | ||
| 141 | getSettings: Effect.succeed(effectiveConfig), | 160 | getSettings: Effect.succeed(effectiveConfig), |
| 142 | streamSettings: Stream.never, | 161 | streamSettings: Stream.never, |
| 143 | haveSettingsChanged: () => false, | 162 | haveSettingsChanged: () => false, |
| 144 | initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), | 163 | initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)), |
| 145 | checkProvider, | 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 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | 170 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, |
| 147 | }).pipe( | 171 | }).pipe( |
| 148 | Effect.mapError( | 172 | Effect.mapError( |
apps/server/src/provider/Drivers/CursorDriver.ts+24| ... | @@ -14,6 +14,7 @@ | ... | @@ -14,6 +14,7 @@ |
| 14 | */ | 14 | */ |
| 15 | import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | 15 | import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | import { HttpClient } from "effect/unstable/http"; | ||
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | ||
| 19 | import { ServerConfig } from "../../config.ts"; | 20 | import { ServerConfig } from "../../config.ts"; |
| ... | @@ -34,13 +35,28 @@ import { | ... | @@ -34,13 +35,28 @@ import { |
| 34 | } from "../ProviderDriver.ts"; | 35 | } from "../ProviderDriver.ts"; |
| 35 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | 36 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 36 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | 37 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 38 | import { | ||
| 39 | makeProviderMaintenanceCapabilities, | ||
| 40 | makeStaticProviderMaintenanceResolver, | ||
| 41 | resolveProviderMaintenanceCapabilitiesEffect, | ||
| 42 | } from "../providerMaintenance.ts"; | ||
| 37 | 43 | ||
| 38 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); | 44 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| 39 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | 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 | export type CursorDriverEnv = | 56 | export type CursorDriverEnv = |
| 42 | | ChildProcessSpawner.ChildProcessSpawner | 57 | | ChildProcessSpawner.ChildProcessSpawner |
| 43 | | FileSystem.FileSystem | 58 | | FileSystem.FileSystem |
| 59 | | HttpClient.HttpClient | ||
| 44 | | Path.Path | 60 | | Path.Path |
| 45 | | ProviderEventLoggers | 61 | | ProviderEventLoggers |
| 46 | | ServerConfig; | 62 | | ServerConfig; |
| ... | @@ -74,6 +90,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -74,6 +90,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 74 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 90 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 75 | const fileSystem = yield* FileSystem.FileSystem; | 91 | const fileSystem = yield* FileSystem.FileSystem; |
| 76 | const path = yield* Path.Path; | 92 | const path = yield* Path.Path; |
| 93 | const httpClient = yield* HttpClient.HttpClient; | ||
| 77 | const eventLoggers = yield* ProviderEventLoggers; | 94 | const eventLoggers = yield* ProviderEventLoggers; |
| 78 | const processEnv = mergeProviderInstanceEnvironment(environment); | 95 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 79 | const continuationIdentity = defaultProviderContinuationIdentity({ | 96 | const continuationIdentity = defaultProviderContinuationIdentity({ |
| ... | @@ -87,6 +104,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -87,6 +104,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 87 | continuationGroupKey: continuationIdentity.continuationKey, | 104 | continuationGroupKey: continuationIdentity.continuationKey, |
| 88 | }); | 105 | }); |
| 89 | const effectiveConfig = { ...config, enabled } satisfies CursorSettings; | 106 | const effectiveConfig = { ...config, enabled } satisfies CursorSettings; |
| 107 | const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | ||
| 108 | binaryPath: effectiveConfig.binaryPath, | ||
| 109 | env: processEnv, | ||
| 110 | }); | ||
| 90 | 111 | ||
| 91 | const adapter = yield* makeCursorAdapter(effectiveConfig, { | 112 | const adapter = yield* makeCursorAdapter(effectiveConfig, { |
| 92 | environment: processEnv, | 113 | environment: processEnv, |
| ... | @@ -103,6 +124,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -103,6 +124,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 103 | ); | 124 | ); |
| 104 | 125 | ||
| 105 | const snapshot = yield* makeManagedServerProvider<CursorSettings>({ | 126 | const snapshot = yield* makeManagedServerProvider<CursorSettings>({ |
| 127 | maintenanceCapabilities, | ||
| 106 | getSettings: Effect.succeed(effectiveConfig), | 128 | getSettings: Effect.succeed(effectiveConfig), |
| 107 | streamSettings: Stream.never, | 129 | streamSettings: Stream.never, |
| 108 | haveSettingsChanged: () => false, | 130 | haveSettingsChanged: () => false, |
| ... | @@ -117,8 +139,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -117,8 +139,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 117 | settings, | 139 | settings, |
| 118 | environment: processEnv, | 140 | environment: processEnv, |
| 119 | snapshot: currentSnapshot, | 141 | snapshot: currentSnapshot, |
| 142 | maintenanceCapabilities, | ||
| 120 | publishSnapshot, | 143 | publishSnapshot, |
| 121 | stampIdentity, | 144 | stampIdentity, |
| 145 | httpClient, | ||
| 122 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), | 146 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), |
| 123 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | 147 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, |
| 124 | }).pipe( | 148 | }).pipe( |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+39| ... | @@ -14,6 +14,7 @@ | ... | @@ -14,6 +14,7 @@ |
| 14 | */ | 14 | */ |
| 15 | import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | 15 | import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; |
| 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | import { HttpClient } from "effect/unstable/http"; | ||
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 19 | ||
| 19 | import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; | 20 | import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; |
| ... | @@ -34,13 +35,40 @@ import { | ... | @@ -34,13 +35,40 @@ import { |
| 34 | } from "../ProviderDriver.ts"; | 35 | } from "../ProviderDriver.ts"; |
| 35 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | 36 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; |
| 36 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | 37 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 38 | import { | ||
| 39 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 40 | makePackageManagedProviderMaintenanceResolver, | ||
| 41 | normalizeCommandPath, | ||
| 42 | resolveProviderMaintenanceCapabilitiesEffect, | ||
| 43 | } from "../providerMaintenance.ts"; | ||
| 37 | 44 | ||
| 38 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); | 45 | const DRIVER_KIND = ProviderDriverKind.make("opencode"); |
| 39 | const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | 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 | export type OpenCodeDriverEnv = | 68 | export type OpenCodeDriverEnv = |
| 42 | | ChildProcessSpawner.ChildProcessSpawner | 69 | | ChildProcessSpawner.ChildProcessSpawner |
| 43 | | FileSystem.FileSystem | 70 | | FileSystem.FileSystem |
| 71 | | HttpClient.HttpClient | ||
| 44 | | OpenCodeRuntime | 72 | | OpenCodeRuntime |
| 45 | | Path.Path | 73 | | Path.Path |
| 46 | | ProviderEventLoggers | 74 | | ProviderEventLoggers |
| ... | @@ -74,6 +102,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -74,6 +102,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 74 | Effect.gen(function* () { | 102 | Effect.gen(function* () { |
| 75 | const openCodeRuntime = yield* OpenCodeRuntime; | 103 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 76 | const serverConfig = yield* ServerConfig; | 104 | const serverConfig = yield* ServerConfig; |
| 105 | const httpClient = yield* HttpClient.HttpClient; | ||
| 77 | const eventLoggers = yield* ProviderEventLoggers; | 106 | const eventLoggers = yield* ProviderEventLoggers; |
| 78 | const processEnv = mergeProviderInstanceEnvironment(environment); | 107 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 79 | const continuationIdentity = defaultProviderContinuationIdentity({ | 108 | const continuationIdentity = defaultProviderContinuationIdentity({ |
| ... | @@ -87,6 +116,10 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -87,6 +116,10 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 87 | continuationGroupKey: continuationIdentity.continuationKey, | 116 | continuationGroupKey: continuationIdentity.continuationKey, |
| 88 | }); | 117 | }); |
| 89 | const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; | 118 | const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; |
| 119 | const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | ||
| 120 | binaryPath: effectiveConfig.binaryPath, | ||
| 121 | env: processEnv, | ||
| 122 | }); | ||
| 90 | 123 | ||
| 91 | const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { | 124 | const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { |
| 92 | instanceId, | 125 | instanceId, |
| ... | @@ -102,11 +135,17 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -102,11 +135,17 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 102 | ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); | 135 | ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); |
| 103 | 136 | ||
| 104 | const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({ | 137 | const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({ |
| 138 | maintenanceCapabilities, | ||
| 105 | getSettings: Effect.succeed(effectiveConfig), | 139 | getSettings: Effect.succeed(effectiveConfig), |
| 106 | streamSettings: Stream.never, | 140 | streamSettings: Stream.never, |
| 107 | haveSettingsChanged: () => false, | 141 | haveSettingsChanged: () => false, |
| 108 | initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), | 142 | initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)), |
| 109 | checkProvider, | 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 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | 149 | refreshInterval: SNAPSHOT_REFRESH_INTERVAL, |
| 111 | }).pipe( | 150 | }).pipe( |
| 112 | Effect.mapError( | 151 | Effect.mapError( |
apps/server/src/provider/Layers/CodexProvider.ts-1| ... | @@ -15,7 +15,6 @@ import type { | ... | @@ -15,7 +15,6 @@ import type { |
| 15 | import { ServerSettingsError } from "@t3tools/contracts"; | 15 | import { ServerSettingsError } from "@t3tools/contracts"; |
| 16 | 16 | ||
| 17 | import { createModelCapabilities } from "@t3tools/shared/model"; | 17 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 18 | |||
| 19 | import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; | 18 | import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; |
| 20 | import { expandHomePath } from "../../pathExpansion.ts"; | 19 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 21 | import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; | 20 | import { scopedSafeTeardown } from "./scopedSafeTeardown.ts"; |
apps/server/src/provider/Layers/CursorProvider.ts+55-25| ... | @@ -12,6 +12,7 @@ import type { | ... | @@ -12,6 +12,7 @@ import type { |
| 12 | import { ProviderDriverKind } from "@t3tools/contracts"; | 12 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 13 | import type * as EffectAcpSchema from "effect-acp/schema"; | 13 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 14 | import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect"; | 14 | import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect"; |
| 15 | import { HttpClient } from "effect/unstable/http"; | ||
| 15 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 16 | import { | 17 | import { |
| 17 | createModelCapabilities, | 18 | createModelCapabilities, |
| ... | @@ -29,6 +30,10 @@ import { | ... | @@ -29,6 +30,10 @@ import { |
| 29 | type CommandResult, | 30 | type CommandResult, |
| 30 | type ServerProviderDraft, | 31 | type ServerProviderDraft, |
| 31 | } from "../providerSnapshot.ts"; | 32 | } from "../providerSnapshot.ts"; |
| 33 | import { | ||
| 34 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 35 | type ProviderMaintenanceCapabilities, | ||
| 36 | } from "../providerMaintenance.ts"; | ||
| 32 | import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; | 37 | import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; |
| 33 | 38 | ||
| 34 | const PROVIDER = ProviderDriverKind.make("cursor"); | 39 | const PROVIDER = ProviderDriverKind.make("cursor"); |
| ... | @@ -1216,42 +1221,67 @@ export const enrichCursorSnapshot = (input: { | ... | @@ -1216,42 +1221,67 @@ export const enrichCursorSnapshot = (input: { |
| 1216 | readonly settings: CursorSettings; | 1221 | readonly settings: CursorSettings; |
| 1217 | readonly environment?: NodeJS.ProcessEnv; | 1222 | readonly environment?: NodeJS.ProcessEnv; |
| 1218 | readonly snapshot: ServerProvider; | 1223 | readonly snapshot: ServerProvider; |
| 1224 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | ||
| 1219 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | 1225 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; |
| 1220 | readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; | 1226 | readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; |
| 1227 | readonly httpClient: HttpClient.HttpClient; | ||
| 1221 | }): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => { | 1228 | }): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => { |
| 1222 | const { settings, snapshot, publishSnapshot } = input; | 1229 | const { settings, snapshot, publishSnapshot } = input; |
| 1223 | const stampIdentity = input.stampIdentity ?? ((value) => value); | 1230 | const stampIdentity = input.stampIdentity ?? ((value) => value); |
| 1224 | 1231 | ||
| 1225 | if ( | 1232 | const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory( |
| 1226 | !settings.enabled || | 1233 | snapshot, |
| 1227 | snapshot.auth.status === "unauthenticated" || | 1234 | input.maintenanceCapabilities, |
| 1228 | !hasUncapturedCursorModels(snapshot) | 1235 | ).pipe( |
| 1229 | ) { | 1236 | Effect.provideService(HttpClient.HttpClient, input.httpClient), |
| 1230 | return Effect.void; | 1237 | Effect.flatMap((enrichedSnapshot) => |
| 1231 | } | 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( | 1247 | return enrichVersionAdvisory.pipe( |
| 1234 | Effect.flatMap((discoveredModels) => { | 1248 | Effect.flatMap((baseSnapshot) => { |
| 1235 | if (discoveredModels.length === 0) { | 1249 | if ( |
| 1250 | !settings.enabled || | ||
| 1251 | baseSnapshot.auth.status === "unauthenticated" || | ||
| 1252 | !hasUncapturedCursorModels(baseSnapshot) | ||
| 1253 | ) { | ||
| 1236 | return Effect.void; | 1254 | return Effect.void; |
| 1237 | } | 1255 | } |
| 1238 | return publishSnapshot( | 1256 | |
| 1239 | stampIdentity({ | 1257 | return discoverCursorModelCapabilitiesViaAcp( |
| 1240 | ...snapshot, | 1258 | settings, |
| 1241 | models: providerModelsFromSettings( | 1259 | baseSnapshot.models, |
| 1242 | discoveredModels, | 1260 | input.environment, |
| 1243 | PROVIDER, | 1261 | ).pipe( |
| 1244 | settings.customModels, | 1262 | Effect.flatMap((discoveredModels) => { |
| 1245 | EMPTY_CAPABILITIES, | 1263 | if (discoveredModels.length === 0) { |
| 1246 | ), | 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 +7,6 @@ import { |
| 7 | import { Cause, Data, Effect } from "effect"; | 7 | import { Cause, Data, Effect } from "effect"; |
| 8 | 8 | ||
| 9 | import { createModelCapabilities } from "@t3tools/shared/model"; | 9 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 10 | |||
| 11 | import { | 10 | import { |
| 12 | buildServerProvider, | 11 | buildServerProvider, |
| 13 | nonEmptyTrimmed, | 12 | nonEmptyTrimmed, |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+5| ... | @@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; | ... | @@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; |
| 14 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | 14 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 15 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | 15 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 16 | import type { ProviderInstance } from "../ProviderDriver.ts"; | 16 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 17 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 17 | import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; | 18 | import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; |
| 18 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; | 19 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; |
| 19 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 20 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | @@ -111,6 +112,10 @@ const makeFakeInstance = ( | ... | @@ -111,6 +112,10 @@ const makeFakeInstance = ( |
| 111 | displayName: undefined, | 112 | displayName: undefined, |
| 112 | enabled: true, | 113 | enabled: true, |
| 113 | snapshot: { | 114 | snapshot: { |
| 115 | maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 116 | provider: driverKind, | ||
| 117 | packageName: null, | ||
| 118 | }), | ||
| 114 | getSnapshot: Effect.succeed({} as unknown as ServerProvider), | 119 | getSnapshot: Effect.succeed({} as unknown as ServerProvider), |
| 115 | refresh: Effect.succeed({} as unknown as ServerProvider), | 120 | refresh: Effect.succeed({} as unknown as ServerProvider), |
| 116 | streamChanges: Stream.empty, | 121 | streamChanges: Stream.empty, |
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+10| ... | @@ -34,6 +34,7 @@ import { | ... | @@ -34,6 +34,7 @@ import { |
| 34 | ProviderInstanceId, | 34 | ProviderInstanceId, |
| 35 | } from "@t3tools/contracts"; | 35 | } from "@t3tools/contracts"; |
| 36 | import { Effect, Layer } from "effect"; | 36 | import { Effect, Layer } from "effect"; |
| 37 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 37 | 38 | ||
| 38 | import { ServerConfig } from "../../config.ts"; | 39 | import { ServerConfig } from "../../config.ts"; |
| 39 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; | 40 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; |
| ... | @@ -44,6 +45,13 @@ import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | ... | @@ -44,6 +45,13 @@ import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; |
| 44 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | 45 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 45 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; | 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 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ | 55 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ |
| 48 | enabled: false, | 56 | enabled: false, |
| 49 | binaryPath: "codex", | 57 | binaryPath: "codex", |
| ... | @@ -89,6 +97,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { | ... | @@ -89,6 +97,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { |
| 89 | prefix: "provider-instance-registry-test", | 97 | prefix: "provider-instance-registry-test", |
| 90 | }).pipe( | 98 | }).pipe( |
| 91 | Layer.provideMerge(NodeServices.layer), | 99 | Layer.provideMerge(NodeServices.layer), |
| 100 | Layer.provideMerge(TestHttpClientLive), | ||
| 92 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 101 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 93 | ); | 102 | ); |
| 94 | 103 | ||
| ... | @@ -225,6 +234,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ... | @@ -225,6 +234,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { |
| 225 | prefix: "provider-instance-registry-all-drivers-test", | 234 | prefix: "provider-instance-registry-all-drivers-test", |
| 226 | }).pipe( | 235 | }).pipe( |
| 227 | Layer.provideMerge(infraLayer), | 236 | Layer.provideMerge(infraLayer), |
| 237 | Layer.provideMerge(TestHttpClientLive), | ||
| 228 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 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,6 +14,7 @@ import { |
| 14 | type ServerSettings as ContractServerSettings, | 14 | type ServerSettings as ContractServerSettings, |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | import * as PlatformError from "effect/PlatformError"; | 16 | import * as PlatformError from "effect/PlatformError"; |
| 17 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | import { deepMerge } from "@t3tools/shared/Struct"; | 19 | import { deepMerge } from "@t3tools/shared/Struct"; |
| 19 | import { createModelCapabilities } from "@t3tools/shared/model"; | 20 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| ... | @@ -26,13 +27,17 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr | ... | @@ -26,13 +27,17 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr |
| 26 | import { | 27 | import { |
| 27 | haveProvidersChanged, | 28 | haveProvidersChanged, |
| 28 | mergeProviderSnapshot, | 29 | mergeProviderSnapshot, |
| 30 | mergeProviderSnapshots, | ||
| 29 | ProviderRegistryLive, | 31 | ProviderRegistryLive, |
| 32 | selectProvidersByKind, | ||
| 30 | } from "./ProviderRegistry.ts"; | 33 | } from "./ProviderRegistry.ts"; |
| 31 | import { ServerConfig } from "../../config.ts"; | 34 | import { ServerConfig } from "../../config.ts"; |
| 32 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; | 35 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; |
| 36 | import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; | ||
| 33 | import type { ProviderInstance } from "../ProviderDriver.ts"; | 37 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 34 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | 38 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 35 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; | 39 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; |
| 40 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 36 | 41 | ||
| 37 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); | 42 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); |
| 38 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); | 43 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); |
| ... | @@ -46,6 +51,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; | ... | @@ -46,6 +51,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; |
| 46 | 51 | ||
| 47 | const encoder = new TextEncoder(); | 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 | function selectDescriptor( | 61 | function selectDescriptor( |
| 50 | id: string, | 62 | id: string, |
| 51 | label: string, | 63 | label: string, |
| ... | @@ -246,7 +258,7 @@ function makeMutableServerSettingsService( | ... | @@ -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 | "ProviderRegistry", | 262 | "ProviderRegistry", |
| 251 | (it) => { | 263 | (it) => { |
| 252 | describe("checkCodexProviderStatus", () => { | 264 | describe("checkCodexProviderStatus", () => { |
| ... | @@ -500,6 +512,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -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 | it.effect("returns the cached provider list when a manual refresh fails", () => | 691 | it.effect("returns the cached provider list when a manual refresh fails", () => |
| 504 | Effect.gen(function* () { | 692 | Effect.gen(function* () { |
| 505 | const codexDriver = ProviderDriverKind.make("codex"); | 693 | const codexDriver = ProviderDriverKind.make("codex"); |
| ... | @@ -527,6 +715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -527,6 +715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 527 | displayName: undefined, | 715 | displayName: undefined, |
| 528 | enabled: true, | 716 | enabled: true, |
| 529 | snapshot: { | 717 | snapshot: { |
| 718 | maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 719 | provider: codexDriver, | ||
| 720 | packageName: null, | ||
| 721 | }), | ||
| 530 | getSnapshot: Effect.succeed(cachedProvider), | 722 | getSnapshot: Effect.succeed(cachedProvider), |
| 531 | refresh: Effect.die(new Error("simulated refresh failure")), | 723 | refresh: Effect.die(new Error("simulated refresh failure")), |
| 532 | streamChanges: Stream.empty, | 724 | streamChanges: Stream.empty, |
| ... | @@ -612,6 +804,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -612,6 +804,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 612 | displayName: undefined, | 804 | displayName: undefined, |
| 613 | enabled: true, | 805 | enabled: true, |
| 614 | snapshot: { | 806 | snapshot: { |
| 807 | maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 808 | provider: provider.driver, | ||
| 809 | packageName: null, | ||
| 810 | }), | ||
| 615 | getSnapshot: Effect.succeed(provider), | 811 | getSnapshot: Effect.succeed(provider), |
| 616 | refresh: Effect.succeed(provider), | 812 | refresh: Effect.succeed(provider), |
| 617 | streamChanges: Stream.empty, | 813 | streamChanges: Stream.empty, |
| ... | @@ -748,6 +944,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -748,6 +944,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 748 | prefix: "t3-provider-registry-", | 944 | prefix: "t3-provider-registry-", |
| 749 | }), | 945 | }), |
| 750 | ), | 946 | ), |
| 947 | Layer.provideMerge(TestHttpClientLive), | ||
| 751 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 948 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 752 | Layer.provideMerge(OpenCodeRuntimeLive), | 949 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 753 | // NO spawner mock — `ChildProcessSpawner` is supplied by the | 950 | // NO spawner mock — `ChildProcessSpawner` is supplied by the |
| ... | @@ -832,6 +1029,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -832,6 +1029,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 832 | prefix: "t3-provider-registry-", | 1029 | prefix: "t3-provider-registry-", |
| 833 | }), | 1030 | }), |
| 834 | ), | 1031 | ), |
| 1032 | Layer.provideMerge(TestHttpClientLive), | ||
| 835 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 1033 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 836 | Layer.provideMerge(OpenCodeRuntimeLive), | 1034 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 837 | // `it.live` does not inherit layers from the outer `it.layer` | 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,6 +1134,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 936 | prefix: "t3-provider-registry-", | 1134 | prefix: "t3-provider-registry-", |
| 937 | }), | 1135 | }), |
| 938 | ), | 1136 | ), |
| 1137 | Layer.provideMerge(TestHttpClientLive), | ||
| 939 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 1138 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 940 | Layer.provideMerge(OpenCodeRuntimeLive), | 1139 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 941 | Layer.provideMerge(NodeServices.layer), | 1140 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -986,6 +1185,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -986,6 +1185,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 986 | prefix: "t3-provider-registry-", | 1185 | prefix: "t3-provider-registry-", |
| 987 | }), | 1186 | }), |
| 988 | ), | 1187 | ), |
| 1188 | Layer.provideMerge(TestHttpClientLive), | ||
| 989 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 1189 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 990 | Layer.provideMerge(OpenCodeRuntimeLive), | 1190 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 991 | Layer.provideMerge( | 1191 | Layer.provideMerge( |
apps/server/src/provider/Layers/ProviderRegistry.ts+128-4| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | ProviderDriverKind, | 27 | ProviderDriverKind, |
| 28 | type ProviderInstanceId, | 28 | type ProviderInstanceId, |
| 29 | type ServerProvider, | 29 | type ServerProvider, |
| 30 | type ServerProviderUpdateState, | ||
| 30 | } from "@t3tools/contracts"; | 31 | } from "@t3tools/contracts"; |
| 31 | import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; | 32 | import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; |
| 32 | import * as Semaphore from "effect/Semaphore"; | 33 | import * as Semaphore from "effect/Semaphore"; |
| ... | @@ -43,6 +44,7 @@ import { | ... | @@ -43,6 +44,7 @@ import { |
| 43 | writeProviderStatusCache, | 44 | writeProviderStatusCache, |
| 44 | } from "../providerStatusCache.ts"; | 45 | } from "../providerStatusCache.ts"; |
| 45 | import type { ProviderInstance } from "../ProviderDriver.ts"; | 46 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 47 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 46 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; | 48 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; |
| 47 | 49 | ||
| 48 | const loadProviders = ( | 50 | const loadProviders = ( |
| ... | @@ -59,6 +61,12 @@ 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 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => | 70 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => |
| 63 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; | 71 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; |
| 64 | 72 | ||
| ... | @@ -96,6 +104,30 @@ export const mergeProviderSnapshot = ( | ... | @@ -96,6 +104,30 @@ export const mergeProviderSnapshot = ( |
| 96 | models: mergeProviderModels(previousProvider.models, nextProvider.models), | 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 | export const haveProvidersChanged = ( | 131 | export const haveProvidersChanged = ( |
| 100 | previousProviders: ReadonlyArray<ServerProvider>, | 132 | previousProviders: ReadonlyArray<ServerProvider>, |
| 101 | nextProviders: ReadonlyArray<ServerProvider>, | 133 | nextProviders: ReadonlyArray<ServerProvider>, |
| ... | @@ -227,6 +259,9 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -227,6 +259,9 @@ export const ProviderRegistryLive = Layer.effect( |
| 227 | ), | 259 | ), |
| 228 | ); | 260 | ); |
| 229 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); | 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 | // Live-source registry — the dynamic counterpart to the boot-time | 266 | // Live-source registry — the dynamic counterpart to the boot-time |
| 232 | // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` | 267 | // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` |
| ... | @@ -264,6 +299,21 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -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 | const upsertProviders = Effect.fn("upsertProviders")(function* ( | 317 | const upsertProviders = Effect.fn("upsertProviders")(function* ( |
| 268 | nextProviders: ReadonlyArray<ServerProvider>, | 318 | nextProviders: ReadonlyArray<ServerProvider>, |
| 269 | options?: { | 319 | options?: { |
| ... | @@ -272,15 +322,24 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -272,15 +322,24 @@ export const ProviderRegistryLive = Layer.effect( |
| 272 | readonly replace?: boolean; | 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 | providersRef, | 333 | providersRef, |
| 277 | (previousProviders) => { | 334 | (previousProviders) => { |
| 278 | const mergedProviders = new Map( | 335 | const mergedProviders = new Map( |
| 279 | previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), | 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 | const key = snapshotInstanceKey(provider); | 341 | const key = snapshotInstanceKey(provider); |
| 342 | updatedKeys.add(key); | ||
| 284 | mergedProviders.set( | 343 | mergedProviders.set( |
| 285 | key, | 344 | key, |
| 286 | options?.replace === true | 345 | options?.replace === true |
| ... | @@ -290,13 +349,16 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -290,13 +349,16 @@ export const ProviderRegistryLive = Layer.effect( |
| 290 | } | 349 | } |
| 291 | 350 | ||
| 292 | const providers = orderProviderSnapshots([...mergedProviders.values()]); | 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 | if (haveProvidersChanged(previousProviders, providers)) { | 359 | if (haveProvidersChanged(previousProviders, providers)) { |
| 298 | if (options?.persist !== false) { | 360 | if (options?.persist !== false) { |
| 299 | yield* Effect.forEach(nextProviders, persistProvider, { | 361 | yield* Effect.forEach(providersToPersist, persistProvider, { |
| 300 | concurrency: "unbounded", | 362 | concurrency: "unbounded", |
| 301 | discard: true, | 363 | discard: true, |
| 302 | }); | 364 | }); |
| ... | @@ -318,6 +380,45 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -318,6 +380,45 @@ export const ProviderRegistryLive = Layer.effect( |
| 318 | return yield* upsertProviders([provider], options); | 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 | const refreshOneSource = Effect.fn("refreshOneSource")(function* ( | 422 | const refreshOneSource = Effect.fn("refreshOneSource")(function* ( |
| 322 | providerSource: ProviderSnapshotSource, | 423 | providerSource: ProviderSnapshotSource, |
| 323 | ) { | 424 | ) { |
| ... | @@ -365,6 +466,18 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -365,6 +466,18 @@ export const ProviderRegistryLive = Layer.effect( |
| 365 | return yield* refreshOneSource(providerSource); | 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 | * Diff the aggregator's live-source set against the current | 482 | * Diff the aggregator's live-source set against the current |
| 370 | * `ProviderInstanceRegistry` and: | 483 | * `ProviderInstanceRegistry` and: |
| ... | @@ -472,6 +585,15 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -472,6 +585,15 @@ export const ProviderRegistryLive = Layer.effect( |
| 472 | if (haveProvidersChanged(previousProviders, providers)) { | 585 | if (haveProvidersChanged(previousProviders, providers)) { |
| 473 | yield* PubSub.publish(changesPubSub, providers); | 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 | const syncLiveSourcesAndContinue = syncLiveSources.pipe( | 599 | const syncLiveSourcesAndContinue = syncLiveSources.pipe( |
| ... | @@ -555,6 +677,8 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -555,6 +677,8 @@ export const ProviderRegistryLive = Layer.effect( |
| 555 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), | 677 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 556 | refreshInstance: (instanceId: ProviderInstanceId) => | 678 | refreshInstance: (instanceId: ProviderInstanceId) => |
| 557 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), | 679 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), |
| 680 | getProviderMaintenanceCapabilitiesForInstance, | ||
| 681 | setProviderMaintenanceActionState, | ||
| 558 | get streamChanges() { | 682 | get streamChanges() { |
| 559 | return Stream.fromPubSub(changesPubSub); | 683 | return Stream.fromPubSub(changesPubSub); |
| 560 | }, | 684 | }, |
apps/server/src/provider/Services/ProviderRegistry.ts+30-1| ... | @@ -6,9 +6,17 @@ | ... | @@ -6,9 +6,17 @@ |
| 6 | * | 6 | * |
| 7 | * @module ProviderRegistry | 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 | import { Context } from "effect"; | 15 | import { Context } from "effect"; |
| 11 | import type { Effect, Stream } from "effect"; | 16 | import type { Effect, Stream } from "effect"; |
| 17 | import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 18 | |||
| 19 | export type ProviderMaintenanceActionKind = "update"; | ||
| 12 | 20 | ||
| 13 | export interface ProviderRegistryShape { | 21 | export interface ProviderRegistryShape { |
| 14 | /** | 22 | /** |
| ... | @@ -39,6 +47,27 @@ export interface ProviderRegistryShape { | ... | @@ -39,6 +47,27 @@ export interface ProviderRegistryShape { |
| 39 | instanceId: ProviderInstanceId, | 47 | instanceId: ProviderInstanceId, |
| 40 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; | 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 | * Stream of provider snapshot updates — one emission per aggregated | 72 | * Stream of provider snapshot updates — one emission per aggregated |
| 44 | * change. The array contains the full current state. | 73 | * change. The array contains the full current state. |
apps/server/src/provider/Services/ServerProvider.ts+2| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | 1 | import type { ServerProvider } from "@t3tools/contracts"; |
| 2 | import type { Effect, Stream } from "effect"; | 2 | import type { Effect, Stream } from "effect"; |
| 3 | import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 3 | 4 | ||
| 4 | export interface ServerProviderShape { | 5 | export interface ServerProviderShape { |
| 6 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | ||
| 5 | readonly getSnapshot: Effect.Effect<ServerProvider>; | 7 | readonly getSnapshot: Effect.Effect<ServerProvider>; |
| 6 | readonly refresh: Effect.Effect<ServerProvider>; | 8 | readonly refresh: Effect.Effect<ServerProvider>; |
| 7 | readonly streamChanges: Stream.Stream<ServerProvider>; | 9 | readonly streamChanges: Stream.Stream<ServerProvider>; |
apps/server/src/provider/makeManagedServerProvider.test.ts+18| ... | @@ -20,6 +20,20 @@ interface TestSettings { | ... | @@ -20,6 +20,20 @@ interface TestSettings { |
| 20 | readonly enabled: boolean; | 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 | const initialSnapshot: ServerProvider = { | 37 | const initialSnapshot: ServerProvider = { |
| 24 | instanceId: ProviderInstanceId.make("codex"), | 38 | instanceId: ProviderInstanceId.make("codex"), |
| 25 | driver: ProviderDriverKind.make("codex"), | 39 | driver: ProviderDriverKind.make("codex"), |
| ... | @@ -90,6 +104,7 @@ describe("makeManagedServerProvider", () => { | ... | @@ -90,6 +104,7 @@ describe("makeManagedServerProvider", () => { |
| 90 | const checkCalls = yield* Ref.make(0); | 104 | const checkCalls = yield* Ref.make(0); |
| 91 | const releaseCheck = yield* Deferred.make<void>(); | 105 | const releaseCheck = yield* Deferred.make<void>(); |
| 92 | const provider = yield* makeManagedServerProvider<TestSettings>({ | 106 | const provider = yield* makeManagedServerProvider<TestSettings>({ |
| 107 | maintenanceCapabilities, | ||
| 93 | getSettings: Effect.succeed({ enabled: true }), | 108 | getSettings: Effect.succeed({ enabled: true }), |
| 94 | streamSettings: Stream.empty, | 109 | streamSettings: Stream.empty, |
| 95 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | 110 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, |
| ... | @@ -131,6 +146,7 @@ describe("makeManagedServerProvider", () => { | ... | @@ -131,6 +146,7 @@ describe("makeManagedServerProvider", () => { |
| 131 | const releaseInitialCheck = yield* Deferred.make<void>(); | 146 | const releaseInitialCheck = yield* Deferred.make<void>(); |
| 132 | const releaseSettingsCheck = yield* Deferred.make<void>(); | 147 | const releaseSettingsCheck = yield* Deferred.make<void>(); |
| 133 | const provider = yield* makeManagedServerProvider<TestSettings>({ | 148 | const provider = yield* makeManagedServerProvider<TestSettings>({ |
| 149 | maintenanceCapabilities, | ||
| 134 | getSettings: Ref.get(settingsRef), | 150 | getSettings: Ref.get(settingsRef), |
| 135 | streamSettings: Stream.fromPubSub(settingsChanges), | 151 | streamSettings: Stream.fromPubSub(settingsChanges), |
| 136 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | 152 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, |
| ... | @@ -172,6 +188,7 @@ describe("makeManagedServerProvider", () => { | ... | @@ -172,6 +188,7 @@ describe("makeManagedServerProvider", () => { |
| 172 | const releaseEnrichment = yield* Deferred.make<void>(); | 188 | const releaseEnrichment = yield* Deferred.make<void>(); |
| 173 | const releaseCheck = yield* Deferred.make<void>(); | 189 | const releaseCheck = yield* Deferred.make<void>(); |
| 174 | const provider = yield* makeManagedServerProvider<TestSettings>({ | 190 | const provider = yield* makeManagedServerProvider<TestSettings>({ |
| 191 | maintenanceCapabilities, | ||
| 175 | getSettings: Effect.succeed({ enabled: true }), | 192 | getSettings: Effect.succeed({ enabled: true }), |
| 176 | streamSettings: Stream.empty, | 193 | streamSettings: Stream.empty, |
| 177 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | 194 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, |
| ... | @@ -212,6 +229,7 @@ describe("makeManagedServerProvider", () => { | ... | @@ -212,6 +229,7 @@ describe("makeManagedServerProvider", () => { |
| 212 | const secondCallbackReady = yield* Deferred.make<void>(); | 229 | const secondCallbackReady = yield* Deferred.make<void>(); |
| 213 | const allowFirstRefresh = yield* Deferred.make<void>(); | 230 | const allowFirstRefresh = yield* Deferred.make<void>(); |
| 214 | const provider = yield* makeManagedServerProvider<TestSettings>({ | 231 | const provider = yield* makeManagedServerProvider<TestSettings>({ |
| 232 | maintenanceCapabilities, | ||
| 215 | getSettings: Effect.succeed({ enabled: true }), | 233 | getSettings: Effect.succeed({ enabled: true }), |
| 216 | streamSettings: Stream.empty, | 234 | streamSettings: Stream.empty, |
| 217 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, | 235 | haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, |
apps/server/src/provider/makeManagedServerProvider.ts+2| ... | @@ -13,6 +13,7 @@ interface ProviderSnapshotState { | ... | @@ -13,6 +13,7 @@ interface ProviderSnapshotState { |
| 13 | export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < | 13 | export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < |
| 14 | Settings, | 14 | Settings, |
| 15 | >(input: { | 15 | >(input: { |
| 16 | readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"]; | ||
| 16 | readonly getSettings: Effect.Effect<Settings>; | 17 | readonly getSettings: Effect.Effect<Settings>; |
| 17 | readonly streamSettings: Stream.Stream<Settings>; | 18 | readonly streamSettings: Stream.Stream<Settings>; |
| 18 | readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; | 19 | readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; |
| ... | @@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( | ... | @@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 143 | ); | 144 | ); |
| 144 | 145 | ||
| 145 | return { | 146 | return { |
| 147 | maintenanceCapabilities: input.maintenanceCapabilities, | ||
| 146 | getSnapshot: input.getSettings.pipe( | 148 | getSnapshot: input.getSettings.pipe( |
| 147 | Effect.flatMap(applySnapshot), | 149 | Effect.flatMap(applySnapshot), |
| 148 | Effect.tapError(Effect.logError), | 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,6 +12,8 @@ import { Effect, Stream } from "effect"; |
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 13 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 14 | import { isWindowsCommandNotFound } from "../processRunner.ts"; | 14 | import { isWindowsCommandNotFound } from "../processRunner.ts"; |
| 15 | import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; | ||
| 16 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | ||
| 15 | 17 | ||
| 16 | export const DEFAULT_TIMEOUT_MS = 4_000; | 18 | export const DEFAULT_TIMEOUT_MS = 4_000; |
| 17 | // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) | 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,6 +184,7 @@ export function buildBooleanOptionDescriptor(input: { |
| 182 | } | 184 | } |
| 183 | 185 | ||
| 184 | export function buildServerProvider(input: { | 186 | export function buildServerProvider(input: { |
| 187 | driver?: ProviderDriverKind; | ||
| 185 | presentation: ServerProviderPresentation; | 188 | presentation: ServerProviderPresentation; |
| 186 | enabled: boolean; | 189 | enabled: boolean; |
| 187 | checkedAt: string; | 190 | checkedAt: string; |
| ... | @@ -190,6 +193,13 @@ export function buildServerProvider(input: { | ... | @@ -190,6 +193,13 @@ export function buildServerProvider(input: { |
| 190 | skills?: ReadonlyArray<ServerProviderSkill>; | 193 | skills?: ReadonlyArray<ServerProviderSkill>; |
| 191 | probe: ProviderProbeResult; | 194 | probe: ProviderProbeResult; |
| 192 | }): ServerProviderDraft { | 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 | return { | 203 | return { |
| 194 | displayName: input.presentation.displayName, | 204 | displayName: input.presentation.displayName, |
| 195 | ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), | 205 | ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), |
| ... | @@ -206,16 +216,11 @@ export function buildServerProvider(input: { | ... | @@ -206,16 +216,11 @@ export function buildServerProvider(input: { |
| 206 | models: input.models, | 216 | models: input.models, |
| 207 | slashCommands: [...(input.slashCommands ?? [])], | 217 | slashCommands: [...(input.slashCommands ?? [])], |
| 208 | skills: [...(input.skills ?? [])], | 218 | skills: [...(input.skills ?? [])], |
| 219 | ...(versionAdvisory ? { versionAdvisory } : {}), | ||
| 209 | }; | 220 | }; |
| 210 | } | 221 | } |
| 211 | 222 | ||
| 212 | export const collectStreamAsString = <E>( | 223 | export const collectStreamAsString = <E>( |
| 213 | stream: Stream.Stream<Uint8Array, E>, | 224 | stream: Stream.Stream<Uint8Array, E>, |
| 214 | ): Effect.Effect<string, E> => | 225 | ): Effect.Effect<string, E> => |
| 215 | stream.pipe( | 226 | collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); |
| 216 | Stream.decodeText(), | ||
| 217 | Stream.runFold( | ||
| 218 | () => "", | ||
| 219 | (acc, chunk) => acc + chunk, | ||
| 220 | ), | ||
| 221 | ); |
apps/server/src/provider/providerStatusCache.ts+5-3| ... | @@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) => | ... | @@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) => |
| 140 | export const writeProviderStatusCache = (input: { | 140 | export const writeProviderStatusCache = (input: { |
| 141 | readonly filePath: string; | 141 | readonly filePath: string; |
| 142 | readonly provider: ServerProvider; | 142 | readonly provider: ServerProvider; |
| 143 | }) => | 143 | }) => { |
| 144 | writeFileStringAtomically({ | 144 | const { updateState: _updateState, ...cacheableProvider } = input.provider; |
| 145 | return writeFileStringAtomically({ | ||
| 145 | filePath: input.filePath, | 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,6 +80,7 @@ import { |
| 80 | ProviderRegistry, | 80 | ProviderRegistry, |
| 81 | type ProviderRegistryShape, | 81 | type ProviderRegistryShape, |
| 82 | } from "./provider/Services/ProviderRegistry.ts"; | 82 | } from "./provider/Services/ProviderRegistry.ts"; |
| 83 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; | ||
| 83 | import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; | 84 | import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; |
| 84 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; | 85 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; |
| 85 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; | 86 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| ... | @@ -517,6 +518,12 @@ const buildAppUnderTest = (options?: { | ... | @@ -517,6 +518,12 @@ const buildAppUnderTest = (options?: { |
| 517 | Layer.mock(ProviderRegistry)({ | 518 | Layer.mock(ProviderRegistry)({ |
| 518 | getProviders: Effect.succeed([]), | 519 | getProviders: Effect.succeed([]), |
| 519 | refresh: () => Effect.succeed([]), | 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 | streamChanges: Stream.empty, | 527 | streamChanges: Stream.empty, |
| 521 | ...options?.layers?.providerRegistry, | 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,6 +8,7 @@ import { |
| 8 | VcsProcessSpawnError, | 8 | VcsProcessSpawnError, |
| 9 | VcsProcessTimeoutError, | 9 | VcsProcessTimeoutError, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | ||
| 11 | 12 | ||
| 12 | export interface VcsProcessInput { | 13 | export interface VcsProcessInput { |
| 13 | readonly operation: string; | 14 | readonly operation: string; |
| ... | @@ -86,44 +87,12 @@ export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: | ... | @@ -86,44 +87,12 @@ export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: |
| 86 | readonly maxOutputBytes?: number; | 87 | readonly maxOutputBytes?: number; |
| 87 | readonly truncateOutputAtMaxBytes?: boolean; | 88 | readonly truncateOutputAtMaxBytes?: boolean; |
| 88 | }) { | 89 | }) { |
| 89 | const decoder = new TextDecoder(); | ||
| 90 | let text = ""; | ||
| 91 | let bytes = 0; | ||
| 92 | let truncated = false; | ||
| 93 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | 90 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 94 | const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; | 91 | return yield* collectUint8StreamText({ |
| 95 | 92 | stream: input.stream, | |
| 96 | yield* Stream.runForEach(input.stream, (chunk) => | 93 | maxBytes: maxOutputBytes, |
| 97 | Effect.sync(() => { | 94 | truncatedMarker: input.truncateOutputAtMaxBytes ? OUTPUT_TRUNCATED_MARKER : null, |
| 98 | if (truncated) return; | 95 | }); |
| 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; | ||
| 127 | }); | 96 | }); |
| 128 | 97 | ||
| 129 | export const make = Effect.fn("makeVcsProcess")(function* () { | 98 | export const make = Effect.fn("makeVcsProcess")(function* () { |
apps/server/src/ws.ts+11| ... | @@ -40,6 +40,7 @@ import { | ... | @@ -40,6 +40,7 @@ import { |
| 40 | observeRpcStreamEffect, | 40 | observeRpcStreamEffect, |
| 41 | } from "./observability/RpcInstrumentation.ts"; | 41 | } from "./observability/RpcInstrumentation.ts"; |
| 42 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; | 42 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; |
| 43 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; | ||
| 43 | import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; | 44 | import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 44 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; | 45 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 45 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; | 46 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| ... | @@ -152,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -152,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 152 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 153 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 153 | const terminalManager = yield* TerminalManager; | 154 | const terminalManager = yield* TerminalManager; |
| 154 | const providerRegistry = yield* ProviderRegistry; | 155 | const providerRegistry = yield* ProviderRegistry; |
| 156 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; | ||
| 155 | const config = yield* ServerConfig; | 157 | const config = yield* ServerConfig; |
| 156 | const lifecycleEvents = yield* ServerLifecycleEvents; | 158 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 157 | const serverSettings = yield* ServerSettingsService; | 159 | const serverSettings = yield* ServerSettingsService; |
| ... | @@ -785,6 +787,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -785,6 +787,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 785 | ).pipe(Effect.map((providers) => ({ providers }))), | 787 | ).pipe(Effect.map((providers) => ({ providers }))), |
| 786 | { "rpc.aggregate": "server" }, | 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 | [WS_METHODS.serverUpsertKeybinding]: (rule) => | 798 | [WS_METHODS.serverUpsertKeybinding]: (rule) => |
| 789 | observeRpcEffect( | 799 | observeRpcEffect( |
| 790 | WS_METHODS.serverUpsertKeybinding, | 800 | WS_METHODS.serverUpsertKeybinding, |
| ... | @@ -1152,6 +1162,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1152,6 +1162,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1152 | Effect.provide( | 1162 | Effect.provide( |
| 1153 | makeWsRpcLayer(session.sessionId).pipe( | 1163 | makeWsRpcLayer(session.sessionId).pipe( |
| 1154 | Layer.provideMerge(RpcSerialization.layerJson), | 1164 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1165 | Layer.provide(ProviderMaintenanceRunner.layer), | ||
| 1155 | Layer.provide( | 1166 | Layer.provide( |
| 1156 | SourceControlDiscoveryLayer.layer.pipe( | 1167 | SourceControlDiscoveryLayer.layer.pipe( |
| 1157 | Layer.provide( | 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,6 +185,7 @@ import { |
| 185 | type SidebarProjectGroupMember, | 185 | type SidebarProjectGroupMember, |
| 186 | type SidebarProjectSnapshot, | 186 | type SidebarProjectSnapshot, |
| 187 | } from "../sidebarProjectGrouping"; | 187 | } from "../sidebarProjectGrouping"; |
| 188 | import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; | ||
| 188 | const THREAD_PREVIEW_LIMIT = 6; | 189 | const THREAD_PREVIEW_LIMIT = 6; |
| 189 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { | 190 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 190 | updated_at: "Last user message", | 191 | updated_at: "Last user message", |
| ... | @@ -2434,6 +2435,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { | ... | @@ -2434,6 +2435,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { |
| 2434 | 2435 | ||
| 2435 | return ( | 2436 | return ( |
| 2436 | <SidebarFooter className="p-2"> | 2437 | <SidebarFooter className="p-2"> |
| 2438 | <SidebarProviderUpdatePill /> | ||
| 2437 | <SidebarUpdatePill /> | 2439 | <SidebarUpdatePill /> |
| 2438 | <SidebarMenu> | 2440 | <SidebarMenu> |
| 2439 | <SidebarMenuItem> | 2441 | <SidebarMenuItem> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+226-83| ... | @@ -1,6 +1,15 @@ | ... | @@ -1,6 +1,15 @@ |
| 1 | "use client"; | 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 | import { useEffect, useState, type ReactNode } from "react"; | 13 | import { useEffect, useState, type ReactNode } from "react"; |
| 5 | import { | 14 | import { |
| 6 | isProviderDriverKind, | 15 | isProviderDriverKind, |
| ... | @@ -13,12 +22,15 @@ import { | ... | @@ -13,12 +22,15 @@ import { |
| 13 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 14 | 23 | ||
| 15 | import { cn } from "../../lib/utils"; | 24 | import { cn } from "../../lib/utils"; |
| 25 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ||
| 16 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 26 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 17 | import { Badge } from "../ui/badge"; | 27 | import { Badge } from "../ui/badge"; |
| 18 | import { Button } from "../ui/button"; | 28 | import { Button } from "../ui/button"; |
| 19 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; | 29 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; |
| 20 | import { DraftInput } from "../ui/draft-input"; | 30 | import { DraftInput } from "../ui/draft-input"; |
| 31 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 21 | import { Switch } from "../ui/switch"; | 32 | import { Switch } from "../ui/switch"; |
| 33 | import { stackedThreadToast, toastManager } from "../ui/toast"; | ||
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 34 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 23 | import type { DriverOption } from "./providerDriverMeta"; | 35 | import type { DriverOption } from "./providerDriverMeta"; |
| 24 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; | 36 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; |
| ... | @@ -26,6 +38,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; | ... | @@ -26,6 +38,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 26 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 38 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 27 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 39 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 28 | import { | 40 | import { |
| 41 | getProviderVersionAdvisoryPresentation, | ||
| 29 | PROVIDER_STATUS_STYLES, | 42 | PROVIDER_STATUS_STYLES, |
| 30 | getProviderSummary, | 43 | getProviderSummary, |
| 31 | getProviderVersionLabel, | 44 | getProviderVersionLabel, |
| ... | @@ -405,6 +418,8 @@ interface ProviderInstanceCardProps { | ... | @@ -405,6 +418,8 @@ interface ProviderInstanceCardProps { |
| 405 | readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void; | 418 | readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void; |
| 406 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; | 419 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; |
| 407 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; | 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,6 +462,8 @@ export function ProviderInstanceCard({ |
| 447 | onHiddenModelsChange, | 462 | onHiddenModelsChange, |
| 448 | onFavoriteModelsChange, | 463 | onFavoriteModelsChange, |
| 449 | onModelOrderChange, | 464 | onModelOrderChange, |
| 465 | onRunUpdate, | ||
| 466 | isUpdating = false, | ||
| 450 | }: ProviderInstanceCardProps) { | 467 | }: ProviderInstanceCardProps) { |
| 451 | const enabled = instance.enabled ?? true; | 468 | const enabled = instance.enabled ?? true; |
| 452 | // The server-reported status wins when present; otherwise fall back to | 469 | // The server-reported status wins when present; otherwise fall back to |
| ... | @@ -464,10 +481,30 @@ export function ProviderInstanceCard({ | ... | @@ -464,10 +481,30 @@ export function ProviderInstanceCard({ |
| 464 | : null; | 481 | : null; |
| 465 | const summary = rawSummary; | 482 | const summary = rawSummary; |
| 466 | const versionLabel = getProviderVersionLabel(liveProvider?.version); | 483 | const versionLabel = getProviderVersionLabel(liveProvider?.version); |
| 484 | const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); | ||
| 485 | const updateCommand = versionAdvisory?.updateCommand ?? null; | ||
| 467 | const FallbackIconComponent = driverOption?.icon; | 486 | const FallbackIconComponent = driverOption?.icon; |
| 468 | const displayName = | 487 | const displayName = |
| 469 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); | 488 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); |
| 470 | const accentColor = normalizeProviderAccentColor(instance.accentColor); | 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 | // Narrow `instance.driver` for callers that key on the closed | 509 | // Narrow `instance.driver` for callers that key on the closed |
| 473 | // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom | 510 | // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom |
| ... | @@ -535,97 +572,203 @@ export function ProviderInstanceCard({ | ... | @@ -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 | return ( | 672 | return ( |
| 539 | <div className="border-t border-border first:border-t-0"> | 673 | <div className="border-t border-border/60 first:border-t-0"> |
| 540 | <div className="px-4 py-4 sm:px-5"> | 674 | <div className="px-4 py-3.5 sm:px-5"> |
| 541 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | 675 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 542 | <div className="min-w-0 flex-1 space-y-1"> | 676 | <div className="min-w-0 flex-1 space-y-1"> |
| 543 | <div className="flex min-h-5 items-center gap-1.5"> | 677 | <div className="flex min-w-0 flex-wrap items-center gap-2"> |
| 544 | {driverKind ? ( | 678 | {titleHeadNode} |
| 545 | <ProviderInstanceIcon | 679 | {versionCodeNode} |
| 546 | driverKind={driverKind} | 680 | {versionAdvisory ? ( |
| 547 | displayName={displayName} | 681 | <Popover> |
| 548 | accentColor={accentColor} | 682 | <PopoverTrigger |
| 549 | showBadge={Boolean(accentColor)} | 683 | render={ |
| 550 | statusDotClassName={statusStyle.dot} | 684 | <Button |
| 551 | className="size-5" | 685 | type="button" |
| 552 | iconClassName="size-4 text-foreground/80" | 686 | size="icon-xs" |
| 553 | badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" | 687 | variant="ghost" |
| 554 | /> | 688 | className={cn( |
| 555 | ) : FallbackIconComponent ? ( | 689 | "size-5 rounded-sm p-0", |
| 556 | <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> | 690 | versionAdvisory.emphasis === "strong" |
| 557 | <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden /> | 691 | ? "text-warning hover:text-warning" |
| 558 | <span | 692 | : "text-primary hover:text-primary", |
| 559 | className={cn( | 693 | )} |
| 560 | "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", | 694 | aria-label="Update available — view details" |
| 561 | statusStyle.dot, | 695 | > |
| 562 | )} | 696 | <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" /> |
| 563 | aria-hidden | 697 | </Button> |
| 698 | } | ||
| 564 | /> | 699 | /> |
| 565 | </span> | 700 | <PopoverPopup side="bottom" align="start" className="w-84"> |
| 566 | ) : ( | 701 | <div className="grid gap-3"> |
| 567 | <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} /> | 702 | <div className="grid gap-0.5"> |
| 568 | )} | 703 | <p className="text-[13px] font-semibold leading-tight text-foreground"> |
| 569 | <h3 className="truncate text-sm font-medium text-foreground">{displayName}</h3> | 704 | Update available |
| 570 | {String(instanceId) !== String(instance.driver) ? ( | 705 | </p> |
| 571 | // Hide the id chip on a default slot whose id === the | 706 | <p |
| 572 | // driver slug — it's redundant with the driver icon + | 707 | className={cn( |
| 573 | // label. Custom instances (and any instance the user has | 708 | "text-xs leading-snug", |
| 574 | // since renamed) keep the chip so their slug stays | 709 | versionAdvisory.emphasis === "strong" |
| 575 | // visible for copy/paste + disambiguation. | 710 | ? "text-warning" |
| 576 | <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground"> | 711 | : "text-muted-foreground", |
| 577 | {instanceId} | 712 | )} |
| 578 | </code> | 713 | > |
| 579 | ) : null} | 714 | {versionAdvisory.detail} |
| 580 | {driverOption?.badgeLabel ? ( | 715 | </p> |
| 581 | <Badge variant="warning" size="sm" className="shrink-0"> | 716 | </div> |
| 582 | {driverOption.badgeLabel} | 717 | {onRunUpdate ? ( |
| 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={ | ||
| 598 | <Button | 718 | <Button |
| 599 | size="icon-xs" | 719 | type="button" |
| 600 | variant="ghost" | 720 | size="xs" |
| 601 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive" | 721 | variant="default" |
| 602 | onClick={onDelete} | 722 | className="w-full" |
| 603 | aria-label={`Delete provider instance ${instanceId}`} | 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 | </Button> | 728 | </Button> |
| 607 | } | 729 | ) : null} |
| 608 | /> | 730 | {onRunUpdate && updateCommand ? ( |
| 609 | <TooltipPopup side="top">Delete instance</TooltipPopup> | 731 | <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> |
| 610 | </Tooltip> | 732 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| 611 | </span> | 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 | ) : null} | 768 | ) : null} |
| 769 | {titleTailNode} | ||
| 613 | </div> | 770 | </div> |
| 614 | <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground"> | 771 | {authRowNode} |
| 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> | ||
| 629 | </div> | 772 | </div> |
| 630 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 773 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 631 | <Button | 774 | <Button |
apps/web/src/components/settings/SettingsPanels.browser.tsx+65-2| ... | @@ -10,7 +10,10 @@ import { | ... | @@ -10,7 +10,10 @@ import { |
| 10 | type DesktopUpdateChannel, | 10 | type DesktopUpdateChannel, |
| 11 | type DesktopUpdateState, | 11 | type DesktopUpdateState, |
| 12 | type LocalApi, | 12 | type LocalApi, |
| 13 | ProviderDriverKind, | ||
| 14 | ProviderInstanceId, | ||
| 13 | type ServerConfig, | 15 | type ServerConfig, |
| 16 | type ServerProvider, | ||
| 14 | type SourceControlDiscoveryResult, | 17 | type SourceControlDiscoveryResult, |
| 15 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 16 | import { DateTime, Option } from "effect"; | 19 | import { DateTime, Option } from "effect"; |
| ... | @@ -23,7 +26,7 @@ import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc | ... | @@ -23,7 +26,7 @@ import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc |
| 23 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; | 26 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; |
| 24 | import { useUiStateStore } from "../../uiStateStore"; | 27 | import { useUiStateStore } from "../../uiStateStore"; |
| 25 | import { ConnectionsSettings } from "./ConnectionsSettings"; | 28 | import { ConnectionsSettings } from "./ConnectionsSettings"; |
| 26 | import { GeneralSettingsPanel } from "./SettingsPanels"; | 29 | import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; |
| 27 | import { SourceControlSettingsPanel } from "./SourceControlSettings"; | 30 | import { SourceControlSettingsPanel } from "./SourceControlSettings"; |
| 28 | 31 | ||
| 29 | const authAccessHarness = vi.hoisted(() => { | 32 | const authAccessHarness = vi.hoisted(() => { |
| ... | @@ -207,6 +210,31 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -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 | function makeUtc(value: string) { | 238 | function makeUtc(value: string) { |
| 211 | return DateTime.makeUnsafe(Date.parse(value)); | 239 | return DateTime.makeUnsafe(Date.parse(value)); |
| 212 | } | 240 | } |
| ... | @@ -1016,7 +1044,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -1016,7 +1044,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 1016 | 1044 | ||
| 1017 | mounted = await render( | 1045 | mounted = await render( |
| 1018 | <AppAtomRegistryProvider> | 1046 | <AppAtomRegistryProvider> |
| 1019 | <GeneralSettingsPanel /> | 1047 | <ProviderSettingsPanel /> |
| 1020 | </AppAtomRegistryProvider>, | 1048 | </AppAtomRegistryProvider>, |
| 1021 | ); | 1049 | ); |
| 1022 | 1050 | ||
| ... | @@ -1031,6 +1059,41 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -1031,6 +1059,41 @@ describe("GeneralSettingsPanel observability", () => { |
| 1031 | await expect.element(page.getByText("Server password")).toBeInTheDocument(); | 1059 | await expect.element(page.getByText("Server password")).toBeInTheDocument(); |
| 1032 | await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); | 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 | describe("SourceControlSettingsPanel discovery states", () => { | 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,6 +4,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; |
| 4 | import { | 4 | import { |
| 5 | defaultInstanceIdForDriver, | 5 | defaultInstanceIdForDriver, |
| 6 | type DesktopUpdateChannel, | 6 | type DesktopUpdateChannel, |
| 7 | PROVIDER_DISPLAY_NAMES, | ||
| 7 | ProviderDriverKind, | 8 | ProviderDriverKind, |
| 8 | type ProviderInstanceConfig, | 9 | type ProviderInstanceConfig, |
| 9 | type ProviderInstanceId, | 10 | type ProviderInstanceId, |
| ... | @@ -56,6 +57,13 @@ import { Switch } from "../ui/switch"; | ... | @@ -56,6 +57,13 @@ import { Switch } from "../ui/switch"; |
| 56 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 57 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 57 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 58 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 58 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | 59 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 60 | import { | ||
| 61 | canOneClickUpdateProviderCandidate, | ||
| 62 | collectProviderUpdateCandidates, | ||
| 63 | hasOneClickUpdateProviderCandidate, | ||
| 64 | isProviderUpdateActive, | ||
| 65 | type ProviderUpdateCandidate, | ||
| 66 | } from "../ProviderUpdateLaunchNotification.logic"; | ||
| 59 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | 67 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 60 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | 68 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 61 | import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; | 69 | import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; |
| ... | @@ -345,37 +353,12 @@ function AboutVersionSection() { | ... | @@ -345,37 +353,12 @@ function AboutVersionSection() { |
| 345 | export function useSettingsRestore(onRestored?: () => void) { | 353 | export function useSettingsRestore(onRestored?: () => void) { |
| 346 | const { theme, setTheme } = useTheme(); | 354 | const { theme, setTheme } = useTheme(); |
| 347 | const settings = useSettings(); | 355 | const settings = useSettings(); |
| 348 | const { resetSettings } = useUpdateSettings(); | 356 | const { updateSettings } = useUpdateSettings(); |
| 349 | 357 | ||
| 350 | const isGitWritingModelDirty = !Equal.equals( | 358 | const isGitWritingModelDirty = !Equal.equals( |
| 351 | settings.textGenerationModelSelection ?? null, | 359 | settings.textGenerationModelSelection ?? null, |
| 352 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, | 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 | const changedSettingLabels = useMemo( | 363 | const changedSettingLabels = useMemo( |
| 381 | () => [ | 364 | () => [ |
| ... | @@ -408,10 +391,8 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -408,10 +391,8 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 408 | ? ["Delete confirmation"] | 391 | ? ["Delete confirmation"] |
| 409 | : []), | 392 | : []), |
| 410 | ...(isGitWritingModelDirty ? ["Git writing model"] : []), | 393 | ...(isGitWritingModelDirty ? ["Git writing model"] : []), |
| 411 | ...(areProviderSettingsDirty ? ["Providers"] : []), | ||
| 412 | ], | 394 | ], |
| 413 | [ | 395 | [ |
| 414 | areProviderSettingsDirty, | ||
| 415 | isGitWritingModelDirty, | 396 | isGitWritingModelDirty, |
| 416 | settings.autoOpenPlanSidebar, | 397 | settings.autoOpenPlanSidebar, |
| 417 | settings.confirmThreadArchive, | 398 | settings.confirmThreadArchive, |
| ... | @@ -437,9 +418,20 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -437,9 +418,20 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 437 | if (!confirmed) return; | 418 | if (!confirmed) return; |
| 438 | 419 | ||
| 439 | setTheme("system"); | 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 | onRestored?.(); | 433 | onRestored?.(); |
| 442 | }, [changedSettingLabels, onRestored, resetSettings, setTheme]); | 434 | }, [changedSettingLabels, onRestored, setTheme, updateSettings]); |
| 443 | 435 | ||
| 444 | return { | 436 | return { |
| 445 | changedSettingLabels, | 437 | changedSettingLabels, |
| ... | @@ -458,44 +450,10 @@ export function GeneralSettingsPanel() { | ... | @@ -458,44 +450,10 @@ export function GeneralSettingsPanel() { |
| 458 | const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< | 450 | const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< |
| 459 | Partial<Record<"keybindings" | "logsDirectory", string | null>> | 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 | const keybindingsConfigPath = useServerKeybindingsConfigPath(); | 453 | const keybindingsConfigPath = useServerKeybindingsConfigPath(); |
| 488 | const availableEditors = useServerAvailableEditors(); | 454 | const availableEditors = useServerAvailableEditors(); |
| 489 | const observability = useServerObservability(); | 455 | const observability = useServerObservability(); |
| 490 | const serverProviders = useServerProviders(); | 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 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; | 457 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; |
| 500 | const diagnosticsDescription = (() => { | 458 | const diagnosticsDescription = (() => { |
| 501 | const exports: string[] = []; | 459 | const exports: string[] = []; |
| ... | @@ -576,221 +534,6 @@ export function GeneralSettingsPanel() { | ... | @@ -576,221 +534,6 @@ export function GeneralSettingsPanel() { |
| 576 | const isOpeningKeybindings = openingPathByTarget.keybindings; | 534 | const isOpeningKeybindings = openingPathByTarget.keybindings; |
| 577 | const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; | 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 | return ( | 537 | return ( |
| 795 | <SettingsPageContainer> | 538 | <SettingsPageContainer> |
| 796 | <SettingsSection title="General"> | 539 | <SettingsSection title="General"> |
| ... | @@ -1168,58 +911,406 @@ export function GeneralSettingsPanel() { | ... | @@ -1168,58 +911,406 @@ export function GeneralSettingsPanel() { |
| 1168 | /> | 911 | /> |
| 1169 | </SettingsSection> | 912 | </SettingsSection> |
| 1170 | 913 | ||
| 1171 | <SettingsSection | 914 | <SettingsSection title="Advanced"> |
| 1172 | title="Providers" | 915 | <SettingsRow |
| 1173 | headerAction={ | 916 | title="Keybindings" |
| 1174 | <div className="flex items-center gap-1.5"> | 917 | description="Open the persisted `keybindings.json` file to edit advanced bindings directly." |
| 1175 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | 918 | status={ |
| 1176 | <Tooltip> | 919 | <> |
| 1177 | <TooltipTrigger | 920 | <span className="block break-all font-mono text-[11px] text-foreground"> |
| 1178 | render={ | 921 | {keybindingsConfigPath ?? "Resolving keybindings path..."} |
| 1179 | <Button | 922 | </span> |
| 1180 | size="icon-xs" | 923 | {openKeybindingsError ? ( |
| 1181 | variant="ghost" | 924 | <span className="mt-1 block text-destructive">{openKeybindingsError}</span> |
| 1182 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | 925 | ) : ( |
| 1183 | onClick={() => setIsAddInstanceDialogOpen(true)} | 926 | <span className="mt-1 block">Opens in your preferred editor.</span> |
| 1184 | aria-label="Add provider instance" | 927 | )} |
| 1185 | > | 928 | </> |
| 1186 | <PlusIcon className="size-3" /> | 929 | } |
| 1187 | </Button> | 930 | control={ |
| 1188 | } | 931 | <Button |
| 1189 | /> | 932 | size="xs" |
| 1190 | <TooltipPopup side="top">Add provider instance</TooltipPopup> | 933 | variant="outline" |
| 1191 | </Tooltip> | 934 | disabled={!keybindingsConfigPath || isOpeningKeybindings} |
| 1192 | <Tooltip> | 935 | onClick={openKeybindingsFile} |
| 1193 | <TooltipTrigger | 936 | > |
| 1194 | render={ | 937 | {isOpeningKeybindings ? "Opening..." : "Open file"} |
| 1195 | <Button | 938 | </Button> |
| 1196 | size="icon-xs" | 939 | } |
| 1197 | variant="ghost" | 940 | /> |
| 1198 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | 941 | </SettingsSection> |
| 1199 | disabled={isRefreshingProviders} | 942 | |
| 1200 | onClick={() => void refreshProviders()} | 943 | <SettingsSection title="About"> |
| 1201 | aria-label="Refresh provider status" | 944 | {isElectron ? ( |
| 1202 | > | 945 | <AboutVersionSection /> |
| 1203 | {isRefreshingProviders ? ( | 946 | ) : ( |
| 1204 | <LoaderIcon className="size-3 animate-spin" /> | 947 | <SettingsRow |
| 1205 | ) : ( | 948 | title={<AboutVersionTitle />} |
| 1206 | <RefreshCwIcon className="size-3" /> | 949 | description="Current version of the application." |
| 1207 | )} | 950 | /> |
| 1208 | </Button> | 951 | )} |
| 1209 | } | 952 | <SettingsRow |
| 1210 | /> | 953 | title="Diagnostics" |
| 1211 | <TooltipPopup side="top">Refresh provider status</TooltipPopup> | 954 | description={diagnosticsDescription} |
| 1212 | </Tooltip> | 955 | status={ |
| 1213 | </div> | 956 | <> |
| 1214 | } | 957 | <span className="block break-all font-mono text-[11px] text-foreground"> |
| 1215 | > | 958 | {logsDirectoryPath ?? "Resolving logs directory..."} |
| 1216 | {rows.map((row) => { | 959 | </span> |
| 1217 | const driverOption = getDriverOption(row.driver); | 960 | {openDiagnosticsError ? ( |
| 1218 | const liveProvider = serverProviders.find( | 961 | <span className="mt-1 block text-destructive">{openDiagnosticsError}</span> |
| 1219 | (candidate) => candidate.instanceId === row.instanceId, | 962 | ) : null} |
| 1220 | ); | 963 | </> |
| 1221 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { | 964 | } |
| 1222 | hiddenModels: [], | 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 | modelOrder: [], | 1314 | modelOrder: [], |
| 1224 | }; | 1315 | }; |
| 1225 | const favoriteModels = (settings.favorites ?? []) | 1316 | const favoriteModels = (settings.favorites ?? []) |
| ... | @@ -1248,13 +1339,6 @@ export function GeneralSettingsPanel() { | ... | @@ -1248,13 +1339,6 @@ export function GeneralSettingsPanel() { |
| 1248 | })) | 1339 | })) |
| 1249 | } | 1340 | } |
| 1250 | onUpdate={(next) => { | 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 | const wasEnabled = row.instance.enabled ?? true; | 1342 | const wasEnabled = row.instance.enabled ?? true; |
| 1259 | const isDisabling = next.enabled === false && wasEnabled; | 1343 | const isDisabling = next.enabled === false && wasEnabled; |
| 1260 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; | 1344 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; |
| ... | @@ -1287,6 +1371,17 @@ export function GeneralSettingsPanel() { | ... | @@ -1287,6 +1371,17 @@ export function GeneralSettingsPanel() { |
| 1287 | modelOrder, | 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,70 +1391,6 @@ export function GeneralSettingsPanel() { |
| 1296 | open={isAddInstanceDialogOpen} | 1391 | open={isAddInstanceDialogOpen} |
| 1297 | onOpenChange={setIsAddInstanceDialogOpen} | 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 | </SettingsPageContainer> | 1394 | </SettingsPageContainer> |
| 1364 | ); | 1395 | ); |
| 1365 | } | 1396 | } |
apps/web/src/components/settings/SettingsSidebarNav.tsx+10-1| ... | @@ -1,5 +1,12 @@ | ... | @@ -1,5 +1,12 @@ |
| 1 | import { useCallback, type ComponentType } from "react"; | 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 | import { useCanGoBack, useNavigate } from "@tanstack/react-router"; | 10 | import { useCanGoBack, useNavigate } from "@tanstack/react-router"; |
| 4 | 11 | ||
| 5 | import { | 12 | import { |
| ... | @@ -15,6 +22,7 @@ import { | ... | @@ -15,6 +22,7 @@ import { |
| 15 | 22 | ||
| 16 | export type SettingsSectionPath = | 23 | export type SettingsSectionPath = |
| 17 | | "/settings/general" | 24 | | "/settings/general" |
| 25 | | "/settings/providers" | ||
| 18 | | "/settings/source-control" | 26 | | "/settings/source-control" |
| 19 | | "/settings/connections" | 27 | | "/settings/connections" |
| 20 | | "/settings/archived"; | 28 | | "/settings/archived"; |
| ... | @@ -25,6 +33,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | ... | @@ -25,6 +33,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 25 | icon: ComponentType<{ className?: string }>; | 33 | icon: ComponentType<{ className?: string }>; |
| 26 | }> = [ | 34 | }> = [ |
| 27 | { label: "General", to: "/settings/general", icon: Settings2Icon }, | 35 | { label: "General", to: "/settings/general", icon: Settings2Icon }, |
| 36 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, | ||
| 28 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, | 37 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, |
| 29 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, | 38 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, |
| 30 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, | 39 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
apps/web/src/components/settings/providerStatus.ts+27-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | 1 | import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | /** | 3 | /** |
| 4 | * Visual treatment for each server-reported provider status. Centralized so | 4 | * Visual treatment for each server-reported provider status. Centralized so |
| ... | @@ -89,3 +89,29 @@ export function getProviderVersionLabel(version: string | null | undefined) { | ... | @@ -89,3 +89,29 @@ export function getProviderVersionLabel(version: string | null | undefined) { |
| 89 | if (!version) return null; | 89 | if (!version) return null; |
| 90 | return version.startsWith("v") ? version : `v${version}`; | 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,5 +1,5 @@ |
| 1 | import { Undo2Icon } from "lucide-react"; | 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 | import { cn } from "../../lib/utils"; | 4 | import { cn } from "../../lib/utils"; |
| 5 | import { Button } from "../ui/button"; | 5 | import { Button } from "../ui/button"; |
| ... | @@ -20,15 +20,17 @@ export function SettingsSection({ | ... | @@ -20,15 +20,17 @@ export function SettingsSection({ |
| 20 | icon, | 20 | icon, |
| 21 | headerAction, | 21 | headerAction, |
| 22 | children, | 22 | children, |
| 23 | }: { | 23 | className, |
| 24 | ...sectionProps | ||
| 25 | }: ComponentPropsWithoutRef<"section"> & { | ||
| 24 | title: string; | 26 | title: string; |
| 25 | icon?: ReactNode; | 27 | icon?: ReactNode; |
| 26 | headerAction?: ReactNode; | 28 | headerAction?: ReactNode; |
| 27 | children: ReactNode; | 29 | children: ReactNode; |
| 28 | }) { | 30 | }) { |
| 29 | return ( | 31 | return ( |
| 30 | <section className="space-y-2.5"> | 32 | <section {...sectionProps} className={cn("space-y-2.5", className)}> |
| 31 | <div className="flex h-5 items-center justify-between px-1"> | 33 | <div className="flex items-center justify-between px-1"> |
| 32 | <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50"> | 34 | <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50"> |
| 33 | <span className="inline-block h-px w-3 bg-border" aria-hidden /> | 35 | <span className="inline-block h-px w-3 bg-border" aria-hidden /> |
| 34 | {icon} | 36 | {icon} |
| ... | @@ -62,7 +64,7 @@ export function SettingsRow({ | ... | @@ -62,7 +64,7 @@ export function SettingsRow({ |
| 62 | <div | 64 | <div |
| 63 | className={cn( | 65 | className={cn( |
| 64 | "border-t border-border/60 px-4 first:border-t-0 sm:px-5", | 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 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | 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,7 +77,7 @@ export function SettingsRow({ |
| 75 | {resetAction} | 77 | {resetAction} |
| 76 | </span> | 78 | </span> |
| 77 | </div> | 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 | {status ? <div className="pt-0.5 text-[11px] text-muted-foreground">{status}</div> : null} | 81 | {status ? <div className="pt-0.5 text-[11px] text-muted-foreground">{status}</div> : null} |
| 80 | </div> | 82 | </div> |
| 81 | {control ? ( | 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 +6,7 @@ import { |
| 6 | useMemo, | 6 | useMemo, |
| 7 | useState, | 7 | useState, |
| 8 | type CSSProperties, | 8 | type CSSProperties, |
| 9 | type ComponentPropsWithoutRef, | ||
| 9 | type KeyboardEvent, | 10 | type KeyboardEvent, |
| 10 | type ReactNode, | 11 | type ReactNode, |
| 11 | } from "react"; | 12 | } from "react"; |
| ... | @@ -38,9 +39,20 @@ import { | ... | @@ -38,9 +39,20 @@ import { |
| 38 | export type ThreadToastData = { | 39 | export type ThreadToastData = { |
| 39 | threadRef?: ScopedThreadRef | null; | 40 | threadRef?: ScopedThreadRef | null; |
| 40 | threadId?: ThreadId | null; | 41 | threadId?: ThreadId | null; |
| 42 | leadingIcon?: ReactNode; | ||
| 41 | tooltipStyle?: boolean; | 43 | tooltipStyle?: boolean; |
| 44 | onClose?: (() => void) | undefined; | ||
| 42 | dismissAfterVisibleMs?: number; | 45 | dismissAfterVisibleMs?: number; |
| 43 | hideCopyButton?: boolean; | 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 | /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */ | 56 | /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */ |
| 45 | expandableContent?: ReactNode; | 57 | expandableContent?: ReactNode; |
| 46 | expandableLabels?: { expand?: string; collapse?: string }; | 58 | expandableLabels?: { expand?: string; collapse?: string }; |
| ... | @@ -90,6 +102,15 @@ const toastCornerOrbClass = cn( | ... | @@ -90,6 +102,15 @@ const toastCornerOrbClass = cn( |
| 90 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | 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 | function CopyErrorButton({ text }: { text: string }) { | 114 | function CopyErrorButton({ text }: { text: string }) { |
| 94 | const { copyToClipboard, isCopied } = useCopyToClipboard(); | 115 | const { copyToClipboard, isCopied } = useCopyToClipboard(); |
| 95 | 116 | ||
| ... | @@ -232,6 +253,7 @@ interface ToastBodyDescriptor { | ... | @@ -232,6 +253,7 @@ interface ToastBodyDescriptor { |
| 232 | readonly Icon: ToastIconComponent | null | undefined; | 253 | readonly Icon: ToastIconComponent | null | undefined; |
| 233 | readonly stackedActionLayout: boolean; | 254 | readonly stackedActionLayout: boolean; |
| 234 | readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>; | 255 | readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>; |
| 256 | readonly secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]>; | ||
| 235 | readonly copyErrorText: string | null; | 257 | readonly copyErrorText: string | null; |
| 236 | readonly hasTrailingControls: boolean; | 258 | readonly hasTrailingControls: boolean; |
| 237 | readonly inlineContentEndPad: string; | 259 | readonly inlineContentEndPad: string; |
| ... | @@ -248,16 +270,21 @@ function deriveToastBodyDescriptor(toast: { | ... | @@ -248,16 +270,21 @@ function deriveToastBodyDescriptor(toast: { |
| 248 | toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; | 270 | toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; |
| 249 | const actionVariant: NonNullable<ThreadToastData["actionVariant"]> = | 271 | const actionVariant: NonNullable<ThreadToastData["actionVariant"]> = |
| 250 | toast.data?.actionVariant ?? "default"; | 272 | toast.data?.actionVariant ?? "default"; |
| 273 | const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> = | ||
| 274 | toast.data?.secondaryActionVariant ?? "outline"; | ||
| 251 | const copyErrorText = | 275 | const copyErrorText = |
| 252 | toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton | 276 | toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton |
| 253 | ? toast.description | 277 | ? toast.description |
| 254 | : null; | 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 | const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; | 282 | const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; |
| 257 | return { | 283 | return { |
| 258 | Icon, | 284 | Icon, |
| 259 | stackedActionLayout, | 285 | stackedActionLayout, |
| 260 | actionVariant, | 286 | actionVariant, |
| 287 | secondaryActionVariant, | ||
| 261 | copyErrorText, | 288 | copyErrorText, |
| 262 | hasTrailingControls, | 289 | hasTrailingControls, |
| 263 | inlineContentEndPad, | 290 | inlineContentEndPad, |
| ... | @@ -277,22 +304,35 @@ function ToastBodyContent({ | ... | @@ -277,22 +304,35 @@ function ToastBodyContent({ |
| 277 | copyErrorText, | 304 | copyErrorText, |
| 278 | actionProps, | 305 | actionProps, |
| 279 | actionVariant, | 306 | actionVariant, |
| 307 | secondaryActionVariant, | ||
| 280 | hasTrailingControls, | 308 | hasTrailingControls, |
| 281 | toastData, | 309 | toastData, |
| 282 | toastDescription, | 310 | toastDescription, |
| 283 | toastType, | 311 | toastType, |
| 284 | }: ToastBodyContentProps) { | 312 | }: ToastBodyContentProps) { |
| 313 | const secondaryActionProps = toastData?.secondaryActionProps; | ||
| 314 | const leadingIcon = toastData?.leadingIcon; | ||
| 315 | const { className: secondaryActionClassName, ...secondaryActionRest } = | ||
| 316 | secondaryActionProps ?? {}; | ||
| 317 | |||
| 285 | return ( | 318 | return ( |
| 286 | <> | 319 | <> |
| 287 | <div className={cn("flex min-w-0 gap-2", !stackedActionLayout && "flex-1")}> | 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 | <div | 329 | <div |
| 290 | className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" | 330 | className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" |
| 291 | data-slot="toast-icon" | 331 | data-slot="toast-icon" |
| 292 | > | 332 | > |
| 293 | <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" /> | 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 | </div> | 334 | </div> |
| 295 | )} | 335 | ) : null} |
| 296 | <div | 336 | <div |
| 297 | className={cn( | 337 | className={cn( |
| 298 | "flex min-h-0 min-w-0 flex-1 flex-col gap-0.5", | 338 | "flex min-h-0 min-w-0 flex-1 flex-col gap-0.5", |
| ... | @@ -315,6 +355,16 @@ function ToastBodyContent({ | ... | @@ -315,6 +355,16 @@ function ToastBodyContent({ |
| 315 | )} | 355 | )} |
| 316 | > | 356 | > |
| 317 | {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null} | 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 | {actionProps ? ( | 368 | {actionProps ? ( |
| 319 | <Toast.Action | 369 | <Toast.Action |
| 320 | className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")} | 370 | className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")} |
| ... | @@ -579,7 +629,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -579,7 +629,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 579 | aria-label="Dismiss notification" | 629 | aria-label="Dismiss notification" |
| 580 | className={toastCornerOrbClass} | 630 | className={toastCornerOrbClass} |
| 581 | data-slot="toast-close" | 631 | data-slot="toast-close" |
| 582 | onClick={() => toastManager.close(toast.id)} | 632 | onClick={() => |
| 633 | handleToastDismissClick(toastManager, toast.id, toast.data?.onClose) | ||
| 634 | } | ||
| 583 | type="button" | 635 | type="button" |
| 584 | > | 636 | > |
| 585 | <XIcon className="size-3" strokeWidth={2.25} /> | 637 | <XIcon className="size-3" strokeWidth={2.25} /> |
| ... | @@ -670,7 +722,13 @@ function AnchoredToasts() { | ... | @@ -670,7 +722,13 @@ function AnchoredToasts() { |
| 670 | aria-label="Dismiss notification" | 722 | aria-label="Dismiss notification" |
| 671 | className={toastCornerOrbClass} | 723 | className={toastCornerOrbClass} |
| 672 | data-slot="toast-close" | 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 | type="button" | 732 | type="button" |
| 675 | > | 733 | > |
| 676 | <XIcon className="size-3" strokeWidth={2.25} /> | 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,6 +26,7 @@ import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/ |
| 26 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; | 26 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| 27 | 27 | ||
| 28 | const clientSettingsListeners = new Set<() => void>(); | 28 | const clientSettingsListeners = new Set<() => void>(); |
| 29 | const clientSettingsHydrationListeners = new Set<() => void>(); | ||
| 29 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | 30 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; |
| 30 | let clientSettingsHydrated = false; | 31 | let clientSettingsHydrated = false; |
| 31 | let clientSettingsHydrationPromise: Promise<void> | null = null; | 32 | let clientSettingsHydrationPromise: Promise<void> | null = null; |
| ... | @@ -36,6 +37,12 @@ function emitClientSettingsChange() { | ... | @@ -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 | function getClientSettingsSnapshot(): ClientSettings { | 46 | function getClientSettingsSnapshot(): ClientSettings { |
| 40 | return clientSettingsSnapshot; | 47 | return clientSettingsSnapshot; |
| 41 | } | 48 | } |
| ... | @@ -45,6 +52,14 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { | ... | @@ -45,6 +52,14 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { |
| 45 | emitClientSettingsChange(); | 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 | function subscribeClientSettings(listener: () => void): () => void { | 63 | function subscribeClientSettings(listener: () => void): () => void { |
| 49 | clientSettingsListeners.add(listener); | 64 | clientSettingsListeners.add(listener); |
| 50 | void hydrateClientSettings(); | 65 | void hydrateClientSettings(); |
| ... | @@ -53,6 +68,18 @@ function subscribeClientSettings(listener: () => void): () => void { | ... | @@ -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 | async function hydrateClientSettings(): Promise<void> { | 83 | async function hydrateClientSettings(): Promise<void> { |
| 57 | if (clientSettingsHydrated) { | 84 | if (clientSettingsHydrated) { |
| 58 | return; | 85 | return; |
| ... | @@ -70,7 +97,7 @@ async function hydrateClientSettings(): Promise<void> { | ... | @@ -70,7 +97,7 @@ async function hydrateClientSettings(): Promise<void> { |
| 70 | } catch (error) { | 97 | } catch (error) { |
| 71 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); | 98 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); |
| 72 | } finally { | 99 | } finally { |
| 73 | clientSettingsHydrated = true; | 100 | setClientSettingsHydrated(true); |
| 74 | } | 101 | } |
| 75 | })(); | 102 | })(); |
| 76 | 103 | ||
| ... | @@ -132,6 +159,14 @@ export function getClientSettings(): ClientSettings { | ... | @@ -132,6 +159,14 @@ export function getClientSettings(): ClientSettings { |
| 132 | return getClientSettingsSnapshot(); | 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 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { | 170 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { |
| 136 | const serverSettings = useServerSettings(); | 171 | const serverSettings = useServerSettings(); |
| 137 | const clientSettings = useSyncExternalStore( | 172 | const clientSettings = useSyncExternalStore( |
| ... | @@ -193,4 +228,5 @@ export function __resetClientSettingsPersistenceForTests(): void { | ... | @@ -193,4 +228,5 @@ export function __resetClientSettingsPersistenceForTests(): void { |
| 193 | clientSettingsHydrated = false; | 228 | clientSettingsHydrated = false; |
| 194 | clientSettingsHydrationPromise = null; | 229 | clientSettingsHydrationPromise = null; |
| 195 | clientSettingsListeners.clear(); | 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,6 +485,19 @@ label:has(> select#reasoning-effort) select { |
| 485 | text-align: left; | 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 | /* Diffs theme bridge (match diff surfaces to app palette) */ | 501 | /* Diffs theme bridge (match diff surfaces to app palette) */ |
| 489 | .diff-panel-viewport { | 502 | .diff-panel-viewport { |
| 490 | background: color-mix(in srgb, var(--background) 94%, var(--card)); | 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,6 +85,7 @@ const rpcClientMock = { |
| 85 | server: { | 85 | server: { |
| 86 | getConfig: vi.fn(), | 86 | getConfig: vi.fn(), |
| 87 | refreshProviders: vi.fn(), | 87 | refreshProviders: vi.fn(), |
| 88 | updateProvider: vi.fn(), | ||
| 88 | upsertKeybinding: vi.fn(), | 89 | upsertKeybinding: vi.fn(), |
| 89 | getSettings: vi.fn(), | 90 | getSettings: vi.fn(), |
| 90 | updateSettings: vi.fn(), | 91 | updateSettings: vi.fn(), |
| ... | @@ -514,6 +515,34 @@ describe("wsApi", () => { | ... | @@ -514,6 +515,34 @@ describe("wsApi", () => { |
| 514 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); | 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 | it("forwards server settings updates directly to the RPC client", async () => { | 546 | it("forwards server settings updates directly to the RPC client", async () => { |
| 518 | const nextSettings = { | 547 | const nextSettings = { |
| 519 | ...DEFAULT_SERVER_SETTINGS, | 548 | ...DEFAULT_SERVER_SETTINGS, |
| ... | @@ -573,6 +602,7 @@ describe("wsApi", () => { | ... | @@ -573,6 +602,7 @@ describe("wsApi", () => { |
| 573 | autoOpenPlanSidebar: false, | 602 | autoOpenPlanSidebar: false, |
| 574 | confirmThreadArchive: true, | 603 | confirmThreadArchive: true, |
| 575 | confirmThreadDelete: false, | 604 | confirmThreadDelete: false, |
| 605 | dismissedProviderUpdateNotificationKeys: [], | ||
| 576 | diffIgnoreWhitespace: true, | 606 | diffIgnoreWhitespace: true, |
| 577 | diffWordWrap: true, | 607 | diffWordWrap: true, |
| 578 | favorites: [], | 608 | favorites: [], |
| ... | @@ -634,6 +664,7 @@ describe("wsApi", () => { | ... | @@ -634,6 +664,7 @@ describe("wsApi", () => { |
| 634 | autoOpenPlanSidebar: false, | 664 | autoOpenPlanSidebar: false, |
| 635 | confirmThreadArchive: true, | 665 | confirmThreadArchive: true, |
| 636 | confirmThreadDelete: false, | 666 | confirmThreadDelete: false, |
| 667 | dismissedProviderUpdateNotificationKeys: [], | ||
| 637 | diffIgnoreWhitespace: true, | 668 | diffIgnoreWhitespace: true, |
| 638 | diffWordWrap: true, | 669 | diffWordWrap: true, |
| 639 | favorites: [], | 670 | favorites: [], |
apps/web/src/localApi.ts+4| ... | @@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { | ... | @@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { |
| 125 | rpcClient | 125 | rpcClient |
| 126 | ? rpcClient.server.refreshProviders() | 126 | ? rpcClient.server.refreshProviders() |
| 127 | : Promise.reject(unavailableLocalBackendError()), | 127 | : Promise.reject(unavailableLocalBackendError()), |
| 128 | updateProvider: (input) => | ||
| 129 | rpcClient | ||
| 130 | ? rpcClient.server.updateProvider(input) | ||
| 131 | : Promise.reject(unavailableLocalBackendError()), | ||
| 128 | upsertKeybinding: (input) => | 132 | upsertKeybinding: (input) => |
| 129 | rpcClient | 133 | rpcClient |
| 130 | ? rpcClient.server.upsertKeybinding(input) | 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,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair' |
| 14 | import { Route as ChatRouteImport } from './routes/_chat' | 14 | import { Route as ChatRouteImport } from './routes/_chat' |
| 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 16 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' | 16 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' |
| 17 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' | ||
| 17 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 18 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 18 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' | 19 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' |
| 19 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 20 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| ... | @@ -44,6 +45,11 @@ const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ | ... | @@ -44,6 +45,11 @@ const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ |
| 44 | path: '/source-control', | 45 | path: '/source-control', |
| 45 | getParentRoute: () => SettingsRoute, | 46 | getParentRoute: () => SettingsRoute, |
| 46 | } as any) | 47 | } as any) |
| 48 | const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ | ||
| 49 | id: '/providers', | ||
| 50 | path: '/providers', | ||
| 51 | getParentRoute: () => SettingsRoute, | ||
| 52 | } as any) | ||
| 47 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ | 53 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ |
| 48 | id: '/general', | 54 | id: '/general', |
| 49 | path: '/general', | 55 | path: '/general', |
| ... | @@ -78,6 +84,7 @@ export interface FileRoutesByFullPath { | ... | @@ -78,6 +84,7 @@ export interface FileRoutesByFullPath { |
| 78 | '/settings/archived': typeof SettingsArchivedRoute | 84 | '/settings/archived': typeof SettingsArchivedRoute |
| 79 | '/settings/connections': typeof SettingsConnectionsRoute | 85 | '/settings/connections': typeof SettingsConnectionsRoute |
| 80 | '/settings/general': typeof SettingsGeneralRoute | 86 | '/settings/general': typeof SettingsGeneralRoute |
| 87 | '/settings/providers': typeof SettingsProvidersRoute | ||
| 81 | '/settings/source-control': typeof SettingsSourceControlRoute | 88 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 82 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 89 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 83 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | 90 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| ... | @@ -88,6 +95,7 @@ export interface FileRoutesByTo { | ... | @@ -88,6 +95,7 @@ export interface FileRoutesByTo { |
| 88 | '/settings/archived': typeof SettingsArchivedRoute | 95 | '/settings/archived': typeof SettingsArchivedRoute |
| 89 | '/settings/connections': typeof SettingsConnectionsRoute | 96 | '/settings/connections': typeof SettingsConnectionsRoute |
| 90 | '/settings/general': typeof SettingsGeneralRoute | 97 | '/settings/general': typeof SettingsGeneralRoute |
| 98 | '/settings/providers': typeof SettingsProvidersRoute | ||
| 91 | '/settings/source-control': typeof SettingsSourceControlRoute | 99 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 92 | '/': typeof ChatIndexRoute | 100 | '/': typeof ChatIndexRoute |
| 93 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 101 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| ... | @@ -101,6 +109,7 @@ export interface FileRoutesById { | ... | @@ -101,6 +109,7 @@ export interface FileRoutesById { |
| 101 | '/settings/archived': typeof SettingsArchivedRoute | 109 | '/settings/archived': typeof SettingsArchivedRoute |
| 102 | '/settings/connections': typeof SettingsConnectionsRoute | 110 | '/settings/connections': typeof SettingsConnectionsRoute |
| 103 | '/settings/general': typeof SettingsGeneralRoute | 111 | '/settings/general': typeof SettingsGeneralRoute |
| 112 | '/settings/providers': typeof SettingsProvidersRoute | ||
| 104 | '/settings/source-control': typeof SettingsSourceControlRoute | 113 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 105 | '/_chat/': typeof ChatIndexRoute | 114 | '/_chat/': typeof ChatIndexRoute |
| 106 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 115 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| ... | @@ -115,6 +124,7 @@ export interface FileRouteTypes { | ... | @@ -115,6 +124,7 @@ export interface FileRouteTypes { |
| 115 | | '/settings/archived' | 124 | | '/settings/archived' |
| 116 | | '/settings/connections' | 125 | | '/settings/connections' |
| 117 | | '/settings/general' | 126 | | '/settings/general' |
| 127 | | '/settings/providers' | ||
| 118 | | '/settings/source-control' | 128 | | '/settings/source-control' |
| 119 | | '/$environmentId/$threadId' | 129 | | '/$environmentId/$threadId' |
| 120 | | '/draft/$draftId' | 130 | | '/draft/$draftId' |
| ... | @@ -125,6 +135,7 @@ export interface FileRouteTypes { | ... | @@ -125,6 +135,7 @@ export interface FileRouteTypes { |
| 125 | | '/settings/archived' | 135 | | '/settings/archived' |
| 126 | | '/settings/connections' | 136 | | '/settings/connections' |
| 127 | | '/settings/general' | 137 | | '/settings/general' |
| 138 | | '/settings/providers' | ||
| 128 | | '/settings/source-control' | 139 | | '/settings/source-control' |
| 129 | | '/' | 140 | | '/' |
| 130 | | '/$environmentId/$threadId' | 141 | | '/$environmentId/$threadId' |
| ... | @@ -137,6 +148,7 @@ export interface FileRouteTypes { | ... | @@ -137,6 +148,7 @@ export interface FileRouteTypes { |
| 137 | | '/settings/archived' | 148 | | '/settings/archived' |
| 138 | | '/settings/connections' | 149 | | '/settings/connections' |
| 139 | | '/settings/general' | 150 | | '/settings/general' |
| 151 | | '/settings/providers' | ||
| 140 | | '/settings/source-control' | 152 | | '/settings/source-control' |
| 141 | | '/_chat/' | 153 | | '/_chat/' |
| 142 | | '/_chat/$environmentId/$threadId' | 154 | | '/_chat/$environmentId/$threadId' |
| ... | @@ -186,6 +198,13 @@ declare module '@tanstack/react-router' { | ... | @@ -186,6 +198,13 @@ declare module '@tanstack/react-router' { |
| 186 | preLoaderRoute: typeof SettingsSourceControlRouteImport | 198 | preLoaderRoute: typeof SettingsSourceControlRouteImport |
| 187 | parentRoute: typeof SettingsRoute | 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 | '/settings/general': { | 208 | '/settings/general': { |
| 190 | id: '/settings/general' | 209 | id: '/settings/general' |
| 191 | path: '/general' | 210 | path: '/general' |
| ... | @@ -242,6 +261,7 @@ interface SettingsRouteChildren { | ... | @@ -242,6 +261,7 @@ interface SettingsRouteChildren { |
| 242 | SettingsArchivedRoute: typeof SettingsArchivedRoute | 261 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 243 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute | 262 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute |
| 244 | SettingsGeneralRoute: typeof SettingsGeneralRoute | 263 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 264 | SettingsProvidersRoute: typeof SettingsProvidersRoute | ||
| 245 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute | 265 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute |
| 246 | } | 266 | } |
| 247 | 267 | ||
| ... | @@ -249,6 +269,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { | ... | @@ -249,6 +269,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { |
| 249 | SettingsArchivedRoute: SettingsArchivedRoute, | 269 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 250 | SettingsConnectionsRoute: SettingsConnectionsRoute, | 270 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 251 | SettingsGeneralRoute: SettingsGeneralRoute, | 271 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 272 | SettingsProvidersRoute: SettingsProvidersRoute, | ||
| 252 | SettingsSourceControlRoute: SettingsSourceControlRoute, | 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,6 +14,7 @@ import { APP_DISPLAY_NAME } from "../branding"; |
| 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 15 | import { CommandPalette } from "../components/CommandPalette"; | 15 | import { CommandPalette } from "../components/CommandPalette"; |
| 16 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; | 16 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 17 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | ||
| 17 | import { | 18 | import { |
| 18 | SlowRpcAckToastCoordinator, | 19 | SlowRpcAckToastCoordinator, |
| 19 | WebSocketConnectionCoordinator, | 20 | WebSocketConnectionCoordinator, |
| ... | @@ -136,6 +137,7 @@ function RootRouteView() { | ... | @@ -136,6 +137,7 @@ function RootRouteView() { |
| 136 | <SshPasswordPromptDialog /> | 137 | <SshPasswordPromptDialog /> |
| 137 | <HostedStaticEnvironmentBootstrap /> | 138 | <HostedStaticEnvironmentBootstrap /> |
| 138 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} | 139 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 140 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} | ||
| 139 | {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} | 141 | {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} |
| 140 | {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} | 142 | {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} |
| 141 | {primaryEnvironmentAuthenticated ? ( | 143 | {primaryEnvironmentAuthenticated ? ( |
apps/web/src/routes/settings.general.tsx+5-1| ... | @@ -2,6 +2,10 @@ import { createFileRoute } from "@tanstack/react-router"; | ... | @@ -2,6 +2,10 @@ import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | ||
| 3 | import { GeneralSettingsPanel } from "../components/settings/SettingsPanels"; | 3 | import { GeneralSettingsPanel } from "../components/settings/SettingsPanels"; |
| 4 | 4 | ||
| 5 | function SettingsGeneralRoute() { | ||
| 6 | return <GeneralSettingsPanel />; | ||
| 7 | } | ||
| 8 | |||
| 5 | export const Route = createFileRoute("/settings/general")({ | 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,6 +119,7 @@ export interface WsRpcClient { |
| 119 | readonly refreshProviders: ( | 119 | readonly refreshProviders: ( |
| 120 | input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>, | 120 | input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>, |
| 121 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; | 121 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; |
| 122 | readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>; | ||
| 122 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | 123 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; |
| 123 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | 124 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; |
| 124 | readonly updateSettings: ( | 125 | readonly updateSettings: ( |
| ... | @@ -234,6 +235,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -234,6 +235,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 234 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), | 235 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), |
| 235 | refreshProviders: (input) => | 236 | refreshProviders: (input) => |
| 236 | transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), | 237 | transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), |
| 238 | updateProvider: (input) => | ||
| 239 | transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), | ||
| 237 | upsertKeybinding: (input) => | 240 | upsertKeybinding: (input) => |
| 238 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | 241 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), |
| 239 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | 242 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), |
packages/contracts/src/ipc.ts+2| ... | @@ -28,6 +28,7 @@ import type { | ... | @@ -28,6 +28,7 @@ import type { |
| 28 | import type { ProviderInstanceId } from "./providerInstance.ts"; | 28 | import type { ProviderInstanceId } from "./providerInstance.ts"; |
| 29 | import type { | 29 | import type { |
| 30 | ServerConfig, | 30 | ServerConfig, |
| 31 | ServerProviderUpdateInput, | ||
| 31 | ServerProviderUpdatedPayload, | 32 | ServerProviderUpdatedPayload, |
| 32 | ServerUpsertKeybindingResult, | 33 | ServerUpsertKeybindingResult, |
| 33 | } from "./server.ts"; | 34 | } from "./server.ts"; |
| ... | @@ -296,6 +297,7 @@ export interface LocalApi { | ... | @@ -296,6 +297,7 @@ export interface LocalApi { |
| 296 | refreshProviders: (input?: { | 297 | refreshProviders: (input?: { |
| 297 | readonly instanceId?: ProviderInstanceId; | 298 | readonly instanceId?: ProviderInstanceId; |
| 298 | }) => Promise<ServerProviderUpdatedPayload>; | 299 | }) => Promise<ServerProviderUpdatedPayload>; |
| 300 | updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>; | ||
| 299 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | 301 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 300 | getSettings: () => Promise<ServerSettings>; | 302 | getSettings: () => Promise<ServerSettings>; |
| 301 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | 303 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; |
packages/contracts/src/rpc.ts+10| ... | @@ -71,6 +71,8 @@ import { | ... | @@ -71,6 +71,8 @@ import { |
| 71 | import { | 71 | import { |
| 72 | ServerConfigStreamEvent, | 72 | ServerConfigStreamEvent, |
| 73 | ServerConfig, | 73 | ServerConfig, |
| 74 | ServerProviderUpdateError, | ||
| 75 | ServerProviderUpdateInput, | ||
| 74 | ServerLifecycleStreamEvent, | 76 | ServerLifecycleStreamEvent, |
| 75 | ServerProviderUpdatedPayload, | 77 | ServerProviderUpdatedPayload, |
| 76 | ServerUpsertKeybindingInput, | 78 | ServerUpsertKeybindingInput, |
| ... | @@ -129,6 +131,7 @@ export const WS_METHODS = { | ... | @@ -129,6 +131,7 @@ export const WS_METHODS = { |
| 129 | // Server meta | 131 | // Server meta |
| 130 | serverGetConfig: "server.getConfig", | 132 | serverGetConfig: "server.getConfig", |
| 131 | serverRefreshProviders: "server.refreshProviders", | 133 | serverRefreshProviders: "server.refreshProviders", |
| 134 | serverUpdateProvider: "server.updateProvider", | ||
| 132 | serverUpsertKeybinding: "server.upsertKeybinding", | 135 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 133 | serverGetSettings: "server.getSettings", | 136 | serverGetSettings: "server.getSettings", |
| 134 | serverUpdateSettings: "server.updateSettings", | 137 | serverUpdateSettings: "server.updateSettings", |
| ... | @@ -172,6 +175,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv | ... | @@ -172,6 +175,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv |
| 172 | success: ServerProviderUpdatedPayload, | 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 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { | 184 | export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { |
| 176 | payload: Schema.Struct({}), | 185 | payload: Schema.Struct({}), |
| 177 | success: ServerSettings, | 186 | success: ServerSettings, |
| ... | @@ -415,6 +424,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, | ... | @@ -415,6 +424,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, |
| 415 | export const WsRpcGroup = RpcGroup.make( | 424 | export const WsRpcGroup = RpcGroup.make( |
| 416 | WsServerGetConfigRpc, | 425 | WsServerGetConfigRpc, |
| 417 | WsServerRefreshProvidersRpc, | 426 | WsServerRefreshProvidersRpc, |
| 427 | WsServerUpdateProviderRpc, | ||
| 418 | WsServerUpsertKeybindingRpc, | 428 | WsServerUpsertKeybindingRpc, |
| 419 | WsServerGetSettingsRpc, | 429 | WsServerGetSettingsRpc, |
| 420 | WsServerUpdateSettingsRpc, | 430 | WsServerUpdateSettingsRpc, |
packages/contracts/src/server.test.ts+28| ... | @@ -23,6 +23,34 @@ describe("ServerProvider", () => { | ... | @@ -23,6 +23,34 @@ describe("ServerProvider", () => { |
| 23 | 23 | ||
| 24 | expect(parsed.slashCommands).toEqual([]); | 24 | expect(parsed.slashCommands).toEqual([]); |
| 25 | expect(parsed.skills).toEqual([]); | 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 | it("decodes continuation group metadata", () => { | 56 | it("decodes continuation group metadata", () => { |
packages/contracts/src/server.ts+58| ... | @@ -109,6 +109,43 @@ export const ServerProviderContinuation = Schema.Struct({ | ... | @@ -109,6 +109,43 @@ export const ServerProviderContinuation = Schema.Struct({ |
| 109 | }); | 109 | }); |
| 110 | export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; | 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 | export const ServerProvider = Schema.Struct({ | 149 | export const ServerProvider = Schema.Struct({ |
| 113 | // Routing key for the configured instance this snapshot represents. This | 150 | // Routing key for the configured instance this snapshot represents. This |
| 114 | // is the only stable identity consumers may use for provider routing. | 151 | // is the only stable identity consumers may use for provider routing. |
| ... | @@ -142,6 +179,8 @@ export const ServerProvider = Schema.Struct({ | ... | @@ -142,6 +179,8 @@ export const ServerProvider = Schema.Struct({ |
| 142 | Schema.withDecodingDefault(Effect.succeed([])), | 179 | Schema.withDecodingDefault(Effect.succeed([])), |
| 143 | ), | 180 | ), |
| 144 | skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | 181 | skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 182 | versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), | ||
| 183 | updateState: Schema.optionalKey(ServerProviderUpdateState), | ||
| 145 | }); | 184 | }); |
| 146 | export type ServerProvider = typeof ServerProvider.Type; | 185 | export type ServerProvider = typeof ServerProvider.Type; |
| 147 | 186 | ||
| ... | @@ -294,3 +333,22 @@ export const ServerProviderUpdatedPayload = Schema.Struct({ | ... | @@ -294,3 +333,22 @@ export const ServerProviderUpdatedPayload = Schema.Struct({ |
| 294 | providers: ServerProviders, | 333 | providers: ServerProviders, |
| 295 | }); | 334 | }); |
| 296 | export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type; | 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,6 +32,9 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 32 | autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 32 | autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 33 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 33 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 34 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 34 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 35 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( | ||
| 36 | Schema.withDecodingDefault(Effect.succeed([])), | ||
| 37 | ), | ||
| 35 | diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 38 | diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 36 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 39 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 37 | // Model favorites. Historically keyed by provider kind, now | 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,7 +24,6 @@ const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeReque |
| 24 | const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse); | 24 | const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse); |
| 25 | const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); | 25 | const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); |
| 26 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); | 26 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); |
| 27 | |||
| 28 | const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => | 27 | const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => |
| 29 | path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), | 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,7 +41,6 @@ const RequestPermissionRequest = jsonRpcRequest( |
| 41 | const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); | 41 | const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); |
| 42 | const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); | 42 | const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); |
| 43 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); | 43 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); |
| 44 | |||
| 45 | const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => | 44 | const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => |
| 46 | path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), | 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,6 +10,7 @@ import { |
| 10 | readEnvironmentFromWindowsShell, | 10 | readEnvironmentFromWindowsShell, |
| 11 | readPathFromLaunchctl, | 11 | readPathFromLaunchctl, |
| 12 | readPathFromLoginShell, | 12 | readPathFromLoginShell, |
| 13 | resolveCommandPath, | ||
| 13 | resolveKnownWindowsCliDirs, | 14 | resolveKnownWindowsCliDirs, |
| 14 | resolveWindowsEnvironment, | 15 | resolveWindowsEnvironment, |
| 15 | } from "./shell.ts"; | 16 | } from "./shell.ts"; |
| ... | @@ -332,6 +333,17 @@ describe("isCommandAvailable", () => { | ... | @@ -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 | describe("resolveWindowsEnvironment", () => { | 347 | describe("resolveWindowsEnvironment", () => { |
| 336 | it("returns the baseline no-profile PATH patch when node is already available", () => { | 348 | it("returns the baseline no-profile PATH patch when node is already available", () => { |
| 337 | const readEnvironment = vi.fn( | 349 | const readEnvironment = vi.fn( |
packages/shared/src/shell.ts+20-9| ... | @@ -376,23 +376,26 @@ function isExecutableFile( | ... | @@ -376,23 +376,26 @@ function isExecutableFile( |
| 376 | } | 376 | } |
| 377 | } | 377 | } |
| 378 | 378 | ||
| 379 | export function isCommandAvailable( | 379 | export function resolveCommandPath( |
| 380 | command: string, | 380 | command: string, |
| 381 | options: CommandAvailabilityOptions = {}, | 381 | options: CommandAvailabilityOptions = {}, |
| 382 | ): boolean { | 382 | ): string | null { |
| 383 | const platform = options.platform ?? process.platform; | 383 | const platform = options.platform ?? process.platform; |
| 384 | const env = options.env ?? process.env; | 384 | const env = options.env ?? process.env; |
| 385 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; | 385 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; |
| 386 | const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); | 386 | const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); |
| 387 | 387 | ||
| 388 | if (command.includes("/") || command.includes("\\")) { | 388 | if (command.includes("/") || command.includes("\\")) { |
| 389 | return commandCandidates.some((candidate) => | 389 | for (const candidate of commandCandidates) { |
| 390 | isExecutableFile(candidate, platform, windowsPathExtensions), | 390 | if (isExecutableFile(candidate, platform, windowsPathExtensions)) { |
| 391 | ); | 391 | return candidate; |
| 392 | } | ||
| 393 | } | ||
| 394 | return null; | ||
| 392 | } | 395 | } |
| 393 | 396 | ||
| 394 | const pathValue = resolvePathEnvironmentVariable(env); | 397 | const pathValue = resolvePathEnvironmentVariable(env); |
| 395 | if (pathValue.length === 0) return false; | 398 | if (pathValue.length === 0) return null; |
| 396 | const pathEntries = pathValue | 399 | const pathEntries = pathValue |
| 397 | .split(pathDelimiterForPlatform(platform)) | 400 | .split(pathDelimiterForPlatform(platform)) |
| 398 | .map((entry) => stripWrappingQuotes(entry.trim())) | 401 | .map((entry) => stripWrappingQuotes(entry.trim())) |
| ... | @@ -400,12 +403,20 @@ export function isCommandAvailable( | ... | @@ -400,12 +403,20 @@ export function isCommandAvailable( |
| 400 | 403 | ||
| 401 | for (const pathEntry of pathEntries) { | 404 | for (const pathEntry of pathEntries) { |
| 402 | for (const candidate of commandCandidates) { | 405 | for (const candidate of commandCandidates) { |
| 403 | if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { | 406 | const candidatePath = join(pathEntry, candidate); |
| 404 | return true; | 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 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { | 422 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { |