authorgravatar for julius0216@outlook.comjulius <julius0216@outlook.com> 2026-03-04 04:34:08-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 21:03:16-08:00
log64f2c3a46565d8cd401351072b5b11494b5eccd0
tree9dd2209c33d372b9bc48fbacc671d2155298da55
parent0a1233344896376ec8b31b79ee65500eeef3a09e

effectify


2 files changed, 347 insertions(+), 246 deletions(-)

apps/server/src/provider/Layers/ProviderHealth.test.ts+185-117
...@@ -1,132 +1,200 @@...@@ -1,132 +1,200 @@
1import { describe, expect, it } from "vitest";1import { assert, it } from "@effect/vitest";
2import { Effect, Layer, Sink, Stream } from "effect";
3import * as PlatformError from "effect/PlatformError";
4import { ChildProcessSpawner } from "effect/unstable/process";
5import { expect } from "vitest";
26
3import type { ProcessRunResult } from "../../processRunner";7import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth";
4import { checkProviderStatusesOnStartup } from "./ProviderHealth";
58
6function result(input: Partial<ProcessRunResult>): ProcessRunResult {9// ── Test helpers ────────────────────────────────────────────────────
7 return {10
8 stdout: "",11const encoder = new TextEncoder();
9 stderr: "",12
10 code: 0,13function mockHandle(result: { stdout: string; stderr: string; code: number }) {
11 signal: null,14 return ChildProcessSpawner.makeHandle({
12 timedOut: false,15 pid: ChildProcessSpawner.ProcessId(1),
13 stdoutTruncated: false,16 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)),
14 stderrTruncated: false,17 isRunning: Effect.succeed(false),
15 ...input,18 kill: () => Effect.void,
16 };19 stdin: Sink.drain,
20 stdout: Stream.make(encoder.encode(result.stdout)),
21 stderr: Stream.make(encoder.encode(result.stderr)),
22 all: Stream.empty,
23 getInputFd: () => Sink.drain,
24 getOutputFd: () => Stream.empty,
25 });
26}
27
28function mockSpawnerLayer(
29 handler: (args: ReadonlyArray<string>) => { stdout: string; stderr: string; code: number },
30) {
31 return Layer.succeed(
32 ChildProcessSpawner.ChildProcessSpawner,
33 ChildProcessSpawner.make((command) => {
34 const cmd = command as unknown as { args: ReadonlyArray<string> };
35 return Effect.succeed(mockHandle(handler(cmd.args)));
36 }),
37 );
38}
39
40function failingSpawnerLayer(description: string) {
41 return Layer.succeed(
42 ChildProcessSpawner.ChildProcessSpawner,
43 ChildProcessSpawner.make(() =>
44 Effect.fail(
45 PlatformError.systemError({
46 _tag: "NotFound",
47 module: "ChildProcess",
48 method: "spawn",
49 description,
50 }),
51 ),
52 ),
53 );
17}54}
1855
19describe("checkProviderStatusesOnStartup", () => {56// ── Tests ───────────────────────────────────────────────────────────
20 it("returns ready when codex is installed and authenticated", async () => {57
21 const status = await checkProviderStatusesOnStartup(async (_command, args) => {58it.effect("returns ready when codex is installed and authenticated", () =>
22 if (args.join(" ") === "--version") {59 checkCodexProviderStatus.pipe(
23 return result({ stdout: "codex 1.0.0\n" });60 Effect.map((status) => {
24 }61 expect(status).toEqual(
25 if (args.join(" ") === "login status") {62 expect.objectContaining({
26 return result({ stdout: "Logged in\n" });63 provider: "codex",
27 }64 status: "ready",
28 throw new Error(`Unexpected command: ${args.join(" ")}`);65 available: true,
29 });66 authStatus: "authenticated",
3067 }),
31 expect(status).toEqual([68 );
32 expect.objectContaining({69 }),
33 provider: "codex",70 Effect.provide(
34 status: "ready",71 mockSpawnerLayer((args) => {
35 available: true,72 const joined = args.join(" ");
36 authStatus: "authenticated",73 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
74 if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };
75 throw new Error(`Unexpected args: ${joined}`);
37 }),76 }),
38 ]);77 ),
39 });78 ),
79);
80
81it.effect("returns unavailable when codex is missing", () =>
82 checkCodexProviderStatus.pipe(
83 Effect.map((status) => {
84 expect(status).toEqual(
85 expect.objectContaining({
86 provider: "codex",
87 status: "error",
88 available: false,
89 authStatus: "unknown",
90 message: "Codex CLI (`codex`) is not installed or not on PATH.",
91 }),
92 );
93 }),
94 Effect.provide(failingSpawnerLayer("spawn codex ENOENT")),
95 ),
96);
4097
41 it("returns unavailable when codex is missing", async () => {98it.effect("returns unauthenticated when auth probe reports login required", () =>
42 const status = await checkProviderStatusesOnStartup(async () => {99 checkCodexProviderStatus.pipe(
43 throw new Error("Command not found: codex");100 Effect.map((status) => {
44 });101 expect(status).toEqual(
45102 expect.objectContaining({
46 expect(status).toEqual([103 provider: "codex",
47 expect.objectContaining({104 status: "error",
48 provider: "codex",105 available: true,
49 status: "error",106 authStatus: "unauthenticated",
50 available: false,107 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
51 authStatus: "unknown",108 }),
52 message: "Codex CLI (`codex`) is not installed or not on PATH.",109 );
110 }),
111 Effect.provide(
112 mockSpawnerLayer((args) => {
113 const joined = args.join(" ");
114 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
115 if (joined === "login status") {
116 return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 };
117 }
118 throw new Error(`Unexpected args: ${joined}`);
53 }),119 }),
54 ]);120 ),
55 });121 ),
122);
56123
57 it("returns unauthenticated when auth probe reports login required", async () => {124it.effect("returns unauthenticated when login status output includes 'not logged in'", () =>
58 const status = await checkProviderStatusesOnStartup(async (_command, args) => {125 checkCodexProviderStatus.pipe(
59 if (args.join(" ") === "--version") {126 Effect.map((status) => {
60 return result({ stdout: "codex 1.0.0\n" });127 expect(status).toEqual(
61 }128 expect.objectContaining({
62 if (args.join(" ") === "login status") {129 provider: "codex",
63 return result({130 status: "error",
64 code: 1,131 available: true,
65 stderr: "Not logged in. Run codex login.",132 authStatus: "unauthenticated",
66 });133 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
67 }134 }),
68 throw new Error(`Unexpected command: ${args.join(" ")}`);135 );
69 });136 }),
70137 Effect.provide(
71 expect(status).toEqual([138 mockSpawnerLayer((args) => {
72 expect.objectContaining({139 const joined = args.join(" ");
73 provider: "codex",140 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
74 status: "error",141 if (joined === "login status") return { stdout: "Not logged in\n", stderr: "", code: 1 };
75 available: true,142 throw new Error(`Unexpected args: ${joined}`);
76 authStatus: "unauthenticated",
77 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
78 }),143 }),
79 ]);144 ),
80 });145 ),
146);
81147
82 it("returns unauthenticated when login status output includes 'not logged in'", async () => {148it.effect("returns warning when login status command is unsupported", () =>
83 const status = await checkProviderStatusesOnStartup(async (_command, args) => {149 checkCodexProviderStatus.pipe(
84 if (args.join(" ") === "--version") {150 Effect.map((status) => {
85 return result({ stdout: "codex 1.0.0\n" });151 expect(status).toEqual(
86 }152 expect.objectContaining({
87 if (args.join(" ") === "login status") {153 provider: "codex",
88 return result({154 status: "warning",
89 code: 1,155 available: true,
90 stdout: "Not logged in\n",156 authStatus: "unknown",
91 });157 message: "Codex CLI authentication status command is unavailable in this Codex version.",
92 }158 }),
93 throw new Error(`Unexpected command: ${args.join(" ")}`);159 );
94 });160 }),
95161 Effect.provide(
96 expect(status).toEqual([162 mockSpawnerLayer((args) => {
97 expect.objectContaining({163 const joined = args.join(" ");
98 provider: "codex",164 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
99 status: "error",165 if (joined === "login status") {
100 available: true,166 return { stdout: "", stderr: "error: unknown command 'login'", code: 2 };
101 authStatus: "unauthenticated",167 }
102 message: "Codex CLI is not authenticated. Run `codex login` and try again.",168 throw new Error(`Unexpected args: ${joined}`);
103 }),169 }),
104 ]);170 ),
171 ),
172);
173
174// ── Pure function tests ─────────────────────────────────────────────
175
176it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => {
177 const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });
178 assert.strictEqual(parsed.status, "ready");
179 assert.strictEqual(parsed.authStatus, "authenticated");
180});
181
182it("parseAuthStatusFromOutput: JSON with authenticated=false is unauthenticated", () => {
183 const parsed = parseAuthStatusFromOutput({
184 stdout: '[{"authenticated":false}]\n',
185 stderr: "",
186 code: 0,
105 });187 });
188 assert.strictEqual(parsed.status, "error");
189 assert.strictEqual(parsed.authStatus, "unauthenticated");
190});
106191
107 it("returns warning when login status command is unsupported", async () => {192it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => {
108 const status = await checkProviderStatusesOnStartup(async (_command, args) => {193 const parsed = parseAuthStatusFromOutput({
109 if (args.join(" ") === "--version") {194 stdout: '[{"ok":true}]\n',
110 return result({ stdout: "codex 1.0.0\n" });195 stderr: "",
111 }196 code: 0,
112 if (args.join(" ") === "login status") {
113 return result({
114 code: 2,
115 stderr: "error: unknown command 'login'",
116 });
117 }
118 throw new Error(`Unexpected command: ${args.join(" ")}`);
119 });
120
121 expect(status).toEqual([
122 expect.objectContaining({
123 provider: "codex",
124 status: "warning",
125 available: true,
126 authStatus: "unknown",
127 message:
128 "Codex CLI authentication status command is unavailable in this Codex version.",
129 }),
130 ]);
131 });197 });
198 assert.strictEqual(parsed.status, "warning");
199 assert.strictEqual(parsed.authStatus, "unknown");
132});200});
apps/server/src/provider/Layers/ProviderHealth.ts+162-129
...@@ -4,6 +4,8 @@...@@ -4,6 +4,8 @@
4 * Performs one-time provider readiness probes when the server starts and4 * 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 ProviderHealthLive9 * @module ProviderHealthLive
8 */10 */
9import type {11import 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";
14import { Effect, Layer } from "effect";16import { Effect, Layer, Option, Result, Stream } from "effect";
17import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1518
16import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "../../processRunner";
17import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";19import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
1820
19type CommandRunner = (
20 command: string,
21 args: readonly string[],
22 options?: ProcessRunOptions,
23) => Promise<ProcessRunResult>;
24
25const DEFAULT_TIMEOUT_MS = 4_000;21const DEFAULT_TIMEOUT_MS = 4_000;
26const CODEX_PROVIDER = "codex" as const;22const CODEX_PROVIDER = "codex" as const;
2723
28function nonEmptyMessage(value: string | undefined): string | undefined {24// ── Pure helpers ────────────────────────────────────────────────────
25
26export interface CommandResult {
27 readonly stdout: string;
28 readonly stderr: string;
29 readonly code: number;
30}
31
32function 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}
3337
34function isCommandMissingError(error: unknown): boolean {38function 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}
4348
44function detailFromResult(result: ProcessRunResult): string | undefined {49function 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 }
6671
67 if (!value || typeof value !== "object") {72 if (!value || typeof value !== "object") return undefined;
68 return undefined;
69 }
7073
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 }
8478 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}
9584
96function parseAuthStatusFromOutput(85export 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 }
116103
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 }
130117
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 })();
152132
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 }
177154
...@@ -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 ...(detail159 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}
187164
188async function checkCodexProviderStatus(run: CommandRunner): Promise<ServerProviderStatus> {165// ── Effect-native command execution ─────────────────────────────────
189 const checkedAt = new Date().toISOString();166
167const 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 );
190173
191 try {174const 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 });
197180
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 !== undefined190 );
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
197export 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 }
224223
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 Error265 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}
254270
255export 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 }
260281
261const 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 ───────────────────────────────────────────────────────────
264294
295export 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 }),
269303);
270export const ProviderHealthLive = Layer.effect(ProviderHealth, makeProviderHealth());