1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Duration from "effect/Duration";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8import * as Cause from "effect/Cause";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Queue from "effect/Queue";
12import * as Ref from "effect/Ref";
13import * as Schema from "effect/Schema";
14import { TestClock } from "effect/testing";
15import { ChildProcessSpawner } from "effect/process";
16
17import {
18 VcsProcessExitError,
19 VcsProcessSpawnError,
20 VcsProcessTimeoutError,
21} from "@t3tools/contracts";
22import * as ProcessRunner from "../processRunner.ts";
23import * as VcsProcess from "./VcsProcess.ts";
24
25const encodeExitError = Schema.encodeEffect(Schema.fromJsonString(VcsProcessExitError));
26
27const run = (input: VcsProcess.VcsProcessInput) =>
28 Effect.gen(function* () {
29 const process = yield* VcsProcess.VcsProcess;
30 return yield* process.run(input);
31 });
32
33const layerLive = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer));
34
35const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) =>
36 effect.pipe(Effect.provide(layerLive));
37
38const baseInput = {
39 operation: "test.process-boundary",
40 command: "git",
41 args: ["status", "--short"],
42 cwd: "/workspace",
43} satisfies VcsProcess.VcsProcessInput;
44
45const captureProcessResult = (
46 result: Effect.Effect<ProcessRunner.ProcessRunOutput, ProcessRunner.ProcessRunError>,
47) =>
48 VcsProcess.make.pipe(
49 Effect.provideService(
50 ProcessRunner.ProcessRunner,
51 ProcessRunner.ProcessRunner.of({ run: () => result }),
52 ),
53 Effect.flatMap((service) => service.run(baseInput)),
54 Effect.flip,
55 );
56
57describe("VcsProcess.run", () => {
58 it.effect.each([
59 { stderr: "fatal: Unable to create '/private/repo/index.lock': File exists", retryable: true },
60 {
61 stderr: "fatal: Unable to create '/private/repo/index.lock': Permission denied",
62 retryable: false,
63 },
64 { stderr: 'error: open("/private/repo/file"): No such file or directory', retryable: true },
65 {
66 stderr: "fatal: unable to stat '/private/repo/file': No such file or directory",
67 retryable: true,
68 },
69 { stderr: 'error: open("/private/repo/file"): Permission denied', retryable: false },
70 {
71 stderr: "error: '/private/repo/child/' does not have a commit checked out",
72 retryable: false,
73 },
74 { stderr: "fatal: index file corrupt", retryable: false },
75 ])(
76 "classifies Git exit retryability without retaining stderr: $stderr",
77 ({ stderr, retryable }) =>
78 Effect.gen(function* () {
79 const error = yield* captureProcessResult(
80 Effect.succeed({
81 stdout: "",
82 stderr,
83 code: ChildProcessSpawner.ExitCode(128),
84 timedOut: false,
85 stdoutTruncated: false,
86 stderrTruncated: false,
87 stdoutInvalidUtf8: false,
88 stderrInvalidUtf8: false,
89 }),
90 );
91 assert.instanceOf(error, VcsProcessExitError);
92 expect(error.retryable === true).toBe(retryable);
93 const encoded = yield* encodeExitError(error);
94 expect(encoded).not.toContain("/private/repo");
95 }),
96 );
97
98 it.effect.each(["timeout", "spawn"] as const)(
99 "checkpoint commands do not retry %s failures",
100 (kind) =>
101 Effect.gen(function* () {
102 let attempts = 0;
103 const fields = { command: "git", argumentCount: 2, cwd: "/workspace" };
104 const failure =
105 kind === "timeout"
106 ? new ProcessRunner.ProcessTimeoutError({ ...fields, timeoutMs: 30_000 })
107 : new ProcessRunner.ProcessSpawnError({ ...fields, cause: new Error("spawn failed") });
108 const service = yield* VcsProcess.make.pipe(
109 Effect.provideService(ProcessRunner.ProcessRunner, {
110 run: () =>
111 Effect.sync(() => {
112 attempts += 1;
113 }).pipe(Effect.andThen(Effect.fail(failure))),
114 }),
115 );
116 const error = yield* service
117 .run({
118 ...baseInput,
119 operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION,
120 })
121 .pipe(Effect.flip);
122 expect(attempts).toBe(1);
123 expect(error._tag).toBe(
124 kind === "timeout" ? "VcsProcessTimeoutError" : "VcsProcessSpawnError",
125 );
126 }),
127 );
128
129 it.effect.each([
130 {
131 name: "recovers after two lock failures",
132 failures: 2,
133 attempts: 3,
134 transient: true,
135 capture: true,
136 streaming: false,
137 },
138 {
139 name: "bounds persistent lock failures",
140 failures: Infinity,
141 attempts: 3,
142 transient: true,
143 capture: true,
144 streaming: false,
145 },
146 {
147 name: "does not retry unknown exits",
148 failures: Infinity,
149 attempts: 1,
150 transient: false,
151 capture: true,
152 streaming: false,
153 },
154 {
155 name: "leaves other operations unchanged",
156 failures: Infinity,
157 attempts: 1,
158 transient: true,
159 capture: false,
160 streaming: false,
161 },
162 {
163 name: "does not replay stdout callbacks",
164 failures: Infinity,
165 attempts: 1,
166 transient: true,
167 capture: true,
168 streaming: true,
169 },
170 ])(
171 "checkpoint command retry $name",
172 ({ failures, attempts: expectedAttempts, transient, capture, streaming }) =>
173 Effect.gen(function* () {
174 let attempts = 0;
175 const service = yield* VcsProcess.make.pipe(
176 Effect.provideService(ProcessRunner.ProcessRunner, {
177 run: () =>
178 Effect.sync(() => {
179 attempts += 1;
180 return {
181 stdout: "",
182 stderr:
183 attempts > failures
184 ? ""
185 : transient
186 ? "fatal: Unable to create '/repo/index.lock': File exists"
187 : "fatal: index file corrupt",
188 code: ChildProcessSpawner.ExitCode(attempts > failures ? 0 : attempts),
189 timedOut: false,
190 stdoutTruncated: false,
191 stderrTruncated: false,
192 stdoutInvalidUtf8: false,
193 stderrInvalidUtf8: false,
194 };
195 }),
196 }),
197 );
198 const fiber = yield* service
199 .run({
200 ...baseInput,
201 operation: capture ? VcsProcess.CHECKPOINT_CAPTURE_OPERATION : baseInput.operation,
202 ...(streaming ? { onStdoutChunk: () => {} } : {}),
203 })
204 .pipe(Effect.exit, Effect.forkScoped);
205 yield* TestClock.adjust("150 millis");
206 const result = yield* Fiber.join(fiber);
207 expect(attempts).toBe(expectedAttempts);
208 if (failures === Infinity) {
209 if (Exit.isSuccess(result))
210 return yield* Effect.die("Expected a capture command failure");
211 const error = Cause.findErrorOption(result.cause);
212 if (error._tag === "None" || error.value._tag !== "VcsProcessExitError") {
213 return yield* Effect.die("Expected a Git exit error");
214 }
215 expect(error.value.exitCode).toBe(expectedAttempts);
216 } else {
217 expect(Exit.isSuccess(result)).toBe(true);
218 }
219 }),
220 );
221
222 it.effect("bounds a synthetic burst of GitHub API processes", () =>
223 Effect.gen(function* () {
224 const gate = yield* Deferred.make<void>();
225 const starts = yield* Queue.unbounded<number>();
226 const active = yield* Ref.make(0);
227 const peak = yield* Ref.make(0);
228 const total = yield* Ref.make(0);
229 const service = yield* VcsProcess.make.pipe(
230 Effect.provideService(
231 ProcessRunner.ProcessRunner,
232 ProcessRunner.ProcessRunner.of({
233 run: () =>
234 Effect.gen(function* () {
235 const count = yield* Ref.updateAndGet(active, (held) => held + 1);
236 yield* Ref.update(peak, (held) => Math.max(held, count));
237 yield* Ref.update(total, (held) => held + 1);
238 yield* Queue.offer(starts, count);
239 yield* Deferred.await(gate);
240 return {
241 stdout: "",
242 stderr: "",
243 code: ChildProcessSpawner.ExitCode(0),
244 timedOut: false,
245 stdoutTruncated: false,
246 stderrTruncated: false,
247 stdoutInvalidUtf8: false,
248 stderrInvalidUtf8: false,
249 };
250 }).pipe(Effect.ensuring(Ref.update(active, (count) => count - 1))),
251 }),
252 ),
253 );
254
255 const burst = yield* Effect.all(
256 Array.from({ length: 32 }, (_, index) =>
257 service.run({
258 operation: `synthetic.github.${index}`,
259 command: "gh",
260 args: ["api", "user"],
261 cwd: "/workspace",
262 }),
263 ),
264 { concurrency: "unbounded" },
265 ).pipe(Effect.forkChild);
266
267 yield* Effect.all(Array.from({ length: 4 }, () => Queue.take(starts)));
268 yield* Effect.yieldNow;
269 expect(yield* Queue.size(starts)).toBe(0);
270 expect(yield* Ref.get(peak)).toBe(4);
271
272 yield* Deferred.succeed(gate, undefined);
273 yield* Fiber.join(burst);
274 expect(yield* Ref.get(total)).toBe(32);
275 expect(yield* Ref.get(peak)).toBe(4);
276 }),
277 );
278
279 it.effect("collects stdout", () =>
280 Effect.gen(function* () {
281 const result = yield* run({
282 operation: "test.stdout",
283 command: "node",
284 args: ["-e", "process.stdout.write('hello')"],
285 cwd: process.cwd(),
286 });
287
288 expect(result.stdout).toBe("hello");
289 expect(result.stderr).toBe("");
290 expect(result.stdoutTruncated).toBe(false);
291 expect(result.stderrTruncated).toBe(false);
292 }).pipe(provideLive),
293 );
294
295 it.effect("writes stdin before waiting for exit", () =>
296 Effect.gen(function* () {
297 const result = yield* run({
298 operation: "test.stdin",
299 command: "node",
300 args: [
301 "-e",
302 [
303 "process.stdin.setEncoding('utf8');",
304 "let data='';",
305 "process.stdin.on('data', chunk => { data += chunk; });",
306 "process.stdin.on('end', () => { process.stdout.write(data); });",
307 ].join(""),
308 ],
309 cwd: process.cwd(),
310 stdin: "stdin payload",
311 });
312
313 expect(result.stdout).toBe("stdin payload");
314 }).pipe(provideLive),
315 );
316
317 it.effect("fails with VcsProcessExitError for non-zero exits by default", () =>
318 Effect.gen(function* () {
319 const secretArgument = "--token=super-secret-token";
320 const secretStderr = "remote rejected super-secret-token";
321 const error = yield* run({
322 operation: "test.exit",
323 command: "node",
324 args: [
325 "-e",
326 "process.stderr.write(process.argv[1]); process.exit(2)",
327 secretStderr,
328 secretArgument,
329 ],
330 cwd: process.cwd(),
331 }).pipe(Effect.flip);
332
333 expect(error).toBeInstanceOf(VcsProcessExitError);
334 expect(error).toMatchObject({
335 operation: "test.exit",
336 command: "node",
337 argumentCount: 4,
338 exitCode: 2,
339 detail: "Process exited with a non-zero status.",
340 failureKind: "command-failed",
341 stderrLength: secretStderr.length,
342 stderrTruncated: false,
343 });
344 expect(error.message).not.toContain(secretArgument);
345 expect(error.message).not.toContain(secretStderr);
346 }).pipe(provideLive),
347 );
348
349 it.effect("classifies authentication failures without retaining stderr", () =>
350 Effect.gen(function* () {
351 const secretStderr = "authentication failed for token super-secret-token";
352 const error = yield* run({
353 operation: "test.authentication",
354 command: "node",
355 args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr],
356 cwd: process.cwd(),
357 }).pipe(Effect.flip);
358
359 expect(error).toBeInstanceOf(VcsProcessExitError);
360 expect(error).toMatchObject({
361 operation: "test.authentication",
362 command: "node",
363 exitCode: 1,
364 detail: "Authentication failed.",
365 failureKind: "authentication",
366 stderrLength: secretStderr.length,
367 stderrTruncated: false,
368 });
369 expect(error.message).not.toContain(secretStderr);
370 expect(error.message).not.toContain("super-secret-token");
371 }).pipe(provideLive),
372 );
373
374 it.effect("classifies API rate limits without retaining provider stderr", () =>
375 Effect.gen(function* () {
376 const providerStderr =
377 "GraphQL: API rate limit already exceeded for user ID 51714798 and token secret-value.";
378 const error = yield* run({
379 operation: "test.rate-limit",
380 command: "node",
381 args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr],
382 cwd: process.cwd(),
383 }).pipe(Effect.flip);
384
385 expect(error).toBeInstanceOf(VcsProcessExitError);
386 expect(error).toMatchObject({
387 command: "node",
388 exitCode: 1,
389 detail: "API rate limit exceeded.",
390 failureKind: "rate-limited",
391 stderrLength: providerStderr.length,
392 stderrTruncated: false,
393 });
394 expect(error.message).not.toContain(providerStderr);
395 expect(error.message).not.toContain("secret-value");
396 }).pipe(provideLive),
397 );
398
399 it.effect("classifies HTTP 429 responses as rate limits", () =>
400 Effect.gen(function* () {
401 const providerStderr = "HTTP 429: Too Many Requests. request-id=secret-value";
402 const error = yield* run({
403 operation: "test.rate-limit",
404 command: "node",
405 args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr],
406 cwd: process.cwd(),
407 }).pipe(Effect.flip);
408
409 expect(error).toMatchObject({
410 detail: "API rate limit exceeded.",
411 failureKind: "rate-limited",
412 });
413 expect(error.message).not.toContain(providerStderr);
414 }).pipe(provideLive),
415 );
416
417 it.effect("retains spawn causes without exposing process arguments in the error message", () =>
418 Effect.gen(function* () {
419 const secretArgument = "--token=super-secret-token";
420 const error = yield* run({
421 operation: "test.spawn",
422 command: "definitely-not-a-t3code-executable",
423 args: [secretArgument],
424 cwd: process.cwd(),
425 }).pipe(Effect.flip);
426
427 expect(error).toBeInstanceOf(VcsProcessSpawnError);
428 expect(error).toMatchObject({
429 operation: "test.spawn",
430 command: "definitely-not-a-t3code-executable",
431 argumentCount: 1,
432 });
433 expect(error).toHaveProperty("cause");
434 expect(error.message).not.toContain(secretArgument);
435 }).pipe(provideLive),
436 );
437
438 it.effect("preserves real boundary causes without manufacturing structural ones", () =>
439 Effect.gen(function* () {
440 const cause = new Error("secret stdin failure");
441 const error = yield* captureProcessResult(
442 Effect.fail(
443 new ProcessRunner.ProcessStdinError({
444 command: baseInput.command,
445 argumentCount: baseInput.args.length,
446 cwd: baseInput.cwd,
447 stdinBytes: 47,
448 cause,
449 }),
450 ),
451 );
452
453 expect(error).toMatchObject({
454 _tag: "VcsProcessStdinWriteError",
455 operation: baseInput.operation,
456 stdinBytes: 47,
457 cause,
458 });
459 expect(error.message).not.toContain(cause.message);
460
461 const missingExitCodeError = yield* captureProcessResult(
462 Effect.succeed({
463 stdout: "",
464 stderr: "",
465 code: null,
466 timedOut: false,
467 stdoutTruncated: false,
468 stderrTruncated: false,
469 stdoutInvalidUtf8: false,
470 stderrInvalidUtf8: false,
471 }),
472 );
473
474 expect(missingExitCodeError).toMatchObject({
475 _tag: "VcsProcessMissingExitCodeError",
476 operation: baseInput.operation,
477 command: baseInput.command,
478 cwd: baseInput.cwd,
479 argumentCount: baseInput.args.length,
480 });
481 expect(missingExitCodeError).not.toHaveProperty("cause");
482 }),
483 );
484
485 it.effect("returns output when non-zero exits are allowed", () =>
486 Effect.gen(function* () {
487 const result = yield* run({
488 operation: "test.allowed-exit",
489 command: "node",
490 args: ["-e", "process.stderr.write('boom'); process.exit(2)"],
491 cwd: process.cwd(),
492 allowNonZeroExit: true,
493 });
494
495 expect(result.exitCode).toBe(2);
496 expect(result.stderr).toBe("boom");
497 }).pipe(provideLive),
498 );
499
500 it.effect("truncates output and appends the marker when requested", () =>
501 Effect.gen(function* () {
502 const result = yield* run({
503 operation: "test.truncate-marker",
504 command: "node",
505 args: ["-e", "process.stdout.write('x'.repeat(2048))"],
506 cwd: process.cwd(),
507 maxOutputBytes: 128,
508 appendTruncationMarker: true,
509 });
510
511 expect(result.stdoutTruncated).toBe(true);
512 expect(result.stdout).toContain("[truncated]");
513 expect(result.stderrTruncated).toBe(false);
514 }).pipe(provideLive),
515 );
516
517 it.effect("truncates without the marker when truncation markers are disabled", () =>
518 Effect.gen(function* () {
519 const result = yield* run({
520 operation: "test.truncate-silent",
521 command: "node",
522 args: ["-e", "process.stdout.write('x'.repeat(2048))"],
523 cwd: process.cwd(),
524 maxOutputBytes: 128,
525 });
526
527 expect(result.stdoutTruncated).toBe(true);
528 expect(result.stdout).not.toContain("[truncated]");
529 }).pipe(provideLive),
530 );
531
532 it.effect("fails with measured byte counts when output must not be truncated", () =>
533 Effect.gen(function* () {
534 const error = yield* run({
535 operation: "test.output-limit",
536 command: "node",
537 args: ["-e", "process.stdout.write('x'.repeat(2048))"],
538 cwd: yield* HostProcess.WorkingDirectory,
539 maxOutputBytes: 128,
540 outputMode: "error",
541 }).pipe(Effect.flip);
542
543 assert(error._tag === "VcsProcessOutputLimitError");
544 expect(error.stream).toBe("stdout");
545 expect(error.maxBytes).toBe(128);
546 expect(error.observedBytes).toBeGreaterThan(error.maxBytes);
547 }).pipe(provideLive),
548 );
549
550 it.effect("streams all stdout bytes beyond the buffered output cap", () =>
551 Effect.gen(function* () {
552 const chunks: Uint8Array[] = [];
553 const result = yield* run({
554 operation: "test.stream-output",
555 command: "node",
556 args: ["-e", "process.stdout.write('x'.repeat(131072) + '\\0S final\\0')"],
557 cwd: process.cwd(),
558 maxOutputBytes: 8,
559 onStdoutChunk: (chunk) => chunks.push(chunk),
560 });
561
562 expect(result.stdout).toBe("xxxxxxxx");
563 expect(result.stdoutTruncated).toBe(true);
564 expect(Buffer.concat(chunks).toString()).toBe("x".repeat(131072) + "\0S final\0");
565 }).pipe(provideLive),
566 );
567
568 it.effect("fails with VcsProcessTimeoutError on timeout", () =>
569 Effect.gen(function* () {
570 const errorFiber = yield* run({
571 operation: "test.timeout",
572 command: "node",
573 args: ["-e", "setTimeout(() => {}, 5000)"],
574 cwd: process.cwd(),
575 timeoutMs: 50,
576 }).pipe(Effect.flip, Effect.forkScoped);
577 yield* Effect.yieldNow;
578 yield* TestClock.adjust(Duration.millis(50));
579 const error = yield* Fiber.join(errorFiber);
580
581 expect(error).toBeInstanceOf(VcsProcessTimeoutError);
582 }).pipe(provideLive),
583 );
584});