| ... | ... | @@ -4,6 +4,8 @@ |
| 4 | 4 | * Performs one-time provider readiness probes when the server starts and |
| 5 | 5 | * keeps the resulting snapshot in memory for `server.getConfig`. |
| 6 | 6 | * |
| 7 | * Uses effect's ChildProcessSpawner to run CLI probes natively. |
| 8 | * |
| 7 | 9 | * @module ProviderHealthLive |
| 8 | 10 | */ |
| 9 | 11 | import type { |
| ... | ... | @@ -11,43 +13,48 @@ import type { |
| 11 | 13 | ServerProviderStatus, |
| 12 | 14 | ServerProviderStatusState, |
| 13 | 15 | } from "@t3tools/contracts"; |
| 14 | | import { Effect, Layer } from "effect"; |
| 16 | import { Effect, Layer, Option, Result, Stream } from "effect"; |
| 17 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 15 | 18 | |
| 16 | | import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "../../processRunner"; |
| 17 | 19 | import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; |
| 18 | 20 | |
| 19 | | type CommandRunner = ( |
| 20 | | command: string, |
| 21 | | args: readonly string[], |
| 22 | | options?: ProcessRunOptions, |
| 23 | | ) => Promise<ProcessRunResult>; |
| 24 | | |
| 25 | 21 | const DEFAULT_TIMEOUT_MS = 4_000; |
| 26 | 22 | const CODEX_PROVIDER = "codex" as const; |
| 27 | 23 | |
| 28 | | function nonEmptyMessage(value: string | undefined): string | undefined { |
| 24 | // ── Pure helpers ──────────────────────────────────────────────────── |
| 25 | |
| 26 | export interface CommandResult { |
| 27 | readonly stdout: string; |
| 28 | readonly stderr: string; |
| 29 | readonly code: number; |
| 30 | } |
| 31 | |
| 32 | function nonEmptyTrimmed(value: string | undefined): string | undefined { |
| 29 | 33 | if (!value) return undefined; |
| 30 | 34 | const trimmed = value.trim(); |
| 31 | 35 | return trimmed.length > 0 ? trimmed : undefined; |
| 32 | 36 | } |
| 33 | 37 | |
| 34 | | function isCommandMissingError(error: unknown): boolean { |
| 38 | function isCommandMissingCause(error: unknown): boolean { |
| 35 | 39 | if (!(error instanceof Error)) return false; |
| 36 | 40 | const lower = error.message.toLowerCase(); |
| 37 | 41 | return ( |
| 38 | 42 | lower.includes("command not found: codex") || |
| 39 | 43 | lower.includes("spawn codex enoent") || |
| 40 | | lower.includes("enoent") |
| 44 | lower.includes("enoent") || |
| 45 | lower.includes("notfound") |
| 41 | 46 | ); |
| 42 | 47 | } |
| 43 | 48 | |
| 44 | | function detailFromResult(result: ProcessRunResult): string | undefined { |
| 49 | function detailFromResult( |
| 50 | result: CommandResult & { readonly timedOut?: boolean }, |
| 51 | ): string | undefined { |
| 45 | 52 | if (result.timedOut) return "Timed out while running command."; |
| 46 | | const stderr = nonEmptyMessage(result.stderr); |
| 53 | const stderr = nonEmptyTrimmed(result.stderr); |
| 47 | 54 | if (stderr) return stderr; |
| 48 | | const stdout = nonEmptyMessage(result.stdout); |
| 55 | const stdout = nonEmptyTrimmed(result.stdout); |
| 49 | 56 | if (stdout) return stdout; |
| 50 | | if (result.code !== null && result.code !== 0) { |
| 57 | if (result.code !== 0) { |
| 51 | 58 | return `Command exited with code ${result.code}.`; |
| 52 | 59 | } |
| 53 | 60 | return undefined; |
| ... | ... | @@ -57,70 +64,50 @@ function extractAuthBoolean(value: unknown): boolean | undefined { |
| 57 | 64 | if (Array.isArray(value)) { |
| 58 | 65 | for (const entry of value) { |
| 59 | 66 | const nested = extractAuthBoolean(entry); |
| 60 | | if (nested !== undefined) { |
| 61 | | return nested; |
| 62 | | } |
| 67 | if (nested !== undefined) return nested; |
| 63 | 68 | } |
| 64 | 69 | return undefined; |
| 65 | 70 | } |
| 66 | 71 | |
| 67 | | if (!value || typeof value !== "object") { |
| 68 | | return undefined; |
| 69 | | } |
| 72 | if (!value || typeof value !== "object") return undefined; |
| 70 | 73 | |
| 71 | 74 | const record = value as Record<string, unknown>; |
| 72 | | const directCandidates = [ |
| 73 | | "authenticated", |
| 74 | | "isAuthenticated", |
| 75 | | "loggedIn", |
| 76 | | "isLoggedIn", |
| 77 | | ] as const; |
| 78 | | for (const key of directCandidates) { |
| 79 | | const candidate = record[key]; |
| 80 | | if (typeof candidate === "boolean") { |
| 81 | | return candidate; |
| 82 | | } |
| 75 | for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { |
| 76 | if (typeof record[key] === "boolean") return record[key]; |
| 83 | 77 | } |
| 84 | | |
| 85 | | const nestedCandidates = ["auth", "status", "session", "account"] as const; |
| 86 | | for (const key of nestedCandidates) { |
| 78 | for (const key of ["auth", "status", "session", "account"] as const) { |
| 87 | 79 | const nested = extractAuthBoolean(record[key]); |
| 88 | | if (nested !== undefined) { |
| 89 | | return nested; |
| 90 | | } |
| 80 | if (nested !== undefined) return nested; |
| 91 | 81 | } |
| 92 | | |
| 93 | 82 | return undefined; |
| 94 | 83 | } |
| 95 | 84 | |
| 96 | | function parseAuthStatusFromOutput( |
| 97 | | result: ProcessRunResult, |
| 98 | | ): { |
| 85 | export function parseAuthStatusFromOutput(result: CommandResult): { |
| 99 | 86 | readonly status: ServerProviderStatusState; |
| 100 | 87 | readonly authStatus: ServerProviderAuthStatus; |
| 101 | 88 | readonly message?: string; |
| 102 | 89 | } { |
| 103 | 90 | const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); |
| 104 | | const unknownCommand = |
| 91 | |
| 92 | if ( |
| 105 | 93 | lowerOutput.includes("unknown command") || |
| 106 | 94 | lowerOutput.includes("unrecognized command") || |
| 107 | | lowerOutput.includes("unexpected argument"); |
| 108 | | if (unknownCommand) { |
| 95 | lowerOutput.includes("unexpected argument") |
| 96 | ) { |
| 109 | 97 | return { |
| 110 | 98 | status: "warning", |
| 111 | 99 | authStatus: "unknown", |
| 112 | | message: |
| 113 | | "Codex CLI authentication status command is unavailable in this Codex version.", |
| 100 | message: "Codex CLI authentication status command is unavailable in this Codex version.", |
| 114 | 101 | }; |
| 115 | 102 | } |
| 116 | 103 | |
| 117 | | const unauthenticatedSignal = |
| 104 | if ( |
| 118 | 105 | lowerOutput.includes("not logged in") || |
| 119 | 106 | lowerOutput.includes("login required") || |
| 120 | 107 | lowerOutput.includes("authentication required") || |
| 121 | 108 | lowerOutput.includes("run `codex login`") || |
| 122 | | lowerOutput.includes("run codex login"); |
| 123 | | if (unauthenticatedSignal) { |
| 109 | lowerOutput.includes("run codex login") |
| 110 | ) { |
| 124 | 111 | return { |
| 125 | 112 | status: "error", |
| 126 | 113 | authStatus: "unauthenticated", |
| ... | ... | @@ -128,27 +115,20 @@ function parseAuthStatusFromOutput( |
| 128 | 115 | }; |
| 129 | 116 | } |
| 130 | 117 | |
| 131 | | const parsedAuth = |
| 132 | | (() => { |
| 133 | | const trimmed = result.stdout.trim(); |
| 134 | | if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { |
| 135 | | return { |
| 136 | | attemptedJsonParse: false as const, |
| 137 | | auth: undefined as boolean | undefined, |
| 138 | | }; |
| 139 | | } |
| 140 | | try { |
| 141 | | return { |
| 142 | | attemptedJsonParse: true as const, |
| 143 | | auth: extractAuthBoolean(JSON.parse(trimmed)), |
| 144 | | }; |
| 145 | | } catch { |
| 146 | | return { |
| 147 | | attemptedJsonParse: false as const, |
| 148 | | auth: undefined as boolean | undefined, |
| 149 | | }; |
| 150 | | } |
| 151 | | })(); |
| 118 | const parsedAuth = (() => { |
| 119 | const trimmed = result.stdout.trim(); |
| 120 | if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { |
| 121 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; |
| 122 | } |
| 123 | try { |
| 124 | return { |
| 125 | attemptedJsonParse: true as const, |
| 126 | auth: extractAuthBoolean(JSON.parse(trimmed)), |
| 127 | }; |
| 128 | } catch { |
| 129 | return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; |
| 130 | } |
| 131 | })(); |
| 152 | 132 | |
| 153 | 133 | if (parsedAuth.auth === true) { |
| 154 | 134 | return { status: "ready", authStatus: "authenticated" }; |
| ... | ... | @@ -160,7 +140,6 @@ function parseAuthStatusFromOutput( |
| 160 | 140 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", |
| 161 | 141 | }; |
| 162 | 142 | } |
| 163 | | |
| 164 | 143 | if (parsedAuth.attemptedJsonParse) { |
| 165 | 144 | return { |
| 166 | 145 | status: "warning", |
| ... | ... | @@ -169,9 +148,7 @@ function parseAuthStatusFromOutput( |
| 169 | 148 | "Could not verify Codex authentication status from JSON output (missing auth marker).", |
| 170 | 149 | }; |
| 171 | 150 | } |
| 172 | | |
| 173 | 151 | if (result.code === 0) { |
| 174 | | // Successful command with no explicit auth marker is still a pass. |
| 175 | 152 | return { status: "ready", authStatus: "authenticated" }; |
| 176 | 153 | } |
| 177 | 154 | |
| ... | ... | @@ -179,70 +156,110 @@ function parseAuthStatusFromOutput( |
| 179 | 156 | return { |
| 180 | 157 | status: "warning", |
| 181 | 158 | authStatus: "unknown", |
| 182 | | ...(detail |
| 183 | | ? { message: `Could not verify Codex authentication status. ${detail}` } |
| 184 | | : { message: "Could not verify Codex authentication status." }), |
| 159 | message: detail |
| 160 | ? `Could not verify Codex authentication status. ${detail}` |
| 161 | : "Could not verify Codex authentication status.", |
| 185 | 162 | }; |
| 186 | 163 | } |
| 187 | 164 | |
| 188 | | async function checkCodexProviderStatus(run: CommandRunner): Promise<ServerProviderStatus> { |
| 189 | | const checkedAt = new Date().toISOString(); |
| 165 | // ── Effect-native command execution ───────────────────────────────── |
| 166 | |
| 167 | const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> => |
| 168 | Stream.runFold( |
| 169 | stream, |
| 170 | () => "", |
| 171 | (acc, chunk) => acc + new TextDecoder().decode(chunk), |
| 172 | ); |
| 190 | 173 | |
| 191 | | try { |
| 192 | | const version = await run("codex", ["--version"], { |
| 193 | | timeoutMs: DEFAULT_TIMEOUT_MS, |
| 194 | | allowNonZeroExit: true, |
| 195 | | outputMode: "truncate", |
| 174 | const runCodexCommand = (args: ReadonlyArray<string>) => |
| 175 | Effect.gen(function* () { |
| 176 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 177 | const command = ChildProcess.make("codex", [...args], { |
| 178 | shell: process.platform === "win32", |
| 196 | 179 | }); |
| 197 | 180 | |
| 198 | | if (version.code !== 0 || version.timedOut) { |
| 199 | | const detail = detailFromResult(version); |
| 200 | | return { |
| 201 | | provider: CODEX_PROVIDER, |
| 202 | | status: "error", |
| 203 | | available: false, |
| 204 | | authStatus: "unknown", |
| 205 | | checkedAt, |
| 206 | | message: |
| 207 | | detail !== undefined |
| 208 | | ? `Codex CLI is installed but failed to run. ${detail}` |
| 209 | | : "Codex CLI is installed but failed to run.", |
| 210 | | }; |
| 211 | | } |
| 212 | | } catch (error) { |
| 181 | const child = yield* spawner.spawn(command); |
| 182 | |
| 183 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 184 | [ |
| 185 | collectStreamAsString(child.stdout), |
| 186 | collectStreamAsString(child.stderr), |
| 187 | child.exitCode.pipe(Effect.map(Number)), |
| 188 | ], |
| 189 | { concurrency: "unbounded" }, |
| 190 | ); |
| 191 | |
| 192 | return { stdout, stderr, code: exitCode } satisfies CommandResult; |
| 193 | }).pipe(Effect.scoped); |
| 194 | |
| 195 | // ── Health check ──────────────────────────────────────────────────── |
| 196 | |
| 197 | export const checkCodexProviderStatus: Effect.Effect< |
| 198 | ServerProviderStatus, |
| 199 | never, |
| 200 | ChildProcessSpawner.ChildProcessSpawner |
| 201 | > = Effect.gen(function* () { |
| 202 | const checkedAt = new Date().toISOString(); |
| 203 | |
| 204 | // Probe 1: `codex --version` — is the CLI reachable? |
| 205 | const versionProbe = yield* runCodexCommand(["--version"]).pipe( |
| 206 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), |
| 207 | Effect.result, |
| 208 | ); |
| 209 | |
| 210 | if (Result.isFailure(versionProbe)) { |
| 211 | const error = versionProbe.failure; |
| 213 | 212 | return { |
| 214 | 213 | provider: CODEX_PROVIDER, |
| 215 | | status: "error", |
| 214 | status: "error" as const, |
| 216 | 215 | available: false, |
| 217 | | authStatus: "unknown", |
| 216 | authStatus: "unknown" as const, |
| 218 | 217 | checkedAt, |
| 219 | | message: isCommandMissingError(error) |
| 218 | message: isCommandMissingCause(error) |
| 220 | 219 | ? "Codex CLI (`codex`) is not installed or not on PATH." |
| 221 | 220 | : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, |
| 222 | 221 | }; |
| 223 | 222 | } |
| 224 | 223 | |
| 225 | | try { |
| 226 | | const auth = await run("codex", ["login", "status"], { |
| 227 | | timeoutMs: DEFAULT_TIMEOUT_MS, |
| 228 | | allowNonZeroExit: true, |
| 229 | | outputMode: "truncate", |
| 230 | | }); |
| 231 | | const parsed = parseAuthStatusFromOutput(auth); |
| 224 | if (Option.isNone(versionProbe.success)) { |
| 232 | 225 | return { |
| 233 | 226 | provider: CODEX_PROVIDER, |
| 234 | | status: parsed.status, |
| 235 | | available: true, |
| 236 | | authStatus: parsed.authStatus, |
| 227 | status: "error" as const, |
| 228 | available: false, |
| 229 | authStatus: "unknown" as const, |
| 237 | 230 | checkedAt, |
| 238 | | ...(parsed.message ? { message: parsed.message } : {}), |
| 231 | message: "Codex CLI is installed but failed to run. Timed out while running command.", |
| 239 | 232 | }; |
| 240 | | } catch (error) { |
| 233 | } |
| 234 | |
| 235 | const version = versionProbe.success.value; |
| 236 | if (version.code !== 0) { |
| 237 | const detail = detailFromResult(version); |
| 241 | 238 | return { |
| 242 | 239 | provider: CODEX_PROVIDER, |
| 243 | | status: "warning", |
| 240 | status: "error" as const, |
| 241 | available: false, |
| 242 | authStatus: "unknown" as const, |
| 243 | checkedAt, |
| 244 | message: detail |
| 245 | ? `Codex CLI is installed but failed to run. ${detail}` |
| 246 | : "Codex CLI is installed but failed to run.", |
| 247 | }; |
| 248 | } |
| 249 | |
| 250 | // Probe 2: `codex login status` — is the user authenticated? |
| 251 | const authProbe = yield* runCodexCommand(["login", "status"]).pipe( |
| 252 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), |
| 253 | Effect.result, |
| 254 | ); |
| 255 | |
| 256 | if (Result.isFailure(authProbe)) { |
| 257 | const error = authProbe.failure; |
| 258 | return { |
| 259 | provider: CODEX_PROVIDER, |
| 260 | status: "warning" as const, |
| 244 | 261 | available: true, |
| 245 | | authStatus: "unknown", |
| 262 | authStatus: "unknown" as const, |
| 246 | 263 | checkedAt, |
| 247 | 264 | message: |
| 248 | 265 | error instanceof Error |
| ... | ... | @@ -250,21 +267,37 @@ async function checkCodexProviderStatus(run: CommandRunner): Promise<ServerProvi |
| 250 | 267 | : "Could not verify Codex authentication status.", |
| 251 | 268 | }; |
| 252 | 269 | } |
| 253 | | } |
| 254 | 270 | |
| 255 | | export async function checkProviderStatusesOnStartup( |
| 256 | | run: CommandRunner = runProcess, |
| 257 | | ): Promise<ReadonlyArray<ServerProviderStatus>> { |
| 258 | | return [await checkCodexProviderStatus(run)]; |
| 259 | | } |
| 271 | if (Option.isNone(authProbe.success)) { |
| 272 | return { |
| 273 | provider: CODEX_PROVIDER, |
| 274 | status: "warning" as const, |
| 275 | available: true, |
| 276 | authStatus: "unknown" as const, |
| 277 | checkedAt, |
| 278 | message: "Could not verify Codex authentication status. Timed out while running command.", |
| 279 | }; |
| 280 | } |
| 260 | 281 | |
| 261 | | const makeProviderHealth = (run: CommandRunner = runProcess) => |
| 262 | | Effect.gen(function* () { |
| 263 | | const statuses = yield* Effect.promise(() => checkProviderStatusesOnStartup(run)); |
| 282 | const parsed = parseAuthStatusFromOutput(authProbe.success.value); |
| 283 | return { |
| 284 | provider: CODEX_PROVIDER, |
| 285 | status: parsed.status, |
| 286 | available: true, |
| 287 | authStatus: parsed.authStatus, |
| 288 | checkedAt, |
| 289 | ...(parsed.message ? { message: parsed.message } : {}), |
| 290 | } satisfies ServerProviderStatus; |
| 291 | }); |
| 292 | |
| 293 | // ── Layer ─────────────────────────────────────────────────────────── |
| 264 | 294 | |
| 295 | export const ProviderHealthLive = Layer.effect( |
| 296 | ProviderHealth, |
| 297 | Effect.gen(function* () { |
| 298 | const codexStatus = yield* checkCodexProviderStatus; |
| 265 | 299 | return { |
| 266 | | getStatuses: Effect.succeed(statuses), |
| 300 | getStatuses: Effect.succeed([codexStatus]), |
| 267 | 301 | } satisfies ProviderHealthShape; |
| 268 | | }); |
| 269 | | |
| 270 | | export const ProviderHealthLive = Layer.effect(ProviderHealth, makeProviderHealth()); |
| 302 | }), |
| 303 | ); |