diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 03492c3ef366..3d501657243a 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -14,8 +14,12 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import * as ProjectMcpService from "./ProjectMcpService.ts"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import { OrchestratorToolkitHandlersLive } from "./toolkits/orchestrator/handlers.ts"; import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts"; +import { ProjectToolkitHandlersLive } from "./toolkits/project/handlers.ts"; +import { ProjectToolkit } from "./toolkits/project/tools.ts"; import { PreviewSnapshotToolkitHandlersLive, PreviewStandardToolkitHandlersLive, @@ -227,6 +231,12 @@ export const OrchestratorToolkitRegistrationLive = McpServer.toolkit(Orchestrato Layer.provide(OrchestratorMcpService.layer), ); +export const ProjectToolkitRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe( + Layer.provide(ProjectToolkitHandlersLive), + Layer.provide(ProjectMcpService.layer), + Layer.provide(T3ProjectFileLoader.layer), +); + export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe( Layer.provide(WorktreeToolkitHandlersLive), Layer.provide(WorktreeMcpService.layer), @@ -242,5 +252,6 @@ const McpTransportLive = McpServer.layerHttp({ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, + ProjectToolkitRegistrationLive, WorktreeToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index d1fd1975003a..5e14bce87ae3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2,14 +2,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId, + MessageId, NodeId, + ProjectId, + ProviderDriverKind, ProviderInstanceId, RunId, + type ServerProvider, ThreadId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; @@ -19,6 +25,137 @@ import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; describe("OrchestratorMcpService", () => { + it.effect("preserves a structured parent admission failure", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-create-admission-parent"); + const parentRunId = RunId.make("run:mcp-create-admission-parent"); + const parentNodeId = NodeId.make("node:mcp-create-admission-parent"); + const projectId = ProjectId.make("project:mcp-create-admission"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const now = DateTime.makeUnsafe("2026-08-30T12:00:00.000Z"); + const activeParent: OrchestrationV2ThreadProjection = { + thread: { + createdBy: "agent", + creationSource: "mcp", + id: parentThreadId, + projectId, + title: "Admission parent", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-test" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: parentThreadId, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + runs: [ + { + id: parentRunId, + threadId: parentThreadId, + ordinal: 1, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-test" }, + providerThreadId: null, + userMessageId: MessageId.make("message:mcp-create-admission-parent"), + rootNodeId: parentNodeId, + activeAttemptId: null, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + ], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: now, + }; + const archivedParent = { + ...activeParent, + thread: { ...activeParent.thread, archivedAt: now }, + } as OrchestrationV2ThreadProjection; + const provider = { + instanceId: providerInstanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-30T12:00:00.000Z", + models: [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + const projectionReads = yield* Ref.make(0); + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService)({ + getThreadProjection: () => + Ref.updateAndGet(projectionReads, (count) => count + 1).pipe( + Effect.map((count) => (count === 1 ? activeParent : archivedParent)), + ), + withProjectCreationAdmission: (_input, effect) => effect(Option.none()), + dispatch: () => Effect.die("dispatch must not run after parent admission fails"), + }), + Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([provider]) }), + Layer.mock(ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-create-admission"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-create-admission", + providerInstanceId, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + const error = yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service + .createThreads(scope, { + clientRequestId: "parent-admission-failure", + threads: [{ title: "Must not be created" }], + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + + assert.equal(error.code, "parent_not_active"); + assert.equal( + error.message, + "Thread creation requires an active parent in the target project.", + ); + assert.equal(yield* Ref.get(projectionReads), 2); + }), + ); + it.effect("retries terminal acknowledgement with a fresh command id", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-ack-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 7d7bd24f02de..e000a211e089 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -153,6 +153,7 @@ export class OrchestratorMcpService extends Context.Service< >()("t3/mcp/OrchestratorMcpService") {} const isThreadManagementError = Schema.is(ThreadManagementError); +const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure { return new OrchestratorMcpFailure({ code, message }); @@ -1370,32 +1371,59 @@ const make = Effect.gen(function* () { title: request.title, index, }); + const createCommandId = stableCommandId({ + scope, + requestKey: key, + operation: "create-thread", + index, + }); + const createCommand = { + type: "thread.create", + createdBy: "agent", + creationSource: "mcp", + commandId: createCommandId, + threadId, + projectId: parent.thread.projectId, + title, + modelSelection: target.modelSelection, + runtimeMode, + interactionMode, + branch: parent.thread.branch, + worktreePath: parent.thread.worktreePath, + } as const; yield* threadManagement - .dispatch({ - type: "thread.create", - createdBy: "agent", - creationSource: "mcp", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "create-thread", - index, - }), - threadId, - projectId: parent.thread.projectId, - title, - modelSelection: target.modelSelection, - runtimeMode, - interactionMode, - branch: parent.thread.branch, - worktreePath: parent.thread.worktreePath, - }) + .withProjectCreationAdmission( + { projectId: parent.thread.projectId, commandId: createCommandId }, + (receipt) => + Effect.gen(function* () { + if (Option.isNone(receipt)) { + const freshParent = yield* loadProjection(scope.threadId); + const freshParentRun = latestActiveRun(freshParent); + if ( + freshParent.thread.projectId !== parent.thread.projectId || + freshParent.thread.deletedAt !== null || + freshParent.thread.archivedAt !== null || + freshParentRun === undefined || + freshParentRun.rootNodeId === null || + freshParentRun.providerInstanceId !== scope.providerInstanceId + ) { + return yield* failure( + "parent_not_active", + "Thread creation requires an active parent in the target project.", + ); + } + } + return yield* threadManagement.dispatch(createCommand); + }), + ) .pipe( Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to create thread ${index + 1}: ${errorMessage(error)}`, - ), + isOrchestratorMcpFailure(error) + ? error + : failure( + "orchestration_error", + `Unable to create thread ${index + 1}: ${errorMessage(error)}`, + ), ), ); if (request.prompt !== undefined) { diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index ad6ea73c7688..04d29e7281b9 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -19,6 +19,7 @@ import { OrchestratorMcpThreadReadResult, OrchestratorMcpThreadSendResult, OrchestratorMcpThreadWaitResult, + type Project, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -35,7 +36,9 @@ import { import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -44,8 +47,9 @@ import { McpSchema, McpServer } from "effect/unstable/ai"; import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as ClientCommandDispatch from "../orchestration-v2/ClientCommandDispatch.ts"; import { OrchestratorV2, type OrchestratorV2Shape } from "../orchestration-v2/Orchestrator.ts"; -import { layer as threadManagementServiceLayer } from "../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import { type ProviderAdapterV2Event, ProviderAdapterProtocolError, @@ -60,12 +64,17 @@ import { import { checkpointWorkspace } from "../orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; import { makeOrchestratorV2ReplayLayerWithRegistry } from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; const parentThreadId = ThreadId.make("thread:mcp-orchestrator-parent"); const projectId = ProjectId.make("project:mcp-orchestrator"); +const deletionRaceProjectId = ProjectId.make("project:mcp-orchestrator-delete-race"); +const deletionRaceParentThreadId = ThreadId.make("thread:mcp-orchestrator-delete-race-parent"); const codexInstanceId = ProviderInstanceId.make("codex"); const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); const codexModel = "gpt-5.4"; @@ -145,6 +154,9 @@ function makeDeterministicAdapter(input: { readonly capabilities: OrchestrationV2ProviderCapabilities; readonly capturedTurns: Ref.Ref>; readonly shouldComplete: (turn: ProviderAdapterV2TurnInput) => boolean; + readonly startedSignal?: ( + turn: ProviderAdapterV2TurnInput, + ) => Deferred.Deferred | undefined; readonly terminalGate?: (turn: ProviderAdapterV2TurnInput) => Deferred.Deferred | undefined; readonly response: (turn: ProviderAdapterV2TurnInput) => string; }): ProviderAdapterV2Shape { @@ -247,6 +259,10 @@ function makeDeterministicAdapter(input: { }, }, ]); + const startedSignal = input.startedSignal?.(turnInput); + if (startedSignal !== undefined) { + yield* Deferred.succeed(startedSignal, turnInput); + } const terminalGate = input.terminalGate?.(turnInput); if (terminalGate !== undefined) { yield* Deferred.await(terminalGate); @@ -429,6 +445,29 @@ describe("orchestrator MCP toolkit", () => { Effect.scoped( Effect.gen(function* () { const cwd = yield* checkpointWorkspace("orchestrator-mcp-toolkit"); + const deletionRaceProject = { + id: deletionRaceProjectId, + title: "MCP deletion race project", + workspaceRoot: cwd, + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: codexSelection, + defaultThreadEnvMode: "local", + scripts: [], + createdAt: "2026-08-30T00:00:00.000Z", + updatedAt: "2026-08-30T00:00:00.000Z", + deletedAt: null, + } satisfies Project; + const deletionRaceProjectState = yield* Ref.make(deletionRaceProject); + const creationAdmissionEntered = yield* Deferred.make(); + const allowCreationAdmission = yield* Deferred.make(); + const deletionLockAttemptSettled = yield* Deferred.make(); + const deletionLockAcquired = yield* Deferred.make(); + const allowDeletion = yield* Deferred.make(); + const deletionRaceProviderStarted = yield* Deferred.make(); + const deletionRaceCommandId = CommandId.make( + "command:mcp:mcp-provider-session-delete-race:create-thread:delete-create-race:0", + ); const capturedTurns = yield* Ref.make>([]); const parentTerminalGates = new Map>(); const deliveryTerminalGates = new Map>(); @@ -439,7 +478,13 @@ describe("orchestrator MCP toolkit", () => { capabilities: CodexProviderCapabilitiesV2, capturedTurns, shouldComplete: (turn) => - turn.threadId !== parentThreadId && turn.message.text !== cancellationPrompt, + turn.threadId !== parentThreadId && + turn.threadId !== deletionRaceParentThreadId && + turn.message.text !== cancellationPrompt, + startedSignal: (turn) => + turn.threadId === deletionRaceParentThreadId + ? deletionRaceProviderStarted + : undefined, terminalGate: (turn) => turn.message.text.startsWith("Delegated task") || turn.message.text.startsWith("Delegated tasks") @@ -511,10 +556,45 @@ describe("orchestrator MCP toolkit", () => { }, registryLayer, ).pipe(Layer.provide(continuationProbeLayer)); - const orchestrationLayer = Layer.merge( - orchestratorLayer, - threadManagementServiceLayer.pipe(Layer.provide(orchestratorLayer)), + const threadManagementLayer = ThreadManagement.layer.pipe( + Layer.provide(orchestratorLayer), ); + const gatedThreadManagementLayer = Layer.effect( + ThreadManagement.ThreadManagementService, + Effect.gen(function* () { + const actual = yield* ThreadManagement.ThreadManagementService; + return ThreadManagement.ThreadManagementService.of({ + ...actual, + withProjectCreationAdmission: (input, effect) => + actual.withProjectCreationAdmission(input, (receipt) => + input.commandId === deletionRaceCommandId && Option.isNone(receipt) + ? Deferred.succeed(creationAdmissionEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowCreationAdmission)), + Effect.andThen(effect(receipt)), + ) + : effect(receipt), + ), + withProjectMutationLock: (lockedProjectId, effect) => + lockedProjectId === deletionRaceProjectId + ? Effect.gen(function* () { + const locked = yield* Effect.forkChild( + actual.withProjectMutationLock( + lockedProjectId, + Deferred.succeed(deletionLockAcquired, undefined).pipe( + Effect.andThen(Deferred.await(allowDeletion)), + Effect.andThen(effect), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.succeed(deletionLockAttemptSettled, undefined); + return yield* Fiber.join(locked); + }) + : actual.withProjectMutationLock(lockedProjectId, effect), + }); + }), + ).pipe(Layer.provide(threadManagementLayer)); + const orchestrationLayer = Layer.merge(orchestratorLayer, gatedThreadManagementLayer); const providerRegistryLayer = makeProviderRegistryLayer([ makeProviderSnapshot({ instanceId: codexInstanceId, @@ -570,11 +650,40 @@ describe("orchestrator MCP toolkit", () => { runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), }), ); - const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + const projectLayer = Layer.mock(ProjectService.ProjectService)({ + getById: (requestedProjectId, options) => + requestedProjectId === deletionRaceProjectId + ? Ref.get(deletionRaceProjectState).pipe( + Effect.map((project) => + project.deletedAt === null || options?.includeDeleted === true + ? Option.some(project) + : Option.none(), + ), + ) + : Effect.succeed(Option.none()), + delete: ({ projectId: deletedProjectId }) => + deletedProjectId === deletionRaceProjectId + ? Ref.updateAndGet(deletionRaceProjectState, (project) => ({ + ...project, + deletedAt: "2026-08-30T00:01:00.000Z", + updatedAt: "2026-08-30T00:01:00.000Z", + })) + : Effect.die(`Unexpected project deletion: ${deletedProjectId}`), + }); + const toolkitRegistrationLayer = Layer.merge( + McpHttpServer.OrchestratorToolkitRegistrationLive, + McpHttpServer.ProjectToolkitRegistrationLive, + ); + const testLayer = toolkitRegistrationLayer.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(orchestrationLayer), Layer.provide(providerRegistryLayer), Layer.provide(scheduledTaskStubLayer), + Layer.provideMerge(projectLayer), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), + ), + Layer.provide(ServerSettings.layerTest({})), Layer.provide(NodeServices.layer), ); @@ -2613,6 +2722,157 @@ describe("orchestrator MCP toolkit", () => { removedDelivery.subagents.find((task) => task.id === removeTask.id), ).toMatchObject({ result: expect.any(String), status: "completed" }); yield* expectOffersToStay(0); + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-delete-race-parent:create"), + threadId: deletionRaceParentThreadId, + projectId: deletionRaceProjectId, + title: "MCP deletion race parent", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-delete-race-parent:start"), + threadId: deletionRaceParentThreadId, + messageId: MessageId.make("message:mcp-delete-race-parent:start"), + text: "Stay active while project deletion races thread creation.", + attachments: [], + modelSelection: codexSelection, + dispatchMode: { type: "start_immediately" }, + }); + const startedRaceTurn = yield* Deferred.await(deletionRaceProviderStarted); + expect(startedRaceTurn.threadId).toBe(deletionRaceParentThreadId); + + const deletionRaceInvocation: McpInvocationContext.McpInvocationScope = { + ...invocation, + threadId: deletionRaceParentThreadId, + providerSessionId: "mcp-provider-session-delete-race", + }; + const invokeDeletionRace = (name: string, args: Record) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + deletionRaceInvocation, + ), + Effect.provideService(McpSchema.McpServerClient, client), + ); + yield* Effect.gen(function* () { + const creation = yield* Effect.forkChild( + invokeDeletionRace("create_threads", { + clientRequestId: "delete-create-race", + threads: [{ title: "Racing child" }], + }), + { startImmediately: true }, + ); + yield* Deferred.await(creationAdmissionEntered); + const deletion = yield* Effect.forkChild( + invokeDeletionRace("t3_project_delete", { + projectId: deletionRaceProjectId, + cascadeThreads: true, + clientRequestId: "delete-create-race-project", + }), + { startImmediately: true }, + ); + yield* Deferred.await(deletionLockAttemptSettled); + expect(yield* Deferred.isDone(deletionLockAcquired)).toBe(false); + + yield* Deferred.succeed(allowCreationAdmission, undefined); + yield* Deferred.await(deletionLockAcquired); + const creationCall = yield* Fiber.join(creation); + expect(creationCall.isError).toBe(false); + const creationResult = yield* decodeCreateThreadsResult( + creationCall.structuredContent, + ).pipe(Effect.orDie); + const createdThread = creationResult.threads[0]; + if (createdThread === undefined) { + return yield* Effect.die(new Error("Racing MCP thread was not created.")); + } + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.projectId, + ).toBe(deletionRaceProjectId); + + yield* Deferred.succeed(allowDeletion, undefined); + const deletionCall = yield* Fiber.join(deletion); + expect(deletionCall.isError).toBe(false); + expect(deletionCall.structuredContent).toMatchObject({ + projectId: deletionRaceProjectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: 2, + workspaceFilesDeleted: false, + }); + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, + ).not.toBeNull(); + expect( + (yield* orchestrator.getThreadProjection(deletionRaceParentThreadId)).thread + .deletedAt, + ).not.toBeNull(); + + const threads = yield* ThreadManagement.ThreadManagementService; + const dispatchClientCommand = (yield* ClientCommandDispatch.make).dispatch; + const acceptedCreateCommand = { + type: "thread.create", + createdBy: "agent", + creationSource: "mcp", + commandId: deletionRaceCommandId, + threadId: createdThread.threadId, + projectId: deletionRaceProjectId, + title: "Racing child", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + } as const; + const sequenceBeforeReplay = yield* threads.getThreadEventSequence( + createdThread.threadId, + ); + const replayed = yield* dispatchClientCommand(acceptedCreateCommand); + expect( + replayed.storedEvents.some((stored) => stored.event.type === "thread.created"), + ).toBe(true); + expect(yield* threads.getThreadEventSequence(createdThread.threadId)).toBe( + sequenceBeforeReplay, + ); + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, + ).not.toBeNull(); + + const freshThreadId = ThreadId.make("thread:mcp-delete-race:fresh-after-delete"); + const freshFailure = yield* dispatchClientCommand({ + ...acceptedCreateCommand, + commandId: CommandId.make("command:mcp-delete-race:fresh-after-delete"), + threadId: freshThreadId, + }).pipe(Effect.flip); + expect(freshFailure).toMatchObject({ _tag: "ProjectMutationError" }); + expect( + Option.isNone( + yield* Effect.option(orchestrator.getThreadProjection(freshThreadId)), + ), + ).toBe(true); + }).pipe( + Effect.ensuring( + Effect.all( + [ + Deferred.succeed(allowCreationAdmission, undefined), + Deferred.succeed(allowDeletion, undefined), + ], + { discard: true }, + ), + ), + ); }).pipe(Effect.provide(testLayer)); }), ), diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts new file mode 100644 index 000000000000..1be25861e9f1 --- /dev/null +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -0,0 +1,950 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + EnvironmentId, + MessageId, + ProjectMcpFailure, + type Project, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings, + ServerSettingsError, + SourceControlRepositoryError, + ThreadId, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import * as ServerSettingsService from "../serverSettings.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import * as ProjectMcp from "./ProjectMcpService.ts"; + +const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment-project-mcp"), + threadId: ThreadId.make("thread-project-mcp"), + providerSessionId: "provider-session-project-mcp", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, +}; + +const now = "2026-08-29T12:00:00.000Z" as const; + +const makeProject = (input: { + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; + readonly updatedAt?: Project["updatedAt"]; +}): Project => ({ + id: input.id, + title: input.title, + workspaceRoot: input.workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + scripts: [], + createdAt: now, + updatedAt: input.updatedAt ?? now, + deletedAt: null, +}); + +it.effect("creates, reads, and updates project defaults through the project MCP service", () => + Effect.gen(function* () { + const state = yield* Ref.make>(new Map()); + const createdInputs = yield* Ref.make>([]); + const updatedInputs = yield* Ref.make>([]); + + const projectService = ProjectService.ProjectService.of({ + create: (input) => + Effect.gen(function* () { + yield* Ref.update(createdInputs, (current) => [...current, input]); + const project = { + ...makeProject({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + }), + defaultModelSelection: input.defaultModelSelection ?? null, + defaultThreadEnvMode: input.defaultThreadEnvMode ?? null, + faviconPath: input.faviconPath ?? null, + scripts: [...(input.scripts ?? [])], + } satisfies Project; + yield* Ref.update(state, (current) => new Map(current).set(project.id, project)); + return project; + }), + bootstrap: () => Effect.die("unused"), + update: (input) => + Effect.gen(function* () { + yield* Ref.update(updatedInputs, (current) => [...current, input]); + const current = (yield* Ref.get(state)).get(input.projectId)!; + const project = { + ...current, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + ...(input.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), + ...(input.scripts === undefined ? {} : { scripts: [...input.scripts] }), + } satisfies Project; + yield* Ref.update(state, (projects) => new Map(projects).set(project.id, project)); + return project; + }), + delete: () => Effect.die("unused"), + getById: (projectId) => + Ref.get(state).pipe( + Effect.map((projects) => Option.fromNullishOr(projects.get(projectId))), + ), + getByWorkspaceRoot: () => Effect.succeed(Option.none()), + snapshot: Ref.get(state).pipe( + Effect.map((projects) => ({ projects: [...projects.values()], updatedAt: now })), + ), + }); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide(Layer.succeed(ProjectService.ProjectService, projectService)), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.some({ defaultThreadEnvMode: "worktree" })), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ + defaultThreadEnvMode: "local", + } as ServerSettings), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + const invalidKey = yield* service + .create(scope, { + title: "Invalid key", + source: { type: "existing_directory", workspaceRoot: "/work/invalid-key" }, + clientRequestId: "create-\ud800-project", + }) + .pipe(Effect.flip); + expect(invalidKey.code).toBe("invalid_request"); + expect(yield* Ref.get(createdInputs)).toEqual([]); + + const created = yield* service.create(scope, { + title: "Created project", + source: { + type: "existing_directory", + workspaceRoot: "/work/created", + createIfMissing: true, + }, + defaultThreadEnvMode: "local", + faviconPath: "/work/created/icon.svg", + clientRequestId: "create-project", + }); + expect(created.projectFileDefaultThreadEnvMode).toBe("worktree"); + expect(created.globalDefaultThreadEnvMode).toBe("local"); + expect(created.effectiveDefaultThreadEnvMode).toBe("local"); + expect((yield* Ref.get(createdInputs))[0]?.createWorkspaceRootIfMissing).toBe(true); + + const updated = yield* service.update(scope, { + projectId: created.id, + defaultThreadEnvMode: null, + faviconPath: null, + clientRequestId: "update-project", + }); + expect(updated.defaultThreadEnvMode).toBeNull(); + expect(updated.faviconPath).toBeNull(); + expect(updated.effectiveDefaultThreadEnvMode).toBe("worktree"); + expect((yield* Ref.get(updatedInputs))[0]).toMatchObject({ + defaultThreadEnvMode: null, + faviconPath: null, + }); + expect(String((yield* Ref.get(updatedInputs))[0]?.commandId)).toContain( + encodeURIComponent(created.id), + ); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("loads response settings before committing project mutations", () => + Effect.gen(function* () { + const createCalls = yield* Ref.make(0); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.none()), + create: () => Ref.update(createCalls, (count) => count + 1).pipe(Effect.as({} as never)), + }), + ), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.fail( + new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-file", + cause: "settings unavailable", + }), + ), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + const error = yield* service + .create(scope, { + title: "Uncommitted project", + source: { type: "existing_directory", workspaceRoot: "/work/uncommitted" }, + clientRequestId: "settings-failure", + }) + .pipe(Effect.flip); + expect(error.code).toBe("operation_failed"); + expect(error.message).toBe("Unable to read project defaults."); + expect(error.message).not.toMatch(/settings unavailable|settings\.json/); + expect(yield* Ref.get(createCalls)).toBe(0); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("redacts repository failures from public project MCP results", () => + Effect.gen(function* () { + const secretRemote = "https://oauth-token@example.com/private/repo.git"; + const logs: Array = []; + const logger = Logger.make(({ message }) => { + logs.push(message); + }); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: () => + Effect.fail( + new SourceControlRepositoryError({ + provider: "unknown", + operation: "cloneRepository", + detail: `fatal: authentication failed for ${secretRemote}`, + }), + ), + }), + ), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + const error = yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + return yield* service + .create(scope, { + title: "Private clone", + source: { + type: "clone", + destinationPath: "/work/private-clone", + remoteUrl: secretRemote, + }, + clientRequestId: "private-clone", + }) + .pipe(Effect.flip); + }).pipe( + Effect.provide( + Layer.mergeAll(testLayer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + + expect(error).toMatchObject({ + code: "operation_failed", + message: "Unable to clone the requested repository.", + }); + expect(error.message).not.toMatch(/oauth-token|example\.com|fatal|authentication/i); + expect(error).not.toHaveProperty("cause"); + expect(logs).toEqual([ + ["Project MCP operation failed.", { operation: "clone-project-repository" }], + ]); + }), +); + +it.effect("clones before registration and requires explicit cascading for nonempty projects", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:mcp:provider-session-project-mcp:clone-and-delete"); + const project = makeProject({ + id: projectId, + title: "Cloned project", + workspaceRoot: "/work/cloned", + }); + const state = yield* Ref.make(null); + const cloneInputs = yield* Ref.make>([]); + const deletedThreadCommands = yield* Ref.make>([]); + const deletedProjects = yield* Ref.make>([]); + const lockedProjects = yield* Ref.make>([]); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + create: (input) => + Effect.gen(function* () { + const created = makeProject({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + }); + yield* Ref.set(state, created); + return created; + }), + getById: (_projectId, options) => + Ref.get(state).pipe( + Effect.map((current) => + current === null || (current.deletedAt !== null && options?.includeDeleted !== true) + ? Option.none() + : Option.some(current), + ), + ), + delete: ({ projectId }) => + Effect.gen(function* () { + yield* Ref.update(deletedProjects, (current) => [...current, projectId]); + const deleted = { ...project, deletedAt: now } satisfies Project; + yield* Ref.set(state, deleted); + return deleted; + }), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: (input) => + Ref.update(cloneInputs, (current) => [ + ...current, + { destinationPath: input.destinationPath }, + ]).pipe( + Effect.as({ + cwd: "/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + repository: null, + }), + ), + }), + ), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + withProjectMutationLock: (lockedProjectId, effect) => + Ref.update(lockedProjects, (current) => [...current, lockedProjectId]).pipe( + Effect.andThen(effect), + ), + getShellSnapshot: (options) => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 1, + threads: + options?.location === "archive" + ? [] + : [ + { + id: ThreadId.make("thread-in-project"), + projectId, + } as never, + ], + archivedThreads: + options?.location === "archive" + ? [ + { + id: ThreadId.make("archived-subagent-in-project"), + projectId, + lineage: { relationshipToParent: "subagent" }, + } as never, + ] + : [], + }), + dispatch: (command) => + command.type === "thread.delete" + ? Ref.update(deletedThreadCommands, (current) => [ + ...current, + command.commandId, + ]).pipe(Effect.as({} as never)) + : Effect.die("unexpected command"), + }), + ), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + const created = yield* service.create(scope, { + title: project.title, + source: { + type: "clone", + destinationPath: "/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "clone-and-delete", + }); + expect(created.workspaceRoot).toBe("/work/cloned"); + expect(yield* Ref.get(cloneInputs)).toEqual([{ destinationPath: "/work/cloned" }]); + + const refusal = yield* service + .delete(scope, { projectId, clientRequestId: "delete-refused" }) + .pipe(Effect.flip); + expect(refusal.code).toBe("project_not_empty"); + + const deleted = yield* service.delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "delete-cascade", + }); + expect(deleted).toMatchObject({ + projectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: 2, + workspaceRoot: "/work/cloned", + workspaceFilesDeleted: false, + }); + expect(yield* Ref.get(deletedThreadCommands)).toHaveLength(2); + expect(yield* Ref.get(deletedProjects)).toEqual([projectId]); + + const replayed = yield* service.delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "delete-cascade", + }); + expect(replayed).toMatchObject({ + deleted: true, + alreadyDeleted: true, + deletedThreadCount: 0, + workspaceFilesDeleted: false, + }); + expect(yield* Ref.get(deletedProjects)).toEqual([projectId]); + expect(yield* Ref.get(lockedProjects)).toEqual([projectId, projectId, projectId]); + + const createRetry = yield* service + .create(scope, { + title: project.title, + source: { + type: "clone", + destinationPath: "/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "clone-and-delete", + }) + .pipe(Effect.flip); + expect(createRetry).toMatchObject({ code: "project_deleted" }); + expect(createRetry.message).toContain("Use a new clientRequestId"); + expect(yield* Ref.get(cloneInputs)).toEqual([{ destinationPath: "/work/cloned" }]); + expect((yield* Ref.get(state))?.deletedAt).toBe(now); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("serializes delegated thread admission with cascading project deletion", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:mcp:delete-delegate-race"); + const parentThreadId = ThreadId.make("thread:mcp:delete-delegate-race:parent"); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", + } as const; + const project = makeProject({ + id: projectId, + title: "Delete delegation race", + workspaceRoot: "/work/delete-delegate-race", + }); + const projectState = yield* Ref.make(project); + const blockAdapterLookup = yield* Ref.make(false); + const admissionEntered = yield* Deferred.make(); + const allowAdmission = yield* Deferred.make(); + const deleteLockAttempted = yield* Deferred.make(); + const deleteLockAcquired = yield* Deferred.make(); + const snapshotEntered = yield* Deferred.make(); + const allowSnapshot = yield* Deferred.make(); + const adapter = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in project deletion tests"), + } as ProviderAdapterV2Shape; + const registry = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistryV2, + ProviderAdapterRegistry.ProviderAdapterRegistryV2.of({ + get: () => + Ref.get(blockAdapterLookup).pipe( + Effect.flatMap((blocked) => + blocked + ? Deferred.succeed(admissionEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowAdmission)), + Effect.as(adapter), + ) + : Effect.succeed(adapter), + ), + ), + list: () => Effect.succeed([modelSelection.instanceId]), + }), + ); + const orchestrator = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "project-delete-delegate-race" }, + registry, + { databaseLayer: SqlitePersistenceMemory, runEffectWorker: false }, + ); + const actualThreads = ThreadManagement.layer.pipe(Layer.provide(orchestrator)); + const gatedThreads = Layer.effect( + ThreadManagement.ThreadManagementService, + Effect.gen(function* () { + const actual = yield* ThreadManagement.ThreadManagementService; + return ThreadManagement.ThreadManagementService.of({ + ...actual, + withProjectMutationLock: (lockedProjectId, effect) => + Deferred.succeed(deleteLockAttempted, undefined).pipe( + Effect.andThen( + actual.withProjectMutationLock( + lockedProjectId, + Deferred.succeed(deleteLockAcquired, undefined).pipe(Effect.andThen(effect)), + ), + ), + ), + getShellSnapshot: (options) => + actual.getShellSnapshot(options).pipe( + Effect.tap(() => Deferred.succeed(snapshotEntered, undefined)), + Effect.tap(() => Deferred.await(allowSnapshot)), + ), + }); + }), + ).pipe(Layer.provide(actualThreads)); + const testLayer = ProjectMcp.layer.pipe( + Layer.provideMerge(gatedThreads), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(projectState).pipe(Effect.map(Option.some)), + delete: () => + Ref.updateAndGet(projectState, (current) => ({ ...current, deletedAt: now })), + }), + ), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const projects = yield* ProjectMcp.ProjectMcpService; + const threads = yield* ThreadManagement.ThreadManagementService; + yield* threads.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp:delete-delegate-race:create"), + threadId: parentThreadId, + projectId, + title: "Delegation parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* threads.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp:delete-delegate-race:start"), + threadId: parentThreadId, + messageId: MessageId.make("message:mcp:delete-delegate-race:start"), + text: "Keep the parent run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const parent = yield* threads.getThreadProjection(parentThreadId); + const parentRun = parent.runs[0]; + if (parentRun === undefined || parentRun.rootNodeId === null) { + return yield* Effect.die(new Error("Delegation parent run was not created.")); + } + + const delegationCommand = { + type: "delegated_task.request", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("command:mcp:delete-delegate-race:delegate"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId, + task: "Race project deletion.", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + } as const; + yield* Ref.set(blockAdapterLookup, true); + const delegation = yield* Effect.forkChild( + threads.dispatch(delegationCommand).pipe(Effect.exit), + { startImmediately: true }, + ); + yield* Deferred.await(admissionEntered); + const deletion = yield* Effect.forkChild( + projects.delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "delete-delegate-race", + }), + { startImmediately: true }, + ); + yield* Deferred.await(deleteLockAttempted); + yield* Effect.yieldNow; + expect(yield* Deferred.isDone(deleteLockAcquired)).toBe(false); + + yield* Deferred.succeed(allowAdmission, undefined); + const delegationExit = yield* Fiber.join(delegation); + expect(delegationExit._tag).toBe("Success"); + yield* Deferred.await(deleteLockAcquired); + yield* Deferred.await(snapshotEntered); + yield* Deferred.succeed(allowSnapshot, undefined); + const deleted = yield* Fiber.join(deletion); + expect(deleted).toMatchObject({ deleted: true, deletedThreadCount: 2 }); + const replayedDelegation = yield* threads.dispatch(delegationCommand); + expect( + replayedDelegation.storedEvents.some((event) => event.event.type === "thread.created"), + ).toBe(true); + const rejectedDelegation = yield* threads + .dispatch({ + ...delegationCommand, + commandId: CommandId.make("command:mcp:delete-delegate-race:after-delete"), + }) + .pipe(Effect.exit); + expect(rejectedDelegation._tag).toBe("Failure"); + const [active, archived] = yield* Effect.all([ + threads.getShellSnapshot({ location: "active" }), + threads.getShellSnapshot({ location: "archive" }), + ]); + expect( + [ + ...active.threads, + ...active.archivedThreads, + ...archived.threads, + ...archived.archivedThreads, + ].filter((thread) => thread.projectId === projectId), + ).toEqual([]); + expect((yield* Ref.get(projectState)).deletedAt).toBe(now); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("paginates summaries before loading project files and reads settings once", () => + Effect.gen(function* () { + const projectFileLoads = yield* Ref.make>([]); + const settingsLoads = yield* Ref.make(0); + const projects = Array.from({ length: 40 }, (_, index) => + makeProject({ + id: ProjectId.make(`project:page:${index.toString().padStart(2, "0")}`), + title: `Project ${index}`, + workspaceRoot: `/work/project-${index}`, + }), + ); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + snapshot: Effect.succeed({ projects, updatedAt: now }), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: (workspaceRoot) => + Ref.update(projectFileLoads, (current) => [...current, workspaceRoot]).pipe( + Effect.as(Option.none()), + ), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Ref.updateAndGet(settingsLoads, (count) => count + 1).pipe( + Effect.as({ defaultThreadEnvMode: "local" } as ServerSettings), + ), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + const page = yield* service.list(scope, { cursor: 10, limit: 5 }); + expect(page).toMatchObject({ nextCursor: 15, totalCount: 40 }); + expect(page.projects.map((project) => project.id)).toEqual( + projects.slice(10, 15).map((project) => project.id), + ); + expect(page.projects.every((project) => !("scripts" in project))).toBe(true); + expect(yield* Ref.get(projectFileLoads)).toEqual( + projects.slice(10, 15).map((project) => project.workspaceRoot), + ); + expect(yield* Ref.get(settingsLoads)).toBe(1); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("orders mixed-offset project timestamps chronologically across pages", () => + Effect.gen(function* () { + const projects = [ + makeProject({ + id: ProjectId.make("project:mixed:oldest"), + title: "Oldest", + workspaceRoot: "/work/mixed-oldest", + updatedAt: "2026-08-29T09:00:00-04:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:tie-b"), + title: "Tie B", + workspaceRoot: "/work/mixed-tie-b", + updatedAt: "2026-08-29T16:00:00+02:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:newest"), + title: "Newest", + workspaceRoot: "/work/mixed-newest", + updatedAt: "2026-08-29T10:00:00-05:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:tie-a"), + title: "Tie A", + workspaceRoot: "/work/mixed-tie-a", + updatedAt: "2026-08-29T14:00:00Z", + }), + ]; + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + snapshot: Effect.succeed({ projects, updatedAt: now }), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide(ServerSettingsService.layerTest({})), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + const firstPage = yield* service.list(scope, { cursor: 0, limit: 2 }); + const secondPage = yield* service.list(scope, { cursor: 2, limit: 2 }); + + expect(firstPage).toMatchObject({ nextCursor: 2, totalCount: 4 }); + expect(firstPage.projects.map((project) => project.id)).toEqual([ + ProjectId.make("project:mixed:newest"), + ProjectId.make("project:mixed:tie-a"), + ]); + expect(secondPage).toMatchObject({ nextCursor: null, totalCount: 4 }); + expect(secondPage.projects.map((project) => project.id)).toEqual([ + ProjectId.make("project:mixed:tie-b"), + ProjectId.make("project:mixed:oldest"), + ]); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("serializes overlapping create retries with the same idempotency key", () => + Effect.gen(function* () { + const state = yield* Ref.make(null); + const cloneCalls = yield* Ref.make(0); + const createCalls = yield* Ref.make(0); + const createEntered = yield* Deferred.make(); + const allowCreate = yield* Deferred.make(); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(state).pipe(Effect.map(Option.fromNullishOr)), + create: (input) => + Effect.gen(function* () { + yield* Ref.update(createCalls, (count) => count + 1); + yield* Deferred.succeed(createEntered, undefined); + yield* Deferred.await(allowCreate); + const created = makeProject({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + }); + yield* Ref.set(state, created); + return created; + }), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: () => + Ref.update(cloneCalls, (count) => count + 1).pipe( + Effect.as({ + cwd: "/work/overlap", + remoteUrl: "https://example.com/acme/repo.git", + repository: null, + }), + ), + }), + ), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + const input = { + title: "Overlapping clone", + source: { + type: "clone" as const, + destinationPath: "/work/overlap", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "overlapping-create", + }; + + yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + const first = yield* Effect.forkChild(service.create(scope, input), { + startImmediately: true, + }); + yield* Deferred.await(createEntered); + const second = yield* Effect.forkChild(service.create(scope, input), { + startImmediately: true, + }); + yield* Deferred.succeed(allowCreate, undefined); + const [firstResult, secondResult] = yield* Effect.all([ + Fiber.join(first), + Fiber.join(second), + ]); + expect(firstResult.id).toBe(secondResult.id); + expect(yield* Ref.get(cloneCalls)).toBe(1); + expect(yield* Ref.get(createCalls)).toBe(1); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("retries registration after a completed clone without changing the project id", () => + Effect.gen(function* () { + const state = yield* Ref.make(null); + const cloneCalls = yield* Ref.make(0); + const createCalls = yield* Ref.make(0); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(state).pipe(Effect.map(Option.fromNullishOr)), + create: (input) => + Ref.updateAndGet(createCalls, (count) => count + 1).pipe( + Effect.flatMap((attempt) => + attempt === 1 + ? Effect.fail( + new ProjectService.ProjectOperationError({ + operation: "dispatch-project-command", + projectId: input.projectId, + cause: "registration unavailable", + }), + ) + : Effect.sync(() => + makeProject({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + }), + ).pipe(Effect.tap((project) => Ref.set(state, project))), + ), + ), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: () => + Ref.update(cloneCalls, (count) => count + 1).pipe( + Effect.as({ + cwd: "/work/recovered-clone", + remoteUrl: "https://example.com/acme/repo.git", + repository: null, + }), + ), + }), + ), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + const input = { + title: "Recovered clone", + source: { + type: "clone" as const, + destinationPath: "/work/recovered-clone", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "retry-registration", + }; + + yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + const firstFailure = yield* service.create(scope, input).pipe(Effect.flip); + expect(firstFailure).toBeInstanceOf(ProjectMcpFailure); + const retried = yield* service.create(scope, input); + expect(retried.id).toBe( + ProjectId.make("project:mcp:provider-session-project-mcp:retry-registration"), + ); + expect(yield* Ref.get(cloneCalls)).toBe(2); + expect(yield* Ref.get(createCalls)).toBe(2); + }).pipe(Effect.provide(testLayer)); + }), +); diff --git a/apps/server/src/mcp/ProjectMcpService.ts b/apps/server/src/mcp/ProjectMcpService.ts new file mode 100644 index 000000000000..65b378c25576 --- /dev/null +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -0,0 +1,459 @@ +import { + CommandId, + type Project, + ProjectId, + ProjectMcpFailure, + type ProjectMcpCreateInput, + type ProjectMcpDeleteInput, + type ProjectMcpDeleteResult, + type ProjectMcpListInput, + type ProjectMcpListResult, + type ProjectMcpProject, + type ProjectMcpProjectSummary, + type ProjectMcpUpdateInput, + type ServerSettings as ServerSettingsValue, +} from "@t3tools/contracts"; +import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; + +export class ProjectMcpService extends Context.Service< + ProjectMcpService, + { + readonly list: ( + scope: McpInvocationScope, + input: ProjectMcpListInput, + ) => Effect.Effect; + readonly read: ( + scope: McpInvocationScope, + projectId: ProjectId, + ) => Effect.Effect; + readonly create: ( + scope: McpInvocationScope, + input: ProjectMcpCreateInput, + ) => Effect.Effect; + readonly update: ( + scope: McpInvocationScope, + input: ProjectMcpUpdateInput, + ) => Effect.Effect; + readonly delete: ( + scope: McpInvocationScope, + input: ProjectMcpDeleteInput, + ) => Effect.Effect; + } +>()("t3/mcp/ProjectMcpService") {} + +function failure(code: ProjectMcpFailure["code"], message: string): ProjectMcpFailure { + return new ProjectMcpFailure({ code, message }); +} + +function redactOperationFailure(operation: string, publicMessage: string) { + return (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.tapError(() => Effect.logWarning("Project MCP operation failed.", { operation })), + Effect.mapError(() => failure("operation_failed", publicMessage)), + ); +} + +function stablePart(value: string): string { + return encodeURIComponent(value); +} + +function stableCommandId(input: { + readonly scope: McpInvocationScope; + readonly requestKey: string; + readonly operation: string; + readonly suffix?: string; +}): CommandId { + return CommandId.make( + [ + "command", + "mcp", + stablePart(input.scope.providerSessionId), + stablePart(input.operation), + stablePart(input.requestKey), + ...(input.suffix === undefined ? [] : [stablePart(input.suffix)]), + ].join(":"), + ); +} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const projects = yield* ProjectService.ProjectService; + const projectFiles = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const sourceControl = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const threads = yield* ThreadManagementService; + const projectCreates = yield* makeKeyedSerialExecutor(); + + const requireCapability = (scope: McpInvocationScope) => + scope.capabilities.has("orchestration") + ? Effect.void + : Effect.fail( + failure( + "capability_denied", + "This MCP credential does not grant orchestration capabilities.", + ), + ); + + const requestKey = (clientRequestId: string | undefined) => + clientRequestId === undefined + ? crypto.randomUUIDv4.pipe(Effect.orDie) + : clientRequestId.isWellFormed() + ? Effect.succeed(clientRequestId) + : Effect.fail( + failure("invalid_request", "clientRequestId must contain well-formed Unicode."), + ); + + const loadSettings = serverSettings.getSettings.pipe( + redactOperationFailure("read-server-settings", "Unable to read project defaults."), + ); + + const projectWorkspaceDefaults = Effect.fn("ProjectMcpService.projectWorkspaceDefaults")( + function* (project: Project, settings: ServerSettingsValue) { + const projectFile = yield* projectFiles.load(project.workspaceRoot); + const projectFileDefaultThreadEnvMode = Option.isSome(projectFile) + ? (projectFile.value.defaultThreadEnvMode ?? null) + : null; + return { + projectFileDefaultThreadEnvMode, + globalDefaultThreadEnvMode: settings.defaultThreadEnvMode, + effectiveDefaultThreadEnvMode: resolveDefaultThreadEnvMode({ + projectSetting: project.defaultThreadEnvMode, + projectFile: projectFileDefaultThreadEnvMode, + globalDefault: settings.defaultThreadEnvMode, + }), + } as const; + }, + ); + + const projectView = Effect.fn("ProjectMcpService.projectView")(function* ( + project: Project, + settings: ServerSettingsValue, + ) { + return { + ...project, + ...(yield* projectWorkspaceDefaults(project, settings)), + } satisfies ProjectMcpProject; + }); + + const projectSummary = Effect.fn("ProjectMcpService.projectSummary")(function* ( + project: Project, + settings: ServerSettingsValue, + ) { + return { + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + ...(project.repositoryIdentity === undefined + ? {} + : { repositoryIdentity: project.repositoryIdentity }), + ...(project.faviconPath === undefined ? {} : { faviconPath: project.faviconPath }), + defaultModelSelection: project.defaultModelSelection, + ...(project.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: project.defaultThreadEnvMode }), + createdAt: project.createdAt, + updatedAt: project.updatedAt, + ...(yield* projectWorkspaceDefaults(project, settings)), + } satisfies ProjectMcpProjectSummary; + }); + + const loadProject = Effect.fn("ProjectMcpService.loadProject")(function* (projectId: ProjectId) { + const project = yield* projects + .getById(projectId) + .pipe(redactOperationFailure("read-project", "Unable to read the requested project.")); + if (Option.isNone(project)) { + return yield* failure("project_not_found", `Project '${projectId}' was not found.`); + } + return project.value; + }); + + const list: ProjectMcpService["Service"]["list"] = (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + const snapshot = yield* projects.snapshot.pipe( + redactOperationFailure("list-projects", "Unable to list projects."), + ); + const settings = yield* loadSettings; + const cursor = input.cursor ?? 0; + const limit = input.limit ?? 25; + const sorted = snapshot.projects.toSorted( + (left, right) => + DateTime.toEpochMillis(DateTime.makeUnsafe(right.updatedAt)) - + DateTime.toEpochMillis(DateTime.makeUnsafe(left.updatedAt)) || + left.id.localeCompare(right.id), + ); + const page = sorted.slice(cursor, cursor + limit); + const nextCursor = cursor + page.length < sorted.length ? cursor + page.length : null; + return { + projects: yield* Effect.forEach(page, (project) => projectSummary(project, settings), { + concurrency: 8, + }), + nextCursor, + totalCount: sorted.length, + }; + }); + + const read: ProjectMcpService["Service"]["read"] = (scope, projectId) => + Effect.gen(function* () { + yield* requireCapability(scope); + return yield* projectView(yield* loadProject(projectId), yield* loadSettings); + }); + + const create: ProjectMcpService["Service"]["create"] = (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + const key = yield* requestKey(input.clientRequestId); + const projectId = ProjectId.make( + ["project", "mcp", stablePart(scope.providerSessionId), stablePart(key)].join(":"), + ); + return yield* projectCreates.withLock( + projectId, + Effect.gen(function* () { + const existing = yield* projects + .getById(projectId, { includeDeleted: true }) + .pipe( + redactOperationFailure( + "read-project-create-receipt", + "Unable to check the project creation result.", + ), + ); + if (Option.isSome(existing)) { + if (existing.value.deletedAt !== null) { + return yield* failure( + "project_deleted", + `Project '${projectId}' created by this clientRequestId was deleted. Use a new clientRequestId to create a new project; the deleted record will not be resurrected.`, + ); + } + return yield* projectView(existing.value, yield* loadSettings); + } + const settings = yield* loadSettings; + + let workspaceRoot: string; + let createWorkspaceRootIfMissing = false; + if (input.source.type === "existing_directory") { + workspaceRoot = input.source.workspaceRoot; + createWorkspaceRootIfMissing = input.source.createIfMissing ?? false; + } else { + const hasRemoteUrl = input.source.remoteUrl !== undefined; + const hasRepository = + input.source.provider !== undefined && input.source.repository !== undefined; + if (hasRemoteUrl === hasRepository) { + return yield* failure( + "invalid_request", + "Clone creation requires either remoteUrl or both provider and repository.", + ); + } + const cloned = yield* sourceControl + .cloneRepository({ + destinationPath: input.source.destinationPath, + ...(input.source.remoteUrl === undefined + ? {} + : { remoteUrl: input.source.remoteUrl }), + ...(input.source.provider === undefined ? {} : { provider: input.source.provider }), + ...(input.source.repository === undefined + ? {} + : { repository: input.source.repository }), + ...(input.source.protocol === undefined ? {} : { protocol: input.source.protocol }), + }) + .pipe( + redactOperationFailure( + "clone-project-repository", + "Unable to clone the requested repository.", + ), + ); + workspaceRoot = cloned.cwd; + } + + const project = yield* projects + .create({ + commandId: stableCommandId({ scope, requestKey: key, operation: "project-create" }), + projectId, + title: input.title, + workspaceRoot, + ...(createWorkspaceRootIfMissing ? { createWorkspaceRootIfMissing: true } : {}), + ...(input.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), + ...(input.scripts === undefined ? {} : { scripts: input.scripts }), + }) + .pipe( + redactOperationFailure("create-project", "Unable to create the requested project."), + ); + return yield* projectView(project, settings); + }), + ); + }); + + const update: ProjectMcpService["Service"]["update"] = (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + if ( + input.title === undefined && + input.workspaceRoot === undefined && + input.defaultModelSelection === undefined && + input.defaultThreadEnvMode === undefined && + input.faviconPath === undefined && + input.scripts === undefined + ) { + return yield* failure("invalid_request", "Provide at least one project field to update."); + } + const key = yield* requestKey(input.clientRequestId); + const settings = yield* loadSettings; + const project = yield* projects + .update({ + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "project-update", + suffix: input.projectId, + }), + projectId: input.projectId, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + ...(input.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), + ...(input.scripts === undefined ? {} : { scripts: input.scripts }), + }) + .pipe( + Effect.tapError(() => + Effect.logWarning("Project MCP operation failed.", { + operation: "update-project", + }), + ), + Effect.mapError((error) => + error._tag === "ProjectNotFoundError" + ? failure("project_not_found", `Project '${input.projectId}' was not found.`) + : failure("operation_failed", "Unable to update the requested project."), + ), + ); + return yield* projectView(project, settings); + }); + + const deleteProjectUnderLock: ProjectMcpService["Service"]["delete"] = (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + const projectOption = yield* projects + .getById(input.projectId, { includeDeleted: true }) + .pipe(redactOperationFailure("read-project-for-delete", "Unable to read the project.")); + if (Option.isNone(projectOption)) { + return yield* failure("project_not_found", `Project '${input.projectId}' was not found.`); + } + const project = projectOption.value; + if (project.deletedAt !== null) { + return { + projectId: input.projectId, + deleted: true, + alreadyDeleted: true, + deletedThreadCount: 0, + workspaceRoot: project.workspaceRoot, + workspaceFilesDeleted: false, + } satisfies ProjectMcpDeleteResult; + } + const [active, archived] = yield* Effect.all([ + threads.getShellSnapshot({ location: "active" }), + threads.getShellSnapshot({ location: "archive" }), + ]).pipe( + redactOperationFailure( + "inspect-project-threads", + "Unable to inspect the project's threads.", + ), + ); + const projectThreads = [ + ...new Map( + [ + ...active.threads, + ...active.archivedThreads, + ...archived.threads, + ...archived.archivedThreads, + ] + .filter((thread) => thread.projectId === input.projectId) + .map((thread) => [thread.id, thread] as const), + ).values(), + ]; + if (projectThreads.length > 0 && input.cascadeThreads !== true) { + return yield* failure( + "project_not_empty", + `Project '${input.projectId}' has ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}. Retry with cascadeThreads=true to delete their records first. Workspace files will remain untouched.`, + ); + } + const key = yield* requestKey(input.clientRequestId); + yield* Effect.forEach( + projectThreads, + (thread) => + threads.dispatch({ + type: "thread.delete", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "project-delete-thread", + suffix: thread.id, + }), + threadId: thread.id, + }), + { concurrency: 1, discard: true }, + ).pipe( + redactOperationFailure( + "delete-project-threads", + "Unable to delete the project's thread records.", + ), + ); + yield* projects + .delete({ + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "project-delete", + suffix: input.projectId, + }), + projectId: input.projectId, + }) + .pipe(redactOperationFailure("delete-project", "Unable to delete the requested project.")); + return { + projectId: input.projectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: projectThreads.length, + workspaceRoot: project.workspaceRoot, + workspaceFilesDeleted: false, + } satisfies ProjectMcpDeleteResult; + }); + + const deleteProject: ProjectMcpService["Service"]["delete"] = (scope, input) => + threads.withProjectMutationLock(input.projectId, deleteProjectUnderLock(scope, input)); + + return ProjectMcpService.of({ list, read, create, update, delete: deleteProject }); +}); + +export const layer: Layer.Layer< + ProjectMcpService, + never, + | Crypto.Crypto + | ProjectService.ProjectService + | T3ProjectFileLoader.T3ProjectFileLoader + | ServerSettings.ServerSettingsService + | SourceControlRepositoryService.SourceControlRepositoryService + | ThreadManagementService +> = Layer.effect(ProjectMcpService, make); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts new file mode 100644 index 000000000000..3be9f0fd42fe --- /dev/null +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -0,0 +1,40 @@ +import * as Effect from "effect/Effect"; + +import { McpInvocationContext } from "../../McpInvocationContext.ts"; +import { ProjectMcpService } from "../../ProjectMcpService.ts"; +import { ProjectToolkit } from "./tools.ts"; + +const handlers = { + t3_project_list: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.list(scope, input); + }), + t3_project_read: ({ projectId }) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.read(scope, projectId); + }), + t3_project_create: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.create(scope, input); + }), + t3_project_update: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.update(scope, input); + }), + t3_project_delete: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.delete(scope, input); + }), +} satisfies Parameters[0]; + +export const ProjectToolkitHandlersLive = ProjectToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts new file mode 100644 index 000000000000..7189276d5e46 --- /dev/null +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -0,0 +1,100 @@ +import { + ProjectMcpCreateInput, + ProjectMcpDeleteInput, + ProjectMcpDeleteResult, + ProjectMcpFailure, + ProjectMcpListInput, + ProjectMcpListResult, + ProjectMcpProject, + ProjectMcpReadInput, + ProjectMcpUpdateInput, +} from "@t3tools/contracts"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { ProjectMcpService } from "../../ProjectMcpService.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext, ProjectMcpService]; + +export const ProjectListTool = Tool.make("t3_project_list", { + description: + "List a bounded page of active project summaries in this T3 environment. Use t3_project_read for configured scripts and full project detail.", + parameters: ProjectMcpListInput, + success: ProjectMcpListResult, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "List T3 projects") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectReadTool = Tool.make("t3_project_read", { + description: + "Read one active project in this T3 environment by projectId, including its root, defaults, configured scripts, and effective workspace mode.", + parameters: ProjectMcpReadInput, + success: ProjectMcpProject, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Read a T3 project") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectCreateTool = Tool.make("t3_project_create", { + description: + "Create a T3 project from an existing directory, optionally creating that directory when missing, or clone a repository into a destination and register the clone. Cloning accepts either remoteUrl or provider plus repository. This tool never publishes a local repository to a host.", + parameters: ProjectMcpCreateInput, + success: ProjectMcpProject, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Create a T3 project") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +export const ProjectUpdateTool = Tool.make("t3_project_update", { + description: + "Update a T3 project. Omitted fields remain unchanged. Explicit null clears the model, workspace-mode, or icon override. Replacing scripts updates configuration only and does not run them.", + parameters: ProjectMcpUpdateInput, + success: ProjectMcpProject, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Update a T3 project") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectDeleteTool = Tool.make("t3_project_delete", { + description: + "Remove a T3 project record. A project with threads requires cascadeThreads=true, which deletes the thread records first. deletedThreadCount reports this attempt; alreadyDeleted distinguishes an idempotent replay. This tool never deletes the project directory, repository, worktrees, or any workspace files.", + parameters: ProjectMcpDeleteInput, + success: ProjectMcpDeleteResult, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Delete a T3 project") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectToolkit = Toolkit.make( + ProjectListTool, + ProjectReadTool, + ProjectCreateTool, + ProjectUpdateTool, + ProjectDeleteTool, +); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 3300a869fe67..3e4b5b6ed606 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -15,6 +15,7 @@ import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRu import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; @@ -28,6 +29,7 @@ const StubServicesLive = Layer.mergeAll( ServerSettings.layerTest({}), Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), Layer.mock(VcsStatusBroadcaster)({}), ); @@ -114,6 +116,14 @@ it.effect("production mcp layer lists worktree tools over http", () => // than replacing them. expect(toolNames).toContain("preview_status"); expect(toolNames).toContain("delegate_task"); + expect(toolNames).toContain("t3_project_list"); + expect(toolNames).toContain("t3_project_delete"); + const projectCreate = tools.find((tool) => tool.name === "t3_project_create"); + expect( + projectCreate?.annotations && "idempotentHint" in projectCreate.annotations + ? projectCreate.annotations.idempotentHint + : undefined, + ).not.toBe(true); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 35a3eb55a7e8..6e70e87229a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -46,6 +46,7 @@ import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeComp import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -579,8 +580,9 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { }); }); - it("matches the read-only allowlist to the orchestrator toolkit annotations", () => { - const readOnlyToolNames = Object.values(OrchestratorToolkit.tools) + it("matches the read-only allowlist to the permitted toolkit annotations", () => { + const readOnlyToolNames = [OrchestratorToolkit, ProjectToolkit] + .flatMap((toolkit) => Object.values(toolkit.tools)) .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) .map((tool) => `mcp__t3-code__${tool.name}`) .sort(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f1ac2fbe2afd..15eccedee8b7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -789,6 +789,8 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__list_scheduled_tasks", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", + "mcp__t3-code__t3_project_list", + "mcp__t3-code__t3_project_read", ]; // The SDK's `allowedTools` only pre-approves tool calls; availability is the diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts new file mode 100644 index 000000000000..214fd564d6f7 --- /dev/null +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from "@effect/vitest"; +import { + CommandId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type Project, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as ClientCommandDispatch from "./ClientCommandDispatch.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; + +it.effect("routes WebSocket thread creation through receipt-aware project admission", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:client-command-admission"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const project = { + id: projectId, + title: "Client command admission", + workspaceRoot: "/work/client-command-admission", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + scripts: [], + createdAt: "2026-08-30T00:00:00.000Z", + updatedAt: "2026-08-30T00:00:00.000Z", + deletedAt: null, + } satisfies Project; + const projectState = yield* Ref.make(project); + const adapter = { + instanceId: providerInstanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in client command tests"), + } as ProviderAdapterV2Shape; + const registry = ProviderAdapterRegistry.makeLayer([adapter]); + const orchestrator = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "client-command-admission" }, + registry, + { databaseLayer: SqlitePersistenceMemory, runEffectWorker: false }, + ); + const threadsLayer = ThreadManagement.layer.pipe(Layer.provide(orchestrator)); + const projectsLayer = Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(projectState).pipe(Effect.map(Option.fromNullishOr)), + }); + + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const dispatchClientCommand = (yield* ClientCommandDispatch.make).dispatch; + const command = { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:client-command-admission:create"), + threadId: ThreadId.make("thread:client-command-admission:create"), + projectId, + title: "Created from WebSocket", + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.1-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + } as const; + + yield* dispatchClientCommand(command); + const sequenceBeforeReplay = yield* threads.getThreadEventSequence(command.threadId); + yield* Ref.set(projectState, null); + + yield* dispatchClientCommand(command); + expect(yield* threads.getThreadEventSequence(command.threadId)).toBe(sequenceBeforeReplay); + + const freshCommand = { + ...command, + commandId: CommandId.make("command:client-command-admission:fresh"), + threadId: ThreadId.make("thread:client-command-admission:fresh"), + }; + const rejected = yield* dispatchClientCommand(freshCommand).pipe(Effect.flip); + expect(rejected).toMatchObject({ _tag: "ProjectMutationError" }); + expect( + Option.isNone(yield* Effect.option(threads.getThreadProjection(freshCommand.threadId))), + ).toBe(true); + }).pipe(Effect.provide(Layer.mergeAll(threadsLayer, projectsLayer))); + }), +); + +it.effect("returns a structured error when creation admission is unavailable", () => + Effect.gen(function* () { + const dispatchClientCommand = (yield* ClientCommandDispatch.make).dispatch; + const command = { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:client-command-admission:unavailable"), + threadId: ThreadId.make("thread:client-command-admission:unavailable"), + projectId: ProjectId.make("project:client-command-admission:unavailable"), + title: "Unavailable creation", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + } as const; + + const error = yield* dispatchClientCommand(command).pipe(Effect.flip); + expect(error).toBeInstanceOf(Orchestrator.OrchestratorDispatchError); + expect(error).toMatchObject({ + commandId: command.commandId, + commandType: "thread.create", + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + ThreadManagement.layer.pipe(Layer.provide(Orchestrator.layerUnavailable)), + Layer.mock(ProjectService.ProjectService)({}), + ), + ), + ), +); diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.ts new file mode 100644 index 000000000000..72bff396e408 --- /dev/null +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.ts @@ -0,0 +1,58 @@ +import { ProjectMutationError, type OrchestrationV2Command } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ProjectService from "../project/ProjectService.ts"; +import type { CommandReceiptStoreV2Error } from "./CommandReceiptStore.ts"; +import type { OrchestratorV2DispatchResult, OrchestratorV2Error } from "./Orchestrator.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; + +export class ClientCommandDispatch extends Context.Service< + ClientCommandDispatch, + { + readonly dispatch: ( + command: OrchestrationV2Command, + ) => Effect.Effect< + OrchestratorV2DispatchResult, + | ProjectMutationError + | ProjectService.ProjectOperationError + | CommandReceiptStoreV2Error + | OrchestratorV2Error + >; + } +>()("t3/orchestration-v2/ClientCommandDispatch") {} + +export const make = Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const threads = yield* ThreadManagement.ThreadManagementService; + + const dispatch = Effect.fn("orchestrationV2.dispatchClientCommand")(function* ( + command: OrchestrationV2Command, + ) { + if (command.type !== "thread.create") { + return yield* threads.dispatch(command); + } + return yield* threads.withProjectCreationAdmission( + { projectId: command.projectId, commandId: command.commandId }, + (receipt) => + Effect.gen(function* () { + if (Option.isNone(receipt)) { + const project = yield* projects.getById(command.projectId); + if (Option.isNone(project)) { + return yield* new ProjectMutationError({ + commandId: command.commandId, + message: `Project ${command.projectId} does not exist.`, + }); + } + } + return yield* threads.dispatch(command); + }), + ); + }); + + return ClientCommandDispatch.of({ dispatch }); +}); + +export const layer = Layer.effect(ClientCommandDispatch, make); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 66378584fd21..f122cac0157e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -41,7 +41,7 @@ import * as Stream from "effect/Stream"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2 } from "./CommandPolicy.ts"; -import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; +import { CommandReceiptStoreReadError, CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; @@ -176,6 +176,7 @@ export interface OrchestratorV2Shape { readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; + readonly getCommandReceipt: CommandReceiptStoreV2["Service"]["getByCommandId"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; @@ -2061,6 +2062,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + if (sourceProjection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Fork source thread ${command.sourceThreadId} is deleted.`, + }); + } const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); @@ -4512,6 +4520,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + if (parentProjection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Delegated task parent thread ${command.parentThreadId} is deleted.`, + }); + } const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); @@ -7160,6 +7175,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return OrchestratorV2.of({ resumeQueuedRuns, dispatch: dispatchWithReceipt, + getCommandReceipt: commandReceipts.getByCommandId, getThreadProjection: (threadId) => projectionStore .getThreadProjection(threadId) @@ -7262,6 +7278,13 @@ export const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + getCommandReceipt: (commandId) => + Effect.fail( + new CommandReceiptStoreReadError({ + commandId, + cause: "Orchestration V2 live runtime is not configured.", + }), + ), getThreadProjection: (threadId) => Effect.fail( new OrchestratorProjectionError({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 7f8b86b80c21..19213cb2a377 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -14,6 +14,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -67,6 +68,7 @@ const adapter = { } as ProviderAdapterV2Shape; interface HarnessOptions { + readonly getProjectById?: ProjectService.ProjectService["Service"]["getById"]; readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]; @@ -112,7 +114,9 @@ function makeHarness(options: HarnessOptions = {}) { bootstrap: () => Effect.die("unused"), update: () => Effect.die("unused"), delete: () => Effect.die("unused"), - getById: (id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none()), + getById: + options.getProjectById ?? + ((id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none())), getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), snapshot: Effect.die("unused"), }), @@ -212,6 +216,99 @@ function waitUntil(predicate: () => Effect.Effect): Effect. }); } +it.effect("claims a thread under the shared project mutation lock", () => + Effect.gen(function* () { + const lockEntered = yield* Deferred.make(); + const releaseLock = yield* Deferred.make(); + const projectRead = yield* Deferred.make(); + const harness = makeHarness({ + getProjectById: (id) => + Deferred.succeed(projectRead, undefined).pipe( + Effect.andThen(Effect.succeed(id === projectId ? Option.some(project) : Option.none())), + ), + }); + + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const blocker = yield* Effect.forkChild( + threads.withProjectMutationLock( + projectId, + Deferred.succeed(lockEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseLock)), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.await(lockEntered); + + const launchFiber = yield* Effect.forkChild( + launches.launch( + launchInput({ + command: "command:launch:project-mutation-lock", + thread: "thread:launch:project-mutation-lock", + }), + ), + { startImmediately: true }, + ); + assert.isFalse(yield* Deferred.isDone(projectRead)); + + yield* Deferred.succeed(releaseLock, undefined); + yield* Fiber.join(blocker); + const launched = yield* Fiber.join(launchFiber); + assert.isTrue(yield* Deferred.isDone(projectRead)); + assert.equal(launched.projection.thread.projectId, projectId); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect("replays an accepted launch after project deletion without new preparation", () => + Effect.gen(function* () { + const projectState = yield* Ref.make(project); + const setupEntered = yield* Deferred.make(); + const allowSetup = yield* Deferred.make(); + const setupFinished = yield* Deferred.make(); + const harness = makeHarness({ + getProjectById: (id) => + Ref.get(projectState).pipe( + Effect.map((current) => + id === projectId && current !== null ? Option.some(current) : Option.none(), + ), + ), + runSetup: () => + Deferred.succeed(setupEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowSetup)), + Effect.tap(() => Deferred.succeed(setupFinished, undefined)), + Effect.as({ status: "no-script" as const }), + ), + }); + + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = launchInput({ + command: "command:launch:replay-after-project-delete", + thread: "thread:launch:replay-after-project-delete", + }); + + const first = yield* launches.launch(input); + yield* Deferred.await(setupEntered); + yield* Deferred.succeed(allowSetup, undefined); + yield* Deferred.await(setupFinished); + const sequenceBeforeReplay = yield* threads.getThreadEventSequence(first.threadId); + yield* Ref.set(projectState, null); + + const replay = yield* launches.launch(input); + const sequenceAfterReplay = yield* threads.getThreadEventSequence(first.threadId); + assert.isFalse(first.resumed); + assert.isTrue(replay.resumed); + assert.equal(replay.threadId, first.threadId); + assert.equal(sequenceAfterReplay, sequenceBeforeReplay); + assert.equal(harness.runSetup.mock.calls.length, 1); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("returns a visible preparing message while provisioning is still blocked", () => Effect.gen(function* () { const worktreeEntered = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 97f9daba388c..50b955a55a80 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -420,75 +420,91 @@ export const make = Effect.gen(function* () { const launch: ThreadLaunchService["Service"]["launch"] = Effect.fn("ThreadLaunchService.launch")( function* (input) { - const project = yield* projects.getById(input.projectId).pipe( - Effect.mapError(mapError(input, "resolve-project")), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(mapError(input, "resolve-project")("Project not found.")), - onSome: Effect.succeed, - }), - ), - ); - if (input.reuseExistingThread === true && input.threadId === undefined) { - return yield* mapError( - input, - "update-thread", - )("Reusing an existing thread requires a thread id."); - } + const { launchReceipt, candidateThreadId, claimed } = yield* threads + .withProjectCreationAdmission( + { projectId: input.projectId, commandId: input.commandId }, + (launchReceipt) => + Effect.gen(function* () { + if (input.reuseExistingThread === true && input.threadId === undefined) { + return yield* mapError( + input, + "update-thread", + )("Reusing an existing thread requires a thread id."); + } - const launchReceipt = yield* readReceipt(input, input.commandId); - return yield* Effect.gen(function* () { - const candidateThreadId = - input.threadId ?? - (yield* ids.allocate - .thread({ projectId: input.projectId }) - .pipe(Effect.mapError(mapError(input, "create-thread")))); - - if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) { - yield* validateReusableThread(input, candidateThreadId); - } + const candidateThreadId = + input.threadId ?? + (Option.isSome(launchReceipt) + ? launchReceipt.value.threadId + : yield* ids.allocate + .thread({ projectId: input.projectId }) + .pipe(Effect.mapError(mapError(input, "create-thread")))); + + if (Option.isNone(launchReceipt)) { + yield* projects.getById(input.projectId).pipe( + Effect.mapError(mapError(input, "resolve-project")), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(mapError(input, "resolve-project")("Project not found.")), + onSome: () => Effect.void, + }), + ), + ); + } - const initialBranch = input.workspaceStrategy.branch ?? null; - const initialWorktreePath = - input.workspaceStrategy.type === "existing_worktree" - ? input.workspaceStrategy.worktreePath - : null; - const claimDispatch = - input.reuseExistingThread === true - ? threads.dispatch({ - type: "thread.metadata.update", - commandId: input.commandId, - threadId: candidateThreadId, - }) - : threads.dispatch({ - type: "thread.create", - commandId: input.commandId, - threadId: candidateThreadId, - projectId: input.projectId, - title: input.title, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - branch: initialBranch, - worktreePath: initialWorktreePath, - createdBy: input.createdBy, - creationSource: input.creationSource, - }); - const claimed = yield* claimDispatch.pipe( - Effect.mapError( - mapError( - input, - input.reuseExistingThread === true ? "update-thread" : "create-thread", - candidateThreadId, - ), + if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) { + yield* validateReusableThread(input, candidateThreadId); + } + + const initialBranch = input.workspaceStrategy.branch ?? null; + const initialWorktreePath = + input.workspaceStrategy.type === "existing_worktree" + ? input.workspaceStrategy.worktreePath + : null; + const claimDispatch = + input.reuseExistingThread === true + ? threads.dispatch({ + type: "thread.metadata.update", + commandId: input.commandId, + threadId: candidateThreadId, + }) + : threads.dispatch({ + type: "thread.create", + commandId: input.commandId, + threadId: candidateThreadId, + projectId: input.projectId, + title: input.title, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: initialBranch, + worktreePath: initialWorktreePath, + createdBy: input.createdBy, + creationSource: input.creationSource, + }); + const claimed = yield* claimDispatch.pipe( + Effect.mapError( + mapError( + input, + input.reuseExistingThread === true ? "update-thread" : "create-thread", + candidateThreadId, + ), + ), + ); + return { launchReceipt, candidateThreadId, claimed }; + }), + ) + .pipe( + Effect.mapError((cause) => + isThreadLaunchError(cause) ? cause : mapError(input, "read-receipt")(cause), ), ); + + return yield* Effect.gen(function* () { const threadId = claimed.storedEvents.find((stored) => stored.event.type.startsWith("thread."))?.event .threadId ?? candidateThreadId; - if (project.id !== input.projectId) { - return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); - } let runId: RunId | null = null; let messageWasAlreadyAccepted = false; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 22741da769ad..66189d0bf0c3 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -35,6 +35,8 @@ import { LegacyV1ThreadImporter, type LegacyV1ThreadImportError, } from "./LegacyV1ThreadImporter.ts"; +import type { CommandReceiptV2 } from "./CommandReceiptStore.ts"; +import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -265,6 +267,14 @@ export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | OrchestratorV2Error; export interface ThreadManagementServiceShape { + readonly withProjectCreationAdmission: ( + input: { readonly projectId: ProjectId; readonly commandId: CommandId }, + effect: (receipt: Option.Option) => Effect.Effect, + ) => Effect.Effect; + readonly withProjectMutationLock: ( + projectId: ProjectId, + effect: Effect.Effect, + ) => Effect.Effect; readonly ensureLegacyTranscript: ( threadId: ThreadId, ) => Effect.Effect; @@ -361,6 +371,24 @@ export function latestSteerableRun( const make = Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; const legacyImporter = yield* LegacyV1ThreadImporter; + const projectMutations = yield* makeKeyedSerialExecutor(); + + const withProjectCreationAdmission: ThreadManagementServiceShape["withProjectCreationAdmission"] = + (input, effect) => + projectMutations.withLock( + input.projectId, + orchestrator.getCommandReceipt(input.commandId).pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: input.commandId, + commandType: "thread.create", + cause, + }), + ), + Effect.flatMap(effect), + ), + ); const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", @@ -422,8 +450,24 @@ const make = Effect.gen(function* () { Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)), ); - const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => - ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command))); + const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => { + const admissionThreadId = + command.type === "thread.fork" + ? command.sourceThreadId + : command.type === "delegated_task.request" + ? command.parentThreadId + : undefined; + const dispatchCommand = orchestrator.dispatch(command); + if (admissionThreadId === undefined) { + return ensureCommandTranscripts(command).pipe(Effect.andThen(dispatchCommand)); + } + return ensureCommandTranscripts(command).pipe( + Effect.andThen(orchestrator.getThreadProjection(admissionThreadId)), + Effect.flatMap((projection) => + projectMutations.withLock(projection.thread.projectId, dispatchCommand), + ), + ); + }; const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) => getThreadProjection(input.threadId).pipe( @@ -651,6 +695,8 @@ const make = Effect.gen(function* () { }); return ThreadManagementService.of({ + withProjectCreationAdmission, + withProjectMutationLock: projectMutations.withLock, ensureLegacyTranscript, dispatch, getThreadProjection, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index b6e24a736efe..8c7bbde04c1e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -10,6 +10,7 @@ import { layer as projectSetupScriptRunnerLayer } from "../project/ProjectSetupS import { layer as checkpointCaptureServiceLayer } from "./CheckpointCaptureService.ts"; import { layer as checkpointServiceLayer } from "./CheckpointService.ts"; import { layer as checkpointRollbackServiceLayer } from "./CheckpointRollbackService.ts"; +import * as ClientCommandDispatch from "./ClientCommandDispatch.ts"; import { layer as commandPolicyLayer } from "./CommandPolicy.ts"; import { layerFromApplicationReceipts as commandReceiptStoreLayer } from "./CommandReceiptStore.ts"; import { layer as contextHandoffServiceLayer } from "./ContextHandoffService.ts"; @@ -185,6 +186,9 @@ const orchestratorProvided = orchestratorLayer.pipe( const threadManagementProvided = threadManagementServiceLayer.pipe( Layer.provide(Layer.merge(orchestratorProvided, legacyV1ThreadImporterProvided)), ); +const clientCommandDispatchProvided = ClientCommandDispatch.layer.pipe( + Layer.provide(Layer.merge(ProjectServiceLayerLive, threadManagementProvided)), +); export const ProjectSetupScriptRunnerLayerLive = projectSetupScriptRunnerLayer.pipe( Layer.provide(ProjectServiceLayerLive), ); @@ -257,6 +261,7 @@ export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( OrchestrationLayerLive, OrchestrationV2LayerLive.pipe(Layer.provide(ProjectServiceLayerLive)), ProjectServiceLayerLive, + clientCommandDispatchProvided, threadLaunchProvided, threadLifecycleProvided, scheduledTaskProvided, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 8d42c2dda207..2759632e1575 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -516,7 +516,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, - defaultThreadEnvMode: null, + defaultThreadEnvMode: event.payload.defaultThreadEnvMode ?? null, faviconPath: event.payload.faviconPath ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 197437054074..e85dd6bd371c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -250,7 +250,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, - faviconPath: null, + defaultThreadEnvMode: command.defaultThreadEnvMode ?? null, + faviconPath: command.faviconPath ?? null, scripts: command.scripts ?? [], createdAt: command.createdAt, updatedAt: command.createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 23e425014c59..649453c9c82e 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -218,7 +218,7 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, - defaultThreadEnvMode: null, + defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null, faviconPath: payload.faviconPath ?? null, scripts: payload.scripts, createdAt: payload.createdAt, diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index c4a877edc8ae..0f687150ade3 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -90,6 +90,8 @@ it.layer(TestLayer)("ProjectService", (it) => { title: "Project", workspaceRoot: "/work/project/", defaultModelSelection: modelSelection, + defaultThreadEnvMode: "worktree", + faviconPath: "/work/project/custom-icon.svg", scripts: [ { id: "setup", @@ -102,22 +104,27 @@ it.layer(TestLayer)("ProjectService", (it) => { }); assert.equal(created.workspaceRoot, "/work/project"); assert.isNull(created.repositoryIdentity); - assert.isNull(created.faviconPath); + assert.equal(created.defaultThreadEnvMode, "worktree"); + assert.equal(created.faviconPath, "/work/project/custom-icon.svg"); const hydratedCreated = yield* waitForProject( service, projectId, - (project) => project.repositoryIdentity !== null && project.faviconPath !== null, + (project) => project.repositoryIdentity !== null, ); assert.equal(hydratedCreated?.repositoryIdentity?.canonicalKey, "github.com/t3tools/project"); - assert.equal(hydratedCreated?.faviconPath, "/work/project/favicon.svg"); + assert.equal(hydratedCreated?.faviconPath, "/work/project/custom-icon.svg"); const updated = yield* service.update({ commandId: CommandId.make("command:project:update"), projectId, title: "Renamed", + defaultThreadEnvMode: null, + faviconPath: null, }); assert.equal(updated.title, "Renamed"); + assert.isNull(updated.defaultThreadEnvMode); + assert.equal(updated.faviconPath, "/work/project/favicon.svg"); assert.equal(updated.createdAt, created.createdAt); const byId = yield* service.getById(projectId); @@ -139,6 +146,18 @@ it.layer(TestLayer)("ProjectService", (it) => { assert.isTrue(Option.isSome(yield* service.getById(projectId, { includeDeleted: true }))); assert.deepEqual((yield* service.snapshot).projects, []); + const replayedCreate = yield* service.create({ + commandId: CommandId.make("command:project:create"), + projectId, + title: "Project", + workspaceRoot: "/work/project/", + defaultModelSelection: modelSelection, + defaultThreadEnvMode: "worktree", + faviconPath: "/work/project/custom-icon.svg", + }); + assert.equal(replayedCreate.deletedAt, deleted.deletedAt); + assert.equal(replayedCreate.title, "Renamed"); + const sql = yield* SqlClient.SqlClient; const changes = yield* sql<{ readonly event_type: string }>` SELECT event_type diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 4f11d0111a3b..44a371563877 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -5,6 +5,7 @@ import { type Project, type ProjectScript, type ProjectSnapshot, + type ThreadEnvMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -25,6 +26,8 @@ export interface ProjectCreateInput { readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } @@ -34,6 +37,8 @@ export interface ProjectUpdateInput { readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } @@ -128,8 +133,9 @@ export const make = Effect.gen(function* () { title: row.title, workspaceRoot: row.workspaceRoot, repositoryIdentity: enrichment?.repositoryIdentity ?? null, - faviconPath: enrichment?.faviconPath ?? null, + faviconPath: row.faviconPath ?? enrichment?.faviconPath ?? null, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -278,6 +284,8 @@ export const make = Effect.gen(function* () { title: input.title, workspaceRoot, defaultModelSelection: input.defaultModelSelection ?? null, + defaultThreadEnvMode: input.defaultThreadEnvMode ?? null, + faviconPath: input.faviconPath ?? null, scripts: [...(input.scripts ?? [])], createdAt: now, }, @@ -327,6 +335,10 @@ export const make = Effect.gen(function* () { ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), ...(input.scripts === undefined ? {} : { scripts: [...input.scripts] }), }, (workspaceRoot === existing.value.workspaceRoot diff --git a/apps/server/src/project/http.test.ts b/apps/server/src/project/http.test.ts index a58e6a299bed..26185a665e2e 100644 --- a/apps/server/src/project/http.test.ts +++ b/apps/server/src/project/http.test.ts @@ -1,17 +1,120 @@ import { assert, it } from "@effect/vitest"; -import { ProjectId } from "@t3tools/contracts"; +import { CommandId, type Project, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import { + type ProjectCreateInput, ProjectConflictError, ProjectNotFoundError, ProjectOperationError, + ProjectService, + type ProjectUpdateInput, } from "./ProjectService.ts"; import { ServerRuntimeStartupError } from "../serverRuntimeStartup.ts"; -import { failProjectMutation } from "./http.ts"; +import { failProjectMutation, projectMutationOperation } from "./http.ts"; const projectId = ProjectId.make("project:http-mutation"); +const project: Project = { + id: projectId, + title: "HTTP project", + workspaceRoot: "/workspace/project", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + scripts: [], + createdAt: "2026-08-29T12:00:00.000Z", + updatedAt: "2026-08-29T12:00:00.000Z", + deletedAt: null, +}; + +it.effect( + "forwards project defaults through HTTP mutations without collapsing omission and null", + () => + Effect.gen(function* () { + const createInputs = yield* Ref.make>([]); + const updateInputs = yield* Ref.make>([]); + const projectLayer = Layer.mock(ProjectService)({ + create: (input) => + Ref.update(createInputs, (inputs) => [...inputs, input]).pipe(Effect.as(project)), + update: (input) => + Ref.update(updateInputs, (inputs) => [...inputs, input]).pipe(Effect.as(project)), + }); + + yield* Effect.gen(function* () { + const projects = yield* ProjectService; + yield* projectMutationOperation(projects, { + type: "project.create", + commandId: CommandId.make("command:http-project:create-supplied"), + projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: true, + defaultThreadEnvMode: "worktree", + faviconPath: "/workspace/project/icon.png", + }); + yield* projectMutationOperation(projects, { + type: "project.create", + commandId: CommandId.make("command:http-project:create-omitted"), + projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + }); + yield* projectMutationOperation(projects, { + type: "project.create", + commandId: CommandId.make("command:http-project:create-null"), + projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: false, + defaultThreadEnvMode: null, + faviconPath: null, + }); + yield* projectMutationOperation(projects, { + type: "project.update", + commandId: CommandId.make("command:http-project:update-supplied"), + projectId, + defaultThreadEnvMode: "worktree", + faviconPath: "/workspace/project/icon.png", + }); + yield* projectMutationOperation(projects, { + type: "project.update", + commandId: CommandId.make("command:http-project:update-omitted"), + projectId, + }); + yield* projectMutationOperation(projects, { + type: "project.update", + commandId: CommandId.make("command:http-project:update-null"), + projectId, + defaultThreadEnvMode: null, + faviconPath: null, + }); + }).pipe(Effect.provide(projectLayer)); + + const [createSupplied, createOmitted, createNullAndFalse] = yield* Ref.get(createInputs); + const [updateSupplied, updateOmitted, updateNull] = yield* Ref.get(updateInputs); + + assert.equal(createSupplied?.defaultThreadEnvMode, "worktree"); + assert.equal(createSupplied?.faviconPath, "/workspace/project/icon.png"); + assert.strictEqual(createSupplied?.createWorkspaceRootIfMissing, true); + assert.equal(Object.hasOwn(createOmitted!, "defaultThreadEnvMode"), false); + assert.equal(Object.hasOwn(createOmitted!, "faviconPath"), false); + assert.equal(Object.hasOwn(createOmitted!, "createWorkspaceRootIfMissing"), false); + assert.strictEqual(createNullAndFalse?.defaultThreadEnvMode, null); + assert.strictEqual(createNullAndFalse?.faviconPath, null); + assert.strictEqual(createNullAndFalse?.createWorkspaceRootIfMissing, false); + assert.equal(updateSupplied?.defaultThreadEnvMode, "worktree"); + assert.equal(updateSupplied?.faviconPath, "/workspace/project/icon.png"); + assert.equal(Object.hasOwn(updateOmitted!, "defaultThreadEnvMode"), false); + assert.equal(Object.hasOwn(updateOmitted!, "faviconPath"), false); + assert.strictEqual(updateNull?.defaultThreadEnvMode, null); + assert.strictEqual(updateNull?.faviconPath, null); + }), +); + it.effect.each([ new ProjectNotFoundError({ projectId }), new ProjectConflictError({ diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index cb31c38bfcac..d05d0b9ec9ad 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -2,6 +2,7 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, + type ProjectMutation, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -24,6 +25,50 @@ export const failProjectMutation = Effect.fn("environment.projects.failMutation" return yield* failEnvironmentInternal("project_mutation_failed", cause); }); +export const projectMutationOperation = ( + projects: ProjectService["Service"], + mutation: ProjectMutation, +) => + mutation.type === "project.create" + ? projects.create({ + commandId: mutation.commandId, + projectId: mutation.projectId, + title: mutation.title, + workspaceRoot: mutation.workspaceRoot, + ...(mutation.createWorkspaceRootIfMissing === undefined + ? {} + : { createWorkspaceRootIfMissing: mutation.createWorkspaceRootIfMissing }), + ...(mutation.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.faviconPath === undefined ? {} : { faviconPath: mutation.faviconPath }), + ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), + }) + : mutation.type === "project.update" + ? projects.update({ + commandId: mutation.commandId, + projectId: mutation.projectId, + ...(mutation.title === undefined ? {} : { title: mutation.title }), + ...(mutation.workspaceRoot === undefined + ? {} + : { workspaceRoot: mutation.workspaceRoot }), + ...(mutation.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.faviconPath === undefined ? {} : { faviconPath: mutation.faviconPath }), + ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), + }) + : projects.delete({ + commandId: mutation.commandId, + projectId: mutation.projectId, + }); + export const projectHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "projects", @@ -47,36 +92,7 @@ export const projectHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.projects.mutate")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationOperateScope); - const mutation = args.payload; - const operation = - mutation.type === "project.create" - ? projects.create({ - commandId: mutation.commandId, - projectId: mutation.projectId, - title: mutation.title, - workspaceRoot: mutation.workspaceRoot, - ...(mutation.defaultModelSelection === undefined - ? {} - : { defaultModelSelection: mutation.defaultModelSelection }), - ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), - }) - : mutation.type === "project.update" - ? projects.update({ - commandId: mutation.commandId, - projectId: mutation.projectId, - ...(mutation.title === undefined ? {} : { title: mutation.title }), - ...(mutation.workspaceRoot === undefined - ? {} - : { workspaceRoot: mutation.workspaceRoot }), - ...(mutation.defaultModelSelection === undefined - ? {} - : { defaultModelSelection: mutation.defaultModelSelection }), - ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), - }) - : projects.delete({ - commandId: mutation.commandId, - projectId: mutation.projectId, - }); + const operation = projectMutationOperation(projects, args.payload); return yield* startup.enqueueCommand(operation).pipe(Effect.catch(failProjectMutation)); }), ); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e05..adf2414292e2 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -9,8 +9,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; @@ -53,6 +56,10 @@ function processOutput(): GitVcsDriver.ExecuteGitResult { }; } +function processOutputWithStdout(stdout: string): GitVcsDriver.ExecuteGitResult { + return { ...processOutput(), stdout }; +} + function makeLayer(input: { readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; readonly git?: Partial; @@ -94,6 +101,23 @@ function makeLayer(input: { : serviceLayer.pipe(Layer.provideMerge(NodeServices.layer)); } +function makeRealGitLayer() { + const gitLayer = GitVcsDriver.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(NodeServices.layer), + ); + return SourceControlRepositoryService.layer.pipe( + Layer.provide( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + get: () => Effect.succeed(makeProvider()), + }), + ), + Layer.provideMerge(gitLayer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-real-git-" })), + Layer.provideMerge(NodeServices.layer), + ); +} + it.effect("looks up repositories through the requested provider without search", () => { const calls: Array<{ cwd: string; repository: string }> = []; const provider = makeProvider({ @@ -156,8 +180,17 @@ it.effect("clones a looked-up repository into the requested destination", () => const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); - const destinationPath = `${parent}/t3code`; + const destinationPath = `${parent}/missing/nested/t3code`; const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; + const lookupCwds: Array = []; + const provider = makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.gen(function* () { + assert.strictEqual(yield* fs.exists(input.cwd).pipe(Effect.orDie), true); + lookupCwds.push(input.cwd); + return CLONE_URLS; + }), + }); yield* Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; @@ -175,8 +208,8 @@ it.effect("clones a looked-up repository into the requested destination", () => }); assert.deepStrictEqual(cloneCalls, [ { - cwd: parent, - args: ["clone", CLONE_URLS.url, "t3code"], + cwd: `${parent}/missing/nested`, + args: ["clone", "--origin", "origin", CLONE_URLS.url, "t3code"], }, ]); }).pipe( @@ -189,6 +222,253 @@ it.effect("clones a looked-up repository into the requested destination", () => return processOutput(); }), }, + provider, + }), + ), + ); + assert.deepStrictEqual(lookupCwds, [`${parent}/missing/nested`]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("recovers a matching completed clone and rejects unrelated contents", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-recover-clone-", + }); + const matchingDestination = `${parent}/matching`; + const unrelatedDestination = `${parent}/unrelated`; + const wrongPortDestination = `${parent}/wrong-port`; + const wrongCaseDestination = `${parent}/wrong-case`; + const wrongUserDestination = `${parent}/wrong-user`; + const absoluteSshDestination = `${parent}/absolute-ssh`; + yield* fs.makeDirectory(matchingDestination); + yield* fs.makeDirectory(unrelatedDestination); + yield* fs.makeDirectory(wrongPortDestination); + yield* fs.makeDirectory(wrongCaseDestination); + yield* fs.makeDirectory(wrongUserDestination); + yield* fs.makeDirectory(absoluteSshDestination); + yield* fs.writeFileString(`${matchingDestination}/README.md`, "matching clone"); + yield* fs.writeFileString(`${unrelatedDestination}/README.md`, "unrelated repository"); + yield* fs.writeFileString(`${wrongPortDestination}/README.md`, "different port"); + yield* fs.writeFileString(`${wrongCaseDestination}/README.md`, "different case"); + yield* fs.writeFileString(`${wrongUserDestination}/README.md`, "different user"); + yield* fs.writeFileString(`${absoluteSshDestination}/README.md`, "absolute SSH path"); + const cloneCalls: Array> = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const recovered = yield* service.cloneRepository({ + remoteUrl: CLONE_URLS.url, + destinationPath: matchingDestination, + }); + assert.strictEqual(recovered.cwd, matchingDestination); + assert.deepStrictEqual(cloneCalls, []); + + const mismatch = yield* Effect.flip( + service.cloneRepository({ + remoteUrl: CLONE_URLS.url, + destinationPath: unrelatedDestination, + }), + ); + assert.strictEqual( + mismatch.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + + for (const [destinationPath, remoteUrl] of [ + [wrongPortDestination, "https://git.example:8444/org/repo.git"], + [wrongCaseDestination, "https://git.example/org/repo.git"], + [wrongUserDestination, "bob@git.example:repo.git"], + [absoluteSshDestination, "git@git.example:repo.git"], + ] as const) { + const strictMismatch = yield* Effect.flip( + service.cloneRepository({ remoteUrl, destinationPath }), + ); + assert.strictEqual( + strictMismatch.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + } + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => { + if (input.args[0] === "rev-parse") { + return Effect.succeed( + processOutputWithStdout( + input.cwd === matchingDestination ? matchingDestination : unrelatedDestination, + ), + ); + } + if (input.args[0] === "for-each-ref") { + return Effect.succeed(processOutputWithStdout(input.cwd)); + } + if (input.args[0] === "status") return Effect.succeed(processOutput()); + cloneCalls.push(input.args); + return Effect.succeed(processOutput()); + }, + readConfigValue: (cwd) => + Effect.succeed( + cwd === matchingDestination + ? CLONE_URLS.url + : cwd === wrongPortDestination + ? "https://git.example:8443/org/repo.git" + : cwd === wrongCaseDestination + ? "https://git.example/org/Repo.git" + : cwd === wrongUserDestination + ? "alice@git.example:repo.git" + : cwd === absoluteSshDestination + ? "ssh://git@git.example/repo.git" + : "https://github.com/acme/other.git", + ), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("recovers a real completed clone after a lost result and rejects unsafe adoption", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-real-recovery-", + }); + const sourcePath = `${parent}/source`; + const otherSourcePath = `${parent}/other-source`; + const destinationPath = `${parent}/destination`; + const incompletePath = `${parent}/incomplete`; + const unrelatedCleanPath = `${parent}/unrelated-clean`; + + const initializeRepository = (cwd: string, contents: string) => + Effect.gen(function* () { + yield* fs.makeDirectory(cwd); + yield* git.execute({ operation: "test.init", cwd, args: ["init"] }); + yield* git.execute({ + operation: "test.config.email", + cwd, + args: ["config", "user.email", "tests@t3.codes"], + }); + yield* git.execute({ + operation: "test.config.name", + cwd, + args: ["config", "user.name", "T3 Tests"], + }); + yield* fs.writeFileString(`${cwd}/README.md`, contents); + yield* git.execute({ operation: "test.add", cwd, args: ["add", "README.md"] }); + yield* git.execute({ operation: "test.commit", cwd, args: ["commit", "-m", "initial"] }); + }); + + yield* initializeRepository(sourcePath, "source"); + yield* initializeRepository(otherSourcePath, "other source"); + + const cloned = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); + assert.strictEqual(cloned.cwd, destinationPath); + + // Simulate the caller losing the successful clone result before project registration. + const recovered = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); + assert.strictEqual(recovered.cwd, destinationPath); + + const unrelated = yield* Effect.flip( + service.cloneRepository({ remoteUrl: otherSourcePath, destinationPath }), + ); + assert.strictEqual( + unrelated.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + + yield* initializeRepository(unrelatedCleanPath, "unrelated history"); + yield* git.execute({ + operation: "test.unrelated.remote", + cwd: unrelatedCleanPath, + args: ["remote", "add", "origin", sourcePath], + }); + const unrelatedHistory = yield* Effect.flip( + service.cloneRepository({ remoteUrl: sourcePath, destinationPath: unrelatedCleanPath }), + ); + assert.strictEqual( + unrelatedHistory.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + + yield* git.execute({ + operation: "test.incomplete.clone", + cwd: parent, + args: ["clone", "--no-checkout", sourcePath, "incomplete"], + }); + const incomplete = yield* Effect.flip( + service.cloneRepository({ remoteUrl: sourcePath, destinationPath: incompletePath }), + ); + assert.strictEqual( + incomplete.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + }).pipe(Effect.provide(makeRealGitLayer())), +); + +it.effect("serializes overlapping clones to one destination and reuses the verified result", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-overlap-clone-", + }); + const realParent = `${parent}/real`; + const aliasParent = `${parent}/alias`; + yield* fs.makeDirectory(realParent); + yield* fs.symlink(realParent, aliasParent); + const destinationPath = `${realParent}/t3code`; + const aliasDestinationPath = `${aliasParent}/t3code`; + const cloneEntered = yield* Deferred.make(); + const allowClone = yield* Deferred.make(); + let cloneCount = 0; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const first = yield* Effect.forkChild( + service.cloneRepository({ remoteUrl: CLONE_URLS.url, destinationPath }), + { startImmediately: true }, + ); + yield* Deferred.await(cloneEntered); + const second = yield* Effect.forkChild( + service.cloneRepository({ + remoteUrl: CLONE_URLS.url, + destinationPath: aliasDestinationPath, + }), + { startImmediately: true }, + ); + yield* Deferred.succeed(allowClone, undefined); + const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); + assert.deepStrictEqual( + results.map((result) => result.cwd), + [destinationPath, aliasDestinationPath], + ); + assert.strictEqual(cloneCount, 1); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => { + if (input.args[0] === "clone") { + cloneCount += 1; + return fs + .makeDirectory(destinationPath) + .pipe( + Effect.andThen(fs.writeFileString(`${destinationPath}/README.md`, "cloned")), + Effect.andThen(Deferred.succeed(cloneEntered, undefined)), + Effect.andThen(Deferred.await(allowClone)), + Effect.as(processOutput()), + Effect.orDie, + ); + } + if (input.args[0] === "status") return Effect.succeed(processOutput()); + return Effect.succeed(processOutputWithStdout(destinationPath)); + }, + readConfigValue: () => Effect.succeed(CLONE_URLS.url), + }, }), ), ); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c4..cc0ecb767df4 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -18,8 +18,8 @@ import { type SourceControlRepositoryInfo, type SourceControlRepositoryLookupInput, } from "@t3tools/contracts"; - import { ServerConfig } from "../config.ts"; +import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -93,6 +93,7 @@ export const make = Effect.gen(function* () { const git = yield* GitVcsDriver.GitVcsDriver; const path = yield* Path.Path; const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const cloneOperations = yield* makeKeyedSerialExecutor(); const ensureConcreteProvider = (input: { readonly operation: string; @@ -141,9 +142,26 @@ export const make = Effect.gen(function* () { }, ); + const canonicalDestinationKey = Effect.fn( + "SourceControlRepositoryService.canonicalDestinationKey", + )(function* (destinationPath: string) { + if (yield* fileSystem.exists(destinationPath)) { + return yield* fileSystem.realPath(destinationPath); + } + + const suffix = [path.basename(destinationPath)]; + let ancestor = path.dirname(destinationPath); + while (!(yield* fileSystem.exists(ancestor))) { + const parent = path.dirname(ancestor); + if (parent === ancestor) break; + suffix.unshift(path.basename(ancestor)); + ancestor = parent; + } + return path.join(yield* fileSystem.realPath(ancestor), ...suffix); + }); + const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( - function* (destinationPath: string) { - const normalizedDestination = yield* normalizeDestinationPath(destinationPath); + function* (normalizedDestination: string) { if (yield* fileSystem.exists(normalizedDestination)) { const entries = yield* fileSystem .readDirectory(normalizedDestination, { recursive: false }) @@ -158,13 +176,12 @@ export const make = Effect.gen(function* () { }), ), ); - if (entries.length > 0) { - return yield* new SourceControlRepositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Destination path already exists and is not empty.", - }); - } + return { + destinationPath: normalizedDestination, + parentPath: path.dirname(normalizedDestination), + directoryName: path.basename(normalizedDestination), + occupied: entries.length > 0, + } as const; } else { yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); } @@ -173,49 +190,144 @@ export const make = Effect.gen(function* () { destinationPath: normalizedDestination, parentPath: path.dirname(normalizedDestination), directoryName: path.basename(normalizedDestination), - }; + occupied: false, + } as const; }, ); + const isMatchingClone = Effect.fn("SourceControlRepositoryService.isMatchingClone")(function* ( + destinationPath: string, + remoteUrl: string, + ) { + const repositoryRoot = yield* git.execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyRoot", + cwd: destinationPath, + args: ["rev-parse", "--show-toplevel"], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }); + const reportedRoot = repositoryRoot.stdout.trim(); + if (reportedRoot.length === 0) return false; + const [canonicalDestination, canonicalRoot] = yield* Effect.all([ + fileSystem.realPath(destinationPath), + fileSystem.realPath(reportedRoot), + ]); + if (canonicalDestination !== canonicalRoot) return false; + const existingRemote = yield* git.readConfigValue(destinationPath, "remote.origin.url"); + if (existingRemote === null || existingRemote.trim() !== remoteUrl.trim()) { + return false; + } + + // A repository shell with only an origin, or with fetched refs but an + // unfinished checkout, can be left behind by an interrupted clone. + const headCommit = yield* git + .execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyHead", + cwd: destinationPath, + args: ["rev-parse", "--verify", "HEAD^{commit}"], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }) + .pipe( + Effect.map((result) => result.stdout.trim() || null), + Effect.orElseSucceed(() => null), + ); + if (headCommit === null) return false; + + const remoteCommits = yield* git + .execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyRemoteRefs", + cwd: destinationPath, + args: ["for-each-ref", "--format=%(objectname)", "refs/remotes/origin"], + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }) + .pipe( + Effect.map((result) => new Set(result.stdout.split("\n").map((line) => line.trim()))), + Effect.orElseSucceed(() => new Set()), + ); + if (!remoteCommits.has(headCommit)) return false; + + return yield* git + .execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyCheckout", + cwd: destinationPath, + args: ["status", "--porcelain=v1", "--untracked-files=no"], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }) + .pipe( + Effect.map((result) => result.stdout.trim().length === 0), + Effect.orElseSucceed(() => false), + ); + }); + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( input: SourceControlCloneRepositoryInput, ) { - const preparedDestination = yield* prepareDestination(input.destinationPath); - let repository: SourceControlRepositoryInfo | null = null; - let remoteUrl = input.remoteUrl?.trim() ?? null; - let provider: SourceControlProviderKind = input.provider ?? "unknown"; + const destinationPath = yield* normalizeDestinationPath(input.destinationPath); + const destinationKey = yield* canonicalDestinationKey(destinationPath); + return yield* cloneOperations.withLock( + destinationKey, + Effect.gen(function* () { + let repository: SourceControlRepositoryInfo | null = null; + let remoteUrl = input.remoteUrl?.trim() ?? null; + let provider: SourceControlProviderKind = input.provider ?? "unknown"; - if (input.provider && input.repository) { - repository = yield* lookupRepository({ - provider: input.provider, - repository: input.repository, - cwd: preparedDestination.parentPath, - }); - remoteUrl = selectRemoteUrl(repository, input.protocol); - provider = input.provider; - } + if (!remoteUrl && !(input.provider && input.repository)) { + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail: "Enter a repository path or clone URL before cloning.", + }); + } - if (!remoteUrl) { - return yield* new SourceControlRepositoryError({ - operation: "cloneRepository", - provider, - detail: "Enter a repository path or clone URL before cloning.", - }); - } + const preparedDestination = yield* prepareDestination(destinationPath); - yield* git.execute({ - operation: "SourceControlRepositoryService.cloneRepository", - cwd: preparedDestination.parentPath, - args: ["clone", remoteUrl, preparedDestination.directoryName], - timeoutMs: 120_000, - maxOutputBytes: 256 * 1024, - }); + if (input.provider && input.repository) { + repository = yield* lookupRepository({ + provider: input.provider, + repository: input.repository, + cwd: preparedDestination.parentPath, + }); + remoteUrl = selectRemoteUrl(repository, input.protocol); + provider = input.provider; + } + + if (!remoteUrl) { + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail: "Enter a repository path or clone URL before cloning.", + }); + } - return { - cwd: preparedDestination.destinationPath, - remoteUrl, - repository, - }; + if (preparedDestination.occupied) { + const matches = yield* isMatchingClone(destinationPath, remoteUrl).pipe( + Effect.orElseSucceed(() => false), + ); + if (!matches) { + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail: + "Destination path already exists and is not a clone of the requested repository.", + }); + } + return { cwd: destinationPath, remoteUrl, repository }; + } + + yield* git.execute({ + operation: "SourceControlRepositoryService.cloneRepository", + cwd: preparedDestination.parentPath, + args: ["clone", "--origin", "origin", remoteUrl, preparedDestination.directoryName], + timeoutMs: 120_000, + maxOutputBytes: 256 * 1024, + }); + + return { cwd: destinationPath, remoteUrl, repository }; + }), + ); }); const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cdf098c91f4e..921c8ee0a2a2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -93,6 +93,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; +import * as ClientCommandDispatch from "./orchestration-v2/ClientCommandDispatch.ts"; import { ProviderSessionManagerV2 } from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; @@ -521,6 +522,7 @@ const makeWsRpcLayer = ( const currentSessionId = currentSession.sessionId; const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const dispatchClientCommand = (yield* ClientCommandDispatch.ClientCommandDispatch).dispatch; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const providerSessionsV2 = yield* ProviderSessionManagerV2; @@ -1186,6 +1188,10 @@ const makeWsRpcLayer = ( ...(mutation.defaultModelSelection === undefined ? {} : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.faviconPath === undefined ? {} : { faviconPath: mutation.faviconPath }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), }); case "project.update": @@ -1199,33 +1205,42 @@ const makeWsRpcLayer = ( ...(mutation.defaultModelSelection === undefined ? {} : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.faviconPath === undefined ? {} : { faviconPath: mutation.faviconPath }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), }); case "project.delete": { - const snapshot = yield* threadManagement.getShellSnapshot(); - const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === mutation.projectId, - ); - if (projectThreads.length > 0 && mutation.force !== true) { - return yield* new ProjectMutationError({ - commandId: mutation.commandId, - message: `Project ${mutation.projectId} is not empty.`, - }); - } - yield* Effect.forEach( - projectThreads, - (thread) => - threadManagement.dispatch({ - type: "thread.delete", - commandId: CommandId.make(`${mutation.commandId}:delete-thread:${thread.id}`), - threadId: thread.id, - }), - { concurrency: 1, discard: true }, + return yield* threadManagement.withProjectMutationLock( + mutation.projectId, + Effect.gen(function* () { + const snapshot = yield* threadManagement.getShellSnapshot(); + const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( + (thread) => thread.projectId === mutation.projectId, + ); + if (projectThreads.length > 0 && mutation.force !== true) { + return yield* new ProjectMutationError({ + commandId: mutation.commandId, + message: `Project ${mutation.projectId} is not empty.`, + }); + } + yield* Effect.forEach( + projectThreads, + (thread) => + threadManagement.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`${mutation.commandId}:delete-thread:${thread.id}`), + threadId: thread.id, + }), + { concurrency: 1, discard: true }, + ); + return yield* projectService.delete({ + commandId: mutation.commandId, + projectId: mutation.projectId, + }); + }), ); - return yield* projectService.delete({ - commandId: mutation.commandId, - projectId: mutation.projectId, - }); } } }); @@ -1250,15 +1265,16 @@ const makeWsRpcLayer = ( claimed === null || command.type !== "message.dispatch" ? command : { ...command, attachments: claimed.attachments }; + const provenanceCommand = ThreadManagementService.withCreationProvenance( + effectiveCommand, + { + createdBy: "user", + creationSource: "creationSource" in command ? command.creationSource : "web", + }, + ); + const dispatchCommand = dispatchClientCommand(provenanceCommand); return yield* startup - .enqueueCommand( - threadManagement.dispatch( - ThreadManagementService.withCreationProvenance(effectiveCommand, { - createdBy: "user", - creationSource: "creationSource" in command ? command.creationSource : "web", - }), - ), - ) + .enqueueCommand(dispatchCommand) .pipe( Effect.tapError(() => claimed === null diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 3aa9a6cb401a..8e452649f17f 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -7,11 +7,12 @@ agent can use this endpoint to: - create an app-owned sub-agent on any supported provider instance; - wait for or poll the sub-agent's durable result; -- cancel an active delegated task; and +- cancel an active delegated task; - create one or more ordinary top-level T3 threads; - list and incrementally read project threads; -- send or steer follow-up messages; and -- wait for or interrupt ordinary thread runs. +- send or steer follow-up messages; +- wait for or interrupt ordinary thread runs; and +- list, inspect, create, update, and remove projects in the current environment. These are T3 orchestration operations, not provider-native sub-agent APIs. Delegated tasks always create a T3 child thread and run. The child receives @@ -140,7 +141,35 @@ selection model-visible without allowing a request that cannot run. ## Tool Surface -The server exposes eleven orchestration tools. +The server exposes orchestration, thread, scheduling, and project tools. + +### Project tools + +`t3_project_list` returns a bounded page of active project summaries from the server environment. +Its numeric cursor defaults to the first page and the default page size is 25, with a maximum of 100. Summaries include project identity, root, title, model default, and saved, checked-in, global, +and effective workspace modes. `t3_project_read` returns the full record, including configured +scripts. + +`t3_project_create` accepts one of two typed sources. `existing_directory` registers a directory and +only creates a missing directory when `createIfMissing` is true. `clone` uses the existing repository +clone service with either a URL or a configured source-control provider and repository name, then +registers the cloned directory. It does not publish repositories. If registration fails after a +clone completes, a retry may reuse the destination only when its exact stored origin matches, its +checked-out commit matches a locally recorded `origin` remote-tracking ref, and its tracked checkout +is clean. Recovery does not fetch the remote. Empty, modified, interrupted, and unrelated-history +repositories are not adopted; register an intentional one with `existing_directory` instead. +If a stable `clientRequestId` already names a deleted project, create fails with `project_deleted` +before touching the filesystem. Use a new request ID; deleted project records are never resurrected. + +`t3_project_update` changes only supplied fields. Explicit `null` clears model, workspace-mode, and +icon overrides, while omitted fields remain unchanged. Script updates change configuration but do +not run scripts. + +`t3_project_delete` rejects a project that still owns thread records unless `cascadeThreads` is true. +The cascade uses normal thread-delete commands before the project-delete command. Project removal +never deletes workspace files, repositories, or worktrees, and the result reports the number of +thread records removed by that attempt. A retry after the project is already deleted returns +`alreadyDeleted: true` with a zero per-attempt count. ### `orchestrator_capabilities` @@ -376,7 +405,9 @@ orchestration_error ## Code Ownership - Shared schemas: `packages/contracts/src/orchestratorMcp.ts` +- Project MCP schemas: `packages/contracts/src/projectMcp.ts` - MCP service: `apps/server/src/mcp/OrchestratorMcpService.ts` +- Project MCP service: `apps/server/src/mcp/ProjectMcpService.ts` - Tool definitions and handlers: `apps/server/src/mcp/toolkits/orchestrator/` - HTTP registration and authentication: diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 56675408fab8..43f4dd6ebf8e 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -14,3 +14,13 @@ T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected pa each checkout in the project group and appears on your connected clients. To use automatic detection again, select **Automatic**. + +# Let an agent manage projects + +Agents running through T3 Code can list and inspect projects in their current environment. They can +also register an existing directory, create a missing directory, clone and register a repository, +or update the same project settings available in the app. + +Removing a project does not remove its directory, repository, worktrees, or other workspace files. +If the project still has threads, the agent must explicitly request that T3 Code delete those thread +records first. This prevents a project removal from silently discarding conversations. diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 90064ccec224..3675ac17dd6d 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -49,6 +49,7 @@ import { settleThread, startThreadTurn, unsettleThread, + updateProject, updateThreadMetadata, } from "./commands.ts"; @@ -158,6 +159,29 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect("preserves omitted and explicit-null project settings", () => + Effect.gen(function* () { + const projects: ProjectMutation[] = []; + const supervisor = yield* makeSupervisor({ commands: [], projects }); + + yield* updateProject({ + projectId: ProjectId.make("project-1"), + defaultThreadEnvMode: null, + faviconPath: null, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(projects).toEqual([ + { + type: "project.update", + commandId: "00000000-0000-4000-8000-000000000000", + projectId: "project-1", + defaultThreadEnvMode: null, + faviconPath: null, + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("preserves caller command ids for idempotent V2 commands", () => Effect.gen(function* () { const commands: OrchestrationV2Command[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 3e1262c5dde1..d93d0d074b80 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -18,6 +18,7 @@ import { type RunId, type RuntimeMode, type RuntimeRequestId, + type ThreadEnvMode, type ThreadId, type UploadChatAttachment, } from "@t3tools/contracts"; @@ -38,6 +39,8 @@ export interface CreateProjectInput extends CommandMetadata { readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } @@ -46,6 +49,8 @@ export interface UpdateProjectInput extends CommandMetadata { readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } @@ -277,6 +282,8 @@ const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } | { @@ -286,6 +293,8 @@ const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } | { @@ -313,6 +322,10 @@ export const createProject = Effect.fn("EnvironmentCommands.createProject")(func ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }); }); @@ -329,6 +342,10 @@ export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(func ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }); }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cfe24f5ffd22..75f65c61cc59 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -32,6 +32,7 @@ export * from "./orchestration.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; export * from "./project.ts"; +export * from "./projectMcp.ts"; export * from "./filesystem.ts"; export * from "./assets.ts"; export * from "./review.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index f51cc77fcf75..ac4685e4ee24 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -28,7 +28,7 @@ import { RuntimeMode, } from "./providerPolicy.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { Project, ProjectScript, ProjectScriptIcon } from "./project.ts"; +import { Project, ProjectFaviconPath, ProjectScript, ProjectScriptIcon } from "./project.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; import { @@ -62,11 +62,7 @@ export { export const CorrelationId = CommandId; export type CorrelationId = typeof CorrelationId.Type; -export const ProjectFaviconPath = TrimmedNonEmptyString.check( - Schema.isMaxLength(1024), - Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), -); -export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; +export { ProjectFaviconPath } from "./project.ts"; export const OrchestrationProject = Schema.Struct({ id: ProjectId, @@ -441,6 +437,8 @@ export const ProjectCreateCommand = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 8e6771cba88f..3071e2e6299d 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectMutation, ProjectSearchContentsError, ProjectSearchContentsInput, ProjectSearchEntriesError, @@ -12,6 +13,7 @@ import { const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); +const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); describe("project search inputs", () => { it("allows an empty entries query for bounded frecency browsing", () => { @@ -37,6 +39,31 @@ describe("project search inputs", () => { }); }); +describe("project mutations", () => { + it("rejects favicon paths the asset endpoint cannot serve", () => { + const created = decodeProjectMutation({ + type: "project.create", + commandId: "command-project-create", + projectId: "project-1", + title: "Project", + workspaceRoot: "/work/project", + faviconPath: "/work/project/favicon.png", + }); + expect(created).toMatchObject({ + type: "project.create", + faviconPath: "/work/project/favicon.png", + }); + expect(() => + decodeProjectMutation({ + type: "project.update", + commandId: "command-project-update", + projectId: "project-1", + faviconPath: "/work/project/.env", + }), + ).toThrow(); + }); +}); + describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 5f95ecd154a4..59e9a3b815b1 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { RepositoryIdentity } from "./environment.ts"; +import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import { CommandId, @@ -16,6 +16,12 @@ const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; +export const ProjectFaviconPath = TrimmedNonEmptyString.check( + Schema.isMaxLength(1024), + Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), +); +export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; + export const ProjectScriptIcon = Schema.Literals([ "play", "test", @@ -42,8 +48,9 @@ export const Project = Schema.Struct({ title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), defaultModelSelection: Schema.NullOr(ModelSelection), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -76,6 +83,8 @@ export const ProjectMutation = Schema.Union([ workspaceRoot: TrimmedNonEmptyString, createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), }), Schema.Struct({ @@ -85,6 +94,8 @@ export const ProjectMutation = Schema.Union([ title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), }), Schema.Struct({ diff --git a/packages/contracts/src/projectMcp.test.ts b/packages/contracts/src/projectMcp.test.ts new file mode 100644 index 000000000000..368be27c454c --- /dev/null +++ b/packages/contracts/src/projectMcp.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + ProjectMcpCreateInput, + ProjectMcpFailure, + ProjectMcpListInput, + ProjectMcpUpdateInput, +} from "./projectMcp.ts"; + +const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput); +const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput); +const decodeUpdateInput = Schema.decodeUnknownSync(ProjectMcpUpdateInput); +const decodeFailure = Schema.decodeUnknownSync(ProjectMcpFailure); + +describe("project MCP contracts", () => { + it("bounds project list pages", () => { + expect(decodeListInput({})).toEqual({}); + expect(decodeListInput({ cursor: 25, limit: 100 })).toEqual({ cursor: 25, limit: 100 }); + expect(() => decodeListInput({ cursor: -1 })).toThrow(); + expect(() => decodeListInput({ limit: 101 })).toThrow(); + }); + + it("accepts exactly one typed clone source", () => { + expect( + decodeCreateInput({ + title: "URL clone", + source: { + type: "clone", + destinationPath: "/work/url-clone", + remoteUrl: "https://example.com/acme/repo.git", + }, + }).source, + ).toMatchObject({ remoteUrl: "https://example.com/acme/repo.git" }); + expect( + decodeCreateInput({ + title: "Provider clone", + source: { + type: "clone", + destinationPath: "/work/provider-clone", + provider: "github", + repository: "acme/repo", + }, + }).source, + ).toMatchObject({ provider: "github", repository: "acme/repo" }); + + expect(() => + decodeCreateInput({ + title: "Ambiguous clone", + source: { + type: "clone", + destinationPath: "/work/ambiguous", + remoteUrl: "https://example.com/acme/repo.git", + provider: "github", + repository: "acme/repo", + }, + }), + ).toThrow(); + expect(() => + decodeCreateInput({ + title: "Incomplete clone", + source: { + type: "clone", + destinationPath: "/work/incomplete", + provider: "github", + }, + }), + ).toThrow(); + }); + + it("identifies retries bound to deleted project ids", () => { + expect( + decodeFailure({ + _tag: "ProjectMcpFailure", + code: "project_deleted", + message: "Use a new clientRequestId.", + }), + ).toMatchObject({ code: "project_deleted" }); + }); + + it("accepts only favicon paths served by the project asset route", () => { + expect( + decodeCreateInput({ + title: "Icon project", + source: { type: "existing_directory", workspaceRoot: "/work/icon" }, + faviconPath: "/work/icon/favicon.svg", + }).faviconPath, + ).toBe("/work/icon/favicon.svg"); + expect(() => + decodeCreateInput({ + title: "Secret project", + source: { type: "existing_directory", workspaceRoot: "/work/secret" }, + faviconPath: "/work/secret/.env", + }), + ).toThrow(); + expect(decodeUpdateInput({ projectId: "project-1", faviconPath: null }).faviconPath).toBeNull(); + expect(() => + decodeUpdateInput({ projectId: "project-1", faviconPath: "/work/secret/.env" }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/projectMcp.ts b/packages/contracts/src/projectMcp.ts new file mode 100644 index 000000000000..365f1c700380 --- /dev/null +++ b/packages/contracts/src/projectMcp.ts @@ -0,0 +1,147 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ThreadEnvMode } from "./environment.ts"; +import { ModelSelection } from "./modelSelection.ts"; +import { Project, ProjectFaviconPath, ProjectScript } from "./project.ts"; +import { SourceControlCloneProtocol, SourceControlProviderKind } from "./sourceControl.ts"; + +const ProjectMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).annotate({ + description: "Stable idempotency key to reuse when retrying this operation.", +}); + +export const ProjectMcpProject = Schema.Struct({ + ...Project.fields, + projectFileDefaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + globalDefaultThreadEnvMode: ThreadEnvMode, + effectiveDefaultThreadEnvMode: ThreadEnvMode, +}); +export type ProjectMcpProject = typeof ProjectMcpProject.Type; + +export const ProjectMcpProjectSummary = Schema.Struct({ + id: Project.fields.id, + title: Project.fields.title, + workspaceRoot: Project.fields.workspaceRoot, + repositoryIdentity: Project.fields.repositoryIdentity, + faviconPath: Project.fields.faviconPath, + defaultModelSelection: Project.fields.defaultModelSelection, + defaultThreadEnvMode: Project.fields.defaultThreadEnvMode, + projectFileDefaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + globalDefaultThreadEnvMode: ThreadEnvMode, + effectiveDefaultThreadEnvMode: ThreadEnvMode, + createdAt: Project.fields.createdAt, + updatedAt: Project.fields.updatedAt, +}); +export type ProjectMcpProjectSummary = typeof ProjectMcpProjectSummary.Type; + +export const ProjectMcpListInput = Schema.Struct({ + cursor: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), +}); +export type ProjectMcpListInput = typeof ProjectMcpListInput.Type; + +export const ProjectMcpListResult = Schema.Struct({ + projects: Schema.Array(ProjectMcpProjectSummary), + nextCursor: Schema.NullOr(NonNegativeInt), + totalCount: NonNegativeInt, +}); +export type ProjectMcpListResult = typeof ProjectMcpListResult.Type; + +export const ProjectMcpReadInput = Schema.Struct({ projectId: ProjectId }); +export type ProjectMcpReadInput = typeof ProjectMcpReadInput.Type; + +export const ProjectMcpExistingDirectorySource = Schema.Struct({ + type: Schema.Literal("existing_directory"), + workspaceRoot: TrimmedNonEmptyString, + createIfMissing: Schema.optional( + Schema.Boolean.annotate({ + description: "Create workspaceRoot as a directory when it does not exist. Defaults to false.", + }), + ), +}); + +export const ProjectMcpCloneSource = Schema.Struct({ + type: Schema.Literal("clone"), + destinationPath: TrimmedNonEmptyString, + provider: Schema.optional(SourceControlProviderKind), + repository: Schema.optional(TrimmedNonEmptyString), + remoteUrl: Schema.optional(TrimmedNonEmptyString), + protocol: Schema.optional(SourceControlCloneProtocol), +}).check( + Schema.makeFilter((input) => { + const hasRemoteUrl = input.remoteUrl !== undefined; + const hasProviderRepository = input.provider !== undefined && input.repository !== undefined; + const hasPartialProviderRepository = + (input.provider === undefined) !== (input.repository === undefined); + return ( + (!hasPartialProviderRepository && hasRemoteUrl !== hasProviderRepository) || + "Provide exactly one clone source: remoteUrl, or both provider and repository." + ); + }), +); + +export const ProjectMcpCreateSource = Schema.Union([ + ProjectMcpExistingDirectorySource, + ProjectMcpCloneSource, +]); +export type ProjectMcpCreateSource = typeof ProjectMcpCreateSource.Type; + +export const ProjectMcpCreateInput = Schema.Struct({ + title: TrimmedNonEmptyString, + source: ProjectMcpCreateSource, + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), + scripts: Schema.optional(Schema.Array(ProjectScript)), + clientRequestId: Schema.optional(ProjectMcpClientRequestId), +}); +export type ProjectMcpCreateInput = typeof ProjectMcpCreateInput.Type; + +export const ProjectMcpUpdateInput = Schema.Struct({ + projectId: ProjectId, + title: Schema.optional(TrimmedNonEmptyString), + workspaceRoot: Schema.optional(TrimmedNonEmptyString), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), + scripts: Schema.optional(Schema.Array(ProjectScript)), + clientRequestId: Schema.optional(ProjectMcpClientRequestId), +}); +export type ProjectMcpUpdateInput = typeof ProjectMcpUpdateInput.Type; + +export const ProjectMcpDeleteInput = Schema.Struct({ + projectId: ProjectId, + cascadeThreads: Schema.optional( + Schema.Boolean.annotate({ + description: + "Delete the project's thread records before removing the project. Required when the project is not empty.", + }), + ), + clientRequestId: Schema.optional(ProjectMcpClientRequestId), +}); +export type ProjectMcpDeleteInput = typeof ProjectMcpDeleteInput.Type; + +export const ProjectMcpDeleteResult = Schema.Struct({ + projectId: ProjectId, + deleted: Schema.Boolean, + alreadyDeleted: Schema.Boolean, + deletedThreadCount: NonNegativeInt, + workspaceRoot: TrimmedNonEmptyString, + workspaceFilesDeleted: Schema.Literal(false), +}); +export type ProjectMcpDeleteResult = typeof ProjectMcpDeleteResult.Type; + +export class ProjectMcpFailure extends Schema.TaggedErrorClass()( + "ProjectMcpFailure", + { + code: Schema.Literals([ + "capability_denied", + "project_not_found", + "project_deleted", + "project_not_empty", + "invalid_request", + "operation_failed", + ]), + message: Schema.String, + }, +) {} diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 77498835757c..bea6b19aad1b 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -51,6 +51,11 @@ const T3_MCP_TOOLS: Record< t3_thread_send: { displayName: "Send to a T3 thread", summaryAction: "thread-send" }, t3_thread_wait: { displayName: "Wait for a T3 thread", summaryAction: "thread-wait" }, t3_thread_interrupt: { displayName: "Interrupt a T3 thread", summaryAction: "thread-interrupt" }, + t3_project_list: { displayName: "List T3 projects" }, + t3_project_read: { displayName: "Read a T3 project" }, + t3_project_create: { displayName: "Create a T3 project" }, + t3_project_update: { displayName: "Update a T3 project" }, + t3_project_delete: { displayName: "Delete a T3 project" }, t3_worktree_handoff: { displayName: "Hand off thread to a git worktree" }, t3_worktree_status: { displayName: "Get thread worktree status" }, preview_status: { displayName: "Get preview browser status" },