Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,4 +391,54 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
}),
);
});

describe("createDirectory", () => {
it.effect("creates a missing directory under an existing parent", () =>
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-create-dir-" });
const targetPath = path.join(cwd, "new-project");

const result = yield* workspaceEntries.createDirectory({
partialPath: targetPath,
});

expect(result).toEqual({ directoryPath: targetPath });
const stat = yield* fileSystem.stat(targetPath);
expect(stat.type).toBe("Directory");
}),
);

it.effect("returns the existing directory path when it already exists", () =>
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const path = yield* Path.Path;
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-create-existing-" });
yield* writeTextFile(cwd, "existing/index.ts", "export {};\n");
const existingPath = path.join(cwd, "existing");

const result = yield* workspaceEntries.createDirectory({
partialPath: existingPath,
});

expect(result).toEqual({ directoryPath: existingPath });
}),
);

it.effect("rejects relative paths without cwd", () =>
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;

const error = yield* workspaceEntries
.createDirectory({
partialPath: "./new-dir",
})
.pipe(Effect.flip);

expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError");
}),
);
});
});
164 changes: 161 additions & 3 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import * as Schema from "effect/Schema";
import type {
FilesystemBrowseInput,
FilesystemBrowseResult,
FilesystemCreateDirectoryInput,
FilesystemCreateDirectoryResult,
ProjectListEntriesInput,
ProjectListEntriesResult,
ProjectSearchEntriesInput,
Expand Down Expand Up @@ -63,13 +65,86 @@ export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedErrorClass<
}
}

export const WorkspaceEntriesBrowseError = Schema.Union([
export class WorkspaceEntriesPathAlreadyExistsError extends Schema.TaggedErrorClass<WorkspaceEntriesPathAlreadyExistsError>()(
"WorkspaceEntriesPathAlreadyExistsError",
{
cwd: Schema.optional(Schema.String),
partialPath: Schema.String,
directoryPath: Schema.String,
},
) {
override get message(): string {
const cwd = this.cwd ? ` from '${this.cwd}'` : "";
return `Filesystem path '${this.directoryPath}' already exists while creating '${this.partialPath}'${cwd}.`;
}
}

export class WorkspaceEntriesPathNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceEntriesPathNotDirectoryError>()(
"WorkspaceEntriesPathNotDirectoryError",
{
cwd: Schema.optional(Schema.String),
partialPath: Schema.String,
directoryPath: Schema.String,
},
) {
override get message(): string {
const cwd = this.cwd ? ` from '${this.cwd}'` : "";
return `Filesystem path '${this.directoryPath}' is not a directory while creating '${this.partialPath}'${cwd}.`;
}
}

export class WorkspaceEntriesParentNotFoundError extends Schema.TaggedErrorClass<WorkspaceEntriesParentNotFoundError>()(
"WorkspaceEntriesParentNotFoundError",
{
cwd: Schema.optional(Schema.String),
partialPath: Schema.String,
parentPath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
const cwd = this.cwd ? ` from '${this.cwd}'` : "";
return `Parent directory '${this.parentPath}' does not exist while creating '${this.partialPath}'${cwd}.`;
}
}

export class WorkspaceEntriesCreateDirectoryError extends Schema.TaggedErrorClass<WorkspaceEntriesCreateDirectoryError>()(
"WorkspaceEntriesCreateDirectoryError",
{
cwd: Schema.optional(Schema.String),
partialPath: Schema.String,
directoryPath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
const cwd = this.cwd ? ` from '${this.cwd}'` : "";
return `Failed to create directory '${this.directoryPath}' for '${this.partialPath}'${cwd}.`;
}
}

export const WorkspaceEntriesResolveTargetError = Schema.Union([
WorkspaceEntriesWindowsPathUnsupportedError,
WorkspaceEntriesCurrentProjectRequiredError,
]);
export type WorkspaceEntriesResolveTargetError = typeof WorkspaceEntriesResolveTargetError.Type;

export const WorkspaceEntriesBrowseError = Schema.Union([
WorkspaceEntriesResolveTargetError,
WorkspaceEntriesReadDirectoryError,
]);
export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;

export const WorkspaceEntriesCreateDirectoryErrorUnion = Schema.Union([
WorkspaceEntriesResolveTargetError,
WorkspaceEntriesPathAlreadyExistsError,
WorkspaceEntriesPathNotDirectoryError,
WorkspaceEntriesParentNotFoundError,
WorkspaceEntriesCreateDirectoryError,
]);
export type WorkspaceEntriesCreateDirectoryErrorUnion =
typeof WorkspaceEntriesCreateDirectoryErrorUnion.Type;

export const WorkspaceEntriesError = Schema.Union([
WorkspacePaths.WorkspaceRootNotExistsError,
WorkspacePaths.WorkspaceRootCreateFailedError,
Expand All @@ -87,6 +162,9 @@ export class WorkspaceEntries extends Context.Service<
readonly browse: (
input: FilesystemBrowseInput,
) => Effect.Effect<FilesystemBrowseResult, WorkspaceEntriesBrowseError>;
readonly createDirectory: (
input: FilesystemCreateDirectoryInput,
) => Effect.Effect<FilesystemCreateDirectoryResult, WorkspaceEntriesCreateDirectoryErrorUnion>;
readonly list: (
input: ProjectListEntriesInput,
) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>;
Expand All @@ -110,7 +188,7 @@ function expandHomePath(input: string, path: Path.Path): string {
const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* (
input: FilesystemBrowseInput,
path: Path.Path,
): Effect.fn.Return<string, WorkspaceEntriesBrowseError> {
): Effect.fn.Return<string, WorkspaceEntriesResolveTargetError> {
const platform = yield* HostProcessPlatform;
if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
return yield* new WorkspaceEntriesWindowsPathUnsupportedError({
Expand Down Expand Up @@ -178,6 +256,86 @@ export const make = Effect.gen(function* () {
},
);

const createDirectory: WorkspaceEntries["Service"]["createDirectory"] = Effect.fn(
"WorkspaceEntries.createDirectory",
)(function* (input) {
const resolvedDirectoryPath = yield* resolveBrowseTarget(input, path);
const existingStat = yield* Effect.tryPromise({
try: () => NodeFSP.stat(resolvedDirectoryPath),
catch: (cause) => cause,
}).pipe(
Effect.match({
onFailure: () => null,
onSuccess: (stat) => stat,
}),
);

if (existingStat) {
if (existingStat.isDirectory()) {
return { directoryPath: resolvedDirectoryPath } as const;
}
return yield* new WorkspaceEntriesPathNotDirectoryError({
cwd: input.cwd,
partialPath: input.partialPath,
directoryPath: resolvedDirectoryPath,
});
}

const parentPath = path.dirname(resolvedDirectoryPath);
const parentStat = yield* Effect.tryPromise({
try: () => NodeFSP.stat(parentPath),
catch: (cause) => cause,
}).pipe(
Effect.matchEffect({
onFailure: (cause) =>
Effect.fail(
new WorkspaceEntriesParentNotFoundError({
cwd: input.cwd,
partialPath: input.partialPath,
parentPath,
cause,
}),
),
onSuccess: Effect.succeed,
}),
);
if (!parentStat.isDirectory()) {
return yield* new WorkspaceEntriesPathNotDirectoryError({
cwd: input.cwd,
partialPath: input.partialPath,
directoryPath: parentPath,
});
}

yield* Effect.tryPromise({
try: () => NodeFSP.mkdir(resolvedDirectoryPath),
catch: (cause) => cause,
}).pipe(
Effect.catchIf(
(cause) => (cause as NodeJS.ErrnoException | undefined)?.code === "EEXIST",
() =>
Effect.fail(
new WorkspaceEntriesPathAlreadyExistsError({
cwd: input.cwd,
partialPath: input.partialPath,
directoryPath: resolvedDirectoryPath,
}),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EEXIST breaks directory idempotency

Medium Severity

The createDirectory function can incorrectly fail with path_already_exists when a directory is created by another process between its initial stat check and the mkdir call. This breaks its intended idempotent behavior, causing RPC failures even when the directory exists.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bcccbf6. Configure here.

),
Effect.mapError(
(cause) =>
new WorkspaceEntriesCreateDirectoryError({
cwd: input.cwd,
partialPath: input.partialPath,
directoryPath: resolvedDirectoryPath,
cause,
}),
),
);

return { directoryPath: resolvedDirectoryPath } as const;
});

const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")(
function* (input) {
const resolvedInputPath = yield* resolveBrowseTarget(input, path);
Expand Down Expand Up @@ -251,7 +409,7 @@ export const make = Effect.gen(function* () {
},
);

return WorkspaceEntries.of({ browse, list, refresh, search });
return WorkspaceEntries.of({ browse, createDirectory, list, refresh, search });
});

export const layer = Layer.effect(WorkspaceEntries, make).pipe(
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
OrchestrationReplayEventsError,
type FilesystemBrowseFailure,
FilesystemBrowseError,
type FilesystemCreateDirectoryFailure,
FilesystemCreateDirectoryError,
AssetWorkspaceContextNotFoundError,
AssetWorkspaceContextResolutionError,
EnvironmentAuthorizationError,
Expand Down Expand Up @@ -201,6 +203,31 @@ function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntries
}
}

function filesystemCreateDirectoryFailureContext(
error: WorkspaceEntries.WorkspaceEntriesCreateDirectoryErrorUnion,
): {
readonly failure: FilesystemCreateDirectoryFailure;
readonly directoryPath?: string;
readonly platform?: string;
} {
switch (error._tag) {
case "WorkspaceEntriesWindowsPathUnsupportedError":
return { failure: "windows_path_unsupported", platform: error.platform };
case "WorkspaceEntriesCurrentProjectRequiredError":
return { failure: "current_project_required" };
case "WorkspaceEntriesPathAlreadyExistsError":
return { failure: "path_already_exists", directoryPath: error.directoryPath };
case "WorkspaceEntriesPathNotDirectoryError":
return { failure: "path_not_directory", directoryPath: error.directoryPath };
case "WorkspaceEntriesParentNotFoundError":
return { failure: "parent_not_found", directoryPath: error.parentPath };
case "WorkspaceEntriesCreateDirectoryError":
return { failure: "create_directory_failed", directoryPath: error.directoryPath };
default:
return unexpectedCompatibilityError(error);
}
}

function projectFileFailureContext(
error:
| WorkspaceFileSystem.WorkspaceFileSystemError
Expand Down Expand Up @@ -305,6 +332,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
[WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
[WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
[WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
[WS_METHODS.filesystemCreateDirectory, AuthOrchestrationOperateScope],
[WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
[WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
[WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
Expand Down Expand Up @@ -1404,6 +1432,21 @@ const makeWsRpcLayer = (
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.filesystemCreateDirectory]: (input) =>
observeRpcEffect(
WS_METHODS.filesystemCreateDirectory,
workspaceEntries.createDirectory(input).pipe(
Effect.mapError(
(cause) =>
new FilesystemCreateDirectoryError({
...input,
...filesystemCreateDirectoryFailureContext(cause),
cause,
}),
),
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.assetsCreateUrl]: (input) =>
observeRpcEffect(
WS_METHODS.assetsCreateUrl,
Expand Down
Loading
Loading