diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index f5ab794bce7..b59ded77f4f 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -6,6 +6,7 @@ import type { ProjectId, ThreadId, } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Effect from "effect/Effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -71,6 +72,30 @@ export function requireProjectAbsent(input: { ); } +export function requireActiveProjectWorkspaceRootAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly workspaceRoot: string; + readonly exceptProjectId?: ProjectId; +}): Effect.Effect { + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot); + const existingProject = input.readModel.projects.find( + (project) => + project.deletedAt === null && + normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot && + project.id !== input.exceptProjectId, + ); + if (existingProject === undefined) { + return Effect.void; + } + return Effect.fail( + invariantError( + input.command.type, + `Active project '${existingProject.id}' already exists for workspace root '${normalizedWorkspaceRoot}'.`, + ), + ); +} + export function requireThread(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 64ba159c740..a0c06840733 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,117 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("rejects project.create for an active workspace root that already exists", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const readModel = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-existing"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-existing"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.create", + commandId: CommandId.make("cmd-project-create-duplicate-root"), + projectId: asProjectId("project-duplicate-root"), + title: "Duplicate Project", + workspaceRoot: "/tmp/project/", + createdAt: now, + }, + readModel, + }), + ); + + expect(failure.message).toContain( + "Active project 'project-existing' already exists for workspace root '/tmp/project'.", + ); + }), + ); + + it.effect("rejects project.meta.update when moving onto another active workspace root", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const withFirstProject = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-first"), + aggregateKind: "project", + aggregateId: asProjectId("project-first"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-first"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-first"), + metadata: {}, + payload: { + projectId: asProjectId("project-first"), + title: "First", + workspaceRoot: "/tmp/project-first", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + const readModel = yield* projectEvent(withFirstProject, { + sequence: 2, + eventId: asEventId("evt-project-create-second"), + aggregateKind: "project", + aggregateId: asProjectId("project-second"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-second"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-second"), + metadata: {}, + payload: { + projectId: asProjectId("project-second"), + title: "Second", + workspaceRoot: "/tmp/project-second", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-update-duplicate-root"), + projectId: asProjectId("project-second"), + workspaceRoot: "/tmp/project-first", + }, + readModel, + }), + ); + + expect(failure.message).toContain( + "Active project 'project-first' already exists for workspace root '/tmp/project-first'.", + ); + }), + ); + it.effect("emits user message and turn-start-requested events for thread.turn.start", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9c95b42269d..1730494ecc6 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -12,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, + requireActiveProjectWorkspaceRootAbsent, requireProject, requireProjectAbsent, requireThread, @@ -111,6 +112,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); return { ...(yield* withEventBase({ @@ -138,6 +145,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + if (command.workspaceRoot !== undefined) { + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 8e5c004b459..5a59a022363 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -974,8 +974,13 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { if (String(event.threadId) !== String(threadId)) { return; } - if (event.type === "request.opened" && !interrupted) { + if (event.type === "request.opened" && event.requestId && !interrupted) { interrupted = true; + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "cancel", + ); yield* adapter.interruptTurn(threadId); return; } @@ -1030,15 +1035,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { entry.result.outcome !== null && "outcome" in entry.result.outcome && entry.result.outcome.outcome === "cancelled"; - const cancelRequests = yield* waitForJsonLogMatch( - requestLogPath, - (entry) => entry.method === "session/cancel", - ); const approvalResponses = yield* waitForJsonLogMatch( requestLogPath, isCancelledApprovalResponse, ); - assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); yield* adapter.stopSession(threadId); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..95dc01e6a2d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3181,8 +3181,9 @@ export default function Sidebar() { return buildPhysicalToLogicalProjectKeyMap({ projects: orderedProjects, settings: projectGroupingSettings, + primaryEnvironmentId, }); - }, [orderedProjects, projectGroupingSettings]); + }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); const projectPhysicalKeyByScopedRef = useMemo( () => new Map( diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index c66bf4977b2..2e4e9c7f3dd 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -7,6 +7,10 @@ import { derivePhysicalProjectKey, resolveProjectGroupingMode, } from "./logicalProject"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, +} from "./sidebarProjectGrouping"; import type { Project } from "./types"; const primaryEnvironmentId = EnvironmentId.make("env-primary"); @@ -119,4 +123,121 @@ describe("environment grouping", () => { }), ).toBe("separate"); }); + + it("dedupes stale project rows with the same environment and workspace path", () => { + const duplicate = makeProject({ + id: ProjectId.make("project-duplicate"), + workspaceRoot: "/tmp/shared-repo/", + repositoryIdentity, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const primary = makeProject({ + id: ProjectId.make("project-primary"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity, + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [primary, duplicate, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === remoteEnvironmentId ? "remote" : "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.groupedProjectCount).toBe(2); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([ + primary.id, + remote.id, + ]); + }); + + it("prefers the fresher project row when duplicate stale rows are ordered first", () => { + const staleDuplicate = makeProject({ + id: ProjectId.make("project-stale"), + workspaceRoot: "/tmp/shared-repo/", + repositoryIdentity, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [staleDuplicate, canonical], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([canonical.id]); + expect(snapshots[0]?.id).toBe(canonical.id); + }); + + it("dedupes stale project rows before logical grouping", () => { + const staleWithoutRepositoryIdentity = makeProject({ + id: ProjectId.make("project-stale"), + repositoryIdentity: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [staleWithoutRepositoryIdentity, canonical, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === remoteEnvironmentId ? "remote" : "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.projectKey).toBe(repositoryIdentity.canonicalKey); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([ + canonical.id, + remote.id, + ]); + }); + + it("routes duplicate physical project keys to the winning logical group", () => { + const staleWithoutRepositoryIdentity = makeProject({ + id: ProjectId.make("project-stale"), + repositoryIdentity: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + + const physicalToLogicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: [staleWithoutRepositoryIdentity, canonical], + settings: defaultGroupingSettings, + primaryEnvironmentId, + }); + + expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( + repositoryIdentity.canonicalKey, + ); + }); }); diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 599d43143df..7ca9370b514 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -31,16 +31,77 @@ export interface SidebarProjectSnapshot extends Project { remoteEnvironmentLabels: readonly string[]; } +interface SidebarProjectGroupCandidate { + readonly logicalKey: string; + readonly project: Project; +} + +function getProjectFreshnessTime(project: Project): number { + const updatedAtTime = Date.parse(project.updatedAt); + if (Number.isFinite(updatedAtTime)) { + return updatedAtTime; + } + const createdAtTime = Date.parse(project.createdAt); + return Number.isFinite(createdAtTime) ? createdAtTime : 0; +} + +function shouldReplaceDuplicateMember(input: { + existingMember: Project; + candidateMember: Project; + primaryEnvironmentId: EnvironmentId | null; +}): boolean { + if ( + input.primaryEnvironmentId !== null && + input.existingMember.environmentId !== input.primaryEnvironmentId && + input.candidateMember.environmentId === input.primaryEnvironmentId + ) { + return true; + } + + const existingFreshness = getProjectFreshnessTime(input.existingMember); + const candidateFreshness = getProjectFreshnessTime(input.candidateMember); + if (candidateFreshness !== existingFreshness) { + return candidateFreshness > existingFreshness; + } + + return input.candidateMember.id > input.existingMember.id; +} + +function collectProjectWinnersByPhysicalKey(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; + primaryEnvironmentId: EnvironmentId | null; +}): Map { + const winnersByPhysicalKey = new Map(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + const physicalProjectKey = derivePhysicalProjectKey(project); + const existing = winnersByPhysicalKey.get(physicalProjectKey); + if (!existing) { + winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project }); + continue; + } + if ( + shouldReplaceDuplicateMember({ + existingMember: existing.project, + candidateMember: project, + primaryEnvironmentId: input.primaryEnvironmentId, + }) + ) { + winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project }); + } + } + return winnersByPhysicalKey; +} + export function buildPhysicalToLogicalProjectKeyMap(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; + primaryEnvironmentId: EnvironmentId | null; }): Map { const mapping = new Map(); - for (const project of input.projects) { - mapping.set( - derivePhysicalProjectKey(project), - deriveLogicalProjectKeyFromSettings(project, input.settings), - ); + for (const [physicalProjectKey, winner] of collectProjectWinnersByPhysicalKey(input)) { + mapping.set(physicalProjectKey, winner.logicalKey); } return mapping; } @@ -56,17 +117,17 @@ export function buildSidebarProjectSnapshots(input: { // legacy behavior. isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; }): SidebarProjectSnapshot[] { + const winnersByPhysicalKey = collectProjectWinnersByPhysicalKey(input); const groupedMembers = new Map(); - for (const project of input.projects) { - const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + for (const { logicalKey, project } of winnersByPhysicalKey.values()) { const member: SidebarProjectGroupMember = { ...project, physicalProjectKey: derivePhysicalProjectKey(project), environmentLabel: input.resolveEnvironmentLabel(project.environmentId), }; - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(member); + const existingMembers = groupedMembers.get(logicalKey); + if (existingMembers) { + existingMembers.push(member); } else { groupedMembers.set(logicalKey, [member]); } diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts index 82a43350650..31f5ce11199 100644 --- a/packages/client-runtime/src/state/projects.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -3,16 +3,16 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, } from "@t3tools/shared/path"; +export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch }; + const isWindowsPlatform = (platform: string): boolean => { return /^win(dows)?/i.test(platform); }; -function isRootPath(value: string): boolean { - return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); -} - function getAbsolutePathKind(value: string): "unix" | "windows" | null { if (isWindowsDrivePath(value) || isUncPath(value)) { return "windows"; @@ -23,20 +23,6 @@ function getAbsolutePathKind(value: string): "unix" | "windows" | null { return null; } -function trimTrailingPathSeparators(value: string): string { - if (value.length === 0 || isRootPath(value)) { - return value; - } - const trimmed = - getAbsolutePathKind(value) === "unix" - ? value.replace(/\/+$/g, "") - : value.replace(/[\\/]+$/g, ""); - if (trimmed.length === 0) { - return value; - } - return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; -} - function preferredPathSeparator(value: string): "/" | "\\" { const absolutePathKind = getAbsolutePathKind(value); if (absolutePathKind === "windows") return "\\"; @@ -108,10 +94,6 @@ export function isUnsupportedWindowsProjectPath(value: string, platform: string) return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); } -export function normalizeProjectPathForDispatch(value: string): string { - return trimTrailingPathSeparators(value.trim()); -} - export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { const trimmedValue = value.trim(); if (!isExplicitRelativePath(trimmedValue) || !cwd) { @@ -139,14 +121,6 @@ export function resolveProjectPathForDispatch(value: string, cwd?: string | null ); } -export function normalizeProjectPathForComparison(value: string): string { - const normalized = normalizeProjectPathForDispatch(value); - if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { - return normalized.replaceAll("/", "\\").toLowerCase(); - } - return normalized; -} - export function findProjectByPath( projects: ReadonlyArray, candidatePath: string, @@ -198,7 +172,7 @@ export function ensureBrowseDirectoryPath(currentPath: string): string { } export function getBrowseParentPath(currentPath: string): string | null { - const trimmed = trimTrailingPathSeparators(currentPath); + const trimmed = normalizeProjectPathForDispatch(currentPath); const absolutePath = splitAbsolutePath(trimmed); if (absolutePath) { if (absolutePath.segments.length === 0) return null; diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index 2bb2ca0238d..66887d3f2ec 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -20,3 +20,32 @@ export function isExplicitRelativePath(value: string): boolean { value.startsWith("..\\") ); } + +function isRootPath(value: string): boolean { + return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); +} + +function trimTrailingPathSeparators(value: string): string { + if (value.length === 0 || isRootPath(value)) { + return value; + } + const trimmed = value.startsWith("/") + ? value.replace(/\/+$/g, "") + : value.replace(/[\\/]+$/g, ""); + if (trimmed.length === 0) { + return value; + } + return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; +} + +export function normalizeProjectPathForDispatch(value: string): string { + return trimTrailingPathSeparators(value.trim()); +} + +export function normalizeProjectPathForComparison(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + if (isWindowsDrivePath(normalized) || isUncPath(normalized)) { + return normalized.replaceAll("/", "\\").toLowerCase(); + } + return normalized; +}