Files
paseo/packages/server/src/server/workspace-archive-undo.e2e.test.ts
T
Mohamed Boudra 6c1b48d296 Archive a workspace from its sidebar row, with undo (#8904)
* feat(app): archive a workspace from its sidebar row and undo it

Hovering a workspace row on desktop shows an archive button next to the
row menu. Archiving shows a toast with Undo and a Cmd/Ctrl+Z shortcut so a
mis-click costs one keystroke instead of a trip through History.

The daemon serializes archive and undo per workspace across every
connection. An undo that lands while an archive is still tearing down
aborts it at the next checkpoint and restores only the agents that archive
took down; deleting the worktree directory is the point of no return, after
which undo recreates the worktree.

* fix(server,app): close archive and undo races found in review

- An archive request now claims the workspace's archive queue before
  looking up the workspace, so an undo sent right behind it cancels it
  instead of running first and losing to the archive.
- A repeat archive of an already archived workspace no longer wipes the
  agents the earlier archive saved for undo.
- Undo no longer brings back an agent that was archived before the
  workspace archive and only loaded to read its history.
- Each local archive or undo request owns its own intent token, so a
  late response to an earlier request cannot release a newer request's
  hold on the row. An archive canceled by another client's undo puts the
  row back.
- The undo failure toast shows translated copy; the cause goes to the log.

* fix(app): keep a workspace's layout when another client restores it during the undo window

The undo window purged tabs and layout whenever it closed after a
finished archive. If another client restored the workspace before this
client's toast closed, the restored workspace lost its saved
arrangement. The archive now commits only if the workspace is still gone
when the window closes.

* fix(app): show the archived toast's Undo as a button

* fix(app): keep a workspace's agent tabs when it is opened while its undo runs

* fix(app): keep a row whose undo is running when a directory snapshot omits it
2026-11-06 16:16:11 +07:00

276 lines
9.6 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
// Undo of a workspace archive, end to end through a real daemon. The archive is parked inside a
// worktree teardown command that waits on a file, so the undo lands at a known point.
let ctx: DaemonTestContext;
const tempRoots: string[] = [];
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
for (const tempRoot of tempRoots.splice(0)) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
interface GatedRepo {
repoDir: string;
teardownStartedPath: string;
releaseTeardown: () => void;
}
function git(repoDir: string, args: string[]): void {
execFileSync("git", args, { cwd: repoDir, stdio: "pipe" });
}
/** A repo whose worktree teardown parks until released. */
function createRepoWithGatedTeardown(): GatedRepo {
const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-archive-undo-"));
tempRoots.push(tempRoot);
const repoDir = path.join(tempRoot, "repo");
const teardownStartedPath = path.join(tempRoot, "teardown-started");
const releasePath = path.join(tempRoot, "release-teardown");
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
git(repoDir, ["config", "user.email", "test@getpaseo.local"]);
git(repoDir, ["config", "user.name", "Paseo Test"]);
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${teardownStartedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
writeFileSync(
path.join(repoDir, "paseo.json"),
JSON.stringify({ worktree: { teardown: [teardown] } }),
);
git(repoDir, ["add", "paseo.json"]);
git(repoDir, ["-c", "commit.gpgsign=false", "commit", "-m", "gated teardown"]);
return {
repoDir,
teardownStartedPath,
releaseTeardown: () => writeFileSync(releasePath, "release\n"),
};
}
async function createWorktreeWorkspaceWithAgent(repoDir: string, slug: string) {
const result = await ctx.client.createWorkspace({
source: { kind: "worktree", cwd: repoDir, worktreeSlug: slug, baseBranch: "main" },
});
const workspace = result.workspace;
if (!workspace?.workspaceDirectory) {
throw new Error(result.error ?? "Failed to create worktree workspace");
}
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd: workspace.workspaceDirectory,
workspaceId: workspace.id,
title: "Undo agent",
});
return { workspaceId: workspace.id, directory: workspace.workspaceDirectory, agentId: agent.id };
}
async function isWorkspaceActive(workspaceId: string): Promise<boolean> {
const workspaces = await ctx.client.fetchWorkspaces();
return workspaces.entries.some((entry) => entry.id === workspaceId);
}
async function isAgentArchived(agentId: string): Promise<boolean> {
const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
const entry = agents.entries.find((candidate) => candidate.agent.id === agentId);
if (!entry) throw new Error(`Agent ${agentId} is missing`);
return entry.agent.archivedAt !== null && entry.agent.archivedAt !== undefined;
}
test.skipIf(process.platform === "win32")(
"an undo while teardown runs cancels the archive before the worktree is deleted",
async () => {
const repo = createRepoWithGatedTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"undo-during-teardown",
);
try {
const archive = ctx.client.archiveWorkspace(workspaceId);
await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true);
const undo = ctx.client.undoWorkspaceArchive(workspaceId);
repo.releaseTeardown();
const [archiveResponse] = await Promise.all([archive, undo]);
expect({
canceled: archiveResponse.canceled,
archivedAt: archiveResponse.archivedAt,
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({
canceled: true,
archivedAt: null,
workspaceActive: true,
directoryExists: true,
agentArchived: false,
});
} finally {
repo.releaseTeardown();
}
},
60000,
);
test.skipIf(process.platform === "win32")(
"an undo after the archive finished recreates the worktree and its agents",
async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"undo-after-archive",
);
const archived = await ctx.client.archiveWorkspace(workspaceId);
expect(archived.error).toBeNull();
expect(existsSync(directory)).toBe(false);
expect(await isAgentArchived(agentId)).toBe(true);
await ctx.client.undoWorkspaceArchive(workspaceId);
expect({
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false });
},
60000,
);
test.skipIf(process.platform === "win32")(
"archive, undo, archive leaves the workspace archived",
async () => {
const repo = createRepoWithGatedTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"archive-undo-archive",
);
try {
const first = ctx.client.archiveWorkspace(workspaceId);
await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true);
const undo = ctx.client.undoWorkspaceArchive(workspaceId);
const second = ctx.client.archiveWorkspace(workspaceId);
repo.releaseTeardown();
const [firstResponse, , secondResponse] = await Promise.all([first, undo, second]);
expect({
firstCanceled: firstResponse.canceled,
secondError: secondResponse.error,
secondCanceled: secondResponse.canceled ?? false,
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({
firstCanceled: true,
secondError: null,
secondCanceled: false,
workspaceActive: false,
directoryExists: false,
agentArchived: true,
});
} finally {
repo.releaseTeardown();
}
},
60000,
);
test.skipIf(process.platform === "win32")(
"an undo sent right behind its archive leaves the workspace active",
async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"archive-then-undo-same-tick",
);
const [archiveResponse] = await Promise.all([
ctx.client.archiveWorkspace(workspaceId),
ctx.client.undoWorkspaceArchive(workspaceId),
]);
expect({
archiveCanceled: archiveResponse.canceled ?? false,
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({
archiveCanceled: true,
workspaceActive: true,
directoryExists: true,
agentArchived: false,
});
},
60000,
);
test.skipIf(process.platform === "win32")(
"the undo response arrives after the client has seen its agents restored",
async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"undo-update-order",
);
await ctx.client.fetchAgents({ subscribe: {} });
// An archived agent leaves a default subscription; a restored one comes back as active.
let seenState: "active" | "archived" | "removed" = "active";
const stop = ctx.client.on("agent_update", (message) => {
if (message.payload.kind === "remove" && message.payload.agentId === agentId) {
seenState = "removed";
}
if (message.payload.kind === "upsert" && message.payload.agent.id === agentId) {
seenState = message.payload.agent.archivedAt ? "archived" : "active";
}
});
try {
await ctx.client.archiveWorkspace(workspaceId);
await expect.poll(() => seenState, { timeout: 15000 }).not.toBe("active");
await ctx.client.undoWorkspaceArchive(workspaceId);
// An app holds its agent tabs open until this response; an agent it still sees as
// archived at that point gets its tab closed.
expect(seenState).toBe("active");
} finally {
stop();
}
},
60000,
);
test("an undo on a workspace that was never archived leaves it as it was", async () => {
const repo = createRepoWithGatedTeardown();
repo.releaseTeardown();
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
repo.repoDir,
"undo-without-archive",
);
await ctx.client.undoWorkspaceArchive(workspaceId);
expect({
workspaceActive: await isWorkspaceActive(workspaceId),
directoryExists: existsSync(directory),
agentArchived: await isAgentArchived(agentId),
}).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false });
}, 60000);