authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:51:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:51:52-07:00
log1cbe8a7a90cb7b942ede116e744a71d45da8fdb0
treeaa3ef5ec34cd54d55533575108b11f941b26f0c0
parent46fdc769d90fc46efd69f4954765e64030353199
signature Signed by PGP key B5690EEEBB952194

[codex] Structure VCS project config failures (#3315)

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

2 files changed, 182 insertions(+), 32 deletions(-)

apps/server/src/vcs/VcsProjectConfig.test.ts+111-4
...@@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";
6import * as Path from "effect/Path";7import * as Path from "effect/Path";
78
8import * as VcsProjectConfig from "./VcsProjectConfig.ts";9import * as VcsProjectConfig from "./VcsProjectConfig.ts";
...@@ -13,6 +14,22 @@ const TestLayer = VcsProjectConfig.layer.pipe(...@@ -13,6 +14,22 @@ const TestLayer = VcsProjectConfig.layer.pipe(
13);14);
1415
15describe("VcsProjectConfig", () => {16describe("VcsProjectConfig", () => {
17 it("keeps operation context and the original cause on config errors", () => {
18 const cause = new Error("permission denied");
19 const error = new VcsProjectConfig.VcsProjectConfigError({
20 operation: "read",
21 cwd: "/repo/packages/app",
22 configPath: "/repo/.t3code/vcs.json",
23 cause,
24 });
25
26 assert.equal(error.operation, "read");
27 assert.equal(error.cwd, "/repo/packages/app");
28 assert.equal(error.configPath, "/repo/.t3code/vcs.json");
29 assert.strictEqual(error.cause, cause);
30 assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json.");
31 });
32
16 it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => {33 it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => {
17 it.effect("returns the requested kind", () =>34 it.effect("returns the requested kind", () =>
18 Effect.gen(function* () {35 Effect.gen(function* () {
...@@ -53,6 +70,46 @@ describe("VcsProjectConfig", () => {...@@ -53,6 +70,46 @@ describe("VcsProjectConfig", () => {
53 );70 );
54 });71 });
5572
73 it.layer(TestLayer)("continues to parent configs after a candidate inspect failure", (it) => {
74 it.effect("logs the failed candidate and returns the parent config", () => {
75 const messages: unknown[] = [];
76 const logger = Logger.make<unknown, void>(({ message }) => {
77 messages.push(message);
78 });
79
80 return Effect.gen(function* () {
81 const fileSystem = yield* FileSystem.FileSystem;
82 const path = yield* Path.Path;
83 const root = yield* fileSystem.makeTempDirectoryScoped({
84 prefix: "t3-vcs-config-test-",
85 });
86 const configDir = path.join(root, ".t3code");
87 const cwd = path.join(root, "invalid\0child");
88 yield* fileSystem.makeDirectory(configDir, { recursive: true });
89 yield* fileSystem.writeFileString(
90 path.join(configDir, "vcs.json"),
91 // @effect-diagnostics-next-line preferSchemaOverJson:off
92 JSON.stringify({ vcs: { kind: "jj" } }),
93 );
94
95 const config = yield* VcsProjectConfig.VcsProjectConfig;
96 const kind = yield* config.resolveKind({ cwd });
97
98 assert.equal(kind, "jj");
99 const [message, context] = messages[0] as [string, Record<string, unknown>];
100 const failedCandidate = path.join(cwd, ".t3code", "vcs.json");
101 assert.equal(message, "Failed to inspect VCS project config at " + failedCandidate + ".");
102 assert.deepInclude(context, {
103 operation: "inspect",
104 cwd,
105 configPath: failedCandidate,
106 errorTag: "VcsProjectConfigError",
107 });
108 assert.equal("cause" in context, false);
109 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
110 });
111 });
112
56 it.layer(TestLayer)("falls back to auto when no config exists", (it) => {113 it.layer(TestLayer)("falls back to auto when no config exists", (it) => {
57 it.effect("returns auto", () =>114 it.effect("returns auto", () =>
58 Effect.gen(function* () {115 Effect.gen(function* () {
...@@ -69,8 +126,13 @@ describe("VcsProjectConfig", () => {...@@ -69,8 +126,13 @@ describe("VcsProjectConfig", () => {
69 });126 });
70127
71 it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => {128 it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => {
72 it.effect("returns auto", () =>129 it.effect("returns auto and logs the failed operation and path", () => {
73 Effect.gen(function* () {130 const messages: unknown[] = [];
131 const logger = Logger.make<unknown, void>(({ message }) => {
132 messages.push(message);
133 });
134
135 return Effect.gen(function* () {
74 const fileSystem = yield* FileSystem.FileSystem;136 const fileSystem = yield* FileSystem.FileSystem;
75 const path = yield* Path.Path;137 const path = yield* Path.Path;
76 const root = yield* fileSystem.makeTempDirectoryScoped({138 const root = yield* fileSystem.makeTempDirectoryScoped({
...@@ -84,8 +146,53 @@ describe("VcsProjectConfig", () => {...@@ -84,8 +146,53 @@ describe("VcsProjectConfig", () => {
84 const kind = yield* config.resolveKind({ cwd: root });146 const kind = yield* config.resolveKind({ cwd: root });
85147
86 assert.equal(kind, "auto");148 assert.equal(kind, "auto");
87 }),149 const [message, context] = messages[0] as [string, Record<string, unknown>];
88 );150 assert.equal(
151 message,
152 "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".",
153 );
154 assert.deepInclude(context, {
155 operation: "decode",
156 cwd: root,
157 configPath: path.join(configDir, "vcs.json"),
158 errorTag: "VcsProjectConfigError",
159 });
160 assert.equal("cause" in context, false);
161 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
162 });
163 });
164
165 it.layer(TestLayer)("falls back to auto when the config path cannot be read", (it) => {
166 it.effect("retains the read failure context", () => {
167 const messages: unknown[] = [];
168 const logger = Logger.make<unknown, void>(({ message }) => {
169 messages.push(message);
170 });
171
172 return Effect.gen(function* () {
173 const fileSystem = yield* FileSystem.FileSystem;
174 const path = yield* Path.Path;
175 const root = yield* fileSystem.makeTempDirectoryScoped({
176 prefix: "t3-vcs-config-test-",
177 });
178 const configPath = path.join(root, ".t3code", "vcs.json");
179 yield* fileSystem.makeDirectory(configPath, { recursive: true });
180
181 const config = yield* VcsProjectConfig.VcsProjectConfig;
182 const kind = yield* config.resolveKind({ cwd: root });
183
184 assert.equal(kind, "auto");
185 const [message, context] = messages[0] as [string, Record<string, unknown>];
186 assert.equal(message, "Failed to read VCS project config at " + configPath + ".");
187 assert.deepInclude(context, {
188 operation: "read",
189 cwd: root,
190 configPath,
191 errorTag: "VcsProjectConfigError",
192 });
193 assert.equal("cause" in context, false);
194 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
195 });
89 });196 });
90197
91 it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => {198 it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => {
apps/server/src/vcs/VcsProjectConfig.ts+71-28
...@@ -18,7 +18,7 @@ const ProjectVcsConfig = Schema.Struct({...@@ -18,7 +18,7 @@ const ProjectVcsConfig = Schema.Struct({
18 vcsKind: Schema.optional(VcsDriverKind),18 vcsKind: Schema.optional(VcsDriverKind),
19});19});
20const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig);20const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig);
21const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);21const decodeProjectVcsConfigJson = Schema.decodeUnknownEffect(ProjectVcsConfigJson);
2222
23type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;23type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;
2424
...@@ -27,6 +27,20 @@ export interface VcsProjectConfigResolveInput {...@@ -27,6 +27,20 @@ export interface VcsProjectConfigResolveInput {
27 readonly requestedKind?: VcsDriverKindType | "auto";27 readonly requestedKind?: VcsDriverKindType | "auto";
28}28}
2929
30export class VcsProjectConfigError extends Schema.TaggedErrorClass<VcsProjectConfigError>()(
31 "VcsProjectConfigError",
32 {
33 operation: Schema.Literals(["inspect", "read", "decode"]),
34 cwd: Schema.String,
35 configPath: Schema.String,
36 cause: Schema.Defect(),
37 },
38) {
39 override get message(): string {
40 return `Failed to ${this.operation} VCS project config at ${this.configPath}.`;
41 }
42}
43
30export class VcsProjectConfig extends Context.Service<44export class VcsProjectConfig extends Context.Service<
31 VcsProjectConfig,45 VcsProjectConfig,
32 {46 {
...@@ -40,8 +54,14 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto...@@ -40,8 +54,14 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
40 return config.vcs?.kind ?? config.vcsKind ?? "auto";54 return config.vcs?.kind ?? config.vcsKind ?? "auto";
41}55}
4256
43const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>57const logVcsProjectConfigError = (error: VcsProjectConfigError) =>
44 decodeProjectVcsConfigJson(raw);58 Effect.logWarning(error.message, {
59 operation: error.operation,
60 cwd: error.cwd,
61 configPath: error.configPath,
62 errorTag: error._tag,
63 stack: error.stack,
64 });
4565
46export const make = Effect.gen(function* () {66export const make = Effect.gen(function* () {
47 const fileSystem = yield* FileSystem.FileSystem;67 const fileSystem = yield* FileSystem.FileSystem;
...@@ -51,7 +71,21 @@ export const make = Effect.gen(function* () {...@@ -51,7 +71,21 @@ export const make = Effect.gen(function* () {
51 let current = cwd;71 let current = cwd;
52 while (true) {72 while (true) {
53 const candidate = path.join(current, ".t3code", "vcs.json");73 const candidate = path.join(current, ".t3code", "vcs.json");
54 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {74 const exists = yield* fileSystem.exists(candidate).pipe(
75 Effect.mapError(
76 (cause) =>
77 new VcsProjectConfigError({
78 operation: "inspect",
79 cwd,
80 configPath: candidate,
81 cause,
82 }),
83 ),
84 Effect.catchTags({
85 VcsProjectConfigError: (error) => logVcsProjectConfigError(error).pipe(Effect.as(false)),
86 }),
87 );
88 if (exists) {
55 return Option.some(candidate);89 return Option.some(candidate);
56 }90 }
5791
...@@ -64,30 +98,32 @@ export const make = Effect.gen(function* () {...@@ -64,30 +98,32 @@ export const make = Effect.gen(function* () {
64 });98 });
6599
66 const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* (100 const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* (
101 cwd: string,
67 configPath: string,102 configPath: string,
68 ) {103 ) {
69 const raw = yield* fileSystem.readFileString(configPath).pipe(104 const raw = yield* fileSystem.readFileString(configPath).pipe(
70 Effect.map(Option.some),105 Effect.mapError(
71 Effect.catch((error) =>106 (cause) =>
72 Effect.logWarning("failed to read VCS project config", {107 new VcsProjectConfigError({
73 configPath,108 operation: "read",
74 error,109 cwd,
75 }).pipe(Effect.as(Option.none())),110 configPath,
111 cause,
112 }),
76 ),113 ),
77 );114 );
78 if (Option.isNone(raw)) {115 const parsed = yield* decodeProjectVcsConfigJson(raw).pipe(
79 return "auto" as const;116 Effect.mapError(
80 }117 (cause) =>
81118 new VcsProjectConfigError({
82 const parsed = parseConfig(raw.value);119 operation: "decode",
83 if (Option.isNone(parsed)) {120 cwd,
84 yield* Effect.logWarning("invalid VCS project config", {121 configPath,
85 configPath,122 cause,
86 });123 }),
87 return "auto" as const;124 ),
88 }125 );
89126 return configuredKind(parsed);
90 return configuredKind(parsed.value);
91 });127 });
92128
93 const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn(129 const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn(
...@@ -97,11 +133,18 @@ export const make = Effect.gen(function* () {...@@ -97,11 +133,18 @@ export const make = Effect.gen(function* () {
97 return input.requestedKind;133 return input.requestedKind;
98 }134 }
99135
100 const configPath = yield* findConfigPath(input.cwd);136 return yield* findConfigPath(input.cwd).pipe(
101 return yield* Option.match(configPath, {137 Effect.flatMap(
102 onNone: () => Effect.succeed("auto" as const),138 Option.match({
103 onSome: readConfiguredKind,139 onNone: () => Effect.succeed("auto" as const),
104 });140 onSome: (configPath) => readConfiguredKind(input.cwd, configPath),
141 }),
142 ),
143 Effect.catchTags({
144 VcsProjectConfigError: (error) =>
145 logVcsProjectConfigError(error).pipe(Effect.as("auto" as const)),
146 }),
147 );
105 });148 });
106149
107 return VcsProjectConfig.of({150 return VcsProjectConfig.of({