authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:57:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:57:48-07:00
log97487d9959554854698bf92e672b758e2c3fdeab
tree5b789ec44692ec5b3fdbb276fb79e1feb917bbcd
parentd2e38c53735718cee17c7aeb58f6c71be24bf435
signature Signed by PGP key B5690EEEBB952194

[codex] Structure reference repo sync failures (#3281)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 241 insertions(+), 46 deletions(-)

scripts/sync-reference-repos.test.ts+131-9
......@@ -19,16 +19,22 @@ const encoder = new TextEncoder();
1919const effectSmol = referenceRepos[0]!;
2020const alchemyEffect = referenceRepos[1]!;
2121
22function mockHandle() {
22function mockHandle(
23 options: {
24 readonly exitCode?: number;
25 readonly stdout?: string;
26 readonly stderr?: string;
27 } = {},
28) {
2329 return ChildProcessSpawner.makeHandle({
2430 pid: ChildProcessSpawner.ProcessId(1),
25 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
31 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode ?? 0)),
2632 isRunning: Effect.succeed(false),
2733 kill: () => Effect.void,
2834 unref: Effect.succeed(Effect.void),
2935 stdin: Sink.drain,
30 stdout: Stream.make(encoder.encode("done\n")),
31 stderr: Stream.empty,
36 stdout: Stream.make(encoder.encode(options.stdout ?? "done\n")),
37 stderr: Stream.make(encoder.encode(options.stderr ?? "")),
3238 all: Stream.empty,
3339 getInputFd: () => Sink.drain,
3440 getOutputFd: () => Stream.empty,
......@@ -37,6 +43,7 @@ function mockHandle() {
3743
3844function mockSpawnerLayer(
3945 commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>,
46 handle = mockHandle(),
4047) {
4148 return Layer.succeed(
4249 ChildProcessSpawner.ChildProcessSpawner,
......@@ -49,7 +56,7 @@ function mockSpawnerLayer(
4956 command: childProcess.command,
5057 args: childProcess.args,
5158 });
52 return Effect.succeed(mockHandle());
59 return Effect.succeed(handle);
5360 }),
5461 );
5562}
......@@ -85,6 +92,75 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => {
8592 }),
8693 );
8794
95 it.effect("preserves version source read context and the filesystem cause", () =>
96 Effect.gen(function* () {
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const rootDir = yield* fs.makeTempDirectoryScoped({
100 prefix: "sync-reference-repos-read-error-",
101 });
102 const sourcePath = path.join(rootDir, effectSmol.versionSourcePath);
103
104 const error = yield* resolveReferenceRepoRef(effectSmol, rootDir, false).pipe(Effect.flip);
105
106 if (error._tag !== "ReferenceRepoVersionSourceError") {
107 assert.fail(`Unexpected error: ${error._tag}`);
108 }
109 assert.equal(error.operation, "read");
110 assert.equal(error.repoId, effectSmol.id);
111 assert.equal(error.sourcePath, sourcePath);
112 assert.ok(error.cause !== undefined);
113 assert.ok(!error.message.includes(String((error.cause as Error).message)));
114 }),
115 );
116
117 it.effect("preserves version source parse context and the schema cause", () =>
118 Effect.gen(function* () {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const rootDir = yield* fs.makeTempDirectoryScoped({
122 prefix: "sync-reference-repos-parse-error-",
123 });
124 const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath);
125 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
126 yield* fs.writeFileString(sourcePath, "{");
127
128 const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip);
129
130 if (error._tag !== "ReferenceRepoVersionSourceError") {
131 assert.fail(`Unexpected error: ${error._tag}`);
132 }
133 assert.equal(error.operation, "parse");
134 assert.equal(error.repoId, alchemyEffect.id);
135 assert.equal(error.sourcePath, sourcePath);
136 assert.ok(error.cause !== undefined);
137 assert.ok(!error.message.includes(String((error.cause as Error).message)));
138 }),
139 );
140
141 it.effect("reports the unresolved package path without inventing a cause", () =>
142 Effect.gen(function* () {
143 const fs = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 const rootDir = yield* fs.makeTempDirectoryScoped({
146 prefix: "sync-reference-repos-resolution-error-",
147 });
148 const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath);
149 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
150 yield* fs.writeFileString(sourcePath, '{"dependencies":{}}');
151
152 const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip);
153
154 if (error._tag !== "ReferenceRepoVersionResolutionError") {
155 assert.fail(`Unexpected error: ${error._tag}`);
156 }
157 assert.equal(error.repoId, alchemyEffect.id);
158 assert.equal(error.sourcePath, sourcePath);
159 assert.deepStrictEqual(error.packageVersionPath, ["dependencies", "alchemy"]);
160 assert.ok(!("cause" in error));
161 }),
162 );
163
88164 it.effect("resolves the alchemy-effect tag from the relay package", () =>
89165 Effect.gen(function* () {
90166 const fs = yield* FileSystem.FileSystem;
......@@ -171,10 +247,56 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => {
171247 dryRun: true,
172248 }).pipe(Effect.flip);
173249
174 assert.equal(
175 error.message,
176 "Unknown reference repo 'missing'. Expected one of: effect-smol, alchemy-effect.",
177 );
250 if (error._tag !== "ReferenceRepoSelectionError") {
251 assert.fail(`Unexpected error: ${error._tag}`);
252 }
253 assert.equal(error.repoId, "missing");
254 assert.deepStrictEqual(error.expectedRepoIds, ["effect-smol", "alchemy-effect"]);
255 assert.ok(!("cause" in error));
178256 }),
179257 );
258
259 it.effect("reports non-zero git exits without retaining process output", () => {
260 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];
261
262 return Effect.gen(function* () {
263 const fs = yield* FileSystem.FileSystem;
264 const path = yield* Path.Path;
265 const rootDir = yield* fs.makeTempDirectoryScoped({
266 prefix: "sync-reference-repos-exit-error-",
267 });
268 yield* fs.writeFileString(
269 path.join(rootDir, "pnpm-workspace.yaml"),
270 "catalog:\n effect: 4.0.0-beta.73\n",
271 );
272
273 const error = yield* syncReferenceRepos({ rootDir, repoId: "effect-smol" }).pipe(
274 Effect.provide(
275 mockSpawnerLayer(
276 commands,
277 mockHandle({ exitCode: 23, stderr: "subtree failed secret-token-value\n" }),
278 ),
279 ),
280 Effect.flip,
281 );
282
283 if (error._tag !== "ReferenceRepoGitSubtreeError") {
284 assert.fail(`Unexpected error: ${error._tag}`);
285 }
286 assert.equal(error.operation, "exit");
287 assert.equal(error.repoId, effectSmol.id);
288 assert.equal(error.action, "add");
289 assert.equal(error.repository, effectSmol.repository);
290 assert.equal(error.ref, "effect@4.0.0-beta.73");
291 assert.equal(error.rootDir, rootDir);
292 assert.equal(error.argumentCount, commands[0]?.args.length);
293 assert.equal(error.exitCode, 23);
294 assert.equal(error.stdoutLength, 5);
295 assert.equal(error.stderrLength, 34);
296 assert.notProperty(error, "args");
297 assert.notProperty(error, "stderr");
298 assert.notInclude(error.message, "secret-token-value");
299 assert.ok(!("cause" in error));
300 });
301 });
180302});
scripts/sync-reference-repos.ts+110-37
......@@ -3,7 +3,6 @@
33import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
44import * as NodeServices from "@effect/platform-node/NodeServices";
55import * as Console from "effect/Console";
6import * as Data from "effect/Data";
76import * as Effect from "effect/Effect";
87import * as FileSystem from "effect/FileSystem";
98import * as Option from "effect/Option";
......@@ -32,10 +31,74 @@ export interface ReferenceRepoSyncPlan {
3231 readonly args: ReadonlyArray<string>;
3332}
3433
35export class ReferenceRepoSyncError extends Data.TaggedError("ReferenceRepoSyncError")<{
36 readonly message: string;
37 readonly cause?: unknown;
38}> {}
34export class ReferenceRepoSelectionError extends Schema.TaggedErrorClass<ReferenceRepoSelectionError>()(
35 "ReferenceRepoSelectionError",
36 {
37 repoId: Schema.String,
38 expectedRepoIds: Schema.Array(Schema.String),
39 },
40) {
41 override get message(): string {
42 return `Unknown reference repo "${this.repoId}". Expected one of: ${this.expectedRepoIds.join(", ")}.`;
43 }
44}
45
46export class ReferenceRepoVersionSourceError extends Schema.TaggedErrorClass<ReferenceRepoVersionSourceError>()(
47 "ReferenceRepoVersionSourceError",
48 {
49 operation: Schema.Literals(["read", "parse"]),
50 repoId: Schema.String,
51 sourcePath: Schema.String,
52 cause: Schema.Defect(),
53 },
54) {
55 override get message(): string {
56 return `Reference repo "${this.repoId}" version source operation "${this.operation}" failed for ${this.sourcePath}.`;
57 }
58}
59
60export class ReferenceRepoVersionResolutionError extends Schema.TaggedErrorClass<ReferenceRepoVersionResolutionError>()(
61 "ReferenceRepoVersionResolutionError",
62 {
63 repoId: Schema.String,
64 sourcePath: Schema.String,
65 packageVersionPath: Schema.Array(Schema.String),
66 },
67) {
68 override get message(): string {
69 return `No version was found for reference repo "${this.repoId}" at ${this.sourcePath}:${this.packageVersionPath.join(".")}.`;
70 }
71}
72
73export class ReferenceRepoGitSubtreeError extends Schema.TaggedErrorClass<ReferenceRepoGitSubtreeError>()(
74 "ReferenceRepoGitSubtreeError",
75 {
76 operation: Schema.Literals(["spawn", "communicate", "exit"]),
77 repoId: Schema.String,
78 action: Schema.Literals(["add", "pull"]),
79 repository: Schema.String,
80 ref: Schema.String,
81 rootDir: Schema.String,
82 argumentCount: Schema.Number,
83 exitCode: Schema.optional(Schema.Number),
84 stdoutLength: Schema.optional(Schema.Number),
85 stderrLength: Schema.optional(Schema.Number),
86 cause: Schema.optional(Schema.Defect()),
87 },
88) {
89 override get message(): string {
90 return `Git subtree ${this.action} for reference repo "${this.repoId}" failed during "${this.operation}".`;
91 }
92}
93
94export const ReferenceRepoSyncError = Schema.Union([
95 ReferenceRepoSelectionError,
96 ReferenceRepoVersionSourceError,
97 ReferenceRepoVersionResolutionError,
98 ReferenceRepoGitSubtreeError,
99]);
100export type ReferenceRepoSyncError = typeof ReferenceRepoSyncError.Type;
101export const isReferenceRepoSyncError = Schema.is(ReferenceRepoSyncError);
39102
40103const decodeJsonSource = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
41104const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown));
......@@ -61,26 +124,21 @@ function readNestedString(input: unknown, keys: ReadonlyArray<string>): string |
61124}
62125
63126function decodeVersionSource(
127 repo: ReferenceRepo,
64128 sourcePath: string,
65129 content: string,
66130): Effect.Effect<unknown, ReferenceRepoSyncError> {
67 if (sourcePath.endsWith(".yaml") || sourcePath.endsWith(".yml")) {
68 return decodeYamlSource(content).pipe(
69 Effect.mapError(
70 (cause) =>
71 new ReferenceRepoSyncError({
72 message: `Unable to parse version source ${sourcePath}.`,
73 cause,
74 }),
75 ),
76 );
77 }
78
79 return decodeJsonSource(content).pipe(
131 const decode =
132 repo.versionSourcePath.endsWith(".yaml") || repo.versionSourcePath.endsWith(".yml")
133 ? decodeYamlSource
134 : decodeJsonSource;
135 return decode(content).pipe(
80136 Effect.mapError(
81137 (cause) =>
82 new ReferenceRepoSyncError({
83 message: `Unable to parse version source ${sourcePath}.`,
138 new ReferenceRepoVersionSourceError({
139 operation: "parse",
140 repoId: repo.id,
141 sourcePath,
84142 cause,
85143 }),
86144 ),
......@@ -98,10 +156,9 @@ function getSelectedRepos(
98156 return repo
99157 ? Effect.succeed([repo])
100158 : Effect.fail(
101 new ReferenceRepoSyncError({
102 message: `Unknown reference repo '${repoId}'. Expected one of: ${referenceRepos
103 .map((candidate) => candidate.id)
104 .join(", ")}.`,
159 new ReferenceRepoSelectionError({
160 repoId,
161 expectedRepoIds: referenceRepos.map((candidate) => candidate.id),
105162 }),
106163 );
107164}
......@@ -121,20 +178,22 @@ export const resolveReferenceRepoRef = Effect.fn("resolveReferenceRepoRef")(func
121178 const versionSourceContent = yield* fs.readFileString(versionSourcePath).pipe(
122179 Effect.mapError(
123180 (cause) =>
124 new ReferenceRepoSyncError({
125 message: `Unable to read package version for '${repo.id}' from ${versionSourcePath}.`,
181 new ReferenceRepoVersionSourceError({
182 operation: "read",
183 repoId: repo.id,
184 sourcePath: versionSourcePath,
126185 cause,
127186 }),
128187 ),
129188 );
130 const versionSource = yield* decodeVersionSource(repo.versionSourcePath, versionSourceContent);
189 const versionSource = yield* decodeVersionSource(repo, versionSourcePath, versionSourceContent);
131190 const version = readNestedString(versionSource, repo.packageVersionPath);
132191
133192 if (!version) {
134 return yield* new ReferenceRepoSyncError({
135 message: `Unable to resolve package version for '${repo.id}' at ${repo.versionSourcePath}:${repo.packageVersionPath.join(
136 ".",
137 )}.`,
193 return yield* new ReferenceRepoVersionResolutionError({
194 repoId: repo.id,
195 sourcePath: versionSourcePath,
196 packageVersionPath: repo.packageVersionPath,
138197 });
139198 }
140199
......@@ -163,11 +222,20 @@ export const planReferenceRepoSync = Effect.fn("planReferenceRepoSync")(function
163222
164223const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRepoSyncPlan) {
165224 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
225 const errorContext = {
226 repoId: plan.repo.id,
227 action: plan.action,
228 repository: plan.repo.repository,
229 ref: plan.ref,
230 rootDir,
231 argumentCount: plan.args.length,
232 } as const;
166233 const child = yield* spawner.spawn(ChildProcess.make("git", plan.args, { cwd: rootDir })).pipe(
167234 Effect.mapError(
168235 (cause) =>
169 new ReferenceRepoSyncError({
170 message: `Unable to start git subtree ${plan.action} for '${plan.repo.id}'.`,
236 new ReferenceRepoGitSubtreeError({
237 ...errorContext,
238 operation: "spawn",
171239 cause,
172240 }),
173241 ),
......@@ -182,16 +250,21 @@ const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRe
182250 ).pipe(
183251 Effect.mapError(
184252 (cause) =>
185 new ReferenceRepoSyncError({
186 message: `Unable to run git subtree ${plan.action} for '${plan.repo.id}'.`,
253 new ReferenceRepoGitSubtreeError({
254 ...errorContext,
255 operation: "communicate",
187256 cause,
188257 }),
189258 ),
190259 );
191260
192261 if (exitCode !== 0) {
193 return yield* new ReferenceRepoSyncError({
194 message: `git subtree ${plan.action} failed for '${plan.repo.id}' with exit code ${exitCode}.\n${stderr.trim()}`,
262 return yield* new ReferenceRepoGitSubtreeError({
263 ...errorContext,
264 operation: "exit",
265 exitCode,
266 stdoutLength: stdout.length,
267 stderrLength: stderr.length,
195268 });
196269 }
197270