import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js"; import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { and, eq } from "drizzle-orm"; import { applyPendingMigrations, activityLog, agentConfigRevisions, agents, agentWakeupRequests, builtInManagedResources, cases, companies, companySkillVersions, companySkills, companyMemberships, createDb, heartbeatRunEvents, heartbeatRuns, issues, principalPermissionGrants, routines, routineTriggers, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; // Observe the Cloud lifecycle doorbell without any network: the real // implementation is env-gated (a no-op off Cloud), so these tests assert // WHEN the service rings it, not what the ring does. const notifyCloudSpy = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../services/cloud-lifecycle-sync.js", () => ({ isCloudPinnedPrimaryCompany: () => false, notifyCloudOfPrimaryCompanyLifecycleChange: notifyCloudSpy, })); import { startAgentLifecycle } from "../services/agent-lifecycle.js"; import { createLifecycleDriver } from "../services/agent-lifecycle-driver.js"; import { reserveRunBudget } from "../services/budget-reservations.js"; import { companyService } from "../services/companies.js"; import { deriveIssuePrefixBase } from "../services/issue-prefix.js"; import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js"; import { builtInAgentService, reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js"; const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_COMPANIES_TEST_DATABASE_URL"); const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres company service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } describeEmbeddedPostgres("companyService", () => { let db!: ReturnType; let lifecycleWorker: ReturnType; beforeEach(() => { lifecycleWorker = startAgentLifecycle(db, { ...createLifecycleDriver(db, {} as never), requiredPluginIds: async () => [], runPlugin: async () => "complete" }, () => true); }); async function insertAgents(input: typeof agents.$inferInsert | Array) { const rows = Array.isArray(input) ? input : [input]; await db.insert(agents).values(rows.map(row => ({ ...row, lifecycleState: ["paused", "pending_approval", "terminated"].includes(row.status ?? "") ? row.status! : "ready", lifecycleHolds: row.status === "paused" ? [row.pauseReason ?? "manual"] : [], }))); } let tempDb: Awaited> | null = null; beforeAll(async () => { if (externalUrl) await applyPendingMigrations(externalUrl); else tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-service-"); db = createDb(externalUrl ?? tempDb!.connectionString); }, 20_000); afterEach(async () => { await lifecycleWorker.stop(); await db.delete(routineTriggers); await db.delete(routines); await db.delete(builtInManagedResources); await db.delete(companySkillVersions); await db.delete(companySkills); await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(agentConfigRevisions); await db.delete(activityLog); await db.delete(agents); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(cases); await db.delete(issues); await db.delete(companies); }); afterAll(async () => { await db?.$client.end({ timeout: 1 }); await tempDb?.cleanup(); }); it("retries generated issue prefixes when Drizzle wraps the unique constraint error", async () => { await db.insert(companies).values({ name: "Aron Existing", issuePrefix: "ARO", }); const created = await companyService(db).create({ name: "Aron & Sharon", }); expect(created.issuePrefix).toBe("AROA"); const rows = await db.select({ issuePrefix: companies.issuePrefix }).from(companies); expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]); }); it("leaves unrelated company-hold projection out of name edits", async () => { const [company] = await db.insert(companies).values({ name: "Rename only", issuePrefix: "REN" }).returning(); const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Unrelated agent", status: "paused", lifecycleState: "paused", lifecycleHolds: ["manual", "company_paused"], pauseReason: "manual" }).returning(); await companyService(db).update(company.id, { description: "Updated description" }); expect((await db.select().from(agents).where(eq(agents.id, agent.id)))[0].lifecycleHolds) .toEqual(["manual", "company_paused"]); await lifecycleWorker.sweep(); expect((await db.select().from(agents).where(eq(agents.id, agent.id)))[0].lifecycleHolds).toEqual(["manual"]); }); it("does not auto-provision bundled built-in agents for a freshly created company", async () => { const created = await companyService(db).create({ name: "Fresh Company", }); // A new company starts clean: the Reflection Coach and Summarizer are // opt-in, not seeded by default for a new user. const agentRows = await db.select().from(agents).where(eq(agents.companyId, created.id)); expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata))).toHaveLength(0); // Startup reconcile leaves a fresh company untouched — nothing is created. await reconcileBuiltInAgentsOnStartup(db); const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id)); expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata))).toHaveLength(0); // The Reflection Coach remains available to enable on demand, and enabling // it materializes its bundled skill + paused routine. const enabled = await builtInAgentService(db).ensure(created.id, "reflection-coach"); expect(enabled.agent).toMatchObject({ name: "Reflection Coach", status: "paused", budgetMonthlyCents: 0, }); const [skill] = await db .select() .from(companySkills) .where(and( eq(companySkills.companyId, created.id), eq(companySkills.key, "paperclipai/bundled/paperclip-operations/reflection-coach"), )); expect(skill).toMatchObject({ slug: "reflection-coach", }); const [routine] = await db .select() .from(routines) .where(and(eq(routines.companyId, created.id), eq(routines.assigneeAgentId, enabled.agentId!))); expect(routine).toMatchObject({ status: "paused", assigneeAgentId: enabled.agentId, originKind: "built_in_agent_bundle", originId: "reflection-coach:recent-agent-reflection", }); const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, routine!.id)); expect(trigger).toMatchObject({ kind: "schedule", enabled: false, }); }); it("archives companies by pausing runnable agents and cancelling active runs", async () => { const companyId = randomUUID(); const runningAgentId = randomUUID(); const idleAgentId = randomUUID(); const errorAgentId = randomUUID(); const pausedAgentId = randomUUID(); const pendingAgentId = randomUUID(); const terminatedAgentId = randomUUID(); const wakeupRequestId = randomUUID(); const runId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Archive Test Co", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents([ { id: runningAgentId, companyId, name: "Running Agent", role: "engineer", status: "running", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: idleAgentId, companyId, name: "Idle Agent", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: errorAgentId, companyId, name: "Error Agent", role: "engineer", status: "error", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: pausedAgentId, companyId, name: "Paused Agent", role: "engineer", status: "paused", pauseReason: "manual", pausedAt: new Date("2026-06-01T00:00:00Z"), adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: pendingAgentId, companyId, name: "Pending Agent", role: "engineer", status: "pending_approval", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: terminatedAgentId, companyId, name: "Terminated Agent", role: "engineer", status: "terminated", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, ]); await db.insert(agentWakeupRequests).values({ id: wakeupRequestId, companyId, agentId: runningAgentId, source: "timer", status: "queued", }); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: runningAgentId, invocationSource: "timer", status: "running", wakeupRequestId, }); const archived = await companyService(db).archive(companyId, { actorType: "user", actorId: "test-user", agentId: null, runId: null, }); expect(archived?.status).toBe("archived"); // The request commits the admission block; stopping providers is owed work. expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0].status).toBe("running"); expect(await lifecycleWorker.hasPending()).toBe(true); await lifecycleWorker.sweep(); const archiveActivity = await db .select({ actorType: activityLog.actorType, actorId: activityLog.actorId, details: activityLog.details, }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.archived"), )); expect(archiveActivity).toHaveLength(1); expect(archiveActivity[0]).toMatchObject({ actorType: "user", actorId: "test-user", details: { agentsPauseRequested: 3, runsCancellationRequested: 1 }, }); const rows = await db .select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason, }) .from(agents); const byId = new Map(rows.map((row) => [row.id, row])); expect(byId.get(runningAgentId)).toMatchObject({ status: "paused", pauseReason: "company_archived" }); expect(byId.get(idleAgentId)).toMatchObject({ status: "paused", pauseReason: "company_archived" }); expect(byId.get(errorAgentId)).toMatchObject({ status: "paused", pauseReason: "company_archived" }); expect(byId.get(pausedAgentId)).toMatchObject({ status: "paused", pauseReason: "manual" }); expect(byId.get(pendingAgentId)).toMatchObject({ status: "pending_approval", pauseReason: null }); expect(byId.get(terminatedAgentId)).toMatchObject({ status: "terminated", pauseReason: null }); const run = await db .select({ status: heartbeatRuns.status, error: heartbeatRuns.error, }) .from(heartbeatRuns) .then((result) => result[0] ?? null); expect(run).toMatchObject({ status: "cancelled", error: "Cancelled because the company was archived", }); const wakeup = await db .select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error, }) .from(agentWakeupRequests) .then((result) => result[0] ?? null); expect(wakeup).toMatchObject({ status: "cancelled", error: "Cancelled because the company was archived", }); }); it.each(["archive", "pause"] as const)("preserves owed stops across immediate %s and reactivation", async action => { const companyId = randomUUID(); const agentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Rapid lifecycle", issuePrefix: `R${companyId.slice(0, 6)}` }); await insertAgents({ id: agentId, companyId, name: "Running target", adapterType: "process", status: "running" }); const [original] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", invocationSource: "on_demand" }).returning(); const service = companyService(db); if (action === "archive") await service.archive(companyId); else await service.update(companyId, { status: "paused" }); expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]).toMatchObject({ lifecycleState: "pausing", status: "paused" }); expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, original.id)))[0].status).toBe("running"); await service.update(companyId, { status: "active" }); const [waiting] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "queued", invocationSource: "on_demand" }).returning(); await expect(reserveRunBudget(db, companyId, waiting.id, null)).rejects.toThrow(/paused|not ready/); await lifecycleWorker.sweep(); expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, original.id)))[0].status).toBe("cancelled"); expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]).toMatchObject({ lifecycleState: "ready", status: "idle" }); const [fresh] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "queued", invocationSource: "on_demand" }).returning(); await expect(reserveRunBudget(db, companyId, fresh.id, null)).resolves.toMatchObject({ runId: fresh.id, reused: false }); }); it("reactivates only agents paused because the company was archived", async () => { const companyId = randomUUID(); const archivedPausedAgentId = randomUUID(); const manualPausedAgentId = randomUUID(); const pendingAgentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Reactivate Test Co", status: "archived", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents([ { id: archivedPausedAgentId, companyId, name: "Archived Paused Agent", role: "engineer", status: "paused", pauseReason: "company_archived", pausedAt: new Date("2026-06-01T00:00:00Z"), adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: manualPausedAgentId, companyId, name: "Manual Paused Agent", role: "engineer", status: "paused", pauseReason: "manual", pausedAt: new Date("2026-06-01T00:00:00Z"), adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: pendingAgentId, companyId, name: "Pending Approval Agent", role: "engineer", status: "pending_approval", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, ]); const reactivated = await companyService(db).update( companyId, { status: "active" }, { actorType: "user", actorId: "test-user", agentId: null, runId: null }, ); expect(reactivated?.status).toBe("active"); await lifecycleWorker.sweep(); const reactivateActivity = await db .select({ actorType: activityLog.actorType, actorId: activityLog.actorId, details: activityLog.details, }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.reactivated"), )); expect(reactivateActivity).toHaveLength(1); expect(reactivateActivity[0]).toMatchObject({ actorType: "user", actorId: "test-user", details: { agentsRestored: 1 }, }); const rows = await db .select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason, pausedAt: agents.pausedAt, }) .from(agents); const byId = new Map(rows.map((row) => [row.id, row])); expect(byId.get(archivedPausedAgentId)).toMatchObject({ status: "idle", pauseReason: null, pausedAt: null, }); expect(byId.get(manualPausedAgentId)).toMatchObject({ status: "paused", pauseReason: "manual", }); expect(byId.get(pendingAgentId)).toMatchObject({ status: "pending_approval", pauseReason: null, }); }); it("runs the archive cascade when update() transitions a company to archived", async () => { const companyId = randomUUID(); const runningAgentId = randomUUID(); const idleAgentId = randomUUID(); const pendingAgentId = randomUUID(); const wakeupRequestId = randomUUID(); const runId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Update Archive Test Co", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents([ { id: runningAgentId, companyId, name: "Running Agent", role: "engineer", status: "running", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: idleAgentId, companyId, name: "Idle Agent", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: pendingAgentId, companyId, name: "Pending Agent", role: "engineer", status: "pending_approval", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, ]); await db.insert(agentWakeupRequests).values({ id: wakeupRequestId, companyId, agentId: runningAgentId, source: "timer", status: "queued", }); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: runningAgentId, invocationSource: "timer", status: "running", wakeupRequestId, }); const archived = await companyService(db).update( companyId, { status: "archived" }, { actorType: "user", actorId: "test-user", agentId: null, runId: null }, ); expect(archived?.status).toBe("archived"); await lifecycleWorker.sweep(); const rows = await db .select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason }) .from(agents); const byId = new Map(rows.map((row) => [row.id, row])); expect(byId.get(runningAgentId)).toMatchObject({ status: "paused", pauseReason: "company_archived" }); expect(byId.get(idleAgentId)).toMatchObject({ status: "paused", pauseReason: "company_archived" }); expect(byId.get(pendingAgentId)).toMatchObject({ status: "pending_approval", pauseReason: null }); const run = await db .select({ status: heartbeatRuns.status, error: heartbeatRuns.error }) .from(heartbeatRuns) .then((result) => result[0] ?? null); expect(run).toMatchObject({ status: "cancelled", error: "Cancelled because the company was archived", }); const archiveActivity = await db .select({ actorType: activityLog.actorType, actorId: activityLog.actorId, details: activityLog.details, }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.archived"), )); expect(archiveActivity).toHaveLength(1); expect(archiveActivity[0]).toMatchObject({ actorType: "user", actorId: "test-user", details: { agentsPauseRequested: 2, runsCancellationRequested: 1 }, }); }); it("reactivates company_archived agents even when going via paused state (archived → paused → active)", async () => { const companyId = randomUUID(); const archivedPausedAgentId = randomUUID(); const manualPausedAgentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Indirect Reactivate Test Co", status: "paused", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents([ { id: archivedPausedAgentId, companyId, name: "Archived Paused Agent", role: "engineer", status: "paused", pauseReason: "company_archived", pausedAt: new Date("2026-06-01T00:00:00Z"), adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, { id: manualPausedAgentId, companyId, name: "Manual Paused Agent", role: "engineer", status: "paused", pauseReason: "manual", pausedAt: new Date("2026-06-01T00:00:00Z"), adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }, ]); const reactivated = await companyService(db).update( companyId, { status: "active" }, { actorType: "user", actorId: "test-user", agentId: null, runId: null }, ); expect(reactivated?.status).toBe("active"); await lifecycleWorker.sweep(); const rows = await db .select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason }) .from(agents); const byId = new Map(rows.map((row) => [row.id, row])); expect(byId.get(archivedPausedAgentId)).toMatchObject({ status: "idle", pauseReason: null }); expect(byId.get(manualPausedAgentId)).toMatchObject({ status: "paused", pauseReason: "manual" }); const reactivateActivity = await db .select({ details: activityLog.details }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.reactivated"), )); expect(reactivateActivity).toHaveLength(1); expect(reactivateActivity[0]).toMatchObject({ details: { agentsRestored: 1 } }); }); it("emits company.reactivated for archived → active even when no agents need restoring", async () => { const companyId = randomUUID(); const terminatedAgentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Empty Reactivate Co", status: "archived", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents({ id: terminatedAgentId, companyId, name: "Terminated Agent", role: "engineer", status: "terminated", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); const reactivated = await companyService(db).update( companyId, { status: "active" }, { actorType: "user", actorId: "test-user", agentId: null, runId: null }, ); expect(reactivated?.status).toBe("active"); const reactivateActivity = await db .select({ details: activityLog.details }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.reactivated"), )); expect(reactivateActivity).toHaveLength(1); expect(reactivateActivity[0]).toMatchObject({ details: { agentsRestored: 0 } }); }); it("does not emit company.reactivated when paused → active restores no archive-paused agents", async () => { const companyId = randomUUID(); const manualPausedAgentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Plain Unpause Co", status: "paused", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents({ id: manualPausedAgentId, companyId, name: "Manual Paused Agent", role: "engineer", status: "paused", pauseReason: "manual", pausedAt: new Date("2026-06-01T00:00:00Z"), adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); const reactivated = await companyService(db).update( companyId, { status: "active" }, { actorType: "user", actorId: "test-user", agentId: null, runId: null }, ); expect(reactivated?.status).toBe("active"); const reactivateActivity = await db .select({ id: activityLog.id }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.reactivated"), )); expect(reactivateActivity).toHaveLength(0); const agent = await db .select({ status: agents.status, pauseReason: agents.pauseReason }) .from(agents) .then((rows) => rows[0] ?? null); expect(agent).toMatchObject({ status: "paused", pauseReason: "manual" }); }); it("cancels orphan queued wakeup requests with no runId during archive", async () => { const companyId = randomUUID(); const agentId = randomUUID(); const orphanWakeupId = randomUUID(); const runWakeupId = randomUUID(); const runId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Orphan Wakeup Co", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents({ id: agentId, companyId, name: "Idle Agent", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); await db.insert(agentWakeupRequests).values([ { id: orphanWakeupId, companyId, agentId, source: "automation", status: "queued", }, { id: runWakeupId, companyId, agentId, source: "timer", status: "queued", }, ]); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "timer", status: "running", wakeupRequestId: runWakeupId, }); const archived = await companyService(db).archive(companyId, { actorType: "user", actorId: "test-user", agentId: null, runId: null, }); expect(archived?.status).toBe("archived"); await lifecycleWorker.sweep(); const wakeups = await db .select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status, error: agentWakeupRequests.error, }) .from(agentWakeupRequests); const byId = new Map(wakeups.map((row) => [row.id, row])); expect(byId.get(orphanWakeupId)).toMatchObject({ status: "cancelled", error: "Cancelled because the company was archived", }); expect(byId.get(runWakeupId)).toMatchObject({ status: "cancelled", error: "Cancelled because the company was archived", }); }); it("archive() is idempotent — re-archiving emits no second cascade or activity entry", async () => { const companyId = randomUUID(); const agentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Idempotent Archive Test Co", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents({ id: agentId, companyId, name: "Idle Agent", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); const actor = { actorType: "user" as const, actorId: "test-user", agentId: null, runId: null }; const first = await companyService(db).archive(companyId, actor); expect(first?.status).toBe("archived"); const second = await companyService(db).archive(companyId, actor); expect(second?.status).toBe("archived"); const archiveActivity = await db .select({ details: activityLog.details }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.archived"), )); expect(archiveActivity).toHaveLength(1); expect(archiveActivity[0]).toMatchObject({ details: { agentsPauseRequested: 1, runsCancellationRequested: 0 } }); }); it("rings the Cloud lifecycle doorbell exactly on archived-boundary transitions", async () => { notifyCloudSpy.mockClear(); const companyId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Doorbell Test Co", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); const svc = companyService(db); const actor = { actorType: "user" as const, actorId: "test-user", agentId: null, runId: null }; // A plain edit never rings. await svc.update(companyId, { name: "Doorbell Test Co (renamed)" }, actor); expect(notifyCloudSpy).not.toHaveBeenCalled(); // archive() rings once; re-archiving (no transition) does not. await svc.archive(companyId, actor); expect(notifyCloudSpy).toHaveBeenCalledTimes(1); expect(notifyCloudSpy).toHaveBeenLastCalledWith(companyId); await svc.archive(companyId, actor); expect(notifyCloudSpy).toHaveBeenCalledTimes(1); // Unarchiving through update() rings again. await svc.update(companyId, { status: "active" }, actor); expect(notifyCloudSpy).toHaveBeenCalledTimes(2); // update() into archived rings too (the status-patch archive path). await svc.update(companyId, { status: "archived" }, actor); expect(notifyCloudSpy).toHaveBeenCalledTimes(3); }); it("runs the archive cascade when update() transitions a paused company to archived", async () => { const companyId = randomUUID(); const idleAgentId = randomUUID(); const runId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paused To Archived Test Co", status: "paused", issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, requireBoardApprovalForNewAgents: false, }); await insertAgents({ id: idleAgentId, companyId, name: "Idle Agent", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: idleAgentId, invocationSource: "timer", status: "queued", }); const archived = await companyService(db).update( companyId, { status: "archived" }, { actorType: "user", actorId: "test-user", agentId: null, runId: null }, ); expect(archived?.status).toBe("archived"); await lifecycleWorker.sweep(); const agent = await db .select({ status: agents.status, pauseReason: agents.pauseReason }) .from(agents) .then((rows) => rows[0] ?? null); expect(agent).toMatchObject({ status: "paused", pauseReason: "company_archived" }); const run = await db .select({ status: heartbeatRuns.status }) .from(heartbeatRuns) .then((rows) => rows[0] ?? null); expect(run?.status).toBe("cancelled"); const archiveActivity = await db .select({ details: activityLog.details }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.archived"), )); expect(archiveActivity).toHaveLength(1); expect(archiveActivity[0]).toMatchObject({ details: { agentsPauseRequested: 1, runsCancellationRequested: 1 }, }); }); it("getById returns null (not a query error) for non-UUID refs", async () => { const svc = companyService(db); await expect(svc.getById("tumbly-haus-creative")).resolves.toBeNull(); await expect(svc.getById("not-a-uuid")).resolves.toBeNull(); await expect(svc.getById("")).resolves.toBeNull(); }); describe("issue prefix re-derivation on rename", () => { const TEST_ACTOR = { actorType: "user" as const, actorId: "test-user", agentId: null, runId: null, }; beforeEach(() => { // The tenant server token is the managed-instance signal the prefix // re-derivation gates on. process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; }); afterEach(() => { delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; }); async function seedCompanyWithWork(name: string, issuePrefix: string) { const [company] = await db .insert(companies) .values({ name, issuePrefix }) .returning({ id: companies.id }); const companyId = company!.id; await db.insert(issues).values([ { companyId, title: "First", issueNumber: 1, identifier: `${issuePrefix}-1` }, { companyId, title: "Second", issueNumber: 12, identifier: `${issuePrefix}-12` }, // A pre-identifier issue must survive the re-key untouched. { companyId, title: "Unnumbered", issueNumber: null, identifier: null }, ]); await db.insert(cases).values({ companyId, caseNumber: 3, identifier: `${issuePrefix}-C3`, caseType: "decision", title: "A case", }); return companyId; } // Nulls last, so an issue that never got an identifier stays visible in // the assertion instead of sorting unpredictably. function sortIdentifiers(values: (string | null)[]) { return [...values].sort((a, b) => { if (a === null) return b === null ? 0 : 1; if (b === null) return -1; return a.localeCompare(b); }); } async function readIdentifiers(companyId: string) { const issueRows = await db .select({ identifier: issues.identifier }) .from(issues) .where(eq(issues.companyId, companyId)); const caseRows = await db .select({ identifier: cases.identifier }) .from(cases) .where(eq(cases.companyId, companyId)); return { issues: sortIdentifiers(issueRows.map((row) => row.identifier)), cases: sortIdentifiers(caseRows.map((row) => row.identifier)), }; } it("re-derives the prefix and re-keys both identifier tables on a managed instance", async () => { const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); const updated = await companyService(db).update( companyId, { name: "Northwind Traders" }, TEST_ACTOR, ); expect(updated).toMatchObject({ name: "Northwind Traders", issuePrefix: "NOR" }); await expect(readIdentifiers(companyId)).resolves.toEqual({ issues: ["NOR-1", "NOR-12", null], cases: ["NOR-C3"], }); const logged = await db .select({ details: activityLog.details }) .from(activityLog) .where(and( eq(activityLog.companyId, companyId), eq(activityLog.action, "company.updated"), )); expect(logged).toHaveLength(1); expect(logged[0]).toMatchObject({ details: { source: "company_rename", reason: "issue_prefix_rederived", previousIssuePrefix: "ACM", issuePrefix: "NOR", issuesRekeyed: 2, casesRekeyed: 1, }, }); }); it("suffixes the candidate when another company already holds the derived base", async () => { await db.insert(companies).values({ name: "Northwind Holdings", issuePrefix: "NOR" }); const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); const updated = await companyService(db).update( companyId, { name: "Northwind Traders" }, TEST_ACTOR, ); expect(updated?.issuePrefix).toBe("NORA"); await expect(readIdentifiers(companyId)).resolves.toEqual({ issues: ["NORA-1", "NORA-12", null], cases: ["NORA-C3"], }); }); it("keeps the prefix when the rename derives the same base", async () => { const companyId = await seedCompanyWithWork("Acme Robotics", "ACMA"); const updated = await companyService(db).update( companyId, { name: "Acme Robotics International" }, TEST_ACTOR, ); // The suffixed prefix is kept: the base did not move, so nothing needs // to be re-keyed and no disambiguating suffix is lost. expect(updated?.issuePrefix).toBe("ACMA"); await expect(readIdentifiers(companyId)).resolves.toEqual({ issues: ["ACMA-1", "ACMA-12", null], cases: ["ACMA-C3"], }); const logged = await db .select({ id: activityLog.id }) .from(activityLog) .where(eq(activityLog.companyId, companyId)); expect(logged).toHaveLength(0); }); it("keeps identifiers and the company prefix together under concurrent renames", async () => { const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); const svc = companyService(db); // Both renames read the company before either commits. Without a row // lock the loser re-keys from the prefix it read, finds nothing left to // move, and strands the identifiers on the winner's prefix while the // company row carries its own. await Promise.all([ svc.update(companyId, { name: "Northwind Traders" }, TEST_ACTOR), svc.update(companyId, { name: "Zenith Freight" }, TEST_ACTOR), ]); const [company] = await db .select({ issuePrefix: companies.issuePrefix }) .from(companies) .where(eq(companies.id, companyId)); const prefix = company!.issuePrefix; expect(["NOR", "ZEN"]).toContain(prefix); await expect(readIdentifiers(companyId)).resolves.toEqual({ issues: [`${prefix}-1`, `${prefix}-12`, null], cases: [`${prefix}-C3`], }); }); it("keeps the prefix consistent when a stale form resubmits the old name during a rename", async () => { const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); const svc = companyService(db); // The second caller submits the name it loaded before the rename. Judged // against its own stale read that name looks unchanged, so a pre-lock // comparison would skip re-derivation and restore "Acme Robotics" on top // of the rename's prefix. await Promise.all([ svc.update(companyId, { name: "Northwind Traders" }, TEST_ACTOR), svc.update(companyId, { name: "Acme Robotics" }, TEST_ACTOR), ]); const [company] = await db .select({ name: companies.name, issuePrefix: companies.issuePrefix }) .from(companies) .where(eq(companies.id, companyId)); // Whichever update commits last, the prefix derives from the name that // survived, and the identifiers sit on that prefix. expect(company!.issuePrefix.startsWith(deriveIssuePrefixBase(company!.name))).toBe(true); await expect(readIdentifiers(companyId)).resolves.toEqual({ issues: [`${company!.issuePrefix}-1`, `${company!.issuePrefix}-12`, null], cases: [`${company!.issuePrefix}-C3`], }); }); it("leaves the prefix alone when only non-name fields change", async () => { const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); const updated = await companyService(db).update( companyId, { description: "Now with rockets" }, TEST_ACTOR, ); expect(updated?.issuePrefix).toBe("ACM"); await expect(readIdentifiers(companyId)).resolves.toEqual({ issues: ["ACM-1", "ACM-12", null], cases: ["ACM-C3"], }); }); it("never touches the prefix on a self-hosted instance", async () => { delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); const updated = await companyService(db).update( companyId, { name: "Northwind Traders" }, TEST_ACTOR, ); expect(updated).toMatchObject({ name: "Northwind Traders", issuePrefix: "ACM" }); await expect(readIdentifiers(companyId)).resolves.toEqual({ issues: ["ACM-1", "ACM-12", null], cases: ["ACM-C3"], }); }); }); });