Keep restored merged workspaces active (#2714)
* fix(workspaces): keep restored merged workspaces active Auto-archive previously reacted to every merged pull request snapshot, so restoring a workspace immediately archived it again. Persist the consumed change request with the archive transition so explicit recovery remains authoritative. * test(e2e): clean partial archive scenarios Track workspace resources as soon as creation succeeds so later setup failures archive the worktree and remove the registered project. * test(workspaces): pin archive latch restoration Cover the persisted archive marker across restore in CI and make the real browser assertion observe active workspace stability after refresh. * fix(workspaces): latch restored legacy archives Resolve the current merged change request while restoring records created before the archive marker existed, then persist it with the unarchive mutation. * fix(workspaces): refresh latch on restore When an archived workspace now points at a different merged change request, record that URL during restore so the restored workspace remains active. * test(e2e): make archive latch setup fail loudly Remove authentication-gated skipping and register each external fixture cleanup immediately after allocation so setup failures cannot leak resources. * test(workspaces): include archive latch in reconciliation
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { clickSessionRow, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
|
||||
import { cloneGithubRepoDefaultBranchOnly, createTempGithubRepo } from "./helpers/github-fixtures";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
} from "./helpers/new-workspace";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Auto-archive after pull request merge", () => {
|
||||
test.describe.configure({ retries: 0, timeout: 180_000 });
|
||||
|
||||
test("manual restore latches and keeps a merged pull request workspace active", async ({
|
||||
page,
|
||||
}) => {
|
||||
const scenario = await createMergedPullRequestScenario();
|
||||
|
||||
try {
|
||||
await scenario.autoArchive();
|
||||
await openArchivedWorkspaceFromHistory(page, scenario.agentTitle);
|
||||
await restoreWorkspace(page, scenario.workspaceId);
|
||||
|
||||
await scenario.refreshMergedPullRequest();
|
||||
await scenario.expectWorkspaceRemainsActive();
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: scenario.workspaceId,
|
||||
});
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface MergedPullRequestScenario {
|
||||
agentTitle: string;
|
||||
workspaceId: string;
|
||||
autoArchive(): Promise<void>;
|
||||
refreshMergedPullRequest(): Promise<void>;
|
||||
expectWorkspaceRemainsActive(): Promise<void>;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
async function createMergedPullRequestScenario(): Promise<MergedPullRequestScenario> {
|
||||
const cleanups: Array<() => Promise<unknown>> = [];
|
||||
|
||||
try {
|
||||
const repo = await createTempGithubRepo({
|
||||
category: "auto-archive-latch",
|
||||
prs: [{ title: "Auto-archive latch", state: "merged" }],
|
||||
});
|
||||
cleanups.push(() => repo.cleanup());
|
||||
const checkout = await cloneGithubRepoDefaultBranchOnly(repo);
|
||||
cleanups.push(() => checkout.cleanup());
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
cleanups.push(() => workspaceClient.close());
|
||||
const agentClient = await connectSeedClient();
|
||||
cleanups.push(() => agentClient.close());
|
||||
const previousConfig = await workspaceClient.getDaemonConfig();
|
||||
await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: false });
|
||||
cleanups.push(() =>
|
||||
workspaceClient.patchDaemonConfig({
|
||||
autoArchiveAfterMerge: previousConfig.config.autoArchiveAfterMerge,
|
||||
}),
|
||||
);
|
||||
|
||||
const pullRequest = repo.prs[0];
|
||||
if (!pullRequest) {
|
||||
throw new Error("Expected the merged pull request fixture");
|
||||
}
|
||||
const created = await workspaceClient.createPaseoWorktree({
|
||||
cwd: checkout.path,
|
||||
action: "checkout",
|
||||
checkoutSource: {
|
||||
kind: "change_request",
|
||||
forge: "github",
|
||||
number: pullRequest.number,
|
||||
},
|
||||
worktreeSlug: `auto-archive-latch-${Date.now()}`,
|
||||
});
|
||||
if (!created.workspace || created.error) {
|
||||
throw new Error(created.error ?? "Failed to create merged pull request workspace");
|
||||
}
|
||||
|
||||
const workspace = created.workspace;
|
||||
cleanups.push(() => workspaceClient.removeProject(workspace.projectId));
|
||||
cleanups.push(() => archiveWorkspaceFromDaemon(workspaceClient, workspace.workspaceDirectory));
|
||||
const agentTitle = `Auto-archive latch ${Date.now()}`;
|
||||
const agent = await agentClient.createAgent({
|
||||
provider: "mock",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
workspaceId: workspace.id,
|
||||
title: agentTitle,
|
||||
});
|
||||
await agentClient.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
|
||||
return {
|
||||
agentTitle,
|
||||
workspaceId: workspace.id,
|
||||
autoArchive: async () => {
|
||||
await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: true });
|
||||
await workspaceClient.checkoutRefresh(workspace.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => agentClient.fetchAgent({ agentId: agent.id }), { timeout: 30_000 })
|
||||
.toMatchObject({ agent: { archivedAt: expect.any(String) } });
|
||||
await expect
|
||||
.poll(() => existsSync(workspace.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
},
|
||||
refreshMergedPullRequest: async () => {
|
||||
const refresh = await workspaceClient.checkoutRefresh(workspace.workspaceDirectory);
|
||||
if (!refresh.success) {
|
||||
throw new Error(`Failed to refresh restored workspace: ${String(refresh.error)}`);
|
||||
}
|
||||
},
|
||||
expectWorkspaceRemainsActive: async () => {
|
||||
const observationDeadline = Date.now() + 5_000;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const descriptor = (await workspaceClient.fetchWorkspaces()).entries.find(
|
||||
(entry) => entry.id === workspace.id,
|
||||
);
|
||||
if (!descriptor) {
|
||||
return "archived";
|
||||
}
|
||||
return Date.now() >= observationDeadline ? "active" : "observing";
|
||||
},
|
||||
{ timeout: 10_000, intervals: [100, 250, 500] },
|
||||
)
|
||||
.toBe("active");
|
||||
},
|
||||
cleanup: () => runScenarioCleanups(cleanups),
|
||||
};
|
||||
} catch (error) {
|
||||
await runScenarioCleanups(cleanups);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runScenarioCleanups(cleanups: Array<() => Promise<unknown>>): Promise<void> {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups
|
||||
.pop()?.()
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, agentTitle: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agentTitle);
|
||||
await clickSessionRow(page, agentTitle);
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const action = page.getByTestId("workspace-recovery-action");
|
||||
await expect(action).toHaveText("Restore");
|
||||
await action.click();
|
||||
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
|
||||
}
|
||||
Reference in New Issue
Block a user