From 0edb742f8d5dd4b06c0686810b9196f53de0d46b Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 29 Jul 2026 10:22:01 -0700 Subject: [PATCH] test(server): relocate referenced-project run-prep tests to a dedicated file (#10446) ## Thinking Path > - Paperclip keeps server tests that protect run-prep and issue service behavior > - The issue service test file now mixes issue service tests with heartbeat run-prep tests > - That mix makes the file harder to scan and harder to keep focused > - The run-prep suites belong with the other heartbeat tests > - This pull request moves those suites into `heartbeat-referenced-projects.test.ts` > - The benefit is a smaller issue service test file and a clearer home for heartbeat tests ## Linked Issues or Issue Description This PR has no public GitHub issue. It moves the referenced-project run-prep suites into a dedicated heartbeat test file. The issue service test file keeps only issue service tests. ## What Changed - Moved the `resolveRunReferencedProjects` suite into `server/src/__tests__/heartbeat-referenced-projects.test.ts` - Moved the multi-project workspace sync kill-switch test into the same file - Left `server/src/__tests__/issues-service.test.ts` with issue service coverage only ## Verification - `pnpm exec tsc --noEmit` - `pnpm exec vitest run src/__tests__/heartbeat-referenced-projects.test.ts` - `pnpm exec vitest run src/__tests__/issues-service.test.ts` - `git log --oneline origin/master..HEAD` shows one commit - `git diff --stat origin/master...HEAD` shows only the two test files ## Risks - Low risk. This change moves tests only and does not change product code. ## Model Used OpenAI Codex, GPT-5, tool use enabled, local shell execution enabled. ## 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 - [ ] 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 --- .../heartbeat-referenced-projects.test.ts | 495 ++++++++++++++++++ server/src/__tests__/issues-service.test.ts | 473 ----------------- 2 files changed, 495 insertions(+), 473 deletions(-) create mode 100644 server/src/__tests__/heartbeat-referenced-projects.test.ts diff --git a/server/src/__tests__/heartbeat-referenced-projects.test.ts b/server/src/__tests__/heartbeat-referenced-projects.test.ts new file mode 100644 index 0000000000..13864d19d7 --- /dev/null +++ b/server/src/__tests__/heartbeat-referenced-projects.test.ts @@ -0,0 +1,495 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + companies, + createDb, + instanceSettings, + issueComments, + issues, + projectWorkspaces, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { issueService } from "../services/issues.ts"; +import { projectService } from "../services/projects.ts"; +import { + isMultiProjectWorkspaceSyncEnabled, + MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS, + MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS, + MULTI_PROJECT_WORKSPACE_SYNC_ENV, + resolveRunReferencedProjects, + type ResolveRunReferencedProjectsOptions, +} from "../services/heartbeat.ts"; +import type { AuthorizationActor, AuthorizationDecision } from "../services/authorization.ts"; +import { buildProjectMentionHref } from "@paperclipai/shared"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describe("multi-project workspace sync kill-switch", () => { + it("is OFF by default and enabled only by truthy env values", () => { + expect(isMultiProjectWorkspaceSyncEnabled({})).toBe(false); + expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "" })).toBe(false); + expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "false" })).toBe(false); + expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "0" })).toBe(false); + expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "true" })).toBe(true); + expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "1" })).toBe(true); + expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "on" })).toBe(true); + }); +}); + +describeEmbeddedPostgres("resolveRunReferencedProjects", () => { + let db!: ReturnType; + let issuesSvc!: ReturnType; + let projectsSvc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-referenced-projects-"); + db = createDb(tempDb.connectionString); + issuesSvc = issueService(db); + projectsSvc = projectService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + const buildActor = (companyId: string): AuthorizationActor => ({ + type: "agent", + agentId: randomUUID(), + companyId, + source: "agent_key", + }); + + const decision = (allowed: boolean): AuthorizationDecision => ({ + allowed, + action: "project:read", + reason: allowed ? "allow_company_agent" : "deny_company_boundary", + explanation: "test decision", + }); + + // Records every project:read authorization call and answers via the supplied resolver. + const recordingAccess = ( + resolve: (projectId: string) => AuthorizationDecision | Promise, + ) => { + const decidedProjectIds: string[] = []; + const access: ResolveRunReferencedProjectsOptions["access"] = { + decide: async (input) => { + const resource = input.resource; + const projectId = resource.type === "project" ? resource.projectId ?? "" : ""; + decidedProjectIds.push(projectId); + return resolve(projectId); + }, + }; + return { decidedProjectIds, access }; + }; + + const seedCompany = async (companyId: string) => { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + }; + + const seedIssueWithMentions = async (input: { + companyId: string; + issueId: string; + anchorProjectId: string | null; + mentionedProjectIds: string[]; + }) => { + const mentionMarkup = input.mentionedProjectIds + .map((projectId, index) => `[Ref${index}](${buildProjectMentionHref(projectId)})`) + .join(" "); + await db.insert(issues).values({ + id: input.issueId, + companyId: input.companyId, + projectId: input.anchorProjectId, + title: `Referencing ${mentionMarkup}`, + description: null, + status: "todo", + priority: "medium", + }); + }; + + const baseOpts = ( + companyId: string, + access: ResolveRunReferencedProjectsOptions["access"], + overrides?: Partial, + ): ResolveRunReferencedProjectsOptions => ({ + companyId, + actor: buildActor(companyId), + issues: issuesSvc, + projects: projectsSvc, + access, + ...overrides, + }); + + it("admits a same-company project that passes project:read", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const mentionedProjectId = randomUUID(); + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + { id: mentionedProjectId, companyId, name: "Mentioned", status: "in_progress" }, + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [mentionedProjectId] }); + + const { decidedProjectIds, access } = recordingAccess(() => decision(true)); + const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); + + expect(result.anchor?.projectId).toBe(anchorProjectId); + expect(result.additional.map((entry) => entry.projectId)).toEqual([mentionedProjectId]); + expect(result.warnings).toEqual([]); + // The anchor is never re-authorized; only the additional project is checked. + expect(decidedProjectIds).toEqual([mentionedProjectId]); + }); + + it("dedupes the anchor against the mentioned set (anchor wins)", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const mentionedProjectId = randomUUID(); + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + { id: mentionedProjectId, companyId, name: "Mentioned", status: "in_progress" }, + ]); + // The anchor is also @-mentioned in the body; it must not appear in `additional`. + await seedIssueWithMentions({ + companyId, + issueId, + anchorProjectId, + mentionedProjectIds: [anchorProjectId, mentionedProjectId], + }); + + const { decidedProjectIds, access } = recordingAccess(() => decision(true)); + const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); + + expect(result.anchor?.projectId).toBe(anchorProjectId); + expect(result.additional.map((entry) => entry.projectId)).toEqual([mentionedProjectId]); + expect(decidedProjectIds).not.toContain(anchorProjectId); + }); + + it("drops a foreign-company project before authorization", async () => { + const companyId = randomUUID(); + const foreignCompanyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const foreignProjectId = randomUUID(); + + await seedCompany(companyId); + await seedCompany(foreignCompanyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + { id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign", status: "in_progress" }, + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [foreignProjectId] }); + + const { decidedProjectIds, access } = recordingAccess(() => decision(true)); + const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); + + expect(result.additional).toEqual([]); + // Company scoping drops the foreign project before any authorization call is made. + expect(decidedProjectIds).toEqual([]); + }); + + it("drops and warns on a project that fails per-project authorization", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const deniedProjectId = randomUUID(); + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + { id: deniedProjectId, companyId, name: "Denied", status: "in_progress" }, + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [deniedProjectId] }); + + const { access } = recordingAccess((projectId) => decision(projectId !== deniedProjectId)); + const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); + + expect(result.additional).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain(deniedProjectId); + expect(result.warnings[0]).toContain("not authorized"); + }); + + it("fail-closed drops a project when the authorization service throws", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const explodingProjectId = randomUUID(); + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + { id: explodingProjectId, companyId, name: "Exploding", status: "in_progress" }, + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [explodingProjectId] }); + + const access: ResolveRunReferencedProjectsOptions["access"] = { + decide: async () => { + throw new Error("authorization backend unavailable"); + }, + }; + const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); + + // The run continues; the un-authorizable project is dropped with a warning rather than throwing. + expect(result.anchor?.projectId).toBe(anchorProjectId); + expect(result.additional).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain(explodingProjectId); + expect(result.warnings[0]).toContain("not authorized"); + }); + + it("caps the number of additional projects and warns about the overflow", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const mentionedProjectIds = [randomUUID(), randomUUID(), randomUUID()]; + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + ...mentionedProjectIds.map((id, index) => ({ + id, + companyId, + name: `Mentioned ${index}`, + status: "in_progress" as const, + })), + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds }); + + const { decidedProjectIds, access } = recordingAccess(() => decision(true)); + const result = await resolveRunReferencedProjects( + issueId, + anchorProjectId, + baseOpts(companyId, access, { maxAdditionalProjects: 2 }), + ); + + expect(result.additional.map((entry) => entry.projectId)).toEqual(mentionedProjectIds.slice(0, 2)); + // The cap counts admitted projects, so the third project is never authorized once two are admitted. + expect(decidedProjectIds).toEqual(mentionedProjectIds.slice(0, 2)); + expect(result.warnings.some((warning) => warning.includes("Only the first 2"))).toBe(true); + expect(MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS).toBeGreaterThan(0); + }); + + it("does not let an unauthorized mention consume an additional-project cap slot", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const deniedProjectId = randomUUID(); + const allowedProjectIds = [randomUUID(), randomUUID()]; + // Mention order: the denied project comes first, ahead of two authorized projects. + const mentionedProjectIds = [deniedProjectId, ...allowedProjectIds]; + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + { id: deniedProjectId, companyId, name: "Denied", status: "in_progress" }, + ...allowedProjectIds.map((id, index) => ({ + id, + companyId, + name: `Allowed ${index}`, + status: "in_progress" as const, + })), + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds }); + + const { access } = recordingAccess((projectId) => decision(projectId !== deniedProjectId)); + const result = await resolveRunReferencedProjects( + issueId, + anchorProjectId, + baseOpts(companyId, access, { maxAdditionalProjects: 2 }), + ); + + // The denied mention is dropped without using a cap slot, so both authorized projects still fit. + expect(result.additional.map((entry) => entry.projectId)).toEqual(allowedProjectIds); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain(deniedProjectId); + expect(result.warnings[0]).toContain("not authorized"); + // The cap was satisfied by admitted projects, so no overflow warning is emitted. + expect(result.warnings.some((warning) => warning.includes("Only the first"))).toBe(false); + }); + + it("bounds authorization fan-out when a same-company mention flood is denied", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + // A flood of same-company mentions that every fail authorization. Without a fan-out cap this + // would authorize all eight candidates because the admitted cap is never reached. + const deniedProjectIds = Array.from({ length: 8 }, () => randomUUID()); + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + ...deniedProjectIds.map((id, index) => ({ + id, + companyId, + name: `Denied ${index}`, + status: "in_progress" as const, + })), + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: deniedProjectIds }); + + const { decidedProjectIds, access } = recordingAccess(() => decision(false)); + const result = await resolveRunReferencedProjects( + issueId, + anchorProjectId, + baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 3 }), + ); + + // No project is admitted (all denied), but only the first three candidates are ever authorized — + // the remaining five are dropped before any authorization decision is made. + expect(result.additional).toEqual([]); + expect(decidedProjectIds).toEqual(deniedProjectIds.slice(0, 3)); + expect(decidedProjectIds).toHaveLength(3); + // Each dropped-but-evaluated candidate warns it was unauthorized; the tail warns it was skipped + // without evaluation. + expect(result.warnings.some((warning) => warning.includes("were evaluated for this run"))).toBe(true); + expect( + result.warnings.some( + (warning) => warning.includes("Only the first 3") && warning.includes("5 additional"), + ), + ).toBe(true); + }); + + it("still admits authorized candidates inside the evaluation window under a flood", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const allowedProjectId = randomUUID(); + // The single authorized project sits ahead of a flood of denied mentions and inside the window. + const deniedProjectIds = Array.from({ length: 5 }, () => randomUUID()); + const mentionedProjectIds = [allowedProjectId, ...deniedProjectIds]; + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + { id: allowedProjectId, companyId, name: "Allowed", status: "in_progress" }, + ...deniedProjectIds.map((id, index) => ({ + id, + companyId, + name: `Denied ${index}`, + status: "in_progress" as const, + })), + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds }); + + const { decidedProjectIds, access } = recordingAccess((projectId) => + decision(projectId === allowedProjectId), + ); + const result = await resolveRunReferencedProjects( + issueId, + anchorProjectId, + baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 3 }), + ); + + // The authorized project is admitted; only the first three candidates are ever authorized. + expect(result.additional.map((entry) => entry.projectId)).toEqual([allowedProjectId]); + expect(decidedProjectIds).toEqual(mentionedProjectIds.slice(0, 3)); + expect(result.warnings.some((warning) => warning.includes("were evaluated for this run"))).toBe(true); + }); + + it("does not let unavailable mentions consume the evaluation window", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const allowedProjectId = randomUUID(); + // Two unavailable (foreign-company / deleted / unknown) mentions sit ahead of the authorized + // project in mention order. The production mention lookup company-filters these out, so drive the + // mention set through a stub to exercise the resolver's own availability filtering directly. + const unavailableProjectIds = [randomUUID(), randomUUID()]; + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + { id: allowedProjectId, companyId, name: "Allowed", status: "in_progress" }, + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [allowedProjectId] }); + + const mentions: ResolveRunReferencedProjectsOptions["issues"] = { + findMentionedProjectIds: async () => [...unavailableProjectIds, allowedProjectId], + }; + + const { decidedProjectIds, access } = recordingAccess(() => decision(true)); + // The evaluation cap is only two slots; if unavailable mentions consumed them, the authorized + // project would be displaced out of the window and the set would underfill. + const result = await resolveRunReferencedProjects( + issueId, + anchorProjectId, + baseOpts(companyId, access, { issues: mentions, maxAdditionalProjects: 2, maxCandidateEvaluations: 2 }), + ); + + // Availability filtering runs before the evaluation cap, so the authorized project still lands + // inside the window and is admitted rather than displaced. + expect(result.additional.map((entry) => entry.projectId)).toEqual([allowedProjectId]); + expect(decidedProjectIds).toEqual([allowedProjectId]); + // Each unavailable mention warns, but none of them consumed an evaluation slot. + expect( + result.warnings.filter((warning) => warning.includes("not available in this company")), + ).toHaveLength(2); + expect(result.warnings.some((warning) => warning.includes("without evaluation"))).toBe(false); + }); + + it("floors the evaluation cap at the admitted cap so the admitted cap stays reachable", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const anchorProjectId = randomUUID(); + const allowedProjectIds = [randomUUID(), randomUUID()]; + + await seedCompany(companyId); + await db.insert(projects).values([ + { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, + ...allowedProjectIds.map((id, index) => ({ + id, + companyId, + name: `Allowed ${index}`, + status: "in_progress" as const, + })), + ]); + await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: allowedProjectIds }); + + const { decidedProjectIds, access } = recordingAccess(() => decision(true)); + // An evaluation cap below the admitted cap must not starve the admitted cap. + const result = await resolveRunReferencedProjects( + issueId, + anchorProjectId, + baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 0 }), + ); + + expect(result.additional.map((entry) => entry.projectId)).toEqual(allowedProjectIds); + expect(decidedProjectIds).toEqual(allowedProjectIds); + expect(result.warnings).toEqual([]); + }); + + it("defaults the evaluation cap at or above the admitted cap", () => { + expect(MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS).toBeGreaterThanOrEqual( + MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS, + ); + }); +}); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ea82f77363..532828082f 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -41,16 +41,6 @@ import { WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, } from "../services/execution-workspace-policy.ts"; -import { projectService } from "../services/projects.ts"; -import { - isMultiProjectWorkspaceSyncEnabled, - MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS, - MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS, - MULTI_PROJECT_WORKSPACE_SYNC_ENV, - resolveRunReferencedProjects, - type ResolveRunReferencedProjectsOptions, -} from "../services/heartbeat.ts"; -import type { AuthorizationActor, AuthorizationDecision } from "../services/authorization.ts"; import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -4943,469 +4933,6 @@ describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { }); }); -describe("multi-project workspace sync kill-switch", () => { - it("is OFF by default and enabled only by truthy env values", () => { - expect(isMultiProjectWorkspaceSyncEnabled({})).toBe(false); - expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "" })).toBe(false); - expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "false" })).toBe(false); - expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "0" })).toBe(false); - expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "true" })).toBe(true); - expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "1" })).toBe(true); - expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "on" })).toBe(true); - }); -}); - -describeEmbeddedPostgres("resolveRunReferencedProjects", () => { - let db!: ReturnType; - let issuesSvc!: ReturnType; - let projectsSvc!: ReturnType; - let tempDb: Awaited> | null = null; - - beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-referenced-projects-"); - db = createDb(tempDb.connectionString); - issuesSvc = issueService(db); - projectsSvc = projectService(db); - }, 20_000); - - afterEach(async () => { - await db.delete(issueComments); - await db.delete(activityLog); - await db.delete(issues); - await db.delete(projectWorkspaces); - await db.delete(projects); - await db.delete(agents); - await db.delete(instanceSettings); - await db.delete(companies); - }); - - afterAll(async () => { - await tempDb?.cleanup(); - }); - - const buildActor = (companyId: string): AuthorizationActor => ({ - type: "agent", - agentId: randomUUID(), - companyId, - source: "agent_key", - }); - - const decision = (allowed: boolean): AuthorizationDecision => ({ - allowed, - action: "project:read", - reason: allowed ? "allow_company_agent" : "deny_company_boundary", - explanation: "test decision", - }); - - // Records every project:read authorization call and answers via the supplied resolver. - const recordingAccess = ( - resolve: (projectId: string) => AuthorizationDecision | Promise, - ) => { - const decidedProjectIds: string[] = []; - const access: ResolveRunReferencedProjectsOptions["access"] = { - decide: async (input) => { - const resource = input.resource; - const projectId = resource.type === "project" ? resource.projectId ?? "" : ""; - decidedProjectIds.push(projectId); - return resolve(projectId); - }, - }; - return { decidedProjectIds, access }; - }; - - const seedCompany = async (companyId: string) => { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - }; - - const seedIssueWithMentions = async (input: { - companyId: string; - issueId: string; - anchorProjectId: string | null; - mentionedProjectIds: string[]; - }) => { - const mentionMarkup = input.mentionedProjectIds - .map((projectId, index) => `[Ref${index}](${buildProjectMentionHref(projectId)})`) - .join(" "); - await db.insert(issues).values({ - id: input.issueId, - companyId: input.companyId, - projectId: input.anchorProjectId, - title: `Referencing ${mentionMarkup}`, - description: null, - status: "todo", - priority: "medium", - }); - }; - - const baseOpts = ( - companyId: string, - access: ResolveRunReferencedProjectsOptions["access"], - overrides?: Partial, - ): ResolveRunReferencedProjectsOptions => ({ - companyId, - actor: buildActor(companyId), - issues: issuesSvc, - projects: projectsSvc, - access, - ...overrides, - }); - - it("admits a same-company project that passes project:read", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const mentionedProjectId = randomUUID(); - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - { id: mentionedProjectId, companyId, name: "Mentioned", status: "in_progress" }, - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [mentionedProjectId] }); - - const { decidedProjectIds, access } = recordingAccess(() => decision(true)); - const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); - - expect(result.anchor?.projectId).toBe(anchorProjectId); - expect(result.additional.map((entry) => entry.projectId)).toEqual([mentionedProjectId]); - expect(result.warnings).toEqual([]); - // The anchor is never re-authorized; only the additional project is checked. - expect(decidedProjectIds).toEqual([mentionedProjectId]); - }); - - it("dedupes the anchor against the mentioned set (anchor wins)", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const mentionedProjectId = randomUUID(); - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - { id: mentionedProjectId, companyId, name: "Mentioned", status: "in_progress" }, - ]); - // The anchor is also @-mentioned in the body; it must not appear in `additional`. - await seedIssueWithMentions({ - companyId, - issueId, - anchorProjectId, - mentionedProjectIds: [anchorProjectId, mentionedProjectId], - }); - - const { decidedProjectIds, access } = recordingAccess(() => decision(true)); - const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); - - expect(result.anchor?.projectId).toBe(anchorProjectId); - expect(result.additional.map((entry) => entry.projectId)).toEqual([mentionedProjectId]); - expect(decidedProjectIds).not.toContain(anchorProjectId); - }); - - it("drops a foreign-company project before authorization", async () => { - const companyId = randomUUID(); - const foreignCompanyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const foreignProjectId = randomUUID(); - - await seedCompany(companyId); - await seedCompany(foreignCompanyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - { id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign", status: "in_progress" }, - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [foreignProjectId] }); - - const { decidedProjectIds, access } = recordingAccess(() => decision(true)); - const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); - - expect(result.additional).toEqual([]); - // Company scoping drops the foreign project before any authorization call is made. - expect(decidedProjectIds).toEqual([]); - }); - - it("drops and warns on a project that fails per-project authorization", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const deniedProjectId = randomUUID(); - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - { id: deniedProjectId, companyId, name: "Denied", status: "in_progress" }, - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [deniedProjectId] }); - - const { access } = recordingAccess((projectId) => decision(projectId !== deniedProjectId)); - const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); - - expect(result.additional).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain(deniedProjectId); - expect(result.warnings[0]).toContain("not authorized"); - }); - - it("fail-closed drops a project when the authorization service throws", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const explodingProjectId = randomUUID(); - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - { id: explodingProjectId, companyId, name: "Exploding", status: "in_progress" }, - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [explodingProjectId] }); - - const access: ResolveRunReferencedProjectsOptions["access"] = { - decide: async () => { - throw new Error("authorization backend unavailable"); - }, - }; - const result = await resolveRunReferencedProjects(issueId, anchorProjectId, baseOpts(companyId, access)); - - // The run continues; the un-authorizable project is dropped with a warning rather than throwing. - expect(result.anchor?.projectId).toBe(anchorProjectId); - expect(result.additional).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain(explodingProjectId); - expect(result.warnings[0]).toContain("not authorized"); - }); - - it("caps the number of additional projects and warns about the overflow", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const mentionedProjectIds = [randomUUID(), randomUUID(), randomUUID()]; - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - ...mentionedProjectIds.map((id, index) => ({ - id, - companyId, - name: `Mentioned ${index}`, - status: "in_progress" as const, - })), - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds }); - - const { decidedProjectIds, access } = recordingAccess(() => decision(true)); - const result = await resolveRunReferencedProjects( - issueId, - anchorProjectId, - baseOpts(companyId, access, { maxAdditionalProjects: 2 }), - ); - - expect(result.additional.map((entry) => entry.projectId)).toEqual(mentionedProjectIds.slice(0, 2)); - // The cap counts admitted projects, so the third project is never authorized once two are admitted. - expect(decidedProjectIds).toEqual(mentionedProjectIds.slice(0, 2)); - expect(result.warnings.some((warning) => warning.includes("Only the first 2"))).toBe(true); - expect(MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS).toBeGreaterThan(0); - }); - - it("does not let an unauthorized mention consume an additional-project cap slot", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const deniedProjectId = randomUUID(); - const allowedProjectIds = [randomUUID(), randomUUID()]; - // Mention order: the denied project comes first, ahead of two authorized projects. - const mentionedProjectIds = [deniedProjectId, ...allowedProjectIds]; - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - { id: deniedProjectId, companyId, name: "Denied", status: "in_progress" }, - ...allowedProjectIds.map((id, index) => ({ - id, - companyId, - name: `Allowed ${index}`, - status: "in_progress" as const, - })), - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds }); - - const { access } = recordingAccess((projectId) => decision(projectId !== deniedProjectId)); - const result = await resolveRunReferencedProjects( - issueId, - anchorProjectId, - baseOpts(companyId, access, { maxAdditionalProjects: 2 }), - ); - - // The denied mention is dropped without using a cap slot, so both authorized projects still fit. - expect(result.additional.map((entry) => entry.projectId)).toEqual(allowedProjectIds); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain(deniedProjectId); - expect(result.warnings[0]).toContain("not authorized"); - // The cap was satisfied by admitted projects, so no overflow warning is emitted. - expect(result.warnings.some((warning) => warning.includes("Only the first"))).toBe(false); - }); - - it("bounds authorization fan-out when a same-company mention flood is denied", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - // A flood of same-company mentions that every fail authorization. Without a fan-out cap this - // would authorize all eight candidates because the admitted cap is never reached. - const deniedProjectIds = Array.from({ length: 8 }, () => randomUUID()); - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - ...deniedProjectIds.map((id, index) => ({ - id, - companyId, - name: `Denied ${index}`, - status: "in_progress" as const, - })), - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: deniedProjectIds }); - - const { decidedProjectIds, access } = recordingAccess(() => decision(false)); - const result = await resolveRunReferencedProjects( - issueId, - anchorProjectId, - baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 3 }), - ); - - // No project is admitted (all denied), but only the first three candidates are ever authorized — - // the remaining five are dropped before any authorization decision is made. - expect(result.additional).toEqual([]); - expect(decidedProjectIds).toEqual(deniedProjectIds.slice(0, 3)); - expect(decidedProjectIds).toHaveLength(3); - // Each dropped-but-evaluated candidate warns it was unauthorized; the tail warns it was skipped - // without evaluation. - expect(result.warnings.some((warning) => warning.includes("were evaluated for this run"))).toBe(true); - expect( - result.warnings.some( - (warning) => warning.includes("Only the first 3") && warning.includes("5 additional"), - ), - ).toBe(true); - }); - - it("still admits authorized candidates inside the evaluation window under a flood", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const allowedProjectId = randomUUID(); - // The single authorized project sits ahead of a flood of denied mentions and inside the window. - const deniedProjectIds = Array.from({ length: 5 }, () => randomUUID()); - const mentionedProjectIds = [allowedProjectId, ...deniedProjectIds]; - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - { id: allowedProjectId, companyId, name: "Allowed", status: "in_progress" }, - ...deniedProjectIds.map((id, index) => ({ - id, - companyId, - name: `Denied ${index}`, - status: "in_progress" as const, - })), - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds }); - - const { decidedProjectIds, access } = recordingAccess((projectId) => - decision(projectId === allowedProjectId), - ); - const result = await resolveRunReferencedProjects( - issueId, - anchorProjectId, - baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 3 }), - ); - - // The authorized project is admitted; only the first three candidates are ever authorized. - expect(result.additional.map((entry) => entry.projectId)).toEqual([allowedProjectId]); - expect(decidedProjectIds).toEqual(mentionedProjectIds.slice(0, 3)); - expect(result.warnings.some((warning) => warning.includes("were evaluated for this run"))).toBe(true); - }); - - it("does not let unavailable mentions consume the evaluation window", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const allowedProjectId = randomUUID(); - // Two unavailable (foreign-company / deleted / unknown) mentions sit ahead of the authorized - // project in mention order. The production mention lookup company-filters these out, so drive the - // mention set through a stub to exercise the resolver's own availability filtering directly. - const unavailableProjectIds = [randomUUID(), randomUUID()]; - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - { id: allowedProjectId, companyId, name: "Allowed", status: "in_progress" }, - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: [allowedProjectId] }); - - const mentions: ResolveRunReferencedProjectsOptions["issues"] = { - findMentionedProjectIds: async () => [...unavailableProjectIds, allowedProjectId], - }; - - const { decidedProjectIds, access } = recordingAccess(() => decision(true)); - // The evaluation cap is only two slots; if unavailable mentions consumed them, the authorized - // project would be displaced out of the window and the set would underfill. - const result = await resolveRunReferencedProjects( - issueId, - anchorProjectId, - baseOpts(companyId, access, { issues: mentions, maxAdditionalProjects: 2, maxCandidateEvaluations: 2 }), - ); - - // Availability filtering runs before the evaluation cap, so the authorized project still lands - // inside the window and is admitted rather than displaced. - expect(result.additional.map((entry) => entry.projectId)).toEqual([allowedProjectId]); - expect(decidedProjectIds).toEqual([allowedProjectId]); - // Each unavailable mention warns, but none of them consumed an evaluation slot. - expect( - result.warnings.filter((warning) => warning.includes("not available in this company")), - ).toHaveLength(2); - expect(result.warnings.some((warning) => warning.includes("without evaluation"))).toBe(false); - }); - - it("floors the evaluation cap at the admitted cap so the admitted cap stays reachable", async () => { - const companyId = randomUUID(); - const issueId = randomUUID(); - const anchorProjectId = randomUUID(); - const allowedProjectIds = [randomUUID(), randomUUID()]; - - await seedCompany(companyId); - await db.insert(projects).values([ - { id: anchorProjectId, companyId, name: "Anchor", status: "in_progress" }, - ...allowedProjectIds.map((id, index) => ({ - id, - companyId, - name: `Allowed ${index}`, - status: "in_progress" as const, - })), - ]); - await seedIssueWithMentions({ companyId, issueId, anchorProjectId, mentionedProjectIds: allowedProjectIds }); - - const { decidedProjectIds, access } = recordingAccess(() => decision(true)); - // An evaluation cap below the admitted cap must not starve the admitted cap. - const result = await resolveRunReferencedProjects( - issueId, - anchorProjectId, - baseOpts(companyId, access, { maxAdditionalProjects: 2, maxCandidateEvaluations: 0 }), - ); - - expect(result.additional.map((entry) => entry.projectId)).toEqual(allowedProjectIds); - expect(decidedProjectIds).toEqual(allowedProjectIds); - expect(result.warnings).toEqual([]); - }); - - it("defaults the evaluation cap at or above the admitted cap", () => { - expect(MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS).toBeGreaterThanOrEqual( - MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS, - ); - }); -}); - describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => { let db!: ReturnType; let svc!: ReturnType;