From 64f2c3a46565d8cd401351072b5b11494b5eccd0 Mon Sep 17 00:00:00 2001 From: julius Date: Wed, 4 Mar 2026 04:34:08 -0800 Subject: [PATCH] effectify --- .../provider/Layers/ProviderHealth.test.ts | 302 +++++++++++------- .../src/provider/Layers/ProviderHealth.ts | 291 +++++++++-------- 2 files changed, 347 insertions(+), 246 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 2c8efd9253b894d99df6ffbdde47491ec6b9459f..251d870b3213e6d9a5acbd7de564a3674169ec6a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -1,132 +1,200 @@ -import { describe, expect, it } from "vitest"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Sink, Stream } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { expect } from "vitest"; -import type { ProcessRunResult } from "../../processRunner"; -import { checkProviderStatusesOnStartup } from "./ProviderHealth"; +import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth"; -function result(input: Partial): ProcessRunResult { - return { - stdout: "", - stderr: "", - code: 0, - signal: null, - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - ...input, - }; +// ── Test helpers ──────────────────────────────────────────────────── + +const encoder = new TextEncoder(); + +function mockHandle(result: { stdout: string; stderr: string; code: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout)), + stderr: Stream.make(encoder.encode(result.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawnerLayer( + handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); +} + +function failingSpawnerLayer(description: string) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description, + }), + ), + ), + ); } -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(" ") === "login status") { - return result({ stdout: "Logged in\n" }); - } - throw new Error(`Unexpected command: ${args.join(" ")}`); - }); - - expect(status).toEqual([ - expect.objectContaining({ - provider: "codex", - status: "ready", - available: true, - authStatus: "authenticated", +// ── Tests ─────────────────────────────────────────────────────────── + +it.effect("returns ready when codex is installed and authenticated", () => + checkCodexProviderStatus.pipe( + Effect.map((status) => { + expect(status).toEqual( + expect.objectContaining({ + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + }), + ); + }), + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); }), - ]); - }); + ), + ), +); + +it.effect("returns unavailable when codex is missing", () => + checkCodexProviderStatus.pipe( + Effect.map((status) => { + expect(status).toEqual( + expect.objectContaining({ + provider: "codex", + status: "error", + available: false, + authStatus: "unknown", + message: "Codex CLI (`codex`) is not installed or not on PATH.", + }), + ); + }), + Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), + ), +); - 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.effect("returns unauthenticated when auth probe reports login required", () => + checkCodexProviderStatus.pipe( + Effect.map((status) => { + 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.", + }), + ); + }), + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; + } + throw new Error(`Unexpected args: ${joined}`); }), - ]); - }); + ), + ), +); - 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(" ") === "login status") { - 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.effect("returns unauthenticated when login status output includes 'not logged in'", () => + checkCodexProviderStatus.pipe( + Effect.map((status) => { + 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.", + }), + ); + }), + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); }), - ]); - }); + ), + ), +); - it("returns unauthenticated when login status output includes 'not logged in'", async () => { - const status = await checkProviderStatusesOnStartup(async (_command, args) => { - if (args.join(" ") === "--version") { - return result({ stdout: "codex 1.0.0\n" }); - } - if (args.join(" ") === "login status") { - return result({ - code: 1, - stdout: "Not logged in\n", - }); - } - 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.effect("returns warning when login status command is unsupported", () => + checkCodexProviderStatus.pipe( + Effect.map((status) => { + 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.", + }), + ); + }), + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; + } + throw new Error(`Unexpected args: ${joined}`); }), - ]); + ), + ), +); + +// ── Pure function tests ───────────────────────────────────────────── + +it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => { + const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); +}); + +it("parseAuthStatusFromOutput: JSON with authenticated=false is unauthenticated", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"authenticated":false}]\n', + stderr: "", + code: 0, }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); +}); - it("returns warning when login 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(" ") === "login status") { - return result({ - code: 2, - stderr: "error: unknown command 'login'", - }); - } - 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.", - }), - ]); +it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"ok":true}]\n', + stderr: "", + code: 0, }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); }); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 40d75f0af30d889c6952dd367605c7f25df1f40f..e9362758667d5fe016f9d714f44a35ee80b00335 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -4,6 +4,8 @@ * Performs one-time provider readiness probes when the server starts and * keeps the resulting snapshot in memory for `server.getConfig`. * + * Uses effect's ChildProcessSpawner to run CLI probes natively. + * * @module ProviderHealthLive */ import type { @@ -11,43 +13,48 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -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 { +// ── Pure helpers ──────────────────────────────────────────────────── + +export interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +function nonEmptyTrimmed(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } -function isCommandMissingError(error: unknown): boolean { +function isCommandMissingCause(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") + lower.includes("enoent") || + lower.includes("notfound") ); } -function detailFromResult(result: ProcessRunResult): string | undefined { +function detailFromResult( + result: CommandResult & { readonly timedOut?: boolean }, +): string | undefined { if (result.timedOut) return "Timed out while running command."; - const stderr = nonEmptyMessage(result.stderr); + const stderr = nonEmptyTrimmed(result.stderr); if (stderr) return stderr; - const stdout = nonEmptyMessage(result.stdout); + const stdout = nonEmptyTrimmed(result.stdout); if (stdout) return stdout; - if (result.code !== null && result.code !== 0) { + if (result.code !== 0) { return `Command exited with code ${result.code}.`; } return undefined; @@ -57,70 +64,50 @@ function extractAuthBoolean(value: unknown): boolean | undefined { if (Array.isArray(value)) { for (const entry of value) { const nested = extractAuthBoolean(entry); - if (nested !== undefined) { - return nested; - } + if (nested !== undefined) return nested; } return undefined; } - if (!value || typeof value !== "object") { - return undefined; - } + if (!value || typeof value !== "object") 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; - } + for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { + if (typeof record[key] === "boolean") return record[key]; } - - const nestedCandidates = ["auth", "status", "session", "account"] as const; - for (const key of nestedCandidates) { + for (const key of ["auth", "status", "session", "account"] as const) { const nested = extractAuthBoolean(record[key]); - if (nested !== undefined) { - return nested; - } + if (nested !== undefined) return nested; } - return undefined; } -function parseAuthStatusFromOutput( - result: ProcessRunResult, -): { +export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: ServerProviderStatusState; readonly authStatus: ServerProviderAuthStatus; readonly message?: string; } { const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); - const unknownCommand = + + if ( lowerOutput.includes("unknown command") || lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument"); - if (unknownCommand) { + lowerOutput.includes("unexpected argument") + ) { return { status: "warning", authStatus: "unknown", - message: - "Codex CLI authentication status command is unavailable in this Codex version.", + message: "Codex CLI authentication status command is unavailable in this Codex version.", }; } - const unauthenticatedSignal = + if ( 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) { + lowerOutput.includes("run codex login") + ) { return { status: "error", authStatus: "unauthenticated", @@ -128,27 +115,20 @@ function parseAuthStatusFromOutput( }; } - const parsedAuth = - (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { - attemptedJsonParse: false as const, - auth: undefined as boolean | undefined, - }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { - attemptedJsonParse: false as const, - auth: undefined as boolean | undefined, - }; - } - })(); + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + try { + return { + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), + }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + })(); if (parsedAuth.auth === true) { return { status: "ready", authStatus: "authenticated" }; @@ -160,7 +140,6 @@ function parseAuthStatusFromOutput( message: "Codex CLI is not authenticated. Run `codex login` and try again.", }; } - if (parsedAuth.attemptedJsonParse) { return { status: "warning", @@ -169,9 +148,7 @@ function parseAuthStatusFromOutput( "Could not verify Codex authentication status from JSON output (missing auth marker).", }; } - if (result.code === 0) { - // Successful command with no explicit auth marker is still a pass. return { status: "ready", authStatus: "authenticated" }; } @@ -179,70 +156,110 @@ function parseAuthStatusFromOutput( return { status: "warning", authStatus: "unknown", - ...(detail - ? { message: `Could not verify Codex authentication status. ${detail}` } - : { message: "Could not verify Codex authentication status." }), + message: detail + ? `Could not verify Codex authentication status. ${detail}` + : "Could not verify Codex authentication status.", }; } -async function checkCodexProviderStatus(run: CommandRunner): Promise { +// ── Effect-native command execution ───────────────────────────────── + +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + Stream.runFold( + stream, + () => "", + (acc, chunk) => acc + new TextDecoder().decode(chunk), + ); + +const runCodexCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make("codex", [...args], { + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +// ── Health check ──────────────────────────────────────────────────── + +export const checkCodexProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { const checkedAt = new Date().toISOString(); - try { - const version = await run("codex", ["--version"], { - timeoutMs: DEFAULT_TIMEOUT_MS, - allowNonZeroExit: true, - outputMode: "truncate", - }); + // Probe 1: `codex --version` — is the CLI reachable? + const versionProbe = yield* runCodexCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); - 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) { + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; return { provider: CODEX_PROVIDER, - status: "error", + status: "error" as const, available: false, - authStatus: "unknown", + authStatus: "unknown" as const, checkedAt, - message: isCommandMissingError(error) + message: isCommandMissingCause(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", ["login", "status"], { - timeoutMs: DEFAULT_TIMEOUT_MS, - allowNonZeroExit: true, - outputMode: "truncate", - }); - const parsed = parseAuthStatusFromOutput(auth); + if (Option.isNone(versionProbe.success)) { return { provider: CODEX_PROVIDER, - status: parsed.status, - available: true, - authStatus: parsed.authStatus, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Codex CLI is installed but failed to run. Timed out while running command.", + }; + } + + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CODEX_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, checkedAt, - ...(parsed.message ? { message: parsed.message } : {}), + message: detail + ? `Codex CLI is installed but failed to run. ${detail}` + : "Codex CLI is installed but failed to run.", }; - } catch (error) { + } + + // Probe 2: `codex login status` — is the user authenticated? + const authProbe = yield* runCodexCommand(["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; return { provider: CODEX_PROVIDER, - status: "warning", + status: "warning" as const, available: true, - authStatus: "unknown", + authStatus: "unknown" as const, checkedAt, message: error instanceof Error @@ -250,21 +267,37 @@ async function checkCodexProviderStatus(run: CommandRunner): Promise> { - return [await checkCodexProviderStatus(run)]; -} + if (Option.isNone(authProbe.success)) { + return { + provider: CODEX_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Could not verify Codex authentication status. Timed out while running command.", + }; + } -const makeProviderHealth = (run: CommandRunner = runProcess) => + const parsed = parseAuthStatusFromOutput(authProbe.success.value); + return { + provider: CODEX_PROVIDER, + status: parsed.status, + available: true, + authStatus: parsed.authStatus, + checkedAt, + ...(parsed.message ? { message: parsed.message } : {}), + } satisfies ServerProviderStatus; +}); + +// ── Layer ─────────────────────────────────────────────────────────── + +export const ProviderHealthLive = Layer.effect( + ProviderHealth, Effect.gen(function* () { - const statuses = yield* Effect.promise(() => checkProviderStatusesOnStartup(run)); - + const codexStatus = yield* checkCodexProviderStatus; return { - getStatuses: Effect.succeed(statuses), + getStatuses: Effect.succeed([codexStatus]), } satisfies ProviderHealthShape; - }); - -export const ProviderHealthLive = Layer.effect(ProviderHealth, makeProviderHealth()); + }), +); -- 2.54.0