diff --git a/packages/shared/src/agent-eligibility.test.ts b/packages/shared/src/agent-eligibility.test.ts new file mode 100644 index 00000000000..20b3fff500c --- /dev/null +++ b/packages/shared/src/agent-eligibility.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + getAgentOrgChainHealth, + getAgentWorkEligibility, + isAgentAssignableToWork, + isAgentInvokable, + type AgentEligibilityAgent, +} from "./agent-eligibility.js"; + +const companyId = "company-1"; + +function agent(overrides: Partial = {}): AgentEligibilityAgent { + return { + id: "agent-1", + companyId, + name: "Coder", + status: "active", + reportsTo: "manager-1", + ...overrides, + }; +} + +describe("agent work eligibility", () => { + it("allows healthy active agents to accept work and be invoked", () => { + const agents = [ + agent(), + agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null }), + ]; + + expect(isAgentAssignableToWork({ agent: agents[0]!, agents })).toBe(true); + expect(isAgentInvokable({ agent: agents[0]!, agents })).toBe(true); + expect(getAgentWorkEligibility({ agent: agents[0]!, agents })).toMatchObject({ + assignable: true, + invokable: true, + assignabilityReason: "eligible", + invokabilityReason: "eligible", + orgChainHealth: { status: "healthy" }, + }); + }); + + it("blocks terminated and pending approval agents from assignment and invocation", () => { + const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null }); + + for (const status of ["terminated", "pending_approval"]) { + const target = agent({ status }); + const eligibility = getAgentWorkEligibility({ agent: target, agents: [target, manager] }); + + expect(eligibility.assignable).toBe(false); + expect(eligibility.invokable).toBe(false); + expect(eligibility.assignabilityReason).toBe(status); + expect(eligibility.invokabilityReason).toBe(status); + } + }); + + it("allows paused agents to keep assignments but blocks invocation", () => { + const target = agent({ status: "paused" }); + const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null }); + + expect(getAgentWorkEligibility({ agent: target, agents: [target, manager] })).toMatchObject({ + assignable: true, + invokable: false, + assignabilityReason: "eligible", + invokabilityReason: "paused", + }); + }); + + it("blocks active descendants of terminated ancestors and reports repair details", () => { + const target = agent({ id: "qa-2", name: "QA 2", status: "active", reportsTo: "cto-2" }); + const terminatedManager = agent({ + id: "cto-2", + name: "CTO 2", + status: "terminated", + reportsTo: "ceo-2", + }); + const terminatedRoot = agent({ + id: "ceo-2", + name: "CEO 2", + status: "terminated", + reportsTo: null, + }); + const agents = [target, terminatedManager, terminatedRoot]; + + const health = getAgentOrgChainHealth({ agent: target, agents }); + expect(health.status).toBe("invalid_org_chain"); + expect(health.reason).toBe("terminated_ancestor"); + expect(health.fullChain).toEqual([ + expect.objectContaining({ id: "qa-2", name: "QA 2", relation: "self", depth: 0 }), + expect.objectContaining({ id: "cto-2", name: "CTO 2", status: "terminated", relation: "ancestor", depth: 1 }), + expect.objectContaining({ id: "ceo-2", name: "CEO 2", status: "terminated", relation: "ancestor", depth: 2 }), + ]); + expect(health.firstInvalidAncestor).toEqual({ id: "cto-2", name: "CTO 2", status: "terminated" }); + expect(health.invalidAncestors).toEqual([ + { id: "cto-2", name: "CTO 2", status: "terminated" }, + { id: "ceo-2", name: "CEO 2", status: "terminated" }, + ]); + expect(health.repairGuidance).toContain("QA 2 reports through terminated ancestor CTO 2"); + + const eligibility = getAgentWorkEligibility({ agent: target, agents }); + expect(eligibility.assignable).toBe(false); + expect(eligibility.invokable).toBe(false); + expect(eligibility.assignabilityReason).toBe("invalid_org_chain"); + expect(eligibility.invokabilityReason).toBe("invalid_org_chain"); + }); + + it("blocks agents whose manager is missing from the company org", () => { + const target = agent({ id: "qa-3", name: "QA 3", status: "active", reportsTo: "missing-manager" }); + + const health = getAgentOrgChainHealth({ agent: target, agents: [target] }); + expect(health.status).toBe("invalid_org_chain"); + expect(health.reason).toBe("missing_manager"); + expect(health.fullChain).toEqual([ + expect.objectContaining({ id: "qa-3", relation: "self", depth: 0 }), + expect.objectContaining({ id: "missing-manager", status: "missing", relation: "ancestor", depth: 1 }), + ]); + expect(health.repairGuidance).toContain("QA 3 reports to missing manager missing-manager"); + + const eligibility = getAgentWorkEligibility({ agent: target, agents: [target] }); + expect(eligibility.assignable).toBe(false); + expect(eligibility.invokable).toBe(false); + expect(eligibility.assignabilityReason).toBe("invalid_org_chain"); + expect(eligibility.invokabilityReason).toBe("invalid_org_chain"); + }); + + it("blocks agents with reporting cycles", () => { + const target = agent({ id: "qa-4", name: "QA 4", status: "active", reportsTo: "cto-4" }); + const manager = agent({ id: "cto-4", name: "CTO 4", status: "active", reportsTo: "qa-4" }); + const agents = [target, manager]; + + const health = getAgentOrgChainHealth({ agent: target, agents }); + expect(health.status).toBe("invalid_org_chain"); + expect(health.reason).toBe("cycle"); + expect(health.fullChain).toEqual([ + expect.objectContaining({ id: "qa-4", relation: "self", depth: 0 }), + expect.objectContaining({ id: "cto-4", relation: "ancestor", depth: 1 }), + expect.objectContaining({ id: "qa-4", status: "cycle", relation: "ancestor", depth: 2 }), + ]); + expect(health.repairGuidance).toContain("QA 4 has a cycle in its reporting chain"); + + const eligibility = getAgentWorkEligibility({ agent: target, agents }); + expect(eligibility.assignable).toBe(false); + expect(eligibility.invokable).toBe(false); + expect(eligibility.assignabilityReason).toBe("invalid_org_chain"); + expect(eligibility.invokabilityReason).toBe("invalid_org_chain"); + }); +}); diff --git a/packages/shared/src/agent-eligibility.ts b/packages/shared/src/agent-eligibility.ts new file mode 100644 index 00000000000..d1e9c266a81 --- /dev/null +++ b/packages/shared/src/agent-eligibility.ts @@ -0,0 +1,240 @@ +import type { AgentStatus } from "./constants.js"; + +export type AgentEligibilityLifecycleReason = + | "eligible" + | "terminated" + | "pending_approval" + | "paused" + | "invalid_org_chain"; + +export interface AgentEligibilityAgent { + id: string; + companyId: string; + name: string; + status: AgentStatus | string; + reportsTo?: string | null; +} + +export interface AgentOrgChainEntry { + id: string; + companyId: string; + name: string; + status: AgentStatus | string; + reportsTo: string | null; + depth: number; + relation: "self" | "ancestor"; +} + +export interface AgentInvalidOrgChainAncestor { + id: string; + name: string; + status: AgentStatus | string; +} + +export type AgentOrgChainInvalidReason = + | "healthy" + | "terminated_ancestor" + | "missing_manager" + | "cycle"; + +export interface AgentOrgChainHealth { + status: "healthy" | "invalid_org_chain"; + reason: AgentOrgChainInvalidReason; + fullChain: AgentOrgChainEntry[]; + firstInvalidAncestor: AgentInvalidOrgChainAncestor | null; + invalidAncestors: AgentInvalidOrgChainAncestor[]; + repairGuidance: string | null; +} + +export interface AgentWorkEligibility { + assignable: boolean; + invokable: boolean; + assignabilityReason: AgentEligibilityLifecycleReason; + invokabilityReason: AgentEligibilityLifecycleReason; + orgChainHealth: AgentOrgChainHealth; +} + +const NON_ASSIGNABLE_AGENT_STATUSES = new Set(["terminated", "pending_approval"]); +const NON_INVOKABLE_AGENT_STATUSES = new Set(["terminated", "pending_approval", "paused"]); +const ASSIGNABLE_AGENT_STATUSES = new Set(["active", "paused", "idle", "running", "error"]); +const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); + +export function isAgentStatusAssignableToWork(status: AgentStatus | string): boolean { + return ASSIGNABLE_AGENT_STATUSES.has(status) && !NON_ASSIGNABLE_AGENT_STATUSES.has(status); +} + +export function isAgentStatusInvokable(status: AgentStatus | string): boolean { + return INVOKABLE_AGENT_STATUSES.has(status) && !NON_INVOKABLE_AGENT_STATUSES.has(status); +} + +function chainEntry( + agent: AgentEligibilityAgent, + depth: number, + relation: AgentOrgChainEntry["relation"], +): AgentOrgChainEntry { + return { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + status: agent.status, + reportsTo: agent.reportsTo ?? null, + depth, + relation, + }; +} + +function invalidAncestor(agent: AgentEligibilityAgent): AgentInvalidOrgChainAncestor { + return { + id: agent.id, + name: agent.name, + status: agent.status, + }; +} + +function buildRepairGuidance( + agent: AgentEligibilityAgent, + firstInvalidAncestor: AgentInvalidOrgChainAncestor, +): string { + if (firstInvalidAncestor.status === "missing") { + return [ + `${agent.name} reports to missing manager ${firstInvalidAncestor.id}.`, + `Reassign ${agent.name} or the nearest affected ancestor under an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`, + ].join(" "); + } + if (firstInvalidAncestor.status === "cycle") { + return [ + `${agent.name} has a cycle in its reporting chain at ${firstInvalidAncestor.name}.`, + `Break the cycle by assigning one affected agent to an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`, + ].join(" "); + } + return [ + `${agent.name} reports through terminated ancestor ${firstInvalidAncestor.name}.`, + `Reassign ${agent.name} or the nearest affected ancestor under an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`, + ].join(" "); +} + +export function getAgentOrgChainHealth(input: { + agent: AgentEligibilityAgent; + agents: AgentEligibilityAgent[]; +}): AgentOrgChainHealth { + const byId = new Map(input.agents.map((agent) => [agent.id, agent])); + const fullChain: AgentOrgChainEntry[] = [chainEntry(input.agent, 0, "self")]; + const invalidAncestors: AgentInvalidOrgChainAncestor[] = []; + const seen = new Set([input.agent.id]); + + let current = input.agent; + let depth = 1; + while (current.reportsTo) { + if (seen.has(current.reportsTo)) { + const cycleAgent = byId.get(current.reportsTo); + const invalid = { + id: current.reportsTo, + name: cycleAgent?.name ?? current.reportsTo, + status: "cycle", + }; + fullChain.push({ + id: invalid.id, + companyId: input.agent.companyId, + name: invalid.name, + status: invalid.status, + reportsTo: cycleAgent?.reportsTo ?? null, + depth, + relation: "ancestor", + }); + invalidAncestors.push(invalid); + break; + } + seen.add(current.reportsTo); + + const parent = byId.get(current.reportsTo); + if (!parent || parent.companyId !== input.agent.companyId) { + const invalid = { + id: current.reportsTo, + name: current.reportsTo, + status: "missing", + }; + fullChain.push({ + id: invalid.id, + companyId: input.agent.companyId, + name: invalid.name, + status: invalid.status, + reportsTo: null, + depth, + relation: "ancestor", + }); + invalidAncestors.push(invalid); + break; + } + + fullChain.push(chainEntry(parent, depth, "ancestor")); + if (parent.status === "terminated") { + invalidAncestors.push(invalidAncestor(parent)); + } + + current = parent; + depth += 1; + } + + const firstInvalidAncestor = invalidAncestors[0] ?? null; + return { + status: firstInvalidAncestor ? "invalid_org_chain" : "healthy", + reason: firstInvalidAncestor + ? firstInvalidAncestor.status === "missing" + ? "missing_manager" + : firstInvalidAncestor.status === "cycle" + ? "cycle" + : "terminated_ancestor" + : "healthy", + fullChain, + firstInvalidAncestor, + invalidAncestors, + repairGuidance: firstInvalidAncestor + ? buildRepairGuidance(input.agent, firstInvalidAncestor) + : null, + }; +} + +export function getAgentWorkEligibility(input: { + agent: AgentEligibilityAgent; + agents: AgentEligibilityAgent[]; +}): AgentWorkEligibility { + const orgChainHealth = getAgentOrgChainHealth(input); + const assignabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusAssignableToWork(input.agent.status) + ? input.agent.status === "terminated" + ? "terminated" + : "pending_approval" + : orgChainHealth.status === "invalid_org_chain" + ? "invalid_org_chain" + : "eligible"; + const invokabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusInvokable(input.agent.status) + ? input.agent.status === "terminated" + ? "terminated" + : input.agent.status === "pending_approval" + ? "pending_approval" + : "paused" + : orgChainHealth.status === "invalid_org_chain" + ? "invalid_org_chain" + : "eligible"; + + return { + assignable: assignabilityReason === "eligible", + invokable: invokabilityReason === "eligible", + assignabilityReason, + invokabilityReason, + orgChainHealth, + }; +} + +export function isAgentAssignableToWork(input: { + agent: AgentEligibilityAgent; + agents: AgentEligibilityAgent[]; +}): boolean { + return getAgentWorkEligibility(input).assignable; +} + +export function isAgentInvokable(input: { + agent: AgentEligibilityAgent; + agents: AgentEligibilityAgent[]; +}): boolean { + return getAgentWorkEligibility(input).invokable; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 559e5ddd8c6..7744af39364 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,19 @@ export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; +export { + getAgentOrgChainHealth, + getAgentWorkEligibility, + isAgentAssignableToWork, + isAgentInvokable, + isAgentStatusAssignableToWork, + isAgentStatusInvokable, + type AgentEligibilityAgent, + type AgentEligibilityLifecycleReason, + type AgentInvalidOrgChainAncestor, + type AgentOrgChainEntry, + type AgentOrgChainHealth, + type AgentOrgChainInvalidReason, + type AgentWorkEligibility, +} from "./agent-eligibility.js"; export { asBoolean, asString, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6b72c752643..b1ea7661f26 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -142,6 +142,15 @@ export type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult, } from "./agent.js"; +export type { + AgentEligibilityAgent, + AgentEligibilityLifecycleReason, + AgentInvalidOrgChainAncestor, + AgentOrgChainEntry, + AgentOrgChainHealth, + AgentOrgChainInvalidReason, + AgentWorkEligibility, +} from "../agent-eligibility.js"; export type { AssetImage } from "./asset.js"; export type { CreateDocumentAnnotationCommentRequest, diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 1fdae88939d..a74b438c0f6 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -168,6 +168,183 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await tempDb?.cleanup(); }); + async function seedAssignableAgentCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + function agentRow(companyId: string, input: { + id: string; + name: string; + status?: string; + reportsTo?: string | null; + }) { + return { + id: input.id, + companyId, + name: input.name, + role: "engineer", + status: input.status ?? "active", + reportsTo: input.reportsTo ?? null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }; + } + + it("rejects direct terminated assignees with structured conflict details", async () => { + const companyId = await seedAssignableAgentCompany(); + const terminatedAgentId = randomUUID(); + await db.insert(agents).values(agentRow(companyId, { + id: terminatedAgentId, + name: "TerminatedCoder", + status: "terminated", + })); + + await expect(svc.create(companyId, { + title: "Do not assign this", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: terminatedAgentId, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId: terminatedAgentId, + }, + }); + }); + + it("rejects invalid ancestor-chain assignees and preserves the existing assignment", async () => { + const companyId = await seedAssignableAgentCompany(); + const activeAgentId = randomUUID(); + const terminatedManagerId = randomUUID(); + const blockedAgentId = randomUUID(); + await db.insert(agents).values([ + agentRow(companyId, { id: activeAgentId, name: "ActiveCoder" }), + agentRow(companyId, { + id: terminatedManagerId, + name: "TerminatedManager", + status: "terminated", + }), + agentRow(companyId, { + id: blockedAgentId, + name: "BlockedCoder", + reportsTo: terminatedManagerId, + }), + ]); + const issue = await svc.create(companyId, { + title: "Keep current assignment", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: activeAgentId, + }); + + await expect(svc.update(issue.id, { + assigneeAgentId: blockedAgentId, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "ancestor_terminated", + assigneeAgentId: blockedAgentId, + invalidAncestorAgentId: terminatedManagerId, + }, + }); + + const persisted = await db + .select({ assigneeAgentId: issues.assigneeAgentId }) + .from(issues) + .where(eq(issues.id, issue.id)) + .then((rows) => rows[0] ?? null); + expect(persisted?.assigneeAgentId).toBe(activeAgentId); + }); + + it("rejects checkout by a terminated agent before assigning the issue", async () => { + const companyId = await seedAssignableAgentCompany(); + const terminatedAgentId = randomUUID(); + await db.insert(agents).values(agentRow(companyId, { + id: terminatedAgentId, + name: "TerminatedCheckoutCoder", + status: "terminated", + })); + const issue = await svc.create(companyId, { + title: "Checkout must stay unassigned", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + }); + + await expect(svc.checkout(issue.id, terminatedAgentId, ["todo"], randomUUID())) + .rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId: terminatedAgentId, + }, + }); + + const persisted = await db + .select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status }) + .from(issues) + .where(eq(issues.id, issue.id)) + .then((rows) => rows[0] ?? null); + expect(persisted).toMatchObject({ + assigneeAgentId: null, + status: "todo", + }); + }); + + it("rejects moving an existing terminated assignment into progress without clearing it", async () => { + const companyId = await seedAssignableAgentCompany(); + const assigneeAgentId = randomUUID(); + await db.insert(agents).values(agentRow(companyId, { + id: assigneeAgentId, + name: "SoonTerminatedCoder", + })); + const issue = await svc.create(companyId, { + title: "Do not restart after termination", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId, + }); + await db.update(agents).set({ status: "terminated" }).where(eq(agents.id, assigneeAgentId)); + + await expect(svc.update(issue.id, { + status: "in_progress", + })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId, + }, + }); + + const persisted = await db + .select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status }) + .from(issues) + .where(eq(issues.id, issue.id)) + .then((rows) => rows[0] ?? null); + expect(persisted).toMatchObject({ + assigneeAgentId, + status: "todo", + }); + }); + it("returns issues an agent participated in across the supported signals", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index f78ab907c13..22756ff9727 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -539,6 +539,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { ).rejects.toMatchObject({ status: 409, message: "Cannot assign routines to terminated agents", + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId: agentId, + }, }); await expect(svc.get(routine.id)).resolves.toMatchObject({ description: "revision 2", @@ -546,6 +551,74 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); }); + it("blocks routine reassignment to agents under terminated managers", async () => { + const { agentId, companyId, routine, svc } = await seedFixture(); + const terminatedManagerId = randomUUID(); + const blockedAgentId = randomUUID(); + await db.insert(agents).values([ + { + id: terminatedManagerId, + companyId, + name: "TerminatedManager", + role: "manager", + status: "terminated", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: blockedAgentId, + companyId, + name: "BlockedRoutineCoder", + role: "engineer", + status: "active", + reportsTo: terminatedManagerId, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await expect(svc.update(routine.id, { + assigneeAgentId: blockedAgentId, + }, { userId: "board-user" })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "ancestor_terminated", + assigneeAgentId: blockedAgentId, + invalidAncestorAgentId: terminatedManagerId, + }, + }); + + await expect(svc.get(routine.id)).resolves.toMatchObject({ + assigneeAgentId: agentId, + }); + }); + + it("blocks manual routine runs when the persisted assignee is no longer assignable", async () => { + const { agentId, routine, svc } = await seedFixture(); + await db + .update(agents) + .set({ status: "terminated" }) + .where(eq(agents.id, agentId)); + + await expect(svc.runRoutine(routine.id, { + source: "manual", + payload: null, + variables: null, + }, { userId: "board-user" })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId: agentId, + }, + }); + }); + it("appends safe trigger metadata revisions without leaking webhook secrets", async () => { const { routine, svc } = await seedFixture(); const created = await svc.createTrigger(routine.id, { diff --git a/server/src/services/access.ts b/server/src/services/access.ts index ad58db87df4..1aaa53713cb 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -1,7 +1,6 @@ import { and, eq, inArray, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { - agents, companyMemberships, instanceUserRoles, issues, @@ -9,6 +8,7 @@ import { } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; import { conflict } from "../errors.js"; +import { assertAssignableAgent } from "./agent-assignability.js"; import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js"; import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js"; @@ -315,21 +315,7 @@ export function accessService(db: Db) { return; } - const agent = await tx - .select({ - id: agents.id, - companyId: agents.companyId, - status: agents.status, - }) - .from(agents) - .where(eq(agents.id, input.assigneeAgentId!)) - .then((rows) => rows[0] ?? null); - if (!agent || agent.companyId !== companyId) { - throw conflict("Replacement agent must belong to the same company"); - } - if (agent.status === "pending_approval" || agent.status === "terminated") { - throw conflict("Replacement agent must be assignable"); - } + await assertAssignableAgent(tx as Db, companyId, input.assigneeAgentId, { kind: "work" }); } async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) { diff --git a/server/src/services/agent-assignability.ts b/server/src/services/agent-assignability.ts new file mode 100644 index 00000000000..f3b48e3a1a0 --- /dev/null +++ b/server/src/services/agent-assignability.ts @@ -0,0 +1,157 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents } from "@paperclipai/db"; +import { + getAgentWorkEligibility, + type AgentEligibilityAgent, + type AgentOrgChainHealth, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; + +type AgentAssignmentKind = "work" | "routine"; + +type AssignabilityAgent = AgentEligibilityAgent; + +type AgentAssignmentConflictReason = + | "pending_approval" + | "assignee_terminated" + | "ancestor_terminated" + | "ancestor_missing" + | "ancestor_cross_company" + | "ancestor_cycle" + | "ancestor_depth_exceeded"; + +function assignmentMessage(kind: AgentAssignmentKind, reason: AgentAssignmentConflictReason) { + if (reason === "pending_approval") { + return kind === "routine" + ? "Cannot assign routines to pending approval agents" + : "Cannot assign work to pending approval agents"; + } + if (reason === "assignee_terminated") { + return kind === "routine" + ? "Cannot assign routines to terminated agents" + : "Cannot assign work to terminated agents"; + } + return kind === "routine" + ? "Cannot assign routines to agents with an invalid org chain" + : "Cannot assign work to agents with an invalid org chain"; +} + +function conflictDetails(input: { + companyId: string; + assigneeAgentId: string; + reason: AgentAssignmentConflictReason; + chain: AssignabilityAgent[]; + invalidAncestorAgentId?: string | null; + missingAncestorAgentId?: string | null; +}) { + return { + code: "agent_not_assignable", + reason: input.reason, + companyId: input.companyId, + assigneeAgentId: input.assigneeAgentId, + invalidAncestorAgentId: input.invalidAncestorAgentId ?? null, + missingAncestorAgentId: input.missingAncestorAgentId ?? null, + ancestorChain: input.chain.map((agent) => ({ + id: agent.id, + companyId: agent.companyId, + status: agent.status, + reportsTo: agent.reportsTo, + })), + }; +} + +async function getAgent(db: Db, agentId: string): Promise { + return db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + status: agents.status, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(eq(agents.id, agentId)) + .then((rows) => rows[0] ?? null); +} + +async function listCompanyAgents(db: Db, companyId: string): Promise { + return db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + status: agents.status, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(eq(agents.companyId, companyId)); +} + +function assignmentReasonFromHealth(health: AgentOrgChainHealth): AgentAssignmentConflictReason { + if (health.reason === "terminated_ancestor") return "ancestor_terminated"; + if (health.reason === "missing_manager") return "ancestor_missing"; + if (health.reason === "cycle") return "ancestor_cycle"; + return "ancestor_missing"; +} + +export async function assertAssignableAgent( + db: Db, + companyId: string, + agentId: string | null | undefined, + options: { kind?: AgentAssignmentKind } = {}, +) { + if (!agentId) return; + const kind = options.kind ?? "work"; + const assignee = await getAgent(db, agentId); + if (!assignee) throw notFound("Assignee agent not found"); + if (assignee.companyId !== companyId) { + throw unprocessable("Assignee must belong to same company"); + } + + const companyAgents = await listCompanyAgents(db, companyId); + const eligibility = getAgentWorkEligibility({ agent: assignee, agents: companyAgents }); + const chain = eligibility.orgChainHealth.fullChain.map((entry) => ({ + id: entry.id, + companyId: entry.companyId, + name: entry.name, + status: entry.status, + reportsTo: entry.reportsTo, + })); + + if (eligibility.assignable) return; + + if (eligibility.assignabilityReason === "pending_approval") { + throw conflict(assignmentMessage(kind, "pending_approval"), conflictDetails({ + companyId, + assigneeAgentId: agentId, + reason: "pending_approval", + chain, + })); + } + if (eligibility.assignabilityReason === "terminated") { + throw conflict(assignmentMessage(kind, "assignee_terminated"), conflictDetails({ + companyId, + assigneeAgentId: agentId, + reason: "assignee_terminated", + chain, + })); + } + + const reason = assignmentReasonFromHealth(eligibility.orgChainHealth); + const firstInvalidAncestor = eligibility.orgChainHealth.firstInvalidAncestor; + throw conflict(assignmentMessage(kind, reason), conflictDetails({ + companyId, + assigneeAgentId: agentId, + reason, + chain, + invalidAncestorAgentId: + firstInvalidAncestor && firstInvalidAncestor.status !== "missing" + ? firstInvalidAncestor.id + : null, + missingAncestorAgentId: + firstInvalidAncestor?.status === "missing" + ? firstInvalidAncestor.id + : null, + })); +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b7f91508a61..e4c01818ee7 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -75,6 +75,7 @@ import { redactSensitiveText } from "../redaction.js"; import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js"; import { getRunLogStore } from "./run-log-store.js"; import { getDefaultCompanyGoal } from "./goals.js"; +import { assertAssignableAgent } from "./agent-assignability.js"; import { isVerifiedIssueTreeControlInteractionWake, issueTreeControlService, @@ -3372,29 +3373,6 @@ export function issueService(db: Db) { }); } - async function assertAssignableAgent(companyId: string, agentId: string) { - const assignee = await db - .select({ - id: agents.id, - companyId: agents.companyId, - status: agents.status, - }) - .from(agents) - .where(eq(agents.id, agentId)) - .then((rows) => rows[0] ?? null); - - if (!assignee) throw notFound("Assignee agent not found"); - if (assignee.companyId !== companyId) { - throw unprocessable("Assignee must belong to same company"); - } - if (assignee.status === "pending_approval") { - throw conflict("Cannot assign work to pending approval agents"); - } - if (assignee.status === "terminated") { - throw conflict("Cannot assign work to terminated agents"); - } - } - async function isTreeHoldInteractionCheckoutAllowed( companyId: string, checkoutRunId: string | null, @@ -4733,7 +4711,7 @@ export function issueService(db: Db) { throw unprocessable("Issue can only have one assignee"); } if (data.assigneeAgentId) { - await assertAssignableAgent(companyId, data.assigneeAgentId); + await assertAssignableAgent(db, companyId, data.assigneeAgentId, { kind: "work" }); } if (data.assigneeUserId) { await assertAssignableUser(companyId, data.assigneeUserId); @@ -5021,8 +4999,11 @@ export function issueService(db: Db) { throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds }); } } - if (issueData.assigneeAgentId) { - await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId); + const shouldValidateNextAssignee = + Boolean(nextAssigneeAgentId) && + (issueData.assigneeAgentId !== undefined || patch.status === "in_progress"); + if (shouldValidateNextAssignee) { + await assertAssignableAgent(dbOrTx as Db, existing.companyId, nextAssigneeAgentId, { kind: "work" }); } if (issueData.assigneeUserId) { await assertAssignableUser(existing.companyId, issueData.assigneeUserId); @@ -5347,7 +5328,7 @@ export function issueService(db: Db) { .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null); if (!issueCompany) throw notFound("Issue not found"); - await assertAssignableAgent(issueCompany.companyId, agentId); + await assertAssignableAgent(db, issueCompany.companyId, agentId, { kind: "work" }); const now = new Date(); const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 44197312a71..7081377c5e2 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -52,6 +52,7 @@ import { logger } from "../middleware/logger.js"; import { getTelemetryClient } from "../telemetry.js"; import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; import { issueService } from "./issues.js"; +import { assertAssignableAgent } from "./agent-assignability.js"; import { secretService } from "./secrets.js"; import { getSecretProvider } from "../secrets/provider-registry.js"; import { parseCron, validateCron } from "./cron.js"; @@ -610,25 +611,12 @@ export function routineService( return routine; } - async function assertAssignableAgent(companyId: string, agentId: string | null | undefined) { - if (!agentId) return; - const agent = await db - .select({ id: agents.id, companyId: agents.companyId, status: agents.status }) - .from(agents) - .where(eq(agents.id, agentId)) - .then((rows) => rows[0] ?? null); - if (!agent) throw notFound("Assignee agent not found"); - if (agent.companyId !== companyId) throw unprocessable("Assignee must belong to same company"); - if (agent.status === "pending_approval") throw conflict("Cannot assign routines to pending approval agents"); - if (agent.status === "terminated") throw conflict("Cannot assign routines to terminated agents"); - } - async function assertRestorableAssignee( companyId: string, assigneeAgentId: string | null | undefined, actor: Actor, ) { - await assertAssignableAgent(companyId, assigneeAgentId); + await assertAssignableAgent(db, companyId, assigneeAgentId, { kind: "routine" }); if (actor.agentId && assigneeAgentId !== actor.agentId) { throw forbidden("Agents can only restore routine revisions assigned to themselves"); } @@ -1170,6 +1158,7 @@ export function routineService( if (!assigneeAgentId) { throw unprocessable("Default agent required"); } + await assertAssignableAgent(db, input.routine.companyId, assigneeAgentId, { kind: "routine" }); const automaticVariables: Record = {}; if (input.executionWorkspaceId && routineUsesWorkspaceBranch(input.routine)) { const workspace = await db @@ -1581,7 +1570,7 @@ export function routineService( create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise => { await assertProject(companyId, input.projectId ?? null); - await assertAssignableAgent(companyId, input.assigneeAgentId ?? null); + await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" }); if (input.goalId) await assertGoal(companyId, input.goalId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); const env = input.env === undefined || input.env === null @@ -1663,7 +1652,9 @@ export function routineService( patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables), ); if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId); - if (patch.assigneeAgentId !== undefined) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId); + if (patch.assigneeAgentId !== undefined || patch.status === "active") { + await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" }); + } if (patch.goalId) await assertGoal(existing.companyId, patch.goalId); if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId); assertRoutineVariableDefinitions(nextVariables); @@ -2187,7 +2178,8 @@ export function routineService( if (!routine) throw notFound("Routine not found"); if (routine.status === "archived") throw conflict("Routine is archived"); await assertProject(routine.companyId, input.projectId ?? null); - await assertAssignableAgent(routine.companyId, input.assigneeAgentId ?? null); + const assigneeAgentId = input.assigneeAgentId ?? routine.assigneeAgentId ?? null; + await assertAssignableAgent(db, routine.companyId, assigneeAgentId, { kind: "routine" }); const trigger = input.triggerId ? await getTriggerById(input.triggerId) : null; if (trigger && trigger.routineId !== routine.id) throw forbidden("Trigger does not belong to routine"); if (trigger && !trigger.enabled) throw conflict("Routine trigger is not active");