Files
PaperClipAI/server/src/__tests__/execution-workspaces-routes.test.ts
T
DottaandPaperclip f329b24ee1 feat: give tasks durable project-optional workspaces (#15749)
Give tasks durable company-scoped workspace bindings independent of project assignment, channel defaults and authorized repository preparation. Reuse verified input seeds and sparse output recovery while preserving trust, privacy and controller provisioning boundaries.

Full CI and E2E, Superagent, legacy checks and Greptile Apex 5/5 passed on reviewed head 753680b61c. All review findings are resolved; the PR records focused verification and historical acceptance limits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-12 20:45:18 -05:00

558 lines
19 KiB
TypeScript

import { sql } from "drizzle-orm";
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { executionWorkspaceRoutes } from "../routes/execution-workspaces.js";
vi.mock("../services/authorization.js", async (importActual) => ({
...await importActual<typeof import("../services/authorization.js")>(),
executionWorkspaceReadSqlCondition: async () => sql<boolean>`true`,
canActorReadExecutionWorkspace: async () => true,
}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({
list: vi.fn(),
listOverview: vi.fn(),
listSummaries: vi.fn(),
getById: vi.fn(),
getCloseReadiness: vi.fn(),
archiveWorkspaceUnderLifecycleLock: vi.fn(),
fenceClosedWorkspaceDestruction: vi.fn(),
reconcileExecutionWorkspaceBranch: vi.fn(),
update: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({
listForExecutionWorkspace: vi.fn(),
createRecorder: vi.fn(),
assertRuntimeControlAvailable: vi.fn(async () => undefined),
}));
const mockWorkspaceRuntimeLeaseService = vi.hoisted(() => ({
claim: vi.fn(async () => ({ outcome: "created", ownerKey: "issue:issue-1", lease: null, reclaimedFrom: null })),
release: vi.fn(async () => ({ released: false, ownerKey: null })),
get: vi.fn(async () => null),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockEnvironmentRuntimeService = vi.hoisted(() => ({
destroyReusableSandboxLeases: vi.fn(async () => undefined),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
heartbeatService: () => mockHeartbeatService,
logActivity: mockLogActivity,
workspaceOperationService: () => mockWorkspaceOperationService,
workspaceRuntimeLeaseService: () => mockWorkspaceRuntimeLeaseService,
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"],
}));
vi.mock("../services/environment-runtime.js", () => ({
environmentRuntimeService: () => mockEnvironmentRuntimeService,
}));
const mockWorkspaceRuntimeTeardown = vi.hoisted(() => ({
stopRuntimeServicesForExecutionWorkspace: vi.fn(async () => undefined),
cleanupExecutionWorkspaceArtifacts: vi.fn(async () => ({ cleaned: true, warnings: [] as string[] })),
}));
vi.mock("../services/workspace-runtime.js", async (importActual) => {
const actual = await importActual<typeof import("../services/workspace-runtime.js")>();
return {
...actual,
stopRuntimeServicesForExecutionWorkspace:
mockWorkspaceRuntimeTeardown.stopRuntimeServicesForExecutionWorkspace,
cleanupExecutionWorkspaceArtifacts: mockWorkspaceRuntimeTeardown.cleanupExecutionWorkspaceArtifacts,
};
});
function createApp(actor: Record<string, unknown> = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", executionWorkspaceRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("execution workspace routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockExecutionWorkspaceService.list.mockResolvedValue([]);
mockExecutionWorkspaceService.listOverview.mockResolvedValue({
items: [],
total: 0,
limit: 50,
offset: 0,
hasMore: false,
nextOffset: null,
});
mockExecutionWorkspaceService.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "Alpha",
mode: "isolated_workspace",
projectWorkspaceId: null,
},
]);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch.mockResolvedValue(null);
mockHeartbeatService.wakeup.mockResolvedValue(null);
});
it("uses summary mode for lightweight workspace lookups", async () => {
const res = await request(createApp())
.get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "workspace-1",
name: "Alpha",
mode: "isolated_workspace",
projectWorkspaceId: null,
},
]);
expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", {
readCondition: expect.anything(),
projectId: undefined,
projectWorkspaceId: undefined,
issueId: undefined,
status: undefined,
reuseEligible: true,
selectableForTask: false,
});
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
});
it("delegates bounded workspace overview queries", async () => {
const res = await request(createApp())
.get("/api/companies/company-1/workspace-overview?status=active,idle&limit=25&offset=10");
expect(res.status).toBe(200);
expect(res.body).toEqual({
items: [],
total: 0,
limit: 50,
offset: 0,
hasMore: false,
nextOffset: null,
});
expect(mockExecutionWorkspaceService.listOverview).toHaveBeenCalledWith("company-1", {
status: ["active", "idle"],
limit: 25,
offset: 10,
}, expect.anything());
});
it("rejects invalid workspace overview pagination", async () => {
const res = await request(createApp())
.get("/api/companies/company-1/workspace-overview?limit=1000");
expect(res.status).toBe(422);
expect(mockExecutionWorkspaceService.listOverview).not.toHaveBeenCalled();
});
it.each([
["forward", { mode: "forward" }],
["override", { mode: "override", reason: "operator break-glass" }],
["quarantine_restore", { mode: "quarantine_restore", reason: "rescue dirty branch" }],
])("rejects agent actors for %s branch reconciliation", async (_mode, body) => {
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
});
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_jwt",
runId: "run-1",
}))
.post("/api/execution-workspaces/workspace-1/reconcile-branch")
.send(body);
expect(res.status).toBe(403);
expect(mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("logs branch reconciliation activity after the service operation succeeds", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
});
mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch.mockResolvedValue({
workspace: {
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
branchName: "feature/current",
},
inspection: {
fingerprint: "workspace_incoherence:v1:sha256:test",
worktreePath: "/tmp/worktree",
repoRoot: "/tmp/repo",
fromBranch: "feature/recorded",
toBranch: "feature/current",
fromSha: "1111111",
toSha: "2222222",
ancestryVerdict: "ancestor",
cleanliness: "clean",
statusEntryCount: 0,
plainLanguageReason: "forward",
},
recoveryAction: {
id: "recovery-1",
},
auditCommentId: "comment-1",
});
const res = await request(createApp())
.post("/api/execution-workspaces/workspace-1/reconcile-branch")
.send({ mode: "forward" });
expect(res.status).toBe(200);
expect(mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch).toHaveBeenCalledWith("workspace-1", {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "execution_workspace.branch_reconciled",
entityType: "execution_workspace",
entityId: "workspace-1",
details: expect.objectContaining({
mode: "forward",
fromBranch: "feature/recorded",
toBranch: "feature/current",
fromSha: "1111111",
toSha: "2222222",
ancestryVerdict: "ancestor",
fingerprint: "workspace_incoherence:v1:sha256:test",
sourceIssueId: "issue-1",
auditCommentId: "comment-1",
recoveryActionId: "recovery-1",
}),
}));
});
it("accepts quarantine_restore, logs the rescue ref, and wakes the restored source issue", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
});
mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch.mockResolvedValue({
workspace: {
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
branchName: "feature/recorded",
},
inspection: {
fingerprint: "workspace_incoherence:v1:sha256:dirty",
worktreePath: "/tmp/worktree",
repoRoot: "/tmp/repo",
fromBranch: "feature/recorded",
toBranch: "feature/live",
fromSha: "1111111",
toSha: "2222222",
ancestryVerdict: "diverged",
cleanliness: "dirty",
statusEntryCount: 2,
plainLanguageReason: "dirty live branch",
},
recoveryAction: {
id: "recovery-1",
},
auditCommentId: "comment-1",
rescueRef: {
branchName: "paperclip/rescue/PAP-123/20260709T120000Z",
commitSha: "3333333",
fileCount: 2,
sourceAuditCommentId: "comment-0",
claimantAuditCommentId: null,
},
restoredSourceIssue: {
id: "issue-1",
companyId: "company-1",
status: "todo",
assigneeAgentId: "agent-1",
},
sourceIssueStatusChanged: true,
});
const res = await request(createApp())
.post("/api/execution-workspaces/workspace-1/reconcile-branch")
.send({ mode: "quarantine_restore" });
expect(res.status).toBe(200);
expect(mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch).toHaveBeenCalledWith("workspace-1", {
mode: "quarantine_restore",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "execution_workspace.branch_reconciled",
entityType: "execution_workspace",
entityId: "workspace-1",
details: expect.objectContaining({
mode: "quarantine_restore",
fingerprint: "workspace_incoherence:v1:sha256:dirty",
recoveryActionId: "recovery-1",
rescueRef: expect.objectContaining({
branchName: "paperclip/rescue/PAP-123/20260709T120000Z",
commitSha: "3333333",
}),
sourceIssueStatus: "todo",
}),
}));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith("agent-1", expect.objectContaining({
source: "automation",
reason: "issue_recovery_action_restored",
payload: expect.objectContaining({
issueId: "issue-1",
recoveryActionId: "recovery-1",
executionWorkspaceId: "workspace-1",
rescueRef: "paperclip/rescue/PAP-123/20260709T120000Z",
mutation: "execution_workspace_quarantine_restore",
}),
contextSnapshot: expect.objectContaining({
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "issue_recovery_action_restored",
source: "execution_workspace.quarantine_restore",
recoveryActionId: "recovery-1",
executionWorkspaceId: "workspace-1",
rescueRef: "paperclip/rescue/PAP-123/20260709T120000Z",
}),
}));
});
it("wakes a restored in_review agent participant after quarantine_restore", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
});
mockExecutionWorkspaceService.reconcileExecutionWorkspaceBranch.mockResolvedValue({
workspace: {
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
branchName: "feature/recorded",
},
inspection: {
fingerprint: "workspace_incoherence:v1:sha256:dirty",
worktreePath: "/tmp/worktree",
repoRoot: "/tmp/repo",
fromBranch: "feature/recorded",
toBranch: "feature/live",
fromSha: "1111111",
toSha: "2222222",
ancestryVerdict: "diverged",
cleanliness: "dirty",
statusEntryCount: 2,
plainLanguageReason: "dirty live branch",
},
recoveryAction: {
id: "recovery-1",
},
auditCommentId: "comment-1",
rescueRef: null,
restoredSourceIssue: {
id: "issue-1",
companyId: "company-1",
status: "in_review",
assigneeAgentId: "reviewer-agent-1",
},
sourceIssueStatusChanged: true,
});
const res = await request(createApp())
.post("/api/execution-workspaces/workspace-1/reconcile-branch")
.send({ mode: "quarantine_restore" });
expect(res.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
details: expect.objectContaining({
sourceIssueStatus: "in_review",
}),
}));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith("reviewer-agent-1", expect.objectContaining({
reason: "issue_recovery_action_restored",
payload: expect.objectContaining({
issueId: "issue-1",
mutation: "execution_workspace_quarantine_restore",
}),
contextSnapshot: expect.objectContaining({
issueId: "issue-1",
wakeReason: "issue_recovery_action_restored",
source: "execution_workspace.quarantine_restore",
}),
}));
});
it("returns 409 and skips destructive cleanup when the archive hits a reopen-pending workspace", async () => {
// A reopen published the workspace active while its source issue is still
// terminal. The archive control must return 409 before any lease teardown,
// runtime-service stop, or artifact cleanup, so it never removes the rebuilt
// worktree.
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
status: "active",
mode: "isolated_workspace",
});
mockExecutionWorkspaceService.getCloseReadiness.mockResolvedValue({
state: "ready",
blockingReasons: [],
});
mockExecutionWorkspaceService.archiveWorkspaceUnderLifecycleLock.mockResolvedValue({
outcome: "reopen_pending",
});
const res = await request(createApp())
.patch("/api/execution-workspaces/workspace-1")
.send({ status: "archived" });
expect(res.status).toBe(409);
expect(mockExecutionWorkspaceService.archiveWorkspaceUnderLifecycleLock).toHaveBeenCalledTimes(1);
// The destruction fence never runs, so no worktree is removed.
expect(mockExecutionWorkspaceService.fenceClosedWorkspaceDestruction).not.toHaveBeenCalled();
});
it("destroys the reusable sandbox leases inside the destruction fence when the archive wins", async () => {
// The archive wins the lifecycle race. The fence runs the destroy callback,
// so the reusable sandbox lease teardown runs with the worktree teardown.
const archivedWorkspace = {
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
status: "archived",
mode: "isolated_workspace",
projectWorkspaceId: null,
projectId: null,
cwd: "/tmp/worktree",
};
mockExecutionWorkspaceService.getById.mockResolvedValue({
...archivedWorkspace,
status: "active",
});
mockExecutionWorkspaceService.getCloseReadiness.mockResolvedValue({
state: "ready",
blockingReasons: [],
});
mockExecutionWorkspaceService.archiveWorkspaceUnderLifecycleLock.mockResolvedValue({
outcome: "archived",
workspace: archivedWorkspace,
capturedGeneration: 3,
});
mockExecutionWorkspaceService.fenceClosedWorkspaceDestruction.mockImplementation(
async ({ destroy }: { destroy: () => Promise<unknown> }) => ({
skippedReopened: false,
result: await destroy(),
}),
);
const res = await request(createApp())
.patch("/api/execution-workspaces/workspace-1")
.send({ status: "archived" });
expect(res.status).toBe(200);
// The lease teardown runs inside the fence, so it uses the closed-workspace
// failure reason and targets the archived row.
expect(mockEnvironmentRuntimeService.destroyReusableSandboxLeases).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-1",
executionWorkspaceId: "workspace-1",
failureReason: "execution_workspace_closed",
}),
);
});
it("keeps the reusable sandbox leases when a reopen makes the fence skip the archive teardown", async () => {
// A reopen raised the lifecycle generation after the archive captured its
// own generation. The fence skips the destroy callback and keeps the
// reopened row, so the lease teardown must not run. Before the fix the lease
// teardown ran before the fence, so an overlapping reopen lost its leases.
const archivedWorkspace = {
id: "workspace-1",
companyId: "company-1",
sourceIssueId: "issue-1",
status: "archived",
mode: "isolated_workspace",
projectWorkspaceId: null,
projectId: null,
cwd: "/tmp/worktree",
};
mockExecutionWorkspaceService.getById.mockResolvedValue({
...archivedWorkspace,
status: "active",
});
mockExecutionWorkspaceService.getCloseReadiness.mockResolvedValue({
state: "ready",
blockingReasons: [],
});
mockExecutionWorkspaceService.archiveWorkspaceUnderLifecycleLock.mockResolvedValue({
outcome: "archived",
workspace: archivedWorkspace,
capturedGeneration: 3,
});
// The fence detects the reopen and never runs the destroy callback.
mockExecutionWorkspaceService.fenceClosedWorkspaceDestruction.mockResolvedValue({
skippedReopened: true,
});
const res = await request(createApp())
.patch("/api/execution-workspaces/workspace-1")
.send({ status: "archived" });
expect(res.status).toBe(200);
expect(mockExecutionWorkspaceService.fenceClosedWorkspaceDestruction).toHaveBeenCalledTimes(1);
// The reopen keeps its reusable leases because the fence skipped the destroy.
expect(mockEnvironmentRuntimeService.destroyReusableSandboxLeases).not.toHaveBeenCalled();
});
});