From 0502c5e02105e644772eb302cf380096e4b765d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 04:56:36 -0700 Subject: [PATCH 1/2] Structure VCS project config failures Co-authored-by: codex --- apps/server/src/vcs/VcsProjectConfig.test.ts | 112 ++++++++++++++++++- apps/server/src/vcs/VcsProjectConfig.ts | 98 +++++++++++----- 2 files changed, 178 insertions(+), 32 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index b4977173bdf..8b8ad010775 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; @@ -13,6 +14,22 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { + it("keeps operation context and the original cause on config errors", () => { + const cause = new Error("permission denied"); + const error = new VcsProjectConfig.VcsProjectConfigError({ + operation: "read", + cwd: "/repo/packages/app", + configPath: "/repo/.t3code/vcs.json", + cause, + }); + + assert.equal(error.operation, "read"); + assert.equal(error.cwd, "/repo/packages/app"); + assert.equal(error.configPath, "/repo/.t3code/vcs.json"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); + }); + it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { @@ -53,6 +70,45 @@ describe("VcsProjectConfig", () => { ); }); + it.layer(TestLayer)("continues to parent configs after a candidate inspect failure", (it) => { + it.effect("logs the failed candidate and returns the parent config", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + const cwd = path.join(root, "invalid\0child"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ vcs: { kind: "jj" } }), + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd }); + + assert.equal(kind, "jj"); + const [message, context] = messages[0] as [string, Record]; + const failedCandidate = path.join(cwd, ".t3code", "vcs.json"); + assert.equal(message, "Failed to inspect VCS project config at " + failedCandidate + "."); + assert.deepInclude(context, { + operation: "inspect", + cwd, + configPath: failedCandidate, + }); + assert.instanceOf(context.cause, VcsProjectConfig.VcsProjectConfigError); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + it.layer(TestLayer)("falls back to auto when no config exists", (it) => { it.effect("returns auto", () => Effect.gen(function* () { @@ -69,8 +125,13 @@ describe("VcsProjectConfig", () => { }); it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => { - it.effect("returns auto", () => - Effect.gen(function* () { + it.effect("returns auto and logs the failed operation and path", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fileSystem.makeTempDirectoryScoped({ @@ -84,8 +145,51 @@ describe("VcsProjectConfig", () => { const kind = yield* config.resolveKind({ cwd: root }); assert.equal(kind, "auto"); - }), - ); + const [message, context] = messages[0] as [string, Record]; + assert.equal( + message, + "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".", + ); + assert.deepInclude(context, { + operation: "decode", + cwd: root, + configPath: path.join(configDir, "vcs.json"), + }); + assert.instanceOf(context.cause, VcsProjectConfig.VcsProjectConfigError); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + + it.layer(TestLayer)("falls back to auto when the config path cannot be read", (it) => { + it.effect("retains the read failure context", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configPath = path.join(root, ".t3code", "vcs.json"); + yield* fileSystem.makeDirectory(configPath, { recursive: true }); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + const [message, context] = messages[0] as [string, Record]; + assert.equal(message, "Failed to read VCS project config at " + configPath + "."); + assert.deepInclude(context, { + operation: "read", + cwd: root, + configPath, + }); + assert.instanceOf(context.cause, VcsProjectConfig.VcsProjectConfigError); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); }); it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => { diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index c3590f5dbb0..adc5088b887 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -18,7 +18,7 @@ const ProjectVcsConfig = Schema.Struct({ vcsKind: Schema.optional(VcsDriverKind), }); const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig); -const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson); +const decodeProjectVcsConfigJson = Schema.decodeUnknownEffect(ProjectVcsConfigJson); type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; @@ -27,6 +27,20 @@ export interface VcsProjectConfigResolveInput { readonly requestedKind?: VcsDriverKindType | "auto"; } +export class VcsProjectConfigError extends Schema.TaggedErrorClass()( + "VcsProjectConfigError", + { + operation: Schema.Literals(["inspect", "read", "decode"]), + cwd: Schema.String, + configPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} VCS project config at ${this.configPath}.`; + } +} + export class VcsProjectConfig extends Context.Service< VcsProjectConfig, { @@ -40,8 +54,13 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto return config.vcs?.kind ?? config.vcsKind ?? "auto"; } -const parseConfig = (raw: string): Option.Option => - decodeProjectVcsConfigJson(raw); +const logVcsProjectConfigError = (error: VcsProjectConfigError) => + Effect.logWarning(error.message, { + operation: error.operation, + cwd: error.cwd, + configPath: error.configPath, + cause: error, + }); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -51,7 +70,21 @@ export const make = Effect.gen(function* () { let current = cwd; while (true) { const candidate = path.join(current, ".t3code", "vcs.json"); - if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + const exists = yield* fileSystem.exists(candidate).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "inspect", + cwd, + configPath: candidate, + cause, + }), + ), + Effect.catchTags({ + VcsProjectConfigError: (error) => logVcsProjectConfigError(error).pipe(Effect.as(false)), + }), + ); + if (exists) { return Option.some(candidate); } @@ -64,30 +97,32 @@ export const make = Effect.gen(function* () { }); const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* ( + cwd: string, configPath: string, ) { const raw = yield* fileSystem.readFileString(configPath).pipe( - Effect.map(Option.some), - Effect.catch((error) => - Effect.logWarning("failed to read VCS project config", { - configPath, - error, - }).pipe(Effect.as(Option.none())), + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "read", + cwd, + configPath, + cause, + }), ), ); - if (Option.isNone(raw)) { - return "auto" as const; - } - - const parsed = parseConfig(raw.value); - if (Option.isNone(parsed)) { - yield* Effect.logWarning("invalid VCS project config", { - configPath, - }); - return "auto" as const; - } - - return configuredKind(parsed.value); + const parsed = yield* decodeProjectVcsConfigJson(raw).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "decode", + cwd, + configPath, + cause, + }), + ), + ); + return configuredKind(parsed); }); const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn( @@ -97,11 +132,18 @@ export const make = Effect.gen(function* () { return input.requestedKind; } - const configPath = yield* findConfigPath(input.cwd); - return yield* Option.match(configPath, { - onNone: () => Effect.succeed("auto" as const), - onSome: readConfiguredKind, - }); + return yield* findConfigPath(input.cwd).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed("auto" as const), + onSome: (configPath) => readConfiguredKind(input.cwd, configPath), + }), + ), + Effect.catchTags({ + VcsProjectConfigError: (error) => + logVcsProjectConfigError(error).pipe(Effect.as("auto" as const)), + }), + ); }); return VcsProjectConfig.of({ From 569a697b1d780be385c9b5031cdfe095e4194416 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:16:54 -0700 Subject: [PATCH 2/2] fix(vcs): keep nested causes out of log metadata Co-authored-by: codex --- apps/server/src/vcs/VcsProjectConfig.test.ts | 9 ++++++--- apps/server/src/vcs/VcsProjectConfig.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 8b8ad010775..5fe5dcc7564 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -103,8 +103,9 @@ describe("VcsProjectConfig", () => { operation: "inspect", cwd, configPath: failedCandidate, + errorTag: "VcsProjectConfigError", }); - assert.instanceOf(context.cause, VcsProjectConfig.VcsProjectConfigError); + assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); @@ -154,8 +155,9 @@ describe("VcsProjectConfig", () => { operation: "decode", cwd: root, configPath: path.join(configDir, "vcs.json"), + errorTag: "VcsProjectConfigError", }); - assert.instanceOf(context.cause, VcsProjectConfig.VcsProjectConfigError); + assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); @@ -186,8 +188,9 @@ describe("VcsProjectConfig", () => { operation: "read", cwd: root, configPath, + errorTag: "VcsProjectConfigError", }); - assert.instanceOf(context.cause, VcsProjectConfig.VcsProjectConfigError); + assert.equal("cause" in context, false); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index adc5088b887..bd8f4515007 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -59,7 +59,8 @@ const logVcsProjectConfigError = (error: VcsProjectConfigError) => operation: error.operation, cwd: error.cwd, configPath: error.configPath, - cause: error, + errorTag: error._tag, + stack: error.stack, }); export const make = Effect.gen(function* () {