diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 677d8a8d18..ea82f77363 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -41,6 +41,16 @@ 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(); @@ -4843,6 +4853,557 @@ describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { commentProjectId, ]); }); + + it("returns multiple same-company mentions in order, deduped", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const firstProjectId = randomUUID(); + const secondProjectId = randomUUID(); + const thirdProjectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(projects).values([ + { id: firstProjectId, companyId, name: "First project", status: "in_progress" }, + { id: secondProjectId, companyId, name: "Second project", status: "in_progress" }, + { id: thirdProjectId, companyId, name: "Third project", status: "in_progress" }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: + `See [First](${buildProjectMentionHref(firstProjectId)}) and ` + + `[Second](${buildProjectMentionHref(secondProjectId)})`, + description: null, + status: "todo", + priority: "medium", + }); + + await db.insert(issueComments).values({ + companyId, + issueId, + // Repeats the first mention (deduped) and introduces a third. + body: + `Also [First again](${buildProjectMentionHref(firstProjectId)}) and ` + + `[Third](${buildProjectMentionHref(thirdProjectId)})`, + }); + + expect(await svc.findMentionedProjectIds(issueId)).toEqual([ + firstProjectId, + secondProjectId, + thirdProjectId, + ]); + }); + + it("filters out a mention from another company", async () => { + const companyId = randomUUID(); + const foreignCompanyId = randomUUID(); + const issueId = randomUUID(); + const sameCompanyProjectId = randomUUID(); + const foreignProjectId = randomUUID(); + + await db.insert(companies).values([ + { + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + { + id: foreignCompanyId, + name: "Other company", + issuePrefix: `F${foreignCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + ]); + + await db.insert(projects).values([ + { id: sameCompanyProjectId, companyId, name: "Same-company project", status: "in_progress" }, + { id: foreignProjectId, companyId: foreignCompanyId, name: "Foreign project", status: "in_progress" }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: + `Ours [Same](${buildProjectMentionHref(sameCompanyProjectId)}) and ` + + `theirs [Foreign](${buildProjectMentionHref(foreignProjectId)})`, + description: null, + status: "todo", + priority: "medium", + }); + + expect(await svc.findMentionedProjectIds(issueId)).toEqual([sameCompanyProjectId]); + }); +}); + +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", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 071f4f246a..7d10535635 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -133,6 +133,8 @@ import { sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; +import { projectService } from "./projects.js"; +import { authorizationService, type AuthorizationActor } from "./authorization.js"; import { createToolGatewayService } from "./tool-gateway.js"; import { toolAccessService } from "./tool-access.js"; import { visibleIssueCondition } from "./issue-visibility.js"; @@ -2120,6 +2122,240 @@ export function prioritizeProjectWorkspaceCandidatesForRun = process.env, +): boolean { + return isTruthyRuntimeEnvValue(env[MULTI_PROJECT_WORKSPACE_SYNC_ENV]); +} + +/** + * Upper bound on how many additional (mentioned) projects a single run may materialize + * beyond the anchor. Bounds the fan-out of per-project authorization and workspace prep. + */ +export const MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS = 10; + +/** + * Upper bound on how many *available* (same-company, hydrated) candidate projects a single run will + * *authorize* before the admitted-project cap is applied. + * + * This is a fan-out guard distinct from {@link MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS}: + * the admitted cap counts only projects that were successfully authorized, so on its own it + * does not bound how many `project:read` decisions a run performs — an adversarial same-company + * mention flood in which every candidate is denied would authorize every candidate before the + * admitted cap is ever reached. This limit caps the number of authorization decisions regardless of + * how many candidates are admitted, so denied mentions cannot force unbounded authorization work. + * Only available candidates count against it — unavailable mentions are filtered by the company-scoped + * hydration first and never consume an evaluation slot. It is always at least the admitted cap so the + * admitted cap remains reachable in the normal (non-flood) case. + */ +export const MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS = 50; + +type RunReferencedProjectRecord = Awaited< + ReturnType["listByIds"]> +>[number]; + +export interface RunReferencedProject { + projectId: string; + project: RunReferencedProjectRecord; +} + +export interface ResolvedRunReferencedProjects { + /** The anchor (primary) project — retains the existing git-worktree run path; never re-authorized here. */ + anchor: RunReferencedProject | null; + /** Additional read-only referenced projects that each passed per-project `project:read` authorization. */ + additional: RunReferencedProject[]; + /** Human-readable warnings for every referenced project that was dropped (unavailable, unauthorized, or capped). */ + warnings: string[]; +} + +export interface ResolveRunReferencedProjectsOptions { + companyId: string; + /** The run actor; every additional project is authorized against this actor. */ + actor: AuthorizationActor; + issues: Pick, "findMentionedProjectIds">; + projects: Pick, "listByIds">; + access: Pick, "decide">; + /** Override the additional-project cap (defaults to {@link MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS}). */ + maxAdditionalProjects?: number; + /** + * Override the candidate authorization fan-out cap — the maximum number of *available* candidates + * that are authorized (defaults to {@link MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS}). Always + * effectively raised to at least the admitted-project cap so the admitted cap stays reachable. + */ + maxCandidateEvaluations?: number; +} + +/** + * Produce the deduped, company-scoped, per-project-authorized referenced-project set + * `[anchor, ...additional]` for a run. + * + * The anchor keeps its existing issue/run authorization path and is never re-authorized or + * inherited by the additional projects. Every additional (mentioned) project must independently + * pass a fail-closed `project:read` authorization check against the run actor before it is + * admitted — any non-`allowed` decision, company mismatch, missing/unknown project, or thrown + * authorization error drops the project and appends a warning (the run always continues). + * + * Candidate evaluation is bounded twice, independently: at most + * {@link ResolveRunReferencedProjectsOptions.maxCandidateEvaluations} *available* candidates are ever + * hydrated and authorized (a fan-out guard against an adversarial same-company mention flood of denied + * projects), and at most {@link ResolveRunReferencedProjectsOptions.maxAdditionalProjects} of those are + * admitted. The evaluation cap bounds hydration as well as authorization: candidates are hydrated and + * availability-filtered in mention order in bounded batches, and hydration stops as soon as the + * evaluation window is filled with available candidates (or the mention set is exhausted), so hydration + * never processes the complete mention set — its cost is bounded by the window, not by mention volume. + * Availability filtering still runs before a candidate consumes an evaluation slot, so an unavailable + * mention (foreign-company, deleted, or malformed id) never occupies a slot or displaces a later + * authorized project. Available candidates beyond the evaluation window are left un-hydrated and dropped + * with a warning, never triggering an authorization decision. + */ +export async function resolveRunReferencedProjects( + issueId: string, + anchorProjectId: string | null, + opts: ResolveRunReferencedProjectsOptions, +): Promise { + const { companyId, actor, issues, projects, access } = opts; + const warnings: string[] = []; + const cap = Math.max(0, opts.maxAdditionalProjects ?? MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS); + // The evaluation cap bounds candidate hydration + authorization fan-out. It is always at least the + // admitted cap so the admitted cap stays reachable in the normal (non-flood) case. + const evaluationCap = Math.max( + cap, + opts.maxCandidateEvaluations ?? MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS, + ); + + // Company-scoped, deduped, order-preserving mention set (title + description + comment bodies). + // Run prep counts mentions in comments, so comment bodies are always included. + const mentionedIds = await issues.findMentionedProjectIds(issueId, { includeCommentBodies: true }); + + // Anchor wins: it keeps the full git-worktree path and is never re-authorized here, so drop it + // from the mention set. Preserve mention order while deduping the remaining candidates. + const allCandidateIds: string[] = []; + const seen = new Set(anchorProjectId ? [anchorProjectId] : []); + for (const projectId of mentionedIds) { + if (seen.has(projectId)) continue; + seen.add(projectId); + allCandidateIds.push(projectId); + } + + // Hydrate + availability-filter candidates in mention order, but never process more of the mention + // set than the evaluation window needs. Candidates are pulled in bounded batches sized to what the + // window still needs, and hydration stops as soon as `evaluationCap` *available* candidates are + // collected (or the mention set is exhausted). This bounds hydration by the evaluation window rather + // than by mention volume: an adversarial same-company mention flood can neither force an unbounded + // hydration query nor displace a later authorized project out of the window. `listByIds` filters by + // company, so each batch both fetches the records and performs availability filtering — a mention that + // did not resolve inside this company (foreign-company, deleted, or malformed id) is dropped here with + // a warning and never occupies an evaluation slot. The anchor is co-hydrated with the first batch (it + // was excluded from `allCandidateIds` above, so it never double-counts) and is never re-authorized. + const availableCandidates: RunReferencedProject[] = []; + let hydrationCursor = 0; + let anchorRecord: RunReferencedProjectRecord | null = null; + let anchorHydrated = false; + while (availableCandidates.length < evaluationCap && hydrationCursor < allCandidateIds.length) { + const need = evaluationCap - availableCandidates.length; + const batchCandidateIds = allCandidateIds.slice(hydrationCursor, hydrationCursor + need); + hydrationCursor += batchCandidateIds.length; + + const hydrateIds = + !anchorHydrated && anchorProjectId ? [anchorProjectId, ...batchCandidateIds] : batchCandidateIds; + const hydrated = await projects.listByIds(companyId, hydrateIds); + const byId = new Map(hydrated.map((project) => [project.id, project])); + + if (!anchorHydrated && anchorProjectId) { + anchorRecord = byId.get(anchorProjectId) ?? null; + anchorHydrated = true; + } + + for (const projectId of batchCandidateIds) { + const project = byId.get(projectId); + if (!project) { + warnings.push(`Referenced project ${projectId} was skipped because it is not available in this company.`); + continue; + } + availableCandidates.push({ projectId, project }); + } + } + + // Hydrate the anchor on its own if the candidate loop never ran (no mentions to co-hydrate it with). + if (!anchorHydrated && anchorProjectId) { + const hydrated = await projects.listByIds(companyId, [anchorProjectId]); + anchorRecord = hydrated.find((project) => project.id === anchorProjectId) ?? null; + anchorHydrated = true; + } + + const anchor: RunReferencedProject | null = + anchorRecord && anchorProjectId ? { projectId: anchorProjectId, project: anchorRecord } : null; + + // The loop already bounds `availableCandidates` to at most `evaluationCap` entries. Any mentions left + // un-hydrated past the window (the fan-out cap dropped them before hydration/authorization) are + // surfaced as a warning after the admit loop below. Denied candidates still consume this window (each + // costs exactly one authorization decision, which is what the cap bounds); unavailable mentions, + // filtered above, do not. + const candidates = availableCandidates; + const unevaluatedCandidateCount = allCandidateIds.length - hydrationCursor; + + // Admit candidates in mention order until the cap of successfully-authorized projects is reached. + // The cap bounds how many additional projects a run *materializes*, so it is counted against + // admitted projects only; denied mentions never use a slot. + const additional: RunReferencedProject[] = []; + let capReachedAtIndex: number | null = null; + for (let index = 0; index < candidates.length; index++) { + if (additional.length >= cap) { + capReachedAtIndex = index; + break; + } + + const { projectId, project } = candidates[index]!; + + let allowed = false; + try { + const decision = await access.decide({ + actor, + action: "project:read", + resource: { type: "project", companyId, projectId }, + scope: { projectId }, + }); + allowed = decision.allowed === true; + } catch { + // Fail-closed: an authorization error never admits a project. + allowed = false; + } + + if (!allowed) { + warnings.push(`Referenced project ${projectId} was skipped because it is not authorized for this run.`); + continue; + } + + additional.push({ projectId, project }); + } + + // Warn once if the admitted cap stopped us before every available candidate was considered. The + // skipped count includes both the still-unconsidered evaluated candidates and any available + // candidates that were dropped before evaluation by the fan-out cap above. + if (capReachedAtIndex !== null) { + const skipped = candidates.length - capReachedAtIndex + unevaluatedCandidateCount; + warnings.push( + `Only the first ${cap} referenced project(s) will be synced for this run; ${skipped} additional referenced project(s) were skipped.`, + ); + } else if (unevaluatedCandidateCount > 0) { + // The admitted cap was never reached (e.g. a flood of denied mentions), but the evaluation + // fan-out cap dropped available candidates before they could be authorized. + warnings.push( + `Only the first ${evaluationCap} referenced mention(s) were evaluated for this run; ${unevaluatedCandidateCount} additional referenced mention(s) were skipped without evaluation.`, + ); + } + + return { anchor, additional, warnings }; +} + function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; }