diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 3e3bbe742c1..a2926afd0ef 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -133,7 +133,10 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass new GitLabCliCommandError({ ...context, cause }), - VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index e13120b1c57..675d20cb82c 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -11,6 +11,7 @@ import { VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; +import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; const run = (input: VcsProcess.VcsProcessInput) => @@ -24,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const provideLive = (effect: Effect.Effect) => effect.pipe(Effect.provide(liveLayer)); +const baseInput = { + operation: "test.process-boundary", + command: "git", + args: ["status", "--short"], + cwd: "/workspace", +} satisfies VcsProcess.VcsProcessInput; + +const captureProcessResult = ( + result: Effect.Effect, +) => + VcsProcess.make.pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: () => result }), + ), + Effect.flatMap((service) => service.run(baseInput)), + Effect.flip, + ); + describe("VcsProcess.run", () => { it.effect("collects stdout", () => Effect.gen(function* () { @@ -141,6 +161,51 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("preserves real boundary causes without manufacturing structural ones", () => + Effect.gen(function* () { + const cause = new Error("secret stdin failure"); + const error = yield* captureProcessResult( + Effect.fail( + new ProcessRunner.ProcessStdinError({ + command: baseInput.command, + argumentCount: baseInput.args.length, + cwd: baseInput.cwd, + stdinBytes: 47, + cause, + }), + ), + ); + + expect(error).toMatchObject({ + _tag: "VcsProcessStdinWriteError", + operation: baseInput.operation, + stdinBytes: 47, + cause, + }); + expect(error.message).not.toContain(cause.message); + + const missingExitCodeError = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + expect(missingExitCodeError).toMatchObject({ + _tag: "VcsProcessMissingExitCodeError", + operation: baseInput.operation, + command: baseInput.command, + cwd: baseInput.cwd, + argumentCount: baseInput.args.length, + }); + expect(missingExitCodeError).not.toHaveProperty("cause"); + }), + ); + it.effect("returns output when non-zero exits are allowed", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 8103c7306a0..52db6f9b1fb 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -5,11 +5,14 @@ import * as Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - VcsOutputDecodeError, type VcsError, VcsProcessExitError, type VcsProcessExitFailureKind, + VcsProcessMissingExitCodeError, + VcsProcessOutputLimitError, + VcsProcessOutputReadError, VcsProcessSpawnError, + VcsProcessStdinWriteError, VcsProcessTimeoutError, } from "@t3tools/contracts"; import * as ProcessRunner from "../processRunner.ts"; @@ -114,19 +117,32 @@ export const make = Effect.gen(function* () { ProcessSpawnError: (error) => VcsProcessSpawnError.fromProcessSpawnError(baseError, error), ProcessOutputLimitError: (error) => - VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), + new VcsProcessOutputLimitError({ + ...baseError, + stream: error.stream, + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }), ProcessTimeoutError: (error) => VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), ProcessStdinError: (error) => - VcsOutputDecodeError.fromProcessStdinError(baseError, error), + new VcsProcessStdinWriteError({ + ...baseError, + stdinBytes: error.stdinBytes, + cause: error.cause, + }), ProcessReadError: (error) => - VcsOutputDecodeError.fromProcessReadError(baseError, error), + new VcsProcessOutputReadError({ + ...baseError, + stream: error.stream, + cause: error.cause, + }), }), ), ); if (result.code === null) { - return yield* VcsOutputDecodeError.missingExitCode(baseError); + return yield* new VcsProcessMissingExitCodeError(baseError); } if (!input.allowNonZeroExit && result.code !== 0) { diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 728cef3974f..c1090f4f39a 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -69,20 +69,6 @@ export interface VcsProcessSpawnFailure { readonly cause: unknown; } -export interface VcsProcessStdinFailure { - readonly cause: unknown; -} - -export interface VcsProcessReadFailure { - readonly stream: "stdout" | "stderr" | "exitCode"; - readonly cause: unknown; -} - -export interface VcsProcessOutputLimitFailure { - readonly stream: "stdout" | "stderr"; - readonly maxBytes: number; -} - export interface VcsProcessTimeoutFailure { readonly timeoutMs: number; } @@ -189,58 +175,70 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass()( - "VcsOutputDecodeError", +const VcsProcessBoundaryErrorFields = { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + argumentCount: Schema.optional(NonNegativeInt), +}; + +export class VcsProcessStdinWriteError extends Schema.TaggedErrorClass()( + "VcsProcessStdinWriteError", { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - argumentCount: Schema.optional(NonNegativeInt), - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + ...VcsProcessBoundaryErrorFields, + stdinBytes: NonNegativeInt, + cause: Schema.Defect(), }, ) { override get message(): string { - return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } - - static fromProcessStdinError(context: VcsProcessErrorContext, error: VcsProcessStdinFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: "failed to write process stdin", - cause: error.cause, - }); + return `VCS process failed to write ${this.stdinBytes} bytes to stdin in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { - return new VcsOutputDecodeError({ - ...context, - detail: - error.stream === "exitCode" - ? "failed to read process exit code" - : `failed to read process ${error.stream}`, - cause: error.cause, - }); +export class VcsProcessOutputReadError extends Schema.TaggedErrorClass()( + "VcsProcessOutputReadError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr", "exitCode"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `VCS process failed to read ${this.stream} in ${this.operation}: ${this.command} (${this.cwd})`; } +} - static fromProcessOutputLimitError( - context: VcsProcessErrorContext, - error: VcsProcessOutputLimitFailure, - ) { - return new VcsOutputDecodeError({ - ...context, - detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, - }); +export class VcsProcessOutputLimitError extends Schema.TaggedErrorClass()( + "VcsProcessOutputLimitError", + { + ...VcsProcessBoundaryErrorFields, + stream: Schema.Literals(["stdout", "stderr"]), + maxBytes: NonNegativeInt, + observedBytes: NonNegativeInt, + }, +) { + override get message(): string { + return `VCS process ${this.stream} produced ${this.observedBytes} bytes in ${this.operation}: ${this.command} (${this.cwd}), exceeding the ${this.maxBytes} byte limit`; } +} - static missingExitCode(context: VcsProcessErrorContext) { - return new VcsOutputDecodeError({ - ...context, - detail: "process completed without an exit code", - }); +export class VcsProcessMissingExitCodeError extends Schema.TaggedErrorClass()( + "VcsProcessMissingExitCodeError", + VcsProcessBoundaryErrorFields, +) { + override get message(): string { + return `VCS process completed without an exit code in ${this.operation}: ${this.command} (${this.cwd})`; } } +export const VcsOutputDecodeError = Schema.Union([ + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, +]); +export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type; + export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass()( "VcsRepositoryDetectionError", { @@ -272,7 +270,10 @@ export const VcsError = Schema.Union([ VcsProcessSpawnError, VcsProcessExitError, VcsProcessTimeoutError, - VcsOutputDecodeError, + VcsProcessStdinWriteError, + VcsProcessOutputReadError, + VcsProcessOutputLimitError, + VcsProcessMissingExitCodeError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, ]);