From bc1a2faa0e3d1ff63ce4f41858e658df57324bed Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 10 Aug 2026 20:42:28 -0500 Subject: [PATCH] feat(server): merge projects atomically and stop phantom cwd projects - Auto-bootstrap looked up projects by the server process cwd only; a pnpm-filtered start (cwd=apps/server) silently created a duplicate project named after the subdirectory, splitting threads across projects - Bootstrap now checks the exact cwd first, then falls back to the cached repository-root lookup before creating anything, so an existing repo-root project is always reused - Add thread.project.set and project.merge commands through the decider so a project's threads can move atomically in one transaction, guarded against unrelated workspace roots; t3 project merge wraps it - Client thread reducer applies thread.project-set so open threads track their new project live Built by Claude Fable 5 via Claude Code with GPT-5.6-Sol via Codex CLI. --- apps/server/src/cli/project.ts | 129 +++++-- .../Layers/OrchestrationEngine.test.ts | 58 +++ .../Layers/OrchestrationEngine.ts | 5 + .../Layers/ProjectionPipeline.ts | 15 + .../src/orchestration/Normalizer.test.ts | 26 ++ apps/server/src/orchestration/Schemas.ts | 2 + .../src/orchestration/commandInvariants.ts | 38 ++ .../decider.threadProjectSet.test.ts | 345 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 97 +++++ .../src/orchestration/projector.test.ts | 54 +++ apps/server/src/orchestration/projector.ts | 25 ++ apps/server/src/serverRuntimeStartup.test.ts | 188 +++++++++- apps/server/src/serverRuntimeStartup.ts | 18 +- .../src/state/threadReducer.test.ts | 25 ++ .../client-runtime/src/state/threadReducer.ts | 10 + packages/contracts/src/orchestration.ts | 34 ++ 16 files changed, 1019 insertions(+), 50 deletions(-) create mode 100644 apps/server/src/orchestration/decider.threadProjectSet.test.ts diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 39b3b2431129..2e8b42c23c04 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -47,9 +47,19 @@ type ProjectMutationTarget = { type ProjectCommandExecutionMode = "live" | "offline"; type ProjectCliDispatchCommand = Extract< ClientOrchestrationCommand, - { type: "project.create" | "project.meta.update" | "project.delete" } + { + type: "project.create" | "project.meta.update" | "project.delete" | "project.merge"; + } >; +export type ProjectMutationHandlerInput = { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + readonly mode: ProjectCommandExecutionMode; +}; + const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export class ProjectCommandIdGenerationError extends Schema.TaggedErrorClass()( @@ -155,6 +165,18 @@ export class ProjectAlreadyExistsError extends Schema.TaggedErrorClass()( + "ProjectMergeSameProjectError", + { + operation: Schema.Literal("mergeProject"), + projectId: ProjectId, + }, +) { + override get message(): string { + return `Cannot merge project '${this.projectId}' into itself.`; + } +} + export const ProjectCommandError = Schema.Union([ ProjectCommandIdGenerationError, ProjectLiveServerDeclaredResponseError, @@ -164,6 +186,7 @@ export const ProjectCommandError = Schema.Union([ ProjectIdentifierEmptyError, ProjectNotFoundError, ProjectAlreadyExistsError, + ProjectMergeSameProjectError, ]); export type ProjectCommandError = typeof ProjectCommandError.Type; @@ -337,8 +360,8 @@ const dispatchLiveOrchestrationCommand = ( const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; - // Project commands only read the project list, so use the lightweight - // command read model instead of hydrating every thread body in the database. + // The command read model includes lightweight thread rows without hydrating + // message, activity, or checkpoint bodies, which is sufficient for merge. return yield* projectionSnapshotQuery.getCommandReadModel(); }); @@ -376,13 +399,9 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu const runProjectMutation = Effect.fn("runProjectMutation")(function* ( flags: CliAuthLocationFlags, - run: (input: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - readonly mode: ProjectCommandExecutionMode; - }) => Effect.Effect< + run: ( + input: ProjectMutationHandlerInput, + ) => Effect.Effect< string, Error, | Crypto.Crypto @@ -455,12 +474,7 @@ const projectAddCommand = Command.make("add", { Effect.fn("projectAddMutation")(function* ({ snapshot, dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { + }: ProjectMutationHandlerInput) { const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); const existingProject = snapshot.projects.find( (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, @@ -507,12 +521,7 @@ const projectRemoveCommand = Command.make("remove", { Effect.fn("projectRemoveMutation")(function* ({ snapshot, dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { + }: ProjectMutationHandlerInput) { const project = yield* findActiveProjectTarget({ snapshot, identifier: flags.project, @@ -543,12 +552,7 @@ const projectRenameCommand = Command.make("rename", { Effect.fn("projectRenameMutation")(function* ({ snapshot, dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { + }: ProjectMutationHandlerInput) { const project = yield* findActiveProjectTarget({ snapshot, identifier: flags.project, @@ -570,7 +574,74 @@ const projectRenameCommand = Command.make("rename", { ), ); +const projectMergeCommand = Command.make("merge", { + ...projectLocationFlags, + source: Argument.string("source").pipe( + Argument.withDescription("Source project id or workspace root."), + ), + target: Argument.string("target").pipe( + Argument.withDescription("Target project id or workspace root."), + ), + allowUnrelatedRoots: Flag.boolean("allow-unrelated-roots").pipe( + Flag.withDescription("Allow moving threads between unrelated workspace roots."), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription("Move a project's threads into another project and delete the source."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectMergeMutation")(function* ({ + snapshot, + dispatch, + }: ProjectMutationHandlerInput) { + const source = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.source, + }); + const target = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.target, + }); + if (source.id === target.id) { + return yield* new ProjectMergeSameProjectError({ + operation: "mergeProject", + projectId: source.id, + }); + } + + const activeSourceThreads = snapshot.threads.filter( + (thread) => thread.projectId === source.id && thread.deletedAt === null, + ); + yield* Console.log(`Threads to move (advisory snapshot: ${activeSourceThreads.length}):`); + if (activeSourceThreads.length === 0) { + yield* Console.log("- (none)"); + } else { + yield* Effect.forEach(activeSourceThreads, (thread) => Console.log(`- ${thread.title}`), { + discard: true, + }); + } + yield* dispatch({ + type: "project.merge", + commandId: CommandId.make(yield* projectCommandUuid), + sourceProjectId: source.id, + targetProjectId: target.id, + allowUnrelatedRoots: flags.allowUnrelatedRoots, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + + return `Merged project ${source.id} (${source.title}) into ${target.id} (${target.title}): moved ${activeSourceThreads.length} threads.`; + }), + ), + ), +); + export const projectCommand = Command.make("project").pipe( Command.withDescription("Manage projects."), - Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), + Command.withSubcommands([ + projectAddCommand, + projectRemoveCommand, + projectRenameCommand, + projectMergeCommand, + ]), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 19290d6ec40e..1e2065608abd 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -302,6 +302,64 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("atomically merges projects and persists the moved thread project", async () => { + const createdAt = now(); + const system = await createOrchestrationSystem(); + const { engine } = system; + + for (const [projectId, title, workspaceRoot] of [ + [asProjectId("project-move-source"), "Source", "/tmp/project-move/apps/server"], + [asProjectId("project-move-target"), "Target", "/tmp/project-move"], + ] as const) { + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`cmd-${projectId}-create`), + projectId, + title, + workspaceRoot, + createdAt, + }), + ); + } + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-project-move-create"), + threadId: ThreadId.make("thread-project-move"), + projectId: asProjectId("project-move-source"), + title: "Move me", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + + const moved = await system.run( + engine.dispatch({ + type: "project.merge", + commandId: CommandId.make("cmd-project-merge"), + sourceProjectId: asProjectId("project-move-source"), + targetProjectId: asProjectId("project-move-target"), + createdAt, + }), + ); + expect(moved.sequence).toBe(5); + const readModel = await system.readModel(); + expect(readModel.threads[0]?.projectId).toBe("project-move-target"); + expect( + readModel.projects.find((project) => project.id === "project-move-source")?.deletedAt, + ).not.toBeNull(); + + await system.dispose(); + }); + it("archives and unarchives threads through orchestration commands", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 19184915ac7c..3257d99f235c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -68,6 +68,11 @@ function commandToAggregateRef(command: OrchestrationCommand): { aggregateKind: "project", aggregateId: command.projectId, }; + case "project.merge": + return { + aggregateKind: "project", + aggregateId: command.sourceProjectId, + }; default: return { aggregateKind: "thread", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..469604da09a6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -819,6 +819,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.project-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + projectId: event.payload.projectId, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.interaction-mode-set": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Normalizer.test.ts b/apps/server/src/orchestration/Normalizer.test.ts index 113b6e1baeed..18cc9223b6ba 100644 --- a/apps/server/src/orchestration/Normalizer.test.ts +++ b/apps/server/src/orchestration/Normalizer.test.ts @@ -70,4 +70,30 @@ describe("canonicalizeClientCommandTimestamps", () => { expect(result.createdAt).toBe(serverReceivedAt); expect(result.bootstrap?.createThread?.createdAt).toBe(serverReceivedAt); }); + + it("canonicalizes project merge and thread project-set timestamps", () => { + const commands: ReadonlyArray = [ + { + type: "project.merge", + commandId: CommandId.make("command-project-merge"), + sourceProjectId: ProjectId.make("project-source"), + targetProjectId: ProjectId.make("project-target"), + createdAt: clientCreatedAt, + }, + { + type: "thread.project.set", + commandId: CommandId.make("command-thread-project-set"), + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-target"), + createdAt: clientCreatedAt, + }, + ]; + + for (const command of commands) { + expect(canonicalizeClientCommandTimestamps(command, serverReceivedAt)).toEqual({ + ...command, + createdAt: serverReceivedAt, + }); + } + }); }); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..257524364500 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -7,6 +7,7 @@ import { ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, + ThreadProjectSetPayload as ContractsThreadProjectSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, @@ -39,6 +40,7 @@ export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; +export const ThreadProjectSetPayload = ContractsThreadProjectSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index b59ded77f4f4..2b7e7f574dd7 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -56,6 +56,25 @@ export function requireProject(input: { ); } +export function requireProjectNotDeleted(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly projectId: ProjectId; +}): Effect.Effect { + return requireProject(input).pipe( + Effect.flatMap((project) => + project.deletedAt === null + ? Effect.succeed(project) + : Effect.fail( + invariantError( + input.command.type, + `Project '${input.projectId}' is deleted and cannot handle command '${input.command.type}'.`, + ), + ), + ), + ); +} + export function requireProjectAbsent(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; @@ -113,6 +132,25 @@ export function requireThread(input: { ); } +export function requireThreadNotDeleted(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly threadId: ThreadId; +}): Effect.Effect { + return requireThread(input).pipe( + Effect.flatMap((thread) => + thread.deletedAt === null + ? Effect.succeed(thread) + : Effect.fail( + invariantError( + input.command.type, + `Thread '${input.threadId}' is deleted and cannot handle command '${input.command.type}'.`, + ), + ), + ), + ); +} + export function requireThreadArchived(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; diff --git a/apps/server/src/orchestration/decider.threadProjectSet.test.ts b/apps/server/src/orchestration/decider.threadProjectSet.test.ts new file mode 100644 index 000000000000..7a239f52384f --- /dev/null +++ b/apps/server/src/orchestration/decider.threadProjectSet.test.ts @@ -0,0 +1,345 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationProject, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-08-10T12:00:00.000Z"; +const LATER = "2026-08-10T12:05:00.000Z"; +const SOURCE_PROJECT_ID = ProjectId.make("project-source"); +const TARGET_PROJECT_ID = ProjectId.make("project-target"); +const THREAD_ID = ThreadId.make("thread-to-move"); + +function makeProject( + id: ProjectId, + input: { readonly workspaceRoot?: string; readonly deletedAt?: string | null } = {}, +): OrchestrationProject { + return { + id, + title: id === SOURCE_PROJECT_ID ? "Source" : "Target", + workspaceRoot: + input.workspaceRoot ?? (id === SOURCE_PROJECT_ID ? "/repo/apps/server" : "/repo"), + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: input.deletedAt ?? null, + }; +} + +function makeThread( + input: { + readonly id?: ThreadId; + readonly projectId?: ProjectId; + readonly deletedAt?: string | null; + } = {}, +): OrchestrationThread { + return { + id: input.id ?? THREAD_ID, + projectId: input.projectId ?? SOURCE_PROJECT_ID, + title: `Thread ${input.id ?? THREAD_ID}`, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: input.deletedAt ?? null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }; +} + +function makeReadModel( + input: { + readonly projects?: ReadonlyArray; + readonly threads?: ReadonlyArray; + } = {}, +): OrchestrationReadModel { + return { + snapshotSequence: 4, + projects: [ + ...(input.projects ?? [makeProject(SOURCE_PROJECT_ID), makeProject(TARGET_PROJECT_ID)]), + ], + threads: [...(input.threads ?? [makeThread()])], + updatedAt: NOW, + }; +} + +const makeThreadProjectSetCommand = ( + input: { + readonly projectId?: ProjectId; + readonly allowUnrelatedRoots?: boolean; + } = {}, +) => ({ + type: "thread.project.set" as const, + commandId: CommandId.make("command-thread-project-set"), + threadId: THREAD_ID, + projectId: input.projectId ?? TARGET_PROJECT_ID, + ...(input.allowUnrelatedRoots !== undefined + ? { allowUnrelatedRoots: input.allowUnrelatedRoots } + : {}), + createdAt: LATER, +}); + +const makeProjectMergeCommand = ( + input: { + readonly sourceProjectId?: ProjectId; + readonly targetProjectId?: ProjectId; + readonly allowUnrelatedRoots?: boolean; + } = {}, +) => ({ + type: "project.merge" as const, + commandId: CommandId.make("command-project-merge"), + sourceProjectId: input.sourceProjectId ?? SOURCE_PROJECT_ID, + targetProjectId: input.targetProjectId ?? TARGET_PROJECT_ID, + ...(input.allowUnrelatedRoots !== undefined + ? { allowUnrelatedRoots: input.allowUnrelatedRoots } + : {}), + createdAt: LATER, +}); + +it.layer(NodeServices.layer)("thread project set decider", (it) => { + it.effect("moves a thread between nested workspace roots", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: makeThreadProjectSetCommand(), + readModel: makeReadModel(), + }); + + expect(Array.isArray(result)).toBe(false); + expect(result).toMatchObject({ + type: "thread.project-set", + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: LATER, + payload: { + threadId: THREAD_ID, + projectId: TARGET_PROJECT_ID, + updatedAt: LATER, + }, + }); + }), + ); + + it.effect("re-emits a projected no-op when the thread already belongs to the target", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: makeThreadProjectSetCommand(), + readModel: makeReadModel({ threads: [makeThread({ projectId: TARGET_PROJECT_ID })] }), + }); + + expect(Array.isArray(result)).toBe(false); + expect(result).toMatchObject({ + type: "thread.project-set", + occurredAt: LATER, + payload: { + threadId: THREAD_ID, + projectId: TARGET_PROJECT_ID, + updatedAt: NOW, + }, + }); + }), + ); + + it.effect("rejects missing and deleted threads", () => + Effect.gen(function* () { + const missing = yield* Effect.flip( + decideOrchestrationCommand({ + command: makeThreadProjectSetCommand(), + readModel: makeReadModel({ threads: [] }), + }), + ); + expect(missing.message).toContain(`Thread '${THREAD_ID}' does not exist`); + + const deleted = yield* Effect.flip( + decideOrchestrationCommand({ + command: makeThreadProjectSetCommand(), + readModel: makeReadModel({ threads: [makeThread({ deletedAt: NOW })] }), + }), + ); + expect(deleted.message).toContain(`Thread '${THREAD_ID}' is deleted`); + }), + ); + + it.effect("rejects missing and deleted target projects", () => + Effect.gen(function* () { + const missing = yield* Effect.flip( + decideOrchestrationCommand({ + command: makeThreadProjectSetCommand(), + readModel: makeReadModel({ projects: [makeProject(SOURCE_PROJECT_ID)] }), + }), + ); + expect(missing.message).toContain(`Project '${TARGET_PROJECT_ID}' does not exist`); + + const deleted = yield* Effect.flip( + decideOrchestrationCommand({ + command: makeThreadProjectSetCommand(), + readModel: makeReadModel({ + projects: [ + makeProject(SOURCE_PROJECT_ID), + makeProject(TARGET_PROJECT_ID, { deletedAt: NOW }), + ], + }), + }), + ); + expect(deleted.message).toContain(`Project '${TARGET_PROJECT_ID}' is deleted`); + }), + ); + + it.effect("rejects unrelated and boundary-collision roots", () => + Effect.gen(function* () { + for (const targetRoot of ["/other-repo", "/repo-other"]) { + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: makeThreadProjectSetCommand(), + readModel: makeReadModel({ + projects: [ + makeProject(SOURCE_PROJECT_ID, { workspaceRoot: "/repo" }), + makeProject(TARGET_PROJECT_ID, { workspaceRoot: targetRoot }), + ], + }), + }), + ); + expect(error.message).toContain("unrelated workspace roots"); + } + }), + ); + + it.effect("allows unrelated roots only with an explicit override", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: makeThreadProjectSetCommand({ allowUnrelatedRoots: true }), + readModel: makeReadModel({ + projects: [ + makeProject(SOURCE_PROJECT_ID, { workspaceRoot: "/repo-a" }), + makeProject(TARGET_PROJECT_ID, { workspaceRoot: "/repo-b" }), + ], + }), + }); + + expect(result).toMatchObject({ type: "thread.project-set" }); + }), + ); + + it.effect("allows equal workspace roots", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: makeThreadProjectSetCommand(), + readModel: makeReadModel({ + projects: [ + makeProject(SOURCE_PROJECT_ID, { workspaceRoot: "/repo" }), + makeProject(TARGET_PROJECT_ID, { workspaceRoot: "/repo" }), + ], + }), + }); + + expect(result).toMatchObject({ type: "thread.project-set" }); + }), + ); +}); + +it.layer(NodeServices.layer)("project merge decider", (it) => { + it.effect("moves every active source thread before deleting the source project", () => + Effect.gen(function* () { + const secondThreadId = ThreadId.make("thread-to-move-2"); + const deletedThreadId = ThreadId.make("thread-already-deleted"); + const result = yield* decideOrchestrationCommand({ + command: makeProjectMergeCommand(), + readModel: makeReadModel({ + threads: [ + makeThread(), + makeThread({ id: secondThreadId }), + makeThread({ id: deletedThreadId, deletedAt: NOW }), + ], + }), + }); + + expect(Array.isArray(result)).toBe(true); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual([ + "thread.project-set", + "thread.project-set", + "project.deleted", + ]); + expect(events.slice(0, 2).map((event) => event.aggregateId)).toEqual([ + THREAD_ID, + secondThreadId, + ]); + expect(events.at(-1)).toMatchObject({ + type: "project.deleted", + payload: { projectId: SOURCE_PROJECT_ID }, + }); + }), + ); + + it.effect("deletes an empty source project with one event", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: makeProjectMergeCommand(), + readModel: makeReadModel({ threads: [] }), + }); + + const events = Array.isArray(result) ? result : [result]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "project.deleted", + payload: { projectId: SOURCE_PROJECT_ID }, + }); + }), + ); + + it.effect("rejects merging a project into itself", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: makeProjectMergeCommand({ targetProjectId: SOURCE_PROJECT_ID }), + readModel: makeReadModel(), + }), + ); + expect(error.message).toContain("cannot be merged into itself"); + }), + ); + + it.effect("passes the unrelated-root override through every expanded move", () => + Effect.gen(function* () { + const readModel = makeReadModel({ + projects: [ + makeProject(SOURCE_PROJECT_ID, { workspaceRoot: "/repo-a" }), + makeProject(TARGET_PROJECT_ID, { workspaceRoot: "/repo-b" }), + ], + }); + const rejected = yield* Effect.flip( + decideOrchestrationCommand({ + command: makeProjectMergeCommand(), + readModel, + }), + ); + expect(rejected.message).toContain("unrelated workspace roots"); + + const allowed = yield* decideOrchestrationCommand({ + command: makeProjectMergeCommand({ allowUnrelatedRoots: true }), + readModel, + }); + const events = Array.isArray(allowed) ? allowed : [allowed]; + expect(events.map((event) => event.type)).toEqual(["thread.project-set", "project.deleted"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a48bb29e154b..69739b662f2a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -15,15 +15,24 @@ import { requireActiveProjectWorkspaceRootAbsent, requireProject, requireProjectAbsent, + requireProjectNotDeleted, requireThread, requireThreadArchived, requireThreadAbsent, requireThreadNotArchived, + requireThreadNotDeleted, } from "./commandInvariants.ts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +function areRelatedWorkspaceRoots(leftRoot: string, rightRoot: string): boolean { + const left = normalizeProjectPathForComparison(leftRoot).replaceAll("\\", "/"); + const right = normalizeProjectPathForComparison(rightRoot).replaceAll("\\", "/"); + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`); +} + // Session adoption takes seconds; a user message still unadopted after this // window is a failed/stale start, not pending work. Mirrors the client's // QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. @@ -349,6 +358,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "project.merge": { + const sourceProject = yield* requireProjectNotDeleted({ + readModel, + command, + projectId: command.sourceProjectId, + }); + yield* requireProjectNotDeleted({ + readModel, + command, + projectId: command.targetProjectId, + }); + if (command.sourceProjectId === command.targetProjectId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${command.sourceProjectId}' cannot be merged into itself.`, + }); + } + + const activeThreads = listThreadsByProjectId(readModel, sourceProject.id).filter( + (thread) => thread.deletedAt === null, + ); + return yield* decideCommandSequence({ + readModel, + commands: [ + ...activeThreads.map( + (thread): Extract => ({ + type: "thread.project.set", + commandId: command.commandId, + threadId: thread.id, + projectId: command.targetProjectId, + ...(command.allowUnrelatedRoots !== undefined + ? { allowUnrelatedRoots: command.allowUnrelatedRoots } + : {}), + createdAt: command.createdAt, + }), + ), + { + type: "project.delete", + commandId: command.commandId, + projectId: command.sourceProjectId, + }, + ], + }); + } + case "thread.create": { yield* requireProject({ readModel, @@ -888,6 +942,49 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.project.set": { + const thread = yield* requireThreadNotDeleted({ + readModel, + command, + threadId: command.threadId, + }); + const currentProject = yield* requireProjectNotDeleted({ + readModel, + command, + projectId: thread.projectId, + }); + const targetProject = yield* requireProjectNotDeleted({ + readModel, + command, + projectId: command.projectId, + }); + if ( + command.allowUnrelatedRoots !== true && + !areRelatedWorkspaceRoots(currentProject.workspaceRoot, targetProject.workspaceRoot) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' cannot move between unrelated workspace roots '${currentProject.workspaceRoot}' and '${targetProject.workspaceRoot}' without allowUnrelatedRoots=true.`, + }); + } + const projectUnchanged = thread.projectId === command.projectId; + const occurredAt = command.createdAt; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.project-set", + payload: { + threadId: command.threadId, + projectId: command.projectId, + updatedAt: projectUnchanged ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.interaction-mode.set": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023c..e3b3ba5d6e59 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -399,6 +399,60 @@ describe("orchestration projector", () => { expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); }); + it("updates a thread project from thread.project-set", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + const updatedAt = "2026-02-23T08:00:05.000Z"; + const afterCreate = await Effect.runPromise( + projectEvent( + createEmptyReadModel(createdAt), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-source", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const afterMove = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.project-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: updatedAt, + commandId: "cmd-project-set", + payload: { + threadId: "thread-1", + projectId: "project-target", + updatedAt, + }, + }), + ), + ); + + expect(afterMove.threads[0]?.projectId).toBe("project-target"); + expect(afterMove.threads[0]?.updatedAt).toBe(updatedAt); + }); + it("marks assistant messages completed with non-streaming updates", async () => { const createdAt = "2026-02-23T09:00:00.000Z"; const deltaAt = "2026-02-23T09:00:01.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..0e7dddc6a882 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -20,6 +20,7 @@ import { ThreadDeletedPayload, ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, + ThreadProjectSetPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, ThreadSettledPayload, @@ -76,6 +77,17 @@ function updateThread( return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); } +function moveThreadProjection( + threads: ReadonlyArray, + threadId: ThreadId, + projectId: OrchestrationThread["projectId"], + updatedAt: OrchestrationThread["updatedAt"], +): OrchestrationThread[] { + return threads.map((thread) => + thread.id === threadId ? { ...thread, projectId, updatedAt } : thread, + ); +} + function decodeForEvent( schema: Schema.Decoder, value: unknown, @@ -472,6 +484,19 @@ export function projectEvent( })), ); + case "thread.project-set": + return decodeForEvent(ThreadProjectSetPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: moveThreadProjection( + nextBase.threads, + payload.threadId, + payload.projectId, + payload.updatedAt, + ), + })), + ); + case "thread.interaction-mode-set": return decodeForEvent( ThreadInteractionModeSetPayload, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e0..3918577df773 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,5 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + type OrchestrationCommand, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -8,13 +14,32 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; +import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; + +const NoRepositoryIdentityResolver = Layer.succeed( + RepositoryIdentityResolver.RepositoryIdentityResolver, + { + resolve: () => Effect.succeed(null), + }, +); +const PassthroughWorkspacePaths = Layer.succeed(WorkspacePaths.WorkspacePaths, { + normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), + resolveRelativePathWithinRoot: () => Effect.die("unused"), +}); +const DefaultBootstrapTestLayer = Layer.mergeAll( + NodeServices.layer, + NoRepositoryIdentityResolver, + PassthroughWorkspacePaths, +); it("uses the canonical Codex default for auto-bootstrapped model selection", () => { assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { @@ -129,6 +154,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa return Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); + const workspaceRootLookups = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", @@ -141,18 +167,20 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => - Effect.succeed( - Option.some({ - id: bootstrapProjectId, - title: "Startup Project", - workspaceRoot: "/tmp/startup-project", - defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - deletedAt: null, - }), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Ref.update(workspaceRootLookups, (roots) => [...roots, workspaceRoot]).pipe( + Effect.as( + Option.some({ + id: bootstrapProjectId, + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }), + ), ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), @@ -172,7 +200,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa streamDomainEvents: Stream.empty, latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), - Effect.provide(NodeServices.layer), + Effect.provideService(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => Effect.die("repository resolution must not run after a cwd project hit"), + }), + Effect.provide(Layer.mergeAll(NodeServices.layer, PassthroughWorkspacePaths)), ); assert.deepStrictEqual(targets, { @@ -180,12 +211,14 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa bootstrapThreadId, }); assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); + assert.deepStrictEqual(yield* Ref.get(workspaceRootLookups), ["/tmp/startup-project"]); }); }); it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => Effect.gen(function* () { - const dispatchCalls = yield* Ref.make>([]); + const dispatchCalls = yield* Ref.make>([]); + const workspaceRootLookups = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", @@ -198,7 +231,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Ref.update(workspaceRootLookups, (roots) => [...roots, workspaceRoot]).pipe( + Effect.as(Option.none()), + ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), @@ -211,21 +247,133 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, dispatch: (command) => - Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe( Effect.as({ sequence: 1 }), ), streamDomainEvents: Stream.empty, latestSequence: Effect.succeed(0), } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), - Effect.provide(NodeServices.layer), + Effect.provideService(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/repository", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/acme/repository.git", + }, + rootPath: "/tmp/repository-root", + }), + }), + Effect.provide(Layer.mergeAll(NodeServices.layer, PassthroughWorkspacePaths)), ); assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); - assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create", "thread.create"]); + assert.deepStrictEqual( + (yield* Ref.get(dispatchCalls)).map((command) => command.type), + ["project.create", "thread.create"], + ); + assert.deepInclude((yield* Ref.get(dispatchCalls))[0], { + type: "project.create", + workspaceRoot: "/tmp/startup-project", + title: "startup-project", + }); + assert.deepStrictEqual(yield* Ref.get(workspaceRootLookups), [ + "/tmp/startup-project", + "/tmp/repository-root", + ]); }), ); +it.effect( + "resolveAutoBootstrapWelcomeTargets self-heals to an existing repository-root project", + () => { + const bootstrapProjectId = ProjectId.make("project-repository-root"); + const bootstrapThreadId = ThreadId.make("thread-repository-root"); + + return Effect.gen(function* () { + const workspaceRootLookups = yield* Ref.make>([]); + const normalizedRoots = yield* Ref.make>([]); + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + cwd: "/repo/apps/server", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Ref.update(workspaceRootLookups, (roots) => [...roots, workspaceRoot]).pipe( + Effect.as( + workspaceRoot === "/repo" + ? Option.some({ + id: bootstrapProjectId, + title: "Repository", + workspaceRoot: "/repo", + defaultModelSelection: + ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }) + : Option.none(), + ), + ), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), + Effect.provideService(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/repository", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/acme/repository.git", + }, + rootPath: "/repo-via-symlink", + }), + }), + Effect.provideService(WorkspacePaths.WorkspacePaths, { + normalizeWorkspaceRoot: (workspaceRoot) => + Ref.update(normalizedRoots, (roots) => [...roots, workspaceRoot]).pipe( + Effect.as("/repo"), + ), + resolveRelativePathWithinRoot: () => Effect.die("unused"), + }), + Effect.provide(NodeServices.layer), + ); + + assert.deepStrictEqual(targets, { bootstrapProjectId, bootstrapThreadId }); + assert.deepStrictEqual(yield* Ref.get(workspaceRootLookups), ["/repo/apps/server", "/repo"]); + assert.deepStrictEqual(yield* Ref.get(normalizedRoots), ["/repo-via-symlink"]); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); + }); + }, +); + it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -277,5 +425,5 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa assert.strictEqual(error, uuidError); assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(DefaultBootstrapTestLayer)), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 5db2b75556ee..f19be0f3a71e 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,8 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import { forkParked } from "./serverActivation.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import { @@ -186,15 +188,29 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const projectionReadModelQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const path = yield* Path.Path; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; let bootstrapProjectId: ProjectId | undefined; let bootstrapThreadId: ThreadId | undefined; if (serverConfig.autoBootstrapProjectFromCwd) { yield* Effect.gen(function* () { - const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( + let existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( serverConfig.cwd, ); + if (Option.isNone(existingProject)) { + const repositoryIdentity = yield* repositoryIdentityResolver.resolve(serverConfig.cwd); + const repositoryRoot = repositoryIdentity?.rootPath?.trim(); + if (repositoryRoot) { + const normalizedRepositoryRoot = + yield* workspacePaths.normalizeWorkspaceRoot(repositoryRoot); + existingProject = + yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( + normalizedRepositoryRoot, + ); + } + } let nextProjectId: ProjectId; let nextProjectDefaultModelSelection: ModelSelection; diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a349..5baf469f5f4e 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -123,6 +123,31 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.project-set", () => { + it("updates the project and timestamp for an open thread", () => { + const updatedAt = "2026-04-01T02:30:00.000Z"; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 3, + occurredAt: updatedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.project-set", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-2"), + updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.projectId).toBe("project-2"); + expect(result.thread.updatedAt).toBe(updatedAt); + } + }); + }); + describe("thread.archived / thread.unarchived", () => { it("sets archivedAt and clears title regeneration", () => { const regeneratingThread: OrchestrationThread = { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 970fd94b1a16..6b278de4d4d9 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -232,6 +232,16 @@ export function applyThreadDetailEvent( }, }; + case "thread.project-set": + return { + kind: "updated", + thread: { + ...thread, + projectId: event.payload.projectId, + updatedAt: event.payload.updatedAt, + }, + }; + case "thread.interaction-mode-set": return { kind: "updated", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 35fef721efa7..db31dd045737 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -650,6 +650,15 @@ const ProjectDeleteCommand = Schema.Struct({ force: Schema.optional(Schema.Boolean), }); +const ProjectMergeCommand = Schema.Struct({ + type: Schema.Literal("project.merge"), + commandId: CommandId, + sourceProjectId: ProjectId, + targetProjectId: ProjectId, + allowUnrelatedRoots: Schema.optional(Schema.Boolean), + createdAt: IsoDateTime, +}); + const ThreadCreateCommand = Schema.Struct({ type: Schema.Literal("thread.create"), commandId: CommandId, @@ -774,6 +783,15 @@ const ThreadRuntimeModeSetCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadProjectSetCommand = Schema.Struct({ + type: Schema.Literal("thread.project.set"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + allowUnrelatedRoots: Schema.optional(Schema.Boolean), + createdAt: IsoDateTime, +}); + const ThreadInteractionModeSetCommand = Schema.Struct({ type: Schema.Literal("thread.interaction-mode.set"), commandId: CommandId, @@ -899,6 +917,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, + ProjectMergeCommand, ThreadCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, @@ -912,6 +931,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, + ThreadProjectSetCommand, ThreadInteractionModeSetCommand, ThreadTurnStartCommand, ThreadTurnInterruptCommand, @@ -927,6 +947,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, + ProjectMergeCommand, ThreadCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, @@ -940,6 +961,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, + ThreadProjectSetCommand, ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, ThreadTurnInterruptCommand, @@ -1058,6 +1080,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.pin-reordered", "thread.meta-updated", "thread.runtime-mode-set", + "thread.project-set", "thread.interaction-mode-set", "thread.message-sent", "thread.turn-start-requested", @@ -1210,6 +1233,12 @@ export const ThreadRuntimeModeSetPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadProjectSetPayload = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + updatedAt: IsoDateTime, +}); + export const ThreadInteractionModeSetPayload = Schema.Struct({ threadId: ThreadId, interactionMode: ProviderInteractionMode.pipe( @@ -1407,6 +1436,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.runtime-mode-set"), payload: ThreadRuntimeModeSetPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.project-set"), + payload: ThreadProjectSetPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.interaction-mode-set"),