Skip to content
Merged
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
25 changes: 25 additions & 0 deletions apps/server/src/orchestration/commandInvariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,6 +72,30 @@ export function requireProjectAbsent(input: {
);
}

export function requireActiveProjectWorkspaceRootAbsent(input: {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
readonly workspaceRoot: string;
readonly exceptProjectId?: ProjectId;
}): Effect.Effect<void, OrchestrationCommandInvariantError> {
const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot);
const existingProject = input.readModel.projects.find(
(project) =>
project.deletedAt === null &&
normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot &&
project.id !== input.exceptProjectId,
Comment thread
cursor[bot] marked this conversation as resolved.
);
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;
Expand Down
111 changes: 111 additions & 0 deletions apps/server/src/orchestration/decider.projectScripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError";
import { OrchestrationCommandInvariantError } from "./Errors.ts";
import {
listThreadsByProjectId,
requireActiveProjectWorkspaceRootAbsent,
requireProject,
requireProjectAbsent,
requireThread,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
12 changes: 6 additions & 6 deletions apps/server/src/provider/Layers/CursorAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
121 changes: 121 additions & 0 deletions apps/web/src/environmentGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
);
});
});
Loading
Loading