authorgravatar for 145564979+justsomelegs@users.noreply.github.comlegs <145564979+justsomelegs@users.noreply.github.com> 2026-05-05 23:59:51+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-05 15:59:51-07:00
log9b604bcae1e2cfe0e0566bc2aff660850e276c6d
tree45dfdc1e0894228f2acc149b6a49f9da9f0fa438
parent35721d9a08b225c4a3752f322ae4daccbeaa564e
signature Signed by PGP key B5690EEEBB952194

provider update advisories (#2312)

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 = {
5252 autoOpenPlanSidebar: false,
5353 confirmThreadArchive: true,
5454 confirmThreadDelete: false,
55 dismissedProviderUpdateNotificationKeys: [],
5556 diffIgnoreWhitespace: true,
5657 diffWordWrap: true,
5758 favorites: [],
apps/server/src/provider/Drivers/ClaudeDriver.ts+40
......@@ -14,6 +14,7 @@
1414 */
1515import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
1616import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { HttpClient } from "effect/unstable/http";
1718import { ChildProcessSpawner } from "effect/unstable/process";
1819
1920import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
......@@ -34,15 +35,43 @@ import {
3435} from "../ProviderDriver.ts";
3536import type { ServerProviderDraft } from "../providerSnapshot.ts";
3637import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38import {
39 enrichProviderSnapshotWithVersionAdvisory,
40 makePackageManagedProviderMaintenanceResolver,
41 normalizeCommandPath,
42 resolveProviderMaintenanceCapabilitiesEffect,
43} from "../providerMaintenance.ts";
3744import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
3845
3946const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
4047const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
4148const CAPABILITIES_PROBE_TTL = Duration.minutes(5);
4249
50function 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
59const 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
4371export type ClaudeDriverEnv =
4472 | ChildProcessSpawner.ChildProcessSpawner
4573 | FileSystem.FileSystem
74 | HttpClient.HttpClient
4675 | Path.Path
4776 | ProviderEventLoggers
4877 | ServerConfig;
......@@ -75,6 +104,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
75104 Effect.gen(function* () {
76105 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
77106 const path = yield* Path.Path;
107 const httpClient = yield* HttpClient.HttpClient;
78108 const eventLoggers = yield* ProviderEventLoggers;
79109 const processEnv = mergeProviderInstanceEnvironment(environment);
80110 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
......@@ -82,6 +112,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
82112 instanceId,
83113 });
84114 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;
115 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
116 binaryPath: effectiveConfig.binaryPath,
117 env: processEnv,
118 });
85119 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig);
86120 const stampIdentity = withInstanceIdentity({
87121 instanceId,
......@@ -121,11 +155,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
121155 );
122156
123157 const snapshot = yield* makeManagedServerProvider<ClaudeSettings>({
158 maintenanceCapabilities,
124159 getSettings: Effect.succeed(effectiveConfig),
125160 streamSettings: Stream.never,
126161 haveSettingsChanged: () => false,
127162 initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)),
128163 checkProvider,
164 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
165 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
166 Effect.provideService(HttpClient.HttpClient, httpClient),
167 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
168 ),
129169 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
130170 }).pipe(
131171 Effect.mapError(
apps/server/src/provider/Drivers/CodexDriver.ts+24
......@@ -23,6 +23,7 @@
2323 */
2424import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
2525import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
26import { HttpClient } from "effect/unstable/http";
2627import { ChildProcessSpawner } from "effect/unstable/process";
2728
2829import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
......@@ -35,6 +36,11 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
3536import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
3637import type { ServerProviderDraft } from "../providerSnapshot.ts";
3738import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
39import {
40 enrichProviderSnapshotWithVersionAdvisory,
41 makePackageManagedProviderMaintenanceResolver,
42 resolveProviderMaintenanceCapabilitiesEffect,
43} from "../providerMaintenance.ts";
3844import {
3945 codexContinuationIdentity,
4046 materializeCodexShadowHome,
......@@ -43,6 +49,12 @@ import {
4349
4450const DRIVER_KIND = ProviderDriverKind.make("codex");
4551const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
52const UPDATE = makePackageManagedProviderMaintenanceResolver({
53 provider: DRIVER_KIND,
54 npmPackageName: "@openai/codex",
55 homebrewFormula: "codex",
56 nativeUpdate: null,
57});
4658
4759/**
4860 * Services the driver needs to materialize an instance. Surfaced as the
......@@ -52,6 +64,7 @@ const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
5264export type CodexDriverEnv =
5365 | ChildProcessSpawner.ChildProcessSpawner
5466 | FileSystem.FileSystem
67 | HttpClient.HttpClient
5568 | Path.Path
5669 | ProviderEventLoggers
5770 | ServerConfig;
......@@ -89,6 +102,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
89102 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
90103 Effect.gen(function* () {
91104 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
105 const httpClient = yield* HttpClient.HttpClient;
92106 const eventLoggers = yield* ProviderEventLoggers;
93107 const processEnv = mergeProviderInstanceEnvironment(environment);
94108 const homeLayout = yield* resolveCodexHomeLayout(config);
......@@ -115,6 +129,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
115129 enabled,
116130 homePath: homeLayout.effectiveHomePath ?? "",
117131 } satisfies CodexSettings;
132 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
133 binaryPath: effectiveConfig.binaryPath,
134 env: processEnv,
135 });
118136
119137 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error
120138 // channels at construction time — their failure modes are all on the
......@@ -138,11 +156,17 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
138156 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
139157 );
140158 const snapshot = yield* makeManagedServerProvider<CodexSettings>({
159 maintenanceCapabilities,
141160 getSettings: Effect.succeed(effectiveConfig),
142161 streamSettings: Stream.never,
143162 haveSettingsChanged: () => false,
144163 initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)),
145164 checkProvider,
165 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
166 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
167 Effect.provideService(HttpClient.HttpClient, httpClient),
168 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
169 ),
146170 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
147171 }).pipe(
148172 Effect.mapError(
apps/server/src/provider/Drivers/CursorDriver.ts+24
......@@ -14,6 +14,7 @@
1414 */
1515import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
1616import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { HttpClient } from "effect/unstable/http";
1718import { ChildProcessSpawner } from "effect/unstable/process";
1819
1920import { ServerConfig } from "../../config.ts";
......@@ -34,13 +35,28 @@ import {
3435} from "../ProviderDriver.ts";
3536import type { ServerProviderDraft } from "../providerSnapshot.ts";
3637import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38import {
39 makeProviderMaintenanceCapabilities,
40 makeStaticProviderMaintenanceResolver,
41 resolveProviderMaintenanceCapabilitiesEffect,
42} from "../providerMaintenance.ts";
3743
3844const DRIVER_KIND = ProviderDriverKind.make("cursor");
3945const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
46const UPDATE = makeStaticProviderMaintenanceResolver(
47 makeProviderMaintenanceCapabilities({
48 provider: DRIVER_KIND,
49 packageName: null,
50 updateExecutable: "agent",
51 updateArgs: ["update"],
52 updateLockKey: "cursor-agent",
53 }),
54);
4055
4156export type CursorDriverEnv =
4257 | ChildProcessSpawner.ChildProcessSpawner
4358 | FileSystem.FileSystem
59 | HttpClient.HttpClient
4460 | Path.Path
4561 | ProviderEventLoggers
4662 | ServerConfig;
......@@ -74,6 +90,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
7490 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7591 const fileSystem = yield* FileSystem.FileSystem;
7692 const path = yield* Path.Path;
93 const httpClient = yield* HttpClient.HttpClient;
7794 const eventLoggers = yield* ProviderEventLoggers;
7895 const processEnv = mergeProviderInstanceEnvironment(environment);
7996 const continuationIdentity = defaultProviderContinuationIdentity({
......@@ -87,6 +104,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
87104 continuationGroupKey: continuationIdentity.continuationKey,
88105 });
89106 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;
107 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
108 binaryPath: effectiveConfig.binaryPath,
109 env: processEnv,
110 });
90111
91112 const adapter = yield* makeCursorAdapter(effectiveConfig, {
92113 environment: processEnv,
......@@ -103,6 +124,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
103124 );
104125
105126 const snapshot = yield* makeManagedServerProvider<CursorSettings>({
127 maintenanceCapabilities,
106128 getSettings: Effect.succeed(effectiveConfig),
107129 streamSettings: Stream.never,
108130 haveSettingsChanged: () => false,
......@@ -117,8 +139,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
117139 settings,
118140 environment: processEnv,
119141 snapshot: currentSnapshot,
142 maintenanceCapabilities,
120143 publishSnapshot,
121144 stampIdentity,
145 httpClient,
122146 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
123147 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
124148 }).pipe(
apps/server/src/provider/Drivers/OpenCodeDriver.ts+39
......@@ -14,6 +14,7 @@
1414 */
1515import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
1616import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { HttpClient } from "effect/unstable/http";
1718import { ChildProcessSpawner } from "effect/unstable/process";
1819
1920import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";
......@@ -34,13 +35,40 @@ import {
3435} from "../ProviderDriver.ts";
3536import type { ServerProviderDraft } from "../providerSnapshot.ts";
3637import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38import {
39 enrichProviderSnapshotWithVersionAdvisory,
40 makePackageManagedProviderMaintenanceResolver,
41 normalizeCommandPath,
42 resolveProviderMaintenanceCapabilitiesEffect,
43} from "../providerMaintenance.ts";
3744
3845const DRIVER_KIND = ProviderDriverKind.make("opencode");
3946const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
4047
48function 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
56const 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
4168export type OpenCodeDriverEnv =
4269 | ChildProcessSpawner.ChildProcessSpawner
4370 | FileSystem.FileSystem
71 | HttpClient.HttpClient
4472 | OpenCodeRuntime
4573 | Path.Path
4674 | ProviderEventLoggers
......@@ -74,6 +102,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
74102 Effect.gen(function* () {
75103 const openCodeRuntime = yield* OpenCodeRuntime;
76104 const serverConfig = yield* ServerConfig;
105 const httpClient = yield* HttpClient.HttpClient;
77106 const eventLoggers = yield* ProviderEventLoggers;
78107 const processEnv = mergeProviderInstanceEnvironment(environment);
79108 const continuationIdentity = defaultProviderContinuationIdentity({
......@@ -87,6 +116,10 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
87116 continuationGroupKey: continuationIdentity.continuationKey,
88117 });
89118 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;
119 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
120 binaryPath: effectiveConfig.binaryPath,
121 env: processEnv,
122 });
90123
91124 const adapter = yield* makeOpenCodeAdapter(effectiveConfig, {
92125 instanceId,
......@@ -102,11 +135,17 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
102135 ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime));
103136
104137 const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({
138 maintenanceCapabilities,
105139 getSettings: Effect.succeed(effectiveConfig),
106140 streamSettings: Stream.never,
107141 haveSettingsChanged: () => false,
108142 initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)),
109143 checkProvider,
144 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
145 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
146 Effect.provideService(HttpClient.HttpClient, httpClient),
147 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
148 ),
110149 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
111150 }).pipe(
112151 Effect.mapError(
apps/server/src/provider/Layers/CodexProvider.ts-1
......@@ -15,7 +15,6 @@ import type {
1515import { ServerSettingsError } from "@t3tools/contracts";
1616
1717import { createModelCapabilities } from "@t3tools/shared/model";
18
1918import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts";
2019import { expandHomePath } from "../../pathExpansion.ts";
2120import { scopedSafeTeardown } from "./scopedSafeTeardown.ts";
apps/server/src/provider/Layers/CursorProvider.ts+55-25
......@@ -12,6 +12,7 @@ import type {
1212import { ProviderDriverKind } from "@t3tools/contracts";
1313import type * as EffectAcpSchema from "effect-acp/schema";
1414import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect";
15import { HttpClient } from "effect/unstable/http";
1516import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1617import {
1718 createModelCapabilities,
......@@ -29,6 +30,10 @@ import {
2930 type CommandResult,
3031 type ServerProviderDraft,
3132} from "../providerSnapshot.ts";
33import {
34 enrichProviderSnapshotWithVersionAdvisory,
35 type ProviderMaintenanceCapabilities,
36} from "../providerMaintenance.ts";
3237import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
3338
3439const PROVIDER = ProviderDriverKind.make("cursor");
......@@ -1216,42 +1221,67 @@ export const enrichCursorSnapshot = (input: {
12161221 readonly settings: CursorSettings;
12171222 readonly environment?: NodeJS.ProcessEnv;
12181223 readonly snapshot: ServerProvider;
1224 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
12191225 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
12201226 readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider;
1227 readonly httpClient: HttpClient.HttpClient;
12211228}): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {
12221229 const { settings, snapshot, publishSnapshot } = input;
12231230 const stampIdentity = input.stampIdentity ?? ((value) => value);
12241231
1225 if (
1226 !settings.enabled ||
1227 snapshot.auth.status === "unauthenticated" ||
1228 !hasUncapturedCursorModels(snapshot)
1229 ) {
1230 return Effect.void;
1231 }
1232 const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory(
1233 snapshot,
1234 input.maintenanceCapabilities,
1235 ).pipe(
1236 Effect.provideService(HttpClient.HttpClient, input.httpClient),
1237 Effect.flatMap((enrichedSnapshot) =>
1238 publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)),
1239 ),
1240 Effect.catchCause((cause) =>
1241 Effect.logWarning("Cursor version advisory enrichment failed", {
1242 cause: Cause.pretty(cause),
1243 }).pipe(Effect.as(snapshot)),
1244 ),
1245 );
12321246
1233 return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe(
1234 Effect.flatMap((discoveredModels) => {
1235 if (discoveredModels.length === 0) {
1247 return enrichVersionAdvisory.pipe(
1248 Effect.flatMap((baseSnapshot) => {
1249 if (
1250 !settings.enabled ||
1251 baseSnapshot.auth.status === "unauthenticated" ||
1252 !hasUncapturedCursorModels(baseSnapshot)
1253 ) {
12361254 return Effect.void;
12371255 }
1238 return publishSnapshot(
1239 stampIdentity({
1240 ...snapshot,
1241 models: providerModelsFromSettings(
1242 discoveredModels,
1243 PROVIDER,
1244 settings.customModels,
1245 EMPTY_CAPABILITIES,
1246 ),
1256
1257 return discoverCursorModelCapabilitiesViaAcp(
1258 settings,
1259 baseSnapshot.models,
1260 input.environment,
1261 ).pipe(
1262 Effect.flatMap((discoveredModels) => {
1263 if (discoveredModels.length === 0) {
1264 return Effect.void;
1265 }
1266 return publishSnapshot(
1267 stampIdentity({
1268 ...baseSnapshot,
1269 models: providerModelsFromSettings(
1270 discoveredModels,
1271 PROVIDER,
1272 settings.customModels,
1273 EMPTY_CAPABILITIES,
1274 ),
1275 }),
1276 );
12471277 }),
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 ),
12481284 );
12491285 }),
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 ),
12561286 );
12571287};
apps/server/src/provider/Layers/OpenCodeProvider.ts-1
......@@ -7,7 +7,6 @@ import {
77import { Cause, Data, Effect } from "effect";
88
99import { createModelCapabilities } from "@t3tools/shared/model";
10
1110import {
1211 buildServerProvider,
1312 nonEmptyTrimmed,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+5
......@@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
1414import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
1515import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
1616import type { ProviderInstance } from "../ProviderDriver.ts";
17import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
1718import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
1819import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
1920import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -111,6 +112,10 @@ const makeFakeInstance = (
111112 displayName: undefined,
112113 enabled: true,
113114 snapshot: {
115 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
116 provider: driverKind,
117 packageName: null,
118 }),
114119 getSnapshot: Effect.succeed({} as unknown as ServerProvider),
115120 refresh: Effect.succeed({} as unknown as ServerProvider),
116121 streamChanges: Stream.empty,
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+10
......@@ -34,6 +34,7 @@ import {
3434 ProviderInstanceId,
3535} from "@t3tools/contracts";
3636import { Effect, Layer } from "effect";
37import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3738
3839import { ServerConfig } from "../../config.ts";
3940import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
......@@ -44,6 +45,13 @@ import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
4445import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
4546import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
4647
48const 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
4755const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
4856 enabled: false,
4957 binaryPath: "codex",
......@@ -89,6 +97,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
8997 prefix: "provider-instance-registry-test",
9098 }).pipe(
9199 Layer.provideMerge(NodeServices.layer),
100 Layer.provideMerge(TestHttpClientLive),
92101 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
93102 );
94103
......@@ -225,6 +234,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
225234 prefix: "provider-instance-registry-all-drivers-test",
226235 }).pipe(
227236 Layer.provideMerge(infraLayer),
237 Layer.provideMerge(TestHttpClientLive),
228238 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
229239 );
230240
apps/server/src/provider/Layers/ProviderRegistry.test.ts+201-1
......@@ -14,6 +14,7 @@ import {
1414 type ServerSettings as ContractServerSettings,
1515} from "@t3tools/contracts";
1616import * as PlatformError from "effect/PlatformError";
17import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1718import { ChildProcessSpawner } from "effect/unstable/process";
1819import { deepMerge } from "@t3tools/shared/Struct";
1920import { createModelCapabilities } from "@t3tools/shared/model";
......@@ -26,13 +27,17 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr
2627import {
2728 haveProvidersChanged,
2829 mergeProviderSnapshot,
30 mergeProviderSnapshots,
2931 ProviderRegistryLive,
32 selectProvidersByKind,
3033} from "./ProviderRegistry.ts";
3134import { ServerConfig } from "../../config.ts";
3235import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
36import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";
3337import type { ProviderInstance } from "../ProviderDriver.ts";
3438import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
3539import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
40import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
3641
3742const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
3843const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
......@@ -46,6 +51,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1";
4651
4752const encoder = new TextEncoder();
4853
54const 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
4961function selectDescriptor(
5062 id: string,
5163 label: string,
......@@ -246,7 +258,7 @@ function makeMutableServerSettingsService(
246258 });
247259}
248260
249it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
261it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), TestHttpClientLive))(
250262 "ProviderRegistry",
251263 (it) => {
252264 describe("checkCodexProviderStatus", () => {
......@@ -500,6 +512,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
500512 ]);
501513 });
502514
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
503691 it.effect("returns the cached provider list when a manual refresh fails", () =>
504692 Effect.gen(function* () {
505693 const codexDriver = ProviderDriverKind.make("codex");
......@@ -527,6 +715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
527715 displayName: undefined,
528716 enabled: true,
529717 snapshot: {
718 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
719 provider: codexDriver,
720 packageName: null,
721 }),
530722 getSnapshot: Effect.succeed(cachedProvider),
531723 refresh: Effect.die(new Error("simulated refresh failure")),
532724 streamChanges: Stream.empty,
......@@ -612,6 +804,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
612804 displayName: undefined,
613805 enabled: true,
614806 snapshot: {
807 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
808 provider: provider.driver,
809 packageName: null,
810 }),
615811 getSnapshot: Effect.succeed(provider),
616812 refresh: Effect.succeed(provider),
617813 streamChanges: Stream.empty,
......@@ -748,6 +944,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
748944 prefix: "t3-provider-registry-",
749945 }),
750946 ),
947 Layer.provideMerge(TestHttpClientLive),
751948 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
752949 Layer.provideMerge(OpenCodeRuntimeLive),
753950 // NO spawner mock — `ChildProcessSpawner` is supplied by the
......@@ -832,6 +1029,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8321029 prefix: "t3-provider-registry-",
8331030 }),
8341031 ),
1032 Layer.provideMerge(TestHttpClientLive),
8351033 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
8361034 Layer.provideMerge(OpenCodeRuntimeLive),
8371035 // `it.live` does not inherit layers from the outer `it.layer`
......@@ -936,6 +1134,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
9361134 prefix: "t3-provider-registry-",
9371135 }),
9381136 ),
1137 Layer.provideMerge(TestHttpClientLive),
9391138 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
9401139 Layer.provideMerge(OpenCodeRuntimeLive),
9411140 Layer.provideMerge(NodeServices.layer),
......@@ -986,6 +1185,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
9861185 prefix: "t3-provider-registry-",
9871186 }),
9881187 ),
1188 Layer.provideMerge(TestHttpClientLive),
9891189 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
9901190 Layer.provideMerge(OpenCodeRuntimeLive),
9911191 Layer.provideMerge(
apps/server/src/provider/Layers/ProviderRegistry.ts+128-4
......@@ -27,6 +27,7 @@ import {
2727 ProviderDriverKind,
2828 type ProviderInstanceId,
2929 type ServerProvider,
30 type ServerProviderUpdateState,
3031} from "@t3tools/contracts";
3132import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect";
3233import * as Semaphore from "effect/Semaphore";
......@@ -43,6 +44,7 @@ import {
4344 writeProviderStatusCache,
4445} from "../providerStatusCache.ts";
4546import type { ProviderInstance } from "../ProviderDriver.ts";
47import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
4648import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
4749
4850const loadProviders = (
......@@ -59,6 +61,12 @@ const loadProviders = (
5961 },
6062 );
6163
64const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) =>
65 makeManualOnlyProviderMaintenanceCapabilities({
66 provider,
67 packageName: null,
68 });
69
6270const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
6371 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
6472
......@@ -96,6 +104,30 @@ export const mergeProviderSnapshot = (
96104 models: mergeProviderModels(previousProvider.models, nextProvider.models),
97105 };
98106
107export 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
125export const selectProvidersByKind = (
126 providers: ReadonlyArray<ServerProvider>,
127 providerKinds: ReadonlySet<ProviderDriverKind>,
128): ReadonlyArray<ServerProvider> =>
129 providers.filter((provider) => providerKinds.has(provider.driver));
130
99131export const haveProvidersChanged = (
100132 previousProviders: ReadonlyArray<ServerProvider>,
101133 nextProviders: ReadonlyArray<ServerProvider>,
......@@ -227,6 +259,9 @@ export const ProviderRegistryLive = Layer.effect(
227259 ),
228260 );
229261 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders);
262 const maintenanceActionStatesRef = yield* Ref.make<
263 ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }>
264 >(new Map());
230265
231266 // Live-source registry — the dynamic counterpart to the boot-time
232267 // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance`
......@@ -264,6 +299,21 @@ export const ProviderRegistryLive = Layer.effect(
264299 );
265300 });
266301
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
267317 const upsertProviders = Effect.fn("upsertProviders")(function* (
268318 nextProviders: ReadonlyArray<ServerProvider>,
269319 options?: {
......@@ -272,15 +322,24 @@ export const ProviderRegistryLive = Layer.effect(
272322 readonly replace?: boolean;
273323 },
274324 ) {
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(
276333 providersRef,
277334 (previousProviders) => {
278335 const mergedProviders = new Map(
279336 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
280337 );
338 const updatedKeys = new Set<ProviderInstanceId>();
281339
282 for (const provider of nextProviders) {
340 for (const provider of nextProvidersWithUpdateState) {
283341 const key = snapshotInstanceKey(provider);
342 updatedKeys.add(key);
284343 mergedProviders.set(
285344 key,
286345 options?.replace === true
......@@ -290,13 +349,16 @@ export const ProviderRegistryLive = Layer.effect(
290349 }
291350
292351 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];
294356 },
295357 );
296358
297359 if (haveProvidersChanged(previousProviders, providers)) {
298360 if (options?.persist !== false) {
299 yield* Effect.forEach(nextProviders, persistProvider, {
361 yield* Effect.forEach(providersToPersist, persistProvider, {
300362 concurrency: "unbounded",
301363 discard: true,
302364 });
......@@ -318,6 +380,45 @@ export const ProviderRegistryLive = Layer.effect(
318380 return yield* upsertProviders([provider], options);
319381 });
320382
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
321422 const refreshOneSource = Effect.fn("refreshOneSource")(function* (
322423 providerSource: ProviderSnapshotSource,
323424 ) {
......@@ -365,6 +466,18 @@ export const ProviderRegistryLive = Layer.effect(
365466 return yield* refreshOneSource(providerSource);
366467 });
367468
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
368481 /**
369482 * Diff the aggregator's live-source set against the current
370483 * `ProviderInstanceRegistry` and:
......@@ -472,6 +585,15 @@ export const ProviderRegistryLive = Layer.effect(
472585 if (haveProvidersChanged(previousProviders, providers)) {
473586 yield* PubSub.publish(changesPubSub, providers);
474587 }
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 });
475597 }),
476598 );
477599 const syncLiveSourcesAndContinue = syncLiveSources.pipe(
......@@ -555,6 +677,8 @@ export const ProviderRegistryLive = Layer.effect(
555677 refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
556678 refreshInstance: (instanceId: ProviderInstanceId) =>
557679 refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
680 getProviderMaintenanceCapabilitiesForInstance,
681 setProviderMaintenanceActionState,
558682 get streamChanges() {
559683 return Stream.fromPubSub(changesPubSub);
560684 },
apps/server/src/provider/Services/ProviderRegistry.ts+30-1
......@@ -6,9 +6,17 @@
66 *
77 * @module ProviderRegistry
88 */
9import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts";
9import type {
10 ProviderInstanceId,
11 ProviderDriverKind,
12 ServerProvider,
13 ServerProviderUpdateState,
14} from "@t3tools/contracts";
1015import { Context } from "effect";
1116import type { Effect, Stream } from "effect";
17import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
18
19export type ProviderMaintenanceActionKind = "update";
1220
1321export interface ProviderRegistryShape {
1422 /**
......@@ -39,6 +47,27 @@ export interface ProviderRegistryShape {
3947 instanceId: ProviderInstanceId,
4048 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;
4149
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
4271 /**
4372 * Stream of provider snapshot updates — one emission per aggregated
4473 * change. The array contains the full current state.
apps/server/src/provider/Services/ServerProvider.ts+2
......@@ -1,7 +1,9 @@
11import type { ServerProvider } from "@t3tools/contracts";
22import type { Effect, Stream } from "effect";
3import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
34
45export interface ServerProviderShape {
6 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
57 readonly getSnapshot: Effect.Effect<ServerProvider>;
68 readonly refresh: Effect.Effect<ServerProvider>;
79 readonly streamChanges: Stream.Stream<ServerProvider>;
apps/server/src/provider/makeManagedServerProvider.test.ts+18
......@@ -20,6 +20,20 @@ interface TestSettings {
2020 readonly enabled: boolean;
2121}
2222
23const 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
2337const initialSnapshot: ServerProvider = {
2438 instanceId: ProviderInstanceId.make("codex"),
2539 driver: ProviderDriverKind.make("codex"),
......@@ -90,6 +104,7 @@ describe("makeManagedServerProvider", () => {
90104 const checkCalls = yield* Ref.make(0);
91105 const releaseCheck = yield* Deferred.make<void>();
92106 const provider = yield* makeManagedServerProvider<TestSettings>({
107 maintenanceCapabilities,
93108 getSettings: Effect.succeed({ enabled: true }),
94109 streamSettings: Stream.empty,
95110 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -131,6 +146,7 @@ describe("makeManagedServerProvider", () => {
131146 const releaseInitialCheck = yield* Deferred.make<void>();
132147 const releaseSettingsCheck = yield* Deferred.make<void>();
133148 const provider = yield* makeManagedServerProvider<TestSettings>({
149 maintenanceCapabilities,
134150 getSettings: Ref.get(settingsRef),
135151 streamSettings: Stream.fromPubSub(settingsChanges),
136152 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -172,6 +188,7 @@ describe("makeManagedServerProvider", () => {
172188 const releaseEnrichment = yield* Deferred.make<void>();
173189 const releaseCheck = yield* Deferred.make<void>();
174190 const provider = yield* makeManagedServerProvider<TestSettings>({
191 maintenanceCapabilities,
175192 getSettings: Effect.succeed({ enabled: true }),
176193 streamSettings: Stream.empty,
177194 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -212,6 +229,7 @@ describe("makeManagedServerProvider", () => {
212229 const secondCallbackReady = yield* Deferred.make<void>();
213230 const allowFirstRefresh = yield* Deferred.make<void>();
214231 const provider = yield* makeManagedServerProvider<TestSettings>({
232 maintenanceCapabilities,
215233 getSettings: Effect.succeed({ enabled: true }),
216234 streamSettings: Stream.empty,
217235 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
apps/server/src/provider/makeManagedServerProvider.ts+2
......@@ -13,6 +13,7 @@ interface ProviderSnapshotState {
1313export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
1414 Settings,
1515>(input: {
16 readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"];
1617 readonly getSettings: Effect.Effect<Settings>;
1718 readonly streamSettings: Stream.Stream<Settings>;
1819 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
......@@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
143144 );
144145
145146 return {
147 maintenanceCapabilities: input.maintenanceCapabilities,
146148 getSnapshot: input.getSettings.pipe(
147149 Effect.flatMap(applySnapshot),
148150 Effect.tapError(Effect.logError),
apps/server/src/provider/providerMaintenance.test.ts created+487
......@@ -0,0 +1,487 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import os from "node:os";
5import path from "node:path";
6import { ProviderDriverKind } from "@t3tools/contracts";
7import { Effect, Random } from "effect";
8import {
9 clearLatestProviderVersionCacheForTests,
10 createProviderVersionAdvisory,
11 makePackageManagedProviderMaintenanceResolver,
12 makeProviderMaintenanceCapabilities,
13 makeStaticProviderMaintenanceResolver,
14 normalizeCommandPath,
15 resolveProviderMaintenanceCapabilitiesEffect,
16} from "./providerMaintenance.ts";
17
18const driver = (value: string) => ProviderDriverKind.make(value);
19const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) {
20 const id = yield* Random.nextUUIDv4;
21 return path.join(os.tmpdir(), `${name}-${id}`);
22});
23const isNativeTestCommandPath =
24 (expectedPathSegment: string) =>
25 (commandPath: string): boolean =>
26 normalizeCommandPath(commandPath).includes(expectedPathSegment);
27const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({
28 provider: driver("packageTool"),
29 npmPackageName: "@example/package-tool",
30 homebrewFormula: "package-tool",
31 nativeUpdate: null,
32});
33const 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});
44const 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});
55const 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
65afterEach(() => {
66 clearLatestProviderVersionCacheForTests();
67});
68
69describe("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 @@
1import {
2 ProviderDriverKind,
3 type ServerProvider,
4 type ServerProviderVersionAdvisory,
5} from "@t3tools/contracts";
6import { resolveCommandPath } from "@t3tools/shared/shell";
7import { DateTime, Effect, FileSystem, Option, Schema } from "effect";
8import { HttpClient, HttpClientRequest } from "effect/unstable/http";
9
10import { compareCliVersions } from "./cliVersion.ts";
11
12const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
13const LATEST_VERSION_TIMEOUT_MS = 4_000;
14const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
15
16export interface ProviderMaintenanceCapabilities {
17 readonly provider: ProviderDriverKind;
18 readonly packageName: string | null;
19 readonly update: ProviderMaintenanceCommandAction | null;
20}
21
22export interface ProviderMaintenanceCommandAction {
23 readonly command: string;
24 readonly executable: string;
25 readonly args: ReadonlyArray<string>;
26 readonly lockKey: string;
27}
28
29export interface ProviderMaintenanceCapabilityResolutionOptions {
30 readonly binaryPath?: string | null;
31 readonly env?: NodeJS.ProcessEnv;
32 readonly platform?: NodeJS.Platform;
33 readonly realCommandPath?: string | null;
34}
35
36export interface ProviderMaintenanceCapabilitiesResolver {
37 readonly resolve: (
38 options?: ProviderMaintenanceCapabilityResolutionOptions,
39 ) => ProviderMaintenanceCapabilities;
40}
41
42export 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
54interface LatestVersionCacheEntry {
55 readonly expiresAt: number;
56 readonly version: string | null;
57}
58
59const latestVersionCache = new Map<string, LatestVersionCacheEntry>();
60const NpmLatestVersionResponse = Schema.Struct({
61 version: Schema.optional(Schema.String),
62});
63
64export function clearLatestProviderVersionCacheForTests(): void {
65 latestVersionCache.clear();
66}
67
68function nonEmptyString(value: unknown): string | null {
69 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
70}
71
72export 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
95export 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
108function 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
120function 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
132function 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
144function 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
156function 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
175function 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
191export function hasPathSeparator(value: string): boolean {
192 return value.includes("/") || value.includes("\\");
193}
194
195export function normalizeCommandPath(commandPath: string): string {
196 return commandPath.replaceAll("\\", "/").toLowerCase();
197}
198
199function isBunGlobalCommandPath(commandPath: string): boolean {
200 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
201}
202
203function isVitePlusGlobalCommandPath(commandPath: string): boolean {
204 return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/");
205}
206
207function 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
218function 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
227function 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
241export 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
299export function makePackageManagedProviderMaintenanceResolver(
300 definition: PackageManagedProviderMaintenanceDefinition,
301): ProviderMaintenanceCapabilitiesResolver {
302 return {
303 resolve: (options) => resolvePackageManagedProviderMaintenance(definition, options),
304 };
305}
306
307export function makeStaticProviderMaintenanceResolver(
308 capabilities: ProviderMaintenanceCapabilities,
309): ProviderMaintenanceCapabilitiesResolver {
310 return {
311 resolve: () => capabilities,
312 };
313}
314
315function makeManualProviderMaintenanceCapabilities(
316 provider: ProviderDriverKind,
317): ProviderMaintenanceCapabilities {
318 return makeManualOnlyProviderMaintenanceCapabilities({
319 provider,
320 packageName: null,
321 });
322}
323
324export 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
354function 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
373export 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
399const 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
422export 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
444export 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 @@
1import { Effect, Ref } from "effect";
2import * as Semaphore from "effect/Semaphore";
3
4export 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
13export 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 @@
1import { afterEach, describe, it, assert } from "@effect/vitest";
2import {
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6 type ServerProviderUpdateState,
7} from "@t3tools/contracts";
8import { ServerProviderUpdateError } from "@t3tools/contracts";
9import { Cause, Effect, Exit, Fiber, Layer, Ref, Schema, Sink, Stream } from "effect";
10import { HttpClient, HttpClientResponse } from "effect/unstable/http";
11import { ChildProcessSpawner } from "effect/unstable/process";
12
13import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";
14import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
15import {
16 clearLatestProviderVersionCacheForTests,
17 makeProviderMaintenanceCapabilities,
18 type ProviderMaintenanceCapabilities,
19} from "./providerMaintenance.ts";
20
21const CODEX_DRIVER = ProviderDriverKind.make("codex");
22const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
23const OPENCODE_DRIVER = ProviderDriverKind.make("opencode");
24const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex");
25const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor");
26const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
27const encoder = new TextEncoder();
28
29afterEach(() => {
30 clearLatestProviderVersionCacheForTests();
31});
32
33function 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
55const 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
69const baseCursorProvider: ServerProvider = {
70 ...baseProvider,
71 instanceId: CURSOR_INSTANCE_ID,
72 driver: CURSOR_DRIVER,
73};
74
75const baseOpenCodeProvider: ServerProvider = {
76 ...baseProvider,
77 instanceId: OPENCODE_INSTANCE_ID,
78 driver: OPENCODE_DRIVER,
79};
80
81const 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
94function 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
115function 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
138function 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
192const 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
201describe("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 @@
1import {
2 defaultInstanceIdForDriver,
3 ProviderDriverKind,
4 ServerProviderUpdateError,
5 type ProviderInstanceId,
6 type ServerProvider,
7 type ServerProviderUpdatedPayload,
8 type ServerProviderUpdateState,
9} from "@t3tools/contracts";
10import { Cause, Context, DateTime, Duration, Effect, Layer, Option, Ref, Schema } from "effect";
11import { HttpClient } from "effect/unstable/http";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13
14import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
15import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
16import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts";
17import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
18import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
19
20const UPDATE_TIMEOUT_MS = 5 * 60_000;
21const UPDATE_OUTPUT_MAX_BYTES = 10_000;
22
23export 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
32export 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
43export class ProviderMaintenanceRunner extends Context.Service<
44 ProviderMaintenanceRunner,
45 ProviderMaintenanceRunnerShape
46>()("t3/provider/ProviderMaintenanceRunner") {}
47
48interface VerifiedProviderRefresh {
49 readonly providers: ReadonlyArray<ServerProvider>;
50 readonly verifiedProviders: ReadonlyArray<ServerProvider>;
51}
52
53const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
54
55const 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
125function trimNullable(value: string): string | null {
126 const trimmed = value.trim();
127 return trimmed.length > 0 ? trimmed : null;
128}
129
130function truncateText(value: string, maxLength: number): string {
131 return value.length <= maxLength ? value : value.slice(0, maxLength);
132}
133
134function 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
142function 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
152function isOutdatedProvider(provider: ServerProvider | undefined): boolean {
153 return provider?.versionAdvisory?.status === "behind_latest";
154}
155
156function 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
172export 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
395export const layer = Layer.effect(ProviderMaintenanceRunner, make());
apps/server/src/provider/providerSnapshot.ts+12-7
......@@ -12,6 +12,8 @@ import { Effect, Stream } from "effect";
1212import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1313import { normalizeModelSlug } from "@t3tools/shared/model";
1414import { isWindowsCommandNotFound } from "../processRunner.ts";
15import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
16import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1517
1618export const DEFAULT_TIMEOUT_MS = 4_000;
1719// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
......@@ -182,6 +184,7 @@ export function buildBooleanOptionDescriptor(input: {
182184}
183185
184186export function buildServerProvider(input: {
187 driver?: ProviderDriverKind;
185188 presentation: ServerProviderPresentation;
186189 enabled: boolean;
187190 checkedAt: string;
......@@ -190,6 +193,13 @@ export function buildServerProvider(input: {
190193 skills?: ReadonlyArray<ServerProviderSkill>;
191194 probe: ProviderProbeResult;
192195}): ServerProviderDraft {
196 const versionAdvisory = input.driver
197 ? createProviderVersionAdvisory({
198 driver: input.driver,
199 currentVersion: input.probe.version,
200 checkedAt: input.checkedAt,
201 })
202 : undefined;
193203 return {
194204 displayName: input.presentation.displayName,
195205 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
......@@ -206,16 +216,11 @@ export function buildServerProvider(input: {
206216 models: input.models,
207217 slashCommands: [...(input.slashCommands ?? [])],
208218 skills: [...(input.skills ?? [])],
219 ...(versionAdvisory ? { versionAdvisory } : {}),
209220 };
210221}
211222
212223export const collectStreamAsString = <E>(
213224 stream: Stream.Stream<Uint8Array, E>,
214225): Effect.Effect<string, E> =>
215 stream.pipe(
216 Stream.decodeText(),
217 Stream.runFold(
218 () => "",
219 (acc, chunk) => acc + chunk,
220 ),
221 );
226 collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text));
apps/server/src/provider/providerStatusCache.ts+5-3
......@@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) =>
140140export const writeProviderStatusCache = (input: {
141141 readonly filePath: string;
142142 readonly provider: ServerProvider;
143}) =>
144 writeFileStringAtomically({
143}) => {
144 const { updateState: _updateState, ...cacheableProvider } = input.provider;
145 return writeFileStringAtomically({
145146 filePath: input.filePath,
146 contents: `${JSON.stringify(input.provider, null, 2)}\n`,
147 contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`,
147148 });
149};
apps/server/src/server.test.ts+7
......@@ -80,6 +80,7 @@ import {
8080 ProviderRegistry,
8181 type ProviderRegistryShape,
8282} from "./provider/Services/ProviderRegistry.ts";
83import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
8384import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts";
8485import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
8586import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
......@@ -517,6 +518,12 @@ const buildAppUnderTest = (options?: {
517518 Layer.mock(ProviderRegistry)({
518519 getProviders: Effect.succeed([]),
519520 refresh: () => Effect.succeed([]),
521 refreshInstance: () => Effect.succeed([]),
522 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
523 Effect.succeed(
524 makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),
525 ),
526 setProviderMaintenanceActionState: () => Effect.succeed([]),
520527 streamChanges: Stream.empty,
521528 ...options?.layers?.providerRegistry,
522529 }),
apps/server/src/stream/collectUint8StreamText.test.ts created+38
......@@ -0,0 +1,38 @@
1import { assert, describe, it } from "@effect/vitest";
2import { Effect, Stream } from "effect";
3
4import { collectUint8StreamText } from "./collectUint8StreamText.ts";
5
6const encoder = new TextEncoder();
7
8describe("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 @@
1import { Effect, Stream } from "effect";
2
3export interface CollectedUint8StreamText {
4 readonly text: string;
5 readonly truncated: boolean;
6 readonly bytes: number;
7}
8
9interface CollectState {
10 readonly text: string;
11 readonly bytes: number;
12 readonly truncated: boolean;
13}
14
15export 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 {
88 VcsProcessSpawnError,
99 VcsProcessTimeoutError,
1010} from "@t3tools/contracts";
11import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1112
1213export interface VcsProcessInput {
1314 readonly operation: string;
......@@ -86,44 +87,12 @@ export const collectText = Effect.fn("VcsProcess.collectText")(function* (input:
8687 readonly maxOutputBytes?: number;
8788 readonly truncateOutputAtMaxBytes?: boolean;
8889}) {
89 const decoder = new TextDecoder();
90 let text = "";
91 let bytes = 0;
92 let truncated = false;
9390 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
94 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
95
96 yield* Stream.runForEach(input.stream, (chunk) =>
97 Effect.sync(() => {
98 if (truncated) return;
99
100 const remainingBytes = maxOutputBytes - bytes;
101 if (remainingBytes <= 0) {
102 truncated = true;
103 if (truncateOutputAtMaxBytes) {
104 text += OUTPUT_TRUNCATED_MARKER;
105 }
106 return;
107 }
108
109 const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk;
110 text += decoder.decode(nextChunk, { stream: true });
111 bytes += nextChunk.byteLength;
112
113 if (chunk.byteLength > remainingBytes) {
114 truncated = true;
115 if (truncateOutputAtMaxBytes) {
116 text += OUTPUT_TRUNCATED_MARKER;
117 }
118 }
119 }),
120 );
121
122 if (!truncated) {
123 text += decoder.decode();
124 }
125
126 return { text, truncated } satisfies VcsProcessCollectedText;
91 return yield* collectUint8StreamText({
92 stream: input.stream,
93 maxBytes: maxOutputBytes,
94 truncatedMarker: input.truncateOutputAtMaxBytes ? OUTPUT_TRUNCATED_MARKER : null,
95 });
12796});
12897
12998export const make = Effect.fn("makeVcsProcess")(function* () {
apps/server/src/ws.ts+11
......@@ -40,6 +40,7 @@ import {
4040 observeRpcStreamEffect,
4141} from "./observability/RpcInstrumentation.ts";
4242import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
43import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
4344import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
4445import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
4546import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
......@@ -152,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
152153 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
153154 const terminalManager = yield* TerminalManager;
154155 const providerRegistry = yield* ProviderRegistry;
156 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
155157 const config = yield* ServerConfig;
156158 const lifecycleEvents = yield* ServerLifecycleEvents;
157159 const serverSettings = yield* ServerSettingsService;
......@@ -785,6 +787,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
785787 ).pipe(Effect.map((providers) => ({ providers }))),
786788 { "rpc.aggregate": "server" },
787789 ),
790 [WS_METHODS.serverUpdateProvider]: (input) =>
791 observeRpcEffect(
792 WS_METHODS.serverUpdateProvider,
793 providerMaintenanceRunner.updateProvider(input),
794 {
795 "rpc.aggregate": "server",
796 },
797 ),
788798 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
789799 observeRpcEffect(
790800 WS_METHODS.serverUpsertKeybinding,
......@@ -1152,6 +1162,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
11521162 Effect.provide(
11531163 makeWsRpcLayer(session.sessionId).pipe(
11541164 Layer.provideMerge(RpcSerialization.layerJson),
1165 Layer.provide(ProviderMaintenanceRunner.layer),
11551166 Layer.provide(
11561167 SourceControlDiscoveryLayer.layer.pipe(
11571168 Layer.provide(
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts created+673
......@@ -0,0 +1,673 @@
1import { describe, expect, it } from "vitest";
2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
3
4import {
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
20const checkedAt = "2026-04-23T10:00:00.000Z";
21const sessionStartedAt = "2026-04-23T09:59:00.000Z";
22const laterCheckedAt = "2026-04-23T10:01:00.000Z";
23
24const driver = (value: string) => ProviderDriverKind.make(value);
25const instanceId = (value: string) => ProviderInstanceId.make(value);
26
27function 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
68function updateCandidate(input: Parameters<typeof provider>[0]): ProviderUpdateCandidate {
69 return provider(input) as ProviderUpdateCandidate;
70}
71
72describe("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 @@
1import {
2 defaultInstanceIdForDriver,
3 PROVIDER_DISPLAY_NAMES,
4 type ProviderDriverKind,
5 type ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8
9export type ProviderUpdateCandidate = ServerProvider & {
10 readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
11 readonly status: "behind_latest";
12 readonly latestVersion: string;
13 };
14};
15
16export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success";
17export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded";
18
19export interface ProviderUpdateToastView {
20 readonly phase: ProviderUpdateToastPhase;
21 readonly type: ProviderUpdateToastType;
22 readonly title: string;
23 readonly description: string;
24 readonly dismissAfterVisibleMs?: number;
25}
26
27export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success";
28
29export 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
38interface ProviderUpdateSidebarPillOptions {
39 readonly visibleAfterIso?: string;
40 readonly dismissedKeys?: ReadonlySet<string>;
41}
42
43const PROVIDER_UPDATE_SUCCESS_VISIBLE_MS = 3_000;
44
45function formatVersion(value: string): string {
46 return value.startsWith("v") ? value : `v${value}`;
47}
48
49function 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
66function 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
79function 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
92function 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
99function 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
105function 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
115export 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
125export function isProviderUpdateActive(provider: Pick<ServerProvider, "updateState">): boolean {
126 return provider.updateState?.status === "queued" || provider.updateState?.status === "running";
127}
128
129export function collectProviderUpdateCandidates(
130 providers: ReadonlyArray<ServerProvider>,
131): ProviderUpdateCandidate[] {
132 return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate));
133}
134
135export 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
166export function canOneClickUpdateProviderCandidate(
167 candidate: ProviderUpdateCandidate,
168 providers: ReadonlyArray<ServerProvider>,
169): boolean {
170 return (
171 !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers)
172 );
173}
174
175export 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
188export function providerUpdateCandidateKey(provider: ProviderUpdateCandidate): string {
189 return providerUpdateNotificationKey([provider])!;
190}
191
192export 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
202export 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
217export 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
226export 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
238export 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
294export 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
329export 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
351export 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
361function getUpdateFinishedAt(provider: ServerProvider): string | null {
362 return provider.updateState?.finishedAt ?? null;
363}
364
365function 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
380function 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
390export 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
518function 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
529function 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 @@
1import { useNavigate } from "@tanstack/react-router";
2import { DownloadIcon } from "lucide-react";
3import { useCallback, useEffect, useMemo, useRef } from "react";
4import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
5
6import { ensureLocalApi } from "../localApi";
7import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
8import { useServerProviders } from "../rpc/serverState";
9import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";
10import {
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";
22import { stackedThreadToast, toastManager } from "./ui/toast";
23
24const seenProviderUpdateNotificationKeys = new Set<string>();
25type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
26
27type 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
37function 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
58function 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
98function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) {
99 return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded";
100}
101
102export 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 {
185185 type SidebarProjectGroupMember,
186186 type SidebarProjectSnapshot,
187187} from "../sidebarProjectGrouping";
188import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
188189const THREAD_PREVIEW_LIMIT = 6;
189190const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
190191 updated_at: "Last user message",
......@@ -2434,6 +2435,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() {
24342435
24352436 return (
24362437 <SidebarFooter className="p-2">
2438 <SidebarProviderUpdatePill />
24372439 <SidebarUpdatePill />
24382440 <SidebarMenu>
24392441 <SidebarMenuItem>
apps/web/src/components/settings/ProviderInstanceCard.tsx+226-83
......@@ -1,6 +1,15 @@
11"use client";
22
3import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
3import {
4 ArrowUpCircleIcon,
5 ChevronDownIcon,
6 CopyIcon,
7 DownloadIcon,
8 LoaderIcon,
9 PlusIcon,
10 Trash2Icon,
11 XIcon,
12} from "lucide-react";
413import { useEffect, useState, type ReactNode } from "react";
514import {
615 isProviderDriverKind,
......@@ -13,12 +22,15 @@ import {
1322} from "@t3tools/contracts";
1423
1524import { cn } from "../../lib/utils";
25import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
1626import { normalizeProviderAccentColor } from "../../providerInstances";
1727import { Badge } from "../ui/badge";
1828import { Button } from "../ui/button";
1929import { Collapsible, CollapsibleContent } from "../ui/collapsible";
2030import { DraftInput } from "../ui/draft-input";
31import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
2132import { Switch } from "../ui/switch";
33import { stackedThreadToast, toastManager } from "../ui/toast";
2234import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2335import type { DriverOption } from "./providerDriverMeta";
2436import { ProviderSettingsForm } from "./ProviderSettingsForm";
......@@ -26,6 +38,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection";
2638import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
2739import { RedactedSensitiveText } from "./RedactedSensitiveText";
2840import {
41 getProviderVersionAdvisoryPresentation,
2942 PROVIDER_STATUS_STYLES,
3043 getProviderSummary,
3144 getProviderVersionLabel,
......@@ -405,6 +418,8 @@ interface ProviderInstanceCardProps {
405418 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;
406419 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
407420 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
421 readonly onRunUpdate?: (() => void) | undefined;
422 readonly isUpdating?: boolean | undefined;
408423}
409424
410425/**
......@@ -447,6 +462,8 @@ export function ProviderInstanceCard({
447462 onHiddenModelsChange,
448463 onFavoriteModelsChange,
449464 onModelOrderChange,
465 onRunUpdate,
466 isUpdating = false,
450467}: ProviderInstanceCardProps) {
451468 const enabled = instance.enabled ?? true;
452469 // The server-reported status wins when present; otherwise fall back to
......@@ -464,10 +481,30 @@ export function ProviderInstanceCard({
464481 : null;
465482 const summary = rawSummary;
466483 const versionLabel = getProviderVersionLabel(liveProvider?.version);
484 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
485 const updateCommand = versionAdvisory?.updateCommand ?? null;
467486 const FallbackIconComponent = driverOption?.icon;
468487 const displayName =
469488 instance.displayName?.trim() || driverOption?.label || String(instance.driver);
470489 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 });
471508
472509 // Narrow `instance.driver` for callers that key on the closed
473510 // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom
......@@ -535,97 +572,203 @@ export function ProviderInstanceCard({
535572 );
536573 };
537574
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
538672 return (
539 <div className="border-t border-border first:border-t-0">
540 <div className="px-4 py-4 sm:px-5">
673 <div className="border-t border-border/60 first:border-t-0">
674 <div className="px-4 py-3.5 sm:px-5">
541675 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
542676 <div className="min-w-0 flex-1 space-y-1">
543 <div className="flex min-h-5 items-center gap-1.5">
544 {driverKind ? (
545 <ProviderInstanceIcon
546 driverKind={driverKind}
547 displayName={displayName}
548 accentColor={accentColor}
549 showBadge={Boolean(accentColor)}
550 statusDotClassName={statusStyle.dot}
551 className="size-5"
552 iconClassName="size-4 text-foreground/80"
553 badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]"
554 />
555 ) : FallbackIconComponent ? (
556 <span className="relative inline-flex size-5 shrink-0 items-center justify-center">
557 <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden />
558 <span
559 className={cn(
560 "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background",
561 statusStyle.dot,
562 )}
563 aria-hidden
677 <div className="flex min-w-0 flex-wrap items-center gap-2">
678 {titleHeadNode}
679 {versionCodeNode}
680 {versionAdvisory ? (
681 <Popover>
682 <PopoverTrigger
683 render={
684 <Button
685 type="button"
686 size="icon-xs"
687 variant="ghost"
688 className={cn(
689 "size-5 rounded-sm p-0",
690 versionAdvisory.emphasis === "strong"
691 ? "text-warning hover:text-warning"
692 : "text-primary hover:text-primary",
693 )}
694 aria-label="Update available — view details"
695 >
696 <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />
697 </Button>
698 }
564699 />
565 </span>
566 ) : (
567 <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} />
568 )}
569 <h3 className="truncate text-sm font-medium text-foreground">{displayName}</h3>
570 {String(instanceId) !== String(instance.driver) ? (
571 // Hide the id chip on a default slot whose id === the
572 // driver slug — it's redundant with the driver icon +
573 // label. Custom instances (and any instance the user has
574 // since renamed) keep the chip so their slug stays
575 // visible for copy/paste + disambiguation.
576 <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground">
577 {instanceId}
578 </code>
579 ) : null}
580 {driverOption?.badgeLabel ? (
581 <Badge variant="warning" size="sm" className="shrink-0">
582 {driverOption.badgeLabel}
583 </Badge>
584 ) : null}
585 {versionLabel ? (
586 <code className="text-xs text-muted-foreground">{versionLabel}</code>
587 ) : null}
588 {headerAction ? (
589 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
590 {headerAction}
591 </span>
592 ) : null}
593 {onDelete ? (
594 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
595 <Tooltip>
596 <TooltipTrigger
597 render={
700 <PopoverPopup side="bottom" align="start" className="w-84">
701 <div className="grid gap-3">
702 <div className="grid gap-0.5">
703 <p className="text-[13px] font-semibold leading-tight text-foreground">
704 Update available
705 </p>
706 <p
707 className={cn(
708 "text-xs leading-snug",
709 versionAdvisory.emphasis === "strong"
710 ? "text-warning"
711 : "text-muted-foreground",
712 )}
713 >
714 {versionAdvisory.detail}
715 </p>
716 </div>
717 {onRunUpdate ? (
598718 <Button
599 size="icon-xs"
600 variant="ghost"
601 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive"
602 onClick={onDelete}
603 aria-label={`Delete provider instance ${instanceId}`}
719 type="button"
720 size="xs"
721 variant="default"
722 className="w-full"
723 disabled={isUpdating}
724 onClick={onRunUpdate}
604725 >
605 <Trash2Icon className="size-3" />
726 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
727 {isUpdating ? "Updating" : "Update now"}
606728 </Button>
607 }
608 />
609 <TooltipPopup side="top">Delete instance</TooltipPopup>
610 </Tooltip>
611 </span>
729 ) : null}
730 {onRunUpdate && updateCommand ? (
731 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
732 <span aria-hidden className="h-px flex-1 bg-border" />
733 or, update manually using
734 <span aria-hidden className="h-px flex-1 bg-border" />
735 </div>
736 ) : null}
737 {updateCommand ? (
738 <div className="flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
739 <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
740 {updateCommand}
741 </code>
742 <Tooltip>
743 <TooltipTrigger
744 render={
745 <Button
746 type="button"
747 size="icon-xs"
748 variant="ghost"
749 className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
750 onClick={() =>
751 copyToClipboard(updateCommand, {
752 providerName: displayName,
753 })
754 }
755 aria-label="Copy update command"
756 >
757 <CopyIcon className="size-3" />
758 </Button>
759 }
760 />
761 <TooltipPopup side="top">Copy command</TooltipPopup>
762 </Tooltip>
763 </div>
764 ) : null}
765 </div>
766 </PopoverPopup>
767 </Popover>
612768 ) : null}
769 {titleTailNode}
613770 </div>
614 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
615 {hasAuthenticatedEmail ? (
616 <>
617 <span>Authenticated as</span>
618 <ProviderAuthEmail email={authEmail} />
619 {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null}
620 </>
621 ) : (
622 <>
623 <span>{summary.headline}</span>
624 <ProviderAuthEmail email={authEmail} separator prefix="Email" />
625 </>
626 )}
627 {summary.detail ? <span>- {summary.detail}</span> : null}
628 </p>
771 {authRowNode}
629772 </div>
630773 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
631774 <Button
apps/web/src/components/settings/SettingsPanels.browser.tsx+65-2
......@@ -10,7 +10,10 @@ import {
1010 type DesktopUpdateChannel,
1111 type DesktopUpdateState,
1212 type LocalApi,
13 ProviderDriverKind,
14 ProviderInstanceId,
1315 type ServerConfig,
16 type ServerProvider,
1417 type SourceControlDiscoveryResult,
1518} from "@t3tools/contracts";
1619import { DateTime, Option } from "effect";
......@@ -23,7 +26,7 @@ import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc
2326import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
2427import { useUiStateStore } from "../../uiStateStore";
2528import { ConnectionsSettings } from "./ConnectionsSettings";
26import { GeneralSettingsPanel } from "./SettingsPanels";
29import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels";
2730import { SourceControlSettingsPanel } from "./SourceControlSettings";
2831
2932const authAccessHarness = vi.hoisted(() => {
......@@ -207,6 +210,31 @@ function createBaseServerConfig(): ServerConfig {
207210 };
208211}
209212
213function 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
210238function makeUtc(value: string) {
211239 return DateTime.makeUnsafe(Date.parse(value));
212240}
......@@ -1016,7 +1044,7 @@ describe("GeneralSettingsPanel observability", () => {
10161044
10171045 mounted = await render(
10181046 <AppAtomRegistryProvider>
1019 <GeneralSettingsPanel />
1047 <ProviderSettingsPanel />
10201048 </AppAtomRegistryProvider>,
10211049 );
10221050
......@@ -1031,6 +1059,41 @@ describe("GeneralSettingsPanel observability", () => {
10311059 await expect.element(page.getByText("Server password")).toBeInTheDocument();
10321060 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();
10331061 });
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 });
10341097});
10351098
10361099describe("SourceControlSettingsPanel discovery states", () => {
apps/web/src/components/settings/SettingsPanels.tsx+433-402
......@@ -4,6 +4,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
44import {
55 defaultInstanceIdForDriver,
66 type DesktopUpdateChannel,
7 PROVIDER_DISPLAY_NAMES,
78 ProviderDriverKind,
89 type ProviderInstanceConfig,
910 type ProviderInstanceId,
......@@ -56,6 +57,13 @@ import { Switch } from "../ui/switch";
5657import { stackedThreadToast, toastManager } from "../ui/toast";
5758import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5859import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
60import {
61 canOneClickUpdateProviderCandidate,
62 collectProviderUpdateCandidates,
63 hasOneClickUpdateProviderCandidate,
64 isProviderUpdateActive,
65 type ProviderUpdateCandidate,
66} from "../ProviderUpdateLaunchNotification.logic";
5967import { ProviderInstanceCard } from "./ProviderInstanceCard";
6068import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
6169import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";
......@@ -345,37 +353,12 @@ function AboutVersionSection() {
345353export function useSettingsRestore(onRestored?: () => void) {
346354 const { theme, setTheme } = useTheme();
347355 const settings = useSettings();
348 const { resetSettings } = useUpdateSettings();
356 const { updateSettings } = useUpdateSettings();
349357
350358 const isGitWritingModelDirty = !Equal.equals(
351359 settings.textGenerationModelSelection ?? null,
352360 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
353361 );
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;
379362
380363 const changedSettingLabels = useMemo(
381364 () => [
......@@ -408,10 +391,8 @@ export function useSettingsRestore(onRestored?: () => void) {
408391 ? ["Delete confirmation"]
409392 : []),
410393 ...(isGitWritingModelDirty ? ["Git writing model"] : []),
411 ...(areProviderSettingsDirty ? ["Providers"] : []),
412394 ],
413395 [
414 areProviderSettingsDirty,
415396 isGitWritingModelDirty,
416397 settings.autoOpenPlanSidebar,
417398 settings.confirmThreadArchive,
......@@ -437,9 +418,20 @@ export function useSettingsRestore(onRestored?: () => void) {
437418 if (!confirmed) return;
438419
439420 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 });
441433 onRestored?.();
442 }, [changedSettingLabels, onRestored, resetSettings, setTheme]);
434 }, [changedSettingLabels, onRestored, setTheme, updateSettings]);
443435
444436 return {
445437 changedSettingLabels,
......@@ -458,44 +450,10 @@ export function GeneralSettingsPanel() {
458450 const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState<
459451 Partial<Record<"keybindings" | "logsDirectory", string | null>>
460452 >({});
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
487453 const keybindingsConfigPath = useServerKeybindingsConfigPath();
488454 const availableEditors = useServerAvailableEditors();
489455 const observability = useServerObservability();
490456 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 );
499457 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
500458 const diagnosticsDescription = (() => {
501459 const exports: string[] = [];
......@@ -576,221 +534,6 @@ export function GeneralSettingsPanel() {
576534 const isOpeningKeybindings = openingPathByTarget.keybindings;
577535 const isOpeningLogsDirectory = openingPathByTarget.logsDirectory;
578536
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
794537 return (
795538 <SettingsPageContainer>
796539 <SettingsSection title="General">
......@@ -1168,58 +911,406 @@ export function GeneralSettingsPanel() {
1168911 />
1169912 </SettingsSection>
1170913
1171 <SettingsSection
1172 title="Providers"
1173 headerAction={
1174 <div className="flex items-center gap-1.5">
1175 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
1176 <Tooltip>
1177 <TooltipTrigger
1178 render={
1179 <Button
1180 size="icon-xs"
1181 variant="ghost"
1182 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
1183 onClick={() => setIsAddInstanceDialogOpen(true)}
1184 aria-label="Add provider instance"
1185 >
1186 <PlusIcon className="size-3" />
1187 </Button>
1188 }
1189 />
1190 <TooltipPopup side="top">Add provider instance</TooltipPopup>
1191 </Tooltip>
1192 <Tooltip>
1193 <TooltipTrigger
1194 render={
1195 <Button
1196 size="icon-xs"
1197 variant="ghost"
1198 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
1199 disabled={isRefreshingProviders}
1200 onClick={() => void refreshProviders()}
1201 aria-label="Refresh provider status"
1202 >
1203 {isRefreshingProviders ? (
1204 <LoaderIcon className="size-3 animate-spin" />
1205 ) : (
1206 <RefreshCwIcon className="size-3" />
1207 )}
1208 </Button>
1209 }
1210 />
1211 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
1212 </Tooltip>
1213 </div>
1214 }
1215 >
1216 {rows.map((row) => {
1217 const driverOption = getDriverOption(row.driver);
1218 const liveProvider = serverProviders.find(
1219 (candidate) => candidate.instanceId === row.instanceId,
1220 );
1221 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
1222 hiddenModels: [],
914 <SettingsSection title="Advanced">
915 <SettingsRow
916 title="Keybindings"
917 description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
918 status={
919 <>
920 <span className="block break-all font-mono text-[11px] text-foreground">
921 {keybindingsConfigPath ?? "Resolving keybindings path..."}
922 </span>
923 {openKeybindingsError ? (
924 <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
925 ) : (
926 <span className="mt-1 block">Opens in your preferred editor.</span>
927 )}
928 </>
929 }
930 control={
931 <Button
932 size="xs"
933 variant="outline"
934 disabled={!keybindingsConfigPath || isOpeningKeybindings}
935 onClick={openKeybindingsFile}
936 >
937 {isOpeningKeybindings ? "Opening..." : "Open file"}
938 </Button>
939 }
940 />
941 </SettingsSection>
942
943 <SettingsSection title="About">
944 {isElectron ? (
945 <AboutVersionSection />
946 ) : (
947 <SettingsRow
948 title={<AboutVersionTitle />}
949 description="Current version of the application."
950 />
951 )}
952 <SettingsRow
953 title="Diagnostics"
954 description={diagnosticsDescription}
955 status={
956 <>
957 <span className="block break-all font-mono text-[11px] text-foreground">
958 {logsDirectoryPath ?? "Resolving logs directory..."}
959 </span>
960 {openDiagnosticsError ? (
961 <span className="mt-1 block text-destructive">{openDiagnosticsError}</span>
962 ) : null}
963 </>
964 }
965 control={
966 <Button
967 size="xs"
968 variant="outline"
969 disabled={!logsDirectoryPath || isOpeningLogsDirectory}
970 onClick={openLogsDirectory}
971 >
972 {isOpeningLogsDirectory ? "Opening..." : "Open logs folder"}
973 </Button>
974 }
975 />
976 </SettingsSection>
977 </SettingsPageContainer>
978 );
979}
980
981export 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: [],
12231314 modelOrder: [],
12241315 };
12251316 const favoriteModels = (settings.favorites ?? [])
......@@ -1248,13 +1339,6 @@ export function GeneralSettingsPanel() {
12481339 }))
12491340 }
12501341 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).
12581342 const wasEnabled = row.instance.enabled ?? true;
12591343 const isDisabling = next.enabled === false && wasEnabled;
12601344 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
......@@ -1287,6 +1371,17 @@ export function GeneralSettingsPanel() {
12871371 modelOrder,
12881372 })
12891373 }
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}
12901385 />
12911386 );
12921387 })}
......@@ -1296,70 +1391,6 @@ export function GeneralSettingsPanel() {
12961391 open={isAddInstanceDialogOpen}
12971392 onOpenChange={setIsAddInstanceDialogOpen}
12981393 />
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>
13631394 </SettingsPageContainer>
13641395 );
13651396}
apps/web/src/components/settings/SettingsSidebarNav.tsx+10-1
......@@ -1,5 +1,12 @@
11import { useCallback, type ComponentType } from "react";
2import { ArchiveIcon, ArrowLeftIcon, GitBranchIcon, Link2Icon, Settings2Icon } from "lucide-react";
2import {
3 ArchiveIcon,
4 ArrowLeftIcon,
5 BotIcon,
6 GitBranchIcon,
7 Link2Icon,
8 Settings2Icon,
9} from "lucide-react";
310import { useCanGoBack, useNavigate } from "@tanstack/react-router";
411
512import {
......@@ -15,6 +22,7 @@ import {
1522
1623export type SettingsSectionPath =
1724 | "/settings/general"
25 | "/settings/providers"
1826 | "/settings/source-control"
1927 | "/settings/connections"
2028 | "/settings/archived";
......@@ -25,6 +33,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
2533 icon: ComponentType<{ className?: string }>;
2634}> = [
2735 { label: "General", to: "/settings/general", icon: Settings2Icon },
36 { label: "Providers", to: "/settings/providers", icon: BotIcon },
2837 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
2938 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
3039 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
apps/web/src/components/settings/providerStatus.ts+27-1
......@@ -1,4 +1,4 @@
1import type { ServerProvider } from "@t3tools/contracts";
1import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts";
22
33/**
44 * Visual treatment for each server-reported provider status. Centralized so
......@@ -89,3 +89,29 @@ export function getProviderVersionLabel(version: string | null | undefined) {
8989 if (!version) return null;
9090 return version.startsWith("v") ? version : `v${version}`;
9191}
92
93export 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 @@
11import { Undo2Icon } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";
2import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from "react";
33
44import { cn } from "../../lib/utils";
55import { Button } from "../ui/button";
......@@ -20,15 +20,17 @@ export function SettingsSection({
2020 icon,
2121 headerAction,
2222 children,
23}: {
23 className,
24 ...sectionProps
25}: ComponentPropsWithoutRef<"section"> & {
2426 title: string;
2527 icon?: ReactNode;
2628 headerAction?: ReactNode;
2729 children: ReactNode;
2830}) {
2931 return (
30 <section className="space-y-2.5">
31 <div className="flex h-5 items-center justify-between px-1">
32 <section {...sectionProps} className={cn("space-y-2.5", className)}>
33 <div className="flex items-center justify-between px-1">
3234 <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50">
3335 <span className="inline-block h-px w-3 bg-border" aria-hidden />
3436 {icon}
......@@ -62,7 +64,7 @@ export function SettingsRow({
6264 <div
6365 className={cn(
6466 "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",
6668 )}
6769 >
6870 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
......@@ -75,7 +77,7 @@ export function SettingsRow({
7577 {resetAction}
7678 </span>
7779 </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>
7981 {status ? <div className="pt-0.5 text-[11px] text-muted-foreground">{status}</div> : null}
8082 </div>
8183 {control ? (
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx created+207
......@@ -0,0 +1,207 @@
1import { useNavigate } from "@tanstack/react-router";
2import type { ServerProvider } from "@t3tools/contracts";
3import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react";
4import { useCallback, useEffect, useState, type CSSProperties } from "react";
5
6import { useServerProviders } from "../../rpc/serverState";
7import {
8 getProviderUpdateSidebarPillView,
9 type ProviderUpdateSidebarPillView,
10} from "../ProviderUpdateLaunchNotification.logic";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12
13const 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
24const PROVIDER_UPDATE_PILL_PROGRESS_STYLES = {
25 success: "bg-success/18",
26 warning: "bg-warning/14",
27 error: "bg-destructive/14",
28} as const;
29
30function 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
40export 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 {
66 useMemo,
77 useState,
88 type CSSProperties,
9 type ComponentPropsWithoutRef,
910 type KeyboardEvent,
1011 type ReactNode,
1112} from "react";
......@@ -38,9 +39,20 @@ import {
3839export type ThreadToastData = {
3940 threadRef?: ScopedThreadRef | null;
4041 threadId?: ThreadId | null;
42 leadingIcon?: ReactNode;
4143 tooltipStyle?: boolean;
44 onClose?: (() => void) | undefined;
4245 dismissAfterVisibleMs?: number;
4346 hideCopyButton?: boolean;
47 secondaryActionProps?: ComponentPropsWithoutRef<"button">;
48 secondaryActionVariant?:
49 | "default"
50 | "destructive"
51 | "destructive-outline"
52 | "ghost"
53 | "link"
54 | "outline"
55 | "secondary";
4456 /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */
4557 expandableContent?: ReactNode;
4658 expandableLabels?: { expand?: string; collapse?: string };
......@@ -90,6 +102,15 @@ const toastCornerOrbClass = cn(
90102 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
91103);
92104
105function handleToastDismissClick(
106 manager: typeof toastManager | typeof anchoredToastManager,
107 toastId: ToastId,
108 onClose: (() => void) | undefined,
109) {
110 onClose?.();
111 manager.close(toastId);
112}
113
93114function CopyErrorButton({ text }: { text: string }) {
94115 const { copyToClipboard, isCopied } = useCopyToClipboard();
95116
......@@ -232,6 +253,7 @@ interface ToastBodyDescriptor {
232253 readonly Icon: ToastIconComponent | null | undefined;
233254 readonly stackedActionLayout: boolean;
234255 readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>;
256 readonly secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]>;
235257 readonly copyErrorText: string | null;
236258 readonly hasTrailingControls: boolean;
237259 readonly inlineContentEndPad: string;
......@@ -248,16 +270,21 @@ function deriveToastBodyDescriptor(toast: {
248270 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
249271 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =
250272 toast.data?.actionVariant ?? "default";
273 const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> =
274 toast.data?.secondaryActionVariant ?? "outline";
251275 const copyErrorText =
252276 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton
253277 ? toast.description
254278 : 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;
256282 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
257283 return {
258284 Icon,
259285 stackedActionLayout,
260286 actionVariant,
287 secondaryActionVariant,
261288 copyErrorText,
262289 hasTrailingControls,
263290 inlineContentEndPad,
......@@ -277,22 +304,35 @@ function ToastBodyContent({
277304 copyErrorText,
278305 actionProps,
279306 actionVariant,
307 secondaryActionVariant,
280308 hasTrailingControls,
281309 toastData,
282310 toastDescription,
283311 toastType,
284312}: ToastBodyContentProps) {
313 const secondaryActionProps = toastData?.secondaryActionProps;
314 const leadingIcon = toastData?.leadingIcon;
315 const { className: secondaryActionClassName, ...secondaryActionRest } =
316 secondaryActionProps ?? {};
317
285318 return (
286319 <>
287320 <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 ? (
289329 <div
290330 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
291331 data-slot="toast-icon"
292332 >
293333 <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" />
294334 </div>
295 )}
335 ) : null}
296336 <div
297337 className={cn(
298338 "flex min-h-0 min-w-0 flex-1 flex-col gap-0.5",
......@@ -315,6 +355,16 @@ function ToastBodyContent({
315355 )}
316356 >
317357 {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}
318368 {actionProps ? (
319369 <Toast.Action
320370 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}
......@@ -579,7 +629,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
579629 aria-label="Dismiss notification"
580630 className={toastCornerOrbClass}
581631 data-slot="toast-close"
582 onClick={() => toastManager.close(toast.id)}
632 onClick={() =>
633 handleToastDismissClick(toastManager, toast.id, toast.data?.onClose)
634 }
583635 type="button"
584636 >
585637 <XIcon className="size-3" strokeWidth={2.25} />
......@@ -670,7 +722,13 @@ function AnchoredToasts() {
670722 aria-label="Dismiss notification"
671723 className={toastCornerOrbClass}
672724 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 }
674732 type="button"
675733 >
676734 <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/
2626const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
2727
2828const clientSettingsListeners = new Set<() => void>();
29const clientSettingsHydrationListeners = new Set<() => void>();
2930let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
3031let clientSettingsHydrated = false;
3132let clientSettingsHydrationPromise: Promise<void> | null = null;
......@@ -36,6 +37,12 @@ function emitClientSettingsChange() {
3637 }
3738}
3839
40function emitClientSettingsHydrationChange() {
41 for (const listener of clientSettingsHydrationListeners) {
42 listener();
43 }
44}
45
3946function getClientSettingsSnapshot(): ClientSettings {
4047 return clientSettingsSnapshot;
4148}
......@@ -45,6 +52,14 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void {
4552 emitClientSettingsChange();
4653}
4754
55function setClientSettingsHydrated(nextHydrated: boolean): void {
56 if (clientSettingsHydrated === nextHydrated) {
57 return;
58 }
59 clientSettingsHydrated = nextHydrated;
60 emitClientSettingsHydrationChange();
61}
62
4863function subscribeClientSettings(listener: () => void): () => void {
4964 clientSettingsListeners.add(listener);
5065 void hydrateClientSettings();
......@@ -53,6 +68,18 @@ function subscribeClientSettings(listener: () => void): () => void {
5368 };
5469}
5570
71function getClientSettingsHydratedSnapshot(): boolean {
72 return clientSettingsHydrated;
73}
74
75function subscribeClientSettingsHydration(listener: () => void): () => void {
76 clientSettingsHydrationListeners.add(listener);
77 void hydrateClientSettings();
78 return () => {
79 clientSettingsHydrationListeners.delete(listener);
80 };
81}
82
5683async function hydrateClientSettings(): Promise<void> {
5784 if (clientSettingsHydrated) {
5885 return;
......@@ -70,7 +97,7 @@ async function hydrateClientSettings(): Promise<void> {
7097 } catch (error) {
7198 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
7299 } finally {
73 clientSettingsHydrated = true;
100 setClientSettingsHydrated(true);
74101 }
75102 })();
76103
......@@ -132,6 +159,14 @@ export function getClientSettings(): ClientSettings {
132159 return getClientSettingsSnapshot();
133160}
134161
162export function useClientSettingsHydrated(): boolean {
163 return useSyncExternalStore(
164 subscribeClientSettingsHydration,
165 getClientSettingsHydratedSnapshot,
166 () => false,
167 );
168}
169
135170export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
136171 const serverSettings = useServerSettings();
137172 const clientSettings = useSyncExternalStore(
......@@ -193,4 +228,5 @@ export function __resetClientSettingsPersistenceForTests(): void {
193228 clientSettingsHydrated = false;
194229 clientSettingsHydrationPromise = null;
195230 clientSettingsListeners.clear();
231 clientSettingsHydrationListeners.clear();
196232}
apps/web/src/index.css+13
......@@ -485,6 +485,19 @@ label:has(> select#reasoning-effort) select {
485485 text-align: left;
486486}
487487
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
488501/* Diffs theme bridge (match diff surfaces to app palette) */
489502.diff-panel-viewport {
490503 background: color-mix(in srgb, var(--background) 94%, var(--card));
apps/web/src/localApi.test.ts+31
......@@ -85,6 +85,7 @@ const rpcClientMock = {
8585 server: {
8686 getConfig: vi.fn(),
8787 refreshProviders: vi.fn(),
88 updateProvider: vi.fn(),
8889 upsertKeybinding: vi.fn(),
8990 getSettings: vi.fn(),
9091 updateSettings: vi.fn(),
......@@ -514,6 +515,34 @@ describe("wsApi", () => {
514515 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
515516 });
516517
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
517546 it("forwards server settings updates directly to the RPC client", async () => {
518547 const nextSettings = {
519548 ...DEFAULT_SERVER_SETTINGS,
......@@ -573,6 +602,7 @@ describe("wsApi", () => {
573602 autoOpenPlanSidebar: false,
574603 confirmThreadArchive: true,
575604 confirmThreadDelete: false,
605 dismissedProviderUpdateNotificationKeys: [],
576606 diffIgnoreWhitespace: true,
577607 diffWordWrap: true,
578608 favorites: [],
......@@ -634,6 +664,7 @@ describe("wsApi", () => {
634664 autoOpenPlanSidebar: false,
635665 confirmThreadArchive: true,
636666 confirmThreadDelete: false,
667 dismissedProviderUpdateNotificationKeys: [],
637668 diffIgnoreWhitespace: true,
638669 diffWordWrap: true,
639670 favorites: [],
apps/web/src/localApi.ts+4
......@@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi {
125125 rpcClient
126126 ? rpcClient.server.refreshProviders()
127127 : Promise.reject(unavailableLocalBackendError()),
128 updateProvider: (input) =>
129 rpcClient
130 ? rpcClient.server.updateProvider(input)
131 : Promise.reject(unavailableLocalBackendError()),
128132 upsertKeybinding: (input) =>
129133 rpcClient
130134 ? rpcClient.server.upsertKeybinding(input)
apps/web/src/providerUpdateDismissal.test.ts created+23
......@@ -0,0 +1,23 @@
1import { beforeEach, describe, expect, it } from "vitest";
2
3import { removeLocalStorageItem } from "./hooks/useLocalStorage";
4import {
5 dismissProviderUpdateNotification,
6 isProviderUpdateNotificationDismissed,
7 PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY,
8} from "./providerUpdateDismissal";
9
10describe("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 @@
1import { useCallback, useMemo } from "react";
2import * as Schema from "effect/Schema";
3
4import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";
5
6export const PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY = "t3code:provider-update-dismissals:v1";
7
8const ProviderUpdateDismissalsSchema = Schema.Struct({
9 keys: Schema.Array(Schema.String),
10});
11
12type ProviderUpdateDismissals = typeof ProviderUpdateDismissalsSchema.Type;
13
14function 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
29function 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
41export function isProviderUpdateNotificationDismissed(
42 dismissalKey: string | null | undefined,
43): boolean {
44 if (!dismissalKey) {
45 return false;
46 }
47 return readProviderUpdateDismissals().keys.includes(dismissalKey);
48}
49
50export 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
64export 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'
1414import { Route as ChatRouteImport } from './routes/_chat'
1515import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1616import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
17import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
1718import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
1819import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
1920import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
......@@ -44,6 +45,11 @@ const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({
4445 path: '/source-control',
4546 getParentRoute: () => SettingsRoute,
4647} as any)
48const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
49 id: '/providers',
50 path: '/providers',
51 getParentRoute: () => SettingsRoute,
52} as any)
4753const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
4854 id: '/general',
4955 path: '/general',
......@@ -78,6 +84,7 @@ export interface FileRoutesByFullPath {
7884 '/settings/archived': typeof SettingsArchivedRoute
7985 '/settings/connections': typeof SettingsConnectionsRoute
8086 '/settings/general': typeof SettingsGeneralRoute
87 '/settings/providers': typeof SettingsProvidersRoute
8188 '/settings/source-control': typeof SettingsSourceControlRoute
8289 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
8390 '/draft/$draftId': typeof ChatDraftDraftIdRoute
......@@ -88,6 +95,7 @@ export interface FileRoutesByTo {
8895 '/settings/archived': typeof SettingsArchivedRoute
8996 '/settings/connections': typeof SettingsConnectionsRoute
9097 '/settings/general': typeof SettingsGeneralRoute
98 '/settings/providers': typeof SettingsProvidersRoute
9199 '/settings/source-control': typeof SettingsSourceControlRoute
92100 '/': typeof ChatIndexRoute
93101 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
......@@ -101,6 +109,7 @@ export interface FileRoutesById {
101109 '/settings/archived': typeof SettingsArchivedRoute
102110 '/settings/connections': typeof SettingsConnectionsRoute
103111 '/settings/general': typeof SettingsGeneralRoute
112 '/settings/providers': typeof SettingsProvidersRoute
104113 '/settings/source-control': typeof SettingsSourceControlRoute
105114 '/_chat/': typeof ChatIndexRoute
106115 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
......@@ -115,6 +124,7 @@ export interface FileRouteTypes {
115124 | '/settings/archived'
116125 | '/settings/connections'
117126 | '/settings/general'
127 | '/settings/providers'
118128 | '/settings/source-control'
119129 | '/$environmentId/$threadId'
120130 | '/draft/$draftId'
......@@ -125,6 +135,7 @@ export interface FileRouteTypes {
125135 | '/settings/archived'
126136 | '/settings/connections'
127137 | '/settings/general'
138 | '/settings/providers'
128139 | '/settings/source-control'
129140 | '/'
130141 | '/$environmentId/$threadId'
......@@ -137,6 +148,7 @@ export interface FileRouteTypes {
137148 | '/settings/archived'
138149 | '/settings/connections'
139150 | '/settings/general'
151 | '/settings/providers'
140152 | '/settings/source-control'
141153 | '/_chat/'
142154 | '/_chat/$environmentId/$threadId'
......@@ -186,6 +198,13 @@ declare module '@tanstack/react-router' {
186198 preLoaderRoute: typeof SettingsSourceControlRouteImport
187199 parentRoute: typeof SettingsRoute
188200 }
201 '/settings/providers': {
202 id: '/settings/providers'
203 path: '/providers'
204 fullPath: '/settings/providers'
205 preLoaderRoute: typeof SettingsProvidersRouteImport
206 parentRoute: typeof SettingsRoute
207 }
189208 '/settings/general': {
190209 id: '/settings/general'
191210 path: '/general'
......@@ -242,6 +261,7 @@ interface SettingsRouteChildren {
242261 SettingsArchivedRoute: typeof SettingsArchivedRoute
243262 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
244263 SettingsGeneralRoute: typeof SettingsGeneralRoute
264 SettingsProvidersRoute: typeof SettingsProvidersRoute
245265 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
246266}
247267
......@@ -249,6 +269,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
249269 SettingsArchivedRoute: SettingsArchivedRoute,
250270 SettingsConnectionsRoute: SettingsConnectionsRoute,
251271 SettingsGeneralRoute: SettingsGeneralRoute,
272 SettingsProvidersRoute: SettingsProvidersRoute,
252273 SettingsSourceControlRoute: SettingsSourceControlRoute,
253274}
254275
apps/web/src/routes/__root.tsx+2
......@@ -14,6 +14,7 @@ import { APP_DISPLAY_NAME } from "../branding";
1414import { AppSidebarLayout } from "../components/AppSidebarLayout";
1515import { CommandPalette } from "../components/CommandPalette";
1616import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
17import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
1718import {
1819 SlowRpcAckToastCoordinator,
1920 WebSocketConnectionCoordinator,
......@@ -136,6 +137,7 @@ function RootRouteView() {
136137 <SshPasswordPromptDialog />
137138 <HostedStaticEnvironmentBootstrap />
138139 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
140 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
139141 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}
140142 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}
141143 {primaryEnvironmentAuthenticated ? (
apps/web/src/routes/settings.general.tsx+5-1
......@@ -2,6 +2,10 @@ import { createFileRoute } from "@tanstack/react-router";
22
33import { GeneralSettingsPanel } from "../components/settings/SettingsPanels";
44
5function SettingsGeneralRoute() {
6 return <GeneralSettingsPanel />;
7}
8
59export const Route = createFileRoute("/settings/general")({
6 component: GeneralSettingsPanel,
10 component: SettingsGeneralRoute,
711});
apps/web/src/routes/settings.providers.tsx created+11
......@@ -0,0 +1,11 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ProviderSettingsPanel } from "../components/settings/SettingsPanels";
4
5function SettingsProvidersRoute() {
6 return <ProviderSettingsPanel />;
7}
8
9export const Route = createFileRoute("/settings/providers")({
10 component: SettingsProvidersRoute,
11});
apps/web/src/rpc/wsRpcClient.ts+3
......@@ -119,6 +119,7 @@ export interface WsRpcClient {
119119 readonly refreshProviders: (
120120 input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>,
121121 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>;
122 readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>;
122123 readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;
123124 readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;
124125 readonly updateSettings: (
......@@ -234,6 +235,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
234235 getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
235236 refreshProviders: (input) =>
236237 transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})),
238 updateProvider: (input) =>
239 transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)),
237240 upsertKeybinding: (input) =>
238241 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
239242 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
packages/contracts/src/ipc.ts+2
......@@ -28,6 +28,7 @@ import type {
2828import type { ProviderInstanceId } from "./providerInstance.ts";
2929import type {
3030 ServerConfig,
31 ServerProviderUpdateInput,
3132 ServerProviderUpdatedPayload,
3233 ServerUpsertKeybindingResult,
3334} from "./server.ts";
......@@ -296,6 +297,7 @@ export interface LocalApi {
296297 refreshProviders: (input?: {
297298 readonly instanceId?: ProviderInstanceId;
298299 }) => Promise<ServerProviderUpdatedPayload>;
300 updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>;
299301 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
300302 getSettings: () => Promise<ServerSettings>;
301303 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
packages/contracts/src/rpc.ts+10
......@@ -71,6 +71,8 @@ import {
7171import {
7272 ServerConfigStreamEvent,
7373 ServerConfig,
74 ServerProviderUpdateError,
75 ServerProviderUpdateInput,
7476 ServerLifecycleStreamEvent,
7577 ServerProviderUpdatedPayload,
7678 ServerUpsertKeybindingInput,
......@@ -129,6 +131,7 @@ export const WS_METHODS = {
129131 // Server meta
130132 serverGetConfig: "server.getConfig",
131133 serverRefreshProviders: "server.refreshProviders",
134 serverUpdateProvider: "server.updateProvider",
132135 serverUpsertKeybinding: "server.upsertKeybinding",
133136 serverGetSettings: "server.getSettings",
134137 serverUpdateSettings: "server.updateSettings",
......@@ -172,6 +175,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv
172175 success: ServerProviderUpdatedPayload,
173176});
174177
178export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {
179 payload: ServerProviderUpdateInput,
180 success: ServerProviderUpdatedPayload,
181 error: ServerProviderUpdateError,
182});
183
175184export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
176185 payload: Schema.Struct({}),
177186 success: ServerSettings,
......@@ -415,6 +424,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,
415424export const WsRpcGroup = RpcGroup.make(
416425 WsServerGetConfigRpc,
417426 WsServerRefreshProvidersRpc,
427 WsServerUpdateProviderRpc,
418428 WsServerUpsertKeybindingRpc,
419429 WsServerGetSettingsRpc,
420430 WsServerUpdateSettingsRpc,
packages/contracts/src/server.test.ts+28
......@@ -23,6 +23,34 @@ describe("ServerProvider", () => {
2323
2424 expect(parsed.slashCommands).toEqual([]);
2525 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);
2654 });
2755
2856 it("decodes continuation group metadata", () => {
packages/contracts/src/server.ts+58
......@@ -109,6 +109,43 @@ export const ServerProviderContinuation = Schema.Struct({
109109});
110110export type ServerProviderContinuation = typeof ServerProviderContinuation.Type;
111111
112export const ServerProviderVersionAdvisoryStatus = Schema.Literals([
113 "unknown",
114 "current",
115 "behind_latest",
116]);
117export type ServerProviderVersionAdvisoryStatus = typeof ServerProviderVersionAdvisoryStatus.Type;
118
119export 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});
128export type ServerProviderVersionAdvisory = typeof ServerProviderVersionAdvisory.Type;
129
130export const ServerProviderUpdateStatus = Schema.Literals([
131 "idle",
132 "queued",
133 "running",
134 "succeeded",
135 "failed",
136 "unchanged",
137]);
138export type ServerProviderUpdateStatus = typeof ServerProviderUpdateStatus.Type;
139
140export 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});
147export type ServerProviderUpdateState = typeof ServerProviderUpdateState.Type;
148
112149export const ServerProvider = Schema.Struct({
113150 // Routing key for the configured instance this snapshot represents. This
114151 // is the only stable identity consumers may use for provider routing.
......@@ -142,6 +179,8 @@ export const ServerProvider = Schema.Struct({
142179 Schema.withDecodingDefault(Effect.succeed([])),
143180 ),
144181 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
182 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
183 updateState: Schema.optionalKey(ServerProviderUpdateState),
145184});
146185export type ServerProvider = typeof ServerProvider.Type;
147186
......@@ -294,3 +333,22 @@ export const ServerProviderUpdatedPayload = Schema.Struct({
294333 providers: ServerProviders,
295334});
296335export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type;
336
337export const ServerProviderUpdateInput = Schema.Struct({
338 provider: ProviderDriverKind,
339 instanceId: Schema.optionalKey(ProviderInstanceId),
340});
341export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type;
342
343export 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({
3232 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3333 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
3434 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
35 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
36 Schema.withDecodingDefault(Effect.succeed([])),
37 ),
3538 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3639 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
3740 // 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
2424const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
2525const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
2626const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
27
2827const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
2928 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
3029);
packages/effect-acp/src/protocol.test.ts-1
......@@ -41,7 +41,6 @@ const RequestPermissionRequest = jsonRpcRequest(
4141const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);
4242const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
4343const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
44
4544const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
4645 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
4746);
packages/shared/src/shell.test.ts+12
......@@ -10,6 +10,7 @@ import {
1010 readEnvironmentFromWindowsShell,
1111 readPathFromLaunchctl,
1212 readPathFromLoginShell,
13 resolveCommandPath,
1314 resolveKnownWindowsCliDirs,
1415 resolveWindowsEnvironment,
1516} from "./shell.ts";
......@@ -332,6 +333,17 @@ describe("isCommandAvailable", () => {
332333 });
333334});
334335
336describe("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
335347describe("resolveWindowsEnvironment", () => {
336348 it("returns the baseline no-profile PATH patch when node is already available", () => {
337349 const readEnvironment = vi.fn(
packages/shared/src/shell.ts+20-9
......@@ -376,23 +376,26 @@ function isExecutableFile(
376376 }
377377}
378378
379export function isCommandAvailable(
379export function resolveCommandPath(
380380 command: string,
381381 options: CommandAvailabilityOptions = {},
382): boolean {
382): string | null {
383383 const platform = options.platform ?? process.platform;
384384 const env = options.env ?? process.env;
385385 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
386386 const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
387387
388388 if (command.includes("/") || command.includes("\\")) {
389 return commandCandidates.some((candidate) =>
390 isExecutableFile(candidate, platform, windowsPathExtensions),
391 );
389 for (const candidate of commandCandidates) {
390 if (isExecutableFile(candidate, platform, windowsPathExtensions)) {
391 return candidate;
392 }
393 }
394 return null;
392395 }
393396
394397 const pathValue = resolvePathEnvironmentVariable(env);
395 if (pathValue.length === 0) return false;
398 if (pathValue.length === 0) return null;
396399 const pathEntries = pathValue
397400 .split(pathDelimiterForPlatform(platform))
398401 .map((entry) => stripWrappingQuotes(entry.trim()))
......@@ -400,12 +403,20 @@ export function isCommandAvailable(
400403
401404 for (const pathEntry of pathEntries) {
402405 for (const candidate of commandCandidates) {
403 if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
404 return true;
406 const candidatePath = join(pathEntry, candidate);
407 if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
408 return candidatePath;
405409 }
406410 }
407411 }
408 return false;
412 return null;
413}
414
415export function isCommandAvailable(
416 command: string,
417 options: CommandAvailabilityOptions = {},
418): boolean {
419 return resolveCommandPath(command, options) !== null;
409420}
410421
411422export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {