| author | |
| committer | |
| log | 99e44f2c23462f9ccf80a9d199bb424ae130bf33 |
| tree | d638de85335c576c32eaa76b2109d1ce8e7b1ad7 |
| parent | 7a0f2d9b3058d7c7da4d49fd31e308c98f2e5e2a |
- add startup ProviderHealth service/layer to probe Codex install and auth state
- extend server contracts and WS payloads to include provider statuses
- show non-ready provider warnings in ChatView and cover with server/web tests9 files changed, 499 insertions(+), 4 deletions(-)
apps/server/src/main.ts+2| ... | ... | @@ -21,6 +21,7 @@ import { fixPath, resolveStateDir } from "./os-jank"; |
| 21 | 21 | import { Open } from "./open"; |
| 22 | 22 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; |
| 23 | 23 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; |
| 24 | import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; | |
| 24 | 25 | import { Server } from "./wsServer"; |
| 25 | 26 | |
| 26 | 27 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| ... | ... | @@ -197,6 +198,7 @@ const LayerLive = (input: CliInput) => |
| 197 | 198 | Layer.empty.pipe( |
| 198 | 199 | Layer.provideMerge(makeServerRuntimeServicesLayer()), |
| 199 | 200 | Layer.provideMerge(makeServerProviderLayer()), |
| 201 | Layer.provideMerge(ProviderHealthLive), | |
| 200 | 202 | Layer.provideMerge(SqlitePersistence.layerConfig), |
| 201 | 203 | Layer.provideMerge(ServerConfigLive(input)), |
| 202 | 204 | ); |
apps/server/src/provider/Layers/ProviderHealth.test.ts created+107| ... | ... | @@ -0,0 +1,107 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import type { ProcessRunResult } from "../../processRunner"; | |
| 4 | import { checkProviderStatusesOnStartup } from "./ProviderHealth"; | |
| 5 | ||
| 6 | function result(input: Partial<ProcessRunResult>): ProcessRunResult { | |
| 7 | return { | |
| 8 | stdout: "", | |
| 9 | stderr: "", | |
| 10 | code: 0, | |
| 11 | signal: null, | |
| 12 | timedOut: false, | |
| 13 | stdoutTruncated: false, | |
| 14 | stderrTruncated: false, | |
| 15 | ...input, | |
| 16 | }; | |
| 17 | } | |
| 18 | ||
| 19 | describe("checkProviderStatusesOnStartup", () => { | |
| 20 | it("returns ready when codex is installed and authenticated", async () => { | |
| 21 | const status = await checkProviderStatusesOnStartup(async (_command, args) => { | |
| 22 | if (args.join(" ") === "--version") { | |
| 23 | return result({ stdout: "codex 1.0.0\n" }); | |
| 24 | } | |
| 25 | if (args.join(" ") === "auth status --json") { | |
| 26 | return result({ stdout: '{"authenticated":true}\n' }); | |
| 27 | } | |
| 28 | throw new Error(`Unexpected command: ${args.join(" ")}`); | |
| 29 | }); | |
| 30 | ||
| 31 | expect(status).toEqual([ | |
| 32 | expect.objectContaining({ | |
| 33 | provider: "codex", | |
| 34 | status: "ready", | |
| 35 | available: true, | |
| 36 | authStatus: "authenticated", | |
| 37 | }), | |
| 38 | ]); | |
| 39 | }); | |
| 40 | ||
| 41 | it("returns unavailable when codex is missing", async () => { | |
| 42 | const status = await checkProviderStatusesOnStartup(async () => { | |
| 43 | throw new Error("Command not found: codex"); | |
| 44 | }); | |
| 45 | ||
| 46 | expect(status).toEqual([ | |
| 47 | expect.objectContaining({ | |
| 48 | provider: "codex", | |
| 49 | status: "error", | |
| 50 | available: false, | |
| 51 | authStatus: "unknown", | |
| 52 | message: "Codex CLI (`codex`) is not installed or not on PATH.", | |
| 53 | }), | |
| 54 | ]); | |
| 55 | }); | |
| 56 | ||
| 57 | it("returns unauthenticated when auth probe reports login required", async () => { | |
| 58 | const status = await checkProviderStatusesOnStartup(async (_command, args) => { | |
| 59 | if (args.join(" ") === "--version") { | |
| 60 | return result({ stdout: "codex 1.0.0\n" }); | |
| 61 | } | |
| 62 | if (args.join(" ") === "auth status --json") { | |
| 63 | return result({ | |
| 64 | code: 1, | |
| 65 | stderr: "Not logged in. Run codex login.", | |
| 66 | }); | |
| 67 | } | |
| 68 | throw new Error(`Unexpected command: ${args.join(" ")}`); | |
| 69 | }); | |
| 70 | ||
| 71 | expect(status).toEqual([ | |
| 72 | expect.objectContaining({ | |
| 73 | provider: "codex", | |
| 74 | status: "error", | |
| 75 | available: true, | |
| 76 | authStatus: "unauthenticated", | |
| 77 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 78 | }), | |
| 79 | ]); | |
| 80 | }); | |
| 81 | ||
| 82 | it("returns warning when auth status command is unsupported", async () => { | |
| 83 | const status = await checkProviderStatusesOnStartup(async (_command, args) => { | |
| 84 | if (args.join(" ") === "--version") { | |
| 85 | return result({ stdout: "codex 1.0.0\n" }); | |
| 86 | } | |
| 87 | if (args.join(" ") === "auth status --json") { | |
| 88 | return result({ | |
| 89 | code: 2, | |
| 90 | stderr: "error: unknown command 'auth'", | |
| 91 | }); | |
| 92 | } | |
| 93 | throw new Error(`Unexpected command: ${args.join(" ")}`); | |
| 94 | }); | |
| 95 | ||
| 96 | expect(status).toEqual([ | |
| 97 | expect.objectContaining({ | |
| 98 | provider: "codex", | |
| 99 | status: "warning", | |
| 100 | available: true, | |
| 101 | authStatus: "unknown", | |
| 102 | message: | |
| 103 | "Codex CLI authentication status command is unavailable in this Codex version.", | |
| 104 | }), | |
| 105 | ]); | |
| 106 | }); | |
| 107 | }); |
apps/server/src/provider/Layers/ProviderHealth.ts created+246| ... | ... | @@ -0,0 +1,246 @@ |
| 1 | /** | |
| 2 | * ProviderHealthLive - Startup-time provider health checks. | |
| 3 | * | |
| 4 | * Performs one-time provider readiness probes when the server starts and | |
| 5 | * keeps the resulting snapshot in memory for `server.getConfig`. | |
| 6 | * | |
| 7 | * @module ProviderHealthLive | |
| 8 | */ | |
| 9 | import type { | |
| 10 | ServerProviderAuthStatus, | |
| 11 | ServerProviderStatus, | |
| 12 | ServerProviderStatusState, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import { Effect, Layer } from "effect"; | |
| 15 | ||
| 16 | import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "../../processRunner"; | |
| 17 | import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; | |
| 18 | ||
| 19 | type CommandRunner = ( | |
| 20 | command: string, | |
| 21 | args: readonly string[], | |
| 22 | options?: ProcessRunOptions, | |
| 23 | ) => Promise<ProcessRunResult>; | |
| 24 | ||
| 25 | const DEFAULT_TIMEOUT_MS = 4_000; | |
| 26 | const CODEX_PROVIDER = "codex" as const; | |
| 27 | ||
| 28 | function nonEmptyMessage(value: string | undefined): string | undefined { | |
| 29 | if (!value) return undefined; | |
| 30 | const trimmed = value.trim(); | |
| 31 | return trimmed.length > 0 ? trimmed : undefined; | |
| 32 | } | |
| 33 | ||
| 34 | function isCommandMissingError(error: unknown): boolean { | |
| 35 | if (!(error instanceof Error)) return false; | |
| 36 | const lower = error.message.toLowerCase(); | |
| 37 | return ( | |
| 38 | lower.includes("command not found: codex") || | |
| 39 | lower.includes("spawn codex enoent") || | |
| 40 | lower.includes("enoent") | |
| 41 | ); | |
| 42 | } | |
| 43 | ||
| 44 | function detailFromResult(result: ProcessRunResult): string | undefined { | |
| 45 | if (result.timedOut) return "Timed out while running command."; | |
| 46 | const stderr = nonEmptyMessage(result.stderr); | |
| 47 | if (stderr) return stderr; | |
| 48 | const stdout = nonEmptyMessage(result.stdout); | |
| 49 | if (stdout) return stdout; | |
| 50 | if (result.code !== null && result.code !== 0) { | |
| 51 | return `Command exited with code ${result.code}.`; | |
| 52 | } | |
| 53 | return undefined; | |
| 54 | } | |
| 55 | ||
| 56 | function extractAuthBoolean(value: unknown): boolean | undefined { | |
| 57 | if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| 58 | return undefined; | |
| 59 | } | |
| 60 | ||
| 61 | const record = value as Record<string, unknown>; | |
| 62 | const directCandidates = [ | |
| 63 | "authenticated", | |
| 64 | "isAuthenticated", | |
| 65 | "loggedIn", | |
| 66 | "isLoggedIn", | |
| 67 | ] as const; | |
| 68 | for (const key of directCandidates) { | |
| 69 | const candidate = record[key]; | |
| 70 | if (typeof candidate === "boolean") { | |
| 71 | return candidate; | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | const nestedCandidates = ["auth", "status", "session", "account"] as const; | |
| 76 | for (const key of nestedCandidates) { | |
| 77 | const nested = extractAuthBoolean(record[key]); | |
| 78 | if (nested !== undefined) { | |
| 79 | return nested; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | return undefined; | |
| 84 | } | |
| 85 | ||
| 86 | function parseAuthStatusFromOutput( | |
| 87 | result: ProcessRunResult, | |
| 88 | ): { | |
| 89 | readonly status: ServerProviderStatusState; | |
| 90 | readonly authStatus: ServerProviderAuthStatus; | |
| 91 | readonly message?: string; | |
| 92 | } { | |
| 93 | const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); | |
| 94 | const unknownCommand = | |
| 95 | lowerOutput.includes("unknown command") || | |
| 96 | lowerOutput.includes("unrecognized command") || | |
| 97 | lowerOutput.includes("unexpected argument"); | |
| 98 | if (unknownCommand) { | |
| 99 | return { | |
| 100 | status: "warning", | |
| 101 | authStatus: "unknown", | |
| 102 | message: | |
| 103 | "Codex CLI authentication status command is unavailable in this Codex version.", | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | const unauthenticatedSignal = | |
| 108 | lowerOutput.includes("not logged in") || | |
| 109 | lowerOutput.includes("login required") || | |
| 110 | lowerOutput.includes("authentication required") || | |
| 111 | lowerOutput.includes("run `codex login`") || | |
| 112 | lowerOutput.includes("run codex login"); | |
| 113 | if (unauthenticatedSignal) { | |
| 114 | return { | |
| 115 | status: "error", | |
| 116 | authStatus: "unauthenticated", | |
| 117 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 118 | }; | |
| 119 | } | |
| 120 | ||
| 121 | const parsedAuth = | |
| 122 | (() => { | |
| 123 | const trimmed = result.stdout.trim(); | |
| 124 | if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { | |
| 125 | return undefined; | |
| 126 | } | |
| 127 | try { | |
| 128 | return extractAuthBoolean(JSON.parse(trimmed)); | |
| 129 | } catch { | |
| 130 | return undefined; | |
| 131 | } | |
| 132 | })() ?? undefined; | |
| 133 | ||
| 134 | if (parsedAuth === true) { | |
| 135 | return { status: "ready", authStatus: "authenticated" }; | |
| 136 | } | |
| 137 | if (parsedAuth === false) { | |
| 138 | return { | |
| 139 | status: "error", | |
| 140 | authStatus: "unauthenticated", | |
| 141 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | |
| 142 | }; | |
| 143 | } | |
| 144 | ||
| 145 | if (result.code === 0) { | |
| 146 | // Successful command with no explicit auth marker is still a pass. | |
| 147 | return { status: "ready", authStatus: "authenticated" }; | |
| 148 | } | |
| 149 | ||
| 150 | const detail = detailFromResult(result); | |
| 151 | return { | |
| 152 | status: "warning", | |
| 153 | authStatus: "unknown", | |
| 154 | ...(detail | |
| 155 | ? { message: `Could not verify Codex authentication status. ${detail}` } | |
| 156 | : { message: "Could not verify Codex authentication status." }), | |
| 157 | }; | |
| 158 | } | |
| 159 | ||
| 160 | async function checkCodexProviderStatus(run: CommandRunner): Promise<ServerProviderStatus> { | |
| 161 | const checkedAt = new Date().toISOString(); | |
| 162 | ||
| 163 | try { | |
| 164 | const version = await run("codex", ["--version"], { | |
| 165 | timeoutMs: DEFAULT_TIMEOUT_MS, | |
| 166 | allowNonZeroExit: true, | |
| 167 | outputMode: "truncate", | |
| 168 | }); | |
| 169 | ||
| 170 | if (version.code !== 0 || version.timedOut) { | |
| 171 | const detail = detailFromResult(version); | |
| 172 | return { | |
| 173 | provider: CODEX_PROVIDER, | |
| 174 | status: "error", | |
| 175 | available: false, | |
| 176 | authStatus: "unknown", | |
| 177 | checkedAt, | |
| 178 | message: | |
| 179 | detail !== undefined | |
| 180 | ? `Codex CLI is installed but failed to run. ${detail}` | |
| 181 | : "Codex CLI is installed but failed to run.", | |
| 182 | }; | |
| 183 | } | |
| 184 | } catch (error) { | |
| 185 | return { | |
| 186 | provider: CODEX_PROVIDER, | |
| 187 | status: "error", | |
| 188 | available: false, | |
| 189 | authStatus: "unknown", | |
| 190 | checkedAt, | |
| 191 | message: isCommandMissingError(error) | |
| 192 | ? "Codex CLI (`codex`) is not installed or not on PATH." | |
| 193 | : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, | |
| 194 | }; | |
| 195 | } | |
| 196 | ||
| 197 | try { | |
| 198 | const auth = await run("codex", ["auth", "status", "--json"], { | |
| 199 | timeoutMs: DEFAULT_TIMEOUT_MS, | |
| 200 | allowNonZeroExit: true, | |
| 201 | outputMode: "truncate", | |
| 202 | }); | |
| 203 | const parsed = parseAuthStatusFromOutput(auth); | |
| 204 | return { | |
| 205 | provider: CODEX_PROVIDER, | |
| 206 | status: parsed.status, | |
| 207 | available: true, | |
| 208 | authStatus: parsed.authStatus, | |
| 209 | checkedAt, | |
| 210 | ...(parsed.message ? { message: parsed.message } : {}), | |
| 211 | }; | |
| 212 | } catch (error) { | |
| 213 | return { | |
| 214 | provider: CODEX_PROVIDER, | |
| 215 | status: "warning", | |
| 216 | available: true, | |
| 217 | authStatus: "unknown", | |
| 218 | checkedAt, | |
| 219 | message: | |
| 220 | error instanceof Error | |
| 221 | ? `Could not verify Codex authentication status: ${error.message}.` | |
| 222 | : "Could not verify Codex authentication status.", | |
| 223 | }; | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | export async function checkProviderStatusesOnStartup( | |
| 228 | run: CommandRunner = runProcess, | |
| 229 | ): Promise<ReadonlyArray<ServerProviderStatus>> { | |
| 230 | return [await checkCodexProviderStatus(run)]; | |
| 231 | } | |
| 232 | ||
| 233 | const makeProviderHealth = (run: CommandRunner = runProcess) => | |
| 234 | Effect.gen(function* () { | |
| 235 | const statuses = yield* Effect.promise(() => checkProviderStatusesOnStartup(run)); | |
| 236 | ||
| 237 | return { | |
| 238 | getStatuses: Effect.succeed(statuses), | |
| 239 | } satisfies ProviderHealthShape; | |
| 240 | }); | |
| 241 | ||
| 242 | export const ProviderHealthLive = Layer.effect(ProviderHealth, makeProviderHealth()); | |
| 243 | ||
| 244 | export function makeProviderHealthLive(run?: CommandRunner) { | |
| 245 | return Layer.effect(ProviderHealth, makeProviderHealth(run)); | |
| 246 | } |
apps/server/src/provider/Services/ProviderHealth.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | /** | |
| 2 | * ProviderHealth - Provider readiness snapshot service. | |
| 3 | * | |
| 4 | * Owns startup-time provider health checks (install/auth reachability) and | |
| 5 | * exposes the cached results to transport layers. | |
| 6 | * | |
| 7 | * @module ProviderHealth | |
| 8 | */ | |
| 9 | import type { ServerProviderStatus } from "@t3tools/contracts"; | |
| 10 | import { ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | export interface ProviderHealthShape { | |
| 14 | /** | |
| 15 | * Read provider health statuses computed at server startup. | |
| 16 | */ | |
| 17 | readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>; | |
| 18 | } | |
| 19 | ||
| 20 | export class ProviderHealth extends ServiceMap.Service<ProviderHealth, ProviderHealthShape>()( | |
| 21 | "t3/provider/Services/ProviderHealth", | |
| 22 | ) {} |
apps/server/src/wsServer.test.ts+31-1| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | WS_METHODS, |
| 26 | 26 | type WebSocketResponse, |
| 27 | 27 | type ProviderRuntimeEvent, |
| 28 | type ServerProviderStatus, | |
| 28 | 29 | type KeybindingsConfig, |
| 29 | 30 | type ResolvedKeybindingsConfig, |
| 30 | 31 | type WsPush, |
| ... | ... | @@ -43,6 +44,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ |
| 43 | 44 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 44 | 45 | import { SqlClient, SqlError } from "effect/unstable/sql"; |
| 45 | 46 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 47 | import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; | |
| 46 | 48 | import { Open, type OpenShape } from "./open"; |
| 47 | 49 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 48 | 50 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| ... | ... | @@ -69,6 +71,20 @@ const defaultOpenService: OpenShape = { |
| 69 | 71 | openInEditor: () => Effect.void, |
| 70 | 72 | }; |
| 71 | 73 | |
| 74 | const defaultProviderStatuses: ReadonlyArray<ServerProviderStatus> = [ | |
| 75 | { | |
| 76 | provider: "codex", | |
| 77 | status: "ready", | |
| 78 | available: true, | |
| 79 | authStatus: "authenticated", | |
| 80 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 81 | }, | |
| 82 | ]; | |
| 83 | ||
| 84 | const defaultProviderHealthService: ProviderHealthShape = { | |
| 85 | getStatuses: Effect.succeed(defaultProviderStatuses), | |
| 86 | }; | |
| 87 | ||
| 72 | 88 | class MockTerminalManager implements TerminalManagerShape { |
| 73 | 89 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 74 | 90 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); |
| ... | ... | @@ -377,6 +393,7 @@ describe("WebSocket Server", () => { |
| 377 | 393 | stateDir?: string; |
| 378 | 394 | staticDir?: string; |
| 379 | 395 | providerLayer?: Layer.Layer<ProviderService, never>; |
| 396 | providerHealth?: ProviderHealthShape; | |
| 380 | 397 | open?: OpenShape; |
| 381 | 398 | gitManager?: GitManagerShape; |
| 382 | 399 | gitCore?: Pick< |
| ... | ... | @@ -394,6 +411,10 @@ describe("WebSocket Server", () => { |
| 394 | 411 | const scope = await Effect.runPromise(Scope.make("sequential")); |
| 395 | 412 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; |
| 396 | 413 | const providerLayer = options.providerLayer ?? makeServerProviderLayer(); |
| 414 | const providerHealthLayer = Layer.succeed( | |
| 415 | ProviderHealth, | |
| 416 | options.providerHealth ?? defaultProviderHealthService, | |
| 417 | ); | |
| 397 | 418 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); |
| 398 | 419 | const serverConfigLayer = Layer.succeed(ServerConfig, { |
| 399 | 420 | mode: "web", |
| ... | ... | @@ -428,6 +449,7 @@ describe("WebSocket Server", () => { |
| 428 | 449 | ); |
| 429 | 450 | const dependenciesLayer = Layer.empty.pipe( |
| 430 | 451 | Layer.provideMerge(runtimeLayer), |
| 452 | Layer.provideMerge(providerHealthLayer), | |
| 431 | 453 | Layer.provideMerge(openLayer), |
| 432 | 454 | Layer.provideMerge(serverConfigLayer), |
| 433 | 455 | Layer.provideMerge(NodeServices.layer), |
| ... | ... | @@ -736,6 +758,7 @@ describe("WebSocket Server", () => { |
| 736 | 758 | keybindingsConfigPath: keybindingsPath, |
| 737 | 759 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, |
| 738 | 760 | issues: [], |
| 761 | providers: defaultProviderStatuses, | |
| 739 | 762 | availableEditors: expect.any(Array), |
| 740 | 763 | }); |
| 741 | 764 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| ... | ... | @@ -761,6 +784,7 @@ describe("WebSocket Server", () => { |
| 761 | 784 | keybindingsConfigPath: keybindingsPath, |
| 762 | 785 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, |
| 763 | 786 | issues: [], |
| 787 | providers: defaultProviderStatuses, | |
| 764 | 788 | availableEditors: expect.any(Array), |
| 765 | 789 | }); |
| 766 | 790 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| ... | ... | @@ -796,6 +820,7 @@ describe("WebSocket Server", () => { |
| 796 | 820 | message: expect.stringContaining("expected JSON array"), |
| 797 | 821 | }, |
| 798 | 822 | ], |
| 823 | providers: defaultProviderStatuses, | |
| 799 | 824 | availableEditors: expect.any(Array), |
| 800 | 825 | }); |
| 801 | 826 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| ... | ... | @@ -830,6 +855,7 @@ describe("WebSocket Server", () => { |
| 830 | 855 | keybindingsConfigPath: string; |
| 831 | 856 | keybindings: ResolvedKeybindingsConfig; |
| 832 | 857 | issues: Array<{ kind: string; index?: number; message: string }>; |
| 858 | providers: ReadonlyArray<ServerProviderStatus>; | |
| 833 | 859 | availableEditors: unknown; |
| 834 | 860 | }; |
| 835 | 861 | expect(result.cwd).toBe("/my/workspace"); |
| ... | ... | @@ -849,6 +875,7 @@ describe("WebSocket Server", () => { |
| 849 | 875 | expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); |
| 850 | 876 | expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); |
| 851 | 877 | expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); |
| 878 | expect(result.providers).toEqual(defaultProviderStatuses); | |
| 852 | 879 | expectAvailableEditors(result.availableEditors); |
| 853 | 880 | }); |
| 854 | 881 | |
| ... | ... | @@ -877,6 +904,7 @@ describe("WebSocket Server", () => { |
| 877 | 904 | ); |
| 878 | 905 | expect(malformedPush.data).toEqual({ |
| 879 | 906 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], |
| 907 | providers: defaultProviderStatuses, | |
| 880 | 908 | }); |
| 881 | 909 | |
| 882 | 910 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); |
| ... | ... | @@ -887,7 +915,7 @@ describe("WebSocket Server", () => { |
| 887 | 915 | Array.isArray((push.data as { issues?: unknown[] }).issues) && |
| 888 | 916 | (push.data as { issues: unknown[] }).issues.length === 0, |
| 889 | 917 | ); |
| 890 | expect(successPush.data).toEqual({ issues: [] }); | |
| 918 | expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); | |
| 891 | 919 | }); |
| 892 | 920 | |
| 893 | 921 | it("routes shell.openInEditor through the injected open service", async () => { |
| ... | ... | @@ -947,6 +975,7 @@ describe("WebSocket Server", () => { |
| 947 | 975 | keybindingsConfigPath: keybindingsPath, |
| 948 | 976 | keybindings: compileKeybindings(persistedConfig), |
| 949 | 977 | issues: [], |
| 978 | providers: defaultProviderStatuses, | |
| 950 | 979 | availableEditors: expect.any(Array), |
| 951 | 980 | }); |
| 952 | 981 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| ... | ... | @@ -994,6 +1023,7 @@ describe("WebSocket Server", () => { |
| 994 | 1023 | keybindingsConfigPath: keybindingsPath, |
| 995 | 1024 | keybindings: compileKeybindings(persistedConfig), |
| 996 | 1025 | issues: [], |
| 1026 | providers: defaultProviderStatuses, | |
| 997 | 1027 | availableEditors: expect.any(Array), |
| 998 | 1028 | }); |
| 999 | 1029 | expectAvailableEditors( |
apps/server/src/wsServer.ts+11-2| ... | ... | @@ -52,6 +52,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati |
| 52 | 52 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 53 | 53 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 54 | 54 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 55 | import { ProviderHealth } from "./provider/Services/ProviderHealth"; | |
| 55 | 56 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| 56 | 57 | import { clamp } from "effect/Number"; |
| 57 | 58 | import { Open, resolveAvailableEditors } from "./open"; |
| ... | ... | @@ -154,7 +155,8 @@ export type ServerCoreRuntimeServices = |
| 154 | 155 | | ProjectionSnapshotQuery |
| 155 | 156 | | CheckpointDiffQuery |
| 156 | 157 | | OrchestrationReactor |
| 157 | | ProviderService; | |
| 158 | | ProviderService | |
| 159 | | ProviderHealth; | |
| 158 | 160 | |
| 159 | 161 | export type ServerRuntimeServices = |
| 160 | 162 | | ServerCoreRuntimeServices |
| ... | ... | @@ -198,6 +200,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 198 | 200 | const gitManager = yield* GitManager; |
| 199 | 201 | const terminalManager = yield* TerminalManager; |
| 200 | 202 | const keybindingsManager = yield* Keybindings; |
| 203 | const providerHealth = yield* ProviderHealth; | |
| 201 | 204 | const git = yield* GitCore; |
| 202 | 205 | const fileSystem = yield* FileSystem.FileSystem; |
| 203 | 206 | const path = yield* Path.Path; |
| ... | ... | @@ -212,6 +215,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 212 | 215 | ), |
| 213 | 216 | ); |
| 214 | 217 | |
| 218 | const providerStatuses = yield* providerHealth.getStatuses; | |
| 219 | ||
| 215 | 220 | const clients = yield* Ref.make(new Set<WebSocket>()); |
| 216 | 221 | const logger = createLogger("ws"); |
| 217 | 222 | |
| ... | ... | @@ -537,7 +542,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 537 | 542 | broadcastPush({ |
| 538 | 543 | type: "push", |
| 539 | 544 | channel: WS_CHANNELS.serverConfigUpdated, |
| 540 | data: event, | |
| 545 | data: { | |
| 546 | issues: event.issues, | |
| 547 | providers: providerStatuses, | |
| 548 | }, | |
| 541 | 549 | }), |
| 542 | 550 | ).pipe(Effect.forkIn(subscriptionsScope)); |
| 543 | 551 | |
| ... | ... | @@ -735,6 +743,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 735 | 743 | keybindingsConfigPath, |
| 736 | 744 | keybindings: keybindingsConfig.keybindings, |
| 737 | 745 | issues: keybindingsConfig.issues, |
| 746 | providers: providerStatuses, | |
| 738 | 747 | availableEditors, |
| 739 | 748 | }; |
| 740 | 749 |
apps/web/src/components/ChatView.tsx+38| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | type ReasoningEffort, |
| 16 | 16 | type ResolvedKeybindingsConfig, |
| 17 | 17 | type ProviderApprovalDecision, |
| 18 | type ServerProviderStatus, | |
| 18 | 19 | type ThreadId, |
| 19 | 20 | type TurnId, |
| 20 | 21 | normalizeModelSlug, |
| ... | ... | @@ -148,6 +149,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 148 | 149 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 149 | 150 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 150 | 151 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; |
| 152 | const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; | |
| 151 | 153 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 152 | 154 | const SCRIPT_TERMINAL_COLS = 120; |
| 153 | 155 | const SCRIPT_TERMINAL_ROWS = 30; |
| ... | ... | @@ -936,6 +938,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 936 | 938 | ); |
| 937 | 939 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; |
| 938 | 940 | const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; |
| 941 | const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; | |
| 942 | const activeProvider = activeThread?.session?.provider ?? "codex"; | |
| 943 | const activeProviderStatus = useMemo( | |
| 944 | () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, | |
| 945 | [activeProvider, providerStatuses], | |
| 946 | ); | |
| 939 | 947 | const activeProjectCwd = activeProject?.cwd ?? null; |
| 940 | 948 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; |
| 941 | 949 | const threadTerminalRuntimeEnv = useMemo(() => { |
| ... | ... | @@ -2497,6 +2505,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2497 | 2505 | </header> |
| 2498 | 2506 | |
| 2499 | 2507 | {/* Error banner */} |
| 2508 | <ProviderHealthBanner status={activeProviderStatus} /> | |
| 2500 | 2509 | <ThreadErrorBanner error={activeThread.error} /> |
| 2501 | 2510 | <PendingApprovalsPanel |
| 2502 | 2511 | pendingApprovals={pendingApprovals} |
| ... | ... | @@ -2997,6 +3006,35 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st |
| 2997 | 3006 | ); |
| 2998 | 3007 | }); |
| 2999 | 3008 | |
| 3009 | const ProviderHealthBanner = memo(function ProviderHealthBanner({ | |
| 3010 | status, | |
| 3011 | }: { | |
| 3012 | status: ServerProviderStatus | null; | |
| 3013 | }) { | |
| 3014 | if (!status || status.status === "ready") { | |
| 3015 | return null; | |
| 3016 | } | |
| 3017 | ||
| 3018 | const defaultMessage = | |
| 3019 | status.status === "error" | |
| 3020 | ? `${status.provider} provider is unavailable.` | |
| 3021 | : `${status.provider} provider has limited availability.`; | |
| 3022 | ||
| 3023 | return ( | |
| 3024 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 3025 | <Alert variant={status.status === "error" ? "error" : "warning"}> | |
| 3026 | <CircleAlertIcon /> | |
| 3027 | <AlertTitle> | |
| 3028 | {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} | |
| 3029 | </AlertTitle> | |
| 3030 | <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}> | |
| 3031 | {status.message ?? defaultMessage} | |
| 3032 | </AlertDescription> | |
| 3033 | </Alert> | |
| 3034 | </div> | |
| 3035 | ); | |
| 3036 | }); | |
| 3037 | ||
| 3000 | 3038 | interface PendingApprovalsPanelProps { |
| 3001 | 3039 | pendingApprovals: PendingApproval[]; |
| 3002 | 3040 | respondingRequestIds: ApprovalRequestId[]; |
apps/web/src/wsNativeApi.test.ts+15| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ProjectId, |
| 7 | 7 | ThreadId, |
| 8 | 8 | WS_CHANNELS, |
| 9 | type ServerProviderStatus, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 11 | 12 | |
| ... | ... | @@ -62,6 +63,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 62 | 63 | return testGlobal.window; |
| 63 | 64 | } |
| 64 | 65 | |
| 66 | const defaultProviders: ReadonlyArray<ServerProviderStatus> = [ | |
| 67 | { | |
| 68 | provider: "codex", | |
| 69 | status: "ready", | |
| 70 | available: true, | |
| 71 | authStatus: "authenticated", | |
| 72 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 73 | }, | |
| 74 | ]; | |
| 75 | ||
| 65 | 76 | beforeEach(() => { |
| 66 | 77 | vi.resetModules(); |
| 67 | 78 | requestMock.mockReset(); |
| ... | ... | @@ -162,6 +173,7 @@ describe("wsNativeApi", () => { |
| 162 | 173 | message: "Entry at index 1 is invalid.", |
| 163 | 174 | }, |
| 164 | 175 | ], |
| 176 | providers: defaultProviders, | |
| 165 | 177 | } as const; |
| 166 | 178 | emitPush(WS_CHANNELS.serverConfigUpdated, payload); |
| 167 | 179 | |
| ... | ... | @@ -185,14 +197,17 @@ describe("wsNativeApi", () => { |
| 185 | 197 | |
| 186 | 198 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 187 | 199 | issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], |
| 200 | providers: defaultProviders, | |
| 188 | 201 | }); |
| 189 | 202 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 190 | 203 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 204 | providers: defaultProviders, | |
| 191 | 205 | }); |
| 192 | 206 | |
| 193 | 207 | expect(listener).toHaveBeenCalledTimes(1); |
| 194 | 208 | expect(listener).toHaveBeenCalledWith({ |
| 195 | 209 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 210 | providers: defaultProviders, | |
| 196 | 211 | }); |
| 197 | 212 | expect(warnSpy).toHaveBeenCalledTimes(1); |
| 198 | 213 | }); |
packages/contracts/src/server.ts+27-1| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| 2 | import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; |
| 4 | 4 | import { EditorId } from "./editor"; |
| 5 | import { ProviderKind } from "./orchestration"; | |
| 5 | 6 | |
| 6 | 7 | export const KeybindingsMalformedConfigIssue = Schema.Struct({ |
| 7 | 8 | kind: Schema.Literal("keybindings.malformed-config"), |
| ... | ... | @@ -25,11 +26,35 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; |
| 25 | 26 | export const ServerConfigIssues = Schema.Array(ServerConfigIssue); |
| 26 | 27 | export type ServerConfigIssues = typeof ServerConfigIssues.Type; |
| 27 | 28 | |
| 29 | export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]); | |
| 30 | export type ServerProviderStatusState = typeof ServerProviderStatusState.Type; | |
| 31 | ||
| 32 | export const ServerProviderAuthStatus = Schema.Literals([ | |
| 33 | "authenticated", | |
| 34 | "unauthenticated", | |
| 35 | "unknown", | |
| 36 | ]); | |
| 37 | export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; | |
| 38 | ||
| 39 | export const ServerProviderStatus = Schema.Struct({ | |
| 40 | provider: ProviderKind, | |
| 41 | status: ServerProviderStatusState, | |
| 42 | available: Schema.Boolean, | |
| 43 | authStatus: ServerProviderAuthStatus, | |
| 44 | checkedAt: IsoDateTime, | |
| 45 | message: Schema.optional(TrimmedNonEmptyString), | |
| 46 | }); | |
| 47 | export type ServerProviderStatus = typeof ServerProviderStatus.Type; | |
| 48 | ||
| 49 | export const ServerProviderStatuses = Schema.Array(ServerProviderStatus); | |
| 50 | export type ServerProviderStatuses = typeof ServerProviderStatuses.Type; | |
| 51 | ||
| 28 | 52 | export const ServerConfig = Schema.Struct({ |
| 29 | 53 | cwd: TrimmedNonEmptyString, |
| 30 | 54 | keybindingsConfigPath: TrimmedNonEmptyString, |
| 31 | 55 | keybindings: ResolvedKeybindingsConfig, |
| 32 | 56 | issues: ServerConfigIssues, |
| 57 | providers: ServerProviderStatuses, | |
| 33 | 58 | availableEditors: Schema.Array(EditorId), |
| 34 | 59 | }); |
| 35 | 60 | export type ServerConfig = typeof ServerConfig.Type; |
| ... | ... | @@ -45,5 +70,6 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T |
| 45 | 70 | |
| 46 | 71 | export const ServerConfigUpdatedPayload = Schema.Struct({ |
| 47 | 72 | issues: ServerConfigIssues, |
| 73 | providers: ServerProviderStatuses, | |
| 48 | 74 | }); |
| 49 | 75 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; |