Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorCl
},
) {
override get message(): string {
return `Project setup script project was not found for thread '${this.threadId}'.`;
return "Project was not found for setup script execution.";
}
}

Expand Down
69 changes: 56 additions & 13 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4430,24 +4430,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
);

it.effect("routes websocket rpc projects.searchEntries errors", () =>
it.effect("preserves workspace rpc failure messages", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-ws-workspace-errors-",
});
const outsideDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-ws-workspace-errors-outside-",
});
const outsideFile = path.join(outsideDir, "outside.txt");
yield* fs.writeFileString(outsideFile, "outside\n");
yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt"));

yield* buildAppUnderTest();

const invalidWorkspace = path.join(workspaceDir, "missing-workspace");
const missingBrowseParent = path.join(workspaceDir, "missing-browse");
const wsUrl = yield* getWsServerUrl("/ws");
const result = yield* Effect.scoped(
const results = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.projectsSearchEntries]({
cwd: "/definitely/not/a/real/workspace/path",
query: "needle",
limit: 10,
Effect.all({
search: client[WS_METHODS.projectsSearchEntries]({
cwd: invalidWorkspace,
query: "needle",
limit: 10,
}).pipe(Effect.result),
list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe(
Effect.result,
),
read: client[WS_METHODS.projectsReadFile]({
cwd: workspaceDir,
relativePath: "linked-outside.txt",
}).pipe(Effect.result),
browse: client[WS_METHODS.filesystemBrowse]({
cwd: workspaceDir,
partialPath: "./missing-browse/child",
}).pipe(Effect.result),
}),
).pipe(Effect.result),
),
);

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "ProjectSearchEntriesError");
assert.equal(result.failure.message, "Failed to search workspace entries.");
assertTrue(results.search._tag === "Failure");
assert.equal(
results.search.failure.message,
`Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`,
);
assertTrue(results.list._tag === "Failure");
assert.equal(
results.list.failure.message,
`Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`,
);
assertTrue(results.read._tag === "Failure");
assert.equal(
results.read.failure.message,
"Failed to read workspace file: Workspace file path resolves outside the project root.",
);
assertTrue(results.browse._tag === "Failure");
assert.equal(
results.browse.failure.message,
`Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`,
);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

Expand Down Expand Up @@ -6102,7 +6146,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
threadId: input.threadId,
worktreePath: input.worktreePath,
operation: "openTerminal",
cause: new Error("pty unavailable"),
cause: { message: "pty unavailable" },
}),
),
);
Expand Down Expand Up @@ -6177,8 +6221,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
);
assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed");
assert.deepEqual(setupFailureActivity?.activity.payload, {
detail:
"Project setup script operation 'openTerminal' failed for thread 'thread-bootstrap-setup-failure' in '/tmp/bootstrap-worktree'.",
detail: "pty unavailable",
worktreePath: "/tmp/bootstrap-worktree",
});
assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete"));
Expand Down
62 changes: 13 additions & 49 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,6 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p
import * as WorkspacePaths from "./WorkspacePaths.ts";
import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";

export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()(
"WorkspaceEntriesError",
{
cwd: Schema.String,
operation: Schema.Literals([
"workspaceEntries.normalizeWorkspaceRoot",
"workspaceEntries.search",
"workspaceEntries.list",
]),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Workspace entries operation '${this.operation}' failed for '${this.cwd}'.`;
}
}

export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass<WorkspaceEntriesWindowsPathUnsupportedError>()(
"WorkspaceEntriesWindowsPathUnsupportedError",
{
Expand Down Expand Up @@ -87,6 +70,16 @@ export const WorkspaceEntriesBrowseError = Schema.Union([
]);
export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;

export const WorkspaceEntriesError = Schema.Union([
WorkspacePaths.WorkspaceRootNotExistsError,
WorkspacePaths.WorkspaceRootCreateFailedError,
WorkspacePaths.WorkspaceRootNotDirectoryError,
WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,
WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,
WorkspaceSearchIndex.WorkspaceSearchIndexSearchFailed,
]);
export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type;

export class WorkspaceEntries extends Context.Service<
WorkspaceEntries,
{
Expand Down Expand Up @@ -146,16 +139,7 @@ export const make = Effect.gen(function* () {
const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (
cwd: string,
): Effect.fn.Return<string, WorkspaceEntriesError> {
return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
Effect.mapError(
(cause) =>
new WorkspaceEntriesError({
cwd,
operation: "workspaceEntries.normalizeWorkspaceRoot",
cause,
}),
),
);
return yield* workspacePaths.normalizeWorkspaceRoot(cwd);
});

const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")(
Expand Down Expand Up @@ -243,17 +227,7 @@ export const make = Effect.gen(function* () {
return yield* Effect.gen(function* () {
const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
return yield* searchIndex.search(normalizedQuery, input.limit);
}).pipe(
Effect.provide(workspaceSearchIndexes.get(normalizedCwd)),
Effect.mapError(
(cause) =>
new WorkspaceEntriesError({
cwd: input.cwd,
operation: "workspaceEntries.search",
cause,
}),
),
);
}).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd)));
},
);

Expand All @@ -263,17 +237,7 @@ export const make = Effect.gen(function* () {
return yield* Effect.gen(function* () {
const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
return yield* searchIndex.list();
}).pipe(
Effect.provide(workspaceSearchIndexes.get(normalizedCwd)),
Effect.mapError(
(cause) =>
new WorkspaceEntriesError({
cwd: input.cwd,
operation: "workspaceEntries.list",
cause,
}),
),
);
}).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd)));
},
);

Expand Down
84 changes: 77 additions & 7 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,77 @@ const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOu

const nowIso = Effect.map(DateTime.now, DateTime.formatIso);

function unexpectedCompatibilityError(error: never): never {
throw new Error(`Unhandled compatibility error: ${String(error)}`);
}

/** Preserve pre-structured-error display behavior at the RPC boundary. */
function legacyPlatformFailureDescription(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}

/** Preserve the setup runner's broader pre-refactor message normalization. */
function legacySetupFailureDescription(cause: unknown): string {
if (
typeof cause === "object" &&
cause !== null &&
"message" in cause &&
typeof cause.message === "string"
) {
return cause.message;
}
return String(cause);
}

function workspaceEntriesCompatibilityDetail(
error: WorkspaceEntries.WorkspaceEntriesError,
): string {
switch (error._tag) {
case "WorkspaceRootNotExistsError":
return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`;
case "WorkspaceRootCreateFailedError":
return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`;
case "WorkspaceRootNotDirectoryError":
return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`;
case "WorkspaceSearchIndexCreateFailed":
return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`;
case "WorkspaceSearchIndexScanTimedOut":
return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`;
case "WorkspaceSearchIndexSearchFailed":
return `Workspace search failed for '${error.cwd}': ${error.reason}`;
default:
return unexpectedCompatibilityError(error);
}
}

function workspaceBrowseCompatibilityDetail(
error: WorkspaceEntries.WorkspaceEntriesBrowseError,
): string {
switch (error._tag) {
case "WorkspaceEntriesWindowsPathUnsupportedError":
return "Windows-style paths are only supported on Windows.";
case "WorkspaceEntriesCurrentProjectRequiredError":
return "Relative filesystem browse paths require a current project.";
case "WorkspaceEntriesReadDirectoryError":
return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`;
default:
return unexpectedCompatibilityError(error);
}
}

function projectSetupScriptCompatibilityDetail(
error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError,
): string {
switch (error._tag) {
case "ProjectSetupScriptOperationError":
return legacySetupFailureDescription(error.cause);
case "ProjectSetupScriptProjectNotFoundError":
return "Project was not found for setup script execution.";
default:
return unexpectedCompatibilityError(error);
}
}

function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<
OrchestrationEvent,
{
Expand Down Expand Up @@ -561,12 +632,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
: Effect.void;

const recordSetupScriptLaunchFailure = (input: {
readonly error: unknown;
readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError;
readonly requestedAt: string;
readonly worktreePath: string;
}) => {
const detail =
input.error instanceof Error ? input.error.message : "Unknown setup failure.";
const detail = projectSetupScriptCompatibilityDetail(input.error);
return appendSetupScriptActivity({
threadId: command.threadId,
kind: "setup-script.failed",
Expand Down Expand Up @@ -1190,7 +1260,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new ProjectSearchEntriesError({
message: "Failed to search workspace entries.",
message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`,
cause,
}),
),
Expand All @@ -1204,7 +1274,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new ProjectListEntriesError({
message: "Failed to list workspace entries.",
message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`,
cause,
}),
),
Expand All @@ -1218,7 +1288,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError((cause) => {
const message = isWorkspacePathOutsideRootError(cause)
? "Workspace file path must stay within the project root."
: "Failed to read workspace file.";
: `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`;
return new ProjectReadFileError({ message, cause });
}),
),
Expand Down Expand Up @@ -1251,7 +1321,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new FilesystemBrowseError({
message: "Failed to browse the filesystem.",
message: workspaceBrowseCompatibilityDetail(cause),
cause,
}),
),
Expand Down
Loading