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
133133 }
134134 },
135135 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 }),
137140 VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }),
138141 VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }),
139142 });
apps/server/src/vcs/VcsProcess.test.ts+65
......@@ -11,6 +11,7 @@ import {
1111 VcsProcessSpawnError,
1212 VcsProcessTimeoutError,
1313} from "@t3tools/contracts";
14import * as ProcessRunner from "../processRunner.ts";
1415import * as VcsProcess from "./VcsProcess.ts";
1516
1617const run = (input: VcsProcess.VcsProcessInput) =>
......@@ -24,6 +25,25 @@ const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer));
2425const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) =>
2526 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
2747describe("VcsProcess.run", () => {
2848 it.effect("collects stdout", () =>
2949 Effect.gen(function* () {
......@@ -141,6 +161,51 @@ describe("VcsProcess.run", () => {
141161 }).pipe(provideLive),
142162 );
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
144209 it.effect("returns output when non-zero exits are allowed", () =>
145210 Effect.gen(function* () {
146211 const result = yield* run({
apps/server/src/vcs/VcsProcess.ts+21-5
......@@ -5,11 +5,14 @@ import * as Match from "effect/Match";
55import { ChildProcessSpawner } from "effect/unstable/process";
66
77import {
8 VcsOutputDecodeError,
98 type VcsError,
109 VcsProcessExitError,
1110 type VcsProcessExitFailureKind,
11 VcsProcessMissingExitCodeError,
12 VcsProcessOutputLimitError,
13 VcsProcessOutputReadError,
1214 VcsProcessSpawnError,
15 VcsProcessStdinWriteError,
1316 VcsProcessTimeoutError,
1417} from "@t3tools/contracts";
1518import * as ProcessRunner from "../processRunner.ts";
......@@ -114,19 +117,32 @@ export const make = Effect.gen(function* () {
114117 ProcessSpawnError: (error) =>
115118 VcsProcessSpawnError.fromProcessSpawnError(baseError, error),
116119 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 }),
118126 ProcessTimeoutError: (error) =>
119127 VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error),
120128 ProcessStdinError: (error) =>
121 VcsOutputDecodeError.fromProcessStdinError(baseError, error),
129 new VcsProcessStdinWriteError({
130 ...baseError,
131 stdinBytes: error.stdinBytes,
132 cause: error.cause,
133 }),
122134 ProcessReadError: (error) =>
123 VcsOutputDecodeError.fromProcessReadError(baseError, error),
135 new VcsProcessOutputReadError({
136 ...baseError,
137 stream: error.stream,
138 cause: error.cause,
139 }),
124140 }),
125141 ),
126142 );
127143
128144 if (result.code === null) {
129 return yield* VcsOutputDecodeError.missingExitCode(baseError);
145 return yield* new VcsProcessMissingExitCodeError(baseError);
130146 }
131147
132148 if (!input.allowNonZeroExit && result.code !== 0) {
packages/contracts/src/vcs.ts+55-54
......@@ -69,20 +69,6 @@ export interface VcsProcessSpawnFailure {
6969 readonly cause: unknown;
7070}
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
8672export interface VcsProcessTimeoutFailure {
8773 readonly timeoutMs: number;
8874}
......@@ -189,58 +175,70 @@ export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTi
189175 }
190176}
191177
192export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()(
193 "VcsOutputDecodeError",
178const VcsProcessBoundaryErrorFields = {
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",
194187 {
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(),
201191 },
202192) {
203193 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})`;
213195 }
196}
214197
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 });
198export 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})`;
224208 }
209}
225210
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 });
211export 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`;
234222 }
223}
235224
236 static missingExitCode(context: VcsProcessErrorContext) {
237 return new VcsOutputDecodeError({
238 ...context,
239 detail: "process completed without an exit code",
240 });
225export 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})`;
241231 }
242232}
243233
234export const VcsOutputDecodeError = Schema.Union([
235 VcsProcessStdinWriteError,
236 VcsProcessOutputReadError,
237 VcsProcessOutputLimitError,
238 VcsProcessMissingExitCodeError,
239]);
240export type VcsOutputDecodeError = typeof VcsOutputDecodeError.Type;
241
244242export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()(
245243 "VcsRepositoryDetectionError",
246244 {
......@@ -272,7 +270,10 @@ export const VcsError = Schema.Union([
272270 VcsProcessSpawnError,
273271 VcsProcessExitError,
274272 VcsProcessTimeoutError,
275 VcsOutputDecodeError,
273 VcsProcessStdinWriteError,
274 VcsProcessOutputReadError,
275 VcsProcessOutputLimitError,
276 VcsProcessMissingExitCodeError,
276277 VcsRepositoryDetectionError,
277278 VcsUnsupportedOperationError,
278279]);