History

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

Julius Marminge committed 4 months ago

Commit 97487d91 parent d2e38c52 files · +241 −46Browse files

Unified · Split

2 files
  1. scripts/sync-reference-repos.test.ts +131 −9
  2. scripts/sync-reference-repos.ts +110 −37
scripts/sync-reference-repos.test.ts+131−9
@@ -19,16 +19,22 @@ const encoder = new TextEncoder(); · 18 unmodified lines
1919 const effectSmol = referenceRepos[0]!;
2020 const alchemyEffect = referenceRepos[1]!;
2121
22−function mockHandle() {
22+function 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() { · 2 unmodified lines
3743
3844 function 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( · 6 unmodified lines
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) => { · 29 unmodified lines
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) => { · 80 unmodified lines
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 @@ · 2 unmodified lines
33 import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
44 import * as NodeServices from "@effect/platform-node/NodeServices";
55 import * as Console from "effect/Console";
6−import * as Data from "effect/Data";
76 import * as Effect from "effect/Effect";
87 import * as FileSystem from "effect/FileSystem";
98 import * as Option from "effect/Option";
@@ -32,10 +31,74 @@ export interface ReferenceRepoSyncPlan { · 22 unmodified lines
3231 readonly args: ReadonlyArray<string>;
3332 }
3433
35−export class ReferenceRepoSyncError extends Data.TaggedError("ReferenceRepoSyncError")<{
36− readonly message: string;
37− readonly cause?: unknown;
38−}> {}
34+export 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+
46+export 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+
60+export 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+
73+export 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+
94+export const ReferenceRepoSyncError = Schema.Union([
95+ ReferenceRepoSelectionError,
96+ ReferenceRepoVersionSourceError,
97+ ReferenceRepoVersionResolutionError,
98+ ReferenceRepoGitSubtreeError,
99+]);
100+export type ReferenceRepoSyncError = typeof ReferenceRepoSyncError.Type;
101+export const isReferenceRepoSyncError = Schema.is(ReferenceRepoSyncError);
39102
40103 const decodeJsonSource = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
41104 const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown));
@@ -61,26 +124,21 @@ function readNestedString(input: unknown, keys: ReadonlyArray<string>): string | · 19 unmodified lines
61124 }
62125
63126 function 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( · 11 unmodified lines
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 · 13 unmodified lines
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 · 22 unmodified lines
163222
164223 const 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 · 8 unmodified lines
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