mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
Auto-provision Reflection Coach for fresh companies
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e5c627ea9b
commit
1b472f7363
4 files changed
+125
-8
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user