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";
33import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";
67import * as Path from "effect/Path";
78
89import * as VcsProjectConfig from "./VcsProjectConfig.ts";
......@@ -13,6 +14,22 @@ const TestLayer = VcsProjectConfig.layer.pipe(
1314);
1415
1516describe("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
1633 it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => {
1734 it.effect("returns the requested kind", () =>
1835 Effect.gen(function* () {
......@@ -53,6 +70,46 @@ describe("VcsProjectConfig", () => {
5370 );
5471 });
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
56113 it.layer(TestLayer)("falls back to auto when no config exists", (it) => {
57114 it.effect("returns auto", () =>
58115 Effect.gen(function* () {
......@@ -69,8 +126,13 @@ describe("VcsProjectConfig", () => {
69126 });
70127
71128 it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => {
72 it.effect("returns auto", () =>
73 Effect.gen(function* () {
129 it.effect("returns auto and logs the failed operation and path", () => {
130 const messages: unknown[] = [];
131 const logger = Logger.make<unknown, void>(({ message }) => {
132 messages.push(message);
133 });
134
135 return Effect.gen(function* () {
74136 const fileSystem = yield* FileSystem.FileSystem;
75137 const path = yield* Path.Path;
76138 const root = yield* fileSystem.makeTempDirectoryScoped({
......@@ -84,8 +146,53 @@ describe("VcsProjectConfig", () => {
84146 const kind = yield* config.resolveKind({ cwd: root });
85147
86148 assert.equal(kind, "auto");
87 }),
88 );
149 const [message, context] = messages[0] as [string, Record<string, unknown>];
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 });
89196 });
90197
91198 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({
1818 vcsKind: Schema.optional(VcsDriverKind),
1919});
2020const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig);
21const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);
21const decodeProjectVcsConfigJson = Schema.decodeUnknownEffect(ProjectVcsConfigJson);
2222
2323type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;
2424
......@@ -27,6 +27,20 @@ export interface VcsProjectConfigResolveInput {
2727 readonly requestedKind?: VcsDriverKindType | "auto";
2828}
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
3044export class VcsProjectConfig extends Context.Service<
3145 VcsProjectConfig,
3246 {
......@@ -40,8 +54,14 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
4054 return config.vcs?.kind ?? config.vcsKind ?? "auto";
4155}
4256
43const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
44 decodeProjectVcsConfigJson(raw);
57const logVcsProjectConfigError = (error: VcsProjectConfigError) =>
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
4666export const make = Effect.gen(function* () {
4767 const fileSystem = yield* FileSystem.FileSystem;
......@@ -51,7 +71,21 @@ export const make = Effect.gen(function* () {
5171 let current = cwd;
5272 while (true) {
5373 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) {
5589 return Option.some(candidate);
5690 }
5791
......@@ -64,30 +98,32 @@ export const make = Effect.gen(function* () {
6498 });
6599
66100 const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* (
101 cwd: string,
67102 configPath: string,
68103 ) {
69104 const raw = yield* fileSystem.readFileString(configPath).pipe(
70 Effect.map(Option.some),
71 Effect.catch((error) =>
72 Effect.logWarning("failed to read VCS project config", {
73 configPath,
74 error,
75 }).pipe(Effect.as(Option.none())),
105 Effect.mapError(
106 (cause) =>
107 new VcsProjectConfigError({
108 operation: "read",
109 cwd,
110 configPath,
111 cause,
112 }),
76113 ),
77114 );
78 if (Option.isNone(raw)) {
79 return "auto" as const;
80 }
81
82 const parsed = parseConfig(raw.value);
83 if (Option.isNone(parsed)) {
84 yield* Effect.logWarning("invalid VCS project config", {
85 configPath,
86 });
87 return "auto" as const;
88 }
89
90 return configuredKind(parsed.value);
115 const parsed = yield* decodeProjectVcsConfigJson(raw).pipe(
116 Effect.mapError(
117 (cause) =>
118 new VcsProjectConfigError({
119 operation: "decode",
120 cwd,
121 configPath,
122 cause,
123 }),
124 ),
125 );
126 return configuredKind(parsed);
91127 });
92128
93129 const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn(
......@@ -97,11 +133,18 @@ export const make = Effect.gen(function* () {
97133 return input.requestedKind;
98134 }
99135
100 const configPath = yield* findConfigPath(input.cwd);
101 return yield* Option.match(configPath, {
102 onNone: () => Effect.succeed("auto" as const),
103 onSome: readConfiguredKind,
104 });
136 return yield* findConfigPath(input.cwd).pipe(
137 Effect.flatMap(
138 Option.match({
139 onNone: () => Effect.succeed("auto" as const),
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 );
105148 });
106149
107150 return VcsProjectConfig.of({