mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Mine inbox is the first page a board user opens to see the tasks that need them > - The page loaded the company's full execution-workspace summary list and kept task rows hidden until that list arrived > - On a company with thousands of workspaces, that read checks each workspace row against the privacy predicate. It was the slowest request on the page > - The inbox needs workspace names only for the workspaces its rows reference, and only when the view shows workspace data > - This pull request adds an `ids` filter to the summary read, makes the inbox request only referenced workspaces, and stops rows from waiting for workspace data when the view does not show it > - The benefit is that Mine rows appear when the task data arrives, and the workspace read becomes a small bounded query ## Linked Issues or Issue Description **What existing behavior does this improve?** The Mine inbox (`/:companyPrefix/issues?view=mine`) shows its task rows late on companies with many execution workspaces. **Subsystem affected** Board UI inbox and the execution-workspace summary API. **Current behavior** The page calls `GET /api/companies/:id/execution-workspaces?summary=true` with no filter. On a large company this returned 8,758 rows (2.5 MB) and took 12.3 s on the server. The reveal gate included this read, so rows appeared at 13.4 s although `inbox/mine` arrived at 1.6 s. **Proposed behavior** The inbox requests summaries only for the workspaces its rows reference. Rows appear when the Mine task data arrives, unless the view shows workspace data. **Reason and benefit** The workspace read becomes a small bounded query, and it leaves the critical path for the default Mine view. **Breaking changes** None. The `ids` parameter is optional and additive. ## What Changed - `packages/shared`: add `executionWorkspaceSummaryIdsQuerySchema` (comma-separated UUIDs, 1–100 distinct values) and `EXECUTION_WORKSPACE_SUMMARY_IDS_MAX`. - `server`: `GET /companies/:companyId/execution-workspaces` accepts `ids`. Bad input returns 422. The service adds `inArray(execution_workspaces.id, ids)`. The company scope and the actor read condition still apply, so privacy does not change. - `ui/src/api/execution-workspaces.ts`: `listSummaries` accepts `ids`. New `listSummariesByIds` reads ids in chunks of 100 and concatenates the results. - `ui/src/pages/Inbox.tsx`: the workspace query is keyed on the sorted distinct `executionWorkspaceId`s of the rows the inbox holds (Mine, touched, context and remote search issues). It uses `keepPreviousData` and runs only when isolated workspaces are on and at least one id exists. - Reveal gate: rows wait for the workspace read only when workspace data changes what the rows show. This is workspace grouping, a visible Workspace column, or an active search (workspace names are searchable). Otherwise rows reveal without it. - Tests: route ids pass-through and 422 cases (non-uuid, empty, separators only, more than 100); service test for the id filter with company scope and the read condition; UI api chunking test; Inbox tests for "rows reveal while the workspace read is pending when no workspace column or grouping is active", rows waiting when the Workspace column is visible, and "shows the resolved isolated workspace name" with the id-filtered call. Follow-ups (not changed here, they still read the unfiltered list): - `ui/src/pages/LegacyInbox.tsx:772` - `ui/src/components/IssuesList.tsx:985` - `ui/src/components/LegacyIssuesList.tsx:888` - `ui/src/components/NewIssueDialog.tsx:570` (filtered by project and reuse, but not by ids) ## Verification Focused checks on this revision: - `cd server && npx vitest run src/__tests__/execution-workspaces-routes.test.ts src/__tests__/execution-workspaces-service.test.ts -t "summar|ids"`: 14 passed (embedded Postgres ran the service test). - `cd ui && npx vitest run src/api/execution-workspaces.test.ts src/pages/Inbox.test.tsx`: all passed. - `pnpm --filter @paperclipai/server --filter @paperclipai/ui --filter @paperclipai/shared typecheck`: passed. - `pnpm check:token-gates`: clean. - `pnpm verify` selected 2,481 Vitest files (the shared validator change reaches most suites). I stopped it at a 25-minute limit on a heavily loaded shared host. Before the limit, three server tests outside this change failed: `agent-commentary-skills` (skill mount), `agent-config-read-access` (24.8 s timeout) and `agent-directory-working-copies`. Required CI supplies the full coverage. - Review fix `fc3f82dae0`: when workspace data is visible, the reveal gate also waits while `keepPreviousData` supplies the previous id set's summaries. A new test switches Mine → Recent to a new workspace and checks the Recent row stays hidden until its workspace loads. It fails without the change. - Follow-up commits `bf34f84823` and `8fc743b5ca` add a 100-row Mine test with a counted workspace id scan. It fails with the old `= []` defaults and passes with the stable `NO_ISSUES` default. Performance (local, synthetic fixture): - Instance: isolated worktree instance, `authenticated` mode, signed in as a non-admin company member so the real workspace privacy predicate runs. Production UI bundle (`PAPERCLIP_UI_DEV_MIDDLEWARE=false`). Headless Chromium 1440×900, a new browser context per run (cold HTTP cache and cold query cache). macOS, Apple Silicon, with other local sessions running. - Fixture: 8,750 execution workspaces (each with a source task and a privacy-source key), 38,750 tasks, 150 Mine tasks; the Mine snapshot returns 100 rows that reference 100 workspaces. - Finish event: the first `[data-slot="task-row"]` is in the DOM. | Measurement | Before (`02bfe71a6b`) | After | | --- | --- | --- | | Cold time to rows, median of 7 | 1,209 ms (1,046–1,312) | 702 ms (567–1,209) | | Workspace summary read, server wait (time to first byte), median of 7 | 496 ms unfiltered, 2.0 MB | 18 ms with 100 ids, 22 KB | The difference of medians is 507 ms. This is close to the unfiltered read's wait on this fixture (about 550–590 ms in the browser runs). The runs were sequential batches, not alternating pairs. Limits: this fixture does not reproduce the 12.3 s server time seen on the development instance. Here the unfiltered read cost 0.5–1.7 s and grew with the company's task count. The browser gain on the original instance should be close to that instance's unfiltered read time, but I did not measure it there. ## Risks - Low risk. The `ids` filter only narrows the existing query. It does not change the read condition or the company scope. - A row whose workspace is not readable by the actor shows no workspace name, as before. - When the Workspace column or grouping is visible, rows still wait for the workspace read. That read is now small. - When the id set changes, the query refetches all chunks. Before a view first reveals, rows wait for the new summaries if workspace data is visible. After that, the previous names stay visible while a refresh loads. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edit, headless browser). Extended reasoning on. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
600 lines
21 KiB
TypeScript
600 lines
21 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", {
|
|
ids: undefined,
|
|
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("bounds summary discovery after company and visibility checks", async () => {
|
|
const cursor = "00000000-0000-4000-8000-000000000001";
|
|
const res = await request(createApp())
|
|
.get(`/api/companies/company-1/execution-workspaces?summary=true&selectableForTask=true&limit=51&cursor=${cursor}`);
|
|
expect(res.status).toBe(200);
|
|
expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
|
readCondition: expect.anything(), selectableForTask: true,
|
|
}), { limit: 51, cursor });
|
|
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["limit=1000", "limit=0", "limit=1.5", "cursor=invalid"])("rejects invalid summary pagination (%s)", async query => {
|
|
const res = await request(createApp()).get(`/api/companies/company-1/execution-workspaces?summary=true&${query}`);
|
|
expect(res.status).toBe(422);
|
|
expect(mockExecutionWorkspaceService.listSummaries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes distinct requested ids to summary reads with the read condition", async () => {
|
|
const first = "00000000-0000-4000-8000-000000000001";
|
|
const second = "00000000-0000-4000-8000-000000000002";
|
|
const res = await request(createApp())
|
|
.get(`/api/companies/company-1/execution-workspaces?summary=true&ids=${second},${first},${second}`);
|
|
expect(res.status).toBe(200);
|
|
expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
|
ids: [second, first],
|
|
readCondition: expect.anything(),
|
|
}));
|
|
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["a non-uuid id", "ids=not-a-uuid"],
|
|
["an empty list", "ids="],
|
|
["only separators", "ids=,,"],
|
|
["more than 100 ids", `ids=${Array.from({ length: 101 }, (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`).join(",")}`],
|
|
])("rejects summary ids with %s", async (_label, query) => {
|
|
const res = await request(createApp()).get(`/api/companies/company-1/execution-workspaces?summary=true&${query}`);
|
|
expect(res.status).toBe(422);
|
|
expect(mockExecutionWorkspaceService.listSummaries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|