diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 6aa889991e7..6a61c0d5c99 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -804,4 +804,47 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + + it("rejects project creation with duplicate workspaceRoot", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-ws-first"), + projectId: asProjectId("project-ws-first"), + title: "First Project", + workspaceRoot: "/tmp/project-same-workspace", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }), + ); + + await expect( + system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-ws-second"), + projectId: asProjectId("project-ws-second"), + title: "Second Project", + workspaceRoot: "/tmp/project-same-workspace", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }), + ), + ).rejects.toThrow("already used by project"); + + const readModel = await system.run(engine.getReadModel()); + expect(readModel.projects.map((p) => p.id)).toEqual([asProjectId("project-ws-first")]); + + await system.dispose(); + }); }); diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 43d665a2c9b..a6f354ae8c4 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -11,11 +11,13 @@ import { import { Effect } from "effect"; import { + findProjectByWorkspaceRoot, findThreadById, listThreadsByProjectId, requireNonNegativeInteger, requireThread, requireThreadAbsent, + requireWorkspaceRootUnique, } from "./commandInvariants.ts"; const now = new Date().toISOString(); @@ -50,6 +52,19 @@ const readModel: OrchestrationReadModel = { updatedAt: now, deletedAt: null, }, + { + id: ProjectId.makeUnsafe("project-deleted"), + title: "Deleted Project", + workspaceRoot: "/tmp/project-deleted", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: now, + }, ], threads: [ { @@ -217,4 +232,144 @@ describe("commandInvariants", () => { ), ).rejects.toThrow("greater than or equal to 0"); }); + + it("finds non-deleted project by workspaceRoot", () => { + expect(findProjectByWorkspaceRoot(readModel, "/tmp/project-a")?.id).toBe("project-a"); + expect(findProjectByWorkspaceRoot(readModel, "/tmp/missing")).toBeUndefined(); + expect(findProjectByWorkspaceRoot(readModel, "/tmp/project-deleted")).toBeUndefined(); + }); + + it("requires unique workspaceRoot for project creation", async () => { + const createCommand: OrchestrationCommand = { + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-create"), + projectId: ProjectId.makeUnsafe("project-new"), + title: "New Project", + workspaceRoot: "/tmp/project-new", + createdAt: now, + }; + + await Effect.runPromise( + requireWorkspaceRootUnique({ + readModel, + command: createCommand, + workspaceRoot: "/tmp/project-new", + }), + ); + + await expect( + Effect.runPromise( + requireWorkspaceRootUnique({ + readModel, + command: createCommand, + workspaceRoot: "/tmp/project-a", + }), + ), + ).rejects.toThrow("already used by project"); + + await Effect.runPromise( + requireWorkspaceRootUnique({ + readModel, + command: createCommand, + workspaceRoot: "/tmp/project-deleted", + }), + ); + }); + + it("requires unique workspaceRoot for project update, excluding self", async () => { + const updateCommand: OrchestrationCommand = { + type: "project.meta.update", + commandId: CommandId.makeUnsafe("cmd-update"), + projectId: ProjectId.makeUnsafe("project-a"), + workspaceRoot: "/tmp/project-a", + }; + + await Effect.runPromise( + requireWorkspaceRootUnique({ + readModel, + command: updateCommand, + workspaceRoot: "/tmp/project-a", + excludeProjectId: ProjectId.makeUnsafe("project-a"), + }), + ); + + await expect( + Effect.runPromise( + requireWorkspaceRootUnique({ + readModel, + command: updateCommand, + workspaceRoot: "/tmp/project-b", + excludeProjectId: ProjectId.makeUnsafe("project-a"), + }), + ), + ).rejects.toThrow("already used by project"); + + await Effect.runPromise( + requireWorkspaceRootUnique({ + readModel, + command: updateCommand, + workspaceRoot: "/tmp/project-new-path", + excludeProjectId: ProjectId.makeUnsafe("project-a"), + }), + ); + }); + + it("allows self-update when pre-existing duplicates place excluded project later in array", async () => { + const duplicateReadModel: OrchestrationReadModel = { + ...readModel, + projects: [ + { + id: ProjectId.makeUnsafe("project-dup-1"), + title: "Dup 1", + workspaceRoot: "/tmp/shared-root", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + { + id: ProjectId.makeUnsafe("project-dup-2"), + title: "Dup 2", + workspaceRoot: "/tmp/shared-root", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + }; + + const updateCommand: OrchestrationCommand = { + type: "project.meta.update", + commandId: CommandId.makeUnsafe("cmd-update-dup"), + projectId: ProjectId.makeUnsafe("project-dup-2"), + title: "Renamed", + }; + + // project-dup-2 updates its own workspaceRoot - should detect project-dup-1 as a conflict + await expect( + Effect.runPromise( + requireWorkspaceRootUnique({ + readModel: duplicateReadModel, + command: updateCommand, + workspaceRoot: "/tmp/shared-root", + excludeProjectId: ProjectId.makeUnsafe("project-dup-2"), + }), + ), + ).rejects.toThrow("already used by project"); + + // project-dup-1 updates its own workspaceRoot - should detect project-dup-2 as a conflict + await expect( + Effect.runPromise( + requireWorkspaceRootUnique({ + readModel: duplicateReadModel, + command: updateCommand, + workspaceRoot: "/tmp/shared-root", + excludeProjectId: ProjectId.makeUnsafe("project-dup-1"), + }), + ), + ).rejects.toThrow("already used by project"); + }); }); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 009fdb190e5..6d5e0486507 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -31,6 +31,38 @@ export function findProjectById( return readModel.projects.find((project) => project.id === projectId); } +export function findProjectByWorkspaceRoot( + readModel: OrchestrationReadModel, + workspaceRoot: string, +): OrchestrationProject | undefined { + return readModel.projects.find( + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, + ); +} + +export function requireWorkspaceRootUnique(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly workspaceRoot: string; + readonly excludeProjectId?: ProjectId; +}): Effect.Effect { + const conflict = input.readModel.projects.find( + (project) => + project.workspaceRoot === input.workspaceRoot && + project.deletedAt === null && + project.id !== input.excludeProjectId, + ); + if (!conflict) { + return Effect.void; + } + return Effect.fail( + invariantError( + input.command.type, + `Workspace root '${input.workspaceRoot}' is already used by project '${conflict.id}'.`, + ), + ); +} + export function listThreadsByProjectId( readModel: OrchestrationReadModel, projectId: ProjectId, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 22f5bcb280d..f816a927d56 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -9,6 +9,7 @@ import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { requireProject, requireProjectAbsent, + requireWorkspaceRootUnique, requireThread, requireThreadArchived, requireThreadAbsent, @@ -64,6 +65,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + yield* requireWorkspaceRootUnique({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + }); return { ...withEventBase({ @@ -91,6 +97,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + if (command.workspaceRoot !== undefined) { + yield* requireWorkspaceRootUnique({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + excludeProjectId: command.projectId, + }); + } const occurredAt = nowIso(); return { ...withEventBase({