| 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,7 +133,10 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass<GitLabCliComm |
| 133 | } | 133 | } |
| 134 | }, | 134 | }, |
| 135 | VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), | 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 | VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), | 140 | VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), |
| 138 | VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), | 141 | VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), |
| 139 | }); | 142 | }); |
apps/server/src/vcs/VcsProcess.test.ts+65| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | VcsProcessSpawnError, | 11 | VcsProcessSpawnError, |
| 12 | VcsProcessTimeoutError, | 12 | VcsProcessTimeoutError, |
| 13 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 14 | import * as VcsProcess from "./VcsProcess.ts"; | 15 | import * as VcsProcess from "./VcsProcess.ts"; |
| 15 | 16 | ||
| 16 | const run = (input: VcsProcess.VcsProcessInput) => | 17 | const run = (input: VcsProcess.VcsProcessInput) => |
| ... | @@ -24,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); | ... | @@ -24,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); |
| 24 | const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) => | 25 | const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) => |
| 25 | effect.pipe(Effect.provide(liveLayer)); | 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 | describe("VcsProcess.run", () => { | 47 | describe("VcsProcess.run", () => { |
| 28 | it.effect("collects stdout", () => | 48 | it.effect("collects stdout", () => |
| 29 | Effect.gen(function* () { | 49 | Effect.gen(function* () { |
| ... | @@ -141,6 +161,51 @@ describe("VcsProcess.run", () => { | ... | @@ -141,6 +161,51 @@ describe("VcsProcess.run", () => { |
| 141 | }).pipe(provideLive), | 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 | it.effect("returns output when non-zero exits are allowed", () => | 209 | it.effect("returns output when non-zero exits are allowed", () => |
| 145 | Effect.gen(function* () { | 210 | Effect.gen(function* () { |
| 146 | const result = yield* run({ | 211 | const result = yield* run({ |
apps/server/src/vcs/VcsProcess.ts+21-5| ... | @@ -5,11 +5,14 @@ import * as Match from "effect/Match"; | ... | @@ -5,11 +5,14 @@ import * as Match from "effect/Match"; |
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | 5 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | VcsOutputDecodeError, | ||
| 9 | type VcsError, | 8 | type VcsError, |
| 10 | VcsProcessExitError, | 9 | VcsProcessExitError, |
| 11 | type VcsProcessExitFailureKind, | 10 | type VcsProcessExitFailureKind, |
| 11 | VcsProcessMissingExitCodeError, | ||
| 12 | VcsProcessOutputLimitError, | ||
| 13 | VcsProcessOutputReadError, | ||
| 12 | VcsProcessSpawnError, | 14 | VcsProcessSpawnError, |
| 15 | VcsProcessStdinWriteError, | ||
| 13 | VcsProcessTimeoutError, | 16 | VcsProcessTimeoutError, |
| 14 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 15 | import * as ProcessRunner from "../processRunner.ts"; | 18 | import * as ProcessRunner from "../processRunner.ts"; |
| ... | @@ -114,19 +117,32 @@ export const make = Effect.gen(function* () { | ... | @@ -114,19 +117,32 @@ export const make = Effect.gen(function* () { |
| 114 | ProcessSpawnError: (error) => | 117 | ProcessSpawnError: (error) => |
| 115 | VcsProcessSpawnError.fromProcessSpawnError(baseError, error), | 118 | VcsProcessSpawnError.fromProcessSpawnError(baseError, error), |
| 116 | ProcessOutputLimitError: (error) => | 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 | ProcessTimeoutError: (error) => | 126 | ProcessTimeoutError: (error) => |
| 119 | VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), | 127 | VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), |
| 120 | ProcessStdinError: (error) => | 128 | ProcessStdinError: (error) => |
| 121 | VcsOutputDecodeError.fromProcessStdinError(baseError, error), | 129 | new VcsProcessStdinWriteError({ |
| 130 | ...baseError, | ||
| 131 | stdinBytes: error.stdinBytes, | ||
| 132 | cause: error.cause, | ||
| 133 | }), | ||
| 122 | ProcessReadError: (error) => | 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 | if (result.code === null) { | 144 | if (result.code === null) { |
| 129 | return yield* VcsOutputDecodeError.missingExitCode(baseError); | 145 | return yield* new VcsProcessMissingExitCodeError(baseError); |
| 130 | } | 146 | } |
| 131 | 147 | ||
| 132 | if (!input.allowNonZeroExit && result.code !== 0) { | 148 | if (!input.allowNonZeroExit && result.code !== 0) { |
packages/contracts/src/vcs.ts+55-54| ... | @@ -69,20 +69,6 @@ export interface VcsProcessSpawnFailure { | ... | @@ -69,20 +69,6 @@ export interface VcsProcessSpawnFailure { |
| 69 | readonly cause: unknown; | 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 | export interface VcsProcessTimeoutFailure { | 72 | export interface VcsProcessTimeoutFailure { |
| 87 | readonly timeoutMs: number; | 73 | readonly timeoutMs: number; |
| 88 | } | 74 | } |
| ... | @@ -189,58 +175,70 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTi | ... | @@ -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>()( | 178 | const VcsProcessBoundaryErrorFields = { |
| 193 | "VcsOutputDecodeError", | 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, | 188 | ...VcsProcessBoundaryErrorFields, |
| 196 | command: Schema.String, | 189 | stdinBytes: NonNegativeInt, |
| 197 | cwd: Schema.String, | 190 | cause: Schema.Defect(), |
| 198 | argumentCount: Schema.optional(NonNegativeInt), | ||
| 199 | detail: Schema.String, | ||
| 200 | cause: Schema.optional(Schema.Defect()), | ||
| 201 | }, | 191 | }, |
| 202 | ) { | 192 | ) { |
| 203 | override get message(): string { | 193 | override get message(): string { |
| 204 | return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | 194 | return `VCS process failed to write ${this.stdinBytes} bytes to stdin in ${this.operation}: ${this.command} (${this.cwd})`; |
| 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 | }); | ||
| 213 | } | 195 | } |
| 196 | } | ||
| 214 | 197 | ||
| 215 | static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) { | 198 | export class VcsProcessOutputReadError extends Schema.TaggedErrorClass<VcsProcessOutputReadError>()( |
| 216 | return new VcsOutputDecodeError({ | 199 | "VcsProcessOutputReadError", |
| 217 | ...context, | 200 | { |
| 218 | detail: | 201 | ...VcsProcessBoundaryErrorFields, |
| 219 | error.stream === "exitCode" | 202 | stream: Schema.Literals(["stdout", "stderr", "exitCode"]), |
| 220 | ? "failed to read process exit code" | 203 | cause: Schema.Defect(), |
| 221 | : `failed to read process ${error.stream}`, | 204 | }, |
| 222 | cause: error.cause, | 205 | ) { |
| 223 | }); | 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( | 211 | export class VcsProcessOutputLimitError extends Schema.TaggedErrorClass<VcsProcessOutputLimitError>()( |
| 227 | context: VcsProcessErrorContext, | 212 | "VcsProcessOutputLimitError", |
| 228 | error: VcsProcessOutputLimitFailure, | 213 | { |
| 229 | ) { | 214 | ...VcsProcessBoundaryErrorFields, |
| 230 | return new VcsOutputDecodeError({ | 215 | stream: Schema.Literals(["stdout", "stderr"]), |
| 231 | ...context, | 216 | maxBytes: NonNegativeInt, |
| 232 | detail: `process ${error.stream} exceeded ${error.maxBytes} bytes`, | 217 | observedBytes: NonNegativeInt, |
| 233 | }); | 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) { | 225 | export class VcsProcessMissingExitCodeError extends Schema.TaggedErrorClass<VcsProcessMissingExitCodeError>()( |
| 237 | return new VcsOutputDecodeError({ | 226 | "VcsProcessMissingExitCodeError", |
| 238 | ...context, | 227 | VcsProcessBoundaryErrorFields, |
| 239 | detail: "process completed without an exit code", | 228 | ) { |
| 240 | }); | 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 | export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()( | 242 | export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()( |
| 245 | "VcsRepositoryDetectionError", | 243 | "VcsRepositoryDetectionError", |
| 246 | { | 244 | { |
| ... | @@ -272,7 +270,10 @@ export const VcsError = Schema.Union([ | ... | @@ -272,7 +270,10 @@ export const VcsError = Schema.Union([ |
| 272 | VcsProcessSpawnError, | 270 | VcsProcessSpawnError, |
| 273 | VcsProcessExitError, | 271 | VcsProcessExitError, |
| 274 | VcsProcessTimeoutError, | 272 | VcsProcessTimeoutError, |
| 275 | VcsOutputDecodeError, | 273 | VcsProcessStdinWriteError, |
| 274 | VcsProcessOutputReadError, | ||
| 275 | VcsProcessOutputLimitError, | ||
| 276 | VcsProcessMissingExitCodeError, | ||
| 276 | VcsRepositoryDetectionError, | 277 | VcsRepositoryDetectionError, |
| 277 | VcsUnsupportedOperationError, | 278 | VcsUnsupportedOperationError, |
| 278 | ]); | 279 | ]); |