Auto-provision Reflection Coach for fresh companies

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 18:50:41 +00:00
1 parent e5c627ea9b
commit 1b472f7363
4 files changed
+125 -8

No files matched your search

+42 -3
View File
@@ -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);
@@ -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();
+22 -5
View File
@@ -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 };
}
+3
View File
@@ -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<Parameters<typeof db.transaction>[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);