mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
[codex] Split backend control-plane QoL slice (#4700)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, so backend task ownership, recovery, review visibility, and company-scoped limits need to stay enforceable without UI-only coupling. > - Closed PR #4692 bundled those backend changes with UI workflow, docs, skills, workflow, and lockfile churn. > - PAP-2694 asks for a clean backend/control-plane slice from that closed branch. > - This branch starts from current `master` and mines only the `cli`, `packages/db`, `packages/shared`, and `server` contracts/tests needed for the backend behavior. > - It explicitly excludes UI workflow/performance work, `.github/workflows/pr.yml`, `pnpm-lock.yaml`, docs, skills, package-script, adapter UI build-config, and perf fixture script changes; the only UI files are fixture/test updates required by the tightened shared `Company` contract. > - The benefit is a smaller reviewable PR that preserves the control-plane fixes while staying under Greptile s 100-file review limit. ## What Changed - Added company-scoped attachment-size limits through DB schema/migrations, shared company portability contracts, CLI import/export coverage, and server attachment upload enforcement. - Added productivity review service/API behavior for no-comment streak, long-active, and high-churn review issues, including request-depth clamping and issue summary exposure. - Hardened issue ownership and recovery/control-plane paths: peer-agent mutation denial, issue tree pause/resume behavior, stranded recovery origins, and related activity/test coverage. - Preserved related backend contract updates for routine timestamp variables and managed agent instruction bundles because they live in shared/server contracts from the source branch. - Addressed Greptile feedback by making `Company.attachmentMaxBytes` non-optional, simplifying review request-depth clamping, fixing the migration final newline, and enforcing the process-level attachment cap as the final ceiling for uploads. - Added minimal company fixtures needed for repo-wide typecheck/build and kept the PR to 66 changed files with forbidden/non-slice paths excluded. ## Verification - `pnpm install --frozen-lockfile` - `git diff --check origin/master..HEAD` - `git diff --name-only origin/master..HEAD | wc -l` -> 66 files - `git diff --name-only origin/master..HEAD -- .github/workflows/pr.yml pnpm-lock.yaml package.json doc skills .agents scripts packages/adapters` -> no output - `pnpm exec vitest run --config vitest.config.ts packages/shared/src/validators/issue.test.ts packages/shared/src/routine-variables.test.ts packages/shared/src/adapter-types.test.ts cli/src/__tests__/company-import-export-e2e.test.ts cli/src/__tests__/company.test.ts server/src/__tests__/productivity-review-service.test.ts server/src/__tests__/issue-tree-control-service.test.ts server/src/__tests__/issue-tree-control-routes.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/issue-attachment-routes.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/issues-service.test.ts` -> 12 files, 147 tests passed - `pnpm exec vitest run --config vitest.config.ts cli/src/__tests__/company-delete.test.ts cli/src/__tests__/company-import-export-e2e.test.ts server/src/__tests__/productivity-review-service.test.ts` -> 3 files, 18 tests passed - `pnpm exec vitest run --config vitest.config.ts server/src/__tests__/issue-attachment-routes.test.ts` -> 1 file, 6 tests passed - `pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter paperclipai typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck && pnpm --filter @paperclipai/ui build` ## Risks - Includes migrations `0073_shiny_salo.sql` and `0074_striped_genesis.sql`; merge ordering matters if another PR adds migrations first. - This is intentionally backend-only apart from fixture/test updates forced by shared type correctness; UI affordances from PR #4692 are not present here and should land in separate UI slices. - The worktree install emitted plugin SDK bin-link warnings for unbuilt plugin packages, but the targeted tests and package typechecks completed successfully. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected; check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled terminal/GitHub workflow. Exact runtime context window was not exposed by the harness. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d9f540c331
commit
1991ec9d6f
66 changed files with 34186 additions and 148 deletions
|
|
@ -481,8 +481,11 @@ describe.sequential("agent skill routes", () => {
|
|||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are QA.",
|
||||
adapterConfig: {},
|
||||
instructionsBundle: {
|
||||
files: {
|
||||
"AGENTS.md": "You are QA.",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -504,6 +507,26 @@ describe.sequential("agent skill routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("rejects legacy prompt templates for directly created local agents", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
instructionsFilePath: "/tmp/existing/AGENTS.md",
|
||||
promptTemplate: "You are QA.",
|
||||
bootstrapPromptTemplate: "Bootstrap QA.",
|
||||
},
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body.error).toContain("New agents must use instructionsBundle/AGENTS.md");
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
|
|
@ -652,8 +675,11 @@ describe.sequential("agent skill routes", () => {
|
|||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are QA.",
|
||||
adapterConfig: {},
|
||||
instructionsBundle: {
|
||||
files: {
|
||||
"AGENTS.md": "You are QA.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -675,4 +701,24 @@ describe.sequential("agent skill routes", () => {
|
|||
| undefined;
|
||||
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects legacy prompt templates for hire approval payloads", async () => {
|
||||
const res = await request(await createApp(createDb(true)))
|
||||
.post("/api/companies/company-1/agent-hires")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
instructionsFilePath: "/tmp/existing/AGENTS.md",
|
||||
promptTemplate: "You are QA.",
|
||||
bootstrapPromptTemplate: "Bootstrap QA.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body.error).toContain("New agents must use instructionsBundle/AGENTS.md");
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ const mockEnvironmentService = vi.hoisted(() => ({
|
|||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueReferenceService = vi.hoisted(() => ({
|
||||
deleteDocumentSource: vi.fn(async () => undefined),
|
||||
diffIssueReferenceSummary: vi.fn(() => ({
|
||||
|
|
@ -51,6 +55,7 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
|||
vi.mock("../services/index.js", () => ({
|
||||
projectService: () => mockProjectService,
|
||||
issueService: () => mockIssueService,
|
||||
companyService: () => mockCompanyService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
issueReferenceService: () => mockIssueReferenceService,
|
||||
logActivity: mockLogActivity,
|
||||
|
|
@ -158,6 +163,11 @@ describe.sequential("execution environment route guards", () => {
|
|||
mockIssueService.update.mockReset();
|
||||
mockIssueService.getByIdentifier.mockReset();
|
||||
mockIssueService.assertCheckoutOwner.mockReset();
|
||||
mockCompanyService.getById.mockReset();
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockIssueReferenceService.deleteDocumentSource.mockClear();
|
||||
mockIssueReferenceService.diffIssueReferenceSummary.mockClear();
|
||||
|
|
|
|||
|
|
@ -672,7 +672,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const issueChain = Array.from({ length: 17 }, () => randomUUID());
|
||||
const deepDescendantIssueId = issueChain.at(-1)!;
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
|
|
@ -705,15 +706,15 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
...issueChain.map((issueId, index) => ({
|
||||
id: issueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Paused child",
|
||||
parentId: index === 0 ? rootIssueId : issueChain[index - 1],
|
||||
title: `Paused desc ${index + 1}`,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
})),
|
||||
]);
|
||||
const [hold] = await db
|
||||
.insert(issueTreeHolds)
|
||||
|
|
@ -731,8 +732,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: { issueId: childIssueId },
|
||||
contextSnapshot: { issueId: childIssueId, wakeReason: "issue_blockers_resolved" },
|
||||
payload: { issueId: deepDescendantIssueId },
|
||||
contextSnapshot: { issueId: deepDescendantIssueId, wakeReason: "issue_blockers_resolved" },
|
||||
});
|
||||
|
||||
expect(blockedWake).toBeNull();
|
||||
|
|
@ -742,7 +743,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
reason: agentWakeupRequests.reason,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${childIssueId}`)
|
||||
.where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${deepDescendantIssueId}`)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" });
|
||||
|
||||
|
|
@ -750,7 +751,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
await db.insert(issueComments).values({
|
||||
id: childCommentId,
|
||||
companyId,
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
authorUserId: "board-user",
|
||||
body: "Please respond while this hold is active.",
|
||||
});
|
||||
|
|
@ -759,7 +760,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: childIssueId, commentId: childCommentId },
|
||||
payload: { issueId: deepDescendantIssueId, commentId: childCommentId },
|
||||
requestedByActorType: "agent",
|
||||
requestedByActorId: agentId,
|
||||
});
|
||||
|
|
@ -769,11 +770,11 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: childIssueId, commentId: childCommentId },
|
||||
payload: { issueId: deepDescendantIssueId, commentId: childCommentId },
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "board-user",
|
||||
contextSnapshot: {
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
commentId: childCommentId,
|
||||
wakeCommentId: childCommentId,
|
||||
wakeReason: "issue_commented",
|
||||
|
|
|
|||
|
|
@ -472,6 +472,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
retryReason?: "assignment_recovery" | "issue_continuation_needed" | null;
|
||||
assignToUser?: boolean;
|
||||
activePauseHold?: boolean;
|
||||
livenessState?: "completed" | "advanced" | "plan_only" | "empty_response" | "blocked" | "failed" | "needs_followup" | null;
|
||||
runErrorCode?: string | null;
|
||||
runError?: string | null;
|
||||
}) {
|
||||
|
|
@ -545,6 +546,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
error: input.runStatus === "succeeded"
|
||||
? null
|
||||
: ("runError" in input ? input.runError : "run failed before issue advanced"),
|
||||
livenessState: input.livenessState ?? null,
|
||||
});
|
||||
|
||||
await db.insert(issues).values([
|
||||
|
|
@ -1417,6 +1419,59 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["failed", "adapter_failed"],
|
||||
["failed", "process_lost"],
|
||||
["timed_out", "adapter_timed_out"],
|
||||
] as const)(
|
||||
"re-enqueues stranded in-progress work after a %s/%s run before escalating",
|
||||
async (runStatus, runErrorCode) => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus,
|
||||
runErrorCode,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.dispatchRequeued).toBe(0);
|
||||
expect(result.continuationRequeued).toBe(1);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.contextSnapshot as Record<string, unknown> | undefined).toMatchObject({
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
retryReason: "issue_continuation_needed",
|
||||
retryOfRunId: runId,
|
||||
source: "issue.continuation_recovery",
|
||||
});
|
||||
|
||||
const recoveries = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "stranded_issue_recovery"),
|
||||
eq(issues.originId, issueId),
|
||||
),
|
||||
);
|
||||
expect(recoveries).toHaveLength(0);
|
||||
|
||||
if (retryRun?.id) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("still re-enqueues stranded assigned todo recovery when an old queued wake exists", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
|
|
@ -2055,18 +2110,21 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("re-enqueues continuation when the latest automatic continuation succeeded without closing the issue", async () => {
|
||||
it("records productive continuation instead of recovery when the latest automatic continuation succeeded", async () => {
|
||||
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
retryReason: "issue_continuation_needed",
|
||||
livenessState: "advanced",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(1);
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.productiveContinuationObserved).toBe(1);
|
||||
expect(result.successfulContinuationObserved).toBe(0);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
expect(result.issueIds).toEqual([]);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("in_progress");
|
||||
|
|
@ -2078,14 +2136,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs.map((row) => row.id)).toEqual([runId]);
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.id).toBeTruthy();
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
|
||||
if (retryRun) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ function registerModuleMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockDocumentService = vi.hoisted(() => ({
|
||||
upsertIssueDocument: vi.fn(),
|
||||
}));
|
||||
|
|
@ -94,6 +98,7 @@ function registerRouteMocks() {
|
|||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companyService: () => mockCompanyService,
|
||||
documentService: () => mockDocumentService,
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
|
|
@ -244,6 +249,7 @@ describe("agent issue mutation checkout ownership", () => {
|
|||
mockAgentService.getById.mockReset();
|
||||
mockAgentService.list.mockReset();
|
||||
mockAgentService.resolveByReference.mockReset();
|
||||
mockCompanyService.getById.mockReset();
|
||||
mockIssueService.addComment.mockReset();
|
||||
mockIssueService.assertCheckoutOwner.mockReset();
|
||||
mockIssueService.getAttachmentById.mockReset();
|
||||
|
|
@ -276,6 +282,7 @@ describe("agent issue mutation checkout ownership", () => {
|
|||
makeAgent(peerAgentId),
|
||||
]);
|
||||
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: null });
|
||||
mockCompanyService.getById.mockResolvedValue({ id: companyId, issuePrefix: "PAP" });
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockIssueService.getByIdentifier.mockResolvedValue(null);
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
|
|
@ -430,18 +437,20 @@ describe("agent issue mutation checkout ownership", () => {
|
|||
expect(mockIssueService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows same-company agent mutations when the issue is not in progress", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }),
|
||||
...patch,
|
||||
}));
|
||||
it.each([
|
||||
["todo", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Todo update" })],
|
||||
["todo", "comment", (app: express.Express) => request(app).post(`/api/issues/${issueId}/comments`).send({ body: "Todo noise" })],
|
||||
["blocked", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked update" })],
|
||||
])("rejects peer agent %s issue %s mutations outside active checkout ownership", async (status, _kind, sendRequest) => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({ status: status as "todo" | "blocked", assigneeAgentId: ownerAgentId }));
|
||||
|
||||
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Todo update" });
|
||||
const res = await sendRequest(await createApp(peerActor()));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
|
||||
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows same-company agent mutations on unassigned in-progress issues", async () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
createAttachment: vi.fn(),
|
||||
getAttachmentById: vi.fn(),
|
||||
}));
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
|
|
@ -39,6 +42,7 @@ function registerRouteMocks() {
|
|||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
companyService: () => mockCompanyService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
|
|
@ -166,6 +170,27 @@ function makeAttachment(contentType: string, originalFilename: string) {
|
|||
};
|
||||
}
|
||||
|
||||
describe("normalizeIssueAttachmentMaxBytes", () => {
|
||||
it("keeps the process-level attachment cap as the final cap", async () => {
|
||||
const previous = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
||||
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = "5";
|
||||
vi.resetModules();
|
||||
try {
|
||||
const { normalizeIssueAttachmentMaxBytes } = await import("../attachment-types.js");
|
||||
expect(normalizeIssueAttachmentMaxBytes(null)).toBe(5);
|
||||
expect(normalizeIssueAttachmentMaxBytes(10)).toBe(5);
|
||||
expect(normalizeIssueAttachmentMaxBytes(3)).toBe(3);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
||||
} else {
|
||||
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = previous;
|
||||
}
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue attachment routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
|
|
@ -180,6 +205,10 @@ describe("issue attachment routes", () => {
|
|||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 1024 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts zip uploads for issue attachments", async () => {
|
||||
|
|
@ -215,6 +244,50 @@ describe("issue attachment routes", () => {
|
|||
expect(res.body.contentType).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("enforces the process-level issue attachment limit even when the company limit allows more", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "large.bin"));
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.alloc(10 * 1024 * 1024 + 1), {
|
||||
filename: "large.bin",
|
||||
contentType: "application/octet-stream",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Attachment exceeds 10485760 bytes");
|
||||
expect(storage.__calls.putFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enforces the configured per-company issue attachment limit", async () => {
|
||||
const storage = createStorageService();
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 4,
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.from("large"), { filename: "large.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Attachment exceeds 4 bytes");
|
||||
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves html attachments as downloads with nosniff", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html"));
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ function registerServiceMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ function registerModuleMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||
documentService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ vi.mock("../services/routines.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
|
|
@ -477,7 +480,7 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
));
|
||||
});
|
||||
|
||||
it("does not implicitly reopen closed issues via POST comments for agent-authored comments", async () => {
|
||||
it("rejects non-assignee agent POST comments on closed issues", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
|
|
@ -500,11 +503,10 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "hello" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.update).not.toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
{ status: "todo" },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -625,7 +627,7 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
));
|
||||
});
|
||||
|
||||
it("does not implicitly reopen closed issues via the PATCH comment path for agent-authored comments", async () => {
|
||||
it("rejects non-assignee agent PATCH comments on closed issues", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
|
|
@ -652,11 +654,10 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).not.toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({ status: "todo" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -874,7 +875,7 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
.send({ body: "restart someone else's work", resume: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Agent cannot request follow-up for another agent's issue");
|
||||
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ function registerModuleMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => mockDocumentsService,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
|||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ function registerModuleMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ function registerModuleMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
|
|
@ -70,7 +73,7 @@ function makeIssue(status: "todo" | "done") {
|
|||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status,
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1018",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ vi.mock("../telemetry.js", () => ({
|
|||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
|
|
|
|||
|
|
@ -355,4 +355,44 @@ describe("issue tree control routes", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns resume operations as released holds and avoids cancellation side effects", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-2"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
mockTreeControlService.createHold.mockResolvedValue({
|
||||
hold: {
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
mode: "resume",
|
||||
status: "released",
|
||||
reason: "resume subtree",
|
||||
},
|
||||
preview: {
|
||||
mode: "resume",
|
||||
totals: {
|
||||
affectedIssues: 1,
|
||||
},
|
||||
warnings: [],
|
||||
activeRuns: [],
|
||||
},
|
||||
resumedPauseHoldIds: ["33333333-3333-4333-8333-333333333333"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
||||
.send({ mode: "resume", reason: "resume subtree" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hold.mode).toBe("resume");
|
||||
expect(res.body.hold.status).toBe("released");
|
||||
expect(res.body.resumedPauseHoldIds).toEqual(["33333333-3333-4333-8333-333333333333"]);
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
expect(mockTreeControlService.cancelUnclaimedWakeupsForTree).not.toHaveBeenCalled();
|
||||
expect(mockTreeControlService.cancelIssueStatusesForHold).not.toHaveBeenCalled();
|
||||
expect(mockTreeControlService.restoreIssueStatusesForHold).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -337,19 +337,20 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("blocks normal checkout but allows comment interaction checkout under a pause hold", async () => {
|
||||
it("walks pause-hold ancestry beyond 15 levels for checkout and interaction waives", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const issuePath = Array.from({ length: 17 }, () => randomUUID());
|
||||
const rootIssueId = issuePath[0];
|
||||
const deepDescendantIssueId = issuePath.at(-1)!;
|
||||
const rootRunId = randomUUID();
|
||||
const childRunId = randomUUID();
|
||||
const deepDescendantRunId = randomUUID();
|
||||
const forgedRunId = randomUUID();
|
||||
const rootWakeupRequestId = randomUUID();
|
||||
const childWakeupRequestId = randomUUID();
|
||||
const deepDescendantWakeupRequestId = randomUUID();
|
||||
const forgedWakeupRequestId = randomUUID();
|
||||
const rootCommentId = randomUUID();
|
||||
const childCommentId = randomUUID();
|
||||
const deepDescendantCommentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
|
|
@ -368,25 +369,17 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
|||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: rootIssueId,
|
||||
await db.insert(issues).values(
|
||||
issuePath.map((issueId, index) => ({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Paused root",
|
||||
parentId: index > 0 ? issuePath[index - 1] : null,
|
||||
title: `Issue ${index}`,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Paused child",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
]);
|
||||
})),
|
||||
);
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
id: rootCommentId,
|
||||
|
|
@ -396,11 +389,11 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
|||
body: "Please answer this root issue question.",
|
||||
},
|
||||
{
|
||||
id: childCommentId,
|
||||
id: deepDescendantCommentId,
|
||||
companyId,
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
authorUserId: "board-user",
|
||||
body: "Please answer this child issue question.",
|
||||
body: "Please answer this deep descendant issue question.",
|
||||
},
|
||||
]);
|
||||
await db.insert(agentWakeupRequests).values([
|
||||
|
|
@ -424,24 +417,24 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
|||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: childIssueId, commentId: childCommentId },
|
||||
payload: { issueId: deepDescendantIssueId, commentId: deepDescendantCommentId },
|
||||
status: "queued",
|
||||
requestedByActorType: "agent",
|
||||
requestedByActorId: agentId,
|
||||
runId: forgedRunId,
|
||||
},
|
||||
{
|
||||
id: childWakeupRequestId,
|
||||
id: deepDescendantWakeupRequestId,
|
||||
companyId,
|
||||
agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: childIssueId, commentId: childCommentId },
|
||||
payload: { issueId: deepDescendantIssueId, commentId: deepDescendantCommentId },
|
||||
status: "queued",
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "board-user",
|
||||
runId: childRunId,
|
||||
runId: deepDescendantRunId,
|
||||
},
|
||||
]);
|
||||
await db.insert(heartbeatRuns).values([
|
||||
|
|
@ -470,25 +463,25 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
|||
status: "queued",
|
||||
wakeupRequestId: forgedWakeupRequestId,
|
||||
contextSnapshot: {
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
wakeReason: "issue_commented",
|
||||
commentId: childCommentId,
|
||||
wakeCommentId: childCommentId,
|
||||
commentId: deepDescendantCommentId,
|
||||
wakeCommentId: deepDescendantCommentId,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: childRunId,
|
||||
id: deepDescendantRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
wakeupRequestId: childWakeupRequestId,
|
||||
wakeupRequestId: deepDescendantWakeupRequestId,
|
||||
contextSnapshot: {
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
wakeReason: "issue_commented",
|
||||
commentId: childCommentId,
|
||||
wakeCommentId: childCommentId,
|
||||
commentId: deepDescendantCommentId,
|
||||
wakeCommentId: deepDescendantCommentId,
|
||||
source: "issue.comment",
|
||||
},
|
||||
},
|
||||
|
|
@ -500,16 +493,28 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
|||
reason: "operator requested pause",
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
const deepDescendantGate = await treeSvc.getActivePauseHoldGate(companyId, deepDescendantIssueId);
|
||||
expect(deepDescendantGate).toMatchObject({
|
||||
holdId: expect.any(String),
|
||||
rootIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
isRoot: false,
|
||||
mode: "pause",
|
||||
});
|
||||
|
||||
const issueSvc = issueService(db);
|
||||
await expect(issueSvc.checkout(childIssueId, agentId, ["todo"], randomUUID())).rejects.toMatchObject({
|
||||
await expect(
|
||||
issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], randomUUID()),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: expect.objectContaining({
|
||||
rootIssueId,
|
||||
mode: "pause",
|
||||
}),
|
||||
});
|
||||
await expect(issueSvc.checkout(childIssueId, agentId, ["todo"], forgedRunId)).rejects.toMatchObject({
|
||||
await expect(
|
||||
issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], forgedRunId),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: expect.objectContaining({
|
||||
rootIssueId,
|
||||
|
|
@ -517,9 +522,9 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
|||
}),
|
||||
});
|
||||
|
||||
const checkedOutChild = await issueSvc.checkout(childIssueId, agentId, ["todo"], childRunId);
|
||||
const checkedOutChild = await issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], deepDescendantRunId);
|
||||
expect(checkedOutChild.status).toBe("in_progress");
|
||||
expect(checkedOutChild.checkoutRunId).toBe(childRunId);
|
||||
expect(checkedOutChild.checkoutRunId).toBe(deepDescendantRunId);
|
||||
|
||||
const checkedOutRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
|
||||
expect(checkedOutRoot.status).toBe("in_progress");
|
||||
|
|
@ -552,4 +557,86 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
|||
expect(checkedOutLegacyFullPauseRoot.status).toBe("in_progress");
|
||||
expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId);
|
||||
});
|
||||
|
||||
it("resumes subtree pauses by releasing matching pause holds", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const nonSubtreeIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Root",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Child",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: nonSubtreeIssueId,
|
||||
companyId,
|
||||
title: "Unrelated",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
const treeSvc = issueTreeControlService(db);
|
||||
const subtreePause = await treeSvc.createHold(companyId, childIssueId, {
|
||||
mode: "pause",
|
||||
reason: "pause child only",
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
const nonSubtreePause = await treeSvc.createHold(companyId, nonSubtreeIssueId, {
|
||||
mode: "pause",
|
||||
reason: "pause unrelated issue",
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
|
||||
const resumed = await treeSvc.createHold(companyId, rootIssueId, {
|
||||
mode: "resume",
|
||||
reason: "resume subtree",
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
|
||||
expect(resumed.hold.mode).toBe("resume");
|
||||
expect(resumed.hold.status).toBe("released");
|
||||
expect(resumed.resumedPauseHoldIds).toEqual([subtreePause.hold.id]);
|
||||
|
||||
const rows = await db
|
||||
.select({ id: issueTreeHolds.id, status: issueTreeHolds.status, releaseMetadata: issueTreeHolds.releaseMetadata })
|
||||
.from(issueTreeHolds)
|
||||
.where(eq(issueTreeHolds.companyId, companyId));
|
||||
const byId = new Map(rows.map((row) => [row.id, row] as const));
|
||||
expect(byId.get(subtreePause.hold.id)?.status).toBe("released");
|
||||
expect(byId.get(nonSubtreePause.hold.id)?.status).toBe("active");
|
||||
expect(byId.get(resumed.hold.id)?.status).toBe("released");
|
||||
|
||||
const releaseMetadata = byId.get(subtreePause.hold.id)?.releaseMetadata as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
expect(releaseMetadata).toMatchObject({
|
||||
resumedByResumeHoldId: resumed.hold.id,
|
||||
resumeHoldMode: "tree_resume",
|
||||
resumedPauseHoldId: subtreePause.hold.id,
|
||||
});
|
||||
expect((byId.get(resumed.hold.id)?.releaseMetadata as Record<string, unknown> | null)).toMatchObject({
|
||||
resumedPauseHoldIds: [subtreePause.hold.id],
|
||||
resumeMode: "subtree",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
|
|||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
|
|
@ -82,6 +85,9 @@ vi.mock("../services/index.js", () => ({
|
|||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ function registerRouteMocks() {
|
|||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
getCommentCursor: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
listBlockerAttention: vi.fn(),
|
||||
listProductivityReviews: vi.fn(),
|
||||
listAttachments: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -91,6 +92,9 @@ const mockWorkProductService = vi.hoisted(() => ({
|
|||
const mockEnvironmentService = vi.hoisted(() => ({}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => mockDocumentsService,
|
||||
|
|
@ -177,6 +181,7 @@ describe.sequential("issue goal context routes", () => {
|
|||
});
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockIssueService.listBlockerAttention.mockResolvedValue(new Map());
|
||||
mockIssueService.listProductivityReviews.mockResolvedValue(new Map());
|
||||
mockIssueService.listAttachments.mockResolvedValue([]);
|
||||
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
|
||||
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
} from "./helpers/embedded-postgres.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { clampIssueListLimit, ISSUE_LIST_MAX_LIMIT, issueService } from "../services/issues.ts";
|
||||
import { buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
|
@ -1451,6 +1451,56 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps helper-created child requestDepth to the safe maximum", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Ship child helpers",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
goalId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
requestDepth: MAX_ISSUE_REQUEST_DEPTH,
|
||||
});
|
||||
|
||||
const { issue: child } = await svc.createChild(parentIssueId, {
|
||||
title: "Child helper",
|
||||
status: "todo",
|
||||
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 100,
|
||||
});
|
||||
|
||||
expect(child.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
|
||||
|
|
|
|||
427
server/src/__tests__/productivity-review-service.test.ts
Normal file
427
server/src/__tests__/productivity-review-service.test.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
|
||||
import {
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
productivityReviewService,
|
||||
} from "../services/productivity-review.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres productivity review tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("productivity review service", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-productivity-review-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.execute(sql.raw(`TRUNCATE TABLE "companies" CASCADE`));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedAssignedIssue(opts?: {
|
||||
status?: "todo" | "in_progress";
|
||||
startedAt?: Date;
|
||||
parentId?: string | null;
|
||||
originKind?: string;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const managerId = randomUUID();
|
||||
const coderId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `PR${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const createdAt = new Date("2026-04-28T10:00:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Productivity Review Co",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: managerId,
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "cto",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: coderId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
reportsTo: managerId,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Implement data import",
|
||||
status: opts?.status ?? "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: coderId,
|
||||
parentId: opts?.parentId ?? null,
|
||||
originKind: opts?.originKind ?? "manual",
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
startedAt: opts?.startedAt ?? createdAt,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
return { companyId, managerId, coderId, issueId, issuePrefix, createdAt };
|
||||
}
|
||||
|
||||
async function insertRuns(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
issueId: string;
|
||||
count: number;
|
||||
now: Date;
|
||||
withRunComments?: boolean;
|
||||
}) {
|
||||
const runs: Array<typeof heartbeatRuns.$inferInsert> = [];
|
||||
for (let index = 0; index < input.count; index += 1) {
|
||||
const runId = randomUUID();
|
||||
const createdAt = new Date(input.now.getTime() - index * 60_000);
|
||||
runs.push({
|
||||
id: runId,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
status: "succeeded",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
startedAt: createdAt,
|
||||
finishedAt: new Date(createdAt.getTime() + 30_000),
|
||||
contextSnapshot: { issueId: input.issueId, taskId: input.issueId },
|
||||
livenessState: "advanced",
|
||||
nextAction: "Continue processing the next batch.",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
}
|
||||
await db.insert(heartbeatRuns).values(runs);
|
||||
|
||||
if (input.withRunComments) {
|
||||
await db.insert(issueComments).values(
|
||||
runs.map((run, index) => ({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
authorAgentId: input.agentId,
|
||||
createdByRunId: run.id,
|
||||
body: `Progress update ${index}`,
|
||||
createdAt: run.createdAt as Date,
|
||||
updatedAt: run.createdAt as Date,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
async function listProductivityReviews(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND)))
|
||||
.orderBy(issues.createdAt);
|
||||
}
|
||||
|
||||
it("creates exactly one manager-assigned review for a no-comment run streak and refreshes it idempotently", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
|
||||
const service = productivityReviewService(db);
|
||||
const first = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const second = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
|
||||
expect(first.created).toBe(1);
|
||||
expect(second.updated).toBe(1);
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
expect(reviews).toHaveLength(1);
|
||||
expect(reviews[0]?.parentId).toBe(seeded.issueId);
|
||||
expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
|
||||
expect(reviews[0]?.originId).toBe(seeded.issueId);
|
||||
expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
|
||||
expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");
|
||||
expect(reviews[0]?.description).toContain("No-comment completed-run streak: 10");
|
||||
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, reviews[0]!.id));
|
||||
expect(comments.some((comment) => comment.body.includes("Productivity review evidence refreshed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("creates a long-active review without enabling a continuation hold", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue({
|
||||
status: "in_progress",
|
||||
startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
|
||||
const result = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const hold = await service.isProductivityReviewContinuationHoldActive({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
agentId: seeded.coderId,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.description).toContain("Primary trigger: `long_active_duration`");
|
||||
expect(review?.priority).toBe("medium");
|
||||
expect(hold.held).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a high-churn review even when every sampled run has a progress comment", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
withRunComments: true,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.description).toContain("Primary trigger: `high_churn`");
|
||||
expect(review?.description).toContain("Runs in rolling windows: 10/1h");
|
||||
});
|
||||
|
||||
it("ignores non-assignee comments when evaluating high-churn productivity reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 9,
|
||||
now,
|
||||
});
|
||||
const managerRuns = await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.managerId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
await db.insert(issueComments).values(
|
||||
managerRuns.map((run, index) => ({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
authorAgentId: seeded.managerId,
|
||||
createdByRunId: run.id,
|
||||
body: `Manager note ${index}`,
|
||||
createdAt: run.createdAt as Date,
|
||||
updatedAt: run.createdAt as Date,
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips productivity-review descendants so reviews cannot recursively spawn reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
const reviewId = randomUUID();
|
||||
const childId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: reviewId,
|
||||
companyId: seeded.companyId,
|
||||
title: "Existing productivity review",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: seeded.issueId,
|
||||
originFingerprint: `productivity-review:${seeded.issueId}`,
|
||||
parentId: seeded.issueId,
|
||||
issueNumber: 2,
|
||||
identifier: `${seeded.issuePrefix}-2`,
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: childId,
|
||||
companyId: seeded.companyId,
|
||||
title: "Review follow-up child",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: seeded.coderId,
|
||||
parentId: reviewId,
|
||||
issueNumber: 3,
|
||||
identifier: `${seeded.issuePrefix}-3`,
|
||||
startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
|
||||
});
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: childId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(reviews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats a recently completed review as a snooze window", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "done", updatedAt: now })
|
||||
.where(eq(issues.id, review!.id));
|
||||
|
||||
const result = await service.reconcileProductivityReviews({
|
||||
now: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
expect(result.snoozed).toBe(1);
|
||||
expect(reviews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports and logs soft-stop holds for open no-comment reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
const [latestRun] = await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
const hold = await service.isProductivityReviewContinuationHoldActive({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
agentId: seeded.coderId,
|
||||
now,
|
||||
});
|
||||
expect(hold.held).toBe(true);
|
||||
if (!hold.held) return;
|
||||
|
||||
await service.recordContinuationHold({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
runId: latestRun!.id as string,
|
||||
agentId: seeded.coderId,
|
||||
reviewIssueId: review!.id,
|
||||
trigger: hold.trigger,
|
||||
reason: hold.reason,
|
||||
});
|
||||
const activities = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.action, "issue.productivity_review_continuation_held"));
|
||||
expect(activities).toHaveLength(1);
|
||||
expect(activities[0]?.entityId).toBe(seeded.issueId);
|
||||
});
|
||||
|
||||
it("clamps poisoned requestDepth metadata instead of aborting productivity reconciliation", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ requestDepth: 2_147_483_647 })
|
||||
.where(eq(issues.id, seeded.issueId));
|
||||
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
||||
});
|
||||
});
|
||||
|
|
@ -320,6 +320,55 @@ describe.sequential("workspace runtime service route authorization", () => {
|
|||
expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled();
|
||||
}, 15000);
|
||||
|
||||
it("blocks shared-project stop/restart requests from agents", async () => {
|
||||
mockProjectService.getById.mockResolvedValue(buildProject({
|
||||
id: projectId,
|
||||
workspaces: [{
|
||||
id: workspaceId,
|
||||
companyId: "company-1",
|
||||
projectId,
|
||||
name: "Workspace",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/project",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: "shared-key",
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: false,
|
||||
runtimeServices: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
}));
|
||||
const app = await createProjectApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const responses = await Promise.all([
|
||||
request(app).post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/stop`).send({}),
|
||||
request(app).post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/restart`).send({}),
|
||||
]);
|
||||
|
||||
for (const res of responses) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Missing permission");
|
||||
expect(mockProjectService.getById).toHaveBeenCalledWith(projectId);
|
||||
expect(mockAssertCanManageProjectWorkspaceRuntimeServices).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
}, 15000);
|
||||
|
||||
it("rejects agent callers that create project execution workspace commands", async () => {
|
||||
const app = await createProjectApp({
|
||||
type: "agent",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue