diff --git a/apps/server/src/services/threads/thread-archive.ts b/apps/server/src/services/threads/thread-archive.ts index 16f30ead3d..46744addc4 100644 --- a/apps/server/src/services/threads/thread-archive.ts +++ b/apps/server/src/services/threads/thread-archive.ts @@ -1,4 +1,5 @@ import { + archiveThread, listLiveThreadsInEnvironment, listUnarchivedAssignedChildThreads, listUnarchivedHiddenSourceThreads, @@ -33,11 +34,15 @@ interface ArchiveThreadEnvironment { id: string; } -interface ArchiveThreadWithLifecycleEffectsArgs { +interface ArchiveThreadTarget { environment: ArchiveThreadEnvironment | null; thread: Pick; } +interface ArchiveThreadWithLifecycleEffectsArgs extends ArchiveThreadTarget { + releaseChildren: boolean; +} + interface ResolveArchiveThreadEnvironmentArgs { thread: ArchiveThreadWithLifecycleEffectsArgs["thread"]; } @@ -85,9 +90,9 @@ function archiveThreadWithLifecycleEffects( deps: AppDeps, args: ArchiveThreadWithLifecycleEffectsArgs, ): Thread | null { - const archivedThread = archiveThreadAndReleaseChildren(deps, { - threadId: args.thread.id, - }); + const archivedThread = args.releaseChildren + ? archiveThreadAndReleaseChildren(deps, { threadId: args.thread.id }) + : archiveThread(deps.db, deps.hub, args.thread.id); if (!archivedThread) { return null; } @@ -126,9 +131,12 @@ function archiveThreadWithLifecycleEffects( */ export function archiveThreadAndHiddenSourceForks( deps: AppDeps, - args: ArchiveThreadWithLifecycleEffectsArgs, + args: ArchiveThreadTarget, ): Thread | null { - const archivedThread = archiveThreadWithLifecycleEffects(deps, args); + const archivedThread = archiveThreadWithLifecycleEffects(deps, { + ...args, + releaseChildren: true, + }); if (!archivedThread) { return null; } @@ -137,6 +145,7 @@ export function archiveThreadAndHiddenSourceForks( })) { archiveThreadWithLifecycleEffects(deps, { environment: resolveArchiveThreadEnvironment(deps, { thread: fork }), + releaseChildren: true, thread: fork, }); } @@ -155,6 +164,9 @@ export function archiveEnvironmentThreads( for (const thread of threads) { const result = archiveThreadWithLifecycleEffects(deps, { environment: args.environment, + // This operation is scoped to one worktree. Releasing a live child in + // another environment would mutate ownership outside that scope. + releaseChildren: false, thread, }); if (!result) { @@ -206,6 +218,7 @@ export function archiveThreadAndChildren( const environment = resolveArchiveThreadEnvironment(deps, { thread }); const result = archiveThreadWithLifecycleEffects(deps, { environment, + releaseChildren: true, thread, }); if (!result) { diff --git a/apps/server/test/public/public-thread-parenting.test.ts b/apps/server/test/public/public-thread-parenting.test.ts index fc6d439cd8..577601273f 100644 --- a/apps/server/test/public/public-thread-parenting.test.ts +++ b/apps/server/test/public/public-thread-parenting.test.ts @@ -2,6 +2,7 @@ import { getThread } from "@bb/db"; import { threadSchema } from "@bb/domain"; import { apiErrorSchema, + environmentArchiveThreadsResponseSchema, sidebarBootstrapResponseSchema, threadArchiveAllResponseSchema, threadChildSummaryResponseSchema, @@ -407,6 +408,54 @@ describe("public thread parenting routes", () => { }); }); + it("preserves child ownership outside an archived worktree", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const parentEnvironment = seedEnvironment(harness.deps, { + hostId: host.id, + managed: true, + projectId: project.id, + workspaceProvisionType: "managed-worktree", + }); + const childEnvironment = seedEnvironment(harness.deps, { + hostId: host.id, + managed: true, + path: "/tmp/child-test-environment", + projectId: project.id, + workspaceProvisionType: "managed-worktree", + }); + const parentThread = seedThread(harness.deps, { + environmentId: parentEnvironment.id, + projectId: project.id, + }); + const childThread = seedThread(harness.deps, { + environmentId: childEnvironment.id, + parentThreadId: parentThread.id, + projectId: project.id, + }); + + const response = await harness.app.request( + `/api/v1/environments/${parentEnvironment.id}/archive-threads`, + { method: "POST" }, + ); + + expect(response.status).toBe(200); + expect( + environmentArchiveThreadsResponseSchema.parse(await readJson(response)), + ).toEqual({ + ok: true, + archivedThreadIds: [parentThread.id], + }); + expect(getThread(harness.db, parentThread.id)?.archivedAt).not.toBeNull(); + const preservedChild = getThread(harness.db, childThread.id); + expect(preservedChild?.archivedAt).toBeNull(); + expect(preservedChild?.parentThreadId).toBe(parentThread.id); + }); + }); + // Archiving one thread cascades too, not just archive-all: the plugin's // `thread.archived` listener used to cover this route. it("archives hidden source-derived forks when archiving a single thread", async () => {