authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-16 10:39:13+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-16 10:39:13+02:00
logd114e2772d059c04460a7e0c25e19ac0c2468f14
tree76f90bc43015680e08cc4e2626e47416e9ad421f
parent0418301de79bb7b6f82305236325cb9811615e9e
signature Signed by PGP key B5690EEEBB952194

Prevent duplicate project workspace roots (#3829)

Co-authored-by: codex <codex@users.noreply.github.com>

9 files changed, 385 insertions(+), 48 deletions(-)

apps/server/src/orchestration/commandInvariants.ts+25
......@@ -6,6 +6,7 @@ import type {
66 ProjectId,
77 ThreadId,
88} from "@t3tools/contracts";
9import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
910import * as Effect from "effect/Effect";
1011
1112import { OrchestrationCommandInvariantError } from "./Errors.ts";
......@@ -71,6 +72,30 @@ export function requireProjectAbsent(input: {
7172 );
7273}
7374
75export function requireActiveProjectWorkspaceRootAbsent(input: {
76 readonly readModel: OrchestrationReadModel;
77 readonly command: OrchestrationCommand;
78 readonly workspaceRoot: string;
79 readonly exceptProjectId?: ProjectId;
80}): Effect.Effect<void, OrchestrationCommandInvariantError> {
81 const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot);
82 const existingProject = input.readModel.projects.find(
83 (project) =>
84 project.deletedAt === null &&
85 normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot &&
86 project.id !== input.exceptProjectId,
87 );
88 if (existingProject === undefined) {
89 return Effect.void;
90 }
91 return Effect.fail(
92 invariantError(
93 input.command.type,
94 `Active project '${existingProject.id}' already exists for workspace root '${normalizedWorkspaceRoot}'.`,
95 ),
96 );
97}
98
7499export function requireThread(input: {
75100 readonly readModel: OrchestrationReadModel;
76101 readonly command: OrchestrationCommand;
apps/server/src/orchestration/decider.projectScripts.test.ts+111
......@@ -94,6 +94,117 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => {
9494 }),
9595 );
9696
97 it.effect("rejects project.create for an active workspace root that already exists", () =>
98 Effect.gen(function* () {
99 const now = "2026-01-01T00:00:00.000Z";
100 const initial = createEmptyReadModel(now);
101 const readModel = yield* projectEvent(initial, {
102 sequence: 1,
103 eventId: asEventId("evt-project-create"),
104 aggregateKind: "project",
105 aggregateId: asProjectId("project-existing"),
106 type: "project.created",
107 occurredAt: now,
108 commandId: CommandId.make("cmd-project-create"),
109 causationEventId: null,
110 correlationId: CommandId.make("cmd-project-create"),
111 metadata: {},
112 payload: {
113 projectId: asProjectId("project-existing"),
114 title: "Project",
115 workspaceRoot: "/tmp/project",
116 defaultModelSelection: null,
117 scripts: [],
118 createdAt: now,
119 updatedAt: now,
120 },
121 });
122
123 const failure = yield* Effect.flip(
124 decideOrchestrationCommand({
125 command: {
126 type: "project.create",
127 commandId: CommandId.make("cmd-project-create-duplicate-root"),
128 projectId: asProjectId("project-duplicate-root"),
129 title: "Duplicate Project",
130 workspaceRoot: "/tmp/project/",
131 createdAt: now,
132 },
133 readModel,
134 }),
135 );
136
137 expect(failure.message).toContain(
138 "Active project 'project-existing' already exists for workspace root '/tmp/project'.",
139 );
140 }),
141 );
142
143 it.effect("rejects project.meta.update when moving onto another active workspace root", () =>
144 Effect.gen(function* () {
145 const now = "2026-01-01T00:00:00.000Z";
146 const initial = createEmptyReadModel(now);
147 const withFirstProject = yield* projectEvent(initial, {
148 sequence: 1,
149 eventId: asEventId("evt-project-create-first"),
150 aggregateKind: "project",
151 aggregateId: asProjectId("project-first"),
152 type: "project.created",
153 occurredAt: now,
154 commandId: CommandId.make("cmd-project-create-first"),
155 causationEventId: null,
156 correlationId: CommandId.make("cmd-project-create-first"),
157 metadata: {},
158 payload: {
159 projectId: asProjectId("project-first"),
160 title: "First",
161 workspaceRoot: "/tmp/project-first",
162 defaultModelSelection: null,
163 scripts: [],
164 createdAt: now,
165 updatedAt: now,
166 },
167 });
168 const readModel = yield* projectEvent(withFirstProject, {
169 sequence: 2,
170 eventId: asEventId("evt-project-create-second"),
171 aggregateKind: "project",
172 aggregateId: asProjectId("project-second"),
173 type: "project.created",
174 occurredAt: now,
175 commandId: CommandId.make("cmd-project-create-second"),
176 causationEventId: null,
177 correlationId: CommandId.make("cmd-project-create-second"),
178 metadata: {},
179 payload: {
180 projectId: asProjectId("project-second"),
181 title: "Second",
182 workspaceRoot: "/tmp/project-second",
183 defaultModelSelection: null,
184 scripts: [],
185 createdAt: now,
186 updatedAt: now,
187 },
188 });
189
190 const failure = yield* Effect.flip(
191 decideOrchestrationCommand({
192 command: {
193 type: "project.meta.update",
194 commandId: CommandId.make("cmd-project-update-duplicate-root"),
195 projectId: asProjectId("project-second"),
196 workspaceRoot: "/tmp/project-first",
197 },
198 readModel,
199 }),
200 );
201
202 expect(failure.message).toContain(
203 "Active project 'project-first' already exists for workspace root '/tmp/project-first'.",
204 );
205 }),
206 );
207
97208 it.effect("emits user message and turn-start-requested events for thread.turn.start", () =>
98209 Effect.gen(function* () {
99210 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/decider.ts+15
......@@ -12,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError";
1212import { OrchestrationCommandInvariantError } from "./Errors.ts";
1313import {
1414 listThreadsByProjectId,
15 requireActiveProjectWorkspaceRootAbsent,
1516 requireProject,
1617 requireProjectAbsent,
1718 requireThread,
......@@ -111,6 +112,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
111112 command,
112113 projectId: command.projectId,
113114 });
115 yield* requireActiveProjectWorkspaceRootAbsent({
116 readModel,
117 command,
118 workspaceRoot: command.workspaceRoot,
119 exceptProjectId: command.projectId,
120 });
114121
115122 return {
116123 ...(yield* withEventBase({
......@@ -138,6 +145,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
138145 command,
139146 projectId: command.projectId,
140147 });
148 if (command.workspaceRoot !== undefined) {
149 yield* requireActiveProjectWorkspaceRootAbsent({
150 readModel,
151 command,
152 workspaceRoot: command.workspaceRoot,
153 exceptProjectId: command.projectId,
154 });
155 }
141156 const occurredAt = yield* nowIso;
142157 return {
143158 ...(yield* withEventBase({
apps/server/src/provider/Layers/CursorAdapter.test.ts+6-6
......@@ -974,8 +974,13 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
974974 if (String(event.threadId) !== String(threadId)) {
975975 return;
976976 }
977 if (event.type === "request.opened" && !interrupted) {
977 if (event.type === "request.opened" && event.requestId && !interrupted) {
978978 interrupted = true;
979 yield* adapter.respondToRequest(
980 threadId,
981 ApprovalRequestId.make(String(event.requestId)),
982 "cancel",
983 );
979984 yield* adapter.interruptTurn(threadId);
980985 return;
981986 }
......@@ -1030,15 +1035,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
10301035 entry.result.outcome !== null &&
10311036 "outcome" in entry.result.outcome &&
10321037 entry.result.outcome.outcome === "cancelled";
1033 const cancelRequests = yield* waitForJsonLogMatch(
1034 requestLogPath,
1035 (entry) => entry.method === "session/cancel",
1036 );
10371038 const approvalResponses = yield* waitForJsonLogMatch(
10381039 requestLogPath,
10391040 isCancelledApprovalResponse,
10401041 );
1041 assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel"));
10421042 assert.isTrue(approvalResponses.some(isCancelledApprovalResponse));
10431043
10441044 yield* adapter.stopSession(threadId);
apps/web/src/components/Sidebar.tsx+2-1
......@@ -3183,8 +3183,9 @@ export default function Sidebar() {
31833183 return buildPhysicalToLogicalProjectKeyMap({
31843184 projects: orderedProjects,
31853185 settings: projectGroupingSettings,
3186 primaryEnvironmentId,
31863187 });
3187 }, [orderedProjects, projectGroupingSettings]);
3188 }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]);
31883189 const projectPhysicalKeyByScopedRef = useMemo(
31893190 () =>
31903191 new Map(
apps/web/src/environmentGrouping.test.ts+121
......@@ -7,6 +7,10 @@ import {
77 derivePhysicalProjectKey,
88 resolveProjectGroupingMode,
99} from "./logicalProject";
10import {
11 buildPhysicalToLogicalProjectKeyMap,
12 buildSidebarProjectSnapshots,
13} from "./sidebarProjectGrouping";
1014import type { Project } from "./types";
1115
1216const primaryEnvironmentId = EnvironmentId.make("env-primary");
......@@ -119,4 +123,121 @@ describe("environment grouping", () => {
119123 }),
120124 ).toBe("separate");
121125 });
126
127 it("dedupes stale project rows with the same environment and workspace path", () => {
128 const duplicate = makeProject({
129 id: ProjectId.make("project-duplicate"),
130 workspaceRoot: "/tmp/shared-repo/",
131 repositoryIdentity,
132 updatedAt: "2026-01-01T00:00:00.000Z",
133 });
134 const primary = makeProject({
135 id: ProjectId.make("project-primary"),
136 repositoryIdentity,
137 updatedAt: "2026-01-02T00:00:00.000Z",
138 });
139 const remote = makeProject({
140 id: ProjectId.make("project-remote"),
141 environmentId: remoteEnvironmentId,
142 workspaceRoot: "/tmp/shared-repo",
143 repositoryIdentity,
144 });
145
146 const snapshots = buildSidebarProjectSnapshots({
147 projects: [primary, duplicate, remote],
148 settings: defaultGroupingSettings,
149 primaryEnvironmentId,
150 resolveEnvironmentLabel: (environmentId) =>
151 environmentId === remoteEnvironmentId ? "remote" : "primary",
152 });
153
154 expect(snapshots).toHaveLength(1);
155 expect(snapshots[0]?.groupedProjectCount).toBe(2);
156 expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([
157 primary.id,
158 remote.id,
159 ]);
160 });
161
162 it("prefers the fresher project row when duplicate stale rows are ordered first", () => {
163 const staleDuplicate = makeProject({
164 id: ProjectId.make("project-stale"),
165 workspaceRoot: "/tmp/shared-repo/",
166 repositoryIdentity,
167 updatedAt: "2026-01-01T00:00:00.000Z",
168 });
169 const canonical = makeProject({
170 id: ProjectId.make("project-canonical"),
171 workspaceRoot: "/tmp/shared-repo",
172 repositoryIdentity,
173 updatedAt: "2026-01-02T00:00:00.000Z",
174 });
175
176 const snapshots = buildSidebarProjectSnapshots({
177 projects: [staleDuplicate, canonical],
178 settings: defaultGroupingSettings,
179 primaryEnvironmentId,
180 resolveEnvironmentLabel: () => "primary",
181 });
182
183 expect(snapshots).toHaveLength(1);
184 expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([canonical.id]);
185 expect(snapshots[0]?.id).toBe(canonical.id);
186 });
187
188 it("dedupes stale project rows before logical grouping", () => {
189 const staleWithoutRepositoryIdentity = makeProject({
190 id: ProjectId.make("project-stale"),
191 repositoryIdentity: null,
192 updatedAt: "2026-01-01T00:00:00.000Z",
193 });
194 const canonical = makeProject({
195 id: ProjectId.make("project-canonical"),
196 repositoryIdentity,
197 updatedAt: "2026-01-02T00:00:00.000Z",
198 });
199 const remote = makeProject({
200 id: ProjectId.make("project-remote"),
201 environmentId: remoteEnvironmentId,
202 repositoryIdentity,
203 });
204
205 const snapshots = buildSidebarProjectSnapshots({
206 projects: [staleWithoutRepositoryIdentity, canonical, remote],
207 settings: defaultGroupingSettings,
208 primaryEnvironmentId,
209 resolveEnvironmentLabel: (environmentId) =>
210 environmentId === remoteEnvironmentId ? "remote" : "primary",
211 });
212
213 expect(snapshots).toHaveLength(1);
214 expect(snapshots[0]?.projectKey).toBe(repositoryIdentity.canonicalKey);
215 expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([
216 canonical.id,
217 remote.id,
218 ]);
219 });
220
221 it("routes duplicate physical project keys to the winning logical group", () => {
222 const staleWithoutRepositoryIdentity = makeProject({
223 id: ProjectId.make("project-stale"),
224 repositoryIdentity: null,
225 updatedAt: "2026-01-01T00:00:00.000Z",
226 });
227 const canonical = makeProject({
228 id: ProjectId.make("project-canonical"),
229 repositoryIdentity,
230 updatedAt: "2026-01-02T00:00:00.000Z",
231 });
232
233 const physicalToLogicalKey = buildPhysicalToLogicalProjectKeyMap({
234 projects: [staleWithoutRepositoryIdentity, canonical],
235 settings: defaultGroupingSettings,
236 primaryEnvironmentId,
237 });
238
239 expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe(
240 repositoryIdentity.canonicalKey,
241 );
242 });
122243});
apps/web/src/sidebarProjectGrouping.ts+71-10
......@@ -31,16 +31,77 @@ export interface SidebarProjectSnapshot extends Project {
3131 remoteEnvironmentLabels: readonly string[];
3232}
3333
34interface SidebarProjectGroupCandidate {
35 readonly logicalKey: string;
36 readonly project: Project;
37}
38
39function getProjectFreshnessTime(project: Project): number {
40 const updatedAtTime = Date.parse(project.updatedAt);
41 if (Number.isFinite(updatedAtTime)) {
42 return updatedAtTime;
43 }
44 const createdAtTime = Date.parse(project.createdAt);
45 return Number.isFinite(createdAtTime) ? createdAtTime : 0;
46}
47
48function shouldReplaceDuplicateMember(input: {
49 existingMember: Project;
50 candidateMember: Project;
51 primaryEnvironmentId: EnvironmentId | null;
52}): boolean {
53 if (
54 input.primaryEnvironmentId !== null &&
55 input.existingMember.environmentId !== input.primaryEnvironmentId &&
56 input.candidateMember.environmentId === input.primaryEnvironmentId
57 ) {
58 return true;
59 }
60
61 const existingFreshness = getProjectFreshnessTime(input.existingMember);
62 const candidateFreshness = getProjectFreshnessTime(input.candidateMember);
63 if (candidateFreshness !== existingFreshness) {
64 return candidateFreshness > existingFreshness;
65 }
66
67 return input.candidateMember.id > input.existingMember.id;
68}
69
70function collectProjectWinnersByPhysicalKey(input: {
71 projects: ReadonlyArray<Project>;
72 settings: ProjectGroupingSettings;
73 primaryEnvironmentId: EnvironmentId | null;
74}): Map<string, SidebarProjectGroupCandidate> {
75 const winnersByPhysicalKey = new Map<string, SidebarProjectGroupCandidate>();
76 for (const project of input.projects) {
77 const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
78 const physicalProjectKey = derivePhysicalProjectKey(project);
79 const existing = winnersByPhysicalKey.get(physicalProjectKey);
80 if (!existing) {
81 winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project });
82 continue;
83 }
84 if (
85 shouldReplaceDuplicateMember({
86 existingMember: existing.project,
87 candidateMember: project,
88 primaryEnvironmentId: input.primaryEnvironmentId,
89 })
90 ) {
91 winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project });
92 }
93 }
94 return winnersByPhysicalKey;
95}
96
3497export function buildPhysicalToLogicalProjectKeyMap(input: {
3598 projects: ReadonlyArray<Project>;
3699 settings: ProjectGroupingSettings;
100 primaryEnvironmentId: EnvironmentId | null;
37101}): Map<string, string> {
38102 const mapping = new Map<string, string>();
39 for (const project of input.projects) {
40 mapping.set(
41 derivePhysicalProjectKey(project),
42 deriveLogicalProjectKeyFromSettings(project, input.settings),
43 );
103 for (const [physicalProjectKey, winner] of collectProjectWinnersByPhysicalKey(input)) {
104 mapping.set(physicalProjectKey, winner.logicalKey);
44105 }
45106 return mapping;
46107}
......@@ -56,17 +117,17 @@ export function buildSidebarProjectSnapshots(input: {
56117 // legacy behavior.
57118 isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean;
58119}): SidebarProjectSnapshot[] {
120 const winnersByPhysicalKey = collectProjectWinnersByPhysicalKey(input);
59121 const groupedMembers = new Map<string, SidebarProjectGroupMember[]>();
60 for (const project of input.projects) {
61 const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
122 for (const { logicalKey, project } of winnersByPhysicalKey.values()) {
62123 const member: SidebarProjectGroupMember = {
63124 ...project,
64125 physicalProjectKey: derivePhysicalProjectKey(project),
65126 environmentLabel: input.resolveEnvironmentLabel(project.environmentId),
66127 };
67 const existing = groupedMembers.get(logicalKey);
68 if (existing) {
69 existing.push(member);
128 const existingMembers = groupedMembers.get(logicalKey);
129 if (existingMembers) {
130 existingMembers.push(member);
70131 } else {
71132 groupedMembers.set(logicalKey, [member]);
72133 }
packages/client-runtime/src/state/projects.ts+5-31
......@@ -3,16 +3,16 @@ import {
33 isUncPath,
44 isWindowsAbsolutePath,
55 isWindowsDrivePath,
6 normalizeProjectPathForComparison,
7 normalizeProjectPathForDispatch,
68} from "@t3tools/shared/path";
79
10export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch };
11
812const isWindowsPlatform = (platform: string): boolean => {
913 return /^win(dows)?/i.test(platform);
1014};
1115
12function isRootPath(value: string): boolean {
13 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
14}
15
1616function getAbsolutePathKind(value: string): "unix" | "windows" | null {
1717 if (isWindowsDrivePath(value) || isUncPath(value)) {
1818 return "windows";
......@@ -23,20 +23,6 @@ function getAbsolutePathKind(value: string): "unix" | "windows" | null {
2323 return null;
2424}
2525
26function trimTrailingPathSeparators(value: string): string {
27 if (value.length === 0 || isRootPath(value)) {
28 return value;
29 }
30 const trimmed =
31 getAbsolutePathKind(value) === "unix"
32 ? value.replace(/\/+$/g, "")
33 : value.replace(/[\\/]+$/g, "");
34 if (trimmed.length === 0) {
35 return value;
36 }
37 return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
38}
39
4026function preferredPathSeparator(value: string): "/" | "\\" {
4127 const absolutePathKind = getAbsolutePathKind(value);
4228 if (absolutePathKind === "windows") return "\\";
......@@ -108,10 +94,6 @@ export function isUnsupportedWindowsProjectPath(value: string, platform: string)
10894 return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
10995}
11096
111export function normalizeProjectPathForDispatch(value: string): string {
112 return trimTrailingPathSeparators(value.trim());
113}
114
11597export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {
11698 const trimmedValue = value.trim();
11799 if (!isExplicitRelativePath(trimmedValue) || !cwd) {
......@@ -139,14 +121,6 @@ export function resolveProjectPathForDispatch(value: string, cwd?: string | null
139121 );
140122}
141123
142export function normalizeProjectPathForComparison(value: string): string {
143 const normalized = normalizeProjectPathForDispatch(value);
144 if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) {
145 return normalized.replaceAll("/", "\\").toLowerCase();
146 }
147 return normalized;
148}
149
150124export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>(
151125 projects: ReadonlyArray<T>,
152126 candidatePath: string,
......@@ -198,7 +172,7 @@ export function ensureBrowseDirectoryPath(currentPath: string): string {
198172}
199173
200174export function getBrowseParentPath(currentPath: string): string | null {
201 const trimmed = trimTrailingPathSeparators(currentPath);
175 const trimmed = normalizeProjectPathForDispatch(currentPath);
202176 const absolutePath = splitAbsolutePath(trimmed);
203177 if (absolutePath) {
204178 if (absolutePath.segments.length === 0) return null;
packages/shared/src/path.ts+29
......@@ -20,3 +20,32 @@ export function isExplicitRelativePath(value: string): boolean {
2020 value.startsWith("..\\")
2121 );
2222}
23
24function isRootPath(value: string): boolean {
25 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
26}
27
28function trimTrailingPathSeparators(value: string): string {
29 if (value.length === 0 || isRootPath(value)) {
30 return value;
31 }
32 const trimmed = value.startsWith("/")
33 ? value.replace(/\/+$/g, "")
34 : value.replace(/[\\/]+$/g, "");
35 if (trimmed.length === 0) {
36 return value;
37 }
38 return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
39}
40
41export function normalizeProjectPathForDispatch(value: string): string {
42 return trimTrailingPathSeparators(value.trim());
43}
44
45export function normalizeProjectPathForComparison(value: string): string {
46 const normalized = normalizeProjectPathForDispatch(value);
47 if (isWindowsDrivePath(normalized) || isUncPath(normalized)) {
48 return normalized.replaceAll("/", "\\").toLowerCase();
49 }
50 return normalized;
51}