authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 20:19:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 20:19:59-07:00
log3c246f56b477e0d7c39f92cfc0a69ca3f6debd4b
tree21a499f938473d631a282cb3a745ce34fc04a54d
parentcebbe6ff193c9598bf77c4f673a933db34ed7bcb
signature Signed by PGP key B5690EEEBB952194

[codex] Structure VCS process boundary errors (#3476)

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";
14import * as ProcessRunner from "../processRunner.ts";
14import * as VcsProcess from "./VcsProcess.ts";15import * as VcsProcess from "./VcsProcess.ts";
1516
16const run = (input: VcsProcess.VcsProcessInput) =>17const 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));
24const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) =>25const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) =>
25 effect.pipe(Effect.provide(liveLayer));26 effect.pipe(Effect.provide(liveLayer));
2627
28const baseInput = {
29 operation: "test.process-boundary",
30 command: "git",
31 args: ["status", "--short"],
32 cwd: "/workspace",
33} satisfies VcsProcess.VcsProcessInput;
34
35const 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
27describe("VcsProcess.run", () => {47describe("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 );
143163
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";
5import { ChildProcessSpawner } from "effect/unstable/process";5import { ChildProcessSpawner } from "effect/unstable/process";
66
7import {7import {
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";
15import * as ProcessRunner from "../processRunner.ts";18import * 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 );
127143
128 if (result.code === null) {144 if (result.code === null) {
129 return yield* VcsOutputDecodeError.missingExitCode(baseError);145 return yield* new VcsProcessMissingExitCodeError(baseError);
130 }146 }
131147
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}
7171
72export interface VcsProcessStdinFailure {
73 readonly cause: unknown;
74}
75
76export interface VcsProcessReadFailure {
77 readonly stream: "stdout" | "stderr" | "exitCode";
78 readonly cause: unknown;
79}
80
81export interface VcsProcessOutputLimitFailure {
82 readonly stream: "stdout" | "stderr";
83 readonly maxBytes: number;
84}
85
86export interface VcsProcessTimeoutFailure {72export 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}
191177
192export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()(178const VcsProcessBoundaryErrorFields = {
193 "VcsOutputDecodeError",179 operation: Schema.String,
180 command: Schema.String,
181 cwd: Schema.String,
182 argumentCount: Schema.optional(NonNegativeInt),
183};
184
185export 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}
214197
215 static fromProcessReadError(context: VcsProcessErrorContext, error: VcsProcessReadFailure) {198export 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}
225210
226 static fromProcessOutputLimitError(211export 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}
235224
236 static missingExitCode(context: VcsProcessErrorContext) {225export 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}
243233
234export const VcsOutputDecodeError = Schema.Union([
235 VcsProcessStdinWriteError,
236 VcsProcessOutputReadError,
237 VcsProcessOutputLimitError,
238 VcsProcessMissingExitCodeError,
239]);
240export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type;
241
244export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()(242export 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]);