Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions apps/server/src/services/threads/thread-archive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
archiveThread,
listLiveThreadsInEnvironment,
listUnarchivedAssignedChildThreads,
listUnarchivedHiddenSourceThreads,
Expand Down Expand Up @@ -33,11 +34,15 @@ interface ArchiveThreadEnvironment {
id: string;
}

interface ArchiveThreadWithLifecycleEffectsArgs {
interface ArchiveThreadTarget {
environment: ArchiveThreadEnvironment | null;
thread: Pick<Thread, "environmentId" | "id" | "status">;
}

interface ArchiveThreadWithLifecycleEffectsArgs extends ArchiveThreadTarget {
releaseChildren: boolean;
}

interface ResolveArchiveThreadEnvironmentArgs {
thread: ArchiveThreadWithLifecycleEffectsArgs["thread"];
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -137,6 +145,7 @@ export function archiveThreadAndHiddenSourceForks(
})) {
archiveThreadWithLifecycleEffects(deps, {
environment: resolveArchiveThreadEnvironment(deps, { thread: fork }),
releaseChildren: true,
thread: fork,
});
}
Expand All @@ -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) {
Expand Down Expand Up @@ -206,6 +218,7 @@ export function archiveThreadAndChildren(
const environment = resolveArchiveThreadEnvironment(deps, { thread });
const result = archiveThreadWithLifecycleEffects(deps, {
environment,
releaseChildren: true,
thread,
});
if (!result) {
Expand Down
49 changes: 49 additions & 0 deletions apps/server/test/public/public-thread-parenting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getThread } from "@bb/db";
import { threadSchema } from "@bb/domain";
import {
apiErrorSchema,
environmentArchiveThreadsResponseSchema,
sidebarBootstrapResponseSchema,
threadArchiveAllResponseSchema,
threadChildSummaryResponseSchema,
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading