diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index b4977173bdf..5fe5dcc7564 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,46 @@ 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, + errorTag: "VcsProjectConfigError", + }); + assert.equal("cause" in context, false); + }).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 +126,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 +146,53 @@ 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"), + errorTag: "VcsProjectConfigError", + }); + assert.equal("cause" in context, false); + }).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, + errorTag: "VcsProjectConfigError", + }); + assert.equal("cause" in context, false); + }).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..bd8f4515007 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,14 @@ 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, + errorTag: error._tag, + stack: error.stack, + }); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -51,7 +71,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 +98,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 +133,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({