diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index a715840eb5246d09f9f522ef2acc4f919882fce1..4f36e7ea1291bb7ca890a0ba507eb6b35a023fe3 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -21,6 +21,7 @@ import { fixPath, resolveStateDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { Server } from "./wsServer"; export class StartupError extends Data.TaggedError("StartupError")<{ @@ -197,6 +198,7 @@ const LayerLive = (input: CliInput) => Layer.empty.pipe( Layer.provideMerge(makeServerRuntimeServicesLayer()), Layer.provideMerge(makeServerProviderLayer()), + Layer.provideMerge(ProviderHealthLive), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerConfigLive(input)), ); diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2530ae1293dcc2f61c7083e835472660c59df603 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import type { ProcessRunResult } from "../../processRunner"; +import { checkProviderStatusesOnStartup } from "./ProviderHealth"; + +function result(input: Partial): ProcessRunResult { + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + ...input, + }; +} + +describe("checkProviderStatusesOnStartup", () => { + it("returns ready when codex is installed and authenticated", async () => { + const status = await checkProviderStatusesOnStartup(async (_command, args) => { + if (args.join(" ") === "--version") { + return result({ stdout: "codex 1.0.0\n" }); + } + if (args.join(" ") === "auth status --json") { + return result({ stdout: '{"authenticated":true}\n' }); + } + throw new Error(`Unexpected command: ${args.join(" ")}`); + }); + + expect(status).toEqual([ + expect.objectContaining({ + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + }), + ]); + }); + + it("returns unavailable when codex is missing", async () => { + const status = await checkProviderStatusesOnStartup(async () => { + throw new Error("Command not found: codex"); + }); + + expect(status).toEqual([ + expect.objectContaining({ + provider: "codex", + status: "error", + available: false, + authStatus: "unknown", + message: "Codex CLI (`codex`) is not installed or not on PATH.", + }), + ]); + }); + + it("returns unauthenticated when auth probe reports login required", async () => { + const status = await checkProviderStatusesOnStartup(async (_command, args) => { + if (args.join(" ") === "--version") { + return result({ stdout: "codex 1.0.0\n" }); + } + if (args.join(" ") === "auth status --json") { + return result({ + code: 1, + stderr: "Not logged in. Run codex login.", + }); + } + throw new Error(`Unexpected command: ${args.join(" ")}`); + }); + + expect(status).toEqual([ + expect.objectContaining({ + provider: "codex", + status: "error", + available: true, + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }), + ]); + }); + + it("returns warning when auth status command is unsupported", async () => { + const status = await checkProviderStatusesOnStartup(async (_command, args) => { + if (args.join(" ") === "--version") { + return result({ stdout: "codex 1.0.0\n" }); + } + if (args.join(" ") === "auth status --json") { + return result({ + code: 2, + stderr: "error: unknown command 'auth'", + }); + } + throw new Error(`Unexpected command: ${args.join(" ")}`); + }); + + expect(status).toEqual([ + expect.objectContaining({ + provider: "codex", + status: "warning", + available: true, + authStatus: "unknown", + message: + "Codex CLI authentication status command is unavailable in this Codex version.", + }), + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts new file mode 100644 index 0000000000000000000000000000000000000000..020554561da2c1eab111827e7d5cb1c900783167 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -0,0 +1,246 @@ +/** + * ProviderHealthLive - Startup-time provider health checks. + * + * Performs one-time provider readiness probes when the server starts and + * keeps the resulting snapshot in memory for `server.getConfig`. + * + * @module ProviderHealthLive + */ +import type { + ServerProviderAuthStatus, + ServerProviderStatus, + ServerProviderStatusState, +} from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "../../processRunner"; +import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; + +type CommandRunner = ( + command: string, + args: readonly string[], + options?: ProcessRunOptions, +) => Promise; + +const DEFAULT_TIMEOUT_MS = 4_000; +const CODEX_PROVIDER = "codex" as const; + +function nonEmptyMessage(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isCommandMissingError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const lower = error.message.toLowerCase(); + return ( + lower.includes("command not found: codex") || + lower.includes("spawn codex enoent") || + lower.includes("enoent") + ); +} + +function detailFromResult(result: ProcessRunResult): string | undefined { + if (result.timedOut) return "Timed out while running command."; + const stderr = nonEmptyMessage(result.stderr); + if (stderr) return stderr; + const stdout = nonEmptyMessage(result.stdout); + if (stdout) return stdout; + if (result.code !== null && result.code !== 0) { + return `Command exited with code ${result.code}.`; + } + return undefined; +} + +function extractAuthBoolean(value: unknown): boolean | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const directCandidates = [ + "authenticated", + "isAuthenticated", + "loggedIn", + "isLoggedIn", + ] as const; + for (const key of directCandidates) { + const candidate = record[key]; + if (typeof candidate === "boolean") { + return candidate; + } + } + + const nestedCandidates = ["auth", "status", "session", "account"] as const; + for (const key of nestedCandidates) { + const nested = extractAuthBoolean(record[key]); + if (nested !== undefined) { + return nested; + } + } + + return undefined; +} + +function parseAuthStatusFromOutput( + result: ProcessRunResult, +): { + readonly status: ServerProviderStatusState; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + const unknownCommand = + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument"); + if (unknownCommand) { + return { + status: "warning", + authStatus: "unknown", + message: + "Codex CLI authentication status command is unavailable in this Codex version.", + }; + } + + const unauthenticatedSignal = + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `codex login`") || + lowerOutput.includes("run codex login"); + if (unauthenticatedSignal) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + + const parsedAuth = + (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return undefined; + } + try { + return extractAuthBoolean(JSON.parse(trimmed)); + } catch { + return undefined; + } + })() ?? undefined; + + if (parsedAuth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + + if (result.code === 0) { + // Successful command with no explicit auth marker is still a pass. + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + ...(detail + ? { message: `Could not verify Codex authentication status. ${detail}` } + : { message: "Could not verify Codex authentication status." }), + }; +} + +async function checkCodexProviderStatus(run: CommandRunner): Promise { + const checkedAt = new Date().toISOString(); + + try { + const version = await run("codex", ["--version"], { + timeoutMs: DEFAULT_TIMEOUT_MS, + allowNonZeroExit: true, + outputMode: "truncate", + }); + + if (version.code !== 0 || version.timedOut) { + const detail = detailFromResult(version); + return { + provider: CODEX_PROVIDER, + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + message: + detail !== undefined + ? `Codex CLI is installed but failed to run. ${detail}` + : "Codex CLI is installed but failed to run.", + }; + } + } catch (error) { + return { + provider: CODEX_PROVIDER, + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + message: isCommandMissingError(error) + ? "Codex CLI (`codex`) is not installed or not on PATH." + : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + try { + const auth = await run("codex", ["auth", "status", "--json"], { + timeoutMs: DEFAULT_TIMEOUT_MS, + allowNonZeroExit: true, + outputMode: "truncate", + }); + const parsed = parseAuthStatusFromOutput(auth); + return { + provider: CODEX_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsed.message ? { message: parsed.message } : {}), + }; + } catch (error) { + return { + provider: CODEX_PROVIDER, + status: "warning", + available: true, + authStatus: "unknown", + checkedAt, + message: + error instanceof Error + ? `Could not verify Codex authentication status: ${error.message}.` + : "Could not verify Codex authentication status.", + }; + } +} + +export async function checkProviderStatusesOnStartup( + run: CommandRunner = runProcess, +): Promise> { + return [await checkCodexProviderStatus(run)]; +} + +const makeProviderHealth = (run: CommandRunner = runProcess) => + Effect.gen(function* () { + const statuses = yield* Effect.promise(() => checkProviderStatusesOnStartup(run)); + + return { + getStatuses: Effect.succeed(statuses), + } satisfies ProviderHealthShape; + }); + +export const ProviderHealthLive = Layer.effect(ProviderHealth, makeProviderHealth()); + +export function makeProviderHealthLive(run?: CommandRunner) { + return Layer.effect(ProviderHealth, makeProviderHealth(run)); +} diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts new file mode 100644 index 0000000000000000000000000000000000000000..318d7e18d0ad57109a021af7329e21118c926103 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderHealth.ts @@ -0,0 +1,22 @@ +/** + * ProviderHealth - Provider readiness snapshot service. + * + * Owns startup-time provider health checks (install/auth reachability) and + * exposes the cached results to transport layers. + * + * @module ProviderHealth + */ +import type { ServerProviderStatus } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ProviderHealthShape { + /** + * Read provider health statuses computed at server startup. + */ + readonly getStatuses: Effect.Effect>; +} + +export class ProviderHealth extends ServiceMap.Service()( + "t3/provider/Services/ProviderHealth", +) {} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f288332f862ac512e526c2b7f05b38a2dbeb1002..e0dc4a079d4d7b168f4059c45661c5453637a594 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -25,6 +25,7 @@ import { WS_METHODS, type WebSocketResponse, type ProviderRuntimeEvent, + type ServerProviderStatus, type KeybindingsConfig, type ResolvedKeybindingsConfig, type WsPush, @@ -43,6 +44,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient, SqlError } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; +import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; import { Open, type OpenShape } from "./open"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import type { GitCoreShape } from "./git/Services/GitCore.ts"; @@ -69,6 +71,20 @@ const defaultOpenService: OpenShape = { openInEditor: () => Effect.void, }; +const defaultProviderStatuses: ReadonlyArray = [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + }, +]; + +const defaultProviderHealthService: ProviderHealthShape = { + getStatuses: Effect.succeed(defaultProviderStatuses), +}; + class MockTerminalManager implements TerminalManagerShape { private readonly sessions = new Map(); private readonly listeners = new Set<(event: TerminalEvent) => void>(); @@ -377,6 +393,7 @@ describe("WebSocket Server", () => { stateDir?: string; staticDir?: string; providerLayer?: Layer.Layer; + providerHealth?: ProviderHealthShape; open?: OpenShape; gitManager?: GitManagerShape; gitCore?: Pick< @@ -394,6 +411,10 @@ describe("WebSocket Server", () => { const scope = await Effect.runPromise(Scope.make("sequential")); const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; const providerLayer = options.providerLayer ?? makeServerProviderLayer(); + const providerHealthLayer = Layer.succeed( + ProviderHealth, + options.providerHealth ?? defaultProviderHealthService, + ); const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); const serverConfigLayer = Layer.succeed(ServerConfig, { mode: "web", @@ -428,6 +449,7 @@ describe("WebSocket Server", () => { ); const dependenciesLayer = Layer.empty.pipe( Layer.provideMerge(runtimeLayer), + Layer.provideMerge(providerHealthLayer), Layer.provideMerge(openLayer), Layer.provideMerge(serverConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -736,6 +758,7 @@ describe("WebSocket Server", () => { keybindingsConfigPath: keybindingsPath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, issues: [], + providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -761,6 +784,7 @@ describe("WebSocket Server", () => { keybindingsConfigPath: keybindingsPath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, issues: [], + providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -796,6 +820,7 @@ describe("WebSocket Server", () => { message: expect.stringContaining("expected JSON array"), }, ], + providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -830,6 +855,7 @@ describe("WebSocket Server", () => { keybindingsConfigPath: string; keybindings: ResolvedKeybindingsConfig; issues: Array<{ kind: string; index?: number; message: string }>; + providers: ReadonlyArray; availableEditors: unknown; }; expect(result.cwd).toBe("/my/workspace"); @@ -849,6 +875,7 @@ describe("WebSocket Server", () => { expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); + expect(result.providers).toEqual(defaultProviderStatuses); expectAvailableEditors(result.availableEditors); }); @@ -877,6 +904,7 @@ describe("WebSocket Server", () => { ); expect(malformedPush.data).toEqual({ issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], + providers: defaultProviderStatuses, }); fs.writeFileSync(keybindingsPath, "[]", "utf8"); @@ -887,7 +915,7 @@ describe("WebSocket Server", () => { Array.isArray((push.data as { issues?: unknown[] }).issues) && (push.data as { issues: unknown[] }).issues.length === 0, ); - expect(successPush.data).toEqual({ issues: [] }); + expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); }); it("routes shell.openInEditor through the injected open service", async () => { @@ -947,6 +975,7 @@ describe("WebSocket Server", () => { keybindingsConfigPath: keybindingsPath, keybindings: compileKeybindings(persistedConfig), issues: [], + providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -994,6 +1023,7 @@ describe("WebSocket Server", () => { keybindingsConfigPath: keybindingsPath, keybindings: compileKeybindings(persistedConfig), issues: [], + providers: defaultProviderStatuses, availableEditors: expect.any(Array), }); expectAvailableEditors( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 9e5c4e788670ac72ff28926a54387e832f078785..50af3b8b10ef5fd0b168047e61bd45269e7e4581 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -52,6 +52,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ProviderService } from "./provider/Services/ProviderService"; +import { ProviderHealth } from "./provider/Services/ProviderHealth"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; import { Open, resolveAvailableEditors } from "./open"; @@ -154,7 +155,8 @@ export type ServerCoreRuntimeServices = | ProjectionSnapshotQuery | CheckpointDiffQuery | OrchestrationReactor - | ProviderService; + | ProviderService + | ProviderHealth; export type ServerRuntimeServices = | ServerCoreRuntimeServices @@ -198,6 +200,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; + const providerHealth = yield* ProviderHealth; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -212,6 +215,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); + const providerStatuses = yield* providerHealth.getStatuses; + const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); @@ -537,7 +542,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< broadcastPush({ type: "push", channel: WS_CHANNELS.serverConfigUpdated, - data: event, + data: { + issues: event.issues, + providers: providerStatuses, + }, }), ).pipe(Effect.forkIn(subscriptionsScope)); @@ -735,6 +743,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, + providers: providerStatuses, availableEditors, }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4f45454af53da6dc1eed29a1d6ce715d9f62f9de..9c24d883b50829aa594411800f4dd959fe33d6f2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -15,6 +15,7 @@ import { type ReasoningEffort, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, + type ServerProviderStatus, type ThreadId, type TurnId, normalizeModelSlug, @@ -148,6 +149,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; +const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -936,6 +938,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; + const activeProvider = activeThread?.session?.provider ?? "codex"; + const activeProviderStatus = useMemo( + () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, + [activeProvider, providerStatuses], + ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const threadTerminalRuntimeEnv = useMemo(() => { @@ -2497,6 +2505,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} + + + + + {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} + + + {status.message ?? defaultMessage} + + + + ); +}); + interface PendingApprovalsPanelProps { pendingApprovals: PendingApproval[]; respondingRequestIds: ApprovalRequestId[]; diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index e6bbb98959532b6120fd5d1b8184275c19f45d69..d2329a041d75a2ec0cc3e4b4fcf4e9b6d978c646 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -6,6 +6,7 @@ import { ProjectId, ThreadId, WS_CHANNELS, + type ServerProviderStatus, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -62,6 +63,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn return testGlobal.window; } +const defaultProviders: ReadonlyArray = [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + }, +]; + beforeEach(() => { vi.resetModules(); requestMock.mockReset(); @@ -162,6 +173,7 @@ describe("wsNativeApi", () => { message: "Entry at index 1 is invalid.", }, ], + providers: defaultProviders, } as const; emitPush(WS_CHANNELS.serverConfigUpdated, payload); @@ -185,14 +197,17 @@ describe("wsNativeApi", () => { emitPush(WS_CHANNELS.serverConfigUpdated, { issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], + providers: defaultProviders, }); emitPush(WS_CHANNELS.serverConfigUpdated, { issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + providers: defaultProviders, }); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + providers: defaultProviders, }); expect(warnSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 950e81289283dd5ceade53453e6b10d51e82e333..5607be35c4ffd7660fffb1ecccef2ac84fc679d8 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,7 +1,8 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; +import { ProviderKind } from "./orchestration"; export const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -25,11 +26,35 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; export const ServerConfigIssues = Schema.Array(ServerConfigIssue); export type ServerConfigIssues = typeof ServerConfigIssues.Type; +export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]); +export type ServerProviderStatusState = typeof ServerProviderStatusState.Type; + +export const ServerProviderAuthStatus = Schema.Literals([ + "authenticated", + "unauthenticated", + "unknown", +]); +export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; + +export const ServerProviderStatus = Schema.Struct({ + provider: ProviderKind, + status: ServerProviderStatusState, + available: Schema.Boolean, + authStatus: ServerProviderAuthStatus, + checkedAt: IsoDateTime, + message: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerProviderStatus = typeof ServerProviderStatus.Type; + +export const ServerProviderStatuses = Schema.Array(ServerProviderStatus); +export type ServerProviderStatuses = typeof ServerProviderStatuses.Type; + export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, + providers: ServerProviderStatuses, availableEditors: Schema.Array(EditorId), }); export type ServerConfig = typeof ServerConfig.Type; @@ -45,5 +70,6 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, + providers: ServerProviderStatuses, }); export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;