authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 19:34:50-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 21:03:16-08:00
log99e44f2c23462f9ccf80a9d199bb424ae130bf33
treed638de85335c576c32eaa76b2109d1ce8e7b1ad7
parent7a0f2d9b3058d7c7da4d49fd31e308c98f2e5e2a

Add Codex provider health status to server config and chat banner

- 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

9 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";
21import { Open } from "./open";21import { Open } from "./open";
22import * as SqlitePersistence from "./persistence/Layers/Sqlite";22import * as SqlitePersistence from "./persistence/Layers/Sqlite";
23import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";23import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
24import { ProviderHealthLive } from "./provider/Layers/ProviderHealth";
24import { Server } from "./wsServer";25import { Server } from "./wsServer";
2526
26export class StartupError extends Data.TaggedError("StartupError")<{27export 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 @@
1import { describe, expect, it } from "vitest";
2
3import type { ProcessRunResult } from "../../processRunner";
4import { checkProviderStatusesOnStartup } from "./ProviderHealth";
5
6function 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
19describe("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 */
9import type {
10 ServerProviderAuthStatus,
11 ServerProviderStatus,
12 ServerProviderStatusState,
13} from "@t3tools/contracts";
14import { Effect, Layer } from "effect";
15
16import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "../../processRunner";
17import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
18
19type CommandRunner = (
20 command: string,
21 args: readonly string[],
22 options?: ProcessRunOptions,
23) => Promise<ProcessRunResult>;
24
25const DEFAULT_TIMEOUT_MS = 4_000;
26const CODEX_PROVIDER = "codex" as const;
27
28function 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
34function 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
44function 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
56function 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
86function 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
160async 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
227export async function checkProviderStatusesOnStartup(
228 run: CommandRunner = runProcess,
229): Promise<ReadonlyArray<ServerProviderStatus>> {
230 return [await checkCodexProviderStatus(run)];
231}
232
233const 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
242export const ProviderHealthLive = Layer.effect(ProviderHealth, makeProviderHealth());
243
244export 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 */
9import type { ServerProviderStatus } from "@t3tools/contracts";
10import { ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13export interface ProviderHealthShape {
14 /**
15 * Read provider health statuses computed at server startup.
16 */
17 readonly getStatuses: Effect.Effect<ReadonlyArray<ServerProviderStatus>>;
18}
19
20export 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/
43import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";44import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
44import { SqlClient, SqlError } from "effect/unstable/sql";45import { SqlClient, SqlError } from "effect/unstable/sql";
45import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";46import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
47import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth";
46import { Open, type OpenShape } from "./open";48import { Open, type OpenShape } from "./open";
47import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";49import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
48import type { GitCoreShape } from "./git/Services/GitCore.ts";50import 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};
7173
74const 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
84const defaultProviderHealthService: ProviderHealthShape = {
85 getStatuses: Effect.succeed(defaultProviderStatuses),
86};
87
72class MockTerminalManager implements TerminalManagerShape {88class 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 });
854881
...@@ -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 });
881909
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 });
892920
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
52import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";52import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
53import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";53import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
54import { ProviderService } from "./provider/Services/ProviderService";54import { ProviderService } from "./provider/Services/ProviderService";
55import { ProviderHealth } from "./provider/Services/ProviderHealth";
55import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";56import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
56import { clamp } from "effect/Number";57import { clamp } from "effect/Number";
57import { Open, resolveAvailableEditors } from "./open";58import { Open, resolveAvailableEditors } from "./open";
...@@ -154,7 +155,8 @@ export type ServerCoreRuntimeServices =...@@ -154,7 +155,8 @@ export type ServerCoreRuntimeServices =
154 | ProjectionSnapshotQuery155 | ProjectionSnapshotQuery
155 | CheckpointDiffQuery156 | CheckpointDiffQuery
156 | OrchestrationReactor157 | OrchestrationReactor
157 | ProviderService;158 | ProviderService
159 | ProviderHealth;
158160
159export type ServerRuntimeServices =161export type ServerRuntimeServices =
160 | ServerCoreRuntimeServices162 | 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 );
214217
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");
217222
...@@ -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));
543551
...@@ -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 };
740749
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[] = [];
148const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];149const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
149const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];150const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
150const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];151const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
152const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];
151const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;153const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
152const SCRIPT_TERMINAL_COLS = 120;154const SCRIPT_TERMINAL_COLS = 120;
153const SCRIPT_TERMINAL_ROWS = 30;155const 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>
24982506
2499 {/* Error banner */}2507 {/* Error banner */}
2508 <ProviderHealthBanner status={activeProviderStatus} />
2500 <ThreadErrorBanner error={activeThread.error} />2509 <ThreadErrorBanner error={activeThread.error} />
2501 <PendingApprovalsPanel2510 <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});
29993008
3009const 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
3000interface PendingApprovalsPanelProps {3038interface 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";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";11import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1112
...@@ -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}
6465
66const 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
65beforeEach(() => {76beforeEach(() => {
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);
167179
...@@ -185,14 +197,17 @@ describe("wsNativeApi", () => {...@@ -185,14 +197,17 @@ describe("wsNativeApi", () => {
185197
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 });
192206
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 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";2import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";
3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
4import { EditorId } from "./editor";4import { EditorId } from "./editor";
5import { ProviderKind } from "./orchestration";
56
6export const KeybindingsMalformedConfigIssue = Schema.Struct({7export 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;
25export const ServerConfigIssues = Schema.Array(ServerConfigIssue);26export const ServerConfigIssues = Schema.Array(ServerConfigIssue);
26export type ServerConfigIssues = typeof ServerConfigIssues.Type;27export type ServerConfigIssues = typeof ServerConfigIssues.Type;
2728
29export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]);
30export type ServerProviderStatusState = typeof ServerProviderStatusState.Type;
31
32export const ServerProviderAuthStatus = Schema.Literals([
33 "authenticated",
34 "unauthenticated",
35 "unknown",
36]);
37export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type;
38
39export 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});
47export type ServerProviderStatus = typeof ServerProviderStatus.Type;
48
49export const ServerProviderStatuses = Schema.Array(ServerProviderStatus);
50export type ServerProviderStatuses = typeof ServerProviderStatuses.Type;
51
28export const ServerConfig = Schema.Struct({52export 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});
35export type ServerConfig = typeof ServerConfig.Type;60export 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
4570
46export const ServerConfigUpdatedPayload = Schema.Struct({71export const ServerConfigUpdatedPayload = Schema.Struct({
47 issues: ServerConfigIssues,72 issues: ServerConfigIssues,
73 providers: ServerProviderStatuses,
48});74});
49export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;75export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;