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";...@@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeSocket from "@effect/platform-node/NodeSocket";2import * as NodeSocket from "@effect/platform-node/NodeSocket";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as NodeCrypto from "node:crypto";4import * as NodeCrypto from "node:crypto";
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
56
6import {7import {
7 AuthAccessTokenType,8 AuthAccessTokenType,
...@@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4541 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4542 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4542 );4543 );
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
4544 it.effect("routes websocket rpc projects.writeFile", () =>4578 it.effect("routes websocket rpc projects.writeFile", () =>
4545 Effect.gen(function* () {4579 Effect.gen(function* () {
4546 const fs = yield* FileSystem.FileSystem;4580 const fs = yield* FileSystem.FileSystem;
apps/server/src/workspace/WorkspaceEntries.ts+1
...@@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ...@@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ
73export const WorkspaceEntriesError = Schema.Union([73export const WorkspaceEntriesError = Schema.Union([
74 WorkspacePaths.WorkspaceRootNotExistsError,74 WorkspacePaths.WorkspaceRootNotExistsError,
75 WorkspacePaths.WorkspaceRootCreateFailedError,75 WorkspacePaths.WorkspaceRootCreateFailedError,
76 WorkspacePaths.WorkspaceRootStatFailedError,
76 WorkspacePaths.WorkspaceRootNotDirectoryError,77 WorkspacePaths.WorkspaceRootNotDirectoryError,
77 WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,78 WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,
78 WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,79 WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,
apps/server/src/workspace/WorkspacePaths.test.ts+74
...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";...@@ -4,6 +4,7 @@ import * 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 Path from "effect/Path";6import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
78
8import * as WorkspacePaths from "./WorkspacePaths.ts";9import * as WorkspacePaths from "./WorkspacePaths.ts";
910
...@@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {...@@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
91 expect(error.message).toContain("Workspace root is not a directory:");92 expect(error.message).toContain("Workspace root is not a directory:");
92 }),93 }),
93 );94 );
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 );
94 });168 });
95169
96 describe("resolveRelativePathWithinRoot", () => {170 describe("resolveRelativePathWithinRoot", () => {
apps/server/src/workspace/WorkspacePaths.ts+52-7
...@@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<Work...@@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<Work
40 }40 }
41}41}
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
43export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()(57export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()(
44 "WorkspaceRootNotDirectoryError",58 "WorkspaceRootNotDirectoryError",
45 {59 {
...@@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works...@@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works
67export const WorkspacePathsError = Schema.Union([81export const WorkspacePathsError = Schema.Union([
68 WorkspaceRootNotExistsError,82 WorkspaceRootNotExistsError,
69 WorkspaceRootCreateFailedError,83 WorkspaceRootCreateFailedError,
84 WorkspaceRootStatFailedError,
70 WorkspaceRootNotDirectoryError,85 WorkspaceRootNotDirectoryError,
71 WorkspacePathOutsideRootError,86 WorkspacePathOutsideRootError,
72]);87]);
...@@ -82,7 +97,10 @@ export class WorkspacePaths extends Context.Service<...@@ -82,7 +97,10 @@ export class WorkspacePaths extends Context.Service<
82 options?: { readonly createIfMissing?: boolean },97 options?: { readonly createIfMissing?: boolean },
83 ) => Effect.Effect<98 ) => Effect.Effect<
84 string,99 string,
85 WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError100 | WorkspaceRootNotExistsError
101 | WorkspaceRootCreateFailedError
102 | WorkspaceRootStatFailedError
103 | WorkspaceRootNotDirectoryError
86 >;104 >;
87 /**105 /**
88 * Resolve a relative path within a validated workspace root.106 * Resolve a relative path within a validated workspace root.
...@@ -117,13 +135,38 @@ export const make = Effect.gen(function* () {...@@ -117,13 +135,38 @@ export const make = Effect.gen(function* () {
117 const fileSystem = yield* FileSystem.FileSystem;135 const fileSystem = yield* FileSystem.FileSystem;
118 const path = yield* Path.Path;136 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
120 const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn(161 const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn(
121 "WorkspacePaths.normalizeWorkspaceRoot",162 "WorkspacePaths.normalizeWorkspaceRoot",
122 )(function* (workspaceRoot, options) {163 )(function* (workspaceRoot, options) {
123 const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));164 const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
124 let workspaceStat = yield* fileSystem165 let workspaceStat = yield* statWorkspaceRoot(
125 .stat(normalizedWorkspaceRoot)166 workspaceRoot,
126 .pipe(Effect.orElseSucceed(() => null));167 normalizedWorkspaceRoot,
168 "validate-existing",
169 );
127 if (!workspaceStat && options?.createIfMissing) {170 if (!workspaceStat && options?.createIfMissing) {
128 yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(171 yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
129 Effect.mapError(172 Effect.mapError(
...@@ -135,9 +178,11 @@ export const make = Effect.gen(function* () {...@@ -135,9 +178,11 @@ export const make = Effect.gen(function* () {
135 }),178 }),
136 ),179 ),
137 );180 );
138 workspaceStat = yield* fileSystem181 workspaceStat = yield* statWorkspaceRoot(
139 .stat(normalizedWorkspaceRoot)182 workspaceRoot,
140 .pipe(Effect.orElseSucceed(() => null));183 normalizedWorkspaceRoot,
184 "verify-created",
185 );
141 }186 }
142 if (!workspaceStat) {187 if (!workspaceStat) {
143 return yield* new WorkspaceRootNotExistsError({188 return yield* new WorkspaceRootNotExistsError({
apps/server/src/ws.ts+6
...@@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr...@@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr
149 failure: "workspace_root_create_failed",149 failure: "workspace_root_create_failed",
150 normalizedCwd: error.normalizedWorkspaceRoot,150 normalizedCwd: error.normalizedWorkspaceRoot,
151 };151 };
152 case "WorkspaceRootStatFailedError":
153 return {
154 failure: "workspace_root_stat_failed",
155 normalizedCwd: error.normalizedWorkspaceRoot,
156 detail: error.phase,
157 };
152 case "WorkspaceRootNotDirectoryError":158 case "WorkspaceRootNotDirectoryError":
153 return {159 return {
154 failure: "workspace_root_not_directory",160 failure: "workspace_root_not_directory",
packages/contracts/src/project.ts+1
...@@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;...@@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;
40export const ProjectEntriesFailure = Schema.Literals([40export const ProjectEntriesFailure = Schema.Literals([
41 "workspace_root_not_found",41 "workspace_root_not_found",
42 "workspace_root_create_failed",42 "workspace_root_create_failed",
43 "workspace_root_stat_failed",
43 "workspace_root_not_directory",44 "workspace_root_not_directory",
44 "search_index_create_failed",45 "search_index_create_failed",
45 "search_index_scan_timed_out",46 "search_index_scan_timed_out",