diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index d7a1bd15c58cd313c410aa792504df6e50403087..e8d771b74df33e4e5524509ec0ccfd8218e82ea2 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -3,11 +3,16 @@ import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; +const isProjectSetupScriptOperationError = Schema.is( + ProjectSetupScriptRunner.ProjectSetupScriptOperationError, +); + const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ id: ProjectId.make("project-1"), title: "Project", @@ -145,4 +150,50 @@ describe("ProjectSetupScriptRunner", () => { }).pipe(Effect.provide(testLayer(project, { open, write }))); }, ); + + it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { + const rootCause = new Error("stat failed"); + const terminalError = new TerminalManager.TerminalCwdError({ + cwd: "/repo/worktrees/a", + reason: "statFailed", + cause: rootCause, + }); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const error = yield* runner + .runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }) + .pipe(Effect.flip); + + expect(isProjectSetupScriptOperationError(error)).toBe(true); + if (isProjectSetupScriptOperationError(error)) { + expect(error.operation).toBe("openTerminal"); + expect(error.threadId).toBe("thread-1"); + expect(error.projectId).toBe("project-1"); + expect(error.worktreePath).toBe("/repo/worktrees/a"); + expect(error.cause).toBe(terminalError); + expect(terminalError.cause).toBe(rootCause); + } + }).pipe( + Effect.provide( + testLayer(project, { + open: () => Effect.fail(terminalError), + write: () => Effect.die("unexpected write"), + }), + ), + ); + }); }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index dc97da51f24532956a2ed78af9978d1fc38b3f08..41bf0fabf489281aeee98e0482a1d6d8655f6f02 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -59,7 +59,7 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorCl }, ) { override get message(): string { - return "Project was not found for setup script execution."; + return `Project was not found for setup script execution for thread '${this.threadId}' in '${this.worktreePath}'.`; } } @@ -78,32 +78,6 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} -const isProjectSetupScriptRunnerError = Schema.is(ProjectSetupScriptRunnerError); - -function operationError( - input: ProjectSetupScriptRunnerInput, - operation: ProjectSetupScriptOperationError["operation"], - cause: unknown, -): ProjectSetupScriptOperationError { - return new ProjectSetupScriptOperationError({ - threadId: input.threadId, - worktreePath: input.worktreePath, - operation, - cause, - ...(input.projectId === undefined ? {} : { projectId: input.projectId }), - ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), - }); -} - -function mapRunnerError( - input: ProjectSetupScriptRunnerInput, - operation: ProjectSetupScriptOperationError["operation"], -) { - return Effect.mapError((cause: unknown) => - isProjectSetupScriptRunnerError(cause) ? cause : operationError(input, operation, cause), - ); -} - export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; @@ -111,26 +85,43 @@ export const make = Effect.gen(function* () { const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", )(function* (input) { + const errorContext = { + threadId: input.threadId, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; const projectById = input.projectId - ? yield* projectionSnapshotQuery - .getProjectShellById(ProjectId.make(input.projectId)) - .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject")) + ? yield* projectionSnapshotQuery.getProjectShellById(ProjectId.make(input.projectId)).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) : null; const project = projectById ?? (input.projectCwd - ? yield* projectionSnapshotQuery - .getActiveProjectByWorkspaceRoot(input.projectCwd) - .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject")) + ? yield* projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(input.projectCwd).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) : null); if (!project) { - return yield* new ProjectSetupScriptProjectNotFoundError({ - threadId: input.threadId, - worktreePath: input.worktreePath, - ...(input.projectId === undefined ? {} : { projectId: input.projectId }), - ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), - }); + return yield* new ProjectSetupScriptProjectNotFoundError(errorContext); } const script = setupProjectScript(project.scripts); @@ -155,14 +146,32 @@ export const make = Effect.gen(function* () { worktreePath: input.worktreePath, env, }) - .pipe(mapRunnerError(input, "openTerminal")); + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); yield* terminalManager .write({ threadId: input.threadId, terminalId, data: `${script.command}\r`, }) - .pipe(mapRunnerError(input, "writeCommand")); + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "writeCommand", + cause, + }), + ), + ); return { status: "started",