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 = {...@@ -52,6 +52,7 @@ const clientSettings: ClientSettings = {
52 autoOpenPlanSidebar: false,52 autoOpenPlanSidebar: false,
53 confirmThreadArchive: true,53 confirmThreadArchive: true,
54 confirmThreadDelete: false,54 confirmThreadDelete: false,
55 dismissedProviderUpdateNotificationKeys: [],
55 diffIgnoreWhitespace: true,56 diffIgnoreWhitespace: true,
56 diffWordWrap: true,57 diffWordWrap: true,
57 favorites: [],58 favorites: [],
apps/server/src/provider/Drivers/ClaudeDriver.ts+40
...@@ -14,6 +14,7 @@...@@ -14,6 +14,7 @@
14 */14 */
15import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";15import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
16import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";16import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { HttpClient } from "effect/unstable/http";
17import { ChildProcessSpawner } from "effect/unstable/process";18import { ChildProcessSpawner } from "effect/unstable/process";
1819
19import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";20import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
...@@ -34,15 +35,43 @@ import {...@@ -34,15 +35,43 @@ import {
34} from "../ProviderDriver.ts";35} from "../ProviderDriver.ts";
35import type { ServerProviderDraft } from "../providerSnapshot.ts";36import type { ServerProviderDraft } from "../providerSnapshot.ts";
36import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";37import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38import {
39 enrichProviderSnapshotWithVersionAdvisory,
40 makePackageManagedProviderMaintenanceResolver,
41 normalizeCommandPath,
42 resolveProviderMaintenanceCapabilitiesEffect,
43} from "../providerMaintenance.ts";
37import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";44import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
3845
39const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");46const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
40const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);47const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
41const CAPABILITIES_PROBE_TTL = Duration.minutes(5);48const 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
43export type ClaudeDriverEnv =71export type ClaudeDriverEnv =
44 | ChildProcessSpawner.ChildProcessSpawner72 | ChildProcessSpawner.ChildProcessSpawner
45 | FileSystem.FileSystem73 | FileSystem.FileSystem
74 | HttpClient.HttpClient
46 | Path.Path75 | Path.Path
47 | ProviderEventLoggers76 | ProviderEventLoggers
48 | ServerConfig;77 | ServerConfig;
...@@ -75,6 +104,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -75,6 +104,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
75 Effect.gen(function* () {104 Effect.gen(function* () {
76 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;105 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
77 const path = yield* Path.Path;106 const path = yield* Path.Path;
107 const httpClient = yield* HttpClient.HttpClient;
78 const eventLoggers = yield* ProviderEventLoggers;108 const eventLoggers = yield* ProviderEventLoggers;
79 const processEnv = mergeProviderInstanceEnvironment(environment);109 const processEnv = mergeProviderInstanceEnvironment(environment);
80 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({110 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
...@@ -82,6 +112,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -82,6 +112,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
82 instanceId,112 instanceId,
83 });113 });
84 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;114 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;
115 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
116 binaryPath: effectiveConfig.binaryPath,
117 env: processEnv,
118 });
85 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig);119 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig);
86 const stampIdentity = withInstanceIdentity({120 const stampIdentity = withInstanceIdentity({
87 instanceId,121 instanceId,
...@@ -121,11 +155,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -121,11 +155,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
121 );155 );
122156
123 const snapshot = yield* makeManagedServerProvider<ClaudeSettings>({157 const snapshot = yield* makeManagedServerProvider<ClaudeSettings>({
158 maintenanceCapabilities,
124 getSettings: Effect.succeed(effectiveConfig),159 getSettings: Effect.succeed(effectiveConfig),
125 streamSettings: Stream.never,160 streamSettings: Stream.never,
126 haveSettingsChanged: () => false,161 haveSettingsChanged: () => false,
127 initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)),162 initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)),
128 checkProvider,163 checkProvider,
164 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
165 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
166 Effect.provideService(HttpClient.HttpClient, httpClient),
167 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
168 ),
129 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,169 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
130 }).pipe(170 }).pipe(
131 Effect.mapError(171 Effect.mapError(
apps/server/src/provider/Drivers/CodexDriver.ts+24
...@@ -23,6 +23,7 @@...@@ -23,6 +23,7 @@
23 */23 */
24import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";24import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
25import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";25import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
26import { HttpClient } from "effect/unstable/http";
26import { ChildProcessSpawner } from "effect/unstable/process";27import { ChildProcessSpawner } from "effect/unstable/process";
2728
28import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";29import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
...@@ -35,6 +36,11 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";...@@ -35,6 +36,11 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
35import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";36import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
36import type { ServerProviderDraft } from "../providerSnapshot.ts";37import type { ServerProviderDraft } from "../providerSnapshot.ts";
37import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";38import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
39import {
40 enrichProviderSnapshotWithVersionAdvisory,
41 makePackageManagedProviderMaintenanceResolver,
42 resolveProviderMaintenanceCapabilitiesEffect,
43} from "../providerMaintenance.ts";
38import {44import {
39 codexContinuationIdentity,45 codexContinuationIdentity,
40 materializeCodexShadowHome,46 materializeCodexShadowHome,
...@@ -43,6 +49,12 @@ import {...@@ -43,6 +49,12 @@ import {
4349
44const DRIVER_KIND = ProviderDriverKind.make("codex");50const DRIVER_KIND = ProviderDriverKind.make("codex");
45const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);51const 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
47/**59/**
48 * Services the driver needs to materialize an instance. Surfaced as the60 * Services the driver needs to materialize an instance. Surfaced as the
...@@ -52,6 +64,7 @@ const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);...@@ -52,6 +64,7 @@ const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
52export type CodexDriverEnv =64export type CodexDriverEnv =
53 | ChildProcessSpawner.ChildProcessSpawner65 | ChildProcessSpawner.ChildProcessSpawner
54 | FileSystem.FileSystem66 | FileSystem.FileSystem
67 | HttpClient.HttpClient
55 | Path.Path68 | Path.Path
56 | ProviderEventLoggers69 | ProviderEventLoggers
57 | ServerConfig;70 | ServerConfig;
...@@ -89,6 +102,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -89,6 +102,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
89 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>102 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
90 Effect.gen(function* () {103 Effect.gen(function* () {
91 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;104 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
105 const httpClient = yield* HttpClient.HttpClient;
92 const eventLoggers = yield* ProviderEventLoggers;106 const eventLoggers = yield* ProviderEventLoggers;
93 const processEnv = mergeProviderInstanceEnvironment(environment);107 const processEnv = mergeProviderInstanceEnvironment(environment);
94 const homeLayout = yield* resolveCodexHomeLayout(config);108 const homeLayout = yield* resolveCodexHomeLayout(config);
...@@ -115,6 +129,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -115,6 +129,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
115 enabled,129 enabled,
116 homePath: homeLayout.effectiveHomePath ?? "",130 homePath: homeLayout.effectiveHomePath ?? "",
117 } satisfies CodexSettings;131 } satisfies CodexSettings;
132 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
133 binaryPath: effectiveConfig.binaryPath,
134 env: processEnv,
135 });
118136
119 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error137 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error
120 // channels at construction time — their failure modes are all on the138 // channels at construction time — their failure modes are all on the
...@@ -138,11 +156,17 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -138,11 +156,17 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
138 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),156 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
139 );157 );
140 const snapshot = yield* makeManagedServerProvider<CodexSettings>({158 const snapshot = yield* makeManagedServerProvider<CodexSettings>({
159 maintenanceCapabilities,
141 getSettings: Effect.succeed(effectiveConfig),160 getSettings: Effect.succeed(effectiveConfig),
142 streamSettings: Stream.never,161 streamSettings: Stream.never,
143 haveSettingsChanged: () => false,162 haveSettingsChanged: () => false,
144 initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)),163 initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)),
145 checkProvider,164 checkProvider,
165 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
166 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
167 Effect.provideService(HttpClient.HttpClient, httpClient),
168 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
169 ),
146 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,170 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
147 }).pipe(171 }).pipe(
148 Effect.mapError(172 Effect.mapError(
apps/server/src/provider/Drivers/CursorDriver.ts+24
...@@ -14,6 +14,7 @@...@@ -14,6 +14,7 @@
14 */14 */
15import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";15import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
16import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";16import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { HttpClient } from "effect/unstable/http";
17import { ChildProcessSpawner } from "effect/unstable/process";18import { ChildProcessSpawner } from "effect/unstable/process";
1819
19import { ServerConfig } from "../../config.ts";20import { ServerConfig } from "../../config.ts";
...@@ -34,13 +35,28 @@ import {...@@ -34,13 +35,28 @@ import {
34} from "../ProviderDriver.ts";35} from "../ProviderDriver.ts";
35import type { ServerProviderDraft } from "../providerSnapshot.ts";36import type { ServerProviderDraft } from "../providerSnapshot.ts";
36import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";37import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38import {
39 makeProviderMaintenanceCapabilities,
40 makeStaticProviderMaintenanceResolver,
41 resolveProviderMaintenanceCapabilitiesEffect,
42} from "../providerMaintenance.ts";
3743
38const DRIVER_KIND = ProviderDriverKind.make("cursor");44const DRIVER_KIND = ProviderDriverKind.make("cursor");
39const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);45const 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
41export type CursorDriverEnv =56export type CursorDriverEnv =
42 | ChildProcessSpawner.ChildProcessSpawner57 | ChildProcessSpawner.ChildProcessSpawner
43 | FileSystem.FileSystem58 | FileSystem.FileSystem
59 | HttpClient.HttpClient
44 | Path.Path60 | Path.Path
45 | ProviderEventLoggers61 | ProviderEventLoggers
46 | ServerConfig;62 | ServerConfig;
...@@ -74,6 +90,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -74,6 +90,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
74 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;90 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
75 const fileSystem = yield* FileSystem.FileSystem;91 const fileSystem = yield* FileSystem.FileSystem;
76 const path = yield* Path.Path;92 const path = yield* Path.Path;
93 const httpClient = yield* HttpClient.HttpClient;
77 const eventLoggers = yield* ProviderEventLoggers;94 const eventLoggers = yield* ProviderEventLoggers;
78 const processEnv = mergeProviderInstanceEnvironment(environment);95 const processEnv = mergeProviderInstanceEnvironment(environment);
79 const continuationIdentity = defaultProviderContinuationIdentity({96 const continuationIdentity = defaultProviderContinuationIdentity({
...@@ -87,6 +104,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -87,6 +104,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
87 continuationGroupKey: continuationIdentity.continuationKey,104 continuationGroupKey: continuationIdentity.continuationKey,
88 });105 });
89 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;106 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;
107 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
108 binaryPath: effectiveConfig.binaryPath,
109 env: processEnv,
110 });
90111
91 const adapter = yield* makeCursorAdapter(effectiveConfig, {112 const adapter = yield* makeCursorAdapter(effectiveConfig, {
92 environment: processEnv,113 environment: processEnv,
...@@ -103,6 +124,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -103,6 +124,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
103 );124 );
104125
105 const snapshot = yield* makeManagedServerProvider<CursorSettings>({126 const snapshot = yield* makeManagedServerProvider<CursorSettings>({
127 maintenanceCapabilities,
106 getSettings: Effect.succeed(effectiveConfig),128 getSettings: Effect.succeed(effectiveConfig),
107 streamSettings: Stream.never,129 streamSettings: Stream.never,
108 haveSettingsChanged: () => false,130 haveSettingsChanged: () => false,
...@@ -117,8 +139,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -117,8 +139,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
117 settings,139 settings,
118 environment: processEnv,140 environment: processEnv,
119 snapshot: currentSnapshot,141 snapshot: currentSnapshot,
142 maintenanceCapabilities,
120 publishSnapshot,143 publishSnapshot,
121 stampIdentity,144 stampIdentity,
145 httpClient,
122 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),146 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
123 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,147 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
124 }).pipe(148 }).pipe(
apps/server/src/provider/Drivers/OpenCodeDriver.ts+39
...@@ -14,6 +14,7 @@...@@ -14,6 +14,7 @@
14 */14 */
15import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";15import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
16import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";16import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17import { HttpClient } from "effect/unstable/http";
17import { ChildProcessSpawner } from "effect/unstable/process";18import { ChildProcessSpawner } from "effect/unstable/process";
1819
19import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";20import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";
...@@ -34,13 +35,40 @@ import {...@@ -34,13 +35,40 @@ import {
34} from "../ProviderDriver.ts";35} from "../ProviderDriver.ts";
35import type { ServerProviderDraft } from "../providerSnapshot.ts";36import type { ServerProviderDraft } from "../providerSnapshot.ts";
36import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";37import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38import {
39 enrichProviderSnapshotWithVersionAdvisory,
40 makePackageManagedProviderMaintenanceResolver,
41 normalizeCommandPath,
42 resolveProviderMaintenanceCapabilitiesEffect,
43} from "../providerMaintenance.ts";
3744
38const DRIVER_KIND = ProviderDriverKind.make("opencode");45const DRIVER_KIND = ProviderDriverKind.make("opencode");
39const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);46const 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
41export type OpenCodeDriverEnv =68export type OpenCodeDriverEnv =
42 | ChildProcessSpawner.ChildProcessSpawner69 | ChildProcessSpawner.ChildProcessSpawner
43 | FileSystem.FileSystem70 | FileSystem.FileSystem
71 | HttpClient.HttpClient
44 | OpenCodeRuntime72 | OpenCodeRuntime
45 | Path.Path73 | Path.Path
46 | ProviderEventLoggers74 | ProviderEventLoggers
...@@ -74,6 +102,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>...@@ -74,6 +102,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
74 Effect.gen(function* () {102 Effect.gen(function* () {
75 const openCodeRuntime = yield* OpenCodeRuntime;103 const openCodeRuntime = yield* OpenCodeRuntime;
76 const serverConfig = yield* ServerConfig;104 const serverConfig = yield* ServerConfig;
105 const httpClient = yield* HttpClient.HttpClient;
77 const eventLoggers = yield* ProviderEventLoggers;106 const eventLoggers = yield* ProviderEventLoggers;
78 const processEnv = mergeProviderInstanceEnvironment(environment);107 const processEnv = mergeProviderInstanceEnvironment(environment);
79 const continuationIdentity = defaultProviderContinuationIdentity({108 const continuationIdentity = defaultProviderContinuationIdentity({
...@@ -87,6 +116,10 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>...@@ -87,6 +116,10 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
87 continuationGroupKey: continuationIdentity.continuationKey,116 continuationGroupKey: continuationIdentity.continuationKey,
88 });117 });
89 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;118 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;
119 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
120 binaryPath: effectiveConfig.binaryPath,
121 env: processEnv,
122 });
90123
91 const adapter = yield* makeOpenCodeAdapter(effectiveConfig, {124 const adapter = yield* makeOpenCodeAdapter(effectiveConfig, {
92 instanceId,125 instanceId,
...@@ -102,11 +135,17 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>...@@ -102,11 +135,17 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
102 ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime));135 ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime));
103136
104 const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({137 const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({
138 maintenanceCapabilities,
105 getSettings: Effect.succeed(effectiveConfig),139 getSettings: Effect.succeed(effectiveConfig),
106 streamSettings: Stream.never,140 streamSettings: Stream.never,
107 haveSettingsChanged: () => false,141 haveSettingsChanged: () => false,
108 initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)),142 initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)),
109 checkProvider,143 checkProvider,
144 enrichSnapshot: ({ snapshot, publishSnapshot }) =>
145 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
146 Effect.provideService(HttpClient.HttpClient, httpClient),
147 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
148 ),
110 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,149 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
111 }).pipe(150 }).pipe(
112 Effect.mapError(151 Effect.mapError(
apps/server/src/provider/Layers/CodexProvider.ts-1
...@@ -15,7 +15,6 @@ import type {...@@ -15,7 +15,6 @@ import type {
15import { ServerSettingsError } from "@t3tools/contracts";15import { ServerSettingsError } from "@t3tools/contracts";
1616
17import { createModelCapabilities } from "@t3tools/shared/model";17import { createModelCapabilities } from "@t3tools/shared/model";
18
19import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts";18import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts";
20import { expandHomePath } from "../../pathExpansion.ts";19import { expandHomePath } from "../../pathExpansion.ts";
21import { scopedSafeTeardown } from "./scopedSafeTeardown.ts";20import { scopedSafeTeardown } from "./scopedSafeTeardown.ts";
apps/server/src/provider/Layers/CursorProvider.ts+55-25
...@@ -12,6 +12,7 @@ import type {...@@ -12,6 +12,7 @@ import type {
12import { ProviderDriverKind } from "@t3tools/contracts";12import { ProviderDriverKind } from "@t3tools/contracts";
13import type * as EffectAcpSchema from "effect-acp/schema";13import type * as EffectAcpSchema from "effect-acp/schema";
14import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect";14import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect";
15import { HttpClient } from "effect/unstable/http";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
16import {17import {
17 createModelCapabilities,18 createModelCapabilities,
...@@ -29,6 +30,10 @@ import {...@@ -29,6 +30,10 @@ import {
29 type CommandResult,30 type CommandResult,
30 type ServerProviderDraft,31 type ServerProviderDraft,
31} from "../providerSnapshot.ts";32} from "../providerSnapshot.ts";
33import {
34 enrichProviderSnapshotWithVersionAdvisory,
35 type ProviderMaintenanceCapabilities,
36} from "../providerMaintenance.ts";
32import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";37import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
3338
34const PROVIDER = ProviderDriverKind.make("cursor");39const PROVIDER = ProviderDriverKind.make("cursor");
...@@ -1216,42 +1221,67 @@ export const enrichCursorSnapshot = (input: {...@@ -1216,42 +1221,67 @@ export const enrichCursorSnapshot = (input: {
1216 readonly settings: CursorSettings;1221 readonly settings: CursorSettings;
1217 readonly environment?: NodeJS.ProcessEnv;1222 readonly environment?: NodeJS.ProcessEnv;
1218 readonly snapshot: ServerProvider;1223 readonly snapshot: ServerProvider;
1224 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
1219 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;1225 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
1220 readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider;1226 readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider;
1227 readonly httpClient: HttpClient.HttpClient;
1221}): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {1228}): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {
1222 const { settings, snapshot, publishSnapshot } = input;1229 const { settings, snapshot, publishSnapshot } = input;
1223 const stampIdentity = input.stampIdentity ?? ((value) => value);1230 const stampIdentity = input.stampIdentity ?? ((value) => value);
12241231
1225 if (1232 const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory(
1226 !settings.enabled ||1233 snapshot,
1227 snapshot.auth.status === "unauthenticated" ||1234 input.maintenanceCapabilities,
1228 !hasUncapturedCursorModels(snapshot)1235 ).pipe(
1229 ) {1236 Effect.provideService(HttpClient.HttpClient, input.httpClient),
1230 return Effect.void;1237 Effect.flatMap((enrichedSnapshot) =>
1231 }1238 publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)),
1239 ),
1240 Effect.catchCause((cause) =>
1241 Effect.logWarning("Cursor version advisory enrichment failed", {
1242 cause: Cause.pretty(cause),
1243 }).pipe(Effect.as(snapshot)),
1244 ),
1245 );
12321246
1233 return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe(1247 return enrichVersionAdvisory.pipe(
1234 Effect.flatMap((discoveredModels) => {1248 Effect.flatMap((baseSnapshot) => {
1235 if (discoveredModels.length === 0) {1249 if (
1250 !settings.enabled ||
1251 baseSnapshot.auth.status === "unauthenticated" ||
1252 !hasUncapturedCursorModels(baseSnapshot)
1253 ) {
1236 return Effect.void;1254 return Effect.void;
1237 }1255 }
1238 return publishSnapshot(1256
1239 stampIdentity({1257 return discoverCursorModelCapabilitiesViaAcp(
1240 ...snapshot,1258 settings,
1241 models: providerModelsFromSettings(1259 baseSnapshot.models,
1242 discoveredModels,1260 input.environment,
1243 PROVIDER,1261 ).pipe(
1244 settings.customModels,1262 Effect.flatMap((discoveredModels) => {
1245 EMPTY_CAPABILITIES,1263 if (discoveredModels.length === 0) {
1246 ),1264 return Effect.void;
1265 }
1266 return publishSnapshot(
1267 stampIdentity({
1268 ...baseSnapshot,
1269 models: providerModelsFromSettings(
1270 discoveredModels,
1271 PROVIDER,
1272 settings.customModels,
1273 EMPTY_CAPABILITIES,
1274 ),
1275 }),
1276 );
1247 }),1277 }),
1278 Effect.catchCause((cause) =>
1279 Effect.logWarning("Cursor ACP background capability enrichment failed", {
1280 models: baseSnapshot.models.map((model) => model.slug),
1281 cause: Cause.pretty(cause),
1282 }).pipe(Effect.asVoid),
1283 ),
1248 );1284 );
1249 }),1285 }),
1250 Effect.catchCause((cause) =>
1251 Effect.logWarning("Cursor ACP background capability enrichment failed", {
1252 models: snapshot.models.map((model) => model.slug),
1253 cause: Cause.pretty(cause),
1254 }).pipe(Effect.asVoid),
1255 ),
1256 );1286 );
1257};1287};
apps/server/src/provider/Layers/OpenCodeProvider.ts-1
...@@ -7,7 +7,6 @@ import {...@@ -7,7 +7,6 @@ import {
7import { Cause, Data, Effect } from "effect";7import { Cause, Data, Effect } from "effect";
88
9import { createModelCapabilities } from "@t3tools/shared/model";9import { createModelCapabilities } from "@t3tools/shared/model";
10
11import {10import {
12 buildServerProvider,11 buildServerProvider,
13 nonEmptyTrimmed,12 nonEmptyTrimmed,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+5
...@@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";...@@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
14import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";14import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
15import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";15import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
16import type { ProviderInstance } from "../ProviderDriver.ts";16import type { ProviderInstance } from "../ProviderDriver.ts";
17import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
17import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";18import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
18import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";19import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
19import * as NodeServices from "@effect/platform-node/NodeServices";20import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -111,6 +112,10 @@ const makeFakeInstance = (...@@ -111,6 +112,10 @@ const makeFakeInstance = (
111 displayName: undefined,112 displayName: undefined,
112 enabled: true,113 enabled: true,
113 snapshot: {114 snapshot: {
115 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
116 provider: driverKind,
117 packageName: null,
118 }),
114 getSnapshot: Effect.succeed({} as unknown as ServerProvider),119 getSnapshot: Effect.succeed({} as unknown as ServerProvider),
115 refresh: Effect.succeed({} as unknown as ServerProvider),120 refresh: Effect.succeed({} as unknown as ServerProvider),
116 streamChanges: Stream.empty,121 streamChanges: Stream.empty,
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+10
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 ProviderInstanceId,34 ProviderInstanceId,
35} from "@t3tools/contracts";35} from "@t3tools/contracts";
36import { Effect, Layer } from "effect";36import { Effect, Layer } from "effect";
37import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3738
38import { ServerConfig } from "../../config.ts";39import { ServerConfig } from "../../config.ts";
39import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";40import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
...@@ -44,6 +45,13 @@ import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";...@@ -44,6 +45,13 @@ import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
44import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";45import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
45import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";46import { 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
47const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({55const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
48 enabled: false,56 enabled: false,
49 binaryPath: "codex",57 binaryPath: "codex",
...@@ -89,6 +97,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -89,6 +97,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
89 prefix: "provider-instance-registry-test",97 prefix: "provider-instance-registry-test",
90 }).pipe(98 }).pipe(
91 Layer.provideMerge(NodeServices.layer),99 Layer.provideMerge(NodeServices.layer),
100 Layer.provideMerge(TestHttpClientLive),
92 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),101 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
93 );102 );
94103
...@@ -225,6 +234,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -225,6 +234,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
225 prefix: "provider-instance-registry-all-drivers-test",234 prefix: "provider-instance-registry-all-drivers-test",
226 }).pipe(235 }).pipe(
227 Layer.provideMerge(infraLayer),236 Layer.provideMerge(infraLayer),
237 Layer.provideMerge(TestHttpClientLive),
228 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),238 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
229 );239 );
230240
apps/server/src/provider/Layers/ProviderRegistry.test.ts+201-1
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 type ServerSettings as ContractServerSettings,14 type ServerSettings as ContractServerSettings,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import * as PlatformError from "effect/PlatformError";16import * as PlatformError from "effect/PlatformError";
17import { HttpClient, HttpClientResponse } from "effect/unstable/http";
17import { ChildProcessSpawner } from "effect/unstable/process";18import { ChildProcessSpawner } from "effect/unstable/process";
18import { deepMerge } from "@t3tools/shared/Struct";19import { deepMerge } from "@t3tools/shared/Struct";
19import { createModelCapabilities } from "@t3tools/shared/model";20import { createModelCapabilities } from "@t3tools/shared/model";
...@@ -26,13 +27,17 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr...@@ -26,13 +27,17 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr
26import {27import {
27 haveProvidersChanged,28 haveProvidersChanged,
28 mergeProviderSnapshot,29 mergeProviderSnapshot,
30 mergeProviderSnapshots,
29 ProviderRegistryLive,31 ProviderRegistryLive,
32 selectProvidersByKind,
30} from "./ProviderRegistry.ts";33} from "./ProviderRegistry.ts";
31import { ServerConfig } from "../../config.ts";34import { ServerConfig } from "../../config.ts";
32import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";35import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
36import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";
33import type { ProviderInstance } from "../ProviderDriver.ts";37import type { ProviderInstance } from "../ProviderDriver.ts";
34import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";38import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
35import { ProviderRegistry } from "../Services/ProviderRegistry.ts";39import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
40import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
3641
37const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});42const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
38const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});43const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
...@@ -46,6 +51,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1";...@@ -46,6 +51,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1";
4651
47const encoder = new TextEncoder();52const 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
49function selectDescriptor(61function selectDescriptor(
50 id: string,62 id: string,
51 label: string,63 label: string,
...@@ -246,7 +258,7 @@ function makeMutableServerSettingsService(...@@ -246,7 +258,7 @@ function makeMutableServerSettingsService(
246 });258 });
247}259}
248260
249it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(261it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), TestHttpClientLive))(
250 "ProviderRegistry",262 "ProviderRegistry",
251 (it) => {263 (it) => {
252 describe("checkCodexProviderStatus", () => {264 describe("checkCodexProviderStatus", () => {
...@@ -500,6 +512,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -500,6 +512,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
500 ]);512 ]);
501 });513 });
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
503 it.effect("returns the cached provider list when a manual refresh fails", () =>691 it.effect("returns the cached provider list when a manual refresh fails", () =>
504 Effect.gen(function* () {692 Effect.gen(function* () {
505 const codexDriver = ProviderDriverKind.make("codex");693 const codexDriver = ProviderDriverKind.make("codex");
...@@ -527,6 +715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -527,6 +715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
527 displayName: undefined,715 displayName: undefined,
528 enabled: true,716 enabled: true,
529 snapshot: {717 snapshot: {
718 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
719 provider: codexDriver,
720 packageName: null,
721 }),
530 getSnapshot: Effect.succeed(cachedProvider),722 getSnapshot: Effect.succeed(cachedProvider),
531 refresh: Effect.die(new Error("simulated refresh failure")),723 refresh: Effect.die(new Error("simulated refresh failure")),
532 streamChanges: Stream.empty,724 streamChanges: Stream.empty,
...@@ -612,6 +804,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -612,6 +804,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
612 displayName: undefined,804 displayName: undefined,
613 enabled: true,805 enabled: true,
614 snapshot: {806 snapshot: {
807 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
808 provider: provider.driver,
809 packageName: null,
810 }),
615 getSnapshot: Effect.succeed(provider),811 getSnapshot: Effect.succeed(provider),
616 refresh: Effect.succeed(provider),812 refresh: Effect.succeed(provider),
617 streamChanges: Stream.empty,813 streamChanges: Stream.empty,
...@@ -748,6 +944,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -748,6 +944,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
748 prefix: "t3-provider-registry-",944 prefix: "t3-provider-registry-",
749 }),945 }),
750 ),946 ),
947 Layer.provideMerge(TestHttpClientLive),
751 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),948 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
752 Layer.provideMerge(OpenCodeRuntimeLive),949 Layer.provideMerge(OpenCodeRuntimeLive),
753 // NO spawner mock — `ChildProcessSpawner` is supplied by the950 // NO spawner mock — `ChildProcessSpawner` is supplied by the
...@@ -832,6 +1029,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -832,6 +1029,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
832 prefix: "t3-provider-registry-",1029 prefix: "t3-provider-registry-",
833 }),1030 }),
834 ),1031 ),
1032 Layer.provideMerge(TestHttpClientLive),
835 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),1033 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
836 Layer.provideMerge(OpenCodeRuntimeLive),1034 Layer.provideMerge(OpenCodeRuntimeLive),
837 // `it.live` does not inherit layers from the outer `it.layer`1035 // `it.live` does not inherit layers from the outer `it.layer`
...@@ -936,6 +1134,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -936,6 +1134,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
936 prefix: "t3-provider-registry-",1134 prefix: "t3-provider-registry-",
937 }),1135 }),
938 ),1136 ),
1137 Layer.provideMerge(TestHttpClientLive),
939 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),1138 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
940 Layer.provideMerge(OpenCodeRuntimeLive),1139 Layer.provideMerge(OpenCodeRuntimeLive),
941 Layer.provideMerge(NodeServices.layer),1140 Layer.provideMerge(NodeServices.layer),
...@@ -986,6 +1185,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -986,6 +1185,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
986 prefix: "t3-provider-registry-",1185 prefix: "t3-provider-registry-",
987 }),1186 }),
988 ),1187 ),
1188 Layer.provideMerge(TestHttpClientLive),
989 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),1189 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
990 Layer.provideMerge(OpenCodeRuntimeLive),1190 Layer.provideMerge(OpenCodeRuntimeLive),
991 Layer.provideMerge(1191 Layer.provideMerge(
apps/server/src/provider/Layers/ProviderRegistry.ts+128-4
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 ProviderDriverKind,27 ProviderDriverKind,
28 type ProviderInstanceId,28 type ProviderInstanceId,
29 type ServerProvider,29 type ServerProvider,
30 type ServerProviderUpdateState,
30} from "@t3tools/contracts";31} from "@t3tools/contracts";
31import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect";32import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect";
32import * as Semaphore from "effect/Semaphore";33import * as Semaphore from "effect/Semaphore";
...@@ -43,6 +44,7 @@ import {...@@ -43,6 +44,7 @@ import {
43 writeProviderStatusCache,44 writeProviderStatusCache,
44} from "../providerStatusCache.ts";45} from "../providerStatusCache.ts";
45import type { ProviderInstance } from "../ProviderDriver.ts";46import type { ProviderInstance } from "../ProviderDriver.ts";
47import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
46import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";48import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
4749
48const loadProviders = (50const loadProviders = (
...@@ -59,6 +61,12 @@ const loadProviders = (...@@ -59,6 +61,12 @@ const loadProviders = (
59 },61 },
60 );62 );
6163
64const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) =>
65 makeManualOnlyProviderMaintenanceCapabilities({
66 provider,
67 packageName: null,
68 });
69
62const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>70const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
63 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;71 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
6472
...@@ -96,6 +104,30 @@ export const mergeProviderSnapshot = (...@@ -96,6 +104,30 @@ export const mergeProviderSnapshot = (
96 models: mergeProviderModels(previousProvider.models, nextProvider.models),104 models: mergeProviderModels(previousProvider.models, nextProvider.models),
97 };105 };
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
99export const haveProvidersChanged = (131export const haveProvidersChanged = (
100 previousProviders: ReadonlyArray<ServerProvider>,132 previousProviders: ReadonlyArray<ServerProvider>,
101 nextProviders: ReadonlyArray<ServerProvider>,133 nextProviders: ReadonlyArray<ServerProvider>,
...@@ -227,6 +259,9 @@ export const ProviderRegistryLive = Layer.effect(...@@ -227,6 +259,9 @@ export const ProviderRegistryLive = Layer.effect(
227 ),259 ),
228 );260 );
229 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders);261 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders);
262 const maintenanceActionStatesRef = yield* Ref.make<
263 ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }>
264 >(new Map());
230265
231 // Live-source registry — the dynamic counterpart to the boot-time266 // Live-source registry — the dynamic counterpart to the boot-time
232 // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance`267 // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance`
...@@ -264,6 +299,21 @@ export const ProviderRegistryLive = Layer.effect(...@@ -264,6 +299,21 @@ export const ProviderRegistryLive = Layer.effect(
264 );299 );
265 });300 });
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
267 const upsertProviders = Effect.fn("upsertProviders")(function* (317 const upsertProviders = Effect.fn("upsertProviders")(function* (
268 nextProviders: ReadonlyArray<ServerProvider>,318 nextProviders: ReadonlyArray<ServerProvider>,
269 options?: {319 options?: {
...@@ -272,15 +322,24 @@ export const ProviderRegistryLive = Layer.effect(...@@ -272,15 +322,24 @@ export const ProviderRegistryLive = Layer.effect(
272 readonly replace?: boolean;322 readonly replace?: boolean;
273 },323 },
274 ) {324 ) {
275 const [previousProviders, providers] = yield* Ref.modify(325 const nextProvidersWithUpdateState = yield* Effect.forEach(
326 nextProviders,
327 applyProviderUpdateState,
328 {
329 concurrency: "unbounded",
330 },
331 );
332 const [previousProviders, providers, providersToPersist] = yield* Ref.modify(
276 providersRef,333 providersRef,
277 (previousProviders) => {334 (previousProviders) => {
278 const mergedProviders = new Map(335 const mergedProviders = new Map(
279 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),336 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
280 );337 );
338 const updatedKeys = new Set<ProviderInstanceId>();
281339
282 for (const provider of nextProviders) {340 for (const provider of nextProvidersWithUpdateState) {
283 const key = snapshotInstanceKey(provider);341 const key = snapshotInstanceKey(provider);
342 updatedKeys.add(key);
284 mergedProviders.set(343 mergedProviders.set(
285 key,344 key,
286 options?.replace === true345 options?.replace === true
...@@ -290,13 +349,16 @@ export const ProviderRegistryLive = Layer.effect(...@@ -290,13 +349,16 @@ export const ProviderRegistryLive = Layer.effect(
290 }349 }
291350
292 const providers = orderProviderSnapshots([...mergedProviders.values()]);351 const providers = orderProviderSnapshots([...mergedProviders.values()]);
293 return [[previousProviders, providers] as const, providers];352 const providersToPersist = providers.filter((provider) =>
353 updatedKeys.has(snapshotInstanceKey(provider)),
354 );
355 return [[previousProviders, providers, providersToPersist] as const, providers];
294 },356 },
295 );357 );
296358
297 if (haveProvidersChanged(previousProviders, providers)) {359 if (haveProvidersChanged(previousProviders, providers)) {
298 if (options?.persist !== false) {360 if (options?.persist !== false) {
299 yield* Effect.forEach(nextProviders, persistProvider, {361 yield* Effect.forEach(providersToPersist, persistProvider, {
300 concurrency: "unbounded",362 concurrency: "unbounded",
301 discard: true,363 discard: true,
302 });364 });
...@@ -318,6 +380,45 @@ export const ProviderRegistryLive = Layer.effect(...@@ -318,6 +380,45 @@ export const ProviderRegistryLive = Layer.effect(
318 return yield* upsertProviders([provider], options);380 return yield* upsertProviders([provider], options);
319 });381 });
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
321 const refreshOneSource = Effect.fn("refreshOneSource")(function* (422 const refreshOneSource = Effect.fn("refreshOneSource")(function* (
322 providerSource: ProviderSnapshotSource,423 providerSource: ProviderSnapshotSource,
323 ) {424 ) {
...@@ -365,6 +466,18 @@ export const ProviderRegistryLive = Layer.effect(...@@ -365,6 +466,18 @@ export const ProviderRegistryLive = Layer.effect(
365 return yield* refreshOneSource(providerSource);466 return yield* refreshOneSource(providerSource);
366 });467 });
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
368 /**481 /**
369 * Diff the aggregator's live-source set against the current482 * Diff the aggregator's live-source set against the current
370 * `ProviderInstanceRegistry` and:483 * `ProviderInstanceRegistry` and:
...@@ -472,6 +585,15 @@ export const ProviderRegistryLive = Layer.effect(...@@ -472,6 +585,15 @@ export const ProviderRegistryLive = Layer.effect(
472 if (haveProvidersChanged(previousProviders, providers)) {585 if (haveProvidersChanged(previousProviders, providers)) {
473 yield* PubSub.publish(changesPubSub, providers);586 yield* PubSub.publish(changesPubSub, providers);
474 }587 }
588 yield* Ref.update(maintenanceActionStatesRef, (previous) => {
589 const next = new Map(previous);
590 for (const instanceId of previous.keys()) {
591 if (!knownInstanceIds.has(instanceId)) {
592 next.delete(instanceId);
593 }
594 }
595 return next;
596 });
475 }),597 }),
476 );598 );
477 const syncLiveSourcesAndContinue = syncLiveSources.pipe(599 const syncLiveSourcesAndContinue = syncLiveSources.pipe(
...@@ -555,6 +677,8 @@ export const ProviderRegistryLive = Layer.effect(...@@ -555,6 +677,8 @@ export const ProviderRegistryLive = Layer.effect(
555 refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),677 refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
556 refreshInstance: (instanceId: ProviderInstanceId) =>678 refreshInstance: (instanceId: ProviderInstanceId) =>
557 refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),679 refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
680 getProviderMaintenanceCapabilitiesForInstance,
681 setProviderMaintenanceActionState,
558 get streamChanges() {682 get streamChanges() {
559 return Stream.fromPubSub(changesPubSub);683 return Stream.fromPubSub(changesPubSub);
560 },684 },
apps/server/src/provider/Services/ProviderRegistry.ts+30-1
...@@ -6,9 +6,17 @@...@@ -6,9 +6,17 @@
6 *6 *
7 * @module ProviderRegistry7 * @module ProviderRegistry
8 */8 */
9import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts";9import type {
10 ProviderInstanceId,
11 ProviderDriverKind,
12 ServerProvider,
13 ServerProviderUpdateState,
14} from "@t3tools/contracts";
10import { Context } from "effect";15import { Context } from "effect";
11import type { Effect, Stream } from "effect";16import type { Effect, Stream } from "effect";
17import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
18
19export type ProviderMaintenanceActionKind = "update";
1220
13export interface ProviderRegistryShape {21export interface ProviderRegistryShape {
14 /**22 /**
...@@ -39,6 +47,27 @@ export interface ProviderRegistryShape {...@@ -39,6 +47,27 @@ export interface ProviderRegistryShape {
39 instanceId: ProviderInstanceId,47 instanceId: ProviderInstanceId,
40 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;48 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;
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
42 /**71 /**
43 * Stream of provider snapshot updates — one emission per aggregated72 * Stream of provider snapshot updates — one emission per aggregated
44 * change. The array contains the full current state.73 * change. The array contains the full current state.
apps/server/src/provider/Services/ServerProvider.ts+2
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import type { ServerProvider } from "@t3tools/contracts";1import type { ServerProvider } from "@t3tools/contracts";
2import type { Effect, Stream } from "effect";2import type { Effect, Stream } from "effect";
3import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
34
4export interface ServerProviderShape {5export interface ServerProviderShape {
6 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
5 readonly getSnapshot: Effect.Effect<ServerProvider>;7 readonly getSnapshot: Effect.Effect<ServerProvider>;
6 readonly refresh: Effect.Effect<ServerProvider>;8 readonly refresh: Effect.Effect<ServerProvider>;
7 readonly streamChanges: Stream.Stream<ServerProvider>;9 readonly streamChanges: Stream.Stream<ServerProvider>;
apps/server/src/provider/makeManagedServerProvider.test.ts+18
...@@ -20,6 +20,20 @@ interface TestSettings {...@@ -20,6 +20,20 @@ interface TestSettings {
20 readonly enabled: boolean;20 readonly enabled: boolean;
21}21}
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
23const initialSnapshot: ServerProvider = {37const initialSnapshot: ServerProvider = {
24 instanceId: ProviderInstanceId.make("codex"),38 instanceId: ProviderInstanceId.make("codex"),
25 driver: ProviderDriverKind.make("codex"),39 driver: ProviderDriverKind.make("codex"),
...@@ -90,6 +104,7 @@ describe("makeManagedServerProvider", () => {...@@ -90,6 +104,7 @@ describe("makeManagedServerProvider", () => {
90 const checkCalls = yield* Ref.make(0);104 const checkCalls = yield* Ref.make(0);
91 const releaseCheck = yield* Deferred.make<void>();105 const releaseCheck = yield* Deferred.make<void>();
92 const provider = yield* makeManagedServerProvider<TestSettings>({106 const provider = yield* makeManagedServerProvider<TestSettings>({
107 maintenanceCapabilities,
93 getSettings: Effect.succeed({ enabled: true }),108 getSettings: Effect.succeed({ enabled: true }),
94 streamSettings: Stream.empty,109 streamSettings: Stream.empty,
95 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,110 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -131,6 +146,7 @@ describe("makeManagedServerProvider", () => {...@@ -131,6 +146,7 @@ describe("makeManagedServerProvider", () => {
131 const releaseInitialCheck = yield* Deferred.make<void>();146 const releaseInitialCheck = yield* Deferred.make<void>();
132 const releaseSettingsCheck = yield* Deferred.make<void>();147 const releaseSettingsCheck = yield* Deferred.make<void>();
133 const provider = yield* makeManagedServerProvider<TestSettings>({148 const provider = yield* makeManagedServerProvider<TestSettings>({
149 maintenanceCapabilities,
134 getSettings: Ref.get(settingsRef),150 getSettings: Ref.get(settingsRef),
135 streamSettings: Stream.fromPubSub(settingsChanges),151 streamSettings: Stream.fromPubSub(settingsChanges),
136 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,152 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -172,6 +188,7 @@ describe("makeManagedServerProvider", () => {...@@ -172,6 +188,7 @@ describe("makeManagedServerProvider", () => {
172 const releaseEnrichment = yield* Deferred.make<void>();188 const releaseEnrichment = yield* Deferred.make<void>();
173 const releaseCheck = yield* Deferred.make<void>();189 const releaseCheck = yield* Deferred.make<void>();
174 const provider = yield* makeManagedServerProvider<TestSettings>({190 const provider = yield* makeManagedServerProvider<TestSettings>({
191 maintenanceCapabilities,
175 getSettings: Effect.succeed({ enabled: true }),192 getSettings: Effect.succeed({ enabled: true }),
176 streamSettings: Stream.empty,193 streamSettings: Stream.empty,
177 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,194 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -212,6 +229,7 @@ describe("makeManagedServerProvider", () => {...@@ -212,6 +229,7 @@ describe("makeManagedServerProvider", () => {
212 const secondCallbackReady = yield* Deferred.make<void>();229 const secondCallbackReady = yield* Deferred.make<void>();
213 const allowFirstRefresh = yield* Deferred.make<void>();230 const allowFirstRefresh = yield* Deferred.make<void>();
214 const provider = yield* makeManagedServerProvider<TestSettings>({231 const provider = yield* makeManagedServerProvider<TestSettings>({
232 maintenanceCapabilities,
215 getSettings: Effect.succeed({ enabled: true }),233 getSettings: Effect.succeed({ enabled: true }),
216 streamSettings: Stream.empty,234 streamSettings: Stream.empty,
217 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,235 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
apps/server/src/provider/makeManagedServerProvider.ts+2
...@@ -13,6 +13,7 @@ interface ProviderSnapshotState {...@@ -13,6 +13,7 @@ interface ProviderSnapshotState {
13export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <13export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
14 Settings,14 Settings,
15>(input: {15>(input: {
16 readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"];
16 readonly getSettings: Effect.Effect<Settings>;17 readonly getSettings: Effect.Effect<Settings>;
17 readonly streamSettings: Stream.Stream<Settings>;18 readonly streamSettings: Stream.Stream<Settings>;
18 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;19 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
...@@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
143 );144 );
144145
145 return {146 return {
147 maintenanceCapabilities: input.maintenanceCapabilities,
146 getSnapshot: input.getSettings.pipe(148 getSnapshot: input.getSettings.pipe(
147 Effect.flatMap(applySnapshot),149 Effect.flatMap(applySnapshot),
148 Effect.tapError(Effect.logError),150 Effect.tapError(Effect.logError),
apps/server/src/provider/providerMaintenance.test.ts created+487
...@@ -0,0 +1,487 @@
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";...@@ -12,6 +12,8 @@ import { Effect, Stream } from "effect";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { normalizeModelSlug } from "@t3tools/shared/model";13import { normalizeModelSlug } from "@t3tools/shared/model";
14import { isWindowsCommandNotFound } from "../processRunner.ts";14import { isWindowsCommandNotFound } from "../processRunner.ts";
15import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
16import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1517
16export const DEFAULT_TIMEOUT_MS = 4_000;18export const DEFAULT_TIMEOUT_MS = 4_000;
17// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)19// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
...@@ -182,6 +184,7 @@ export function buildBooleanOptionDescriptor(input: {...@@ -182,6 +184,7 @@ export function buildBooleanOptionDescriptor(input: {
182}184}
183185
184export function buildServerProvider(input: {186export function buildServerProvider(input: {
187 driver?: ProviderDriverKind;
185 presentation: ServerProviderPresentation;188 presentation: ServerProviderPresentation;
186 enabled: boolean;189 enabled: boolean;
187 checkedAt: string;190 checkedAt: string;
...@@ -190,6 +193,13 @@ export function buildServerProvider(input: {...@@ -190,6 +193,13 @@ export function buildServerProvider(input: {
190 skills?: ReadonlyArray<ServerProviderSkill>;193 skills?: ReadonlyArray<ServerProviderSkill>;
191 probe: ProviderProbeResult;194 probe: ProviderProbeResult;
192}): ServerProviderDraft {195}): ServerProviderDraft {
196 const versionAdvisory = input.driver
197 ? createProviderVersionAdvisory({
198 driver: input.driver,
199 currentVersion: input.probe.version,
200 checkedAt: input.checkedAt,
201 })
202 : undefined;
193 return {203 return {
194 displayName: input.presentation.displayName,204 displayName: input.presentation.displayName,
195 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),205 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
...@@ -206,16 +216,11 @@ export function buildServerProvider(input: {...@@ -206,16 +216,11 @@ export function buildServerProvider(input: {
206 models: input.models,216 models: input.models,
207 slashCommands: [...(input.slashCommands ?? [])],217 slashCommands: [...(input.slashCommands ?? [])],
208 skills: [...(input.skills ?? [])],218 skills: [...(input.skills ?? [])],
219 ...(versionAdvisory ? { versionAdvisory } : {}),
209 };220 };
210}221}
211222
212export const collectStreamAsString = <E>(223export const collectStreamAsString = <E>(
213 stream: Stream.Stream<Uint8Array, E>,224 stream: Stream.Stream<Uint8Array, E>,
214): Effect.Effect<string, E> =>225): Effect.Effect<string, E> =>
215 stream.pipe(226 collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text));
216 Stream.decodeText(),
217 Stream.runFold(
218 () => "",
219 (acc, chunk) => acc + chunk,
220 ),
221 );
apps/server/src/provider/providerStatusCache.ts+5-3
...@@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) =>...@@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) =>
140export const writeProviderStatusCache = (input: {140export const writeProviderStatusCache = (input: {
141 readonly filePath: string;141 readonly filePath: string;
142 readonly provider: ServerProvider;142 readonly provider: ServerProvider;
143}) =>143}) => {
144 writeFileStringAtomically({144 const { updateState: _updateState, ...cacheableProvider } = input.provider;
145 return writeFileStringAtomically({
145 filePath: input.filePath,146 filePath: input.filePath,
146 contents: `${JSON.stringify(input.provider, null, 2)}\n`,147 contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`,
147 });148 });
149};
apps/server/src/server.test.ts+7
...@@ -80,6 +80,7 @@ import {...@@ -80,6 +80,7 @@ import {
80 ProviderRegistry,80 ProviderRegistry,
81 type ProviderRegistryShape,81 type ProviderRegistryShape,
82} from "./provider/Services/ProviderRegistry.ts";82} from "./provider/Services/ProviderRegistry.ts";
83import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
83import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts";84import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts";
84import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";85import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
85import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";86import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
...@@ -517,6 +518,12 @@ const buildAppUnderTest = (options?: {...@@ -517,6 +518,12 @@ const buildAppUnderTest = (options?: {
517 Layer.mock(ProviderRegistry)({518 Layer.mock(ProviderRegistry)({
518 getProviders: Effect.succeed([]),519 getProviders: Effect.succeed([]),
519 refresh: () => Effect.succeed([]),520 refresh: () => Effect.succeed([]),
521 refreshInstance: () => Effect.succeed([]),
522 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
523 Effect.succeed(
524 makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),
525 ),
526 setProviderMaintenanceActionState: () => Effect.succeed([]),
520 streamChanges: Stream.empty,527 streamChanges: Stream.empty,
521 ...options?.layers?.providerRegistry,528 ...options?.layers?.providerRegistry,
522 }),529 }),
apps/server/src/stream/collectUint8StreamText.test.ts created+38
...@@ -0,0 +1,38 @@
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 {...@@ -8,6 +8,7 @@ import {
8 VcsProcessSpawnError,8 VcsProcessSpawnError,
9 VcsProcessTimeoutError,9 VcsProcessTimeoutError,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1112
12export interface VcsProcessInput {13export interface VcsProcessInput {
13 readonly operation: string;14 readonly operation: string;
...@@ -86,44 +87,12 @@ export const collectText = Effect.fn("VcsProcess.collectText")(function* (input:...@@ -86,44 +87,12 @@ export const collectText = Effect.fn("VcsProcess.collectText")(function* (input:
86 readonly maxOutputBytes?: number;87 readonly maxOutputBytes?: number;
87 readonly truncateOutputAtMaxBytes?: boolean;88 readonly truncateOutputAtMaxBytes?: boolean;
88}) {89}) {
89 const decoder = new TextDecoder();
90 let text = "";
91 let bytes = 0;
92 let truncated = false;
93 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;90 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
94 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;91 return yield* collectUint8StreamText({
9592 stream: input.stream,
96 yield* Stream.runForEach(input.stream, (chunk) =>93 maxBytes: maxOutputBytes,
97 Effect.sync(() => {94 truncatedMarker: input.truncateOutputAtMaxBytes ? OUTPUT_TRUNCATED_MARKER : null,
98 if (truncated) return;95 });
99
100 const remainingBytes = maxOutputBytes - bytes;
101 if (remainingBytes <= 0) {
102 truncated = true;
103 if (truncateOutputAtMaxBytes) {
104 text += OUTPUT_TRUNCATED_MARKER;
105 }
106 return;
107 }
108
109 const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk;
110 text += decoder.decode(nextChunk, { stream: true });
111 bytes += nextChunk.byteLength;
112
113 if (chunk.byteLength > remainingBytes) {
114 truncated = true;
115 if (truncateOutputAtMaxBytes) {
116 text += OUTPUT_TRUNCATED_MARKER;
117 }
118 }
119 }),
120 );
121
122 if (!truncated) {
123 text += decoder.decode();
124 }
125
126 return { text, truncated } satisfies VcsProcessCollectedText;
127});96});
12897
129export const make = Effect.fn("makeVcsProcess")(function* () {98export const make = Effect.fn("makeVcsProcess")(function* () {
apps/server/src/ws.ts+11
...@@ -40,6 +40,7 @@ import {...@@ -40,6 +40,7 @@ import {
40 observeRpcStreamEffect,40 observeRpcStreamEffect,
41} from "./observability/RpcInstrumentation.ts";41} from "./observability/RpcInstrumentation.ts";
42import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";42import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
43import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
43import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";44import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
44import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";45import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
45import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";46import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
...@@ -152,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -152,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
152 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;153 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
153 const terminalManager = yield* TerminalManager;154 const terminalManager = yield* TerminalManager;
154 const providerRegistry = yield* ProviderRegistry;155 const providerRegistry = yield* ProviderRegistry;
156 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
155 const config = yield* ServerConfig;157 const config = yield* ServerConfig;
156 const lifecycleEvents = yield* ServerLifecycleEvents;158 const lifecycleEvents = yield* ServerLifecycleEvents;
157 const serverSettings = yield* ServerSettingsService;159 const serverSettings = yield* ServerSettingsService;
...@@ -785,6 +787,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -785,6 +787,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
785 ).pipe(Effect.map((providers) => ({ providers }))),787 ).pipe(Effect.map((providers) => ({ providers }))),
786 { "rpc.aggregate": "server" },788 { "rpc.aggregate": "server" },
787 ),789 ),
790 [WS_METHODS.serverUpdateProvider]: (input) =>
791 observeRpcEffect(
792 WS_METHODS.serverUpdateProvider,
793 providerMaintenanceRunner.updateProvider(input),
794 {
795 "rpc.aggregate": "server",
796 },
797 ),
788 [WS_METHODS.serverUpsertKeybinding]: (rule) =>798 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
789 observeRpcEffect(799 observeRpcEffect(
790 WS_METHODS.serverUpsertKeybinding,800 WS_METHODS.serverUpsertKeybinding,
...@@ -1152,6 +1162,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -1152,6 +1162,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
1152 Effect.provide(1162 Effect.provide(
1153 makeWsRpcLayer(session.sessionId).pipe(1163 makeWsRpcLayer(session.sessionId).pipe(
1154 Layer.provideMerge(RpcSerialization.layerJson),1164 Layer.provideMerge(RpcSerialization.layerJson),
1165 Layer.provide(ProviderMaintenanceRunner.layer),
1155 Layer.provide(1166 Layer.provide(
1156 SourceControlDiscoveryLayer.layer.pipe(1167 SourceControlDiscoveryLayer.layer.pipe(
1157 Layer.provide(1168 Layer.provide(
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts created+673
...@@ -0,0 +1,673 @@
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 {...@@ -185,6 +185,7 @@ import {
185 type SidebarProjectGroupMember,185 type SidebarProjectGroupMember,
186 type SidebarProjectSnapshot,186 type SidebarProjectSnapshot,
187} from "../sidebarProjectGrouping";187} from "../sidebarProjectGrouping";
188import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
188const THREAD_PREVIEW_LIMIT = 6;189const THREAD_PREVIEW_LIMIT = 6;
189const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {190const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
190 updated_at: "Last user message",191 updated_at: "Last user message",
...@@ -2434,6 +2435,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() {...@@ -2434,6 +2435,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() {
24342435
2435 return (2436 return (
2436 <SidebarFooter className="p-2">2437 <SidebarFooter className="p-2">
2438 <SidebarProviderUpdatePill />
2437 <SidebarUpdatePill />2439 <SidebarUpdatePill />
2438 <SidebarMenu>2440 <SidebarMenu>
2439 <SidebarMenuItem>2441 <SidebarMenuItem>
apps/web/src/components/settings/ProviderInstanceCard.tsx+226-83
...@@ -1,6 +1,15 @@...@@ -1,6 +1,15 @@
1"use client";1"use client";
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";
4import { useEffect, useState, type ReactNode } from "react";13import { useEffect, useState, type ReactNode } from "react";
5import {14import {
6 isProviderDriverKind,15 isProviderDriverKind,
...@@ -13,12 +22,15 @@ import {...@@ -13,12 +22,15 @@ import {
13} from "@t3tools/contracts";22} from "@t3tools/contracts";
1423
15import { cn } from "../../lib/utils";24import { cn } from "../../lib/utils";
25import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
16import { normalizeProviderAccentColor } from "../../providerInstances";26import { normalizeProviderAccentColor } from "../../providerInstances";
17import { Badge } from "../ui/badge";27import { Badge } from "../ui/badge";
18import { Button } from "../ui/button";28import { Button } from "../ui/button";
19import { Collapsible, CollapsibleContent } from "../ui/collapsible";29import { Collapsible, CollapsibleContent } from "../ui/collapsible";
20import { DraftInput } from "../ui/draft-input";30import { DraftInput } from "../ui/draft-input";
31import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
21import { Switch } from "../ui/switch";32import { Switch } from "../ui/switch";
33import { stackedThreadToast, toastManager } from "../ui/toast";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";34import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
23import type { DriverOption } from "./providerDriverMeta";35import type { DriverOption } from "./providerDriverMeta";
24import { ProviderSettingsForm } from "./ProviderSettingsForm";36import { ProviderSettingsForm } from "./ProviderSettingsForm";
...@@ -26,6 +38,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection";...@@ -26,6 +38,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection";
26import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";38import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
27import { RedactedSensitiveText } from "./RedactedSensitiveText";39import { RedactedSensitiveText } from "./RedactedSensitiveText";
28import {40import {
41 getProviderVersionAdvisoryPresentation,
29 PROVIDER_STATUS_STYLES,42 PROVIDER_STATUS_STYLES,
30 getProviderSummary,43 getProviderSummary,
31 getProviderVersionLabel,44 getProviderVersionLabel,
...@@ -405,6 +418,8 @@ interface ProviderInstanceCardProps {...@@ -405,6 +418,8 @@ interface ProviderInstanceCardProps {
405 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;418 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;
406 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;419 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
407 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;420 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
421 readonly onRunUpdate?: (() => void) | undefined;
422 readonly isUpdating?: boolean | undefined;
408}423}
409424
410/**425/**
...@@ -447,6 +462,8 @@ export function ProviderInstanceCard({...@@ -447,6 +462,8 @@ export function ProviderInstanceCard({
447 onHiddenModelsChange,462 onHiddenModelsChange,
448 onFavoriteModelsChange,463 onFavoriteModelsChange,
449 onModelOrderChange,464 onModelOrderChange,
465 onRunUpdate,
466 isUpdating = false,
450}: ProviderInstanceCardProps) {467}: ProviderInstanceCardProps) {
451 const enabled = instance.enabled ?? true;468 const enabled = instance.enabled ?? true;
452 // The server-reported status wins when present; otherwise fall back to469 // The server-reported status wins when present; otherwise fall back to
...@@ -464,10 +481,30 @@ export function ProviderInstanceCard({...@@ -464,10 +481,30 @@ export function ProviderInstanceCard({
464 : null;481 : null;
465 const summary = rawSummary;482 const summary = rawSummary;
466 const versionLabel = getProviderVersionLabel(liveProvider?.version);483 const versionLabel = getProviderVersionLabel(liveProvider?.version);
484 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
485 const updateCommand = versionAdvisory?.updateCommand ?? null;
467 const FallbackIconComponent = driverOption?.icon;486 const FallbackIconComponent = driverOption?.icon;
468 const displayName =487 const displayName =
469 instance.displayName?.trim() || driverOption?.label || String(instance.driver);488 instance.displayName?.trim() || driverOption?.label || String(instance.driver);
470 const accentColor = normalizeProviderAccentColor(instance.accentColor);489 const accentColor = normalizeProviderAccentColor(instance.accentColor);
490 const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({
491 onCopy: ({ providerName }) => {
492 toastManager.add({
493 type: "success",
494 title: `${providerName} update command copied`,
495 description: "Run it in a terminal when you are ready to update.",
496 });
497 },
498 onError: (error, { providerName }) => {
499 toastManager.add(
500 stackedThreadToast({
501 type: "error",
502 title: `Could not copy ${providerName} update command`,
503 description: error.message,
504 }),
505 );
506 },
507 });
471508
472 // Narrow `instance.driver` for callers that key on the closed509 // Narrow `instance.driver` for callers that key on the closed
473 // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom510 // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom
...@@ -535,97 +572,203 @@ export function ProviderInstanceCard({...@@ -535,97 +572,203 @@ export function ProviderInstanceCard({
535 );572 );
536 };573 };
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
538 return (672 return (
539 <div className="border-t border-border first:border-t-0">673 <div className="border-t border-border/60 first:border-t-0">
540 <div className="px-4 py-4 sm:px-5">674 <div className="px-4 py-3.5 sm:px-5">
541 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">675 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
542 <div className="min-w-0 flex-1 space-y-1">676 <div className="min-w-0 flex-1 space-y-1">
543 <div className="flex min-h-5 items-center gap-1.5">677 <div className="flex min-w-0 flex-wrap items-center gap-2">
544 {driverKind ? (678 {titleHeadNode}
545 <ProviderInstanceIcon679 {versionCodeNode}
546 driverKind={driverKind}680 {versionAdvisory ? (
547 displayName={displayName}681 <Popover>
548 accentColor={accentColor}682 <PopoverTrigger
549 showBadge={Boolean(accentColor)}683 render={
550 statusDotClassName={statusStyle.dot}684 <Button
551 className="size-5"685 type="button"
552 iconClassName="size-4 text-foreground/80"686 size="icon-xs"
553 badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]"687 variant="ghost"
554 />688 className={cn(
555 ) : FallbackIconComponent ? (689 "size-5 rounded-sm p-0",
556 <span className="relative inline-flex size-5 shrink-0 items-center justify-center">690 versionAdvisory.emphasis === "strong"
557 <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden />691 ? "text-warning hover:text-warning"
558 <span692 : "text-primary hover:text-primary",
559 className={cn(693 )}
560 "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background",694 aria-label="Update available — view details"
561 statusStyle.dot,695 >
562 )}696 <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />
563 aria-hidden697 </Button>
698 }
564 />699 />
565 </span>700 <PopoverPopup side="bottom" align="start" className="w-84">
566 ) : (701 <div className="grid gap-3">
567 <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} />702 <div className="grid gap-0.5">
568 )}703 <p className="text-[13px] font-semibold leading-tight text-foreground">
569 <h3 className="truncate text-sm font-medium text-foreground">{displayName}</h3>704 Update available
570 {String(instanceId) !== String(instance.driver) ? (705 </p>
571 // Hide the id chip on a default slot whose id === the706 <p
572 // driver slug — it's redundant with the driver icon +707 className={cn(
573 // label. Custom instances (and any instance the user has708 "text-xs leading-snug",
574 // since renamed) keep the chip so their slug stays709 versionAdvisory.emphasis === "strong"
575 // visible for copy/paste + disambiguation.710 ? "text-warning"
576 <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground">711 : "text-muted-foreground",
577 {instanceId}712 )}
578 </code>713 >
579 ) : null}714 {versionAdvisory.detail}
580 {driverOption?.badgeLabel ? (715 </p>
581 <Badge variant="warning" size="sm" className="shrink-0">716 </div>
582 {driverOption.badgeLabel}717 {onRunUpdate ? (
583 </Badge>
584 ) : null}
585 {versionLabel ? (
586 <code className="text-xs text-muted-foreground">{versionLabel}</code>
587 ) : null}
588 {headerAction ? (
589 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
590 {headerAction}
591 </span>
592 ) : null}
593 {onDelete ? (
594 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
595 <Tooltip>
596 <TooltipTrigger
597 render={
598 <Button718 <Button
599 size="icon-xs"719 type="button"
600 variant="ghost"720 size="xs"
601 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive"721 variant="default"
602 onClick={onDelete}722 className="w-full"
603 aria-label={`Delete provider instance ${instanceId}`}723 disabled={isUpdating}
724 onClick={onRunUpdate}
604 >725 >
605 <Trash2Icon className="size-3" />726 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
727 {isUpdating ? "Updating" : "Update now"}
606 </Button>728 </Button>
607 }729 ) : null}
608 />730 {onRunUpdate && updateCommand ? (
609 <TooltipPopup side="top">Delete instance</TooltipPopup>731 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
610 </Tooltip>732 <span aria-hidden className="h-px flex-1 bg-border" />
611 </span>733 or, update manually using
734 <span aria-hidden className="h-px flex-1 bg-border" />
735 </div>
736 ) : null}
737 {updateCommand ? (
738 <div className="flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
739 <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
740 {updateCommand}
741 </code>
742 <Tooltip>
743 <TooltipTrigger
744 render={
745 <Button
746 type="button"
747 size="icon-xs"
748 variant="ghost"
749 className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
750 onClick={() =>
751 copyToClipboard(updateCommand, {
752 providerName: displayName,
753 })
754 }
755 aria-label="Copy update command"
756 >
757 <CopyIcon className="size-3" />
758 </Button>
759 }
760 />
761 <TooltipPopup side="top">Copy command</TooltipPopup>
762 </Tooltip>
763 </div>
764 ) : null}
765 </div>
766 </PopoverPopup>
767 </Popover>
612 ) : null}768 ) : null}
769 {titleTailNode}
613 </div>770 </div>
614 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">771 {authRowNode}
615 {hasAuthenticatedEmail ? (
616 <>
617 <span>Authenticated as</span>
618 <ProviderAuthEmail email={authEmail} />
619 {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null}
620 </>
621 ) : (
622 <>
623 <span>{summary.headline}</span>
624 <ProviderAuthEmail email={authEmail} separator prefix="Email" />
625 </>
626 )}
627 {summary.detail ? <span>- {summary.detail}</span> : null}
628 </p>
629 </div>772 </div>
630 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">773 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
631 <Button774 <Button
apps/web/src/components/settings/SettingsPanels.browser.tsx+65-2
...@@ -10,7 +10,10 @@ import {...@@ -10,7 +10,10 @@ import {
10 type DesktopUpdateChannel,10 type DesktopUpdateChannel,
11 type DesktopUpdateState,11 type DesktopUpdateState,
12 type LocalApi,12 type LocalApi,
13 ProviderDriverKind,
14 ProviderInstanceId,
13 type ServerConfig,15 type ServerConfig,
16 type ServerProvider,
14 type SourceControlDiscoveryResult,17 type SourceControlDiscoveryResult,
15} from "@t3tools/contracts";18} from "@t3tools/contracts";
16import { DateTime, Option } from "effect";19import { DateTime, Option } from "effect";
...@@ -23,7 +26,7 @@ import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc...@@ -23,7 +26,7 @@ import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc
23import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";26import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
24import { useUiStateStore } from "../../uiStateStore";27import { useUiStateStore } from "../../uiStateStore";
25import { ConnectionsSettings } from "./ConnectionsSettings";28import { ConnectionsSettings } from "./ConnectionsSettings";
26import { GeneralSettingsPanel } from "./SettingsPanels";29import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels";
27import { SourceControlSettingsPanel } from "./SourceControlSettings";30import { SourceControlSettingsPanel } from "./SourceControlSettings";
2831
29const authAccessHarness = vi.hoisted(() => {32const authAccessHarness = vi.hoisted(() => {
...@@ -207,6 +210,31 @@ function createBaseServerConfig(): ServerConfig {...@@ -207,6 +210,31 @@ function createBaseServerConfig(): ServerConfig {
207 };210 };
208}211}
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
210function makeUtc(value: string) {238function makeUtc(value: string) {
211 return DateTime.makeUnsafe(Date.parse(value));239 return DateTime.makeUnsafe(Date.parse(value));
212}240}
...@@ -1016,7 +1044,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -1016,7 +1044,7 @@ describe("GeneralSettingsPanel observability", () => {
10161044
1017 mounted = await render(1045 mounted = await render(
1018 <AppAtomRegistryProvider>1046 <AppAtomRegistryProvider>
1019 <GeneralSettingsPanel />1047 <ProviderSettingsPanel />
1020 </AppAtomRegistryProvider>,1048 </AppAtomRegistryProvider>,
1021 );1049 );
10221050
...@@ -1031,6 +1059,41 @@ describe("GeneralSettingsPanel observability", () => {...@@ -1031,6 +1059,41 @@ describe("GeneralSettingsPanel observability", () => {
1031 await expect.element(page.getByText("Server password")).toBeInTheDocument();1059 await expect.element(page.getByText("Server password")).toBeInTheDocument();
1032 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();1060 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();
1033 });1061 });
1062
1063 it("runs one-click provider updates from the provider card", async () => {
1064 const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({
1065 providers: [createOutdatedProvider("codex")],
1066 });
1067 window.nativeApi = {
1068 persistence: {
1069 getClientSettings: vi.fn().mockResolvedValue(null),
1070 setClientSettings: vi.fn().mockResolvedValue(undefined),
1071 },
1072 server: {
1073 updateProvider,
1074 },
1075 } as unknown as LocalApi;
1076
1077 setServerConfigSnapshot({
1078 ...createBaseServerConfig(),
1079 providers: [createOutdatedProvider("codex")],
1080 });
1081
1082 mounted = await render(
1083 <AppAtomRegistryProvider>
1084 <ProviderSettingsPanel />
1085 </AppAtomRegistryProvider>,
1086 );
1087
1088 await page.getByRole("button", { name: "Update available — view details" }).click();
1089 await expect.element(page.getByRole("button", { name: "Update now" })).toBeInTheDocument();
1090 await page.getByRole("button", { name: "Update now" }).click();
1091
1092 expect(updateProvider).toHaveBeenCalledWith({
1093 provider: ProviderDriverKind.make("codex"),
1094 instanceId: ProviderInstanceId.make("codex"),
1095 });
1096 });
1034});1097});
10351098
1036describe("SourceControlSettingsPanel discovery states", () => {1099describe("SourceControlSettingsPanel discovery states", () => {
apps/web/src/components/settings/SettingsPanels.tsx+433-402
...@@ -4,6 +4,7 @@ import { useCallback, useMemo, useRef, useState } from "react";...@@ -4,6 +4,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
4import {4import {
5 defaultInstanceIdForDriver,5 defaultInstanceIdForDriver,
6 type DesktopUpdateChannel,6 type DesktopUpdateChannel,
7 PROVIDER_DISPLAY_NAMES,
7 ProviderDriverKind,8 ProviderDriverKind,
8 type ProviderInstanceConfig,9 type ProviderInstanceConfig,
9 type ProviderInstanceId,10 type ProviderInstanceId,
...@@ -56,6 +57,13 @@ import { Switch } from "../ui/switch";...@@ -56,6 +57,13 @@ import { Switch } from "../ui/switch";
56import { stackedThreadToast, toastManager } from "../ui/toast";57import { stackedThreadToast, toastManager } from "../ui/toast";
57import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";58import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
58import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";59import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
60import {
61 canOneClickUpdateProviderCandidate,
62 collectProviderUpdateCandidates,
63 hasOneClickUpdateProviderCandidate,
64 isProviderUpdateActive,
65 type ProviderUpdateCandidate,
66} from "../ProviderUpdateLaunchNotification.logic";
59import { ProviderInstanceCard } from "./ProviderInstanceCard";67import { ProviderInstanceCard } from "./ProviderInstanceCard";
60import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";68import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
61import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";69import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";
...@@ -345,37 +353,12 @@ function AboutVersionSection() {...@@ -345,37 +353,12 @@ function AboutVersionSection() {
345export function useSettingsRestore(onRestored?: () => void) {353export function useSettingsRestore(onRestored?: () => void) {
346 const { theme, setTheme } = useTheme();354 const { theme, setTheme } = useTheme();
347 const settings = useSettings();355 const settings = useSettings();
348 const { resetSettings } = useUpdateSettings();356 const { updateSettings } = useUpdateSettings();
349357
350 const isGitWritingModelDirty = !Equal.equals(358 const isGitWritingModelDirty = !Equal.equals(
351 settings.textGenerationModelSelection ?? null,359 settings.textGenerationModelSelection ?? null,
352 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,360 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
353 );361 );
354 // A provider surface is "dirty" if either the legacy per-kind
355 // `settings.providers[kind]` struct differs from defaults (for users
356 // on pre-migration data) or the new `settings.providerInstances` map
357 // has any entries (every edit to a default slot promotes it into an
358 // explicit entry, so any key in that map represents user intent to
359 // diverge from factory defaults). Checking both keeps the Restore
360 // Defaults chip accurate throughout the legacy→instance migration.
361 const areProviderSettingsDirty =
362 PROVIDER_SETTINGS.some((providerSettings) => {
363 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
364 const currentProviders = settings.providers as Record<
365 string,
366 LegacyProviderSettings | undefined
367 >;
368 const defaultProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
369 string,
370 LegacyProviderSettings | undefined
371 >;
372 const currentSettings = currentProviders[providerSettings.provider];
373 const defaultSettings = defaultProviders[providerSettings.provider];
374 return !Equal.equals(currentSettings, defaultSettings);
375 }) ||
376 Object.keys(settings.providerInstances ?? {}).length > 0 ||
377 Object.keys(settings.providerModelPreferences ?? {}).length > 0 ||
378 (settings.favorites ?? []).length > 0;
379362
380 const changedSettingLabels = useMemo(363 const changedSettingLabels = useMemo(
381 () => [364 () => [
...@@ -408,10 +391,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -408,10 +391,8 @@ export function useSettingsRestore(onRestored?: () => void) {
408 ? ["Delete confirmation"]391 ? ["Delete confirmation"]
409 : []),392 : []),
410 ...(isGitWritingModelDirty ? ["Git writing model"] : []),393 ...(isGitWritingModelDirty ? ["Git writing model"] : []),
411 ...(areProviderSettingsDirty ? ["Providers"] : []),
412 ],394 ],
413 [395 [
414 areProviderSettingsDirty,
415 isGitWritingModelDirty,396 isGitWritingModelDirty,
416 settings.autoOpenPlanSidebar,397 settings.autoOpenPlanSidebar,
417 settings.confirmThreadArchive,398 settings.confirmThreadArchive,
...@@ -437,9 +418,20 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -437,9 +418,20 @@ export function useSettingsRestore(onRestored?: () => void) {
437 if (!confirmed) return;418 if (!confirmed) return;
438419
439 setTheme("system");420 setTheme("system");
440 resetSettings();421 updateSettings({
422 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
423 diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
424 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
425 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
426 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
427 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
428 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
429 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
430 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
431 textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
432 });
441 onRestored?.();433 onRestored?.();
442 }, [changedSettingLabels, onRestored, resetSettings, setTheme]);434 }, [changedSettingLabels, onRestored, setTheme, updateSettings]);
443435
444 return {436 return {
445 changedSettingLabels,437 changedSettingLabels,
...@@ -458,44 +450,10 @@ export function GeneralSettingsPanel() {...@@ -458,44 +450,10 @@ export function GeneralSettingsPanel() {
458 const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState<450 const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState<
459 Partial<Record<"keybindings" | "logsDirectory", string | null>>451 Partial<Record<"keybindings" | "logsDirectory", string | null>>
460 >({});452 >({});
461 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
462 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
463 // Collapsible state per provider-instance card, keyed by the instance id.
464 // `Record<string, boolean>` so we don't need to preseed an entry for every
465 // configured instance — an absent key reads as collapsed. Default-slot
466 // rows share this state: their id is the driver slug
467 // (`defaultInstanceIdForDriver(driver)`), which is also `ProviderDriverKind` at
468 // runtime, so a pre-existing open key for e.g. "codex" persists across
469 // the legacy/unified render swap.
470 const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({});
471 const refreshingRef = useRef(false);
472 const refreshProviders = useCallback(() => {
473 if (refreshingRef.current) return;
474 refreshingRef.current = true;
475 setIsRefreshingProviders(true);
476 void ensureLocalApi()
477 .server.refreshProviders()
478 .catch((error: unknown) => {
479 console.warn("Failed to refresh providers", error);
480 })
481 .finally(() => {
482 refreshingRef.current = false;
483 setIsRefreshingProviders(false);
484 });
485 }, []);
486
487 const keybindingsConfigPath = useServerKeybindingsConfigPath();453 const keybindingsConfigPath = useServerKeybindingsConfigPath();
488 const availableEditors = useServerAvailableEditors();454 const availableEditors = useServerAvailableEditors();
489 const observability = useServerObservability();455 const observability = useServerObservability();
490 const serverProviders = useServerProviders();456 const serverProviders = useServerProviders();
491 const visibleProviderSettings = PROVIDER_SETTINGS.filter(
492 (providerSettings) =>
493 providerSettings.provider !== "cursor" ||
494 serverProviders.some(
495 (provider) =>
496 provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
497 ),
498 );
499 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;457 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
500 const diagnosticsDescription = (() => {458 const diagnosticsDescription = (() => {
501 const exports: string[] = [];459 const exports: string[] = [];
...@@ -576,221 +534,6 @@ export function GeneralSettingsPanel() {...@@ -576,221 +534,6 @@ export function GeneralSettingsPanel() {
576 const isOpeningKeybindings = openingPathByTarget.keybindings;534 const isOpeningKeybindings = openingPathByTarget.keybindings;
577 const isOpeningLogsDirectory = openingPathByTarget.logsDirectory;535 const isOpeningLogsDirectory = openingPathByTarget.logsDirectory;
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
794 return (537 return (
795 <SettingsPageContainer>538 <SettingsPageContainer>
796 <SettingsSection title="General">539 <SettingsSection title="General">
...@@ -1168,58 +911,406 @@ export function GeneralSettingsPanel() {...@@ -1168,58 +911,406 @@ export function GeneralSettingsPanel() {
1168 />911 />
1169 </SettingsSection>912 </SettingsSection>
1170913
1171 <SettingsSection914 <SettingsSection title="Advanced">
1172 title="Providers"915 <SettingsRow
1173 headerAction={916 title="Keybindings"
1174 <div className="flex items-center gap-1.5">917 description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
1175 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />918 status={
1176 <Tooltip>919 <>
1177 <TooltipTrigger920 <span className="block break-all font-mono text-[11px] text-foreground">
1178 render={921 {keybindingsConfigPath ?? "Resolving keybindings path..."}
1179 <Button922 </span>
1180 size="icon-xs"923 {openKeybindingsError ? (
1181 variant="ghost"924 <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
1182 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"925 ) : (
1183 onClick={() => setIsAddInstanceDialogOpen(true)}926 <span className="mt-1 block">Opens in your preferred editor.</span>
1184 aria-label="Add provider instance"927 )}
1185 >928 </>
1186 <PlusIcon className="size-3" />929 }
1187 </Button>930 control={
1188 }931 <Button
1189 />932 size="xs"
1190 <TooltipPopup side="top">Add provider instance</TooltipPopup>933 variant="outline"
1191 </Tooltip>934 disabled={!keybindingsConfigPath || isOpeningKeybindings}
1192 <Tooltip>935 onClick={openKeybindingsFile}
1193 <TooltipTrigger936 >
1194 render={937 {isOpeningKeybindings ? "Opening..." : "Open file"}
1195 <Button938 </Button>
1196 size="icon-xs"939 }
1197 variant="ghost"940 />
1198 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"941 </SettingsSection>
1199 disabled={isRefreshingProviders}942
1200 onClick={() => void refreshProviders()}943 <SettingsSection title="About">
1201 aria-label="Refresh provider status"944 {isElectron ? (
1202 >945 <AboutVersionSection />
1203 {isRefreshingProviders ? (946 ) : (
1204 <LoaderIcon className="size-3 animate-spin" />947 <SettingsRow
1205 ) : (948 title={<AboutVersionTitle />}
1206 <RefreshCwIcon className="size-3" />949 description="Current version of the application."
1207 )}950 />
1208 </Button>951 )}
1209 }952 <SettingsRow
1210 />953 title="Diagnostics"
1211 <TooltipPopup side="top">Refresh provider status</TooltipPopup>954 description={diagnosticsDescription}
1212 </Tooltip>955 status={
1213 </div>956 <>
1214 }957 <span className="block break-all font-mono text-[11px] text-foreground">
1215 >958 {logsDirectoryPath ?? "Resolving logs directory..."}
1216 {rows.map((row) => {959 </span>
1217 const driverOption = getDriverOption(row.driver);960 {openDiagnosticsError ? (
1218 const liveProvider = serverProviders.find(961 <span className="mt-1 block text-destructive">{openDiagnosticsError}</span>
1219 (candidate) => candidate.instanceId === row.instanceId,962 ) : null}
1220 );963 </>
1221 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {964 }
1222 hiddenModels: [],965 control={
966 <Button
967 size="xs"
968 variant="outline"
969 disabled={!logsDirectoryPath || isOpeningLogsDirectory}
970 onClick={openLogsDirectory}
971 >
972 {isOpeningLogsDirectory ? "Opening..." : "Open logs folder"}
973 </Button>
974 }
975 />
976 </SettingsSection>
977 </SettingsPageContainer>
978 );
979}
980
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: [],
1223 modelOrder: [],1314 modelOrder: [],
1224 };1315 };
1225 const favoriteModels = (settings.favorites ?? [])1316 const favoriteModels = (settings.favorites ?? [])
...@@ -1248,13 +1339,6 @@ export function GeneralSettingsPanel() {...@@ -1248,13 +1339,6 @@ export function GeneralSettingsPanel() {
1248 }))1339 }))
1249 }1340 }
1250 onUpdate={(next) => {1341 onUpdate={(next) => {
1251 // When the user disables the exact instance the text-gen
1252 // selection points at, fall back to the global default so we
1253 // don't leave the selection dangling on a disabled instance.
1254 // Prior kind-level behavior cleared on any kind-matching
1255 // disable; instance-level addressing makes this narrower and
1256 // more accurate (other instances of the same kind stay
1257 // untouched).
1258 const wasEnabled = row.instance.enabled ?? true;1342 const wasEnabled = row.instance.enabled ?? true;
1259 const isDisabling = next.enabled === false && wasEnabled;1343 const isDisabling = next.enabled === false && wasEnabled;
1260 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;1344 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
...@@ -1287,6 +1371,17 @@ export function GeneralSettingsPanel() {...@@ -1287,6 +1371,17 @@ export function GeneralSettingsPanel() {
1287 modelOrder,1371 modelOrder,
1288 })1372 })
1289 }1373 }
1374 onRunUpdate={
1375 showInlineUpdateButton && updateCandidate
1376 ? () => {
1377 if (!canRunInlineUpdate) {
1378 return;
1379 }
1380 void runProviderUpdate(updateCandidate);
1381 }
1382 : undefined
1383 }
1384 isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined}
1290 />1385 />
1291 );1386 );
1292 })}1387 })}
...@@ -1296,70 +1391,6 @@ export function GeneralSettingsPanel() {...@@ -1296,70 +1391,6 @@ export function GeneralSettingsPanel() {
1296 open={isAddInstanceDialogOpen}1391 open={isAddInstanceDialogOpen}
1297 onOpenChange={setIsAddInstanceDialogOpen}1392 onOpenChange={setIsAddInstanceDialogOpen}
1298 />1393 />
1299
1300 <SettingsSection title="Advanced">
1301 <SettingsRow
1302 title="Keybindings"
1303 description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
1304 status={
1305 <>
1306 <span className="block break-all font-mono text-[11px] text-foreground">
1307 {keybindingsConfigPath ?? "Resolving keybindings path..."}
1308 </span>
1309 {openKeybindingsError ? (
1310 <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
1311 ) : (
1312 <span className="mt-1 block">Opens in your preferred editor.</span>
1313 )}
1314 </>
1315 }
1316 control={
1317 <Button
1318 size="xs"
1319 variant="outline"
1320 disabled={!keybindingsConfigPath || isOpeningKeybindings}
1321 onClick={openKeybindingsFile}
1322 >
1323 {isOpeningKeybindings ? "Opening..." : "Open file"}
1324 </Button>
1325 }
1326 />
1327 </SettingsSection>
1328
1329 <SettingsSection title="About">
1330 {isElectron ? (
1331 <AboutVersionSection />
1332 ) : (
1333 <SettingsRow
1334 title={<AboutVersionTitle />}
1335 description="Current version of the application."
1336 />
1337 )}
1338 <SettingsRow
1339 title="Diagnostics"
1340 description={diagnosticsDescription}
1341 status={
1342 <>
1343 <span className="block break-all font-mono text-[11px] text-foreground">
1344 {logsDirectoryPath ?? "Resolving logs directory..."}
1345 </span>
1346 {openDiagnosticsError ? (
1347 <span className="mt-1 block text-destructive">{openDiagnosticsError}</span>
1348 ) : null}
1349 </>
1350 }
1351 control={
1352 <Button
1353 size="xs"
1354 variant="outline"
1355 disabled={!logsDirectoryPath || isOpeningLogsDirectory}
1356 onClick={openLogsDirectory}
1357 >
1358 {isOpeningLogsDirectory ? "Opening..." : "Open logs folder"}
1359 </Button>
1360 }
1361 />
1362 </SettingsSection>
1363 </SettingsPageContainer>1394 </SettingsPageContainer>
1364 );1395 );
1365}1396}
apps/web/src/components/settings/SettingsSidebarNav.tsx+10-1
...@@ -1,5 +1,12 @@...@@ -1,5 +1,12 @@
1import { useCallback, type ComponentType } from "react";1import { 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";
3import { useCanGoBack, useNavigate } from "@tanstack/react-router";10import { useCanGoBack, useNavigate } from "@tanstack/react-router";
411
5import {12import {
...@@ -15,6 +22,7 @@ import {...@@ -15,6 +22,7 @@ import {
1522
16export type SettingsSectionPath =23export type SettingsSectionPath =
17 | "/settings/general"24 | "/settings/general"
25 | "/settings/providers"
18 | "/settings/source-control"26 | "/settings/source-control"
19 | "/settings/connections"27 | "/settings/connections"
20 | "/settings/archived";28 | "/settings/archived";
...@@ -25,6 +33,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{...@@ -25,6 +33,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
25 icon: ComponentType<{ className?: string }>;33 icon: ComponentType<{ className?: string }>;
26}> = [34}> = [
27 { label: "General", to: "/settings/general", icon: Settings2Icon },35 { label: "General", to: "/settings/general", icon: Settings2Icon },
36 { label: "Providers", to: "/settings/providers", icon: BotIcon },
28 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },37 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
29 { label: "Connections", to: "/settings/connections", icon: Link2Icon },38 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
30 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },39 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
apps/web/src/components/settings/providerStatus.ts+27-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ServerProvider } from "@t3tools/contracts";1import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts";
22
3/**3/**
4 * Visual treatment for each server-reported provider status. Centralized so4 * Visual treatment for each server-reported provider status. Centralized so
...@@ -89,3 +89,29 @@ export function getProviderVersionLabel(version: string | null | undefined) {...@@ -89,3 +89,29 @@ export function getProviderVersionLabel(version: string | null | undefined) {
89 if (!version) return null;89 if (!version) return null;
90 return version.startsWith("v") ? version : `v${version}`;90 return version.startsWith("v") ? version : `v${version}`;
91}91}
92
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 @@...@@ -1,5 +1,5 @@
1import { Undo2Icon } from "lucide-react";1import { Undo2Icon } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";2import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from "react";
33
4import { cn } from "../../lib/utils";4import { cn } from "../../lib/utils";
5import { Button } from "../ui/button";5import { Button } from "../ui/button";
...@@ -20,15 +20,17 @@ export function SettingsSection({...@@ -20,15 +20,17 @@ export function SettingsSection({
20 icon,20 icon,
21 headerAction,21 headerAction,
22 children,22 children,
23}: {23 className,
24 ...sectionProps
25}: ComponentPropsWithoutRef<"section"> & {
24 title: string;26 title: string;
25 icon?: ReactNode;27 icon?: ReactNode;
26 headerAction?: ReactNode;28 headerAction?: ReactNode;
27 children: ReactNode;29 children: ReactNode;
28}) {30}) {
29 return (31 return (
30 <section className="space-y-2.5">32 <section {...sectionProps} className={cn("space-y-2.5", className)}>
31 <div className="flex h-5 items-center justify-between px-1">33 <div className="flex items-center justify-between px-1">
32 <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50">34 <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50">
33 <span className="inline-block h-px w-3 bg-border" aria-hidden />35 <span className="inline-block h-px w-3 bg-border" aria-hidden />
34 {icon}36 {icon}
...@@ -62,7 +64,7 @@ export function SettingsRow({...@@ -62,7 +64,7 @@ export function SettingsRow({
62 <div64 <div
63 className={cn(65 className={cn(
64 "border-t border-border/60 px-4 first:border-t-0 sm:px-5",66 "border-t border-border/60 px-4 first:border-t-0 sm:px-5",
65 children ? "pt-4 pb-0" : "py-4",67 children ? "pt-3.5 pb-0" : "py-3.5",
66 )}68 )}
67 >69 >
68 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">70 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
...@@ -75,7 +77,7 @@ export function SettingsRow({...@@ -75,7 +77,7 @@ export function SettingsRow({
75 {resetAction}77 {resetAction}
76 </span>78 </span>
77 </div>79 </div>
78 <p className="text-xs leading-relaxed text-muted-foreground/80">{description}</p>80 <p className="text-xs text-muted-foreground/80">{description}</p>
79 {status ? <div className="pt-0.5 text-[11px] text-muted-foreground">{status}</div> : null}81 {status ? <div className="pt-0.5 text-[11px] text-muted-foreground">{status}</div> : null}
80 </div>82 </div>
81 {control ? (83 {control ? (
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx created+207
...@@ -0,0 +1,207 @@
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 {...@@ -6,6 +6,7 @@ import {
6 useMemo,6 useMemo,
7 useState,7 useState,
8 type CSSProperties,8 type CSSProperties,
9 type ComponentPropsWithoutRef,
9 type KeyboardEvent,10 type KeyboardEvent,
10 type ReactNode,11 type ReactNode,
11} from "react";12} from "react";
...@@ -38,9 +39,20 @@ import {...@@ -38,9 +39,20 @@ import {
38export type ThreadToastData = {39export type ThreadToastData = {
39 threadRef?: ScopedThreadRef | null;40 threadRef?: ScopedThreadRef | null;
40 threadId?: ThreadId | null;41 threadId?: ThreadId | null;
42 leadingIcon?: ReactNode;
41 tooltipStyle?: boolean;43 tooltipStyle?: boolean;
44 onClose?: (() => void) | undefined;
42 dismissAfterVisibleMs?: number;45 dismissAfterVisibleMs?: number;
43 hideCopyButton?: boolean;46 hideCopyButton?: boolean;
47 secondaryActionProps?: ComponentPropsWithoutRef<"button">;
48 secondaryActionVariant?:
49 | "default"
50 | "destructive"
51 | "destructive-outline"
52 | "ghost"
53 | "link"
54 | "outline"
55 | "secondary";
44 /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */56 /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */
45 expandableContent?: ReactNode;57 expandableContent?: ReactNode;
46 expandableLabels?: { expand?: string; collapse?: string };58 expandableLabels?: { expand?: string; collapse?: string };
...@@ -90,6 +102,15 @@ const toastCornerOrbClass = cn(...@@ -90,6 +102,15 @@ const toastCornerOrbClass = cn(
90 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",102 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
91);103);
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
93function CopyErrorButton({ text }: { text: string }) {114function CopyErrorButton({ text }: { text: string }) {
94 const { copyToClipboard, isCopied } = useCopyToClipboard();115 const { copyToClipboard, isCopied } = useCopyToClipboard();
95116
...@@ -232,6 +253,7 @@ interface ToastBodyDescriptor {...@@ -232,6 +253,7 @@ interface ToastBodyDescriptor {
232 readonly Icon: ToastIconComponent | null | undefined;253 readonly Icon: ToastIconComponent | null | undefined;
233 readonly stackedActionLayout: boolean;254 readonly stackedActionLayout: boolean;
234 readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>;255 readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>;
256 readonly secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]>;
235 readonly copyErrorText: string | null;257 readonly copyErrorText: string | null;
236 readonly hasTrailingControls: boolean;258 readonly hasTrailingControls: boolean;
237 readonly inlineContentEndPad: string;259 readonly inlineContentEndPad: string;
...@@ -248,16 +270,21 @@ function deriveToastBodyDescriptor(toast: {...@@ -248,16 +270,21 @@ function deriveToastBodyDescriptor(toast: {
248 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";270 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
249 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =271 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =
250 toast.data?.actionVariant ?? "default";272 toast.data?.actionVariant ?? "default";
273 const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> =
274 toast.data?.secondaryActionVariant ?? "outline";
251 const copyErrorText =275 const copyErrorText =
252 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton276 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton
253 ? toast.description277 ? toast.description
254 : null;278 : null;
255 const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined;279 const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;
280 const hasTrailingControls =
281 copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction;
256 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";282 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
257 return {283 return {
258 Icon,284 Icon,
259 stackedActionLayout,285 stackedActionLayout,
260 actionVariant,286 actionVariant,
287 secondaryActionVariant,
261 copyErrorText,288 copyErrorText,
262 hasTrailingControls,289 hasTrailingControls,
263 inlineContentEndPad,290 inlineContentEndPad,
...@@ -277,22 +304,35 @@ function ToastBodyContent({...@@ -277,22 +304,35 @@ function ToastBodyContent({
277 copyErrorText,304 copyErrorText,
278 actionProps,305 actionProps,
279 actionVariant,306 actionVariant,
307 secondaryActionVariant,
280 hasTrailingControls,308 hasTrailingControls,
281 toastData,309 toastData,
282 toastDescription,310 toastDescription,
283 toastType,311 toastType,
284}: ToastBodyContentProps) {312}: ToastBodyContentProps) {
313 const secondaryActionProps = toastData?.secondaryActionProps;
314 const leadingIcon = toastData?.leadingIcon;
315 const { className: secondaryActionClassName, ...secondaryActionRest } =
316 secondaryActionProps ?? {};
317
285 return (318 return (
286 <>319 <>
287 <div className={cn("flex min-w-0 gap-2", !stackedActionLayout && "flex-1")}>320 <div className={cn("flex min-w-0 gap-2", !stackedActionLayout && "flex-1")}>
288 {Icon && (321 {leadingIcon ? (
322 <div
323 className="flex h-lh w-4 shrink-0 items-center justify-center"
324 data-slot="toast-icon"
325 >
326 {leadingIcon}
327 </div>
328 ) : Icon ? (
289 <div329 <div
290 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"330 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
291 data-slot="toast-icon"331 data-slot="toast-icon"
292 >332 >
293 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />333 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
294 </div>334 </div>
295 )}335 ) : null}
296 <div336 <div
297 className={cn(337 className={cn(
298 "flex min-h-0 min-w-0 flex-1 flex-col gap-0.5",338 "flex min-h-0 min-w-0 flex-1 flex-col gap-0.5",
...@@ -315,6 +355,16 @@ function ToastBodyContent({...@@ -315,6 +355,16 @@ function ToastBodyContent({
315 )}355 )}
316 >356 >
317 {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null}357 {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null}
358 {secondaryActionProps ? (
359 <button
360 {...secondaryActionRest}
361 className={cn(
362 buttonVariants({ size: "xs", variant: secondaryActionVariant }),
363 secondaryActionClassName,
364 )}
365 type="button"
366 />
367 ) : null}
318 {actionProps ? (368 {actionProps ? (
319 <Toast.Action369 <Toast.Action
320 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}370 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}
...@@ -579,7 +629,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -579,7 +629,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
579 aria-label="Dismiss notification"629 aria-label="Dismiss notification"
580 className={toastCornerOrbClass}630 className={toastCornerOrbClass}
581 data-slot="toast-close"631 data-slot="toast-close"
582 onClick={() => toastManager.close(toast.id)}632 onClick={() =>
633 handleToastDismissClick(toastManager, toast.id, toast.data?.onClose)
634 }
583 type="button"635 type="button"
584 >636 >
585 <XIcon className="size-3" strokeWidth={2.25} />637 <XIcon className="size-3" strokeWidth={2.25} />
...@@ -670,7 +722,13 @@ function AnchoredToasts() {...@@ -670,7 +722,13 @@ function AnchoredToasts() {
670 aria-label="Dismiss notification"722 aria-label="Dismiss notification"
671 className={toastCornerOrbClass}723 className={toastCornerOrbClass}
672 data-slot="toast-close"724 data-slot="toast-close"
673 onClick={() => anchoredToastManager.close(toast.id)}725 onClick={() =>
726 handleToastDismissClick(
727 anchoredToastManager,
728 toast.id,
729 toast.data?.onClose,
730 )
731 }
674 type="button"732 type="button"
675 >733 >
676 <XIcon className="size-3" strokeWidth={2.25} />734 <XIcon className="size-3" strokeWidth={2.25} />
apps/web/src/hooks/useSettings.ts+37-1
...@@ -26,6 +26,7 @@ import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/...@@ -26,6 +26,7 @@ import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/
26const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";26const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
2727
28const clientSettingsListeners = new Set<() => void>();28const clientSettingsListeners = new Set<() => void>();
29const clientSettingsHydrationListeners = new Set<() => void>();
29let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;30let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
30let clientSettingsHydrated = false;31let clientSettingsHydrated = false;
31let clientSettingsHydrationPromise: Promise<void> | null = null;32let clientSettingsHydrationPromise: Promise<void> | null = null;
...@@ -36,6 +37,12 @@ function emitClientSettingsChange() {...@@ -36,6 +37,12 @@ function emitClientSettingsChange() {
36 }37 }
37}38}
3839
40function emitClientSettingsHydrationChange() {
41 for (const listener of clientSettingsHydrationListeners) {
42 listener();
43 }
44}
45
39function getClientSettingsSnapshot(): ClientSettings {46function getClientSettingsSnapshot(): ClientSettings {
40 return clientSettingsSnapshot;47 return clientSettingsSnapshot;
41}48}
...@@ -45,6 +52,14 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void {...@@ -45,6 +52,14 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void {
45 emitClientSettingsChange();52 emitClientSettingsChange();
46}53}
4754
55function setClientSettingsHydrated(nextHydrated: boolean): void {
56 if (clientSettingsHydrated === nextHydrated) {
57 return;
58 }
59 clientSettingsHydrated = nextHydrated;
60 emitClientSettingsHydrationChange();
61}
62
48function subscribeClientSettings(listener: () => void): () => void {63function subscribeClientSettings(listener: () => void): () => void {
49 clientSettingsListeners.add(listener);64 clientSettingsListeners.add(listener);
50 void hydrateClientSettings();65 void hydrateClientSettings();
...@@ -53,6 +68,18 @@ function subscribeClientSettings(listener: () => void): () => void {...@@ -53,6 +68,18 @@ function subscribeClientSettings(listener: () => void): () => void {
53 };68 };
54}69}
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
56async function hydrateClientSettings(): Promise<void> {83async function hydrateClientSettings(): Promise<void> {
57 if (clientSettingsHydrated) {84 if (clientSettingsHydrated) {
58 return;85 return;
...@@ -70,7 +97,7 @@ async function hydrateClientSettings(): Promise<void> {...@@ -70,7 +97,7 @@ async function hydrateClientSettings(): Promise<void> {
70 } catch (error) {97 } catch (error) {
71 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);98 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
72 } finally {99 } finally {
73 clientSettingsHydrated = true;100 setClientSettingsHydrated(true);
74 }101 }
75 })();102 })();
76103
...@@ -132,6 +159,14 @@ export function getClientSettings(): ClientSettings {...@@ -132,6 +159,14 @@ export function getClientSettings(): ClientSettings {
132 return getClientSettingsSnapshot();159 return getClientSettingsSnapshot();
133}160}
134161
162export function useClientSettingsHydrated(): boolean {
163 return useSyncExternalStore(
164 subscribeClientSettingsHydration,
165 getClientSettingsHydratedSnapshot,
166 () => false,
167 );
168}
169
135export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {170export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
136 const serverSettings = useServerSettings();171 const serverSettings = useServerSettings();
137 const clientSettings = useSyncExternalStore(172 const clientSettings = useSyncExternalStore(
...@@ -193,4 +228,5 @@ export function __resetClientSettingsPersistenceForTests(): void {...@@ -193,4 +228,5 @@ export function __resetClientSettingsPersistenceForTests(): void {
193 clientSettingsHydrated = false;228 clientSettingsHydrated = false;
194 clientSettingsHydrationPromise = null;229 clientSettingsHydrationPromise = null;
195 clientSettingsListeners.clear();230 clientSettingsListeners.clear();
231 clientSettingsHydrationListeners.clear();
196}232}
apps/web/src/index.css+13
...@@ -485,6 +485,19 @@ label:has(> select#reasoning-effort) select {...@@ -485,6 +485,19 @@ label:has(> select#reasoning-effort) select {
485 text-align: left;485 text-align: left;
486}486}
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
488/* Diffs theme bridge (match diff surfaces to app palette) */501/* Diffs theme bridge (match diff surfaces to app palette) */
489.diff-panel-viewport {502.diff-panel-viewport {
490 background: color-mix(in srgb, var(--background) 94%, var(--card));503 background: color-mix(in srgb, var(--background) 94%, var(--card));
apps/web/src/localApi.test.ts+31
...@@ -85,6 +85,7 @@ const rpcClientMock = {...@@ -85,6 +85,7 @@ const rpcClientMock = {
85 server: {85 server: {
86 getConfig: vi.fn(),86 getConfig: vi.fn(),
87 refreshProviders: vi.fn(),87 refreshProviders: vi.fn(),
88 updateProvider: vi.fn(),
88 upsertKeybinding: vi.fn(),89 upsertKeybinding: vi.fn(),
89 getSettings: vi.fn(),90 getSettings: vi.fn(),
90 updateSettings: vi.fn(),91 updateSettings: vi.fn(),
...@@ -514,6 +515,34 @@ describe("wsApi", () => {...@@ -514,6 +515,34 @@ describe("wsApi", () => {
514 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();515 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
515 });516 });
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
517 it("forwards server settings updates directly to the RPC client", async () => {546 it("forwards server settings updates directly to the RPC client", async () => {
518 const nextSettings = {547 const nextSettings = {
519 ...DEFAULT_SERVER_SETTINGS,548 ...DEFAULT_SERVER_SETTINGS,
...@@ -573,6 +602,7 @@ describe("wsApi", () => {...@@ -573,6 +602,7 @@ describe("wsApi", () => {
573 autoOpenPlanSidebar: false,602 autoOpenPlanSidebar: false,
574 confirmThreadArchive: true,603 confirmThreadArchive: true,
575 confirmThreadDelete: false,604 confirmThreadDelete: false,
605 dismissedProviderUpdateNotificationKeys: [],
576 diffIgnoreWhitespace: true,606 diffIgnoreWhitespace: true,
577 diffWordWrap: true,607 diffWordWrap: true,
578 favorites: [],608 favorites: [],
...@@ -634,6 +664,7 @@ describe("wsApi", () => {...@@ -634,6 +664,7 @@ describe("wsApi", () => {
634 autoOpenPlanSidebar: false,664 autoOpenPlanSidebar: false,
635 confirmThreadArchive: true,665 confirmThreadArchive: true,
636 confirmThreadDelete: false,666 confirmThreadDelete: false,
667 dismissedProviderUpdateNotificationKeys: [],
637 diffIgnoreWhitespace: true,668 diffIgnoreWhitespace: true,
638 diffWordWrap: true,669 diffWordWrap: true,
639 favorites: [],670 favorites: [],
apps/web/src/localApi.ts+4
...@@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi {...@@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi {
125 rpcClient125 rpcClient
126 ? rpcClient.server.refreshProviders()126 ? rpcClient.server.refreshProviders()
127 : Promise.reject(unavailableLocalBackendError()),127 : Promise.reject(unavailableLocalBackendError()),
128 updateProvider: (input) =>
129 rpcClient
130 ? rpcClient.server.updateProvider(input)
131 : Promise.reject(unavailableLocalBackendError()),
128 upsertKeybinding: (input) =>132 upsertKeybinding: (input) =>
129 rpcClient133 rpcClient
130 ? rpcClient.server.upsertKeybinding(input)134 ? 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'...@@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair'
14import { Route as ChatRouteImport } from './routes/_chat'14import { Route as ChatRouteImport } from './routes/_chat'
15import { Route as ChatIndexRouteImport } from './routes/_chat.index'15import { Route as ChatIndexRouteImport } from './routes/_chat.index'
16import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'16import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
17import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
17import { Route as SettingsGeneralRouteImport } from './routes/settings.general'18import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
18import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'19import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
19import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'20import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
...@@ -44,6 +45,11 @@ const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({...@@ -44,6 +45,11 @@ const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({
44 path: '/source-control',45 path: '/source-control',
45 getParentRoute: () => SettingsRoute,46 getParentRoute: () => SettingsRoute,
46} as any)47} as any)
48const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
49 id: '/providers',
50 path: '/providers',
51 getParentRoute: () => SettingsRoute,
52} as any)
47const SettingsGeneralRoute = SettingsGeneralRouteImport.update({53const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
48 id: '/general',54 id: '/general',
49 path: '/general',55 path: '/general',
...@@ -78,6 +84,7 @@ export interface FileRoutesByFullPath {...@@ -78,6 +84,7 @@ export interface FileRoutesByFullPath {
78 '/settings/archived': typeof SettingsArchivedRoute84 '/settings/archived': typeof SettingsArchivedRoute
79 '/settings/connections': typeof SettingsConnectionsRoute85 '/settings/connections': typeof SettingsConnectionsRoute
80 '/settings/general': typeof SettingsGeneralRoute86 '/settings/general': typeof SettingsGeneralRoute
87 '/settings/providers': typeof SettingsProvidersRoute
81 '/settings/source-control': typeof SettingsSourceControlRoute88 '/settings/source-control': typeof SettingsSourceControlRoute
82 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute89 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
83 '/draft/$draftId': typeof ChatDraftDraftIdRoute90 '/draft/$draftId': typeof ChatDraftDraftIdRoute
...@@ -88,6 +95,7 @@ export interface FileRoutesByTo {...@@ -88,6 +95,7 @@ export interface FileRoutesByTo {
88 '/settings/archived': typeof SettingsArchivedRoute95 '/settings/archived': typeof SettingsArchivedRoute
89 '/settings/connections': typeof SettingsConnectionsRoute96 '/settings/connections': typeof SettingsConnectionsRoute
90 '/settings/general': typeof SettingsGeneralRoute97 '/settings/general': typeof SettingsGeneralRoute
98 '/settings/providers': typeof SettingsProvidersRoute
91 '/settings/source-control': typeof SettingsSourceControlRoute99 '/settings/source-control': typeof SettingsSourceControlRoute
92 '/': typeof ChatIndexRoute100 '/': typeof ChatIndexRoute
93 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute101 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
...@@ -101,6 +109,7 @@ export interface FileRoutesById {...@@ -101,6 +109,7 @@ export interface FileRoutesById {
101 '/settings/archived': typeof SettingsArchivedRoute109 '/settings/archived': typeof SettingsArchivedRoute
102 '/settings/connections': typeof SettingsConnectionsRoute110 '/settings/connections': typeof SettingsConnectionsRoute
103 '/settings/general': typeof SettingsGeneralRoute111 '/settings/general': typeof SettingsGeneralRoute
112 '/settings/providers': typeof SettingsProvidersRoute
104 '/settings/source-control': typeof SettingsSourceControlRoute113 '/settings/source-control': typeof SettingsSourceControlRoute
105 '/_chat/': typeof ChatIndexRoute114 '/_chat/': typeof ChatIndexRoute
106 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute115 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
...@@ -115,6 +124,7 @@ export interface FileRouteTypes {...@@ -115,6 +124,7 @@ export interface FileRouteTypes {
115 | '/settings/archived'124 | '/settings/archived'
116 | '/settings/connections'125 | '/settings/connections'
117 | '/settings/general'126 | '/settings/general'
127 | '/settings/providers'
118 | '/settings/source-control'128 | '/settings/source-control'
119 | '/$environmentId/$threadId'129 | '/$environmentId/$threadId'
120 | '/draft/$draftId'130 | '/draft/$draftId'
...@@ -125,6 +135,7 @@ export interface FileRouteTypes {...@@ -125,6 +135,7 @@ export interface FileRouteTypes {
125 | '/settings/archived'135 | '/settings/archived'
126 | '/settings/connections'136 | '/settings/connections'
127 | '/settings/general'137 | '/settings/general'
138 | '/settings/providers'
128 | '/settings/source-control'139 | '/settings/source-control'
129 | '/'140 | '/'
130 | '/$environmentId/$threadId'141 | '/$environmentId/$threadId'
...@@ -137,6 +148,7 @@ export interface FileRouteTypes {...@@ -137,6 +148,7 @@ export interface FileRouteTypes {
137 | '/settings/archived'148 | '/settings/archived'
138 | '/settings/connections'149 | '/settings/connections'
139 | '/settings/general'150 | '/settings/general'
151 | '/settings/providers'
140 | '/settings/source-control'152 | '/settings/source-control'
141 | '/_chat/'153 | '/_chat/'
142 | '/_chat/$environmentId/$threadId'154 | '/_chat/$environmentId/$threadId'
...@@ -186,6 +198,13 @@ declare module '@tanstack/react-router' {...@@ -186,6 +198,13 @@ declare module '@tanstack/react-router' {
186 preLoaderRoute: typeof SettingsSourceControlRouteImport198 preLoaderRoute: typeof SettingsSourceControlRouteImport
187 parentRoute: typeof SettingsRoute199 parentRoute: typeof SettingsRoute
188 }200 }
201 '/settings/providers': {
202 id: '/settings/providers'
203 path: '/providers'
204 fullPath: '/settings/providers'
205 preLoaderRoute: typeof SettingsProvidersRouteImport
206 parentRoute: typeof SettingsRoute
207 }
189 '/settings/general': {208 '/settings/general': {
190 id: '/settings/general'209 id: '/settings/general'
191 path: '/general'210 path: '/general'
...@@ -242,6 +261,7 @@ interface SettingsRouteChildren {...@@ -242,6 +261,7 @@ interface SettingsRouteChildren {
242 SettingsArchivedRoute: typeof SettingsArchivedRoute261 SettingsArchivedRoute: typeof SettingsArchivedRoute
243 SettingsConnectionsRoute: typeof SettingsConnectionsRoute262 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
244 SettingsGeneralRoute: typeof SettingsGeneralRoute263 SettingsGeneralRoute: typeof SettingsGeneralRoute
264 SettingsProvidersRoute: typeof SettingsProvidersRoute
245 SettingsSourceControlRoute: typeof SettingsSourceControlRoute265 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
246}266}
247267
...@@ -249,6 +269,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {...@@ -249,6 +269,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
249 SettingsArchivedRoute: SettingsArchivedRoute,269 SettingsArchivedRoute: SettingsArchivedRoute,
250 SettingsConnectionsRoute: SettingsConnectionsRoute,270 SettingsConnectionsRoute: SettingsConnectionsRoute,
251 SettingsGeneralRoute: SettingsGeneralRoute,271 SettingsGeneralRoute: SettingsGeneralRoute,
272 SettingsProvidersRoute: SettingsProvidersRoute,
252 SettingsSourceControlRoute: SettingsSourceControlRoute,273 SettingsSourceControlRoute: SettingsSourceControlRoute,
253}274}
254275
apps/web/src/routes/__root.tsx+2
...@@ -14,6 +14,7 @@ import { APP_DISPLAY_NAME } from "../branding";...@@ -14,6 +14,7 @@ import { APP_DISPLAY_NAME } from "../branding";
14import { AppSidebarLayout } from "../components/AppSidebarLayout";14import { AppSidebarLayout } from "../components/AppSidebarLayout";
15import { CommandPalette } from "../components/CommandPalette";15import { CommandPalette } from "../components/CommandPalette";
16import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";16import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
17import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
17import {18import {
18 SlowRpcAckToastCoordinator,19 SlowRpcAckToastCoordinator,
19 WebSocketConnectionCoordinator,20 WebSocketConnectionCoordinator,
...@@ -136,6 +137,7 @@ function RootRouteView() {...@@ -136,6 +137,7 @@ function RootRouteView() {
136 <SshPasswordPromptDialog />137 <SshPasswordPromptDialog />
137 <HostedStaticEnvironmentBootstrap />138 <HostedStaticEnvironmentBootstrap />
138 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}139 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
140 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
139 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}141 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}
140 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}142 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}
141 {primaryEnvironmentAuthenticated ? (143 {primaryEnvironmentAuthenticated ? (
apps/web/src/routes/settings.general.tsx+5-1
...@@ -2,6 +2,10 @@ import { createFileRoute } from "@tanstack/react-router";...@@ -2,6 +2,10 @@ import { createFileRoute } from "@tanstack/react-router";
22
3import { GeneralSettingsPanel } from "../components/settings/SettingsPanels";3import { GeneralSettingsPanel } from "../components/settings/SettingsPanels";
44
5function SettingsGeneralRoute() {
6 return <GeneralSettingsPanel />;
7}
8
5export const Route = createFileRoute("/settings/general")({9export const Route = createFileRoute("/settings/general")({
6 component: GeneralSettingsPanel,10 component: SettingsGeneralRoute,
7});11});
apps/web/src/routes/settings.providers.tsx created+11
...@@ -0,0 +1,11 @@
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 {...@@ -119,6 +119,7 @@ export interface WsRpcClient {
119 readonly refreshProviders: (119 readonly refreshProviders: (
120 input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>,120 input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>,
121 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>;121 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>;
122 readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>;
122 readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;123 readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;
123 readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;124 readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;
124 readonly updateSettings: (125 readonly updateSettings: (
...@@ -234,6 +235,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -234,6 +235,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
234 getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),235 getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
235 refreshProviders: (input) =>236 refreshProviders: (input) =>
236 transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})),237 transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})),
238 updateProvider: (input) =>
239 transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)),
237 upsertKeybinding: (input) =>240 upsertKeybinding: (input) =>
238 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),241 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
239 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),242 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
packages/contracts/src/ipc.ts+2
...@@ -28,6 +28,7 @@ import type {...@@ -28,6 +28,7 @@ import type {
28import type { ProviderInstanceId } from "./providerInstance.ts";28import type { ProviderInstanceId } from "./providerInstance.ts";
29import type {29import type {
30 ServerConfig,30 ServerConfig,
31 ServerProviderUpdateInput,
31 ServerProviderUpdatedPayload,32 ServerProviderUpdatedPayload,
32 ServerUpsertKeybindingResult,33 ServerUpsertKeybindingResult,
33} from "./server.ts";34} from "./server.ts";
...@@ -296,6 +297,7 @@ export interface LocalApi {...@@ -296,6 +297,7 @@ export interface LocalApi {
296 refreshProviders: (input?: {297 refreshProviders: (input?: {
297 readonly instanceId?: ProviderInstanceId;298 readonly instanceId?: ProviderInstanceId;
298 }) => Promise<ServerProviderUpdatedPayload>;299 }) => Promise<ServerProviderUpdatedPayload>;
300 updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>;
299 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;301 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
300 getSettings: () => Promise<ServerSettings>;302 getSettings: () => Promise<ServerSettings>;
301 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;303 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
packages/contracts/src/rpc.ts+10
...@@ -71,6 +71,8 @@ import {...@@ -71,6 +71,8 @@ import {
71import {71import {
72 ServerConfigStreamEvent,72 ServerConfigStreamEvent,
73 ServerConfig,73 ServerConfig,
74 ServerProviderUpdateError,
75 ServerProviderUpdateInput,
74 ServerLifecycleStreamEvent,76 ServerLifecycleStreamEvent,
75 ServerProviderUpdatedPayload,77 ServerProviderUpdatedPayload,
76 ServerUpsertKeybindingInput,78 ServerUpsertKeybindingInput,
...@@ -129,6 +131,7 @@ export const WS_METHODS = {...@@ -129,6 +131,7 @@ export const WS_METHODS = {
129 // Server meta131 // Server meta
130 serverGetConfig: "server.getConfig",132 serverGetConfig: "server.getConfig",
131 serverRefreshProviders: "server.refreshProviders",133 serverRefreshProviders: "server.refreshProviders",
134 serverUpdateProvider: "server.updateProvider",
132 serverUpsertKeybinding: "server.upsertKeybinding",135 serverUpsertKeybinding: "server.upsertKeybinding",
133 serverGetSettings: "server.getSettings",136 serverGetSettings: "server.getSettings",
134 serverUpdateSettings: "server.updateSettings",137 serverUpdateSettings: "server.updateSettings",
...@@ -172,6 +175,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv...@@ -172,6 +175,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv
172 success: ServerProviderUpdatedPayload,175 success: ServerProviderUpdatedPayload,
173});176});
174177
178export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {
179 payload: ServerProviderUpdateInput,
180 success: ServerProviderUpdatedPayload,
181 error: ServerProviderUpdateError,
182});
183
175export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {184export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
176 payload: Schema.Struct({}),185 payload: Schema.Struct({}),
177 success: ServerSettings,186 success: ServerSettings,
...@@ -415,6 +424,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,...@@ -415,6 +424,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,
415export const WsRpcGroup = RpcGroup.make(424export const WsRpcGroup = RpcGroup.make(
416 WsServerGetConfigRpc,425 WsServerGetConfigRpc,
417 WsServerRefreshProvidersRpc,426 WsServerRefreshProvidersRpc,
427 WsServerUpdateProviderRpc,
418 WsServerUpsertKeybindingRpc,428 WsServerUpsertKeybindingRpc,
419 WsServerGetSettingsRpc,429 WsServerGetSettingsRpc,
420 WsServerUpdateSettingsRpc,430 WsServerUpdateSettingsRpc,
packages/contracts/src/server.test.ts+28
...@@ -23,6 +23,34 @@ describe("ServerProvider", () => {...@@ -23,6 +23,34 @@ describe("ServerProvider", () => {
2323
24 expect(parsed.slashCommands).toEqual([]);24 expect(parsed.slashCommands).toEqual([]);
25 expect(parsed.skills).toEqual([]);25 expect(parsed.skills).toEqual([]);
26 expect(parsed.versionAdvisory).toBeUndefined();
27 expect(parsed.updateState).toBeUndefined();
28 });
29
30 it("defaults one-click update support when decoding older advisory snapshots", () => {
31 const parsed = decodeServerProvider({
32 instanceId: "codex",
33 driver: "codex",
34 enabled: true,
35 installed: true,
36 version: "1.0.0",
37 status: "ready",
38 auth: {
39 status: "authenticated",
40 },
41 checkedAt: "2026-04-10T00:00:00.000Z",
42 models: [],
43 versionAdvisory: {
44 status: "behind_latest",
45 currentVersion: "1.0.0",
46 latestVersion: "1.0.1",
47 updateCommand: "npm install -g @openai/codex@latest",
48 checkedAt: "2026-04-10T00:00:00.000Z",
49 message: "Update available.",
50 },
51 });
52
53 expect(parsed.versionAdvisory?.canUpdate).toBe(false);
26 });54 });
2755
28 it("decodes continuation group metadata", () => {56 it("decodes continuation group metadata", () => {
packages/contracts/src/server.ts+58
...@@ -109,6 +109,43 @@ export const ServerProviderContinuation = Schema.Struct({...@@ -109,6 +109,43 @@ export const ServerProviderContinuation = Schema.Struct({
109});109});
110export type ServerProviderContinuation = typeof ServerProviderContinuation.Type;110export 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
112export const ServerProvider = Schema.Struct({149export const ServerProvider = Schema.Struct({
113 // Routing key for the configured instance this snapshot represents. This150 // Routing key for the configured instance this snapshot represents. This
114 // is the only stable identity consumers may use for provider routing.151 // is the only stable identity consumers may use for provider routing.
...@@ -142,6 +179,8 @@ export const ServerProvider = Schema.Struct({...@@ -142,6 +179,8 @@ export const ServerProvider = Schema.Struct({
142 Schema.withDecodingDefault(Effect.succeed([])),179 Schema.withDecodingDefault(Effect.succeed([])),
143 ),180 ),
144 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),181 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
182 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
183 updateState: Schema.optionalKey(ServerProviderUpdateState),
145});184});
146export type ServerProvider = typeof ServerProvider.Type;185export type ServerProvider = typeof ServerProvider.Type;
147186
...@@ -294,3 +333,22 @@ export const ServerProviderUpdatedPayload = Schema.Struct({...@@ -294,3 +333,22 @@ export const ServerProviderUpdatedPayload = Schema.Struct({
294 providers: ServerProviders,333 providers: ServerProviders,
295});334});
296export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type;335export 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({...@@ -32,6 +32,9 @@ export const ClientSettingsSchema = Schema.Struct({
32 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),32 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
33 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),33 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
34 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),34 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
35 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
36 Schema.withDecodingDefault(Effect.succeed([])),
37 ),
35 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),38 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
36 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),39 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
37 // Model favorites. Historically keyed by provider kind, now40 // Model favorites. Historically keyed by provider kind, now
packages/effect-acp/src/client.test.ts-1
...@@ -24,7 +24,6 @@ const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeReque...@@ -24,7 +24,6 @@ const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeReque
24const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);24const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
25const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));25const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
26const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));26const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
27
28const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>27const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
29 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),28 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
30);29);
packages/effect-acp/src/protocol.test.ts-1
...@@ -41,7 +41,6 @@ const RequestPermissionRequest = jsonRpcRequest(...@@ -41,7 +41,6 @@ const RequestPermissionRequest = jsonRpcRequest(
41const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);41const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);
42const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));42const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
43const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));43const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
44
45const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>44const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
46 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),45 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
47);46);
packages/shared/src/shell.test.ts+12
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 readEnvironmentFromWindowsShell,10 readEnvironmentFromWindowsShell,
11 readPathFromLaunchctl,11 readPathFromLaunchctl,
12 readPathFromLoginShell,12 readPathFromLoginShell,
13 resolveCommandPath,
13 resolveKnownWindowsCliDirs,14 resolveKnownWindowsCliDirs,
14 resolveWindowsEnvironment,15 resolveWindowsEnvironment,
15} from "./shell.ts";16} from "./shell.ts";
...@@ -332,6 +333,17 @@ describe("isCommandAvailable", () => {...@@ -332,6 +333,17 @@ describe("isCommandAvailable", () => {
332 });333 });
333});334});
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
335describe("resolveWindowsEnvironment", () => {347describe("resolveWindowsEnvironment", () => {
336 it("returns the baseline no-profile PATH patch when node is already available", () => {348 it("returns the baseline no-profile PATH patch when node is already available", () => {
337 const readEnvironment = vi.fn(349 const readEnvironment = vi.fn(
packages/shared/src/shell.ts+20-9
...@@ -376,23 +376,26 @@ function isExecutableFile(...@@ -376,23 +376,26 @@ function isExecutableFile(
376 }376 }
377}377}
378378
379export function isCommandAvailable(379export function resolveCommandPath(
380 command: string,380 command: string,
381 options: CommandAvailabilityOptions = {},381 options: CommandAvailabilityOptions = {},
382): boolean {382): string | null {
383 const platform = options.platform ?? process.platform;383 const platform = options.platform ?? process.platform;
384 const env = options.env ?? process.env;384 const env = options.env ?? process.env;
385 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];385 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
386 const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);386 const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
387387
388 if (command.includes("/") || command.includes("\\")) {388 if (command.includes("/") || command.includes("\\")) {
389 return commandCandidates.some((candidate) =>389 for (const candidate of commandCandidates) {
390 isExecutableFile(candidate, platform, windowsPathExtensions),390 if (isExecutableFile(candidate, platform, windowsPathExtensions)) {
391 );391 return candidate;
392 }
393 }
394 return null;
392 }395 }
393396
394 const pathValue = resolvePathEnvironmentVariable(env);397 const pathValue = resolvePathEnvironmentVariable(env);
395 if (pathValue.length === 0) return false;398 if (pathValue.length === 0) return null;
396 const pathEntries = pathValue399 const pathEntries = pathValue
397 .split(pathDelimiterForPlatform(platform))400 .split(pathDelimiterForPlatform(platform))
398 .map((entry) => stripWrappingQuotes(entry.trim()))401 .map((entry) => stripWrappingQuotes(entry.trim()))
...@@ -400,12 +403,20 @@ export function isCommandAvailable(...@@ -400,12 +403,20 @@ export function isCommandAvailable(
400403
401 for (const pathEntry of pathEntries) {404 for (const pathEntry of pathEntries) {
402 for (const candidate of commandCandidates) {405 for (const candidate of commandCandidates) {
403 if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {406 const candidatePath = join(pathEntry, candidate);
404 return true;407 if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
408 return candidatePath;
405 }409 }
406 }410 }
407 }411 }
408 return false;412 return null;
413}
414
415export function isCommandAvailable(
416 command: string,
417 options: CommandAvailabilityOptions = {},
418): boolean {
419 return resolveCommandPath(command, options) !== null;
409}420}
410421
411export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {422export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {