diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index 53690250f99..6fbaf0a62ff 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -79,14 +79,14 @@ describeEmbeddedPostgres("built-in agents", () => { await tempDb?.cleanup(); }); - async function seedCompany() { + async function seedCompany(options: { requireApproval?: boolean } = {}) { const companyId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: issuePrefix(companyId), defaultResponsibleUserId: "responsible-user", - requireBoardApprovalForNewAgents: true, + requireBoardApprovalForNewAgents: options.requireApproval ?? true, }); return companyId; } @@ -413,7 +413,7 @@ describeEmbeddedPostgres("built-in agents", () => { }); it("auto-provisions a paused Reflection Coach bundle with skill sync and a disabled routine", async () => { - const companyId = await seedCompany(); + const companyId = await seedCompany({ requireApproval: false }); await agentService(db).create(companyId, { name: "CEO", role: "ceo", @@ -488,6 +488,45 @@ describeEmbeddedPostgres("built-in agents", () => { }); }); + it("preserves new-agent approval gates during automatic Reflection Coach provisioning", async () => { + const companyId = await seedCompany({ requireApproval: true }); + + const result = await reconcileBuiltInAgentsOnStartup(db); + + expect(result).toMatchObject({ + autoEnsured: 1, + pendingApprovals: 1, + }); + const state = await builtInAgentService(db).get(companyId, "reflection-coach"); + expect(state).toMatchObject({ + status: "pending_approval", + agent: { + companyId, + name: "Reflection Coach", + status: "pending_approval", + budgetMonthlyCents: 0, + }, + }); + expect(state.resources.map((resource) => resource.stockStatus)).toEqual(["missing", "missing", "missing"]); + + const [approval] = await db.select().from(approvals).where(eq(approvals.companyId, companyId)); + expect(approval).toMatchObject({ + type: "hire_agent", + status: "pending", + payload: { + agentId: state.agentId, + sourceBuiltInAgentKey: "reflection-coach", + featureKeys: ["reflection-coach"], + }, + }); + + await reconcileBuiltInAgentsOnStartup(db); + const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1); + const approvalRows = await db.select().from(approvals).where(eq(approvals.companyId, companyId)); + expect(approvalRows).toHaveLength(1); + }); + it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => { const companyId = await seedCompany(); const builtIns = builtInAgentService(db); diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts index 2301b33b02d..869d80062eb 100644 --- a/server/src/__tests__/companies-service.test.ts +++ b/server/src/__tests__/companies-service.test.ts @@ -3,18 +3,26 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { and, eq } from "drizzle-orm"; import { activityLog, + agentConfigRevisions, agents, agentWakeupRequests, + builtInManagedResources, companies, + companySkillVersions, + companySkills, createDb, heartbeatRunEvents, heartbeatRuns, + routines, + routineTriggers, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { companyService } from "../services/companies.js"; +import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js"; +import { reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -35,9 +43,15 @@ describeEmbeddedPostgres("companyService", () => { }, 20_000); afterEach(async () => { + 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(companies); @@ -63,6 +77,50 @@ describeEmbeddedPostgres("companyService", () => { expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]); }); + it("auto-provisions one paused Reflection Coach bundle for a freshly created company", async () => { + const created = await companyService(db).create({ + name: "Fresh Company", + }); + + const agentRows = await db.select().from(agents).where(eq(agents.companyId, created.id)); + const reflectionRows = agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach"); + expect(reflectionRows).toHaveLength(1); + expect(reflectionRows[0]).toMatchObject({ + name: "Reflection Coach", + status: "paused", + budgetMonthlyCents: 0, + spentMonthlyCents: 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(eq(routines.companyId, created.id)); + expect(routine).toMatchObject({ + status: "paused", + assigneeAgentId: reflectionRows[0]!.id, + 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, + }); + + await reconcileBuiltInAgentsOnStartup(db); + const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id)); + expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1); + }); + it("archives companies by pausing runnable agents and cancelling active runs", async () => { const companyId = randomUUID(); const runningAgentId = randomUUID(); diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index 89e321b8897..e4fcae5b3d7 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1495,6 +1495,22 @@ export function builtInAgentService(db: Db) { throw builtInAgentNotConfiguredError(current); } + async function autoProvisionBundledAgents(companyId: string) { + const company = await ensureCompany(companyId); + let autoEnsured = 0; + let pendingApprovals = 0; + for (const definition of DEFINITIONS.filter((entry) => entry.bundle)) { + if (company.requireBoardApprovalForNewAgents) { + const result = await provision(companyId, definition.key); + if (result.approval) pendingApprovals += 1; + } else { + await ensure(companyId, definition.key); + } + autoEnsured += 1; + } + return { autoEnsured, pendingApprovals }; + } + return { definitions: listBuiltInAgentDefinitions, get, @@ -1516,6 +1532,7 @@ export function builtInAgentService(db: Db) { ) => setRoutineSchedule(companyId, key, routineKey, false, actor), runRoutine, requireBuiltInAgent, + autoProvisionBundledAgents, reconcileDefinitionDefaults, }; } @@ -1526,11 +1543,11 @@ export async function reconcileBuiltInAgentsOnStartup(db: Db) { .select({ id: companies.id }) .from(companies); let autoEnsured = 0; + let pendingApprovals = 0; for (const company of companyRows) { - for (const definition of DEFINITIONS.filter((entry) => entry.bundle)) { - await svc.ensure(company.id, definition.key); - autoEnsured += 1; - } + const result = await svc.autoProvisionBundledAgents(company.id); + autoEnsured += result.autoEnsured; + pendingApprovals += result.pendingApprovals; } const rows = await db .select({ @@ -1564,5 +1581,5 @@ export async function reconcileBuiltInAgentsOnStartup(db: Db) { reconciled += 1; } - return { scanned, reconciled, unknown, duplicates, autoEnsured }; + return { scanned, reconciled, unknown, duplicates, autoEnsured, pendingApprovals }; } diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index f33f7187a52..7f7ac07c28e 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -33,6 +33,7 @@ import { notFound, unprocessable } from "../errors.js"; import { environmentService } from "./environments.js"; import { heartbeatService } from "./heartbeat.js"; import { logActivity } from "./activity-log.js"; +import { builtInAgentService } from "./built-in-agents.js"; export interface CompanyActivityActor { actorType: "user" | "agent" | "system" | "plugin"; @@ -52,6 +53,7 @@ export function companyService(db: Db) { const ISSUE_PREFIX_FALLBACK = "CMP"; const environmentsSvc = environmentService(db); const heartbeat = heartbeatService(db); + const builtInAgents = builtInAgentService(db); type CompanyTx = Parameters[0]>[0]; @@ -263,6 +265,7 @@ export function companyService(db: Db) { create: async (data: typeof companies.$inferInsert) => { const created = await createCompanyWithUniquePrefix(data); await environmentsSvc.ensureLocalEnvironment(created.id); + await builtInAgents.autoProvisionBundledAgents(created.id); const row = await getCompanyQuery(db) .where(eq(companies.id, created.id)) .then((rows) => rows[0] ?? null);