| author | |
| committer | |
| log | 3c246f56b477e0d7c39f92cfc0a69ca3f6debd4b |
| tree | 21a499f938473d631a282cb3a745ce34fc04a54d |
| parent | cebbe6ff193c9598bf77c4f673a933db34ed7bcb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 145 insertions(+), 60 deletions(-)
apps/server/src/sourceControl/GitLabCli.ts+4-1| ... | ... | @@ -133,7 +133,10 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass<GitLabCliComm |
| 133 | 133 | } |
| 134 | 134 | }, |
| 135 | 135 | VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), |
| 136 | VcsOutputDecodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), | |
| 136 | VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), | |
| 137 | VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), | |
| 138 | VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), | |
| 139 | VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), | |
| 137 | 140 | VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), |
| 138 | 141 | VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), |
| 139 | 142 | }); |
apps/server/src/vcs/VcsProcess.test.ts+65| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | VcsProcessSpawnError, |
| 12 | 12 | VcsProcessTimeoutError, |
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | import * as ProcessRunner from "../processRunner.ts"; | |
| 14 | 15 | import * as VcsProcess from "./VcsProcess.ts"; |
| 15 | 16 | |
| 16 | 17 | const run = (input: VcsProcess.VcsProcessInput) => |
| ... | ... | @@ -24,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); |
| 24 | 25 | const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) => |
| 25 | 26 | effect.pipe(Effect.provide(liveLayer)); |
| 26 | 27 | |
| 28 | const baseInput = { | |
| 29 | operation: "test.process-boundary", | |
| 30 | command: "git", | |
| 31 | args: ["status", "--short"], | |
| 32 | cwd: "/workspace", | |
| 33 | } satisfies VcsProcess.VcsProcessInput; | |
| 34 | ||
| 35 | const captureProcessResult = ( | |
| 36 | result: Effect.Effect<ProcessRunner.ProcessRunOutput, ProcessRunner.ProcessRunError>, | |
| 37 | ) => | |
| 38 | VcsProcess.make.pipe( | |
| 39 | Effect.provideService( | |
| 40 | ProcessRunner.ProcessRunner, | |
| 41 | ProcessRunner.ProcessRunner.of({ run: () => result }), | |
| 42 | ), | |
| 43 | Effect.flatMap((service) => service.run(baseInput)), | |
| 44 | Effect.flip, | |
| 45 | ); | |
| 46 | ||
| 27 | 47 | describe("VcsProcess.run", () => { |
| 28 | 48 | it.effect("collects stdout", () => |
| 29 | 49 | Effect.gen(function* () { |
| ... | ... | @@ -141,6 +161,51 @@ describe("VcsProcess.run", () => { |
| 141 | 161 | }).pipe(provideLive), |
| 142 | 162 | ); |
| 143 | 163 | |
| 164 | it.effect("preserves real boundary causes without manufacturing structural ones", () => | |
| 165 | Effect.gen(function* () { | |
| 166 | const cause = new Error("secret stdin failure"); | |
| 167 | const error = yield* captureProcessResult( | |
| 168 | Effect.fail( | |
| 169 | new ProcessRunner.ProcessStdinError({ | |
| 170 | command: baseInput.command, | |
| 171 | argumentCount: baseInput.args.length, | |
| 172 | cwd: baseInput.cwd, | |
| 173 | stdinBytes: 47, | |
| 174 | cause, | |
| 175 | }), | |
| 176 | ), | |
| 177 | ); | |
| 178 | ||
| 179 | expect(error).toMatchObject({ | |
| 180 | _tag: "VcsProcessStdinWriteError", | |
| 181 | operation: baseInput.operation, | |
| 182 | stdinBytes: 47, | |
| 183 | cause, | |
| 184 | }); | |
| 185 | expect(error.message).not.toContain(cause.message); | |
| 186 | ||
| 187 | const missingExitCodeError = yield* captureProcessResult( | |
| 188 | Effect.succeed({ | |
| 189 | stdout: "", | |
| 190 | stderr: "", | |
| 191 | code: null, | |
| 192 | timedOut: false, | |
| 193 | stdoutTruncated: false, | |
| 194 | stderrTruncated: false, | |
| 195 | }), | |
| 196 | ); | |
| 197 | ||
| 198 | expect(missingExitCodeError).toMatchObject({ | |
| 199 | _tag: "VcsProcessMissingExitCodeError", | |
| 200 | operation: baseInput.operation, | |
| 201 | command: baseInput.command, | |
| 202 | cwd: baseInput.cwd, | |
| 203 | argumentCount: baseInput.args.length, | |
| 204 | }); | |
| 205 | expect(missingExitCodeError).not.toHaveProperty("cause"); | |
| 206 | }), | |
| 207 | ); | |
| 208 | ||
| 144 | 209 | it.effect("returns output when non-zero exits are allowed", () => |
| 145 | 210 | Effect.gen(function* () { |
| 146 | 211 | const result = yield* run({ |
apps/server/src/vcs/VcsProcess.ts+21-5| ... | ... | @@ -5,11 +5,14 @@ import * as Match from "effect/Match"; |
| 5 | 5 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | VcsOutputDecodeError, | |
| 9 | 8 | type VcsError, |
| 10 | 9 | VcsProcessExitError, |
| 11 | 10 | type VcsProcessExitFailureKind, |
| 11 | VcsProcessMissingExitCodeError, | |
| 12 | VcsProcessOutputLimitError, | |
| 13 | VcsProcessOutputReadError, | |
| 12 | 14 | VcsProcessSpawnError, |
| 15 | VcsProcessStdinWriteError, | |
| 13 | 16 | VcsProcessTimeoutError, |
| 14 | 17 | } from "@t3tools/contracts"; |
| 15 | 18 | import * as ProcessRunner from "../processRunner.ts"; |
| ... | ... | @@ -114,19 +117,32 @@ export const make = Effect.gen(function* () { |
| 114 | 117 | ProcessSpawnError: (error) => |
| 115 | 118 | VcsProcessSpawnError.fromProcessSpawnError(baseError, error), |
| 116 | 119 | ProcessOutputLimitError: (error) => |
| 117 | VcsOutputDecodeError.fromProcessOutputLimitError(baseError, error), | |
| 120 | new VcsProcessOutputLimitError({ | |
| 121 | ...baseError, | |
| 122 | stream: error.stream, | |
| 123 | maxBytes: error.maxBytes, | |
| 124 | observedBytes: error.observedBytes, | |
| 125 | }), | |
| 118 | 126 | ProcessTimeoutError: (error) => |
| 119 | 127 | VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), |
| 120 | 128 | ProcessStdinError: (error) => |
| 121 | VcsOutputDecodeError.fromProcessStdinError(baseError, error), | |
| 129 | new VcsProcessStdinWriteError({ | |
| 130 | ...baseError, | |
| 131 | stdinBytes: error.stdinBytes, | |
| 132 | cause: error.cause, | |
| 133 | }), | |
| 122 | 134 | ProcessReadError: (error) => |
| 123 | VcsOutputDecodeError.fromProcessReadError(baseError, error), | |
| 135 | new VcsProcessOutputReadError({ | |
| 136 | ...baseError, | |
| 137 | stream: error.stream, | |
| 138 | cause: error.cause, | |
| 139 | }), | |
| 124 | 140 | }), |
| 125 | 141 | ), |
| 126 | 142 | ); |
| 127 | 143 | |
| 128 | 144 | if (result.code === null) { |
| 129 | return yield* VcsOutputDecodeError.missingExitCode(baseError); | |
| 145 | return yield* new VcsProcessMissingExitCodeError(baseError); | |
| 130 | 146 | } |
| 131 | 147 | |
| 132 | 148 | if (!input.allowNonZeroExit && result.code !== 0) { |
packages/contracts/src/vcs.ts+55-54| ... | ... | @@ -69,20 +69,6 @@ export interface VcsProcessSpawnFailure { |
| 69 | 69 | readonly cause: unknown; |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | export interface VcsProcessStdinFailure { | |
| 73 | readonly cause: unknown; | |
| 74 | } | |
| 75 | ||
| 76 | export interface VcsProcessReadFailure { | |
| 77 | readonly stream: "stdout" | "stderr" | "exitCode"; | |
| 78 | readonly cause: unknown; | |
| 79 | } | |
| 80 | ||
| 81 | export interface VcsProcessOutputLimitFailure { | |
| 82 | readonly stream: "stdout" | "stderr"; | |
| 83 | readonly maxBytes: number; | |
| 84 | } | |
| 85 | ||
| 86 | 72 | export interface VcsProcessTimeoutFailure { |
| 87 | 73 | readonly timeoutMs: number; |
| 88 | 74 | } |
| ... | ... | @@ -189,58 +175,70 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTi |
| 189 | 175 | } |
| 190 | 176 | } |
| 191 | 177 | |
| 192 | export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()( | |
| 193 | "VcsOutputDecodeError", | |
| 178 | const VcsProcessBoundaryErrorFields = { | |
| 179 | operation: Schema.String, | |
| 180 | command: Schema.String, | |
| 181 | cwd: Schema.String, | |
| 182 | argumentCount: Schema.optional(NonNegativeInt), | |
| 183 | }; | |
| 184 | ||
| 185 | export class VcsProcessStdinWriteError extends Schema.TaggedErrorClass<VcsProcessStdinWriteError>()( | |
| 186 | "VcsProcessStdinWriteError", | |
| 194 | 187 | { |
| 195 | operation: Schema.String, | |
| 196 | command: Schema.String, | |
| 197 | cwd: Schema.String, | |
| 198 | argumentCount: Schema.optional(NonNegativeInt), | |
| 199 | detail: Schema.String, | |
| 200 | cause: Schema.optional(Schema.Defect()), | |
| 188 | ...VcsProcessBoundaryErrorFields, | |
| 189 | stdinBytes: NonNegativeInt, | |
| 190 | cause: Schema.Defect(), | |
| 201 | 191 | }, |
| 202 | 192 | ) { |
| 203 | 193 | override get message(): string { |
| 204 | return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | |
| 205 | } | |
| 206 | ||
| 207 | static fromProcessStdinError(context: VcsProcessErrorContext, error: VcsProcessStdinFailure) { | |
| 208 | return new VcsOutputDecodeError({ | |
| 209 | ...context, | |
| 210 | detail: "failed to write process stdin", | |
| 211 | cause: error.cause, | |
| 212 | }); | |
| 194 | return `VCS process failed to write ${this.stdinBytes} bytes to stdin in ${this.operation}: ${this.command} (${this.cwd})`; | |
| 213 | 195 | } |
| 196 | } | |
| 214 | 197 | |
| 215 | static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { | |
| 216 | return new VcsOutputDecodeError({ | |
| 217 | ...context, | |
| 218 | detail: | |
| 219 | error.stream === "exitCode" | |
| 220 | ? "failed to read process exit code" | |
| 221 | : `failed to read process ${error.stream}`, | |
| 222 | cause: error.cause, | |
| 223 | }); | |
| 198 | export class VcsProcessOutputReadError extends Schema.TaggedErrorClass<VcsProcessOutputReadError>()( | |
| 199 | "VcsProcessOutputReadError", | |
| 200 | { | |
| 201 | ...VcsProcessBoundaryErrorFields, | |
| 202 | stream: Schema.Literals(["stdout", "stderr", "exitCode"]), | |
| 203 | cause: Schema.Defect(), | |
| 204 | }, | |
| 205 | ) { | |
| 206 | override get message(): string { | |
| 207 | return `VCS process failed to read ${this.stream} in ${this.operation}: ${this.command} (${this.cwd})`; | |
| 224 | 208 | } |
| 209 | } | |
| 225 | 210 | |
| 226 | static fromProcessOutputLimitError( | |
| 227 | context: VcsProcessErrorContext, | |
| 228 | error: VcsProcessOutputLimitFailure, | |
| 229 | ) { | |
| 230 | return new VcsOutputDecodeError({ | |
| 231 | ...context, | |
| 232 | detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, | |
| 233 | }); | |
| 211 | export class VcsProcessOutputLimitError extends Schema.TaggedErrorClass<VcsProcessOutputLimitError>()( | |
| 212 | "VcsProcessOutputLimitError", | |
| 213 | { | |
| 214 | ...VcsProcessBoundaryErrorFields, | |
| 215 | stream: Schema.Literals(["stdout", "stderr"]), | |
| 216 | maxBytes: NonNegativeInt, | |
| 217 | observedBytes: NonNegativeInt, | |
| 218 | }, | |
| 219 | ) { | |
| 220 | override get message(): string { | |
| 221 | return `VCS process ${this.stream} produced ${this.observedBytes} bytes in ${this.operation}: ${this.command} (${this.cwd}), exceeding the ${this.maxBytes} byte limit`; | |
| 234 | 222 | } |
| 223 | } | |
| 235 | 224 | |
| 236 | static missingExitCode(context: VcsProcessErrorContext) { | |
| 237 | return new VcsOutputDecodeError({ | |
| 238 | ...context, | |
| 239 | detail: "process completed without an exit code", | |
| 240 | }); | |
| 225 | export class VcsProcessMissingExitCodeError extends Schema.TaggedErrorClass<VcsProcessMissingExitCodeError>()( | |
| 226 | "VcsProcessMissingExitCodeError", | |
| 227 | VcsProcessBoundaryErrorFields, | |
| 228 | ) { | |
| 229 | override get message(): string { | |
| 230 | return `VCS process completed without an exit code in ${this.operation}: ${this.command} (${this.cwd})`; | |
| 241 | 231 | } |
| 242 | 232 | } |
| 243 | 233 | |
| 234 | export const VcsOutputDecodeError = Schema.Union([ | |
| 235 | VcsProcessStdinWriteError, | |
| 236 | VcsProcessOutputReadError, | |
| 237 | VcsProcessOutputLimitError, | |
| 238 | VcsProcessMissingExitCodeError, | |
| 239 | ]); | |
| 240 | export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type; | |
| 241 | ||
| 244 | 242 | export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()( |
| 245 | 243 | "VcsRepositoryDetectionError", |
| 246 | 244 | { |
| ... | ... | @@ -272,7 +270,10 @@ export const VcsError = Schema.Union([ |
| 272 | 270 | VcsProcessSpawnError, |
| 273 | 271 | VcsProcessExitError, |
| 274 | 272 | VcsProcessTimeoutError, |
| 275 | VcsOutputDecodeError, | |
| 273 | VcsProcessStdinWriteError, | |
| 274 | VcsProcessOutputReadError, | |
| 275 | VcsProcessOutputLimitError, | |
| 276 | VcsProcessMissingExitCodeError, | |
| 276 | 277 | VcsRepositoryDetectionError, |
| 277 | 278 | VcsUnsupportedOperationError, |
| 278 | 279 | ]); |