- 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 tests
Unified · Split
| @@ -21,6 +21,7 @@ import { fixPath, resolveStateDir } from "./os-jank"; · 20 unmodified lines | ||
| 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) => · 170 unmodified lines | ||
| 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 | ); |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +) {} | |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 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/ · 12 unmodified lines | ||
| 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 = { · 20 unmodified lines | ||
| 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", () => { · 302 unmodified lines | ||
| 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", () => { · 11 unmodified lines | ||
| 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", () => { · 28 unmodified lines | ||
| 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", () => { · 302 unmodified lines | ||
| 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", () => { · 19 unmodified lines | ||
| 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", () => { · 29 unmodified lines | ||
| 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", () => { · 28 unmodified lines | ||
| 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", () => { · 13 unmodified lines | ||
| 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", () => { · 22 unmodified lines | ||
| 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", () => { · 4 unmodified lines | ||
| 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", () => { · 53 unmodified lines | ||
| 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", () => { · 41 unmodified lines | ||
| 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( |
| @@ -52,6 +52,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati · 51 unmodified lines | ||
| 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 = · 96 unmodified lines | ||
| 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< · 37 unmodified lines | ||
| 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< · 8 unmodified lines | ||
| 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< · 319 unmodified lines | ||
| 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< · 191 unmodified lines | ||
| 735 | 743 | keybindingsConfigPath, |
| 736 | 744 | keybindings: keybindingsConfig.keybindings, |
| 737 | 745 | issues: keybindingsConfig.issues, |
| 746 | + providers: providerStatuses, | |
| 738 | 747 | availableEditors, |
| 739 | 748 | }; |
| 740 | 749 | |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 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[] = []; · 127 unmodified lines | ||
| 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) { · 782 unmodified lines | ||
| 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) { · 1555 unmodified lines | ||
| 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 · 494 unmodified lines | ||
| 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[]; |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 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 · 50 unmodified lines | ||
| 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", () => { · 94 unmodified lines | ||
| 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", () => { · 17 unmodified lines | ||
| 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 | }); |
| @@ -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; · 17 unmodified lines | ||
| 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 · 9 unmodified lines | ||
| 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; |