diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index aeb47996e690..1a158b6d4180 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -15,8 +15,12 @@ import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.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, @@ -228,6 +232,12 @@ export const OrchestratorToolkitRegistrationLive = McpServer.toolkit(Orchestrato Layer.provide(ThreadMetadataMcpService.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), @@ -243,5 +253,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..546b43fc777e 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2,14 +2,22 @@ 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 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 Ref from "effect/Ref"; import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; @@ -19,6 +27,168 @@ 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 replacementRunId = RunId.make("run:mcp-create-admission-parent-replacement"); + const replacementNodeId = NodeId.make("node:mcp-create-admission-parent-replacement"); + const replacedParentRun = { + ...activeParent, + runs: [ + { + ...activeParent.runs[0]!, + status: "completed" as const, + completedAt: now, + }, + { + ...activeParent.runs[0]!, + id: replacementRunId, + ordinal: 2, + userMessageId: MessageId.make("message:mcp-create-admission-parent-replacement"), + rootNodeId: replacementNodeId, + }, + ], + } satisfies 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 currentParent = yield* Ref.make(activeParent); + const admissionEntered = yield* Deferred.make(); + const allowAdmission = yield* Deferred.make(); + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService)({ + getThreadProjection: () => + Ref.update(projectionReads, (count) => count + 1).pipe( + Effect.andThen(Ref.get(currentParent)), + ), + withProjectCreationAdmission: (_input, effect) => + Deferred.succeed(admissionEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowAdmission)), + Effect.andThen(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 creation = 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))), + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(admissionEntered); + yield* Ref.set(currentParent, replacedParentRun); + yield* Deferred.succeed(allowAdmission, undefined); + return yield* Fiber.join(creation); + }).pipe(Effect.ensuring(Deferred.succeed(allowAdmission, undefined))); + + 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 ea0abc1e9785..6512f79e4e72 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -154,6 +154,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 }); @@ -1433,32 +1434,60 @@ 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.id !== parentRun.id || + 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 d9e06a5bacb6..e8930dc44fcc 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, @@ -37,6 +38,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -48,8 +50,13 @@ import { McpSchema, McpServer } from "effect/unstable/ai"; import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; +import * as ClientCommandDispatch from "../orchestration-v2/ClientCommandDispatch.ts"; +import { + ThreadCommandExecutor, + layer as threadCommandExecutorLayer, +} from "../orchestration-v2/ThreadCommandExecutor.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, @@ -71,13 +78,18 @@ import { materializeReplayTranscriptWorkspace, } from "../orchestration-v2/testkit/ReplayTranscriptNdjson.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"; import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.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"; @@ -176,6 +188,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 { @@ -278,6 +293,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); @@ -472,6 +491,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>(); @@ -482,7 +524,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") @@ -554,10 +602,28 @@ 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), + ), + }); + }), + ).pipe(Layer.provide(threadManagementLayer)); + const orchestrationLayer = Layer.merge(orchestratorLayer, gatedThreadManagementLayer); const providerRegistryLayer = makeProviderRegistryLayer([ makeProviderSnapshot({ instanceId: codexInstanceId, @@ -613,11 +679,96 @@ describe("orchestrator MCP toolkit", () => { runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), }), ); - const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + const projectLayer = Layer.effect( + ProjectService.ProjectService, + Effect.gen(function* () { + const threadCommands = yield* ThreadCommandExecutor; + const threads = yield* ThreadManagement.ThreadManagementService; + return ProjectService.ProjectService.of({ + create: () => Effect.die("unused"), + bootstrap: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + deleteDetailed: ({ commandId, projectId: deletedProjectId }) => + Effect.gen(function* () { + if (deletedProjectId !== deletionRaceProjectId) { + return yield* Effect.die(`Unexpected project deletion: ${deletedProjectId}`); + } + const deletion = yield* Effect.forkChild( + threadCommands.withProjectLock( + deletedProjectId, + Deferred.succeed(deletionLockAcquired, undefined).pipe( + Effect.andThen(Deferred.await(allowDeletion)), + Effect.andThen( + Effect.gen(function* () { + const snapshot = yield* threads.getShellSnapshot(); + const projectThreads = [ + ...snapshot.threads, + ...snapshot.archivedThreads, + ].filter((thread) => thread.projectId === deletedProjectId); + yield* Effect.forEach( + projectThreads, + (thread) => + threads + .dispatch({ + type: "thread.delete", + commandId: CommandId.make( + `${commandId}:delete-thread:${thread.id}`, + ), + threadId: thread.id, + }) + .pipe(Effect.orDie), + { concurrency: 1, discard: true }, + ); + const project = yield* Ref.updateAndGet( + deletionRaceProjectState, + (current) => ({ + ...current, + deletedAt: "2026-08-30T00:01:00.000Z", + updatedAt: "2026-08-30T00:01:00.000Z", + }), + ); + return { project, deletedThreadCount: projectThreads.length }; + }), + ), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.succeed(deletionLockAttemptSettled, undefined); + return yield* Fiber.join(deletion); + }).pipe(Effect.orDie), + 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()), + getByWorkspaceRoot: () => Effect.die("unused"), + snapshot: Effect.die("unused"), + }); + }), + ).pipe( + Layer.provide(Layer.merge(threadCommandExecutorLayer, gatedThreadManagementLayer)), + ); + 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), ); @@ -2876,6 +3027,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)); }), ), @@ -2900,7 +3202,7 @@ describe("orchestrator MCP toolkit", () => { ); const orchestrationLayer = Layer.merge( orchestratorLayer, - threadManagementServiceLayer.pipe(Layer.provide(orchestratorLayer)), + ThreadManagement.layer.pipe(Layer.provide(orchestratorLayer)), ); const providerRegistryLayer = makeProviderRegistryLayer([ makeProviderSnapshot({ diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts new file mode 100644 index 000000000000..3fc1c69bda5a --- /dev/null +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -0,0 +1,1079 @@ +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 { + ThreadCommandExecutor, + layer as threadCommandExecutorLayer, +} from "../orchestration-v2/ThreadCommandExecutor.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"), + deleteDetailed: () => 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: "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/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: "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/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: "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/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: "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/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: "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/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: "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/work/cloned", + }); + const state = yield* Ref.make(null); + const cloneInputs = yield* Ref.make>([]); + const deletedProjects = 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), + ), + ), + deleteDetailed: ({ projectId }) => + Effect.gen(function* () { + yield* Ref.update(deletedProjects, (current) => [...current, projectId]); + const deleted = { ...project, deletedAt: now } satisfies Project; + yield* Ref.set(state, deleted); + return { project: deleted, deletedThreadCount: 2 }; + }), + }), + ), + 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: "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/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + repository: null, + }), + ), + }), + ), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + 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, + ] + : [], + }), + }), + ), + 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: "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/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "clone-and-delete", + }); + expect(created.workspaceRoot).toBe("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/work/cloned"); + expect(yield* Ref.get(cloneInputs)).toEqual([{ destinationPath: "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/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: "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/work/cloned", + workspaceFilesDeleted: false, + }); + 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]); + + const createRetry = yield* service + .create(scope, { + title: project.title, + source: { + type: "clone", + destinationPath: "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/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: "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/work/cloned" }]); + expect((yield* Ref.get(state))?.deletedAt).toBe(now); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("reports an overlapping completed deletion as an idempotent result", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:mcp:overlapping-delete"); + const project = makeProject({ + id: projectId, + title: "Overlapping deletion", + workspaceRoot: "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/work/overlapping-delete", + }); + const projectState = yield* Ref.make(project); + const deleteAttempts = yield* Ref.make(0); + const firstDeleteEntered = yield* Deferred.make(); + const secondDeleteEntered = yield* Deferred.make(); + const allowFirstDelete = yield* Deferred.make(); + const firstDeleteFinished = yield* Deferred.make(); + const projectService = ProjectService.ProjectService.of({ + create: () => Effect.die("unused"), + bootstrap: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + deleteDetailed: ({ projectId: deletedProjectId }) => + Effect.gen(function* () { + const attempt = yield* Ref.updateAndGet(deleteAttempts, (count) => count + 1); + if (attempt === 1) { + yield* Deferred.succeed(firstDeleteEntered, undefined); + yield* Deferred.await(allowFirstDelete); + const deleted = { ...project, deletedAt: now } satisfies Project; + yield* Ref.set(projectState, deleted); + yield* Deferred.succeed(firstDeleteFinished, undefined); + return { project: deleted, deletedThreadCount: 2 }; + } + yield* Deferred.succeed(secondDeleteEntered, undefined); + yield* Deferred.await(firstDeleteFinished); + return yield* new ProjectService.ProjectNotFoundError({ + projectId: deletedProjectId, + }); + }).pipe(Effect.ensuring(Deferred.succeed(firstDeleteFinished, undefined))), + getById: (_projectId, options) => + Ref.get(projectState).pipe( + Effect.map((current) => + current.deletedAt !== null && options?.includeDeleted !== true + ? Option.none() + : Option.some(current), + ), + ), + getByWorkspaceRoot: () => Effect.die("unused"), + snapshot: Effect.die("unused"), + }); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide(Layer.succeed(ProjectService.ProjectService, projectService)), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + 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 first = yield* service + .delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "overlapping-delete-first", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstDeleteEntered); + const second = yield* service + .delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "overlapping-delete-second", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(secondDeleteEntered); + yield* Deferred.succeed(allowFirstDelete, undefined); + + expect(yield* Fiber.join(first)).toMatchObject({ + projectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: 2, + }); + expect(yield* Fiber.join(second)).toMatchObject({ + projectId, + deleted: true, + alreadyDeleted: true, + deletedThreadCount: 0, + }); + expect(yield* Ref.get(deleteAttempts)).toBe(2); + }).pipe( + Effect.provide(testLayer), + Effect.ensuring(Deferred.succeed(allowFirstDelete, undefined)), + Effect.ensuring(Deferred.succeed(firstDeleteFinished, undefined)), + ); + }), +); + +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: "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/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, + getShellSnapshot: (options) => + actual.getShellSnapshot(options).pipe( + Effect.tap(() => Deferred.succeed(snapshotEntered, undefined)), + Effect.tap(() => Deferred.await(allowSnapshot)), + ), + }); + }), + ).pipe(Layer.provide(actualThreads)); + const projectLayer = Layer.effect( + ProjectService.ProjectService, + Effect.gen(function* () { + const threadCommands = yield* ThreadCommandExecutor; + const threadManagement = yield* ThreadManagement.ThreadManagementService; + return ProjectService.ProjectService.of({ + create: () => Effect.die("unused"), + bootstrap: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + deleteDetailed: ({ commandId, projectId: deletedProjectId }) => + Effect.gen(function* () { + const deletion = yield* Effect.forkChild( + threadCommands.withProjectLock( + deletedProjectId, + Deferred.succeed(deleteLockAcquired, undefined).pipe( + Effect.andThen( + Effect.gen(function* () { + const snapshot = yield* threadManagement.getShellSnapshot(); + const projectThreads = [ + ...snapshot.threads, + ...snapshot.archivedThreads, + ].filter((thread) => thread.projectId === deletedProjectId); + yield* Effect.forEach( + projectThreads, + (thread) => + threadManagement + .dispatch({ + type: "thread.delete", + commandId: CommandId.make( + `${commandId}:delete-thread:${thread.id}`, + ), + threadId: thread.id, + }) + .pipe(Effect.orDie), + { concurrency: 1, discard: true }, + ); + const project = yield* Ref.updateAndGet(projectState, (current) => ({ + ...current, + deletedAt: now, + })); + return { project, deletedThreadCount: projectThreads.length }; + }), + ), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.succeed(deleteLockAttempted, undefined); + return yield* Fiber.join(deletion); + }).pipe(Effect.orDie), + getById: () => Ref.get(projectState).pipe(Effect.map(Option.some)), + getByWorkspaceRoot: () => Effect.die("unused"), + snapshot: Effect.die("unused"), + }); + }), + ).pipe(Layer.provide(Layer.merge(threadCommandExecutorLayer, gatedThreads))); + const testLayer = ProjectMcp.layer.pipe( + Layer.provideMerge(gatedThreads), + Layer.provide(projectLayer), + 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: "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/work/mixed-oldest", + updatedAt: "2026-08-29T09:00:00-04:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:tie-b"), + title: "Tie B", + workspaceRoot: "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/work/mixed-tie-b", + updatedAt: "2026-08-29T16:00:00+02:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:newest"), + title: "Newest", + workspaceRoot: "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/work/mixed-newest", + updatedAt: "2026-08-29T10:00:00-05:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:tie-a"), + title: "Tie A", + workspaceRoot: "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/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: "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/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: "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/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: "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/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: "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/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..75b54430c5fd --- /dev/null +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -0,0 +1,479 @@ +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; + } + if (input.cascadeThreads !== true) { + 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 threadCount = new Set( + [ + ...active.threads, + ...active.archivedThreads, + ...archived.threads, + ...archived.archivedThreads, + ] + .filter((thread) => thread.projectId === input.projectId) + .map((thread) => thread.id), + ).size; + if (threadCount > 0) { + return yield* failure( + "project_not_empty", + `Project '${input.projectId}' has ${threadCount} thread${threadCount === 1 ? "" : "s"}. Retry with cascadeThreads=true to delete their records first. Workspace files will remain untouched.`, + ); + } + } + const key = yield* requestKey(input.clientRequestId); + const deletion = yield* projects + .deleteDetailed({ + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "project-delete", + suffix: input.projectId, + }), + projectId: input.projectId, + ...(input.cascadeThreads === true ? { force: true } : {}), + }) + .pipe( + Effect.map((result) => ({ type: "deleted" as const, result })), + Effect.catchTag("ProjectNotFoundError", (notFound) => + projects.getById(input.projectId, { includeDeleted: true }).pipe( + redactOperationFailure( + "read-project-after-overlapping-delete", + "Unable to confirm the project deletion.", + ), + Effect.flatMap((reloaded) => + Option.isSome(reloaded) && reloaded.value.deletedAt !== null + ? Effect.succeed({ type: "already_deleted" as const, project: reloaded.value }) + : Effect.fail(notFound), + ), + ), + ), + Effect.tapError(() => + Effect.logWarning("Project MCP operation failed.", { operation: "delete-project" }), + ), + Effect.mapError((error) => + error._tag === "ProjectMcpFailure" + ? error + : error._tag === "ProjectNotEmptyError" + ? failure( + "project_not_empty", + `Project '${input.projectId}' is not empty. Retry with cascadeThreads=true to delete its thread records first. Workspace files will remain untouched.`, + ) + : error._tag === "ProjectNotFoundError" + ? failure("project_not_found", `Project '${input.projectId}' was not found.`) + : failure("operation_failed", "Unable to delete the requested project."), + ), + ); + if (deletion.type === "already_deleted") { + return { + projectId: input.projectId, + deleted: true, + alreadyDeleted: true, + deletedThreadCount: 0, + workspaceRoot: deletion.project.workspaceRoot, + workspaceFilesDeleted: false, + } satisfies ProjectMcpDeleteResult; + } + return { + projectId: input.projectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: deletion.result.deletedThreadCount, + workspaceRoot: project.workspaceRoot, + workspaceFilesDeleted: false, + } satisfies ProjectMcpDeleteResult; + }); + + const deleteProject: ProjectMcpService["Service"]["delete"] = deleteProjectUnderLock; + + 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 b422190fc04c..3d4f0f141ee8 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 c0684ca2357c..9250e569dc22 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -808,6 +808,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: "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/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 88fe7598325f..9c14f305e5bf 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"; @@ -181,6 +181,7 @@ export interface OrchestratorV2Shape { readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; + readonly getCommandReceipt: CommandReceiptStoreV2["Service"]["getByCommandId"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; @@ -2034,6 +2035,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); @@ -4549,6 +4557,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, ); @@ -7335,6 +7350,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return OrchestratorV2.of({ resumeQueuedRuns, dispatch: dispatchWithReceipt, + getCommandReceipt: commandReceipts.getByCommandId, getThreadProjection: (threadId) => projectionStore .getThreadProjection(threadId) @@ -7443,6 +7459,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/ThreadCommandExecutor.ts b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts index c9863776d44a..390d63cbb150 100644 --- a/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts +++ b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts @@ -1,13 +1,37 @@ -import type { ThreadId } from "@t3tools/contracts"; +import type { ProjectId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { makeKeyedSerialExecutor, type KeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; +import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; /** Shared by thread commands and project deletion so both plan against current thread state. */ export class ThreadCommandExecutor extends Context.Service< ThreadCommandExecutor, - KeyedSerialExecutor + { + readonly withLock: ( + threadId: ThreadId, + effect: Effect.Effect, + ) => Effect.Effect; + readonly withProjectLock: ( + projectId: ProjectId, + effect: Effect.Effect, + ) => Effect.Effect; + } >()("t3/orchestration-v2/ThreadCommandExecutor") {} -export const layer = Layer.effect(ThreadCommandExecutor, makeKeyedSerialExecutor()); +const make = Effect.gen(function* () { + const executor = yield* makeKeyedSerialExecutor(); + const withLock: ThreadCommandExecutor["Service"]["withLock"] = (threadId, effect) => + executor.withLock(`thread:${threadId}`, effect); + const withProjectLock: ThreadCommandExecutor["Service"]["withProjectLock"] = ( + projectId, + effect, + ) => executor.withLock(`project:${projectId}`, effect); + return ThreadCommandExecutor.of({ + withLock, + withProjectLock, + }); +}); + +export const layer = Layer.effect(ThreadCommandExecutor, make); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 924cea782c2e..893c85007473 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -15,6 +15,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"; @@ -69,6 +70,7 @@ const adapter = { } as ProviderAdapterV2Shape; interface HarnessOptions { + readonly getProjectById?: ProjectService.ProjectService["Service"]["getById"]; readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]; readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; @@ -115,7 +117,10 @@ 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()), + deleteDetailed: () => Effect.die("unused"), + getById: + options.getProjectById ?? + ((id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none())), getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), snapshot: Effect.die("unused"), }), @@ -218,6 +223,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 a7695f527450..db0a27aa4e0a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -436,75 +436,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 258c553b705e..cebedb291cbd 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -35,6 +35,11 @@ import { LegacyV1ThreadImporter, type LegacyV1ThreadImportError, } from "./LegacyV1ThreadImporter.ts"; +import type { CommandReceiptV2 } from "./CommandReceiptStore.ts"; +import { + ThreadCommandExecutor, + layer as threadCommandExecutorLayer, +} from "./ThreadCommandExecutor.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -265,6 +270,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; @@ -362,6 +375,24 @@ export function latestSteerableRun( const make = Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; const legacyImporter = yield* LegacyV1ThreadImporter; + const threadCommands = yield* ThreadCommandExecutor; + + const withProjectCreationAdmission: ThreadManagementServiceShape["withProjectCreationAdmission"] = + (input, effect) => + threadCommands.withProjectLock( + 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", @@ -428,8 +459,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) => + threadCommands.withProjectLock(projection.thread.projectId, dispatchCommand), + ), + ); + }; const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) => getThreadProjection(input.threadId).pipe( @@ -657,6 +704,8 @@ const make = Effect.gen(function* () { }); return ThreadManagementService.of({ + withProjectCreationAdmission, + withProjectMutationLock: threadCommands.withProjectLock, ensureLegacyTranscript, dispatch, getThreadProjection, @@ -690,10 +739,10 @@ const legacyV1ThreadImporterNoopLayer = Layer.succeed( export const layer: Layer.Layer = Layer.effect( ThreadManagementService, make, -).pipe(Layer.provide(legacyV1ThreadImporterNoopLayer)); +).pipe(Layer.provide(legacyV1ThreadImporterNoopLayer), Layer.provide(threadCommandExecutorLayer)); export const layerWithLegacyImporter: Layer.Layer< ThreadManagementService, never, LegacyV1ThreadImporter | OrchestratorV2 -> = Layer.effect(ThreadManagementService, make); +> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(threadCommandExecutorLayer)); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 637916e7f9bf..6f257e25eb52 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -11,6 +11,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"; @@ -201,6 +202,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), ); @@ -275,6 +279,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 d47006f936e1..cd5847ed66d7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -512,7 +512,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, autoPull: false, faviconPath: event.payload.faviconPath ?? null, projectIcon: event.payload.projectIcon ?? null, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 75d190166403..29038c1d8df7 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -226,7 +226,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // automatic seed here, but only a metadata update records an // explicit project default. defaultModelSelection: null, - faviconPath: null, + defaultThreadEnvMode: command.defaultThreadEnvMode ?? null, + faviconPath: command.faviconPath ?? null, projectIcon: null, scripts: command.scripts ?? [], createdAt: command.createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 028f0df53586..5341064f9a3b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -241,7 +241,7 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, - defaultThreadEnvMode: null, + defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null, autoPull: false, faviconPath: payload.faviconPath ?? null, projectIcon: payload.projectIcon ?? null, diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 01825da3348c..e3ffb762c4fa 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import { CommandId, EventId, @@ -9,7 +9,9 @@ import { ThreadId, } from "@t3tools/contracts"; 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 Schema from "effect/Schema"; @@ -26,7 +28,7 @@ import { layer as eventSinkLayer, } from "../orchestration-v2/EventSink.ts"; import { layer as eventStoreLayer } from "../orchestration-v2/EventStore.ts"; -import { layer as idAllocatorLayer } from "../orchestration-v2/IdAllocator.ts"; +import { IdAllocatorV2, layer as idAllocatorLayer } from "../orchestration-v2/IdAllocator.ts"; import { LegacyV1ThreadImporter, layer as legacyImporterLayer, @@ -39,7 +41,11 @@ import { ProjectionStoreV2, layer as projectionStoreLayer, } from "../orchestration-v2/ProjectionStore.ts"; -import { layer as threadCommandExecutorLayer } from "../orchestration-v2/ThreadCommandExecutor.ts"; +import { + ThreadCommandExecutor, + layer as threadCommandExecutorLayer, +} from "../orchestration-v2/ThreadCommandExecutor.ts"; +import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -136,6 +142,161 @@ function nativeThreadCreated(projectId: ProjectId, threadId: ThreadId) { }; } +it.effect("serializes admitted thread creation with project deletion", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:create-delete-race"); + const threadId = ThreadId.make("thread:create-delete-race"); + const createCommandId = CommandId.make("command:create-delete-race:create"); + const deleteCommandId = CommandId.make("command:create-delete-race:delete"); + yield* seedProject(projectId); + + yield* Effect.gen(function* () { + const executor = yield* ThreadCommandExecutor; + const eventSink = yield* EventSinkV2; + const projections = yield* ProjectionStoreV2; + const service = yield* ProjectService.make; + const creationEntered = yield* Deferred.make(); + const allowCreation = yield* Deferred.make(); + const deletionAttempted = yield* Deferred.make(); + const withProjectLock = executor.withProjectLock; + let projectLockCalls = 0; + const interceptProjectLock: ThreadCommandExecutor["Service"]["withProjectLock"] = ( + key, + effect, + ) => { + if (key !== projectId || ++projectLockCalls !== 2) { + return withProjectLock(key, effect); + } + return Deferred.succeed(deletionAttempted, undefined).pipe( + Effect.andThen(withProjectLock(key, effect)), + ); + }; + const lockSpy = vi + .spyOn(executor, "withProjectLock") + .mockImplementation(interceptProjectLock); + + yield* Effect.gen(function* () { + const creation = yield* executor + .withProjectLock( + projectId, + Deferred.succeed(creationEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowCreation)), + Effect.andThen( + eventSink.commitCommand({ + commandId: createCommandId, + commandType: "thread.create", + threadId, + acceptedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + events: [nativeThreadCreated(projectId, threadId)], + effects: [], + }), + ), + ), + ) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(creationEntered); + + const deletion = yield* service + .deleteDetailed({ commandId: deleteCommandId, projectId, force: true }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(deletionAttempted); + assert.isUndefined(deletion.pollUnsafe()); + + yield* Deferred.succeed(allowCreation, undefined); + const accepted = yield* Fiber.join(creation); + assert.equal(accepted.receipt.status, "accepted"); + const result = yield* Fiber.join(deletion); + assert.equal(result.deletedThreadCount, 1); + assert.isNotNull(result.project.deletedAt); + assert.isNotNull((yield* projections.getThreadProjection(threadId)).thread.deletedAt); + }).pipe( + Effect.ensuring(Deferred.succeed(allowCreation, undefined)), + Effect.ensuring(Effect.sync(() => lockSpy.mockRestore())), + ); + }).pipe(Effect.provide(servicesLayer)); + }).pipe(Effect.provide(databaseLayer)), +); + +it.effect("counts only children deleted by the project cascade", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:concurrent-child-delete"); + const threadId = ThreadId.make("thread:concurrent-child-delete"); + const projectDeleteCommandId = CommandId.make("command:concurrent-child-delete:project"); + yield* seedProject(projectId); + + yield* Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const executor = yield* ThreadCommandExecutor; + const idAllocator = yield* IdAllocatorV2; + const projections = yield* ProjectionStoreV2; + const service = yield* ProjectService.make; + const cascadeLockAttempted = yield* Deferred.make(); + const allowCascadeLock = yield* Deferred.make(); + const withLock = executor.withLock; + let threadLockCalls = 0; + const interceptThreadLock: ThreadCommandExecutor["Service"]["withLock"] = (key, effect) => { + if (key !== threadId || ++threadLockCalls !== 1) { + return withLock(key, effect); + } + return Deferred.succeed(cascadeLockAttempted, undefined).pipe( + Effect.andThen(Deferred.await(allowCascadeLock)), + Effect.andThen(withLock(key, effect)), + ); + }; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(interceptThreadLock); + + yield* Effect.gen(function* () { + yield* eventSink.write({ events: [nativeThreadCreated(projectId, threadId)] }); + const projectDeletion = yield* service + .deleteDetailed({ + commandId: projectDeleteCommandId, + projectId, + force: true, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(cascadeLockAttempted); + + const directDeletion = yield* executor.withLock( + threadId, + Effect.gen(function* () { + const command = { + type: "thread.delete" as const, + commandId: CommandId.make("command:concurrent-child-delete:thread"), + threadId, + }; + const projection = yield* projections.getThreadProjection(threadId); + const acceptedAt = yield* DateTime.now; + const plan = yield* planThreadDeletion({ + command, + projection, + now: acceptedAt, + idAllocator, + }); + return yield* eventSink.commitCommand({ + commandId: command.commandId, + commandType: command.type, + threadId, + acceptedAt, + events: plan.events, + effects: plan.effects, + }); + }), + ); + assert.isTrue(directDeletion.committed); + yield* Deferred.succeed(allowCascadeLock, undefined); + + const result = yield* Fiber.join(projectDeletion); + assert.equal(result.deletedThreadCount, 0); + assert.isNotNull(result.project.deletedAt); + assert.isNotNull((yield* projections.getThreadProjection(threadId)).thread.deletedAt); + }).pipe( + Effect.ensuring(Deferred.succeed(allowCascadeLock, undefined)), + Effect.ensuring(Effect.sync(() => lockSpy.mockRestore())), + ); + }).pipe(Effect.provide(servicesLayer)); + }).pipe(Effect.provide(databaseLayer)), +); + it.effect("retries a partial project deletion without repeating child events or cleanup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index cc3082b5b16e..79845d7bf72c 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: "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/work/project/", defaultModelSelection: modelSelection, + defaultThreadEnvMode: "worktree", + faviconPath: "/work/project/custom-icon.svg", scripts: [ { id: "setup", @@ -102,15 +104,16 @@ it.layer(TestLayer)("ProjectService", (it) => { }); assert.equal(created.workspaceRoot, "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/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"), @@ -147,7 +150,7 @@ it.layer(TestLayer)("ProjectService", (it) => { }); assert.isFalse(reset.autoPull); assert.isNull(reset.projectIcon); - assert.equal(reset.faviconPath, hydratedCreated.faviconPath); + assert.equal(reset.faviconPath, "/work/project/favicon.svg"); assert.isNull(reset.defaultThreadEnvMode); assert.deepEqual( (yield* service.snapshot).projects.map((project) => project.id), @@ -163,6 +166,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: "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/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 f21044361110..32a694190d73 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -36,6 +36,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; } @@ -60,6 +62,11 @@ export interface ProjectDeleteInput { readonly force?: boolean; } +export interface ProjectDeleteResult { + readonly project: Project; + readonly deletedThreadCount: number; +} + export class ProjectNotFoundError extends Schema.TaggedErrorClass()( "ProjectNotFoundError", { projectId: ProjectId }, @@ -130,6 +137,9 @@ export class ProjectService extends Context.Service< >; readonly update: (input: ProjectUpdateInput) => Effect.Effect; readonly delete: (input: ProjectDeleteInput) => Effect.Effect; + readonly deleteDetailed: ( + input: ProjectDeleteInput, + ) => Effect.Effect; readonly getById: ( projectId: ProjectId, options?: { readonly includeDeleted?: boolean }, @@ -314,6 +324,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, }, @@ -387,103 +399,120 @@ export const make = Effect.gen(function* () { }, ); - const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( - function* (input) { - const { projectId } = input; - const existing = yield* projects - .getById({ projectId }) - .pipe( - Effect.mapError( - (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }), - ), - ); - if (Option.isNone(existing) || existing.value.deletedAt !== null) { - return yield* new ProjectNotFoundError({ projectId }); - } + const deleteProjectDetailed: ProjectService["Service"]["deleteDetailed"] = Effect.fn( + "ProjectService.deleteDetailed", + )(function* (input) { + return yield* threadCommands.withProjectLock( + input.projectId, + Effect.gen(function* () { + const { projectId } = input; + const existing = yield* projects + .getById({ projectId }) + .pipe( + Effect.mapError( + (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }), + ), + ); + if (Option.isNone(existing) || existing.value.deletedAt !== null) { + return yield* new ProjectNotFoundError({ projectId }); + } - const snapshot = yield* threadProjections - .getShellSnapshot() - .pipe( - Effect.mapError( - (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), - ), - ); - const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === projectId, - ); - if (projectThreads.length > 0 && input.force !== true) { - return yield* new ProjectNotEmptyError({ projectId }); - } + const snapshot = yield* threadProjections + .getShellSnapshot() + .pipe( + Effect.mapError( + (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), + ), + ); + const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads] + .filter((thread) => thread.projectId === projectId) + .toSorted((left, right) => left.id.localeCompare(right.id)); + if (projectThreads.length > 0 && input.force !== true) { + return yield* new ProjectNotEmptyError({ projectId }); + } - // Delete children durably before the project. Stable command IDs let a retry - // finish a partially completed cascade without repeating cleanup effects. - yield* Effect.forEach( - projectThreads, - (thread) => - threadCommands - .withLock( - thread.id, - Effect.gen(function* () { - yield* legacyImporter.ensureTranscript(thread.id); - const projection = yield* threadProjections.getThreadProjection(thread.id); - if ( - projection.thread.deletedAt !== null || - projection.thread.projectId !== projectId - ) { - return; - } - const command = { - type: "thread.delete" as const, - commandId: CommandId.make(`${input.commandId}:delete-thread:${thread.id}`), - threadId: thread.id, - }; - const now = yield* DateTime.now; - const plan = yield* planThreadDeletion({ command, projection, now, idAllocator }); - const committed = yield* threadEvents.commitCommand({ - commandId: command.commandId, - commandType: command.type, - threadId: command.threadId, - acceptedAt: now, - events: plan.events, - effects: plan.effects, - }); - if ( - committed.receipt.threadId !== command.threadId || - committed.receipt.commandType !== command.type - ) { - return yield* Effect.fail( - "The thread deletion command ID belongs to a different command.", - ); - } - if (committed.receipt.status === "rejected") { - return yield* Effect.fail( - committed.receipt.error ?? "Thread deletion was previously rejected.", - ); - } - }), - ) - .pipe( - Effect.mapError( - (cause) => - new ProjectOperationError({ operation: "delete-thread", projectId, cause }), + // Delete children durably before the project. Stable command IDs let a retry + // finish a partially completed cascade without repeating cleanup effects. + const deletedThreadCounts = yield* Effect.forEach( + projectThreads, + (thread) => + threadCommands + .withLock( + thread.id, + Effect.gen(function* () { + yield* legacyImporter.ensureTranscript(thread.id); + const projection = yield* threadProjections.getThreadProjection(thread.id); + if ( + projection.thread.deletedAt !== null || + projection.thread.projectId !== projectId + ) { + return 0; + } + const command = { + type: "thread.delete" as const, + commandId: CommandId.make(`${input.commandId}:delete-thread:${thread.id}`), + threadId: thread.id, + }; + const now = yield* DateTime.now; + const plan = yield* planThreadDeletion({ command, projection, now, idAllocator }); + const committed = yield* threadEvents.commitCommand({ + commandId: command.commandId, + commandType: command.type, + threadId: command.threadId, + acceptedAt: now, + events: plan.events, + effects: plan.effects, + }); + if ( + committed.receipt.threadId !== command.threadId || + committed.receipt.commandType !== command.type + ) { + return yield* Effect.fail( + "The thread deletion command ID belongs to a different command.", + ); + } + if (committed.receipt.status === "rejected") { + return yield* Effect.fail( + committed.receipt.error ?? "Thread deletion was previously rejected.", + ); + } + return committed.committed + ? committed.storedEvents.filter( + (stored) => stored.event.type === "thread.deleted", + ).length + : 0; + }), + ) + .pipe( + Effect.mapError( + (cause) => + new ProjectOperationError({ operation: "delete-thread", projectId, cause }), + ), ), - ), - { concurrency: 1, discard: true }, - ); - return yield* dispatch( - projectId, - { - type: "project.delete", - commandId: input.commandId, + { concurrency: 1 }, + ); + const project = yield* dispatch( projectId, - ...(input.force === undefined ? {} : { force: input.force }), - }, - invalidateEnrichment(existing.value.workspaceRoot).pipe( - Effect.andThen(readCommitted(projectId)), - ), - ); - }, - ); + { + type: "project.delete", + commandId: input.commandId, + projectId, + ...(input.force === undefined ? {} : { force: input.force }), + }, + invalidateEnrichment(existing.value.workspaceRoot).pipe( + Effect.andThen(readCommitted(projectId)), + ), + ); + return { + project, + deletedThreadCount: deletedThreadCounts.reduce((total, count) => total + count, 0), + }; + }), + ); + }); + + const deleteProject: ProjectService["Service"]["delete"] = (input) => + deleteProjectDetailed(input).pipe(Effect.map((result) => result.project)); const snapshot = Effect.gen(function* () { const rows = (yield* readRows()).filter((row) => row.deletedAt === null); @@ -499,6 +528,7 @@ export const make = Effect.gen(function* () { bootstrap, update, delete: deleteProject, + deleteDetailed: deleteProjectDetailed, getById, getByWorkspaceRoot, snapshot, diff --git a/apps/server/src/project/http.test.ts b/apps/server/src/project/http.test.ts index ba20c5fbbc9f..6a11a87fab77 100644 --- a/apps/server/src/project/http.test.ts +++ b/apps/server/src/project/http.test.ts @@ -1,18 +1,121 @@ 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, ProjectNotEmptyError, 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: "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/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 ProjectNotEmptyError({ projectId }), diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index 0477bcd3b8f0..d82137e010d1 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"; @@ -28,6 +29,53 @@ 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.autoPull === undefined ? {} : { autoPull: mutation.autoPull }), + ...(mutation.projectIcon === undefined ? {} : { projectIcon: mutation.projectIcon }), + ...(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, + ...(mutation.force === undefined ? {} : { force: mutation.force }), + }); + export const projectHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "projects", @@ -51,37 +99,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, - ...(mutation.force === undefined ? {} : { force: mutation.force }), - }); + const operation = projectMutationOperation(projects, args.payload); return yield* startup.enqueueCommand(operation).pipe(Effect.catch(failProjectMutation)); }), ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d19d819e595c..f6475483e8b4 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -121,6 +121,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( const currentShell = yield* Ref.make(shell()); const shellReads: ThreadId[] = []; const threads = ThreadManagementService.of({ + withProjectCreationAdmission: (_input, effect) => effect(Option.none()), + withProjectMutationLock: (_projectId, effect) => effect, getThreadShell: (threadId) => Effect.sync(() => shellReads.push(threadId)).pipe(Effect.andThen(Ref.get(currentShell))), getShellSnapshot: unused, @@ -180,6 +182,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( bootstrap: unused, update: unused, delete: unused, + deleteDetailed: unused, getByWorkspaceRoot: unused, snapshot: unused(), getById: () => diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 461bff08668a..3e91adea04e1 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,15 +3,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; 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"; @@ -54,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; @@ -95,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({ @@ -154,12 +177,20 @@ it.effect("preserves provider failures without deriving the repository message f it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); - const destinationPath = path.join(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; @@ -177,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( @@ -191,6 +222,318 @@ 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" + : "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/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"); + yield* fs.writeFileString(`${sourcePath}/CHANGELOG.md`, "second source commit"); + yield* git.execute({ operation: "test.add.second", cwd: sourcePath, args: ["add", "."] }); + yield* git.execute({ + operation: "test.commit.second", + cwd: sourcePath, + args: ["commit", "-m", "second"], + }); + + const cloned = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); + assert.strictEqual(cloned.cwd, destinationPath); + + const headCommit = (yield* git.execute({ + operation: "test.read.head", + cwd: destinationPath, + args: ["rev-parse", "HEAD"], + })).stdout.trim(); + const previousCommit = (yield* git.execute({ + operation: "test.read.previous", + cwd: destinationPath, + args: ["rev-parse", "HEAD^"], + })).stdout.trim(); + yield* git + .execute({ + operation: "test.remove.remote.head", + cwd: destinationPath, + args: ["symbolic-ref", "-d", "refs/remotes/origin/HEAD"], + }) + .pipe(Effect.ignore); + const existingRemoteRefs = (yield* git.execute({ + operation: "test.list.remote.refs", + cwd: destinationPath, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes/origin"], + })).stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + yield* Effect.forEach( + existingRemoteRefs, + (refName) => + git.execute({ + operation: "test.remove.remote.ref", + cwd: destinationPath, + args: ["update-ref", "-d", refName], + }), + { discard: true }, + ); + const noiseRefCommands = Array.from( + { length: 1_700 }, + (_, index) => + `create refs/remotes/origin/noise-${index.toString().padStart(4, "0")} ${previousCommit}`, + ); + yield* git.execute({ + operation: "test.create.many.remote.refs", + cwd: destinationPath, + args: ["update-ref", "--stdin"], + stdin: [...noiseRefCommands, `create refs/remotes/origin/zzzz-match ${headCommit}`, ""].join( + "\n", + ), + }); + const oversizedInventory = yield* git + .execute({ + operation: "test.verify.old.remote-ref-boundary", + cwd: destinationPath, + args: ["for-each-ref", "--format=%(objectname)", "refs/remotes/origin"], + maxOutputBytes: 64 * 1024, + }) + .pipe(Effect.flip); + assert.instanceOf(oversizedInventory, GitCommandError); + + // 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 b38fe3d5c302..1b607b6171b8 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -17,9 +17,9 @@ import { type SourceControlRepositoryInfo, type SourceControlRepositoryLookupInput, } from "@t3tools/contracts"; - import { ServerConfig } from "../config.ts"; import { expandHomePathWith } from "../pathExpansion.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); @@ -83,6 +83,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; @@ -131,9 +132,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 }) @@ -148,13 +166,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 }); } @@ -163,49 +180,150 @@ 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 remoteCommit = yield* git + .execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyRemoteRefs", + cwd: destinationPath, + args: [ + "for-each-ref", + "--count=1", + "--format=%(objectname)", + `--points-at=${headCommit}`, + "refs/remotes/origin", + ], + timeoutMs: 5_000, + maxOutputBytes: 1024, + }) + .pipe( + Effect.map((result) => result.stdout.trim() || null), + Effect.orElseSucceed(() => null), + ); + if (remoteCommit !== 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 10b593a863fc..ab5546a2c087 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -90,6 +90,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"; @@ -527,6 +528,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 canReplayPersistedRange = Effect.fnUntraced(function* ( @@ -1278,6 +1280,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": @@ -1329,15 +1335,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 215a350a2f04..a3080825d828 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -7,12 +7,13 @@ 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; - rename threads, regenerate titles, and link or unlink pull requests; -- 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 @@ -141,7 +142,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` @@ -404,6 +433,8 @@ orchestration_error `packages/contracts/src/threadMetadataMcp.ts` - MCP services: `apps/server/src/mcp/OrchestratorMcpService.ts` and the focused `apps/server/src/mcp/ThreadMetadataMcpService.ts` +- Project MCP schemas: `packages/contracts/src/projectMcp.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 747bc52c07ec..0616ca978dbf 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -16,3 +16,13 @@ upstream. T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, or local commits. It skips checkouts on another branch or without an upstream. If a checkout has local work, resolve it yourself before automatic pulls can resume. + +## 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.ts b/packages/client-runtime/src/operations/commands.ts index 4e76017bacb1..f44500100d00 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -19,8 +19,8 @@ import { type RunId, type RuntimeMode, type RuntimeRequestId, - type ThreadId, type ThreadEnvMode, + type ThreadId, type UploadChatAttachment, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; @@ -40,6 +40,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; } @@ -283,6 +285,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; } | { @@ -292,6 +296,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; } | { @@ -319,6 +325,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 }), }); }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 092de0f48f26..c1ac45ea6815 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -38,6 +38,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 2c2990a56520..e964393c9e78 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -26,7 +26,13 @@ import { RuntimeMode, } from "./providerPolicy.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { ProjectScript, ProjectIconOverride } from "./project.ts"; +import { + Project, + ProjectFaviconPath, + ProjectIconOverride, + ProjectScript, + ProjectScriptIcon, +} from "./project.ts"; export { ProjectIconColor, ProjectIconOverride } from "./project.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; @@ -61,11 +67,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, @@ -446,6 +448,8 @@ export const ProjectCreateCommand = Schema.Struct({ // Retained for older clients that sent an automatic create-time seed. The // server ignores it; explicit project defaults use project.meta.update. 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: "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/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 aa67ed072e13..16814b7006ab 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -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", @@ -84,7 +90,7 @@ 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)), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), @@ -122,6 +128,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({ @@ -133,7 +141,7 @@ export const ProjectMutation = Schema.Union([ defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), autoPull: Schema.optional(Schema.Boolean), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.optional(Schema.Array(ProjectScript)), }), 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: "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/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: "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/work/provider-clone", + provider: "github", + repository: "acme/repo", + }, + }).source, + ).toMatchObject({ provider: "github", repository: "acme/repo" }); + + expect(() => + decodeCreateInput({ + title: "Ambiguous clone", + source: { + type: "clone", + destinationPath: "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/work/ambiguous", + remoteUrl: "https://example.com/acme/repo.git", + provider: "github", + repository: "acme/repo", + }, + }), + ).toThrow(); + expect(() => + decodeCreateInput({ + title: "Incomplete clone", + source: { + type: "clone", + destinationPath: "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/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: "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/work/icon" }, + faviconPath: "/work/icon/favicon.svg", + }).faviconPath, + ).toBe("/work/icon/favicon.svg"); + expect(() => + decodeCreateInput({ + title: "Secret project", + source: { type: "existing_directory", workspaceRoot: "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/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 a9dcd685cc0c..91af118f0005 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -52,6 +52,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" },