| author | |
| committer | |
| log | 300d4d566a1044dc401bcd7ebf43def08621229e |
| tree | 7def31379c28fe0e1ae0ce72e80f145046709a3c |
| parent | 53a477c2e91d206057adc5ac4c28cbdaddfbc07c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>2 files changed, 102 insertions(+), 12 deletions(-)
apps/server/src/provider/providerSnapshot.test.ts+76-2| ... | @@ -1,8 +1,19 @@ | ... | @@ -1,8 +1,19 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; | 2 | import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 3 | import { createModelCapabilities } from "@t3tools/shared/model"; | 4 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as PlatformError from "effect/PlatformError"; | ||
| 8 | import * as Sink from "effect/Sink"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 4 | 11 | ||
| 5 | import { providerModelsFromSettings } from "./providerSnapshot.ts"; | 12 | import { |
| 13 | isCommandMissingCause, | ||
| 14 | providerModelsFromSettings, | ||
| 15 | spawnAndCollect, | ||
| 16 | } from "./providerSnapshot.ts"; | ||
| 6 | 17 | ||
| 7 | const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | 18 | const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| 8 | optionDescriptors: [ | 19 | optionDescriptors: [ |
| ... | @@ -42,3 +53,66 @@ describe("providerModelsFromSettings", () => { | ... | @@ -42,3 +53,66 @@ describe("providerModelsFromSettings", () => { |
| 42 | ]); | 53 | ]); |
| 43 | }); | 54 | }); |
| 44 | }); | 55 | }); |
| 56 | |||
| 57 | describe("ProviderCommandNotFoundError", () => { | ||
| 58 | it("classifies normalized platform failures without parsing messages", () => { | ||
| 59 | expect( | ||
| 60 | isCommandMissingCause( | ||
| 61 | PlatformError.systemError({ | ||
| 62 | _tag: "NotFound", | ||
| 63 | module: "ChildProcess", | ||
| 64 | method: "spawn", | ||
| 65 | description: "arbitrary host detail", | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | ).toBe(true); | ||
| 69 | expect(isCommandMissingCause(new Error("spawn provider ENOENT"))).toBe(false); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it.effect("retains safe failed-command diagnostics without process output", () => { | ||
| 73 | const stderr = "'codex' is not recognized: secret-token-value"; | ||
| 74 | const spawner = ChildProcessSpawner.make(() => | ||
| 75 | Effect.succeed( | ||
| 76 | ChildProcessSpawner.makeHandle({ | ||
| 77 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 78 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9009)), | ||
| 79 | isRunning: Effect.succeed(false), | ||
| 80 | kill: () => Effect.void, | ||
| 81 | unref: Effect.succeed(Effect.void), | ||
| 82 | stdin: Sink.drain, | ||
| 83 | stdout: Stream.empty, | ||
| 84 | stderr: Stream.encodeText(Stream.make(stderr)), | ||
| 85 | all: Stream.empty, | ||
| 86 | getInputFd: () => Sink.drain, | ||
| 87 | getOutputFd: () => Stream.empty, | ||
| 88 | }), | ||
| 89 | ), | ||
| 90 | ); | ||
| 91 | return Effect.gen(function* () { | ||
| 92 | const error = yield* spawnAndCollect( | ||
| 93 | "C:\\tools\\codex.cmd", | ||
| 94 | ChildProcess.make("codex", ["--version"]), | ||
| 95 | ).pipe( | ||
| 96 | Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), | ||
| 97 | Effect.provideService(HostProcessPlatform, "win32"), | ||
| 98 | Effect.flip, | ||
| 99 | ); | ||
| 100 | |||
| 101 | if (error._tag !== "ProviderCommandNotFoundError") { | ||
| 102 | throw new Error(`Unexpected error: ${error._tag}`); | ||
| 103 | } | ||
| 104 | |||
| 105 | expect(error.binaryPath).toBe("C:\\tools\\codex.cmd"); | ||
| 106 | expect(error.exitCode).toBe(9009); | ||
| 107 | expect(error.stdoutLength).toBe(0); | ||
| 108 | expect(error.stderrLength).toBe(stderr.length); | ||
| 109 | expect(error.message).toBe( | ||
| 110 | "Provider command C:\\tools\\codex.cmd was not found (exit code 9009).", | ||
| 111 | ); | ||
| 112 | expect(isCommandMissingCause(error)).toBe(true); | ||
| 113 | expect(error).not.toHaveProperty("stdout"); | ||
| 114 | expect(error).not.toHaveProperty("stderr"); | ||
| 115 | expect(error.message).not.toContain("secret-token-value"); | ||
| 116 | }); | ||
| 117 | }); | ||
| 118 | }); |
apps/server/src/provider/providerSnapshot.ts+26-10| ... | @@ -9,7 +9,8 @@ import type { | ... | @@ -9,7 +9,8 @@ import type { |
| 9 | ServerProviderState, | 9 | ServerProviderState, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Data from "effect/Data"; | 12 | import * as PlatformError from "effect/PlatformError"; |
| 13 | import * as Schema from "effect/Schema"; | ||
| 13 | import * as Stream from "effect/Stream"; | 14 | import * as Stream from "effect/Stream"; |
| 14 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 15 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 15 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 16 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| ... | @@ -27,11 +28,21 @@ export interface CommandResult { | ... | @@ -27,11 +28,21 @@ export interface CommandResult { |
| 27 | readonly code: number; | 28 | readonly code: number; |
| 28 | } | 29 | } |
| 29 | 30 | ||
| 30 | export class ProviderCommandExecutionError extends Data.TaggedError( | 31 | export class ProviderCommandNotFoundError extends Schema.TaggedErrorClass<ProviderCommandNotFoundError>()( |
| 31 | "ProviderCommandExecutionError", | 32 | "ProviderCommandNotFoundError", |
| 32 | )<{ | 33 | { |
| 33 | readonly message: string; | 34 | binaryPath: Schema.String, |
| 34 | }> {} | 35 | exitCode: Schema.Number, |
| 36 | stdoutLength: Schema.Number, | ||
| 37 | stderrLength: Schema.Number, | ||
| 38 | }, | ||
| 39 | ) { | ||
| 40 | override get message(): string { | ||
| 41 | return `Provider command ${this.binaryPath} was not found (exit code ${this.exitCode}).`; | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError); | ||
| 35 | 46 | ||
| 36 | export interface ProviderProbeResult { | 47 | export interface ProviderProbeResult { |
| 37 | readonly installed: boolean; | 48 | readonly installed: boolean; |
| ... | @@ -56,9 +67,9 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { | ... | @@ -56,9 +67,9 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { |
| 56 | return trimmed.length > 0 ? trimmed : undefined; | 67 | return trimmed.length > 0 ? trimmed : undefined; |
| 57 | } | 68 | } |
| 58 | 69 | ||
| 59 | export function isCommandMissingCause(error: { readonly message: string }): boolean { | 70 | export function isCommandMissingCause(error: unknown): boolean { |
| 60 | const lower = error.message.toLowerCase(); | 71 | if (isProviderCommandNotFoundError(error)) return true; |
| 61 | return lower.includes("enoent") || lower.includes("notfound"); | 72 | return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; |
| 62 | } | 73 | } |
| 63 | 74 | ||
| 64 | export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => | 75 | export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => |
| ... | @@ -76,7 +87,12 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman | ... | @@ -76,7 +87,12 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman |
| 76 | 87 | ||
| 77 | const result: CommandResult = { stdout, stderr, code: exitCode }; | 88 | const result: CommandResult = { stdout, stderr, code: exitCode }; |
| 78 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { | 89 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { |
| 79 | return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); | 90 | return yield* new ProviderCommandNotFoundError({ |
| 91 | binaryPath, | ||
| 92 | exitCode, | ||
| 93 | stdoutLength: stdout.length, | ||
| 94 | stderrLength: stderr.length, | ||
| 95 | }); | ||
| 80 | } | 96 | } |
| 81 | return result; | 97 | return result; |
| 82 | }).pipe(Effect.scoped); | 98 | }).pipe(Effect.scoped); |