Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/server/src/mcp/linkOrigin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.t
import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
Expand Down Expand Up @@ -157,6 +158,7 @@ const layerTools = Layer.mergeAll(
}),
}),
),
Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer
import * as ServerConfig from "../../../config.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import * as Project from "../../../project/ProjectService.ts";
import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
import * as PeerForwarding from "../../../peer/PeerForwarding.ts";
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
Expand Down Expand Up @@ -531,3 +532,47 @@ it.effect("a client launches with the fallback for an exhausted default", () =>
expect(launched[0]?.modelSelection).toEqual(fallbackModel);
}),
);

it.effect("deleting a project through MCP also discards its clone record", () =>
Effect.gen(function* () {
const discarded: Array<ProjectId> = [];
const harness = clientLaunchHarness({ runtimeModeCeiling: "full-access", launched: [] });
const dependencies = Layer.merge(
harness.dependencies,
Layer.mergeAll(
Layer.mock(Project.ProjectService)({
delete: (input) =>
Effect.succeed({
id: input.projectId,
title: "client-target",
workspaceRoot: "/projects/client-target",
repositoryIdentity: null,
faviconPath: null,
defaultModelSelection: null,
defaultThreadEnvMode: null,
autoPull: false,
projectIcon: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: "2026-01-01T00:00:00.000Z",
}),
}),
Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({
discard: (projectId) => Effect.sync(() => void discarded.push(projectId)),
}),
),
);
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);

yield* toolkit
.handle("t3_project_delete", { projectId: harness.projectId })
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));

expect(discarded).toEqual([harness.projectId]);
}),
);
11 changes: 8 additions & 3 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"
import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
import * as Project from "../../../project/ProjectService.ts";
import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts";
import { mutateProjectWithClone } from "../../../project/ProjectMutation.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
Expand Down Expand Up @@ -343,9 +345,12 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
t3_project_delete: McpToolAccess.writesEnvironment((input) =>
Effect.gen(function* () {
const projects = yield* Project.ProjectService;
return yield* projects
.delete({ ...input, commandId: yield* newCommandId() })
.pipe(Effect.mapError(projectFailure));
const clones = yield* ProjectCloneTracker.ProjectCloneTracker;
return yield* mutateProjectWithClone(projects, clones, {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
...input,
type: "project.delete",
commandId: yield* newCommandId(),
}).pipe(Effect.mapError(projectFailure));
}),
),
t3_project_clone: McpToolAccess.writesEnvironment(
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as Crypto from "effect/Crypto";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/ai";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts";
Expand Down Expand Up @@ -101,6 +102,7 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", {
projectId: ProjectId,
force: Schema.optionalKey(Schema.Boolean),
}),
dependencies: [...shared.dependencies, ProjectCloneTracker.ProjectCloneTracker],
}).annotate(Tool.Destructive, true);
const ProjectCloneTool = Tool.make("t3_project_clone", {
...shared,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/peer/PeerForwarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
Expand Down Expand Up @@ -227,6 +228,7 @@ const serveBox = (seen: Ref.Ref<Seen>, linkSession: Ref.Ref<string>) =>
Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
Layer.provide(ServerSettings.layerTest()),
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Expand Down Expand Up @@ -307,6 +309,7 @@ const makeLaptop = Effect.gen(function* () {
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
ServerSettings.layerTest(),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/peer/RemoteDelegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.t
import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
Expand Down Expand Up @@ -495,6 +496,7 @@ const boxToolkitLayer = (thread: BoxThread) => {
}),
}),
),
Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
Layer.provide(ServerSettings.layerTest()),
Layer.provide(NodeServices.layer),
Expand Down Expand Up @@ -594,6 +596,7 @@ const makeLaptop = (
Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Expand Down
78 changes: 78 additions & 0 deletions apps/server/src/project/ProjectMutation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "@effect/vitest";
import { CommandId, type Project, ProjectId } from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";

import { ProjectNotEmptyError } from "./ProjectService.ts";
import { mutateProjectWithClone } from "./ProjectMutation.ts";

const projectId = ProjectId.make("project-1");
const deletedProject: Project = {
id: projectId,
title: "kh-frontend",
workspaceRoot: "/workspace/kh-frontend",
repositoryIdentity: null,
faviconPath: null,
defaultModelSelection: null,
defaultThreadEnvMode: null,
autoPull: false,
projectIcon: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: "2026-01-01T00:00:00.000Z",
};

describe("mutateProjectWithClone", () => {
it.effect("keeps the clone when deletion is rejected", () =>
Effect.gen(function* () {
const rejected = new ProjectNotEmptyError({ projectId });
const result = yield* mutateProjectWithClone(
{
create: () => Effect.die("unused"),
update: () => Effect.die("unused"),
delete: () => Effect.fail(rejected),
},
{ discard: () => Effect.die("must not discard a project that still exists") },
{ type: "project.delete", commandId: CommandId.make("rejected-delete"), projectId },
).pipe(Effect.flip);

expect(result).toBe(rejected);
}),
);

it.effect("discards the clone record even when the caller disconnects mid-delete", () =>
Effect.gen(function* () {
const committed = yield* Deferred.make<void>();
const finishDelete = yield* Deferred.make<void>();
const discarded: Array<ProjectId> = [];
const projects = {
create: () => Effect.die("unused"),
update: () => Effect.die("unused"),
// The delete event is already durable while its post-commit cleanup runs.
delete: () =>
Deferred.succeed(committed, undefined).pipe(
Effect.andThen(Deferred.await(finishDelete)),
Effect.as(deletedProject),
),
};
const clones = {
discard: (id: ProjectId) => Effect.sync(() => void discarded.push(id)),
};

const request = yield* mutateProjectWithClone(projects, clones, {
type: "project.delete",
commandId: CommandId.make("command-1"),
projectId,
}).pipe(Effect.forkChild);
yield* Deferred.await(committed);
// Delivered synchronously so it lands before the delete can finish.
request.interruptUnsafe();
yield* Deferred.succeed(finishDelete, undefined);
yield* Fiber.await(request);

expect(discarded).toEqual([projectId]);
}),
);
});
15 changes: 15 additions & 0 deletions apps/server/src/project/ProjectMutation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ProjectMutation } from "@t3tools/contracts";
import * as Effect from "effect/Effect";

import { type ProjectCloneTracker } from "./ProjectCloneTracker.ts";
import { type ProjectService } from "./ProjectService.ts";

type ProjectMutations = Pick<ProjectService["Service"], "create" | "delete" | "update">;
Expand Down Expand Up @@ -51,3 +52,17 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu
});
}
});

// A disconnect after deletion commits must not leave the in-memory clone record behind.
export const mutateProjectWithClone = Effect.fn("mutateProjectWithClone")(function* (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
projects: ProjectMutations,
clones: Pick<ProjectCloneTracker["Service"], "discard">,
mutation: ProjectMutation,
) {
if (mutation.type !== "project.delete")
return yield* projectMutationOperation(projects, mutation);
return yield* projectMutationOperation(projects, mutation).pipe(
Effect.tap(() => clones.discard(mutation.projectId)),
Effect.uninterruptible,
);
});
6 changes: 4 additions & 2 deletions apps/server/src/project/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
} from "../auth/http.ts";
import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts";
import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
import * as ProjectCloneTracker from "./ProjectCloneTracker.ts";
import * as ProjectService from "./ProjectService.ts";
import { projectMutationOperation } from "./ProjectMutation.ts";
import { mutateProjectWithClone } from "./ProjectMutation.ts";

const failProjectMutation = Effect.fn("environment.projects.failMutation")(function* (
cause: ProjectService.ProjectServiceError | ServerRuntimeStartup.ServerRuntimeStartupError,
Expand All @@ -36,6 +37,7 @@ export const layer = HttpApiBuilder.group(
Effect.fnUntraced(function* (handlers) {
const projects = yield* ProjectService.ProjectService;
const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
const projectClones = yield* ProjectCloneTracker.ProjectCloneTracker;

return handlers
.handle(
Expand All @@ -56,7 +58,7 @@ export const layer = HttpApiBuilder.group(
for (const scope of requiredScopesForProjectMutation(args.payload)) {
yield* requireEnvironmentScope(scope);
}
const operation = projectMutationOperation(projects, args.payload);
const operation = mutateProjectWithClone(projects, projectClones, args.payload);
return yield* startup
.enqueueCommand(operation)
.pipe(traceLocalHandlerWork, Effect.catch(failProjectMutation));
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/AgentContextBudget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { RemoteDelegation } from "../peer/RemoteDelegation.ts";
import { PreviewManager } from "../preview/Manager.ts";
import { PreviewBrowser } from "../preview/PreviewBrowser.ts";
import { ManagedProjectFolders } from "../project/ManagedProjectFolders.ts";
import { ProjectCloneTracker } from "../project/ProjectCloneTracker.ts";
import { ProjectService } from "../project/ProjectService.ts";
import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts";
import { PullRequestReviewGuideService } from "../pullRequest/PullRequestReviewGuideService.ts";
Expand Down Expand Up @@ -58,6 +59,7 @@ const toolHandlerServices = Layer.mergeAll(
Layer.mock(PreviewAutomationBroker)({}),
Layer.mock(PreviewBrowser)({}),
Layer.mock(PreviewManager)({}),
Layer.mock(ProjectCloneTracker)({}),
Layer.mock(ProjectService)({}),
Layer.mock(ProjectSetupScriptRunner)({}),
Layer.mock(ProviderAdapterRegistryV2)({}),
Expand Down
7 changes: 2 additions & 5 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ import * as ProjectService from "./project/ProjectService.ts";
import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts";
import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts";
import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts";
import { projectMutationOperation } from "./project/ProjectMutation.ts";
import { mutateProjectWithClone } from "./project/ProjectMutation.ts";
import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
Expand Down Expand Up @@ -1856,10 +1856,7 @@ export const layerWsRpc = (
);

const mutateProject = Effect.fn("ws.projects.mutate")(function* (mutation: ProjectMutation) {
const result = yield* projectMutationOperation(projectService, mutation);
if (mutation.type === "project.delete")
yield* projectCloneTracker.discard(mutation.projectId);
return result;
return yield* mutateProjectWithClone(projectService, projectCloneTracker, mutation);
});

const handlers = ServerWsRpcGroup.of({
Expand Down
Loading