| 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,6 +21,7 @@ import { fixPath, resolveStateDir } from "./os-jank"; |
| 21 | import { Open } from "./open"; | 21 | import { Open } from "./open"; |
| 22 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; | 22 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; |
| 23 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | 23 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; |
| 24 | import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; | ||
| 24 | import { Server } from "./wsServer"; | 25 | import { Server } from "./wsServer"; |
| 25 | 26 | ||
| 26 | export class StartupError extends Data.TaggedError("StartupError")<{ | 27 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| ... | @@ -197,6 +198,7 @@ const LayerLive = (input: CliInput) => | ... | @@ -197,6 +198,7 @@ const LayerLive = (input: CliInput) => |
| 197 | Layer.empty.pipe( | 198 | Layer.empty.pipe( |
| 198 | Layer.provideMerge(makeServerRuntimeServicesLayer()), | 199 | Layer.provideMerge(makeServerRuntimeServicesLayer()), |
| 199 | Layer.provideMerge(makeServerProviderLayer()), | 200 | Layer.provideMerge(makeServerProviderLayer()), |
| 201 | Layer.provideMerge(ProviderHealthLive), | ||
| 200 | Layer.provideMerge(SqlitePersistence.layerConfig), | 202 | Layer.provideMerge(SqlitePersistence.layerConfig), |
| 201 | Layer.provideMerge(ServerConfigLive(input)), | 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,6 +25,7 @@ import { |
| 25 | WS_METHODS, | 25 | WS_METHODS, |
| 26 | type WebSocketResponse, | 26 | type WebSocketResponse, |
| 27 | type ProviderRuntimeEvent, | 27 | type ProviderRuntimeEvent, |
| 28 | type ServerProviderStatus, | ||
| 28 | type KeybindingsConfig, | 29 | type KeybindingsConfig, |
| 29 | type ResolvedKeybindingsConfig, | 30 | type ResolvedKeybindingsConfig, |
| 30 | type WsPush, | 31 | type WsPush, |
| ... | @@ -43,6 +44,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ | ... | @@ -43,6 +44,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ |
| 43 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | 44 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 44 | import { SqlClient, SqlError } from "effect/unstable/sql"; | 45 | import { SqlClient, SqlError } from "effect/unstable/sql"; |
| 45 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 46 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 47 | import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; | ||
| 46 | import { Open, type OpenShape } from "./open"; | 48 | import { Open, type OpenShape } from "./open"; |
| 47 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | 49 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 48 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; | 50 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| ... | @@ -69,6 +71,20 @@ const defaultOpenService: OpenShape = { | ... | @@ -69,6 +71,20 @@ const defaultOpenService: OpenShape = { |
| 69 | openInEditor: () => Effect.void, | 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 | class MockTerminalManager implements TerminalManagerShape { | 88 | class MockTerminalManager implements TerminalManagerShape { |
| 73 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); | 89 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 74 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); | 90 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); |
| ... | @@ -377,6 +393,7 @@ describe("WebSocket Server", () => { | ... | @@ -377,6 +393,7 @@ describe("WebSocket Server", () => { |
| 377 | stateDir?: string; | 393 | stateDir?: string; |
| 378 | staticDir?: string; | 394 | staticDir?: string; |
| 379 | providerLayer?: Layer.Layer<ProviderService, never>; | 395 | providerLayer?: Layer.Layer<ProviderService, never>; |
| 396 | providerHealth?: ProviderHealthShape; | ||
| 380 | open?: OpenShape; | 397 | open?: OpenShape; |
| 381 | gitManager?: GitManagerShape; | 398 | gitManager?: GitManagerShape; |
| 382 | gitCore?: Pick< | 399 | gitCore?: Pick< |
| ... | @@ -394,6 +411,10 @@ describe("WebSocket Server", () => { | ... | @@ -394,6 +411,10 @@ describe("WebSocket Server", () => { |
| 394 | const scope = await Effect.runPromise(Scope.make("sequential")); | 411 | const scope = await Effect.runPromise(Scope.make("sequential")); |
| 395 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; | 412 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; |
| 396 | const providerLayer = options.providerLayer ?? makeServerProviderLayer(); | 413 | const providerLayer = options.providerLayer ?? makeServerProviderLayer(); |
| 414 | const providerHealthLayer = Layer.succeed( | ||
| 415 | ProviderHealth, | ||
| 416 | options.providerHealth ?? defaultProviderHealthService, | ||
| 417 | ); | ||
| 397 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); | 418 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); |
| 398 | const serverConfigLayer = Layer.succeed(ServerConfig, { | 419 | const serverConfigLayer = Layer.succeed(ServerConfig, { |
| 399 | mode: "web", | 420 | mode: "web", |
| ... | @@ -428,6 +449,7 @@ describe("WebSocket Server", () => { | ... | @@ -428,6 +449,7 @@ describe("WebSocket Server", () => { |
| 428 | ); | 449 | ); |
| 429 | const dependenciesLayer = Layer.empty.pipe( | 450 | const dependenciesLayer = Layer.empty.pipe( |
| 430 | Layer.provideMerge(runtimeLayer), | 451 | Layer.provideMerge(runtimeLayer), |
| 452 | Layer.provideMerge(providerHealthLayer), | ||
| 431 | Layer.provideMerge(openLayer), | 453 | Layer.provideMerge(openLayer), |
| 432 | Layer.provideMerge(serverConfigLayer), | 454 | Layer.provideMerge(serverConfigLayer), |
| 433 | Layer.provideMerge(NodeServices.layer), | 455 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -736,6 +758,7 @@ describe("WebSocket Server", () => { | ... | @@ -736,6 +758,7 @@ describe("WebSocket Server", () => { |
| 736 | keybindingsConfigPath: keybindingsPath, | 758 | keybindingsConfigPath: keybindingsPath, |
| 737 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | 759 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, |
| 738 | issues: [], | 760 | issues: [], |
| 761 | providers: defaultProviderStatuses, | ||
| 739 | availableEditors: expect.any(Array), | 762 | availableEditors: expect.any(Array), |
| 740 | }); | 763 | }); |
| 741 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | 764 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| ... | @@ -761,6 +784,7 @@ describe("WebSocket Server", () => { | ... | @@ -761,6 +784,7 @@ describe("WebSocket Server", () => { |
| 761 | keybindingsConfigPath: keybindingsPath, | 784 | keybindingsConfigPath: keybindingsPath, |
| 762 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | 785 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, |
| 763 | issues: [], | 786 | issues: [], |
| 787 | providers: defaultProviderStatuses, | ||
| 764 | availableEditors: expect.any(Array), | 788 | availableEditors: expect.any(Array), |
| 765 | }); | 789 | }); |
| 766 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | 790 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| ... | @@ -796,6 +820,7 @@ describe("WebSocket Server", () => { | ... | @@ -796,6 +820,7 @@ describe("WebSocket Server", () => { |
| 796 | message: expect.stringContaining("expected JSON array"), | 820 | message: expect.stringContaining("expected JSON array"), |
| 797 | }, | 821 | }, |
| 798 | ], | 822 | ], |
| 823 | providers: defaultProviderStatuses, | ||
| 799 | availableEditors: expect.any(Array), | 824 | availableEditors: expect.any(Array), |
| 800 | }); | 825 | }); |
| 801 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | 826 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| ... | @@ -830,6 +855,7 @@ describe("WebSocket Server", () => { | ... | @@ -830,6 +855,7 @@ describe("WebSocket Server", () => { |
| 830 | keybindingsConfigPath: string; | 855 | keybindingsConfigPath: string; |
| 831 | keybindings: ResolvedKeybindingsConfig; | 856 | keybindings: ResolvedKeybindingsConfig; |
| 832 | issues: Array<{ kind: string; index?: number; message: string }>; | 857 | issues: Array<{ kind: string; index?: number; message: string }>; |
| 858 | providers: ReadonlyArray<ServerProviderStatus>; | ||
| 833 | availableEditors: unknown; | 859 | availableEditors: unknown; |
| 834 | }; | 860 | }; |
| 835 | expect(result.cwd).toBe("/my/workspace"); | 861 | expect(result.cwd).toBe("/my/workspace"); |
| ... | @@ -849,6 +875,7 @@ describe("WebSocket Server", () => { | ... | @@ -849,6 +875,7 @@ describe("WebSocket Server", () => { |
| 849 | expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); | 875 | expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); |
| 850 | expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); | 876 | expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); |
| 851 | expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); | 877 | expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); |
| 878 | expect(result.providers).toEqual(defaultProviderStatuses); | ||
| 852 | expectAvailableEditors(result.availableEditors); | 879 | expectAvailableEditors(result.availableEditors); |
| 853 | }); | 880 | }); |
| 854 | 881 | ||
| ... | @@ -877,6 +904,7 @@ describe("WebSocket Server", () => { | ... | @@ -877,6 +904,7 @@ describe("WebSocket Server", () => { |
| 877 | ); | 904 | ); |
| 878 | expect(malformedPush.data).toEqual({ | 905 | expect(malformedPush.data).toEqual({ |
| 879 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], | 906 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], |
| 907 | providers: defaultProviderStatuses, | ||
| 880 | }); | 908 | }); |
| 881 | 909 | ||
| 882 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); | 910 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); |
| ... | @@ -887,7 +915,7 @@ describe("WebSocket Server", () => { | ... | @@ -887,7 +915,7 @@ describe("WebSocket Server", () => { |
| 887 | Array.isArray((push.data as { issues?: unknown[] }).issues) && | 915 | Array.isArray((push.data as { issues?: unknown[] }).issues) && |
| 888 | (push.data as { issues: unknown[] }).issues.length === 0, | 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 | it("routes shell.openInEditor through the injected open service", async () => { | 921 | it("routes shell.openInEditor through the injected open service", async () => { |
| ... | @@ -947,6 +975,7 @@ describe("WebSocket Server", () => { | ... | @@ -947,6 +975,7 @@ describe("WebSocket Server", () => { |
| 947 | keybindingsConfigPath: keybindingsPath, | 975 | keybindingsConfigPath: keybindingsPath, |
| 948 | keybindings: compileKeybindings(persistedConfig), | 976 | keybindings: compileKeybindings(persistedConfig), |
| 949 | issues: [], | 977 | issues: [], |
| 978 | providers: defaultProviderStatuses, | ||
| 950 | availableEditors: expect.any(Array), | 979 | availableEditors: expect.any(Array), |
| 951 | }); | 980 | }); |
| 952 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); | 981 | expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); |
| ... | @@ -994,6 +1023,7 @@ describe("WebSocket Server", () => { | ... | @@ -994,6 +1023,7 @@ describe("WebSocket Server", () => { |
| 994 | keybindingsConfigPath: keybindingsPath, | 1023 | keybindingsConfigPath: keybindingsPath, |
| 995 | keybindings: compileKeybindings(persistedConfig), | 1024 | keybindings: compileKeybindings(persistedConfig), |
| 996 | issues: [], | 1025 | issues: [], |
| 1026 | providers: defaultProviderStatuses, | ||
| 997 | availableEditors: expect.any(Array), | 1027 | availableEditors: expect.any(Array), |
| 998 | }); | 1028 | }); |
| 999 | expectAvailableEditors( | 1029 | expectAvailableEditors( |
apps/server/src/wsServer.ts+11-2| ... | @@ -52,6 +52,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati | ... | @@ -52,6 +52,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati |
| 52 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | 52 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 53 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | 53 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 54 | import { ProviderService } from "./provider/Services/ProviderService"; | 54 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 55 | import { ProviderHealth } from "./provider/Services/ProviderHealth"; | ||
| 55 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | 56 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| 56 | import { clamp } from "effect/Number"; | 57 | import { clamp } from "effect/Number"; |
| 57 | import { Open, resolveAvailableEditors } from "./open"; | 58 | import { Open, resolveAvailableEditors } from "./open"; |
| ... | @@ -154,7 +155,8 @@ export type ServerCoreRuntimeServices = | ... | @@ -154,7 +155,8 @@ export type ServerCoreRuntimeServices = |
| 154 | | ProjectionSnapshotQuery | 155 | | ProjectionSnapshotQuery |
| 155 | | CheckpointDiffQuery | 156 | | CheckpointDiffQuery |
| 156 | | OrchestrationReactor | 157 | | OrchestrationReactor |
| 157 | | ProviderService; | 158 | | ProviderService |
| 159 | | ProviderHealth; | ||
| 158 | 160 | ||
| 159 | export type ServerRuntimeServices = | 161 | export type ServerRuntimeServices = |
| 160 | | ServerCoreRuntimeServices | 162 | | ServerCoreRuntimeServices |
| ... | @@ -198,6 +200,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -198,6 +200,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 198 | const gitManager = yield* GitManager; | 200 | const gitManager = yield* GitManager; |
| 199 | const terminalManager = yield* TerminalManager; | 201 | const terminalManager = yield* TerminalManager; |
| 200 | const keybindingsManager = yield* Keybindings; | 202 | const keybindingsManager = yield* Keybindings; |
| 203 | const providerHealth = yield* ProviderHealth; | ||
| 201 | const git = yield* GitCore; | 204 | const git = yield* GitCore; |
| 202 | const fileSystem = yield* FileSystem.FileSystem; | 205 | const fileSystem = yield* FileSystem.FileSystem; |
| 203 | const path = yield* Path.Path; | 206 | const path = yield* Path.Path; |
| ... | @@ -212,6 +215,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -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 | const clients = yield* Ref.make(new Set<WebSocket>()); | 220 | const clients = yield* Ref.make(new Set<WebSocket>()); |
| 216 | const logger = createLogger("ws"); | 221 | const logger = createLogger("ws"); |
| 217 | 222 | ||
| ... | @@ -537,7 +542,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -537,7 +542,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 537 | broadcastPush({ | 542 | broadcastPush({ |
| 538 | type: "push", | 543 | type: "push", |
| 539 | channel: WS_CHANNELS.serverConfigUpdated, | 544 | channel: WS_CHANNELS.serverConfigUpdated, |
| 540 | data: event, | 545 | data: { |
| 546 | issues: event.issues, | ||
| 547 | providers: providerStatuses, | ||
| 548 | }, | ||
| 541 | }), | 549 | }), |
| 542 | ).pipe(Effect.forkIn(subscriptionsScope)); | 550 | ).pipe(Effect.forkIn(subscriptionsScope)); |
| 543 | 551 | ||
| ... | @@ -735,6 +743,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -735,6 +743,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 735 | keybindingsConfigPath, | 743 | keybindingsConfigPath, |
| 736 | keybindings: keybindingsConfig.keybindings, | 744 | keybindings: keybindingsConfig.keybindings, |
| 737 | issues: keybindingsConfig.issues, | 745 | issues: keybindingsConfig.issues, |
| 746 | providers: providerStatuses, | ||
| 738 | availableEditors, | 747 | availableEditors, |
| 739 | }; | 748 | }; |
| 740 | 749 |
apps/web/src/components/ChatView.tsx+38| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | type ReasoningEffort, | 15 | type ReasoningEffort, |
| 16 | type ResolvedKeybindingsConfig, | 16 | type ResolvedKeybindingsConfig, |
| 17 | type ProviderApprovalDecision, | 17 | type ProviderApprovalDecision, |
| 18 | type ServerProviderStatus, | ||
| 18 | type ThreadId, | 19 | type ThreadId, |
| 19 | type TurnId, | 20 | type TurnId, |
| 20 | normalizeModelSlug, | 21 | normalizeModelSlug, |
| ... | @@ -148,6 +149,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | ... | @@ -148,6 +149,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 148 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 149 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 149 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | 150 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 150 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; | 151 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; |
| 152 | const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; | ||
| 151 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | 153 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 152 | const SCRIPT_TERMINAL_COLS = 120; | 154 | const SCRIPT_TERMINAL_COLS = 120; |
| 153 | const SCRIPT_TERMINAL_ROWS = 30; | 155 | const SCRIPT_TERMINAL_ROWS = 30; |
| ... | @@ -936,6 +938,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -936,6 +938,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 936 | ); | 938 | ); |
| 937 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; | 939 | const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; |
| 938 | const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; | 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 | const activeProjectCwd = activeProject?.cwd ?? null; | 947 | const activeProjectCwd = activeProject?.cwd ?? null; |
| 940 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; | 948 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; |
| 941 | const threadTerminalRuntimeEnv = useMemo(() => { | 949 | const threadTerminalRuntimeEnv = useMemo(() => { |
| ... | @@ -2497,6 +2505,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2497,6 +2505,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2497 | </header> | 2505 | </header> |
| 2498 | 2506 | ||
| 2499 | {/* Error banner */} | 2507 | {/* Error banner */} |
| 2508 | <ProviderHealthBanner status={activeProviderStatus} /> | ||
| 2500 | <ThreadErrorBanner error={activeThread.error} /> | 2509 | <ThreadErrorBanner error={activeThread.error} /> |
| 2501 | <PendingApprovalsPanel | 2510 | <PendingApprovalsPanel |
| 2502 | pendingApprovals={pendingApprovals} | 2511 | pendingApprovals={pendingApprovals} |
| ... | @@ -2997,6 +3006,35 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st | ... | @@ -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 | interface PendingApprovalsPanelProps { | 3038 | interface PendingApprovalsPanelProps { |
| 3001 | pendingApprovals: PendingApproval[]; | 3039 | pendingApprovals: PendingApproval[]; |
| 3002 | respondingRequestIds: ApprovalRequestId[]; | 3040 | respondingRequestIds: ApprovalRequestId[]; |
apps/web/src/wsNativeApi.test.ts+15| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | ProjectId, | 6 | ProjectId, |
| 7 | ThreadId, | 7 | ThreadId, |
| 8 | WS_CHANNELS, | 8 | WS_CHANNELS, |
| 9 | type ServerProviderStatus, | ||
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 11 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 11 | 12 | ||
| ... | @@ -62,6 +63,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn | ... | @@ -62,6 +63,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 62 | return testGlobal.window; | 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 | beforeEach(() => { | 76 | beforeEach(() => { |
| 66 | vi.resetModules(); | 77 | vi.resetModules(); |
| 67 | requestMock.mockReset(); | 78 | requestMock.mockReset(); |
| ... | @@ -162,6 +173,7 @@ describe("wsNativeApi", () => { | ... | @@ -162,6 +173,7 @@ describe("wsNativeApi", () => { |
| 162 | message: "Entry at index 1 is invalid.", | 173 | message: "Entry at index 1 is invalid.", |
| 163 | }, | 174 | }, |
| 164 | ], | 175 | ], |
| 176 | providers: defaultProviders, | ||
| 165 | } as const; | 177 | } as const; |
| 166 | emitPush(WS_CHANNELS.serverConfigUpdated, payload); | 178 | emitPush(WS_CHANNELS.serverConfigUpdated, payload); |
| 167 | 179 | ||
| ... | @@ -185,14 +197,17 @@ describe("wsNativeApi", () => { | ... | @@ -185,14 +197,17 @@ describe("wsNativeApi", () => { |
| 185 | 197 | ||
| 186 | emitPush(WS_CHANNELS.serverConfigUpdated, { | 198 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 187 | issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], | 199 | issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], |
| 200 | providers: defaultProviders, | ||
| 188 | }); | 201 | }); |
| 189 | emitPush(WS_CHANNELS.serverConfigUpdated, { | 202 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 190 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | 203 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 204 | providers: defaultProviders, | ||
| 191 | }); | 205 | }); |
| 192 | 206 | ||
| 193 | expect(listener).toHaveBeenCalledTimes(1); | 207 | expect(listener).toHaveBeenCalledTimes(1); |
| 194 | expect(listener).toHaveBeenCalledWith({ | 208 | expect(listener).toHaveBeenCalledWith({ |
| 195 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | 209 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 210 | providers: defaultProviders, | ||
| 196 | }); | 211 | }); |
| 197 | expect(warnSpy).toHaveBeenCalledTimes(1); | 212 | expect(warnSpy).toHaveBeenCalledTimes(1); |
| 198 | }); | 213 | }); |
packages/contracts/src/server.ts+27-1| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; | 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; |
| 4 | import { EditorId } from "./editor"; | 4 | import { EditorId } from "./editor"; |
| 5 | import { ProviderKind } from "./orchestration"; | ||
| 5 | 6 | ||
| 6 | export const KeybindingsMalformedConfigIssue = Schema.Struct({ | 7 | export const KeybindingsMalformedConfigIssue = Schema.Struct({ |
| 7 | kind: Schema.Literal("keybindings.malformed-config"), | 8 | kind: Schema.Literal("keybindings.malformed-config"), |
| ... | @@ -25,11 +26,35 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; | ... | @@ -25,11 +26,35 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; |
| 25 | export const ServerConfigIssues = Schema.Array(ServerConfigIssue); | 26 | export const ServerConfigIssues = Schema.Array(ServerConfigIssue); |
| 26 | export type ServerConfigIssues = typeof ServerConfigIssues.Type; | 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 | export const ServerConfig = Schema.Struct({ | 52 | export const ServerConfig = Schema.Struct({ |
| 29 | cwd: TrimmedNonEmptyString, | 53 | cwd: TrimmedNonEmptyString, |
| 30 | keybindingsConfigPath: TrimmedNonEmptyString, | 54 | keybindingsConfigPath: TrimmedNonEmptyString, |
| 31 | keybindings: ResolvedKeybindingsConfig, | 55 | keybindings: ResolvedKeybindingsConfig, |
| 32 | issues: ServerConfigIssues, | 56 | issues: ServerConfigIssues, |
| 57 | providers: ServerProviderStatuses, | ||
| 33 | availableEditors: Schema.Array(EditorId), | 58 | availableEditors: Schema.Array(EditorId), |
| 34 | }); | 59 | }); |
| 35 | export type ServerConfig = typeof ServerConfig.Type; | 60 | export type ServerConfig = typeof ServerConfig.Type; |
| ... | @@ -45,5 +70,6 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T | ... | @@ -45,5 +70,6 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T |
| 45 | 70 | ||
| 46 | export const ServerConfigUpdatedPayload = Schema.Struct({ | 71 | export const ServerConfigUpdatedPayload = Schema.Struct({ |
| 47 | issues: ServerConfigIssues, | 72 | issues: ServerConfigIssues, |
| 73 | providers: ServerProviderStatuses, | ||
| 48 | }); | 74 | }); |
| 49 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; | 75 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; |