authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:58:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:58:59-07:00
logbe56fa43b0aaad6ab537526e2129501038bbb6a8
treed38c10043f111ad0941a37245ef3b5bba8e18468
parenteb5eb0d9fa99364aa3b5091c62d6a187f5eef375
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve workspace root stat failures (#3278)

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

6 files changed, 168 insertions(+), 7 deletions(-)

apps/server/src/server.test.ts+34
......@@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
22import * as NodeSocket from "@effect/platform-node/NodeSocket";
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import * as NodeCrypto from "node:crypto";
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
56
67import {
78 AuthAccessTokenType,
......@@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
45414542 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
45424543 );
45434544
4545 it.effect("reports workspace root stat failures without relabeling them as missing", () =>
4546 Effect.gen(function* () {
4547 if ((yield* HostProcessPlatform) === "win32") return;
4548
4549 const fs = yield* FileSystem.FileSystem;
4550 const path = yield* Path.Path;
4551 const blockedRoot = yield* fs.makeTempDirectoryScoped({
4552 prefix: "t3-ws-workspace-stat-error-",
4553 });
4554 const workspaceRoot = path.join(blockedRoot, "workspace");
4555 yield* fs.makeDirectory(workspaceRoot);
4556 yield* fs.chmod(blockedRoot, 0o000);
4557
4558 const result = yield* Effect.gen(function* () {
4559 yield* buildAppUnderTest();
4560 const wsUrl = yield* getWsServerUrl("/ws");
4561 return yield* Effect.scoped(
4562 withWsRpcClient(wsUrl, (client) =>
4563 client[WS_METHODS.projectsListEntries]({ cwd: workspaceRoot }).pipe(Effect.result),
4564 ),
4565 );
4566 }).pipe(Effect.ensuring(fs.chmod(blockedRoot, 0o700).pipe(Effect.ignore)));
4567
4568 if (result._tag !== "Failure" || result.failure._tag !== "ProjectListEntriesError") {
4569 assert.fail("Expected a ProjectListEntriesError");
4570 }
4571 const error = result.failure;
4572 assert.equal(error.failure, "workspace_root_stat_failed");
4573 assert.equal(error.normalizedCwd, workspaceRoot);
4574 assert.equal(error.detail, "validate-existing");
4575 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4576 );
4577
45444578 it.effect("routes websocket rpc projects.writeFile", () =>
45454579 Effect.gen(function* () {
45464580 const fs = yield* FileSystem.FileSystem;
apps/server/src/workspace/WorkspaceEntries.ts+1
......@@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ
7373export const WorkspaceEntriesError = Schema.Union([
7474 WorkspacePaths.WorkspaceRootNotExistsError,
7575 WorkspacePaths.WorkspaceRootCreateFailedError,
76 WorkspacePaths.WorkspaceRootStatFailedError,
7677 WorkspacePaths.WorkspaceRootNotDirectoryError,
7778 WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,
7879 WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,
apps/server/src/workspace/WorkspacePaths.test.ts+74
......@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
66import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
78
89import * as WorkspacePaths from "./WorkspacePaths.ts";
910
......@@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
9192 expect(error.message).toContain("Workspace root is not a directory:");
9293 }),
9394 );
95
96 it.effect("preserves non-NotFound stat failures while validating the root", () =>
97 Effect.gen(function* () {
98 const fileSystem = yield* FileSystem.FileSystem;
99 const workspacePaths = yield* WorkspacePaths.make.pipe(
100 Effect.provideService(FileSystem.FileSystem, {
101 ...fileSystem,
102 stat: (path) =>
103 Effect.fail(
104 PlatformError.systemError({
105 _tag: "PermissionDenied",
106 module: "FileSystem",
107 method: "stat",
108 pathOrDescriptor: String(path),
109 description: "Test PermissionDenied stat failure.",
110 }),
111 ),
112 }),
113 );
114 const path = yield* Path.Path;
115 const workspaceRoot = " ./permission-denied ";
116 const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim());
117
118 const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip);
119
120 expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError);
121 expect(error).toMatchObject({
122 workspaceRoot,
123 normalizedWorkspaceRoot,
124 phase: "validate-existing",
125 });
126 }),
127 );
128
129 it.effect("preserves stat failures while verifying a newly created root", () =>
130 Effect.gen(function* () {
131 const fileSystem = yield* FileSystem.FileSystem;
132 let statCalls = 0;
133 const workspacePaths = yield* WorkspacePaths.make.pipe(
134 Effect.provideService(FileSystem.FileSystem, {
135 ...fileSystem,
136 stat: (path) => {
137 statCalls += 1;
138 const reason = statCalls === 1 ? "NotFound" : "PermissionDenied";
139 return Effect.fail(
140 PlatformError.systemError({
141 _tag: reason,
142 module: "FileSystem",
143 method: "stat",
144 pathOrDescriptor: String(path),
145 description: `Test ${reason} stat failure.`,
146 }),
147 );
148 },
149 makeDirectory: () => Effect.void,
150 }),
151 );
152 const path = yield* Path.Path;
153 const workspaceRoot = " ./created-then-unreadable ";
154 const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim());
155
156 const error = yield* workspacePaths
157 .normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true })
158 .pipe(Effect.flip);
159
160 expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError);
161 expect(error).toMatchObject({
162 workspaceRoot,
163 normalizedWorkspaceRoot,
164 phase: "verify-created",
165 });
166 }),
167 );
94168 });
95169
96170 describe("resolveRelativePathWithinRoot", () => {
apps/server/src/workspace/WorkspacePaths.ts+52-7
......@@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<Work
4040 }
4141}
4242
43export class WorkspaceRootStatFailedError extends Schema.TaggedErrorClass<WorkspaceRootStatFailedError>()(
44 "WorkspaceRootStatFailedError",
45 {
46 workspaceRoot: Schema.String,
47 normalizedWorkspaceRoot: Schema.String,
48 phase: Schema.Literals(["validate-existing", "verify-created"]),
49 cause: Schema.Defect(),
50 },
51) {
52 override get message(): string {
53 return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`;
54 }
55}
56
4357export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()(
4458 "WorkspaceRootNotDirectoryError",
4559 {
......@@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works
6781export const WorkspacePathsError = Schema.Union([
6882 WorkspaceRootNotExistsError,
6983 WorkspaceRootCreateFailedError,
84 WorkspaceRootStatFailedError,
7085 WorkspaceRootNotDirectoryError,
7186 WorkspacePathOutsideRootError,
7287]);
......@@ -82,7 +97,10 @@ export class WorkspacePaths extends Context.Service<
8297 options?: { readonly createIfMissing?: boolean },
8398 ) => Effect.Effect<
8499 string,
85 WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError
100 | WorkspaceRootNotExistsError
101 | WorkspaceRootCreateFailedError
102 | WorkspaceRootStatFailedError
103 | WorkspaceRootNotDirectoryError
86104 >;
87105 /**
88106 * Resolve a relative path within a validated workspace root.
......@@ -117,13 +135,38 @@ export const make = Effect.gen(function* () {
117135 const fileSystem = yield* FileSystem.FileSystem;
118136 const path = yield* Path.Path;
119137
138 const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* (
139 workspaceRoot: string,
140 normalizedWorkspaceRoot: string,
141 phase: WorkspaceRootStatFailedError["phase"],
142 ) {
143 return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe(
144 Effect.matchEffect({
145 onFailure: (cause) =>
146 cause.reason._tag === "NotFound"
147 ? Effect.succeed(null)
148 : Effect.fail(
149 new WorkspaceRootStatFailedError({
150 workspaceRoot,
151 normalizedWorkspaceRoot,
152 phase,
153 cause,
154 }),
155 ),
156 onSuccess: Effect.succeed,
157 }),
158 );
159 });
160
120161 const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn(
121162 "WorkspacePaths.normalizeWorkspaceRoot",
122163 )(function* (workspaceRoot, options) {
123164 const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
124 let workspaceStat = yield* fileSystem
125 .stat(normalizedWorkspaceRoot)
126 .pipe(Effect.orElseSucceed(() => null));
165 let workspaceStat = yield* statWorkspaceRoot(
166 workspaceRoot,
167 normalizedWorkspaceRoot,
168 "validate-existing",
169 );
127170 if (!workspaceStat && options?.createIfMissing) {
128171 yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
129172 Effect.mapError(
......@@ -135,9 +178,11 @@ export const make = Effect.gen(function* () {
135178 }),
136179 ),
137180 );
138 workspaceStat = yield* fileSystem
139 .stat(normalizedWorkspaceRoot)
140 .pipe(Effect.orElseSucceed(() => null));
181 workspaceStat = yield* statWorkspaceRoot(
182 workspaceRoot,
183 normalizedWorkspaceRoot,
184 "verify-created",
185 );
141186 }
142187 if (!workspaceStat) {
143188 return yield* new WorkspaceRootNotExistsError({
apps/server/src/ws.ts+6
......@@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr
149149 failure: "workspace_root_create_failed",
150150 normalizedCwd: error.normalizedWorkspaceRoot,
151151 };
152 case "WorkspaceRootStatFailedError":
153 return {
154 failure: "workspace_root_stat_failed",
155 normalizedCwd: error.normalizedWorkspaceRoot,
156 detail: error.phase,
157 };
152158 case "WorkspaceRootNotDirectoryError":
153159 return {
154160 failure: "workspace_root_not_directory",
packages/contracts/src/project.ts+1
......@@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;
4040export const ProjectEntriesFailure = Schema.Literals([
4141 "workspace_root_not_found",
4242 "workspace_root_create_failed",
43 "workspace_root_stat_failed",
4344 "workspace_root_not_directory",
4445 "search_index_create_failed",
4546 "search_index_scan_timed_out",