mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 21:35:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents discover reusable workspaces through `list_workspaces`. > - The tool returned every authorized workspace in one result. > - A large inventory exceeded the durable command journal limit. The result stayed pending, and session cleanup blocked the next turn. > - This pull request limits discovery in SQL and returns explicit continuation metadata. > - Agents can inspect every authorized workspace without sending the full inventory in one command. ## Linked Issues or Issue Description Related: #15749 introduced the task workspace tools. No duplicate pagination fix was found. **What happened?** A native workspace discovery call returned a multi-megabyte inventory. Persisting its result failed with `command_payload_too_large`. The preceding answer could still be saved, but cleanup retained the unsettled tool call. A later turn failed before provider work with `native_session_cleanup_quarantined`. **Expected behavior** Workspace discovery must return a manageable page. Agents must be able to request the remaining authorized rows. Existing visibility checks and journal integrity checks must stay effective. **Steps to reproduce** 1. Create 2,000 task-selectable workspaces with ordinary repository paths. 2. Call `list_workspaces` from an admitted native run. 3. On the old code, the real API regression returns 869,781 bytes. The command journal allows about 510 KiB. 4. On the repaired code, follow `nextCursor` to inspect all rows. Pages contain at most 100 rows and fit within 128 KiB. **Paperclip version or commit** Reproduced on source commit `7351a9d74f` before the repair. **Deployment mode** Isolated source checkout with real PostgreSQL and authenticated native agent APIs. Hosted deployment and incident recovery remain separate. ## What Changed - Validate workspace list limits and UUID cursors with a JSON Schema pattern supported by strict validators. Return `workspaces`, `limit`, `returnedCount`, `hasMore`, and `nextCursor`. - Apply company, visibility, lifecycle, and cursor conditions before the SQL limit. Order pages by workspace ID. Fetch one extra row to detect continuation without a count query. - Fit encoded pages within 128 KiB, including JSON escaping and Unicode. If one summary cannot fit, return explicit string previews with `truncatedFields`. The authorized detail API retains its full record. - Keep existing unpaged summary callers on their array response and recent-use order. - Update the native tool descriptor, generated contracts, catalog fixture, and execution documentation. - Add regression coverage for large inventories, complete pagination, stable pages, private and foreign rows, invalid inputs, and legacy summary behavior. Add an opt-in two-turn live Codex acceptance check. ## Verification - Red before repair: `task-workspace-api.test.ts -t "pages a large authorized workspace inventory"` failed because the result was 869,781 bytes instead of below 128 KiB. The transport regression also failed at 2,284,671 bytes. - Green after repair and review changes: 120 tests passed across the workspace API, transport, routes, native authority, coordinator, historical-question, and Codex vertical-slice suites. They cover complete discovery, byte-sized pages, oversized individual summaries, full detail retrieval, private/foreign/lifecycle exclusions, and strict schema compatibility. - Journal safety: two focused durable control-plane tests passed. Oversized result rejection and lossless valid result delivery remain effective. - Tool catalog and reconciliation: 30 tests passed. - `pnpm -r typecheck`: passed. - `pnpm build`: passed again on final commit `bc10880da6`. Server typecheck also passed after the review changes. The original affected suites passed on the rebased first commit (31 tests), and the expanded review regression set passed afterward (120 tests). - Real provider acceptance passed again on final commit `bc10880da6`: local macOS arm64, rebuilt native Runner, `gpt-6.1-sol`, high effort, per-turn lifecycle, and 2,000 persisted workspaces. Both turns succeeded. Persisted call inputs and results prove that the second page uses the first result’s cursor, both calls succeed, their IDs do not overlap, and the encoded results stay within 128 KiB. The second turn kept the provider conversation and file. Both task locks were clear after completion, and no recovered execution failure was recorded. Duration: 166.92 seconds. - Full local `pnpm test:run` stopped in the chat shard: 330 tests passed and one Slack registration test failed with `socket hang up`. That exact test passed in isolation without changes, and the final-head CI chat shards passed. A broader local managed-Slack follow-up produced no result after about eight minutes and was interrupted; it is not counted as a pass. The original full-suite failure remains recorded. - The first CI head failed native tool-schema compilation because strict validators did not recognize `format: uuid`. The revised descriptor uses a UUID pattern. The previously failing coordinator, native vertical-slice, authority, and history suites now pass locally. The stronger live cursor-receipt acceptance check passed. Final-head CI completed on `bc10880da6`: 57 checks passed and five optional checks were skipped, with no failures or pending checks. Greptile reviewed that commit at 5/5; both prior findings are resolved. - Dependency cost: paged discovery runs the existing visibility-scoped summary query with an ID cursor and at most 101 returned rows. It adds no count query, filesystem scan, provider call, or background worker. ## Risks - Native `list_workspaces` now returns a page object instead of an array. Individual summaries can be explicit previews when a single record exceeds the byte budget. Its advertised descriptor and generated contracts change together. Agents must follow `nextCursor` until null for a complete inventory. - Each page rechecks current visibility. Concurrent insertions, deletions, or access changes require a fresh scan when a complete point-in-time inventory is needed. - No migration or journal-limit change. An existing cleanup quarantine still requires normal recovery after deployment; this PR does not bypass it. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, shell tools, code editing, and test execution. The exact served model revision and context window are not exposed in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
575 lines
20 KiB
TypeScript
575 lines
20 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("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("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();
|
|
});
|
|
});
|