| author | |
| committer | |
| log | 96c4bfa0a27f776f7cd17df7c7115c3de79ee399 |
| tree | 0cbdd1ee4c8000d21323e2c473b9d749480d27ef |
| parent | a493946bb42ab16e1d18285dac3e9e4603651330 |
| signature | Signed by PGP key B5690EEEBB952194 |
22 files changed, 1099 insertions(+), 59 deletions(-)
apps/mobile/src/features/threads/ThreadComposer.tsx+17| ... | @@ -651,6 +651,23 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -651,6 +651,23 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 651 | </View> | 651 | </View> |
| 652 | ) : null} | 652 | ) : null} |
| 653 | 653 | ||
| 654 | {selectedProviderStatus?.compatibilityAdvisory?.message && | ||
| 655 | (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" || | ||
| 656 | selectedProviderStatus.compatibilityAdvisory.status === "broken") ? ( | ||
| 657 | <Text | ||
| 658 | accessibilityRole={ | ||
| 659 | selectedProviderStatus.compatibilityAdvisory.status === "broken" ? "alert" : undefined | ||
| 660 | } | ||
| 661 | accessibilityLiveRegion={ | ||
| 662 | selectedProviderStatus.compatibilityAdvisory.status === "broken" | ||
| 663 | ? "assertive" | ||
| 664 | : "polite" | ||
| 665 | } | ||
| 666 | className="px-3 py-2 text-xs text-foreground" | ||
| 667 | > | ||
| 668 | {selectedProviderStatus.compatibilityAdvisory.message} | ||
| 669 | </Text> | ||
| 670 | ) : null} | ||
| 654 | {modelUnavailable ? ( | 671 | {modelUnavailable ? ( |
| 655 | <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}> | 672 | <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}> |
| 656 | <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text> | 673 | <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text> |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+2-1| ... | @@ -77,12 +77,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; | ... | @@ -77,12 +77,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; |
| 77 | const encoder = new TextEncoder(); | 77 | const encoder = new TextEncoder(); |
| 78 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | 78 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 79 | 79 | ||
| 80 | // Provider metadata checks use a bundled manifest and stubbed HTTP. | ||
| 80 | const TestHttpClientLive = Layer.succeed( | 81 | const TestHttpClientLive = Layer.succeed( |
| 81 | HttpClient.HttpClient, | 82 | HttpClient.HttpClient, |
| 82 | HttpClient.make((request) => | 83 | HttpClient.make((request) => |
| 83 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), | 84 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), |
| 84 | ), | 85 | ), |
| 85 | ); | 86 | ).pipe(Layer.provideMerge(ModelManifest.layerTest)); |
| 86 | 87 | ||
| 87 | const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | 88 | const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ |
| 88 | reportClientActivity: () => Effect.void, | 89 | reportClientActivity: () => Effect.void, |
apps/server/src/provider/Layers/ProviderRegistry.ts+35-3| ... | @@ -40,6 +40,8 @@ import * as Ref from "effect/Ref"; | ... | @@ -40,6 +40,8 @@ import * as Ref from "effect/Ref"; |
| 40 | import * as Stream from "effect/Stream"; | 40 | import * as Stream from "effect/Stream"; |
| 41 | import * as Semaphore from "effect/Semaphore"; | 41 | import * as Semaphore from "effect/Semaphore"; |
| 42 | 42 | ||
| 43 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 44 | import { applyProviderCompatibility } from "../providerCompatibility.ts"; | ||
| 43 | import { ServerConfig } from "../../config.ts"; | 45 | import { ServerConfig } from "../../config.ts"; |
| 44 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | 46 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 45 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; | 47 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; |
| ... | @@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect( |
| 278 | ProviderRegistry, | 280 | ProviderRegistry, |
| 279 | Effect.gen(function* () { | 281 | Effect.gen(function* () { |
| 280 | const instanceRegistry = yield* ProviderInstanceRegistry; | 282 | const instanceRegistry = yield* ProviderInstanceRegistry; |
| 283 | const manifestService = yield* ModelManifest.ModelManifest; | ||
| 284 | const serviceScope = yield* Effect.scope; | ||
| 281 | const config = yield* ServerConfig; | 285 | const config = yield* ServerConfig; |
| 282 | const fileSystem = yield* FileSystem.FileSystem; | 286 | const fileSystem = yield* FileSystem.FileSystem; |
| 283 | const path = yield* Path.Path; | 287 | const path = yield* Path.Path; |
| ... | @@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect( |
| 356 | ), | 360 | ), |
| 357 | ), | 361 | ), |
| 358 | ); | 362 | ); |
| 359 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); | 363 | const initialManifest = yield* manifestService.current; |
| 364 | const classifyCompatibility = ( | ||
| 365 | provider: ServerProvider, | ||
| 366 | manifest: ModelManifest.ModelManifestData, | ||
| 367 | ) => | ||
| 368 | applyProviderCompatibility( | ||
| 369 | provider, | ||
| 370 | manifest.compatibility, | ||
| 371 | ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, | ||
| 372 | ); | ||
| 373 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>( | ||
| 374 | cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), | ||
| 375 | ); | ||
| 360 | const workspaceRefreshesRef = yield* Ref.make< | 376 | const workspaceRefreshesRef = yield* Ref.make< |
| 361 | ReadonlyMap<ProviderInstance, ReadonlySet<string>> | 377 | ReadonlyMap<ProviderInstance, ReadonlySet<string>> |
| 362 | >(new Map()); | 378 | >(new Map()); |
| ... | @@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect( |
| 424 | readonly replace?: boolean; | 440 | readonly replace?: boolean; |
| 425 | }, | 441 | }, |
| 426 | ) { | 442 | ) { |
| 443 | const manifest = yield* manifestService.current; | ||
| 427 | const nextProvidersWithUpdateState = yield* Effect.forEach( | 444 | const nextProvidersWithUpdateState = yield* Effect.forEach( |
| 428 | nextProviders, | 445 | nextProviders, |
| 429 | applyProviderUpdateState, | 446 | applyProviderUpdateState, |
| ... | @@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect( |
| 450 | ); | 467 | ); |
| 451 | } | 468 | } |
| 452 | 469 | ||
| 453 | const providers = orderProviderSnapshots([...mergedProviders.values()]); | 470 | const providers = orderProviderSnapshots( |
| 471 | [...mergedProviders.values()].map((provider) => | ||
| 472 | classifyCompatibility(provider, manifest), | ||
| 473 | ), | ||
| 474 | ); | ||
| 454 | const providersToPersist = providers.filter((provider) => | 475 | const providersToPersist = providers.filter((provider) => |
| 455 | updatedKeys.has(snapshotInstanceKey(provider)), | 476 | updatedKeys.has(snapshotInstanceKey(provider)), |
| 456 | ); | 477 | ); |
| ... | @@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect( |
| 473 | return providers; | 494 | return providers; |
| 474 | }); | 495 | }); |
| 475 | 496 | ||
| 497 | const compatibilityRefreshRunning = yield* Ref.make(false); | ||
| 476 | const syncProvider = Effect.fn("syncProvider")(function* ( | 498 | const syncProvider = Effect.fn("syncProvider")(function* ( |
| 477 | provider: ServerProvider, | 499 | provider: ServerProvider, |
| 478 | options?: { | 500 | options?: { |
| 479 | readonly publish?: boolean; | 501 | readonly publish?: boolean; |
| 480 | }, | 502 | }, |
| 481 | ) { | 503 | ) { |
| 482 | return yield* upsertProviders([provider], options); | 504 | const providers = yield* upsertProviders([provider], options); |
| 505 | // Reclassify the current read model after fetching. Never republish the | ||
| 506 | // probe captured before the fetch: a newer health result may have landed. | ||
| 507 | if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { | ||
| 508 | yield* manifestService.refresh.pipe( | ||
| 509 | Effect.andThen(upsertProviders([], { persist: false })), | ||
| 510 | Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), | ||
| 511 | Effect.forkIn(serviceScope), | ||
| 512 | ); | ||
| 513 | } | ||
| 514 | return providers; | ||
| 483 | }); | 515 | }); |
| 484 | 516 | ||
| 485 | const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( | 517 | const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( |
apps/server/src/provider/ModelManifest.test.ts+40| ... | @@ -549,3 +549,43 @@ describe("ModelManifest service", () => { | ... | @@ -549,3 +549,43 @@ describe("ModelManifest service", () => { |
| 549 | ), | 549 | ), |
| 550 | ); | 550 | ); |
| 551 | }); | 551 | }); |
| 552 | |||
| 553 | it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { | ||
| 554 | const remote: ModelManifestData = { | ||
| 555 | ...REMOTE_MANIFEST, | ||
| 556 | compatibility: [ | ||
| 557 | { | ||
| 558 | driver: "codex", | ||
| 559 | t3CodeRange: ">=0.0.42", | ||
| 560 | recommendedVersion: "2.0.0", | ||
| 561 | ranges: [{ range: "=2.0.0", status: "supported" }], | ||
| 562 | }, | ||
| 563 | ], | ||
| 564 | }; | ||
| 565 | let invalid = false; | ||
| 566 | return Effect.gen(function* () { | ||
| 567 | const service = yield* make; | ||
| 568 | assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); | ||
| 569 | invalid = true; | ||
| 570 | yield* TestClock.adjust("1 hour"); | ||
| 571 | assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); | ||
| 572 | const rebooted = yield* make; | ||
| 573 | assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); | ||
| 574 | }).pipe( | ||
| 575 | Effect.scoped, | ||
| 576 | Effect.provide( | ||
| 577 | serviceLayers({ | ||
| 578 | prefix: "model-manifest-compatibility-test", | ||
| 579 | response: () => | ||
| 580 | Response.json( | ||
| 581 | invalid | ||
| 582 | ? { | ||
| 583 | ...remote, | ||
| 584 | compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], | ||
| 585 | } | ||
| 586 | : remote, | ||
| 587 | ), | ||
| 588 | }), | ||
| 589 | ), | ||
| 590 | ); | ||
| 591 | }); |
apps/server/src/provider/ModelManifest.ts+2| ... | @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts"; | ... | @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts"; |
| 34 | import * as ServerSettings from "../serverSettings.ts"; | 34 | import * as ServerSettings from "../serverSettings.ts"; |
| 35 | import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; | 35 | import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; |
| 36 | import bundledManifestJson from "./model-manifest.json" with { type: "json" }; | 36 | import bundledManifestJson from "./model-manifest.json" with { type: "json" }; |
| 37 | import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; | ||
| 37 | import type { ServerProviderDraft } from "./providerSnapshot.ts"; | 38 | import type { ServerProviderDraft } from "./providerSnapshot.ts"; |
| 38 | 39 | ||
| 39 | const MODEL_MANIFEST_URL = | 40 | const MODEL_MANIFEST_URL = |
| ... | @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({ | ... | @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({ |
| 89 | * files still decode; they count as older than any dated bundle. | 90 | * files still decode; they count as older than any dated bundle. |
| 90 | */ | 91 | */ |
| 91 | updatedAt: Schema.optional(Schema.String), | 92 | updatedAt: Schema.optional(Schema.String), |
| 93 | compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), | ||
| 92 | currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), | 94 | currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), |
| 93 | providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), | 95 | providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), |
| 94 | }); | 96 | }); |
apps/server/src/provider/model-manifest.json+9-1| ... | @@ -1,6 +1,14 @@ | ... | @@ -1,6 +1,14 @@ |
| 1 | { | 1 | { |
| 2 | "version": 1, | 2 | "version": 1, |
| 3 | "updatedAt": "2026-09-22T19:42:55Z", | 3 | "updatedAt": "2026-09-22T21:40:00Z", |
| 4 | "compatibility": [ | ||
| 5 | { | ||
| 6 | "driver": "opencode", | ||
| 7 | "t3CodeRange": ">=0.0.42", | ||
| 8 | "recommendedRange": ">=1.14.19", | ||
| 9 | "ranges": [{ "range": "<1.14.19", "status": "broken" }] | ||
| 10 | } | ||
| 11 | ], | ||
| 4 | "currentModels": { | 12 | "currentModels": { |
| 5 | "codex": [ | 13 | "codex": [ |
| 6 | "gpt-6-astra", | 14 | "gpt-6-astra", |
apps/server/src/provider/providerCompatibility.test.ts created+258| ... | @@ -0,0 +1,258 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as PubSub from "effect/PubSub"; | ||
| 9 | import * as Ref from "effect/Ref"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import * as ServerConfig from "../config.ts"; | ||
| 12 | import * as ModelManifest from "./ModelManifest.ts"; | ||
| 13 | import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts"; | ||
| 14 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; | ||
| 15 | import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; | ||
| 16 | import type { ProviderInstance } from "./ProviderDriver.ts"; | ||
| 17 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import { | ||
| 20 | applyProviderCompatibility, | ||
| 21 | ProviderCompatibilityPolicy, | ||
| 22 | resolveProviderCompatibility, | ||
| 23 | } from "./providerCompatibility.ts"; | ||
| 24 | |||
| 25 | const driver = ProviderDriverKind.make("codex"); | ||
| 26 | const policy: ProviderCompatibilityPolicy = { | ||
| 27 | driver, | ||
| 28 | t3CodeRange: ">=0.0.42 <0.1.0", | ||
| 29 | recommendedVersion: "2.0.0", | ||
| 30 | recommendedRange: ">=2.0.0 <3.0.0", | ||
| 31 | ranges: [ | ||
| 32 | { range: "<1.0.0", status: "broken" }, | ||
| 33 | { range: ">=1.0.0 <1.5.0", status: "unsupported" }, | ||
| 34 | { range: ">=1.5.0 <2.0.0", status: "graceful" }, | ||
| 35 | { range: ">=2.0.0 <3.0.0", status: "supported" }, | ||
| 36 | ], | ||
| 37 | }; | ||
| 38 | const provider: ServerProvider = { | ||
| 39 | driver, | ||
| 40 | instanceId: ProviderInstanceId.make("codex-work"), | ||
| 41 | enabled: true, | ||
| 42 | installed: true, | ||
| 43 | version: "0.9.0", | ||
| 44 | status: "error", | ||
| 45 | message: "Authentication failed", | ||
| 46 | checkedAt: "2026-09-22T00:00:00Z", | ||
| 47 | auth: { status: "unauthenticated" }, | ||
| 48 | models: [], | ||
| 49 | skills: [], | ||
| 50 | slashCommands: [], | ||
| 51 | }; | ||
| 52 | |||
| 53 | describe("provider compatibility", () => { | ||
| 54 | it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { | ||
| 55 | for (const [version, expected] of [ | ||
| 56 | ["0.9.9", "broken"], | ||
| 57 | ["1.0.0", "unsupported"], | ||
| 58 | ["1.5.0", "graceful"], | ||
| 59 | ["2.0.0", "supported"], | ||
| 60 | ["v2.0.0", "supported"], | ||
| 61 | ["3.0.0", "unknown"], | ||
| 62 | ["2.0.0-beta.1", "unknown"], | ||
| 63 | ["agy_acp_server_20260818_01_RC01", "unknown"], | ||
| 64 | [null, "unknown"], | ||
| 65 | ] as const) { | ||
| 66 | assert.strictEqual(resolveProviderCompatibility([policy], driver, version)?.status, expected); | ||
| 67 | } | ||
| 68 | assert.isUndefined(resolveProviderCompatibility([policy], driver, "0.9.0", "0.1.0")); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("supports every driver without inventing policies for uncovered adapters", () => { | ||
| 72 | for (const kind of [ | ||
| 73 | "codex", | ||
| 74 | "claudeAgent", | ||
| 75 | "cursor", | ||
| 76 | "grok", | ||
| 77 | "opencode", | ||
| 78 | "antigravity", | ||
| 79 | "customDriver", | ||
| 80 | ]) { | ||
| 81 | const adapter = ProviderDriverKind.make(kind); | ||
| 82 | assert.strictEqual( | ||
| 83 | resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, | ||
| 84 | "supported", | ||
| 85 | ); | ||
| 86 | assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); | ||
| 87 | } | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { | ||
| 91 | const broken = applyProviderCompatibility(provider, [], [policy]); | ||
| 92 | assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); | ||
| 93 | const relaxed = { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }; | ||
| 94 | const supported = applyProviderCompatibility(broken, [relaxed], [policy]); | ||
| 95 | assert.strictEqual(supported.compatibilityAdvisory?.status, "supported"); | ||
| 96 | assert.strictEqual(supported.status, "error"); | ||
| 97 | assert.strictEqual(supported.message, "Authentication failed"); | ||
| 98 | assert.strictEqual( | ||
| 99 | applyProviderCompatibility(supported, [{ ...policy, t3CodeRange: ">=9.0.0" }], [policy]) | ||
| 100 | .compatibilityAdvisory?.status, | ||
| 101 | "broken", | ||
| 102 | ); | ||
| 103 | const removed = applyProviderCompatibility(supported, [], []); | ||
| 104 | assert.isUndefined(removed.compatibilityAdvisory); | ||
| 105 | assert.strictEqual(removed.status, "error"); | ||
| 106 | assert.strictEqual(removed.message, "Authentication failed"); | ||
| 107 | assert.isUndefined( | ||
| 108 | applyProviderCompatibility({ ...broken, enabled: false }, [], [policy]).compatibilityAdvisory, | ||
| 109 | ); | ||
| 110 | assert.isUndefined( | ||
| 111 | applyProviderCompatibility({ ...broken, installed: false }, [], [policy]) | ||
| 112 | .compatibilityAdvisory, | ||
| 113 | ); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("rejects invalid ranges and recommendations outside the first supported match", () => { | ||
| 117 | const decode = Schema.decodeUnknownSync(ProviderCompatibilityPolicy); | ||
| 118 | assert.doesNotThrow(() => decode(policy)); | ||
| 119 | const prefixed = decode({ | ||
| 120 | ...policy, | ||
| 121 | t3CodeRange: ">=v0.0.42 <v0.1", | ||
| 122 | recommendedRange: "^v2", | ||
| 123 | ranges: [{ range: ">=v2.0 <v3", status: "supported" }], | ||
| 124 | }); | ||
| 125 | assert.strictEqual( | ||
| 126 | resolveProviderCompatibility([prefixed], driver, "2.0.0")?.status, | ||
| 127 | "supported", | ||
| 128 | ); | ||
| 129 | assert.strictEqual( | ||
| 130 | resolveProviderCompatibility([prefixed], driver, "3.0.0")?.status, | ||
| 131 | "unknown", | ||
| 132 | ); | ||
| 133 | for (const invalid of [ | ||
| 134 | { ...policy, t3CodeRange: "*" }, | ||
| 135 | { ...policy, recommendedVersion: "3.0.0" }, | ||
| 136 | { ...policy, ranges: [{ range: ">=2.0.0 garbage", status: "supported" }] }, | ||
| 137 | { ...policy, recommendedVersion: "2.0.0; echo unsafe" }, | ||
| 138 | { ...policy, ranges: [{ range: ">=0.0.0", status: "broken" }, ...policy.ranges] }, | ||
| 139 | ]) | ||
| 140 | assert.throws(() => decode(invalid)); | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it.effect("a remote policy refresh preserves a newer health result on the registry stream", () => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | const started = yield* Deferred.make<void>(); | ||
| 147 | const release = yield* Deferred.make<void>(); | ||
| 148 | const health = yield* Ref.make<ServerProvider>({ | ||
| 149 | ...provider, | ||
| 150 | status: "ready" as ServerProvider["status"], | ||
| 151 | message: "Healthy", | ||
| 152 | }); | ||
| 153 | const manifest = yield* Ref.make<ModelManifest.ModelManifestData>({ | ||
| 154 | version: 1, | ||
| 155 | currentModels: {}, | ||
| 156 | compatibility: [policy], | ||
| 157 | }); | ||
| 158 | const instance: ProviderInstance = { | ||
| 159 | instanceId: provider.instanceId, | ||
| 160 | driverKind: driver, | ||
| 161 | enabled: true, | ||
| 162 | displayName: undefined, | ||
| 163 | continuationIdentity: { driverKind: driver, continuationKey: "test-codex" }, | ||
| 164 | snapshot: { | ||
| 165 | getSnapshot: Ref.get(health), | ||
| 166 | refresh: Ref.get(health), | ||
| 167 | streamChanges: Stream.empty, | ||
| 168 | applyUsageLimits: () => Effect.void, | ||
| 169 | resolveMaintenance: () => | ||
| 170 | Effect.succeed( | ||
| 171 | makeManualOnlyProviderMaintenanceCapabilities({ provider: driver, packageName: null }), | ||
| 172 | ), | ||
| 173 | }, | ||
| 174 | adapter: {} as ProviderInstance["adapter"], | ||
| 175 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 176 | }; | ||
| 177 | const refresh = Deferred.succeed(started, undefined).pipe( | ||
| 178 | Effect.andThen(Deferred.await(release)), | ||
| 179 | Effect.andThen( | ||
| 180 | Ref.updateAndGet(manifest, (current) => ({ | ||
| 181 | ...current, | ||
| 182 | compatibility: [ | ||
| 183 | { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }, | ||
| 184 | ], | ||
| 185 | })), | ||
| 186 | ), | ||
| 187 | ); | ||
| 188 | const dependencies = Layer.mergeAll( | ||
| 189 | Layer.succeed(ModelManifest.ModelManifest, { | ||
| 190 | current: Ref.get(manifest), | ||
| 191 | refresh, | ||
| 192 | forceRefresh: refresh, | ||
| 193 | refreshInBackground: Effect.void, | ||
| 194 | }), | ||
| 195 | Layer.succeed(ProviderInstanceRegistry, { | ||
| 196 | getInstance: (id) => Effect.succeed(id === instance.instanceId ? instance : undefined), | ||
| 197 | listInstances: Effect.succeed([instance]), | ||
| 198 | listUnavailable: Effect.succeed([]), | ||
| 199 | streamChanges: Stream.empty, | ||
| 200 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | ||
| 201 | }), | ||
| 202 | ServerConfig.layerTest(process.cwd(), { prefix: "compatibility-registry-test" }).pipe( | ||
| 203 | Layer.provideMerge(NodeServices.layer), | ||
| 204 | ), | ||
| 205 | ); | ||
| 206 | yield* Effect.gen(function* () { | ||
| 207 | const registry = yield* ProviderRegistry; | ||
| 208 | yield* Deferred.await(started); | ||
| 209 | assert.strictEqual( | ||
| 210 | (yield* registry.getProviders)[0]?.compatibilityAdvisory?.status, | ||
| 211 | "broken", | ||
| 212 | ); | ||
| 213 | yield* Ref.set(health, provider); | ||
| 214 | const latestHealth = yield* registry.refreshInstance(provider.instanceId); | ||
| 215 | assert.strictEqual(latestHealth[0]?.message, "Authentication failed"); | ||
| 216 | const supported = yield* Stream.toPull( | ||
| 217 | registry.streamChanges.pipe( | ||
| 218 | Stream.filter((snapshots) => snapshots[0]?.compatibilityAdvisory?.status === "supported"), | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | const subscribed = yield* supported.pipe(Effect.forkScoped({ startImmediately: true })); | ||
| 222 | yield* Deferred.succeed(release, undefined); | ||
| 223 | const updated = (yield* Fiber.join(subscribed))[0]?.[0]; | ||
| 224 | assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); | ||
| 225 | assert.strictEqual(updated?.status, "error"); | ||
| 226 | assert.strictEqual(updated?.message, "Authentication failed"); | ||
| 227 | }).pipe(Effect.provide(ProviderRegistryLive.pipe(Layer.provide(dependencies)))); | ||
| 228 | }).pipe(Effect.scoped), | ||
| 229 | ); | ||
| 230 | |||
| 231 | it("recomputes latest-version compatibility without losing the underlying update advisory", () => { | ||
| 232 | const snapshot: ServerProvider = { | ||
| 233 | ...provider, | ||
| 234 | version: "2.0.0", | ||
| 235 | versionAdvisory: { | ||
| 236 | status: "behind_latest", | ||
| 237 | currentVersion: "2.0.0", | ||
| 238 | latestVersion: "4.0.0", | ||
| 239 | canUpdate: true, | ||
| 240 | updateCommand: "npm install -g fixture@latest", | ||
| 241 | checkedAt: provider.checkedAt, | ||
| 242 | message: null, | ||
| 243 | }, | ||
| 244 | }; | ||
| 245 | const blockedPolicy: ProviderCompatibilityPolicy = { | ||
| 246 | ...policy, | ||
| 247 | ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], | ||
| 248 | }; | ||
| 249 | const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); | ||
| 250 | assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); | ||
| 251 | const relaxed = applyProviderCompatibility( | ||
| 252 | blocked, | ||
| 253 | [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], | ||
| 254 | [], | ||
| 255 | ); | ||
| 256 | assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); | ||
| 257 | assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); | ||
| 258 | }); | ||
apps/server/src/provider/providerCompatibility.ts created+119| ... | @@ -0,0 +1,119 @@ | ||
| 1 | import { | ||
| 2 | TrimmedNonEmptyString, | ||
| 3 | ServerProviderCompatibilityStatus, | ||
| 4 | type ProviderDriverKind, | ||
| 5 | type ServerProvider, | ||
| 6 | type ServerProviderCompatibilityAdvisory, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 11 | |||
| 12 | // Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. | ||
| 13 | // Release tags and prereleases remain unknown instead of matching stable ranges. | ||
| 14 | const StableVersion = TrimmedNonEmptyString.pipe( | ||
| 15 | Schema.check(Schema.makeFilter((value) => /^\d+\.\d+\.\d+$/.test(value))), | ||
| 16 | ); | ||
| 17 | const VersionRange = TrimmedNonEmptyString.pipe( | ||
| 18 | Schema.check( | ||
| 19 | Schema.makeFilter((value) => | ||
| 20 | value.split("||").every((group) => { | ||
| 21 | const tokens = group.trim().split(/\s+/); | ||
| 22 | return tokens.every((token) => /^(?:\^|>=|>|<=|<|=)?v?\d+(?:\.\d+){0,2}$/.test(token)); | ||
| 23 | }), | ||
| 24 | ), | ||
| 25 | ), | ||
| 26 | ); | ||
| 27 | const Policy = Schema.Struct({ | ||
| 28 | driver: TrimmedNonEmptyString, | ||
| 29 | t3CodeRange: VersionRange, | ||
| 30 | recommendedRange: Schema.optionalKey(VersionRange), | ||
| 31 | recommendedVersion: Schema.optionalKey(StableVersion), | ||
| 32 | ranges: Schema.Array( | ||
| 33 | Schema.Struct({ | ||
| 34 | range: VersionRange, | ||
| 35 | status: ServerProviderCompatibilityStatus, | ||
| 36 | }), | ||
| 37 | ), | ||
| 38 | }); | ||
| 39 | |||
| 40 | export const ProviderCompatibilityPolicy = Policy.pipe( | ||
| 41 | Schema.check( | ||
| 42 | Schema.makeFilter( | ||
| 43 | (policy) => { | ||
| 44 | const version = policy.recommendedVersion; | ||
| 45 | if (version === undefined) return true; | ||
| 46 | return ( | ||
| 47 | (policy.recommendedRange === undefined || | ||
| 48 | satisfiesSemverRange(version, policy.recommendedRange)) && | ||
| 49 | policy.ranges.find((entry) => satisfiesSemverRange(version, entry.range))?.status === | ||
| 50 | "supported" | ||
| 51 | ); | ||
| 52 | }, | ||
| 53 | { expected: "a recommended version in a supported range" }, | ||
| 54 | ), | ||
| 55 | ), | ||
| 56 | ); | ||
| 57 | export type ProviderCompatibilityPolicy = typeof ProviderCompatibilityPolicy.Type; | ||
| 58 | |||
| 59 | export function resolveProviderCompatibility( | ||
| 60 | policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined, | ||
| 61 | driver: ProviderDriverKind, | ||
| 62 | version: string | null, | ||
| 63 | t3CodeVersion = packageJson.version, | ||
| 64 | ): ServerProviderCompatibilityAdvisory | undefined { | ||
| 65 | const policy = policies?.find( | ||
| 66 | (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), | ||
| 67 | ); | ||
| 68 | if (!policy) return undefined; | ||
| 69 | const stable = version?.replace(/^v/, ""); | ||
| 70 | const status = | ||
| 71 | stable && /^\d+\.\d+\.\d+$/.test(stable) | ||
| 72 | ? (policy.ranges.find((entry) => satisfiesSemverRange(stable, entry.range))?.status ?? | ||
| 73 | "unknown") | ||
| 74 | : "unknown"; | ||
| 75 | const message = | ||
| 76 | status === "broken" | ||
| 77 | ? "This provider version is known to be incompatible with this T3 Code release." | ||
| 78 | : status === "unsupported" | ||
| 79 | ? "This provider version is outside the supported range for this T3 Code release." | ||
| 80 | : status === "graceful" | ||
| 81 | ? "This provider version has limited compatibility with this T3 Code release." | ||
| 82 | : null; | ||
| 83 | const recommendedVersion = policy.recommendedVersion ?? null; | ||
| 84 | const recommendedRange = policy.recommendedRange ?? null; | ||
| 85 | const recommendation = recommendedVersion ?? recommendedRange; | ||
| 86 | return { | ||
| 87 | status, | ||
| 88 | message: message && recommendation ? `${message} Use ${recommendation}.` : message, | ||
| 89 | recommendedVersion, | ||
| 90 | recommendedRange, | ||
| 91 | }; | ||
| 92 | } | ||
| 93 | |||
| 94 | /** A remote policy replaces its matching bundled policy; omission keeps the bundle. */ | ||
| 95 | export function applyProviderCompatibility( | ||
| 96 | snapshot: ServerProvider, | ||
| 97 | policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined, | ||
| 98 | fallback: ReadonlyArray<ProviderCompatibilityPolicy> | undefined, | ||
| 99 | ): ServerProvider { | ||
| 100 | const { compatibilityAdvisory: _previous, ...base } = snapshot; | ||
| 101 | if (!snapshot.enabled || !snapshot.installed) return base; | ||
| 102 | const advisory = | ||
| 103 | resolveProviderCompatibility(policies, snapshot.driver, snapshot.version) ?? | ||
| 104 | resolveProviderCompatibility(fallback, snapshot.driver, snapshot.version); | ||
| 105 | const latestVersion = snapshot.versionAdvisory?.latestVersion; | ||
| 106 | const latestAdvisory = latestVersion | ||
| 107 | ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? | ||
| 108 | resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) | ||
| 109 | : undefined; | ||
| 110 | return advisory | ||
| 111 | ? { | ||
| 112 | ...base, | ||
| 113 | compatibilityAdvisory: { | ||
| 114 | ...advisory, | ||
| 115 | ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), | ||
| 116 | }, | ||
| 117 | } | ||
| 118 | : base; | ||
| 119 | } | ||
apps/server/src/provider/providerMaintenance.test.ts+43| ... | @@ -15,6 +15,7 @@ import { HttpClient } from "effect/unstable/http"; | ... | @@ -15,6 +15,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 15 | import { ChildProcessSpawner } from "effect/unstable/process"; | 15 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 16 | import { | 16 | import { |
| 17 | createProviderVersionAdvisory, | 17 | createProviderVersionAdvisory, |
| 18 | makeTargetedProviderUpdateAction, | ||
| 18 | enrichProviderSnapshotWithVersionAdvisory, | 19 | enrichProviderSnapshotWithVersionAdvisory, |
| 19 | homebrewOwnershipFromCommandPath, | 20 | homebrewOwnershipFromCommandPath, |
| 20 | makeCachedProviderMaintenanceResolution, | 21 | makeCachedProviderMaintenanceResolution, |
| ... | @@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 786 | }), | 787 | }), |
| 787 | ); | 788 | ); |
| 788 | }); | 789 | }); |
| 790 | |||
| 791 | it("pins only owned package-manager installs and preserves their execution context", () => { | ||
| 792 | const capabilities = makeProviderMaintenanceCapabilities({ | ||
| 793 | provider: driver("codex"), | ||
| 794 | packageName: "@openai/codex", | ||
| 795 | updateExecutable: "npm", | ||
| 796 | updateLockKey: "npm-global:/opt/node", | ||
| 797 | updateArgs: [ | ||
| 798 | "install", | ||
| 799 | "-g", | ||
| 800 | "--prefix", | ||
| 801 | "/opt/node", | ||
| 802 | "--allow-scripts=@openai/codex", | ||
| 803 | "@openai/codex@latest", | ||
| 804 | ], | ||
| 805 | env: { PATH: "/opt/node/bin" }, | ||
| 806 | }); | ||
| 807 | const pinned = makeTargetedProviderUpdateAction(capabilities, "2.0.0"); | ||
| 808 | expect(pinned).toMatchObject({ | ||
| 809 | executable: "npm", | ||
| 810 | lockKey: capabilities.update?.lockKey, | ||
| 811 | env: capabilities.update?.env, | ||
| 812 | args: [ | ||
| 813 | "install", | ||
| 814 | "-g", | ||
| 815 | "--prefix", | ||
| 816 | "/opt/node", | ||
| 817 | "--allow-scripts=@openai/codex", | ||
| 818 | "@openai/codex@2.0.0", | ||
| 819 | ], | ||
| 820 | }); | ||
| 821 | expect(pinned?.command).toContain("@openai/codex@2.0.0"); | ||
| 822 | for (const lockKey of ["codex-native", "homebrew", "manual"]) { | ||
| 823 | expect( | ||
| 824 | makeTargetedProviderUpdateAction( | ||
| 825 | { ...capabilities, update: { ...capabilities.update!, lockKey } }, | ||
| 826 | "2.0.0", | ||
| 827 | ), | ||
| 828 | ).toBeNull(); | ||
| 829 | } | ||
| 830 | expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0; rm -rf /")).toBeNull(); | ||
| 831 | }); |
apps/server/src/provider/providerMaintenance.ts+28| ... | @@ -186,6 +186,33 @@ export function makeProviderMaintenanceCapabilities(input: { | ... | @@ -186,6 +186,33 @@ export function makeProviderMaintenanceCapabilities(input: { |
| 186 | }; | 186 | }; |
| 187 | } | 187 | } |
| 188 | 188 | ||
| 189 | /** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */ | ||
| 190 | export function makeTargetedProviderUpdateAction( | ||
| 191 | capabilities: ProviderMaintenanceCapabilities, | ||
| 192 | version: string, | ||
| 193 | ): ProviderMaintenanceCommandAction | null { | ||
| 194 | if (!/^\d+\.\d+\.\d+$/.test(version)) return null; | ||
| 195 | const update = capabilities.update; | ||
| 196 | const packageName = capabilities.packageName; | ||
| 197 | if (!update || !packageName) return null; | ||
| 198 | if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) | ||
| 199 | return null; | ||
| 200 | const packageIndex = update.args.findIndex( | ||
| 201 | (arg) => arg === `${packageName}@latest` || arg === packageName, | ||
| 202 | ); | ||
| 203 | if (packageIndex < 0) return null; | ||
| 204 | const args = update.args.map((arg, index) => | ||
| 205 | index === packageIndex ? `${packageName}@${version}` : arg, | ||
| 206 | ); | ||
| 207 | const previous = update.args[packageIndex]!; | ||
| 208 | const commandIndex = update.command.lastIndexOf(previous); | ||
| 209 | const command = | ||
| 210 | commandIndex < 0 | ||
| 211 | ? update.command | ||
| 212 | : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`; | ||
| 213 | return { ...update, args, command }; | ||
| 214 | } | ||
| 215 | |||
| 189 | export function makeManualOnlyProviderMaintenanceCapabilities(input: { | 216 | export function makeManualOnlyProviderMaintenanceCapabilities(input: { |
| 190 | readonly provider: ProviderDriverKind; | 217 | readonly provider: ProviderDriverKind; |
| 191 | readonly packageName: string | null; | 218 | readonly packageName: string | null; |
| ... | @@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: { | ... | @@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: { |
| 627 | latestVersion, | 654 | latestVersion, |
| 628 | updateCommand: capabilities.update?.command ?? null, | 655 | updateCommand: capabilities.update?.command ?? null, |
| 629 | canUpdate: capabilities.update !== null, | 656 | canUpdate: capabilities.update !== null, |
| 657 | canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, | ||
| 630 | checkedAt: input.checkedAt ?? null, | 658 | checkedAt: input.checkedAt ?? null, |
| 631 | message: advisory.message, | 659 | message: advisory.message, |
| 632 | }; | 660 | }; |
apps/server/src/provider/providerMaintenanceRunner.test.ts+101-2| ... | @@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos | ... | @@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos |
| 21 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | 21 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 22 | 22 | ||
| 23 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; | 23 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; |
| 24 | import * as ModelManifest from "./ModelManifest.ts"; | ||
| 24 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; | 25 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; |
| 25 | import { | 26 | import { |
| 26 | makeProviderMaintenanceCapabilities, | 27 | makeProviderMaintenanceCapabilities, |
| ... | @@ -208,13 +209,22 @@ function makeRegistry( | ... | @@ -208,13 +209,22 @@ function makeRegistry( |
| 208 | }); | 209 | }); |
| 209 | } | 210 | } |
| 210 | 211 | ||
| 211 | const makeTestRunner = (registry: ProviderRegistryShape) => | 212 | const makeTestRunner = ( |
| 213 | registry: ProviderRegistryShape, | ||
| 214 | manifest = ModelManifest.BUNDLED_MODEL_MANIFEST, | ||
| 215 | ) => | ||
| 212 | Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( | 216 | Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( |
| 213 | Effect.provide( | 217 | Effect.provide( |
| 214 | ProviderMaintenanceRunner.layer.pipe( | 218 | ProviderMaintenanceRunner.layer.pipe( |
| 215 | Layer.provide( | 219 | Layer.provide( |
| 216 | Layer.mergeAll( | 220 | Layer.mergeAll( |
| 217 | Layer.succeed(ProviderRegistry, registry), | 221 | Layer.succeed(ProviderRegistry, registry), |
| 222 | Layer.succeed(ModelManifest.ModelManifest, { | ||
| 223 | current: Effect.succeed(manifest), | ||
| 224 | refresh: Effect.succeed(manifest), | ||
| 225 | forceRefresh: Effect.succeed(manifest), | ||
| 226 | refreshInBackground: Effect.void, | ||
| 227 | }), | ||
| 218 | // Fresh per runner so a version cached by one test cannot leak into another. | 228 | // Fresh per runner so a version cached by one test cannot leak into another. |
| 219 | Layer.sync(ProviderVersionCache, () => new Map()), | 229 | Layer.sync(ProviderVersionCache, () => new Map()), |
| 220 | ), | 230 | ), |
| ... | @@ -743,7 +753,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -743,7 +753,7 @@ describe("providerMaintenanceRunner", () => { |
| 743 | Effect.provide( | 753 | Effect.provide( |
| 744 | Layer.mergeAll( | 754 | Layer.mergeAll( |
| 745 | NonWindowsPlatform, | 755 | NonWindowsPlatform, |
| 746 | latestVersionHttpClient("0.0.0"), | 756 | latestVersionHttpClient("2.0.0"), |
| 747 | mockSpawnerLayer((_command, args) => { | 757 | mockSpawnerLayer((_command, args) => { |
| 748 | calls.push(args.join(" ")); | 758 | calls.push(args.join(" ")); |
| 749 | if (calls.length === 1) { | 759 | if (calls.length === 1) { |
| ... | @@ -911,3 +921,92 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -911,3 +921,92 @@ describe("providerMaintenanceRunner", () => { |
| 911 | ); | 921 | ); |
| 912 | }); | 922 | }); |
| 913 | }); | 923 | }); |
| 924 | |||
| 925 | it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { | ||
| 926 | const calls: string[] = []; | ||
| 927 | const manifest: ModelManifest.ModelManifestData = { | ||
| 928 | version: 1, | ||
| 929 | currentModels: {}, | ||
| 930 | compatibility: [ | ||
| 931 | { | ||
| 932 | driver: "codex", | ||
| 933 | t3CodeRange: ">=0.0.42", | ||
| 934 | recommendedVersion: "2.0.0", | ||
| 935 | ranges: [ | ||
| 936 | { range: "=2.0.0", status: "supported" }, | ||
| 937 | { range: ">2.0.0", status: "broken" }, | ||
| 938 | ], | ||
| 939 | }, | ||
| 940 | ], | ||
| 941 | }; | ||
| 942 | return Effect.gen(function* () { | ||
| 943 | const { registry, providersRef } = yield* makeRegistry(); | ||
| 944 | const pinnedCapabilities = makeProviderMaintenanceCapabilities({ | ||
| 945 | provider: CODEX_DRIVER, | ||
| 946 | packageName: "@openai/codex", | ||
| 947 | updateExecutable: "npm", | ||
| 948 | updateArgs: ["install", "-g", "@openai/codex@latest"], | ||
| 949 | updateLockKey: "npm-global:/fixture", | ||
| 950 | }); | ||
| 951 | const updater = yield* makeTestRunner( | ||
| 952 | { | ||
| 953 | ...registry, | ||
| 954 | getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), | ||
| 955 | }, | ||
| 956 | manifest, | ||
| 957 | ); | ||
| 958 | for (const targetVersion of [undefined, "", "1.0.0", "2.0.0; echo unsafe"]) { | ||
| 959 | const result = yield* updater.updateProvider({ | ||
| 960 | provider: CODEX_DRIVER, | ||
| 961 | ...(targetVersion !== undefined ? { targetVersion } : {}), | ||
| 962 | }); | ||
| 963 | assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); | ||
| 964 | } | ||
| 965 | assert.deepStrictEqual(calls, []); | ||
| 966 | yield* Ref.update(providersRef, (providers) => | ||
| 967 | providers.map((entry) => ({ ...entry, version: "2.0.0" })), | ||
| 968 | ); | ||
| 969 | const installed = yield* updater.updateProvider({ | ||
| 970 | provider: CODEX_DRIVER, | ||
| 971 | targetVersion: "2.0.0", | ||
| 972 | }); | ||
| 973 | assert.deepStrictEqual(calls, ["install -g @openai/codex@2.0.0"]); | ||
| 974 | assert.strictEqual(installed.providers[0]?.updateState?.status, "succeeded"); | ||
| 975 | yield* Ref.update(providersRef, (providers) => | ||
| 976 | providers.map((entry) => ({ ...entry, version: "1.0.0" })), | ||
| 977 | ); | ||
| 978 | const unchanged = yield* updater.updateProvider({ | ||
| 979 | provider: CODEX_DRIVER, | ||
| 980 | targetVersion: "2.0.0", | ||
| 981 | }); | ||
| 982 | assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged"); | ||
| 983 | const nativeUpdater = yield* makeTestRunner( | ||
| 984 | { | ||
| 985 | ...registry, | ||
| 986 | getProviderMaintenanceCapabilitiesForInstance: () => | ||
| 987 | Effect.succeed({ | ||
| 988 | ...pinnedCapabilities, | ||
| 989 | update: { ...pinnedCapabilities.update!, lockKey: "codex-native" }, | ||
| 990 | }), | ||
| 991 | }, | ||
| 992 | manifest, | ||
| 993 | ); | ||
| 994 | const refused = yield* nativeUpdater.updateProvider({ | ||
| 995 | provider: CODEX_DRIVER, | ||
| 996 | targetVersion: "2.0.0", | ||
| 997 | }); | ||
| 998 | assert.strictEqual(refused.providers[0]?.updateState?.status, "failed"); | ||
| 999 | assert.strictEqual(calls.length, 2); | ||
| 1000 | }).pipe( | ||
| 1001 | Effect.provide( | ||
| 1002 | Layer.mergeAll( | ||
| 1003 | NonWindowsPlatform, | ||
| 1004 | latestVersionHttpClient("3.0.0"), | ||
| 1005 | mockSpawnerLayer((_command, args) => { | ||
| 1006 | calls.push(args.join(" ")); | ||
| 1007 | return { stdout: "installed" }; | ||
| 1008 | }), | ||
| 1009 | ), | ||
| 1010 | ), | ||
| 1011 | ); | ||
| 1012 | }); |
apps/server/src/provider/providerMaintenanceRunner.ts+54-5| ... | @@ -21,10 +21,14 @@ import * as Schema from "effect/Schema"; | ... | @@ -21,10 +21,14 @@ import * as Schema from "effect/Schema"; |
| 21 | import { HttpClient } from "effect/unstable/http"; | 21 | import { HttpClient } from "effect/unstable/http"; |
| 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 23 | 23 | ||
| 24 | import * as ModelManifest from "./ModelManifest.ts"; | ||
| 25 | import { resolveProviderCompatibility } from "./providerCompatibility.ts"; | ||
| 24 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; | 26 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; |
| 25 | import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; | 27 | import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; |
| 26 | import { | 28 | import { |
| 27 | enrichProviderSnapshotWithVersionAdvisory, | 29 | enrichProviderSnapshotWithVersionAdvisory, |
| 30 | makeTargetedProviderUpdateAction, | ||
| 31 | resolveLatestProviderVersion, | ||
| 28 | type ProviderMaintenanceCommandAction, | 32 | type ProviderMaintenanceCommandAction, |
| 29 | ProviderVersionCache, | 33 | ProviderVersionCache, |
| 30 | } from "./providerMaintenance.ts"; | 34 | } from "./providerMaintenance.ts"; |
| ... | @@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape { | ... | @@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape { |
| 51 | | { | 55 | | { |
| 52 | readonly provider: ProviderDriverKind; | 56 | readonly provider: ProviderDriverKind; |
| 53 | readonly instanceId?: ProviderInstanceId | undefined; | 57 | readonly instanceId?: ProviderInstanceId | undefined; |
| 58 | readonly targetVersion?: string | undefined; | ||
| 54 | }, | 59 | }, |
| 55 | ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>; | 60 | ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>; |
| 56 | } | 61 | } |
| ... | @@ -214,6 +219,7 @@ function makeUpdateState(input: { | ... | @@ -214,6 +219,7 @@ function makeUpdateState(input: { |
| 214 | /** @public Service construction is part of the canonical Effect module API. */ | 219 | /** @public Service construction is part of the canonical Effect module API. */ |
| 215 | export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | 220 | export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 216 | const providerRegistry = yield* ProviderRegistry; | 221 | const providerRegistry = yield* ProviderRegistry; |
| 222 | const manifestService = yield* ModelManifest.ModelManifest; | ||
| 217 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 223 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 218 | const httpClient = yield* HttpClient.HttpClient; | 224 | const httpClient = yield* HttpClient.HttpClient; |
| 219 | const versionCache = yield* ProviderVersionCache; | 225 | const versionCache = yield* ProviderVersionCache; |
| ... | @@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | ... | @@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 310 | typeof target === "string" | 316 | typeof target === "string" |
| 311 | ? defaultInstanceIdForDriver(provider) | 317 | ? defaultInstanceIdForDriver(provider) |
| 312 | : (target.instanceId ?? defaultInstanceIdForDriver(provider)); | 318 | : (target.instanceId ?? defaultInstanceIdForDriver(provider)); |
| 319 | const targetVersion = typeof target === "string" ? undefined : target.targetVersion; | ||
| 313 | const targetKey = `instance:${instanceId}`; | 320 | const targetKey = `instance:${instanceId}`; |
| 314 | const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( | 321 | const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( |
| 315 | instanceId, | 322 | instanceId, |
| ... | @@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | ... | @@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 376 | ); | 383 | ); |
| 377 | } | 384 | } |
| 378 | 385 | ||
| 379 | const result = yield* runMaintenanceCommand(fresh.update); | 386 | const manifest = yield* manifestService.current; |
| 387 | const candidateVersion = | ||
| 388 | targetVersion ?? | ||
| 389 | (yield* resolveLatestProviderVersion(fresh).pipe( | ||
| 390 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 391 | Effect.provideService(ProviderVersionCache, versionCache), | ||
| 392 | )); | ||
| 393 | const advisory = | ||
| 394 | resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ?? | ||
| 395 | resolveProviderCompatibility( | ||
| 396 | ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, | ||
| 397 | provider, | ||
| 398 | candidateVersion, | ||
| 399 | ); | ||
| 400 | const command = | ||
| 401 | targetVersion !== undefined | ||
| 402 | ? makeTargetedProviderUpdateAction(fresh, targetVersion) | ||
| 403 | : fresh.update; | ||
| 404 | const rejected = | ||
| 405 | targetVersion !== undefined | ||
| 406 | ? !command || | ||
| 407 | advisory?.recommendedVersion !== targetVersion || | ||
| 408 | advisory.status !== "supported" | ||
| 409 | : advisory?.status === "broken" || advisory?.status === "unsupported"; | ||
| 410 | if (rejected || !command) { | ||
| 411 | return yield* finish( | ||
| 412 | makeUpdateState({ | ||
| 413 | status: "failed", | ||
| 414 | startedAt, | ||
| 415 | finishedAt: yield* nowIso, | ||
| 416 | message: | ||
| 417 | targetVersion !== undefined | ||
| 418 | ? "This version is no longer recommended or this installer cannot install a specific version. Refresh provider settings." | ||
| 419 | : "The latest provider version is incompatible with this T3 Code release. Review provider settings.", | ||
| 420 | }), | ||
| 421 | ); | ||
| 422 | } | ||
| 423 | const result = yield* runMaintenanceCommand(command); | ||
| 380 | const finishedAt = yield* nowIso; | 424 | const finishedAt = yield* nowIso; |
| 381 | if (result.timedOut || result.exitCode !== 0) { | 425 | if (result.timedOut || result.exitCode !== 0) { |
| 382 | return yield* finish( | 426 | return yield* finish( |
| ... | @@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { | ... | @@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 407 | // Cursor's `about` probe can fail transiently on a healthy binary. | 451 | // Cursor's `about` probe can fail transiently on a healthy binary. |
| 408 | const couldNotVerify = | 452 | const couldNotVerify = |
| 409 | verifiedProviders.length === 0 || | 453 | verifiedProviders.length === 0 || |
| 410 | verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); | 454 | verifiedProviders.some( |
| 411 | const stillOutdated = verifiedProviders.some((verifiedProvider) => | 455 | (verifiedProvider) => |
| 412 | isOutdatedProvider(verifiedProvider), | 456 | !isStillInstalled(verifiedProvider) || |
| 413 | ); | 457 | (targetVersion !== undefined && |
| 458 | verifiedProvider.version?.replace(/^v/, "") !== targetVersion), | ||
| 459 | ); | ||
| 460 | const stillOutdated = | ||
| 461 | targetVersion === undefined && | ||
| 462 | verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); | ||
| 414 | return yield* finish( | 463 | return yield* finish( |
| 415 | makeUpdateState({ | 464 | makeUpdateState({ |
| 416 | status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", | 465 | status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+20| ... | @@ -24,6 +24,7 @@ import { | ... | @@ -24,6 +24,7 @@ import { |
| 24 | getProviderUpdateSidebarPillView, | 24 | getProviderUpdateSidebarPillView, |
| 25 | hasOneClickUpdateProviderCandidate, | 25 | hasOneClickUpdateProviderCandidate, |
| 26 | isProviderUpdateCandidate, | 26 | isProviderUpdateCandidate, |
| 27 | isProviderSettingsUpdateCandidate, | ||
| 27 | isTerminalProviderUpdatePhase, | 28 | isTerminalProviderUpdatePhase, |
| 28 | localEnvironmentUpdateNotificationKey, | 29 | localEnvironmentUpdateNotificationKey, |
| 29 | providerUpdateNotificationKey, | 30 | providerUpdateNotificationKey, |
| ... | @@ -1038,3 +1039,22 @@ describe("provider update launch notification logic", () => { | ... | @@ -1038,3 +1039,22 @@ describe("provider update launch notification logic", () => { |
| 1038 | }); | 1039 | }); |
| 1039 | }); | 1040 | }); |
| 1040 | }); | 1041 | }); |
| 1042 | |||
| 1043 | it("does not offer incompatible latest versions and restores suggestions after policy relaxation", () => { | ||
| 1044 | const installed = provider({ driver: driver("codex") }); | ||
| 1045 | for (const latestVersionStatus of ["broken", "unsupported", "supported", "unknown"] as const) { | ||
| 1046 | const snapshot: ServerProvider = { | ||
| 1047 | ...installed, | ||
| 1048 | compatibilityAdvisory: { | ||
| 1049 | status: "supported", | ||
| 1050 | latestVersionStatus, | ||
| 1051 | message: null, | ||
| 1052 | recommendedRange: null, | ||
| 1053 | recommendedVersion: null, | ||
| 1054 | }, | ||
| 1055 | }; | ||
| 1056 | const expected = latestVersionStatus === "supported" || latestVersionStatus === "unknown"; | ||
| 1057 | expect(isProviderUpdateCandidate(snapshot)).toBe(expected); | ||
| 1058 | expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected); | ||
| 1059 | } | ||
| 1060 | }); |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+4| ... | @@ -141,6 +141,8 @@ export function isProviderUpdateCandidate( | ... | @@ -141,6 +141,8 @@ export function isProviderUpdateCandidate( |
| 141 | ): provider is ProviderUpdateCandidate { | 141 | ): provider is ProviderUpdateCandidate { |
| 142 | return ( | 142 | return ( |
| 143 | provider.enabled && | 143 | provider.enabled && |
| 144 | provider.compatibilityAdvisory?.latestVersionStatus !== "broken" && | ||
| 145 | provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" && | ||
| 144 | provider.versionAdvisory?.status === "behind_latest" && | 146 | provider.versionAdvisory?.status === "behind_latest" && |
| 145 | provider.versionAdvisory.latestVersion !== null | 147 | provider.versionAdvisory.latestVersion !== null |
| 146 | ); | 148 | ); |
| ... | @@ -161,6 +163,8 @@ export function isProviderSettingsUpdateCandidate( | ... | @@ -161,6 +163,8 @@ export function isProviderSettingsUpdateCandidate( |
| 161 | ): provider is ProviderSettingsUpdateCandidate { | 163 | ): provider is ProviderSettingsUpdateCandidate { |
| 162 | return ( | 164 | return ( |
| 163 | provider.enabled && | 165 | provider.enabled && |
| 166 | provider.compatibilityAdvisory?.latestVersionStatus !== "broken" && | ||
| 167 | provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" && | ||
| 164 | provider.versionAdvisory?.status === "behind_latest" && | 168 | provider.versionAdvisory?.status === "behind_latest" && |
| 165 | provider.versionAdvisory.canUpdate === true && | 169 | provider.versionAdvisory.canUpdate === true && |
| 166 | provider.versionAdvisory.updateCommand !== null | 170 | provider.versionAdvisory.updateCommand !== null |
apps/web/src/components/chat/ProviderStatusBanner.test.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | getProviderStatusBannerKey, | ||
| 5 | getProviderStatusMessage, | ||
| 6 | shouldShowProviderStatusBanner, | ||
| 7 | } from "./ProviderStatusBanner"; | ||
| 8 | |||
| 9 | const provider: ServerProvider = { | ||
| 10 | instanceId: ProviderInstanceId.make("codex-work"), | ||
| 11 | driver: ProviderDriverKind.make("codex"), | ||
| 12 | enabled: true, | ||
| 13 | installed: true, | ||
| 14 | version: "1.0.0", | ||
| 15 | status: "ready", | ||
| 16 | auth: { status: "authenticated" }, | ||
| 17 | checkedAt: "2026-09-22T00:00:00Z", | ||
| 18 | models: [], | ||
| 19 | skills: [], | ||
| 20 | slashCommands: [], | ||
| 21 | compatibilityAdvisory: { | ||
| 22 | status: "unsupported", | ||
| 23 | message: "Unsupported version. Use 2.0.0.", | ||
| 24 | recommendedVersion: "2.0.0", | ||
| 25 | recommendedRange: null, | ||
| 26 | }, | ||
| 27 | }; | ||
| 28 | |||
| 29 | describe("compatibility banners", () => { | ||
| 30 | it("shows and dismisses a warning on a healthy provider, then clears it after policy relaxation", () => { | ||
| 31 | expect(shouldShowProviderStatusBanner(provider, null)).toBe(true); | ||
| 32 | expect(shouldShowProviderStatusBanner(provider, getProviderStatusBannerKey(provider))).toBe( | ||
| 33 | false, | ||
| 34 | ); | ||
| 35 | expect( | ||
| 36 | shouldShowProviderStatusBanner( | ||
| 37 | { ...provider, version: "1.0.1" }, | ||
| 38 | getProviderStatusBannerKey(provider), | ||
| 39 | ), | ||
| 40 | ).toBe(true); | ||
| 41 | const relaxed: ServerProvider = { | ||
| 42 | ...provider, | ||
| 43 | compatibilityAdvisory: { | ||
| 44 | ...provider.compatibilityAdvisory!, | ||
| 45 | status: "supported", | ||
| 46 | message: null, | ||
| 47 | }, | ||
| 48 | }; | ||
| 49 | expect(getProviderStatusBannerKey(relaxed)).toBeNull(); | ||
| 50 | expect(getProviderStatusBannerKey({ ...provider, status: "disabled" })).toBeNull(); | ||
| 51 | expect( | ||
| 52 | getProviderStatusBannerKey({ | ||
| 53 | ...provider, | ||
| 54 | compatibilityAdvisory: { ...provider.compatibilityAdvisory!, status: "graceful" }, | ||
| 55 | }), | ||
| 56 | ).toBeNull(); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => { | ||
| 60 | const unauthenticated: ServerProvider = { | ||
| 61 | ...provider, | ||
| 62 | status: "error", | ||
| 63 | auth: { status: "unauthenticated" }, | ||
| 64 | }; | ||
| 65 | expect(getProviderStatusMessage(unauthenticated)).toBe( | ||
| 66 | "Sign in via the CLI to authenticate again.", | ||
| 67 | ); | ||
| 68 | expect(getProviderStatusMessage({ ...unauthenticated, message: "Credentials expired" })).toBe( | ||
| 69 | "Credentials expired", | ||
| 70 | ); | ||
| 71 | }); | ||
| 72 | }); | ||
apps/web/src/components/chat/ProviderStatusBanner.tsx+23-4| ... | @@ -6,8 +6,22 @@ import { Button, InlineButton } from "../ui/button"; | ... | @@ -6,8 +6,22 @@ import { Button, InlineButton } from "../ui/button"; |
| 6 | import { formatProviderDriverKindLabel } from "../../providerModels"; | 6 | import { formatProviderDriverKindLabel } from "../../providerModels"; |
| 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 8 | 8 | ||
| 9 | /** Unsupported and broken versions fail mid-turn, so they warn even when ready. */ | ||
| 10 | function getIncompatibleVersion(status: ServerProvider) { | ||
| 11 | const compatibility = status.compatibilityAdvisory; | ||
| 12 | return compatibility?.status === "unsupported" || compatibility?.status === "broken" | ||
| 13 | ? compatibility | ||
| 14 | : null; | ||
| 15 | } | ||
| 16 | |||
| 9 | export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { | 17 | export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { |
| 10 | if (!status || status.status === "ready" || status.status === "disabled") return null; | 18 | if (!status || status.status === "disabled") return null; |
| 19 | if (status.status === "ready") { | ||
| 20 | const incompatible = getIncompatibleVersion(status); | ||
| 21 | return incompatible | ||
| 22 | ? [status.instanceId, incompatible.status, status.version ?? ""].join("\u0000") | ||
| 23 | : null; | ||
| 24 | } | ||
| 11 | // Antigravity checks saved credentials when a session starts. Its local | 25 | // Antigravity checks saved credentials when a session starts. Its local |
| 12 | // health check leaves auth unknown after a restart, which is not a failure. | 26 | // health check leaves auth unknown after a restart, which is not a failure. |
| 13 | if ( | 27 | if ( |
| ... | @@ -76,15 +90,20 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ | ... | @@ -76,15 +90,20 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 76 | 90 | ||
| 77 | const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); | 91 | const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); |
| 78 | const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; | 92 | const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; |
| 93 | const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null; | ||
| 79 | const title = isUnauthenticated | 94 | const title = isUnauthenticated |
| 80 | ? `${providerName} is unauthenticated` | 95 | ? `${providerName} is unauthenticated` |
| 81 | : `${providerName} provider status`; | 96 | : incompatible |
| 82 | const message = getProviderStatusMessage(status); | 97 | ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}` |
| 98 | : `${providerName} provider status`; | ||
| 99 | const message = incompatible?.message ?? getProviderStatusMessage(status); | ||
| 100 | const isWarning = status.status === "warning" || incompatible !== null; | ||
| 83 | 101 | ||
| 84 | return ( | 102 | return ( |
| 85 | <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3"> | 103 | <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3"> |
| 86 | <Alert | 104 | <Alert |
| 87 | variant={status.status === "warning" ? "warning" : "error"} | 105 | variant={isWarning ? "warning" : "error"} |
| 106 | role={incompatible && incompatible.status !== "broken" ? "status" : "alert"} | ||
| 88 | surface="glass" | 107 | surface="glass" |
| 89 | controlAlignment="first-line" | 108 | controlAlignment="first-line" |
| 90 | > | 109 | > |
apps/web/src/components/settings/ProviderInstanceCard.tsx+108-33| ... | @@ -3,6 +3,7 @@ | ... | @@ -3,6 +3,7 @@ |
| 3 | import { Spinner } from "~/components/ui/spinner"; | 3 | import { Spinner } from "~/components/ui/spinner"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | AlertTriangleIcon, | ||
| 6 | ArrowUpCircleIcon, | 7 | ArrowUpCircleIcon, |
| 7 | CopyIcon, | 8 | CopyIcon, |
| 8 | DownloadIcon, | 9 | DownloadIcon, |
| ... | @@ -14,7 +15,7 @@ import { | ... | @@ -14,7 +15,7 @@ import { |
| 14 | } from "lucide-react"; | 15 | } from "lucide-react"; |
| 15 | import * as Arr from "effect/Array"; | 16 | import * as Arr from "effect/Array"; |
| 16 | import * as Result from "effect/Result"; | 17 | import * as Result from "effect/Result"; |
| 17 | import { useEffect, useRef, useState, type ReactNode } from "react"; | 18 | import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"; |
| 18 | import { | 19 | import { |
| 19 | isProviderDriverKind, | 20 | isProviderDriverKind, |
| 20 | resolveProviderInstanceEnabled, | 21 | resolveProviderInstanceEnabled, |
| ... | @@ -58,6 +59,22 @@ import { | ... | @@ -58,6 +59,22 @@ import { |
| 58 | 59 | ||
| 59 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | 60 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 60 | 61 | ||
| 62 | function ProviderStatusDiagnostic({ | ||
| 63 | detail, | ||
| 64 | children, | ||
| 65 | }: { | ||
| 66 | detail: string | null; | ||
| 67 | children: ReactElement; | ||
| 68 | }) { | ||
| 69 | if (!detail) return children; | ||
| 70 | return ( | ||
| 71 | <Tooltip> | ||
| 72 | <TooltipTrigger render={children} /> | ||
| 73 | <TooltipPopup side="top">{detail}</TooltipPopup> | ||
| 74 | </Tooltip> | ||
| 75 | ); | ||
| 76 | } | ||
| 77 | |||
| 61 | let environmentVariableDraftId = 0; | 78 | let environmentVariableDraftId = 0; |
| 62 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; | 79 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| 63 | 80 | ||
| ... | @@ -377,6 +394,7 @@ interface ProviderInstanceCardProps { | ... | @@ -377,6 +394,7 @@ interface ProviderInstanceCardProps { |
| 377 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; | 394 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; |
| 378 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; | 395 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; |
| 379 | readonly onRunUpdate?: (() => void) | undefined; | 396 | readonly onRunUpdate?: (() => void) | undefined; |
| 397 | readonly onInstallRecommended?: (() => void) | undefined; | ||
| 380 | readonly isUpdating?: boolean | undefined; | 398 | readonly isUpdating?: boolean | undefined; |
| 381 | } | 399 | } |
| 382 | 400 | ||
| ... | @@ -419,9 +437,11 @@ export function ProviderInstanceCard({ | ... | @@ -419,9 +437,11 @@ export function ProviderInstanceCard({ |
| 419 | onFavoriteModelsChange, | 437 | onFavoriteModelsChange, |
| 420 | onModelOrderChange, | 438 | onModelOrderChange, |
| 421 | onRunUpdate, | 439 | onRunUpdate, |
| 440 | onInstallRecommended, | ||
| 422 | isUpdating = false, | 441 | isUpdating = false, |
| 423 | }: ProviderInstanceCardProps) { | 442 | }: ProviderInstanceCardProps) { |
| 424 | const enabled = resolveProviderInstanceEnabled(instance); | 443 | const enabled = resolveProviderInstanceEnabled(instance); |
| 444 | const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; | ||
| 425 | // A locally disabled provider reads "Disabled" with a muted dot even if its | 445 | // A locally disabled provider reads "Disabled" with a muted dot even if its |
| 426 | // last server status is stale. Enabled providers use the server status. | 446 | // last server status is stale. Enabled providers use the server status. |
| 427 | const statusKey: ProviderStatusKey = enabled | 447 | const statusKey: ProviderStatusKey = enabled |
| ... | @@ -438,8 +458,18 @@ export function ProviderInstanceCard({ | ... | @@ -438,8 +458,18 @@ export function ProviderInstanceCard({ |
| 438 | ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null) | 458 | ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null) |
| 439 | : null; | 459 | : null; |
| 440 | const versionLabel = getProviderVersionLabel(liveProvider?.version); | 460 | const versionLabel = getProviderVersionLabel(liveProvider?.version); |
| 441 | const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); | 461 | const versionAdvisory = getProviderVersionAdvisoryPresentation( |
| 462 | liveProvider?.versionAdvisory, | ||
| 463 | liveProvider?.compatibilityAdvisory, | ||
| 464 | enabled, | ||
| 465 | ); | ||
| 442 | const updateCommand = versionAdvisory?.updateCommand ?? null; | 466 | const updateCommand = versionAdvisory?.updateCommand ?? null; |
| 467 | const hasCompatibilityWarning = | ||
| 468 | compatibility !== undefined && | ||
| 469 | compatibility.status !== "supported" && | ||
| 470 | compatibility.status !== "unknown"; | ||
| 471 | const VersionAdvisoryIcon = hasCompatibilityWarning ? AlertTriangleIcon : ArrowUpCircleIcon; | ||
| 472 | const onRunVersionAction = versionAdvisory?.targetVersion ? onInstallRecommended : onRunUpdate; | ||
| 443 | const FallbackIconComponent = driverOption?.icon; | 473 | const FallbackIconComponent = driverOption?.icon; |
| 444 | const displayName = | 474 | const displayName = |
| 445 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); | 475 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); |
| ... | @@ -568,10 +598,16 @@ export function ProviderInstanceCard({ | ... | @@ -568,10 +598,16 @@ export function ProviderInstanceCard({ |
| 568 | statusKey === "warning" || statusKey === "error" ? ( | 598 | statusKey === "warning" || statusKey === "error" ? ( |
| 569 | <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden /> | 599 | <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden /> |
| 570 | ) : null; | 600 | ) : null; |
| 571 | // Trouble states carry the server's explanation (a failed probe, a shadow | ||
| 572 | // home entry that is not a symlink, a missing binary). Show it wherever the | ||
| 573 | // headline shows so the user can act without opening the editor. | ||
| 574 | const needsAttention = statusKey === "warning" || statusKey === "error"; | 601 | const needsAttention = statusKey === "warning" || statusKey === "error"; |
| 602 | const statusDiagnostic = hasCompatibilityWarning && needsAttention ? summary.detail : null; | ||
| 603 | // Keep compatibility copy compact; the version popover carries the explanation. | ||
| 604 | const inlineStatusDetail = hasCompatibilityWarning | ||
| 605 | ? compatibility?.status === "broken" | ||
| 606 | ? "Incompatible" | ||
| 607 | : compatibility?.status === "unsupported" | ||
| 608 | ? "Unsupported" | ||
| 609 | : "Limited support" | ||
| 610 | : summary.detail; | ||
| 575 | const editorStatusNode = | 611 | const editorStatusNode = |
| 576 | isAuthenticated && authEmail ? ( | 612 | isAuthenticated && authEmail ? ( |
| 577 | <> | 613 | <> |
| ... | @@ -579,16 +615,16 @@ export function ProviderInstanceCard({ | ... | @@ -579,16 +615,16 @@ export function ProviderInstanceCard({ |
| 579 | <span>Authenticated as</span> | 615 | <span>Authenticated as</span> |
| 580 | <ProviderAuthEmail email={authEmail} /> | 616 | <ProviderAuthEmail email={authEmail} /> |
| 581 | {authLabel ? <span>· {authLabel}</span> : null} | 617 | {authLabel ? <span>· {authLabel}</span> : null} |
| 582 | {summary.detail ? ( | 618 | {inlineStatusDetail ? ( |
| 583 | <span className="min-w-0 [overflow-wrap:anywhere]">· {summary.detail}</span> | 619 | <span className="min-w-0 [overflow-wrap:anywhere]">· {inlineStatusDetail}</span> |
| 584 | ) : null} | 620 | ) : null} |
| 585 | </> | 621 | </> |
| 586 | ) : ( | 622 | ) : ( |
| 587 | <> | 623 | <> |
| 588 | {statusDotNode} | 624 | {statusDotNode} |
| 589 | <span>{summary.headline}</span> | 625 | <span>{summary.headline}</span> |
| 590 | {summary.detail ? ( | 626 | {inlineStatusDetail ? ( |
| 591 | <span className="min-w-0 [overflow-wrap:anywhere]">· {summary.detail}</span> | 627 | <span className="min-w-0 [overflow-wrap:anywhere]">· {inlineStatusDetail}</span> |
| 592 | ) : null} | 628 | ) : null} |
| 593 | </> | 629 | </> |
| 594 | ); | 630 | ); |
| ... | @@ -629,7 +665,23 @@ export function ProviderInstanceCard({ | ... | @@ -629,7 +665,23 @@ export function ProviderInstanceCard({ |
| 629 | </code> | 665 | </code> |
| 630 | ) : null} | 666 | ) : null} |
| 631 | {versionAdvisory ? ( | 667 | {versionAdvisory ? ( |
| 632 | updateCommand ? ( | 668 | hasCompatibilityWarning ? ( |
| 669 | <Tooltip> | ||
| 670 | <TooltipTrigger | ||
| 671 | render={ | ||
| 672 | <span | ||
| 673 | tabIndex={0} | ||
| 674 | role="img" | ||
| 675 | aria-label={versionAdvisory.title} | ||
| 676 | className="pointer-events-auto relative inline-flex shrink-0 text-warning" | ||
| 677 | > | ||
| 678 | <VersionAdvisoryIcon className="size-3.5" /> | ||
| 679 | </span> | ||
| 680 | } | ||
| 681 | /> | ||
| 682 | <TooltipPopup side="top">{versionAdvisory.detail}</TooltipPopup> | ||
| 683 | </Tooltip> | ||
| 684 | ) : updateCommand ? ( | ||
| 633 | <Tooltip> | 685 | <Tooltip> |
| 634 | <TooltipTrigger | 686 | <TooltipTrigger |
| 635 | render={ | 687 | render={ |
| ... | @@ -660,10 +712,15 @@ export function ProviderInstanceCard({ | ... | @@ -660,10 +712,15 @@ export function ProviderInstanceCard({ |
| 660 | {statusDotNode ? ( | 712 | {statusDotNode ? ( |
| 661 | <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span> | 713 | <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span> |
| 662 | ) : null} | 714 | ) : null} |
| 663 | <span className="line-clamp-2 [overflow-wrap:anywhere]"> | 715 | <ProviderStatusDiagnostic detail={statusDiagnostic}> |
| 664 | {summary.headline} | 716 | <span |
| 665 | {needsAttention && summary.detail ? ` · ${summary.detail}` : null} | 717 | tabIndex={statusDiagnostic ? 0 : undefined} |
| 666 | </span> | 718 | className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]" |
| 719 | > | ||
| 720 | {summary.headline} | ||
| 721 | {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} | ||
| 722 | </span> | ||
| 723 | </ProviderStatusDiagnostic> | ||
| 667 | </span> | 724 | </span> |
| 668 | </span> | 725 | </span> |
| 669 | </div> | 726 | </div> |
| ... | @@ -694,25 +751,32 @@ export function ProviderInstanceCard({ | ... | @@ -694,25 +751,32 @@ export function ProviderInstanceCard({ |
| 694 | > | 751 | > |
| 695 | {versionAdvisory ? ( | 752 | {versionAdvisory ? ( |
| 696 | <Popover> | 753 | <Popover> |
| 697 | <PopoverTrigger | 754 | <Tooltip> |
| 698 | render={ | 755 | <TooltipTrigger |
| 699 | <Button | 756 | render={ |
| 700 | type="button" | 757 | <PopoverTrigger |
| 701 | size="icon-xs" | 758 | render={ |
| 702 | variant="ghost-muted" | 759 | <Button |
| 703 | aria-label="Update available — view details" | 760 | type="button" |
| 704 | > | 761 | size="icon-xs" |
| 705 | <ArrowUpCircleIcon | 762 | variant="ghost-muted" |
| 706 | className={cn(versionAdvisory.emphasis === "strong" && "text-warning")} | 763 | aria-label={`${versionAdvisory.title} — view details`} |
| 764 | > | ||
| 765 | <VersionAdvisoryIcon | ||
| 766 | className={cn(hasCompatibilityWarning && "text-warning")} | ||
| 767 | /> | ||
| 768 | </Button> | ||
| 769 | } | ||
| 707 | /> | 770 | /> |
| 708 | </Button> | 771 | } |
| 709 | } | 772 | /> |
| 710 | /> | 773 | <TooltipPopup side="top">{versionAdvisory.title}</TooltipPopup> |
| 774 | </Tooltip> | ||
| 711 | <PopoverPopup side="bottom" align="end" width="md"> | 775 | <PopoverPopup side="bottom" align="end" width="md"> |
| 712 | <div className="grid min-w-0 gap-3"> | 776 | <div className="grid min-w-0 gap-3"> |
| 713 | <div className="grid gap-0.5"> | 777 | <div className="grid gap-0.5"> |
| 714 | <p className="text-[13px] font-semibold leading-tight text-foreground"> | 778 | <p className="text-[13px] font-semibold leading-tight text-foreground"> |
| 715 | Update available | 779 | {versionAdvisory.title} |
| 716 | </p> | 780 | </p> |
| 717 | <p | 781 | <p |
| 718 | className={cn( | 782 | className={cn( |
| ... | @@ -725,20 +789,24 @@ export function ProviderInstanceCard({ | ... | @@ -725,20 +789,24 @@ export function ProviderInstanceCard({ |
| 725 | {versionAdvisory.detail} | 789 | {versionAdvisory.detail} |
| 726 | </p> | 790 | </p> |
| 727 | </div> | 791 | </div> |
| 728 | {onRunUpdate ? ( | 792 | {onRunVersionAction ? ( |
| 729 | <Button | 793 | <Button |
| 730 | type="button" | 794 | type="button" |
| 731 | size="xs" | 795 | size="xs" |
| 732 | variant="outline" | 796 | variant="outline" |
| 733 | className="w-full" | 797 | className="w-full" |
| 734 | disabled={isUpdating} | 798 | disabled={isUpdating} |
| 735 | onClick={onRunUpdate} | 799 | onClick={onRunVersionAction} |
| 736 | > | 800 | > |
| 737 | {isUpdating ? <Spinner /> : <DownloadIcon />} | 801 | {isUpdating ? <Spinner /> : <DownloadIcon />} |
| 738 | {isUpdating ? "Updating" : "Update now"} | 802 | {isUpdating |
| 803 | ? "Updating" | ||
| 804 | : versionAdvisory.targetVersion | ||
| 805 | ? `Install ${getProviderVersionLabel(versionAdvisory.targetVersion)}` | ||
| 806 | : "Update now"} | ||
| 739 | </Button> | 807 | </Button> |
| 740 | ) : null} | 808 | ) : null} |
| 741 | {onRunUpdate && updateCommand ? ( | 809 | {onRunVersionAction && updateCommand ? ( |
| 742 | <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> | 810 | <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> |
| 743 | <span aria-hidden className="h-px flex-1 bg-border" /> | 811 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| 744 | or, update manually using | 812 | or, update manually using |
| ... | @@ -798,7 +866,14 @@ export function ProviderInstanceCard({ | ... | @@ -798,7 +866,14 @@ export function ProviderInstanceCard({ |
| 798 | <SettingsRow | 866 | <SettingsRow |
| 799 | title="Display name" | 867 | title="Display name" |
| 800 | status={ | 868 | status={ |
| 801 | <div className="flex min-w-0 flex-wrap items-center gap-x-1.5">{editorStatusNode}</div> | 869 | <ProviderStatusDiagnostic detail={statusDiagnostic}> |
| 870 | <div | ||
| 871 | tabIndex={statusDiagnostic ? 0 : undefined} | ||
| 872 | className="flex min-w-0 flex-wrap items-center gap-x-1.5" | ||
| 873 | > | ||
| 874 | {editorStatusNode} | ||
| 875 | </div> | ||
| 876 | </ProviderStatusDiagnostic> | ||
| 802 | } | 877 | } |
| 803 | control={ | 878 | control={ |
| 804 | <div | 879 | <div |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+21-7| ... | @@ -649,7 +649,10 @@ export function EnvironmentProviderSettings({ | ... | @@ -649,7 +649,10 @@ export function EnvironmentProviderSettings({ |
| 649 | }, [environmentId, refreshServerProviders]); | 649 | }, [environmentId, refreshServerProviders]); |
| 650 | 650 | ||
| 651 | const runProviderUpdate = useCallback( | 651 | const runProviderUpdate = useCallback( |
| 652 | async (candidate: ProviderSettingsUpdateCandidate) => { | 652 | async ( |
| 653 | candidate: Pick<ProviderSettingsUpdateCandidate, "driver" | "instanceId">, | ||
| 654 | targetVersion?: string, | ||
| 655 | ) => { | ||
| 653 | // Ref-based re-entry guard, mirroring refreshProviders: a state updater | 656 | // Ref-based re-entry guard, mirroring refreshProviders: a state updater |
| 654 | // may run after this function returns, so it cannot gate the dispatch. | 657 | // may run after this function returns, so it cannot gate the dispatch. |
| 655 | if (updatingInstanceIdsRef.current.has(candidate.instanceId)) { | 658 | if (updatingInstanceIdsRef.current.has(candidate.instanceId)) { |
| ... | @@ -663,6 +666,7 @@ export function EnvironmentProviderSettings({ | ... | @@ -663,6 +666,7 @@ export function EnvironmentProviderSettings({ |
| 663 | input: { | 666 | input: { |
| 664 | provider: candidate.driver, | 667 | provider: candidate.driver, |
| 665 | instanceId: candidate.instanceId, | 668 | instanceId: candidate.instanceId, |
| 669 | ...(targetVersion ? { targetVersion } : {}), | ||
| 666 | }, | 670 | }, |
| 667 | }); | 671 | }); |
| 668 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 672 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| ... | @@ -887,9 +891,8 @@ export function EnvironmentProviderSettings({ | ... | @@ -887,9 +891,8 @@ export function EnvironmentProviderSettings({ |
| 887 | ); | 891 | ); |
| 888 | const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId); | 892 | const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId); |
| 889 | const isInstanceUpdateRunning = | 893 | const isInstanceUpdateRunning = |
| 890 | updateCandidate !== undefined && | 894 | updatingProviderInstanceIds.has(row.instanceId) || |
| 891 | (updatingProviderInstanceIds.has(updateCandidate.instanceId) || | 895 | (liveProvider !== undefined && isProviderUpdateActive(liveProvider)); |
| 892 | isProviderUpdateActive(updateCandidate)); | ||
| 893 | const showInlineUpdateButton = updateCandidate !== undefined; | 896 | const showInlineUpdateButton = updateCandidate !== undefined; |
| 894 | const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning; | 897 | const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning; |
| 895 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { | 898 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { |
| ... | @@ -971,6 +974,19 @@ export function EnvironmentProviderSettings({ | ... | @@ -971,6 +974,19 @@ export function EnvironmentProviderSettings({ |
| 971 | modelOrder, | 974 | modelOrder, |
| 972 | }) | 975 | }) |
| 973 | } | 976 | } |
| 977 | onInstallRecommended={ | ||
| 978 | mode === "editor" && | ||
| 979 | liveProvider?.compatibilityAdvisory?.message && | ||
| 980 | liveProvider.compatibilityAdvisory.recommendedVersion && | ||
| 981 | liveProvider.versionAdvisory?.canInstallVersion | ||
| 982 | ? () => { | ||
| 983 | void runProviderUpdate( | ||
| 984 | liveProvider, | ||
| 985 | liveProvider.compatibilityAdvisory?.recommendedVersion ?? undefined, | ||
| 986 | ); | ||
| 987 | } | ||
| 988 | : undefined | ||
| 989 | } | ||
| 974 | onRunUpdate={ | 990 | onRunUpdate={ |
| 975 | mode === "editor" && showInlineUpdateButton && updateCandidate | 991 | mode === "editor" && showInlineUpdateButton && updateCandidate |
| 976 | ? () => { | 992 | ? () => { |
| ... | @@ -978,9 +994,7 @@ export function EnvironmentProviderSettings({ | ... | @@ -978,9 +994,7 @@ export function EnvironmentProviderSettings({ |
| 978 | } | 994 | } |
| 979 | : undefined | 995 | : undefined |
| 980 | } | 996 | } |
| 981 | isUpdating={ | 997 | isUpdating={mode === "editor" ? isInstanceUpdateRunning : undefined} |
| 982 | mode === "editor" && showInlineUpdateButton ? isInstanceUpdateRunning : undefined | ||
| 983 | } | ||
| 984 | /> | 998 | /> |
| 985 | ); | 999 | ); |
| 986 | }; | 1000 | }; |
apps/web/src/components/settings/providerStatus.test.ts+71-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { getProviderSummary } from "./providerStatus"; | 4 | import { getProviderSummary, getProviderVersionAdvisoryPresentation } from "./providerStatus"; |
| 5 | 5 | ||
| 6 | const provider: ServerProvider = { | 6 | const provider: ServerProvider = { |
| 7 | instanceId: ProviderInstanceId.make("codex"), | 7 | instanceId: ProviderInstanceId.make("codex"), |
| ... | @@ -69,3 +69,73 @@ describe("getProviderSummary", () => { | ... | @@ -69,3 +69,73 @@ describe("getProviderSummary", () => { |
| 69 | expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); | 69 | expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); |
| 70 | }); | 70 | }); |
| 71 | }); | 71 | }); |
| 72 | |||
| 73 | it("does not suggest copying a command that installs an incompatible latest version", () => { | ||
| 74 | const advisory = { | ||
| 75 | status: "behind_latest" as const, | ||
| 76 | currentVersion: "1.0.0", | ||
| 77 | latestVersion: "2.0.0", | ||
| 78 | updateCommand: "npm install -g fixture@latest", | ||
| 79 | canUpdate: true, | ||
| 80 | checkedAt: provider.checkedAt, | ||
| 81 | message: null, | ||
| 82 | }; | ||
| 83 | const compatibility = { | ||
| 84 | status: "supported" as const, | ||
| 85 | latestVersionStatus: "broken" as const, | ||
| 86 | message: null, | ||
| 87 | recommendedRange: null, | ||
| 88 | recommendedVersion: null, | ||
| 89 | }; | ||
| 90 | expect(getProviderVersionAdvisoryPresentation(advisory, compatibility)).toBeNull(); | ||
| 91 | expect( | ||
| 92 | getProviderVersionAdvisoryPresentation(advisory, { ...compatibility, status: "broken" }, false), | ||
| 93 | ).toBeNull(); | ||
| 94 | expect( | ||
| 95 | getProviderVersionAdvisoryPresentation(advisory, { | ||
| 96 | ...compatibility, | ||
| 97 | latestVersionStatus: "supported", | ||
| 98 | }), | ||
| 99 | ).not.toBeNull(); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("shows compatibility in the version popover even when the installed version is current", () => { | ||
| 103 | const advisory = { | ||
| 104 | status: "current" as const, | ||
| 105 | currentVersion: "2.0.0", | ||
| 106 | latestVersion: "2.0.0", | ||
| 107 | updateCommand: "npm install -g fixture@latest", | ||
| 108 | canUpdate: true, | ||
| 109 | checkedAt: provider.checkedAt, | ||
| 110 | message: null, | ||
| 111 | }; | ||
| 112 | const compatibility = { | ||
| 113 | status: "broken" as const, | ||
| 114 | latestVersionStatus: "broken" as const, | ||
| 115 | message: "This version drops turns. Use 1.9.0.", | ||
| 116 | recommendedRange: null, | ||
| 117 | recommendedVersion: "1.9.0", | ||
| 118 | }; | ||
| 119 | expect(getProviderVersionAdvisoryPresentation(advisory, compatibility)).toEqual({ | ||
| 120 | title: "Known broken version", | ||
| 121 | detail: compatibility.message, | ||
| 122 | updateCommand: null, | ||
| 123 | emphasis: "strong", | ||
| 124 | targetVersion: "1.9.0", | ||
| 125 | }); | ||
| 126 | expect( | ||
| 127 | getProviderVersionAdvisoryPresentation(undefined, { | ||
| 128 | ...compatibility, | ||
| 129 | status: "graceful", | ||
| 130 | recommendedVersion: null, | ||
| 131 | recommendedRange: ">=2.1.0", | ||
| 132 | message: null, | ||
| 133 | }), | ||
| 134 | ).toEqual({ | ||
| 135 | title: "Limited support", | ||
| 136 | detail: "Use >=2.1.0 for full support.", | ||
| 137 | updateCommand: null, | ||
| 138 | emphasis: "normal", | ||
| 139 | targetVersion: null, | ||
| 140 | }); | ||
| 141 | }); |
apps/web/src/components/settings/providerStatus.ts+47-2| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; | 1 | import type { |
| 2 | ServerProvider, | ||
| 3 | ServerProviderVersionAdvisory, | ||
| 4 | ServerProviderCompatibilityAdvisory, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | 6 | ||
| 3 | /** | 7 | /** |
| 4 | * Visual treatment for each server-reported provider status. Centralized so | 8 | * Visual treatment for each server-reported provider status. Centralized so |
| ... | @@ -99,14 +103,53 @@ export function getProviderVersionLabel(version: string | null | undefined) { | ... | @@ -99,14 +103,53 @@ export function getProviderVersionLabel(version: string | null | undefined) { |
| 99 | return /^\d/.test(version) ? `v${version}` : version; | 103 | return /^\d/.test(version) ? `v${version}` : version; |
| 100 | } | 104 | } |
| 101 | 105 | ||
| 106 | const COMPATIBILITY_TITLES = { | ||
| 107 | graceful: "Limited support", | ||
| 108 | unsupported: "Unsupported version", | ||
| 109 | broken: "Known broken version", | ||
| 110 | } as const; | ||
| 111 | |||
| 112 | /** Compatibility guidance shares the version popover, with safe install actions. */ | ||
| 102 | export function getProviderVersionAdvisoryPresentation( | 113 | export function getProviderVersionAdvisoryPresentation( |
| 103 | advisory: ServerProviderVersionAdvisory | undefined, | 114 | advisory: ServerProviderVersionAdvisory | undefined, |
| 115 | compatibility?: ServerProviderCompatibilityAdvisory | undefined, | ||
| 116 | showCompatibility = true, | ||
| 104 | ): { | 117 | ): { |
| 118 | readonly title: string; | ||
| 105 | readonly detail: string; | 119 | readonly detail: string; |
| 106 | readonly updateCommand: string | null; | 120 | readonly updateCommand: string | null; |
| 107 | readonly emphasis: "normal" | "strong"; | 121 | readonly emphasis: "normal" | "strong"; |
| 122 | readonly targetVersion: string | null; | ||
| 108 | } | null { | 123 | } | null { |
| 109 | if (!advisory || advisory.status === "current" || advisory.status === "unknown") { | 124 | const latestIsIncompatible = |
| 125 | compatibility?.latestVersionStatus === "broken" || | ||
| 126 | compatibility?.latestVersionStatus === "unsupported"; | ||
| 127 | if ( | ||
| 128 | showCompatibility && | ||
| 129 | compatibility && | ||
| 130 | (compatibility.status === "graceful" || | ||
| 131 | compatibility.status === "unsupported" || | ||
| 132 | compatibility.status === "broken") | ||
| 133 | ) { | ||
| 134 | const targetVersion = compatibility.recommendedVersion; | ||
| 135 | const recommendation = getProviderVersionLabel(targetVersion) ?? compatibility.recommendedRange; | ||
| 136 | return { | ||
| 137 | title: COMPATIBILITY_TITLES[compatibility.status], | ||
| 138 | detail: | ||
| 139 | compatibility.message ?? | ||
| 140 | (recommendation ? `Use ${recommendation} for full support.` : "Update for full support."), | ||
| 141 | updateCommand: | ||
| 142 | targetVersion || latestIsIncompatible ? null : (advisory?.updateCommand ?? null), | ||
| 143 | emphasis: compatibility.status === "graceful" ? "normal" : "strong", | ||
| 144 | targetVersion, | ||
| 145 | }; | ||
| 146 | } | ||
| 147 | if ( | ||
| 148 | !advisory || | ||
| 149 | advisory.status === "current" || | ||
| 150 | advisory.status === "unknown" || | ||
| 151 | latestIsIncompatible | ||
| 152 | ) { | ||
| 110 | return null; | 153 | return null; |
| 111 | } | 154 | } |
| 112 | 155 | ||
| ... | @@ -115,6 +158,7 @@ export function getProviderVersionAdvisoryPresentation( | ... | @@ -115,6 +158,7 @@ export function getProviderVersionAdvisoryPresentation( |
| 115 | const versionLabel = getProviderVersionLabel(version); | 158 | const versionLabel = getProviderVersionLabel(version); |
| 116 | 159 | ||
| 117 | return { | 160 | return { |
| 161 | title: label, | ||
| 118 | detail: | 162 | detail: |
| 119 | advisory.message ?? | 163 | advisory.message ?? |
| 120 | (versionLabel | 164 | (versionLabel |
| ... | @@ -122,5 +166,6 @@ export function getProviderVersionAdvisoryPresentation( | ... | @@ -122,5 +166,6 @@ export function getProviderVersionAdvisoryPresentation( |
| 122 | : `${label}: install the latest provider version.`), | 166 | : `${label}: install the latest provider version.`), |
| 123 | updateCommand: advisory.updateCommand, | 167 | updateCommand: advisory.updateCommand, |
| 124 | emphasis: "normal" as const, | 168 | emphasis: "normal" as const, |
| 169 | targetVersion: null, | ||
| 125 | }; | 170 | }; |
| 126 | } | 171 | } |
docs/user/install.md+6| ... | @@ -120,6 +120,12 @@ Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set it | ... | @@ -120,6 +120,12 @@ Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set it |
| 120 | Cursor's executable is `cursor-agent`, although its login command is | 120 | Cursor's executable is `cursor-agent`, although its login command is |
| 121 | `agent login`. Antigravity can use its managed runtime without a `PATH` entry. | 121 | `agent login`. Antigravity can use its managed runtime without a `PATH` entry. |
| 122 | 122 | ||
| 123 | T3 Code warns when a provider version has known compatibility problems with your | ||
| 124 | release. Check **Settings → Providers** on that environment for the recommended | ||
| 125 | version or range. When its package manager supports installing a specific version, | ||
| 126 | you can install the recommendation there. Otherwise use the provider's installer | ||
| 127 | on the environment's machine. An unlisted version is unverified. | ||
| 128 | |||
| 123 | When a provider CLI is behind its latest release, its provider card shows the | 129 | When a provider CLI is behind its latest release, its provider card shows the |
| 124 | available version. **Update now** appears only when T3 Code can tell which | 130 | available version. **Update now** appears only when T3 Code can tell which |
| 125 | installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm, | 131 | installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm, |
packages/contracts/src/server.ts+19| ... | @@ -148,6 +148,22 @@ export const ServerProviderContinuation = Schema.Struct({ | ... | @@ -148,6 +148,22 @@ export const ServerProviderContinuation = Schema.Struct({ |
| 148 | }); | 148 | }); |
| 149 | export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; | 149 | export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; |
| 150 | 150 | ||
| 151 | export const ServerProviderCompatibilityStatus = Schema.Literals([ | ||
| 152 | "unknown", | ||
| 153 | "supported", | ||
| 154 | "graceful", | ||
| 155 | "unsupported", | ||
| 156 | "broken", | ||
| 157 | ]); | ||
| 158 | export const ServerProviderCompatibilityAdvisory = Schema.Struct({ | ||
| 159 | status: ServerProviderCompatibilityStatus, | ||
| 160 | latestVersionStatus: Schema.optionalKey(ServerProviderCompatibilityStatus), | ||
| 161 | message: Schema.NullOr(TrimmedNonEmptyString), | ||
| 162 | recommendedVersion: Schema.NullOr(TrimmedNonEmptyString), | ||
| 163 | recommendedRange: Schema.NullOr(TrimmedNonEmptyString), | ||
| 164 | }); | ||
| 165 | export type ServerProviderCompatibilityAdvisory = typeof ServerProviderCompatibilityAdvisory.Type; | ||
| 166 | |||
| 151 | export const ServerProviderVersionAdvisoryStatus = Schema.Literals([ | 167 | export const ServerProviderVersionAdvisoryStatus = Schema.Literals([ |
| 152 | "unknown", | 168 | "unknown", |
| 153 | "current", | 169 | "current", |
| ... | @@ -161,6 +177,7 @@ export const ServerProviderVersionAdvisory = Schema.Struct({ | ... | @@ -161,6 +177,7 @@ export const ServerProviderVersionAdvisory = Schema.Struct({ |
| 161 | latestVersion: Schema.NullOr(TrimmedNonEmptyString), | 177 | latestVersion: Schema.NullOr(TrimmedNonEmptyString), |
| 162 | updateCommand: Schema.NullOr(TrimmedNonEmptyString), | 178 | updateCommand: Schema.NullOr(TrimmedNonEmptyString), |
| 163 | canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 179 | canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 180 | canInstallVersion: Schema.optionalKey(Schema.Boolean), | ||
| 164 | checkedAt: Schema.NullOr(IsoDateTime), | 181 | checkedAt: Schema.NullOr(IsoDateTime), |
| 165 | message: Schema.NullOr(TrimmedNonEmptyString), | 182 | message: Schema.NullOr(TrimmedNonEmptyString), |
| 166 | }); | 183 | }); |
| ... | @@ -234,6 +251,7 @@ export const ServerProvider = Schema.Struct({ | ... | @@ -234,6 +251,7 @@ export const ServerProvider = Schema.Struct({ |
| 234 | // Absent when the driver has no notion of subscription usage. | 251 | // Absent when the driver has no notion of subscription usage. |
| 235 | usageLimits: Schema.optional(ServerProviderUsageLimits), | 252 | usageLimits: Schema.optional(ServerProviderUsageLimits), |
| 236 | versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), | 253 | versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), |
| 254 | compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory), | ||
| 237 | updateState: Schema.optionalKey(ServerProviderUpdateState), | 255 | updateState: Schema.optionalKey(ServerProviderUpdateState), |
| 238 | }); | 256 | }); |
| 239 | export type ServerProvider = typeof ServerProvider.Type; | 257 | export type ServerProvider = typeof ServerProvider.Type; |
| ... | @@ -799,6 +817,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T | ... | @@ -799,6 +817,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T |
| 799 | 817 | ||
| 800 | export const ServerProviderUpdateInput = Schema.Struct({ | 818 | export const ServerProviderUpdateInput = Schema.Struct({ |
| 801 | provider: ProviderDriverKind, | 819 | provider: ProviderDriverKind, |
| 820 | targetVersion: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 802 | instanceId: Schema.optionalKey(ProviderInstanceId), | 821 | instanceId: Schema.optionalKey(ProviderInstanceId), |
| 803 | }); | 822 | }); |
| 804 | export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; | 823 | export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; |