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