Skip to content
Closed
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
43 changes: 43 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
155 changes: 155 additions & 0 deletions apps/server/src/orchestration/commandInvariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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");
});
});
32 changes: 32 additions & 0 deletions apps/server/src/orchestration/commandInvariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

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.

Unused helper duplicates workspace lookup logic

Low Severity

findProjectByWorkspaceRoot is exported but not used by runtime code, and requireWorkspaceRootUnique reimplements the same workspaceRoot lookup inline. This creates dead API surface and duplicated logic in commandInvariants.ts, increasing the chance these checks drift apart later.

Additional Locations (1)
Fix in Cursor Fix in Web

}

export function requireWorkspaceRootUnique(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
readonly workspaceRoot: string;
readonly excludeProjectId?: ProjectId;
}): Effect.Effect<void, OrchestrationCommandInvariantError> {
const conflict = input.readModel.projects.find(
(project) =>
project.workspaceRoot === input.workspaceRoot &&
project.deletedAt === null &&
project.id !== input.excludeProjectId,
);
if (!conflict) {
return Effect.void;
Comment thread
cursor[bot] marked this conversation as resolved.
}
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,
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { OrchestrationCommandInvariantError } from "./Errors.ts";
import {
requireProject,
requireProjectAbsent,
requireWorkspaceRootUnique,
requireThread,
requireThreadArchived,
requireThreadAbsent,
Expand Down Expand Up @@ -64,6 +65,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
command,
projectId: command.projectId,
});
yield* requireWorkspaceRootUnique({
readModel,
command,
workspaceRoot: command.workspaceRoot,
});

return {
...withEventBase({
Expand Down Expand Up @@ -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({
Expand Down
Loading