Files
PaperClipAI/server/src/__tests__/execution-workspace-reopen.test.ts
T
DottaandPaperclip 813e808af4 Remove filesystem history authorization from workspace preparation (#16963)
Remove physical-root source/reader authorization and sticky directory classifications from workspace preparation. Preserve current task/run authority, app-record privacy, credential authorization, containment, and lifecycle/publication integrity.

Validate replacement selections against persisted new inputs and refresh verified checkout ownership so bounded reuse survives earlier workspace relocation. Add real PostgreSQL/Git regressions, a reproducible history-scaling benchmark, and documentation.

Full CI passed all 48 jobs and Greptile scored 5/5 at 3d5da6c259. Five preparation-guard medians improved from 20,512.99 ms to 52.22 ms on the 8,500-workspace fixture; these are not end-to-end startup timings.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-20 09:19:59 -05:00

803 lines
39 KiB
TypeScript

import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, subscribeDatabaseWork } from "@paperclipai/db";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
authUsers,
companyMemberships,
issueAccessGrants,
companies,
createDb,
executionWorkspaces,
issues,
projectWorkspaces,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY,
EXECUTION_WORKSPACE_REOPEN_FAILED_REASON,
EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY,
EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY,
executionWorkspaceService,
metadataHasReopenPendingConsumption,
readExecutionWorkspaceLifecycleGeneration,
readMetadataReopenPendingConsumptionSince,
} from "../services/execution-workspaces.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres reopen tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempDirs: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reopen-");
db = createDb(tempDb.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(companyMemberships);
await db.delete(agents);
await db.delete(companies);
await db.delete(authUsers);
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function makeExistingDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "paperclip-reopen-cwd-"));
tempDirs.push(dir);
return dir;
}
async function seedCompanyProject() {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `PAP-${companyId.slice(0, 8)}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Reopen project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
cwd: "/tmp/paperclip-reopen-project",
isPrimary: true,
});
return { companyId, projectId, projectWorkspaceId };
}
async function seedClosedWorkspace(input: {
companyId: string;
projectId: string;
projectWorkspaceId: string;
cwd: string;
status?: "archived" | "cleanup_failed" | "active";
generation?: number;
}) {
const workspaceId = randomUUID();
const closed = (input.status ?? "archived") !== "active";
await db.insert(executionWorkspaces).values({
id: workspaceId,
companyId: input.companyId,
projectId: input.projectId,
projectWorkspaceId: input.projectWorkspaceId,
mode: "isolated_workspace",
// project_primary strategy so the rebuild only checks that the directory
// exists, with no git operation.
strategyType: "project_primary",
name: "reopen-workspace",
status: input.status ?? "archived",
providerType: "local_fs",
cwd: input.cwd,
closedAt: closed ? new Date() : null,
cleanupReason: closed ? "issue_terminal" : null,
cleanupEligibleAt: closed ? new Date() : null,
metadata: {
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: input.generation ?? 1,
},
});
return workspaceId;
}
// Seed a company and project whose primary project workspace has a null cwd.
// This models a managed_checkout project: the base is not a local folder, so
// the live managed checkout supplies the base path at rebuild time.
async function seedManagedCheckoutProject() {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `PAP-${companyId.slice(0, 8)}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Managed checkout project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "managed_checkout",
cwd: null,
isPrimary: true,
});
return { companyId, projectId, projectWorkspaceId };
}
// Seed one closed isolated git_worktree row. The reaper already removed the
// worktree directory, so cwd points at a path that is not on disk.
async function seedClosedGitWorktreeWorkspace(input: {
companyId: string;
projectId: string;
projectWorkspaceId: string;
cwd: string;
repoUrl: string;
branchName: string;
}) {
const workspaceId = randomUUID();
await db.insert(executionWorkspaces).values({
id: workspaceId,
companyId: input.companyId,
projectId: input.projectId,
projectWorkspaceId: input.projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "reopen-git-worktree",
status: "archived",
providerType: "local_fs",
cwd: input.cwd,
repoUrl: input.repoUrl,
baseRef: null,
branchName: input.branchName,
closedAt: new Date(),
cleanupReason: "issue_terminal",
cleanupEligibleAt: new Date(),
metadata: {
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1,
},
});
return workspaceId;
}
// Build a real git repository at the managed checkout path so
// ensureManagedProjectWorkspace adopts it without a network clone. The repo
// holds the branch that the archived worktree row references.
function initManagedGitRepo(dir: string, worktreeBranch: string) {
mkdirSync(dir, { recursive: true });
const git = (...args: string[]) =>
execFileSync("git", args, {
cwd: dir,
env: {
...process.env,
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@example.com",
},
stdio: "ignore",
});
git("init");
writeFileSync(join(dir, "README.md"), "seed\n");
git("add", "README.md");
git("commit", "-m", "seed");
git("branch", worktreeBranch);
}
async function seedIssue(input: {
companyId: string;
projectId: string;
workspaceId: string;
issueNumber: number;
}) {
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId: input.companyId,
projectId: input.projectId,
identifier: `PAP-${input.issueNumber}`,
issueNumber: input.issueNumber,
title: "Resume me",
status: "todo",
priority: "medium",
executionWorkspaceId: input.workspaceId,
});
return issueId;
}
async function readWorkspace(id: string) {
return db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, id))
.then((rows) => rows[0] ?? null);
}
describe.each(["configured", "builtin"] as const)("reopen %s host provision containment", kind => {
it.each(["task", "agent", "organization", "source", "same-source", "trusted", "explicit", "projectless", "nondefault", "foreign-source-task", "retained-missing", "retained-false", "retained-null"] as const)("honors persisted %s policy before running repository scripts", async restriction => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const directory = await makeExistingDir();
const base = join(directory, "source"), target = join(directory, "target"), marker = join(directory, "host-provision-ran");
initManagedGitRepo(base, "reopen-feature");
const command = `printf 'host execution\n' > '${marker}'`;
mkdirSync(join(base, "scripts"));
writeFileSync(join(base, "scripts", "provision-worktree.sh"), command + "\n");
const git = (...args: string[]) => execFileSync("git", args, { cwd: base, stdio: "ignore",
env: { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" } });
git("add", "scripts"); git("commit", "-m", "Host provision fixture"); git("branch", "-f", "reopen-feature", "HEAD");
await db.update(projectWorkspaces).set({ cwd: base }).where(eq(projectWorkspaces.id, projectWorkspaceId));
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId,
cwd: target, repoUrl: pathToFileURL(base).href, branchName: "reopen-feature" });
if (kind === "configured") await db.update(executionWorkspaces).set({ metadata: {
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: command },
} }).where(eq(executionWorkspaces.id, workspaceId));
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4199 });
let organizationId: string | null = restriction === "projectless" ? null : projectId;
if (restriction === "organization") {
organizationId = randomUUID();
await db.insert(projects).values({ id: organizationId, companyId, name: "Organizational policy" });
}
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: issueId,
projectIds: [...new Set([projectId, ...(organizationId ? [organizationId] : [])])] } } };
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId, name: "Reopen assignee",
permissions: restriction === "agent" ? policy : {} });
await db.update(issues).set({ projectId: organizationId, assigneeAgentId: agentId,
executionPolicy: restriction === "task" ? policy : null }).where(eq(issues.id, issueId));
if (["source", "same-source", "organization"].includes(restriction)) {
await db.update(projects).set({ executionWorkspacePolicy: policy })
.where(eq(projects.id, restriction === "organization" ? organizationId! : projectId));
}
if (restriction === "explicit" || restriction.startsWith("retained-")) {
await db.update(issues).set({ workspaceSelection: { version: 1, source: "explicit",
selection: { kind: "configured_source", projectWorkspaceId, mode: "managed_isolated" } } })
.where(eq(issues.id, issueId));
}
if (restriction === "nondefault") {
await db.update(projectWorkspaces).set({ isPrimary: false }).where(eq(projectWorkspaces.id, projectWorkspaceId));
await db.insert(projectWorkspaces).values({ companyId, projectId, name: "Earlier default",
sourceType: "local_path", cwd: base, isPrimary: true, createdAt: new Date(0) });
}
if (restriction === "foreign-source-task") {
const sourceIssueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4198 });
await db.update(executionWorkspaces).set({ sourceIssueId }).where(eq(executionWorkspaces.id, workspaceId));
}
if (restriction.startsWith("retained-")) {
const workspace = await readWorkspace(workspaceId);
await db.update(executionWorkspaces).set({ metadata: { ...workspace?.metadata,
_controllerProvisionCopyOnly: true } }).where(eq(executionWorkspaces.id, workspaceId));
await db.update(issues).set({ workspaceSelection: null }).where(eq(issues.id, issueId));
const service = executionWorkspaceService(db);
await service.update(workspaceId, { metadata: restriction === "retained-null" ? null
: restriction === "retained-false" ? { _controllerProvisionCopyOnly: false } : {} });
if (kind === "configured") await service.update(workspaceId, { metadata: { config: { provisionCommand: command } } });
expect((await readWorkspace(workspaceId))?.metadata).toMatchObject({ _controllerProvisionCopyOnly: true });
}
const result = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId, issue: { id: issueId, companyId, projectId: organizationId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result).toMatchObject({ ok: true, reopened: true });
await expect(readFile(join(target, "README.md"), "utf8")).resolves.toBe("seed\n");
if (restriction === "trusted") await expect(readFile(marker, "utf8")).resolves.toBe("host execution\n");
else await expect(readFile(marker, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
expect(await readWorkspace(workspaceId)).toMatchObject({ status: "active", projectId, projectWorkspaceId });
if (["explicit", "projectless", "nondefault", "foreign-source-task"].includes(restriction) || restriction.startsWith("retained-")) expect((await readWorkspace(workspaceId))?.metadata)
.toMatchObject({ _controllerProvisionCopyOnly: true });
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0])
.toMatchObject({ projectId: organizationId, executionWorkspaceId: workspaceId, assigneeAgentId: agentId });
});
});
it("reopens the archived row in place, keeps the issue link, and raises the generation", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd, generation: 3 });
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4100 });
const svc = executionWorkspaceService(db);
const events: string[] = [];
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.terminalWorkspaces, event => events.push(event));
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
}).finally(unsubscribe);
expect(events).toEqual(["intent", "settled"]);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.reopened).toBe(true);
const row = await readWorkspace(workspaceId);
expect(row?.status).toBe("active");
expect(row?.closedAt).toBeNull();
expect(row?.cleanupReason).toBeNull();
expect(row?.cleanupEligibleAt).toBeNull();
expect(readExecutionWorkspaceLifecycleGeneration(row?.metadata as Record<string, unknown> | null)).toBe(4);
// The reopen flags the row so the terminal reaper does not archive and
// destroy the rebuilt worktree before the caller consumes it.
expect(metadataHasReopenPendingConsumption(row?.metadata as Record<string, unknown> | null)).toBe(true);
// The reopen never changes the issue-to-workspace link.
const issueRow = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issueRow?.executionWorkspaceId).toBe(workspaceId);
});
it("reopens the persisted source after the task leaves its organizational project", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4105 });
await db.update(issues).set({ projectId: null }).where(eq(issues.id, issueId));
const result = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: issueId, companyId, projectId: null },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
expect((await readWorkspace(workspaceId))?.projectId).toBe(projectId);
});
it("keeps access for every issue that shares the reopened row", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
const firstIssueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4101 });
const secondIssueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4102 });
const svc = executionWorkspaceService(db);
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: firstIssueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
const rows = await db.select().from(issues);
for (const row of rows) {
expect(row.executionWorkspaceId).toBe(workspaceId);
}
const workspace = await readWorkspace(workspaceId);
expect(workspace?.status).toBe("active");
expect(firstIssueId).not.toBe(secondIssueId);
});
it("fails closed and keeps the row closed when the rebuild fails", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
// A directory that does not exist. The project_primary rebuild returns null.
const missingDir = join(tmpdir(), `paperclip-reopen-missing-${randomUUID()}`);
const workspaceId = await seedClosedWorkspace({
companyId,
projectId,
projectWorkspaceId,
cwd: missingDir,
generation: 2,
});
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4103 });
const svc = executionWorkspaceService(db);
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.code).toBe("rebuild_failed");
const row = await readWorkspace(workspaceId);
// The row stays closed and retryable. The generation still rises so a queued
// cleanup with the old generation does nothing.
expect(row?.status).toBe("archived");
expect(row?.cleanupReason).toBe(EXECUTION_WORKSPACE_REOPEN_FAILED_REASON);
expect(row?.cleanupEligibleAt).toBeNull();
expect(readExecutionWorkspaceLifecycleGeneration(row?.metadata as Record<string, unknown> | null)).toBe(3);
});
it("resolves the managed base checkout for a git_worktree row when the project workspace cwd is null", async () => {
const previousHome = process.env.PAPERCLIP_HOME;
const tempHome = await mkdtemp(join(tmpdir(), "paperclip-reopen-home-"));
tempDirs.push(tempHome);
process.env.PAPERCLIP_HOME = tempHome;
try {
const { companyId, projectId, projectWorkspaceId } = await seedManagedCheckoutProject();
const repoUrl = "https://example.test/acme/widget.git";
const branchName = "reopen-feature";
// Build the live managed checkout that the rebuild must spawn git in.
const managedDir = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "widget" });
initManagedGitRepo(managedDir, branchName);
// The archived worktree path. The reaper already removed it from disk.
const deletedWorktree = join(tempHome, "worktrees", "reopen-worktree");
const workspaceId = await seedClosedGitWorktreeWorkspace({
companyId,
projectId,
projectWorkspaceId,
cwd: deletedWorktree,
repoUrl,
branchName,
});
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4305 });
const svc = executionWorkspaceService(db);
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.reopened).toBe(true);
const row = await readWorkspace(workspaceId);
expect(row?.status).toBe("active");
expect(row?.closedAt).toBeNull();
expect(row?.cleanupReason).toBeNull();
// The rebuild recreated the worktree at the archived path from the managed
// base checkout.
const worktreeStat = await stat(deletedWorktree).catch(() => null);
expect(worktreeStat?.isDirectory()).toBe(true);
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
}
});
it("reopens from a configured repository subdirectory using its concrete Git root", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const directory = await makeExistingDir();
const source = join(directory, "source");
const nested = join(source, "nested");
const target = join(directory, "restored-worktree");
initManagedGitRepo(source, "reopen-nested");
mkdirSync(nested);
await db.update(projectWorkspaces).set({ cwd: nested }).where(eq(projectWorkspaces.id, projectWorkspaceId));
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId,
cwd: target, repoUrl: pathToFileURL(source).href, branchName: "reopen-nested" });
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4900 });
const reopened = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId,
issue: { id: issueId, companyId, projectId }, actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(reopened).toMatchObject({ ok: true, reopened: true });
expect((await stat(target)).isDirectory()).toBe(true);
});
it.each([false, true, "revoked_during_provision"] as const)("reopens a current task using a private historical local source while preserving provisioning failure=%s", async scenario => {
const provisionFails = scenario === true;
const revokeDuringProvision = scenario === "revoked_during_provision";
const previousHome = process.env.PAPERCLIP_HOME;
const previousPrivacy = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
const home = await makeExistingDir();
process.env.PAPERCLIP_HOME = home;
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
try {
const { companyId, projectId, projectWorkspaceId } = await seedManagedCheckoutProject();
const readerId = randomUUID();
await db.insert(authUsers).values({ id: readerId, name: "Reader", email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: readerId, status: "active", membershipRole: "member" });
const authorizationActor = { type: "board" as const, source: "session" as const, userId: readerId, companyIds: [companyId], isInstanceAdmin: false };
const source = join(home, "private-source"), target = join(home, "worktree-copy");
initManagedGitRepo(source, "reopen-feature");
writeFileSync(join(source, "private.txt"), "PRIVATE_REOPEN_BYTES");
execFileSync("git", ["add", "private.txt"], { cwd: source });
execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "private"], { cwd: source });
execFileSync("git", ["branch", "-f", "reopen-feature", "HEAD"], { cwd: source });
const repoUrl = pathToFileURL(source).href;
await db.update(projectWorkspaces).set({ repoUrl }).where(eq(projectWorkspaces.id, projectWorkspaceId));
const privateIssueId = randomUUID();
await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private source", visibility: "private", status: "todo", responsibleUserId: "another-owner" });
await db.insert(executionWorkspaces).values({ companyId, projectId: null, sourceIssueId: privateIssueId, name: "Private owner", mode: "shared_workspace", strategyType: "project_primary", cwd: source, providerType: "local_fs", status: "active" });
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId, cwd: target, repoUrl, branchName: "reopen-feature" });
if (!provisionFails && !revokeDuringProvision) {
await db.update(executionWorkspaces).set({ sourceIssueId: privateIssueId }).where(eq(executionWorkspaces.id, workspaceId));
}
const waitingProvision = `node -e 'const fs = require("node:fs"); fs.writeFileSync("dirty.txt", "DIRTY_REOPEN_BYTES"); fs.writeFileSync("provision-started", "ready"); const deadline = Date.now() + 15000; const timer = setInterval(() => { if (fs.existsSync("provision-continue")) { clearInterval(timer); process.exit(0); } if (Date.now() > deadline) process.exit(2); }, 20);'`;
if (provisionFails || revokeDuringProvision) await db.update(executionWorkspaces).set({ metadata: { [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: provisionFails ? "exit 42" : waitingProvision } } }).where(eq(executionWorkspaces.id, workspaceId));
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4901 });
if (revokeDuringProvision) await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
const reopen = () => executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId, issue: { id: issueId, companyId, projectId }, actor: { actorType: "user", agentId: null }, authorizationActor });
const attempt = revokeDuringProvision ? reopen() : null;
if (attempt) {
try {
await vi.waitFor(async () => expect(await readFile(join(target, "provision-started"), "utf8")).toBe("ready"), { timeout: 10_000 });
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
} finally {
await writeFile(join(target, "provision-continue"), "resume");
}
}
const results = attempt ? [await attempt] : provisionFails ? [await reopen()] : await Promise.all([reopen(), reopen()]);
if (!provisionFails) expect(results).toEqual(results.map(() => expect.objectContaining({ ok: true })));
else expect(results.every(result => !result.ok)).toBe(true);
if (!provisionFails && !revokeDuringProvision) expect(results.map(result => result.ok && result.reopened).sort()).toEqual([false, true]);
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
if (revokeDuringProvision) {
expect(results[0]).toMatchObject({ ok: true });
expect(await readFile(join(target, "dirty.txt"), "utf8")).toBe("DIRTY_REOPEN_BYTES");
}
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, projectWorkspaceId));
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
await rm(source, { recursive: true, force: true });
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
if (previousPrivacy === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacy;
}
}, 30_000);
it("refuses to reopen a workspace in another company", async () => {
const first = await seedCompanyProject();
const second = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({
companyId: first.companyId,
projectId: first.projectId,
projectWorkspaceId: first.projectWorkspaceId,
cwd,
});
const svc = executionWorkspaceService(db);
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
// The issue belongs to a different company than the workspace.
issue: { id: randomUUID(), companyId: second.companyId, projectId: second.projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.code).toBe("not_reopenable");
const row = await readWorkspace(workspaceId);
expect(row?.status).toBe("archived");
});
it("reports success without a rebuild when the workspace is already active", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({
companyId,
projectId,
projectWorkspaceId,
cwd,
status: "active",
generation: 5,
});
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4104 });
const svc = executionWorkspaceService(db);
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.reopened).toBe(false);
const row = await readWorkspace(workspaceId);
// No reopen ran, so the generation is unchanged.
expect(readExecutionWorkspaceLifecycleGeneration(row?.metadata as Record<string, unknown> | null)).toBe(5);
});
it("runs the destroy under the fence when the generation matches, and skips it after a reopen", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd, generation: 7 });
const svc = executionWorkspaceService(db);
// The captured generation matches, so the destroy callback runs.
const destroyMatches = vi.fn(async () => "destroyed");
const matched = await svc.fenceClosedWorkspaceDestruction({
workspaceId,
capturedGeneration: 7,
destroy: destroyMatches,
});
expect(matched.skippedReopened).toBe(false);
expect(destroyMatches).toHaveBeenCalledTimes(1);
// Simulate a reopen: raise the generation and mark the row active.
await db
.update(executionWorkspaces)
.set({
status: "active",
closedAt: null,
cleanupReason: null,
cleanupEligibleAt: null,
metadata: { [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 8 },
})
.where(eq(executionWorkspaces.id, workspaceId));
// A stale cleanup captured generation 7. The fence must skip the destroy.
const destroyStale = vi.fn(async () => "destroyed");
const skipped = await svc.fenceClosedWorkspaceDestruction({
workspaceId,
capturedGeneration: 7,
destroy: destroyStale,
});
expect(skipped.skippedReopened).toBe(true);
expect(destroyStale).not.toHaveBeenCalled();
});
it("clears the reopen-pending flag after an unconsumed reopen and stays idempotent", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4109 });
const svc = executionWorkspaceService(db);
// The reopen publishes the row as active and sets the reopen-pending flag.
const reopenResult = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(reopenResult.ok).toBe(true);
if (!reopenResult.ok) throw new Error("reopen failed");
const reopenGeneration = reopenResult.generation;
const reopenedRow = await readWorkspace(workspaceId);
expect(metadataHasReopenPendingConsumption(reopenedRow?.metadata as Record<string, unknown> | null)).toBe(true);
// The reopen stamps the time it set the flag, so the reaper can age a
// stranded flag out of the way after the grace period.
expect(readMetadataReopenPendingConsumptionSince(reopenedRow?.metadata as Record<string, unknown> | null))
.toBeInstanceOf(Date);
// The caller never consumed the reopen, so clear the flag at its generation.
const cleared = await svc.clearReopenPendingConsumptionForUnconsumedReopen({
workspaceId,
issue: { id: issueId, companyId },
actor: { agentId: null, actorType: "user" },
expectedGeneration: reopenGeneration,
});
expect(cleared.cleared).toBe(true);
const clearedRow = await readWorkspace(workspaceId);
// The flag is gone, so the terminal reaper can archive and reclaim the row.
expect(metadataHasReopenPendingConsumption(clearedRow?.metadata as Record<string, unknown> | null)).toBe(false);
// The clear removes the timestamp too, so no orphan key survives.
expect((clearedRow?.metadata as Record<string, unknown> | null)?.[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY])
.toBeUndefined();
// The row stays active, so a retried resume can still reuse the rebuilt worktree.
expect(clearedRow?.status).toBe("active");
const events = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, workspaceId));
expect(events.some((event) => event.action === "execution_workspace.reopen_unconsumed")).toBe(true);
// A second call finds no flag and does nothing.
const second = await svc.clearReopenPendingConsumptionForUnconsumedReopen({
workspaceId,
issue: { id: issueId, companyId },
actor: { agentId: null, actorType: "user" },
expectedGeneration: reopenGeneration,
});
expect(second.cleared).toBe(false);
const eventsAfter = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, workspaceId));
expect(eventsAfter.filter((event) => event.action === "execution_workspace.reopen_unconsumed").length).toBe(1);
});
it("does not clear a newer reopen's fence when a stale request presents an old generation", async () => {
const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject();
const cwd = await makeExistingDir();
const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd });
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4115 });
const svc = executionWorkspaceService(db);
const reopenResult = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId,
issue: { id: issueId, companyId, projectId },
actor: { agentId: null, actorType: "user" },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true },
});
expect(reopenResult.ok).toBe(true);
if (!reopenResult.ok) throw new Error("reopen failed");
const staleGeneration = reopenResult.generation;
// Simulate a newer reopen that raised the generation and installed its own
// fence with a fresh timestamp. This models two overlapping reopen requests.
const newerGeneration = staleGeneration + 1;
await db
.update(executionWorkspaces)
.set({
metadata: {
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: newerGeneration,
[EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY]: true,
[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY]: new Date().toISOString(),
},
})
.where(eq(executionWorkspaces.id, workspaceId));
// The stale request's response-end clear presents the old generation. It must
// not clear the newer reopen's live fence.
const staleClear = await svc.clearReopenPendingConsumptionForUnconsumedReopen({
workspaceId,
issue: { id: issueId, companyId },
actor: { agentId: null, actorType: "user" },
expectedGeneration: staleGeneration,
});
expect(staleClear.cleared).toBe(false);
const afterStale = await readWorkspace(workspaceId);
expect(metadataHasReopenPendingConsumption(afterStale?.metadata as Record<string, unknown> | null)).toBe(true);
expect(readExecutionWorkspaceLifecycleGeneration(afterStale?.metadata as Record<string, unknown> | null))
.toBe(newerGeneration);
// The newer owner clears its own fence at the matching generation.
const ownerClear = await svc.clearReopenPendingConsumptionForUnconsumedReopen({
workspaceId,
issue: { id: issueId, companyId },
actor: { agentId: null, actorType: "user" },
expectedGeneration: newerGeneration,
});
expect(ownerClear.cleared).toBe(true);
const afterOwner = await readWorkspace(workspaceId);
expect(metadataHasReopenPendingConsumption(afterOwner?.metadata as Record<string, unknown> | null)).toBe(false);
});
});