mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
feat(server): receive and apply the Paperclip Cloud onboarding seed
Paperclip Cloud collects a mission, a first agent and a first task during signup and pushes them into the tenant stack at activation, as `POST /api/companies/:companyId/onboarding-seed`. Nothing served that route, so the tenant answered 404, Cloud recorded the push as unacknowledged, and a freshly activated stack opened on the empty first-run wizard with the answers the customer had already given stranded on the Cloud side. This adds the receiver. Contract. The body carries a content-addressed `revision` plus optional `mission`, `agent` (`name`, `role`) and `firstTask` (`title`, `details`). It is customer free text, so it is validated and bounded in `packages/shared` and never read from a header — the `x-paperclip-cloud-*` set is the trusted identity envelope, derived server-side from host plus verified domain records, and mixing user content into it would undermine exactly that property. A test plants a mission on a cloud header and asserts the body wins. Application. The mission becomes the company-level goal, the agent becomes the company's first hire (its free-text role lands on `title`; the structural `role` stays `ceo`, which is what the org chart and the default-instructions lookup read), and the first task becomes an issue in the Onboarding project assigned to that agent — the same shapes the first-run wizard produces, so a seeded stack and a manually onboarded one are indistinguishable afterwards. The seeded agent is created on `claude_local`, mirroring the teams-catalog default for agents created server-side without a human running an environment test first, and overridable via `PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE`. Idempotency. Cloud retries until it gets a 2xx and treats any 2xx as "the tenant holds this content", so the new `company_onboarding_seeds` table records the applied revision along with the goal, agent and issue it produced. Replaying a revision that already matches is a successful no-op; a later revision — the customer edited their answers — updates those three rows in place rather than creating a second agent and a second task. The record is written last, after every other write has landed, so a partial application cannot present itself as acknowledged. Ordering, not eventual consistency. Everything is applied before the 200 is sent. The tests read the database immediately after the response with no waiting and no polling, so a lazy receiver fails them on a fast machine as well as a slow one. That matters because the redirect into the tenant dashboard is gated on this acknowledgement. Tested with `pnpm typecheck` (whole workspace) and `vitest run server/src/__tests__/onboarding-seed-route.test.ts server/src/__tests__/openapi-routes.test.ts` — 14 passing, including the happy path, replay, revision update, project reuse, the header-injection guard, the field bounds, and the 403 for a caller without access to the company. The suite runs against embedded Postgres, so the new migration is exercised too. Co-authored-by: Paperclip <noreply@paperclip.ing> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
276730d63e
commit
d6745ac8bd
14 files changed
+800
No files matched your search
@@ -0,0 +1,42 @@
|
||||
CREATE TABLE IF NOT EXISTS "company_onboarding_seeds" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"revision" text NOT NULL,
|
||||
"mission" text,
|
||||
"agent_name" text,
|
||||
"agent_role" text,
|
||||
"first_task_title" text,
|
||||
"first_task_details" text,
|
||||
"goal_id" uuid,
|
||||
"agent_id" uuid,
|
||||
"issue_id" uuid,
|
||||
"applied_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_onboarding_seeds_company_id_companies_id_fk' AND conrelid = 'company_onboarding_seeds'::regclass) THEN
|
||||
ALTER TABLE "company_onboarding_seeds" ADD CONSTRAINT "company_onboarding_seeds_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "companies"("id") ON DELETE cascade;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_onboarding_seeds_goal_id_goals_id_fk' AND conrelid = 'company_onboarding_seeds'::regclass) THEN
|
||||
ALTER TABLE "company_onboarding_seeds" ADD CONSTRAINT "company_onboarding_seeds_goal_id_goals_id_fk" FOREIGN KEY ("goal_id") REFERENCES "goals"("id") ON DELETE set null;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_onboarding_seeds_agent_id_agents_id_fk' AND conrelid = 'company_onboarding_seeds'::regclass) THEN
|
||||
ALTER TABLE "company_onboarding_seeds" ADD CONSTRAINT "company_onboarding_seeds_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE set null;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_onboarding_seeds_issue_id_issues_id_fk' AND conrelid = 'company_onboarding_seeds'::regclass) THEN
|
||||
ALTER TABLE "company_onboarding_seeds" ADD CONSTRAINT "company_onboarding_seeds_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "issues"("id") ON DELETE set null;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "company_onboarding_seeds_company_uq" ON "company_onboarding_seeds" ("company_id");
|
||||
+7
@@ -1499,6 +1499,13 @@
|
||||
"when": 1786467951628,
|
||||
"tag": "0215_flat_daimon_hellstrom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 216,
|
||||
"version": "7",
|
||||
"when": 1786467952628,
|
||||
"tag": "0216_company_onboarding_seeds",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { pgTable, uuid, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
/**
|
||||
* The onboarding answers Paperclip Cloud collected during signup, pushed into
|
||||
* this stack at activation and applied here.
|
||||
*
|
||||
* `revision` is the content hash Cloud computed over the seed. Cloud retries
|
||||
* the push until it gets a 2xx and only then records the acknowledged
|
||||
* revision, so the receiver has to be idempotent: replaying a revision that
|
||||
* already matches this row must not create a second agent or a second task.
|
||||
* The `goal_id` / `agent_id` / `issue_id` back-references are what a later
|
||||
* revision updates in place rather than duplicating.
|
||||
*/
|
||||
export const companyOnboardingSeeds = pgTable(
|
||||
"company_onboarding_seeds",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
revision: text("revision").notNull(),
|
||||
mission: text("mission"),
|
||||
agentName: text("agent_name"),
|
||||
agentRole: text("agent_role"),
|
||||
firstTaskTitle: text("first_task_title"),
|
||||
firstTaskDetails: text("first_task_details"),
|
||||
goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }),
|
||||
agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
appliedAt: timestamp("applied_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUq: uniqueIndex("company_onboarding_seeds_company_uq").on(table.companyId),
|
||||
}),
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
export { companies } from "./companies.js";
|
||||
export { companyLogos } from "./company_logos.js";
|
||||
export { companyTransferRuns } from "./company_transfer_runs.js";
|
||||
export { companyOnboardingSeeds } from "./company_onboarding_seeds.js";
|
||||
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
||||
export { instanceSettings } from "./instance_settings.js";
|
||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||
|
||||
@@ -1859,6 +1859,8 @@ export {
|
||||
updateGoalSchema,
|
||||
type CreateGoal,
|
||||
type UpdateGoal,
|
||||
applyOnboardingSeedSchema,
|
||||
type ApplyOnboardingSeed,
|
||||
createApprovalSchema,
|
||||
upsertBudgetPolicySchema,
|
||||
resolveBudgetIncidentSchema,
|
||||
|
||||
@@ -590,6 +590,11 @@ export {
|
||||
type UpdateGoal,
|
||||
} from "./goal.js";
|
||||
|
||||
export {
|
||||
applyOnboardingSeedSchema,
|
||||
type ApplyOnboardingSeed,
|
||||
} from "./onboarding-seed.js";
|
||||
|
||||
export {
|
||||
createApprovalSchema,
|
||||
resolveApprovalSchema,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The onboarding seed Paperclip Cloud pushes into a stack at activation
|
||||
* Every field except `revision` is customer free text collected in
|
||||
* the Cloud signup wizard, so it is untrusted input and is bounded here to the
|
||||
* same limits Cloud enforces before sending.
|
||||
*
|
||||
* The seed rides the JSON body only. The `x-paperclip-cloud-*` headers are the
|
||||
* trusted identity envelope — every member is derived server-side from host +
|
||||
* verified domain records — and must never be read for seed content.
|
||||
*/
|
||||
export const MISSION_MAX_LENGTH = 2000;
|
||||
export const AGENT_NAME_MAX_LENGTH = 80;
|
||||
export const AGENT_ROLE_MAX_LENGTH = 120;
|
||||
export const FIRST_TASK_TITLE_MAX_LENGTH = 200;
|
||||
export const FIRST_TASK_DETAILS_MAX_LENGTH = 2000;
|
||||
|
||||
export const applyOnboardingSeedSchema = z.object({
|
||||
revision: z.string().min(1).max(128),
|
||||
mission: z.string().max(MISSION_MAX_LENGTH).optional(),
|
||||
agent: z
|
||||
.object({
|
||||
name: z.string().min(1).max(AGENT_NAME_MAX_LENGTH),
|
||||
role: z.string().max(AGENT_ROLE_MAX_LENGTH).optional(),
|
||||
})
|
||||
.optional(),
|
||||
firstTask: z
|
||||
.object({
|
||||
title: z.string().min(1).max(FIRST_TASK_TITLE_MAX_LENGTH),
|
||||
details: z.string().max(FIRST_TASK_DETAILS_MAX_LENGTH).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ApplyOnboardingSeed = z.infer<typeof applyOnboardingSeedSchema>;
|
||||
@@ -0,0 +1,289 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companyOnboardingSeeds,
|
||||
goals,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { onboardingSeedRoutes } from "../routes/onboarding-seed.js";
|
||||
import {
|
||||
describeEmbeddedPostgres,
|
||||
resetCompanyIssueFixtures,
|
||||
routeApp,
|
||||
seedCompanyWithBoardAccess,
|
||||
useEmbeddedPostgres,
|
||||
type BoardActor,
|
||||
} from "./helpers/route-test-harness.js";
|
||||
|
||||
const SEED = {
|
||||
revision: "a".repeat(32),
|
||||
mission: "Make robotics boring enough to trust",
|
||||
agent: { name: "Ada", role: "Chief of Staff" },
|
||||
firstTask: { title: "Draft the one-page strategy", details: "One page, no more" },
|
||||
};
|
||||
|
||||
describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => {
|
||||
const ctx = useEmbeddedPostgres("onboarding-seed-route");
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.db.delete(activityLog);
|
||||
await ctx.db.delete(companyOnboardingSeeds);
|
||||
await ctx.db.delete(issues);
|
||||
await ctx.db.delete(projects);
|
||||
await ctx.db.delete(agents);
|
||||
await ctx.db.delete(goals);
|
||||
await resetCompanyIssueFixtures(ctx.db);
|
||||
});
|
||||
|
||||
async function seedCompany() {
|
||||
const seeded = await seedCompanyWithBoardAccess(ctx.db, "Onboarding seed");
|
||||
return { ...seeded, app: routeApp(ctx.db, seeded.actor, onboardingSeedRoutes) };
|
||||
}
|
||||
|
||||
function post(app: ReturnType<typeof routeApp>, companyId: string, body: unknown) {
|
||||
return request(app).post(`/api/companies/${companyId}/onboarding-seed`).send(body);
|
||||
}
|
||||
|
||||
// Ordering, not eventual consistency: every assertion below reads the
|
||||
// database immediately after the 200 comes back, with no waiting and no
|
||||
// polling. A lazy receiver that applied the seed in the background would
|
||||
// fail here on any machine — which is the point, since Cloud gates the
|
||||
// redirect into the tenant dashboard on this response.
|
||||
it("applies the mission, the first agent and the first task before it answers", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
const response = await post(app, companyId, SEED);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.applied).toBe(true);
|
||||
expect(response.body.changed).toBe(true);
|
||||
expect(response.body.revision).toBe(SEED.revision);
|
||||
|
||||
const companyGoals = await ctx.db.select().from(goals).where(eq(goals.companyId, companyId));
|
||||
expect(companyGoals).toHaveLength(1);
|
||||
expect(companyGoals[0]?.title).toBe(SEED.mission);
|
||||
expect(companyGoals[0]?.level).toBe("company");
|
||||
expect(companyGoals[0]?.status).toBe("active");
|
||||
|
||||
const companyAgents = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(companyAgents).toHaveLength(1);
|
||||
expect(companyAgents[0]?.name).toBe("Ada");
|
||||
// The seed's free-text role is a job title; the structural role stays `ceo`.
|
||||
expect(companyAgents[0]?.title).toBe("Chief of Staff");
|
||||
expect(companyAgents[0]?.role).toBe("ceo");
|
||||
|
||||
const companyIssues = await ctx.db.select().from(issues).where(eq(issues.companyId, companyId));
|
||||
expect(companyIssues).toHaveLength(1);
|
||||
expect(companyIssues[0]?.title).toBe(SEED.firstTask.title);
|
||||
expect(companyIssues[0]?.description).toBe(SEED.firstTask.details);
|
||||
expect(companyIssues[0]?.assigneeAgentId).toBe(companyAgents[0]?.id);
|
||||
expect(companyIssues[0]?.goalId).toBe(companyGoals[0]?.id);
|
||||
|
||||
const companyProjects = await ctx.db.select().from(projects).where(eq(projects.companyId, companyId));
|
||||
expect(companyProjects).toHaveLength(1);
|
||||
expect(companyProjects[0]?.name).toBe("Onboarding");
|
||||
expect(companyIssues[0]?.projectId).toBe(companyProjects[0]?.id);
|
||||
|
||||
const record = await ctx.db
|
||||
.select()
|
||||
.from(companyOnboardingSeeds)
|
||||
.where(eq(companyOnboardingSeeds.companyId, companyId));
|
||||
expect(record).toHaveLength(1);
|
||||
expect(record[0]?.revision).toBe(SEED.revision);
|
||||
expect(record[0]?.agentId).toBe(companyAgents[0]?.id);
|
||||
expect(record[0]?.issueId).toBe(companyIssues[0]?.id);
|
||||
});
|
||||
|
||||
it("is idempotent per revision — a replay creates no second agent or task", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
const first = await post(app, companyId, SEED);
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.changed).toBe(true);
|
||||
|
||||
const replay = await post(app, companyId, SEED);
|
||||
expect(replay.status).toBe(200);
|
||||
expect(replay.body.applied).toBe(true);
|
||||
// The revision already matched, so nothing was re-applied.
|
||||
expect(replay.body.changed).toBe(false);
|
||||
expect(replay.body.agentId).toBe(first.body.agentId);
|
||||
expect(replay.body.issueId).toBe(first.body.issueId);
|
||||
|
||||
expect(await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
|
||||
expect(await ctx.db.select().from(issues).where(eq(issues.companyId, companyId))).toHaveLength(1);
|
||||
expect(await ctx.db.select().from(goals).where(eq(goals.companyId, companyId))).toHaveLength(1);
|
||||
expect(await ctx.db.select().from(projects).where(eq(projects.companyId, companyId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updates in place when the customer edits their answers and the revision changes", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
const first = await post(app, companyId, SEED);
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const revised = await post(app, companyId, {
|
||||
revision: "b".repeat(32),
|
||||
mission: "Make robotics dependable",
|
||||
agent: { name: "Grace", role: "Head of Ops" },
|
||||
firstTask: { title: "Draft the two-page strategy", details: "Two pages now" },
|
||||
});
|
||||
expect(revised.status).toBe(200);
|
||||
expect(revised.body.changed).toBe(true);
|
||||
expect(revised.body.agentId).toBe(first.body.agentId);
|
||||
expect(revised.body.issueId).toBe(first.body.issueId);
|
||||
|
||||
const companyAgents = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(companyAgents).toHaveLength(1);
|
||||
expect(companyAgents[0]?.name).toBe("Grace");
|
||||
expect(companyAgents[0]?.title).toBe("Head of Ops");
|
||||
|
||||
const companyIssues = await ctx.db.select().from(issues).where(eq(issues.companyId, companyId));
|
||||
expect(companyIssues).toHaveLength(1);
|
||||
expect(companyIssues[0]?.title).toBe("Draft the two-page strategy");
|
||||
|
||||
const companyGoals = await ctx.db.select().from(goals).where(eq(goals.companyId, companyId));
|
||||
expect(companyGoals).toHaveLength(1);
|
||||
expect(companyGoals[0]?.title).toBe("Make robotics dependable");
|
||||
});
|
||||
|
||||
it("splits a multi-line mission into a goal title and description", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
const response = await post(app, companyId, {
|
||||
revision: "c".repeat(32),
|
||||
mission: "Make robotics boring\nBoring enough that hospitals buy it.",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const companyGoals = await ctx.db.select().from(goals).where(eq(goals.companyId, companyId));
|
||||
expect(companyGoals[0]?.title).toBe("Make robotics boring");
|
||||
expect(companyGoals[0]?.description).toBe("Boring enough that hospitals buy it.");
|
||||
// No agent and no task were sent, so none were invented.
|
||||
expect(await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
expect(await ctx.db.select().from(issues).where(eq(issues.companyId, companyId))).toHaveLength(0);
|
||||
expect(response.body.agentId).toBeNull();
|
||||
expect(response.body.issueId).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a revision-only seed and records it", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
const response = await post(app, companyId, { revision: "d".repeat(32) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.changed).toBe(true);
|
||||
const record = await ctx.db
|
||||
.select()
|
||||
.from(companyOnboardingSeeds)
|
||||
.where(eq(companyOnboardingSeeds.companyId, companyId));
|
||||
expect(record[0]?.revision).toBe("d".repeat(32));
|
||||
expect(record[0]?.mission).toBeNull();
|
||||
});
|
||||
|
||||
it("logs the application once, and not again on a replay", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
await post(app, companyId, SEED);
|
||||
await post(app, companyId, SEED);
|
||||
|
||||
const entries = await ctx.db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.action, "company.onboarding_seed_applied"),
|
||||
));
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses a caller without access to the company", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const strangerActor: BoardActor = {
|
||||
type: "board",
|
||||
source: "session",
|
||||
userId: `user-${randomUUID()}`,
|
||||
companyIds: [randomUUID()],
|
||||
memberships: [],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
const strangerApp = routeApp(ctx.db, strangerActor, onboardingSeedRoutes);
|
||||
|
||||
const response = await post(strangerApp, companyId, SEED);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
|
||||
expect(await ctx.db.select().from(companyOnboardingSeeds)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a body missing the revision", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
const response = await post(app, companyId, { mission: "No revision here" });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await ctx.db.select().from(companyOnboardingSeeds)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects seed fields past the bounds Cloud enforces before sending", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
const overlongMission = await post(app, companyId, {
|
||||
revision: "e".repeat(32),
|
||||
mission: "m".repeat(2001),
|
||||
});
|
||||
expect(overlongMission.status).toBe(400);
|
||||
|
||||
const overlongAgentName = await post(app, companyId, {
|
||||
revision: "e".repeat(32),
|
||||
agent: { name: "n".repeat(81), role: "Chief of Staff" },
|
||||
});
|
||||
expect(overlongAgentName.status).toBe(400);
|
||||
|
||||
const overlongTaskTitle = await post(app, companyId, {
|
||||
revision: "e".repeat(32),
|
||||
firstTask: { title: "t".repeat(201) },
|
||||
});
|
||||
expect(overlongTaskTitle.status).toBe(400);
|
||||
|
||||
expect(await ctx.db.select().from(companyOnboardingSeeds)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never reads the seed from a trusted Cloud header", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
// The `x-paperclip-cloud-*` set is the trusted identity channel, derived
|
||||
// server-side. A mission planted there must be ignored entirely — only the
|
||||
// body is read.
|
||||
const response = await request(app)
|
||||
.post(`/api/companies/${companyId}/onboarding-seed`)
|
||||
.set("x-paperclip-cloud-mission", "Header-supplied mission")
|
||||
.set("x-paperclip-cloud-paperclip-company-name", "Header-supplied mission")
|
||||
.send({ revision: "f".repeat(32), mission: "Body-supplied mission" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const companyGoals = await ctx.db.select().from(goals).where(eq(goals.companyId, companyId));
|
||||
expect(companyGoals[0]?.title).toBe("Body-supplied mission");
|
||||
});
|
||||
|
||||
it("reuses an existing Onboarding project instead of creating a second one", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
await ctx.db.insert(projects).values({
|
||||
companyId,
|
||||
name: "Onboarding",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const response = await post(app, companyId, SEED);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const companyProjects = await ctx.db.select().from(projects).where(eq(projects.companyId, companyId));
|
||||
expect(companyProjects).toHaveLength(1);
|
||||
const companyIssues = await ctx.db.select().from(issues).where(eq(issues.companyId, companyId));
|
||||
expect(companyIssues[0]?.projectId).toBe(companyProjects[0]?.id);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"issues.ts": "/api",
|
||||
"issue-tree-control.ts": "/api",
|
||||
"llms.ts": "/api",
|
||||
"onboarding-seed.ts": "/api",
|
||||
"openapi.ts": "/api",
|
||||
"plugin-ui-static.ts": "/api",
|
||||
"plugins.ts": "/api",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { pipelineRoutes } from "./routes/pipelines.js";
|
||||
import { environmentRoutes } from "./routes/environments.js";
|
||||
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
||||
import { goalRoutes } from "./routes/goals.js";
|
||||
import { onboardingSeedRoutes } from "./routes/onboarding-seed.js";
|
||||
import { boardChatRoutes } from "./routes/board-chat.js";
|
||||
import { approvalRoutes } from "./routes/approvals.js";
|
||||
import { secretRoutes } from "./routes/secrets.js";
|
||||
@@ -438,6 +439,7 @@ export async function createApp(
|
||||
}));
|
||||
api.use(executionWorkspaceRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(goalRoutes(db));
|
||||
api.use(onboardingSeedRoutes(db));
|
||||
api.use(boardChatRoutes(db, { deploymentMode: opts.deploymentMode }));
|
||||
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(secretRoutes(db));
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
} from "./file-resources.js";
|
||||
export { routineRoutes } from "./routines.js";
|
||||
export { goalRoutes } from "./goals.js";
|
||||
export { onboardingSeedRoutes } from "./onboarding-seed.js";
|
||||
export { approvalRoutes } from "./approvals.js";
|
||||
export { secretRoutes } from "./secrets.js";
|
||||
export { toolAccessRoutes } from "./tool-access.js";
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { applyOnboardingSeedSchema } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/index.js";
|
||||
import { logActivity } from "../services/index.js";
|
||||
import { onboardingSeedService } from "../services/onboarding-seed.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
/**
|
||||
* Receiver for the onboarding seed Paperclip Cloud collects at signup and
|
||||
* pushes into the stack at activation.
|
||||
*
|
||||
* Authentication is the trusted Cloud envelope, resolved exactly as it is for
|
||||
* `POST /api/companies/:companyId/logo`: the `x-paperclip-cloud-*` headers
|
||||
* produce a company-scoped actor and `assertCompanyAccess` fails closed for
|
||||
* anyone else. The seed itself is customer free text and rides the JSON body
|
||||
* only — it is never read from a header. Every `x-paperclip-cloud-*` value is
|
||||
* derived server-side from the host plus verified domain records, which is
|
||||
* what makes that set trustworthy; customer free text must not be mixed into
|
||||
* it.
|
||||
*
|
||||
* Cloud treats any 2xx as "the tenant holds this content" and writes the
|
||||
* acknowledged revision only afterwards, retrying from the next portfolio
|
||||
* fetch otherwise. So this route answers 200 only once every part of the seed
|
||||
* has been applied, and a replay of an already-applied revision is a
|
||||
* successful no-op rather than a second agent and a second task.
|
||||
*/
|
||||
export function onboardingSeedRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = onboardingSeedService(db);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/onboarding-seed",
|
||||
validate(applyOnboardingSeedSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const result = await svc.apply(companyId, req.body);
|
||||
|
||||
if (result.changed) {
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "company.onboarding_seed_applied",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
revision: result.revision,
|
||||
goalId: result.goalId,
|
||||
agentId: result.agentId,
|
||||
issueId: result.issueId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
companyId,
|
||||
revision: result.revision,
|
||||
applied: true,
|
||||
changed: result.changed,
|
||||
goalId: result.goalId,
|
||||
agentId: result.agentId,
|
||||
issueId: result.issueId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -926,6 +926,7 @@ const CREATED_OPERATIONS = new Set([
|
||||
"POST /api/approvals/{id}/comments",
|
||||
"POST /api/companies/{companyId}/assets/images",
|
||||
"POST /api/companies/{companyId}/logo",
|
||||
"POST /api/companies/{companyId}/onboarding-seed",
|
||||
"POST /api/cli-auth/challenges",
|
||||
"POST /api/board-api-keys",
|
||||
"POST /api/companies",
|
||||
@@ -4794,6 +4795,15 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/onboarding-seed",
|
||||
tags: ["companies"],
|
||||
summary: "Apply the onboarding seed Paperclip Cloud collected at signup",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 422: r.unprocessable },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/assets/{assetId}/content",
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, companyOnboardingSeeds, goals, issues, projects } from "@paperclipai/db";
|
||||
import type { ApplyOnboardingSeed } from "@paperclipai/shared";
|
||||
import { agentService } from "./agents.js";
|
||||
import { goalService } from "./goals.js";
|
||||
import { projectService } from "./projects.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
|
||||
|
||||
/**
|
||||
* The project the seeded first task lands in, matching the name the tenant's
|
||||
* own first-run wizard uses so a later manual run reuses it instead of
|
||||
* creating a second "Onboarding" project.
|
||||
*/
|
||||
export const ONBOARDING_SEED_PROJECT_NAME = "Onboarding";
|
||||
|
||||
/**
|
||||
* Role assigned to the seeded lead agent. The seed's own `agent.role` is
|
||||
* customer free text ("Chief of Staff") and lands on `title`; `role` stays the
|
||||
* structural `ceo` key the org chart and default-instructions lookup read.
|
||||
*/
|
||||
const SEEDED_AGENT_ROLE = "ceo";
|
||||
|
||||
/**
|
||||
* Adapter the seeded agent is created with. Mirrors the teams-catalog default
|
||||
* (`claude_local`), which is the safe adapter for agents created server-side
|
||||
* without a human running an environment test first.
|
||||
*/
|
||||
const FALLBACK_SEEDED_AGENT_ADAPTER_TYPE = "claude_local";
|
||||
|
||||
function seededAgentAdapterType() {
|
||||
return process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE?.trim()
|
||||
|| process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE?.trim()
|
||||
|| FALLBACK_SEEDED_AGENT_ADAPTER_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a free-text mission into a goal title + description the same way the
|
||||
* first-run wizard's `parseOnboardingGoalInput` does: first line is the title,
|
||||
* the remainder is the description.
|
||||
*/
|
||||
export function parseSeedMission(raw: string): { title: string; description: string | null } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { title: "", description: null };
|
||||
|
||||
const [firstLine, ...restLines] = trimmed.split(/\r?\n/);
|
||||
const description = restLines.join("\n").trim();
|
||||
return {
|
||||
title: (firstLine ?? "").trim(),
|
||||
description: description.length > 0 ? description : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type OnboardingSeedApplication = {
|
||||
revision: string;
|
||||
/** False when the stored revision already matched and nothing was re-applied. */
|
||||
changed: boolean;
|
||||
goalId: string | null;
|
||||
agentId: string | null;
|
||||
issueId: string | null;
|
||||
};
|
||||
|
||||
export function onboardingSeedService(db: Db) {
|
||||
const agentSvc = agentService(db);
|
||||
const goalSvc = goalService(db);
|
||||
const projectSvc = projectService(db);
|
||||
const issueSvc = issueService(db);
|
||||
|
||||
async function readRecord(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companyOnboardingSeeds)
|
||||
.where(eq(companyOnboardingSeeds.companyId, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function goalStillExists(companyId: string, goalId: string | null) {
|
||||
if (!goalId) return false;
|
||||
return db
|
||||
.select({ id: goals.id })
|
||||
.from(goals)
|
||||
.where(and(eq(goals.id, goalId), eq(goals.companyId, companyId)))
|
||||
.then((rows) => rows.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent a re-push should update rather than duplicate: the one this
|
||||
* seed created if it is still around, else a pre-existing lead the tenant
|
||||
* already has. Built-in agents are excluded — they are provisioned by the
|
||||
* platform and are not the customer's first hire.
|
||||
*/
|
||||
async function resolveTargetAgentId(companyId: string, recordedAgentId: string | null) {
|
||||
if (recordedAgentId) {
|
||||
const recorded = await db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, recordedAgentId), eq(agents.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (recorded) return recorded.id;
|
||||
}
|
||||
|
||||
const candidates = await db
|
||||
.select({ id: agents.id, metadata: agents.metadata })
|
||||
.from(agents)
|
||||
.where(and(
|
||||
eq(agents.companyId, companyId),
|
||||
eq(agents.role, SEEDED_AGENT_ROLE),
|
||||
ne(agents.status, "terminated"),
|
||||
));
|
||||
return candidates.find((row) => !readBuiltInAgentMarker(row.metadata))?.id ?? null;
|
||||
}
|
||||
|
||||
async function issueStillExists(companyId: string, issueId: string | null) {
|
||||
if (!issueId) return false;
|
||||
return db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows.length > 0);
|
||||
}
|
||||
|
||||
async function resolveOnboardingProjectId(companyId: string, goalId: string | null) {
|
||||
const existing = await db
|
||||
.select({ id: projects.id, name: projects.name, status: projects.status })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
const reusable = existing.find(
|
||||
(project) =>
|
||||
project.status !== "cancelled"
|
||||
&& project.name.trim().toLowerCase() === ONBOARDING_SEED_PROJECT_NAME.toLowerCase(),
|
||||
);
|
||||
if (reusable) return reusable.id;
|
||||
|
||||
const created = await projectSvc.create(companyId, {
|
||||
name: ONBOARDING_SEED_PROJECT_NAME,
|
||||
status: "in_progress",
|
||||
...(goalId ? { goalIds: [goalId] } : {}),
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an onboarding seed to a company.
|
||||
*
|
||||
* Idempotent per `revision`: a replay of the revision already stored is a
|
||||
* no-op that still reports success, because Cloud reads any 2xx as "the
|
||||
* tenant holds this content" and retries otherwise. A *different* revision
|
||||
* (the customer edited their answers in Cloud) updates the goal, agent and
|
||||
* task this seed previously created rather than creating a second set.
|
||||
*
|
||||
* Every write happens before the caller responds — Cloud records the applied
|
||||
* revision only on a 2xx, and the redirect into the tenant dashboard is
|
||||
* gated on it, so a partially-applied seed must surface as a failure rather
|
||||
* than as an acknowledged one.
|
||||
*/
|
||||
async function apply(
|
||||
companyId: string,
|
||||
seed: ApplyOnboardingSeed,
|
||||
): Promise<OnboardingSeedApplication> {
|
||||
const existing = await readRecord(companyId);
|
||||
if (existing && existing.revision === seed.revision) {
|
||||
return {
|
||||
revision: existing.revision,
|
||||
changed: false,
|
||||
goalId: existing.goalId,
|
||||
agentId: existing.agentId,
|
||||
issueId: existing.issueId,
|
||||
};
|
||||
}
|
||||
|
||||
const mission = seed.mission?.trim() || null;
|
||||
const agentName = seed.agent?.name.trim() || null;
|
||||
const agentRole = seed.agent?.role?.trim() || null;
|
||||
const firstTaskTitle = seed.firstTask?.title.trim() || null;
|
||||
const firstTaskDetails = seed.firstTask?.details?.trim() || null;
|
||||
|
||||
// 1. Mission → the company-level goal the dashboard reads.
|
||||
let goalId = existing?.goalId ?? null;
|
||||
if (mission) {
|
||||
const parsed = parseSeedMission(mission);
|
||||
const target = (await goalStillExists(companyId, goalId))
|
||||
? goalId
|
||||
: (await goalSvc.getDefaultCompanyGoal(companyId))?.id ?? null;
|
||||
if (target) {
|
||||
await goalSvc.update(target, {
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
});
|
||||
goalId = target;
|
||||
} else {
|
||||
const created = await goalSvc.create(companyId, {
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
level: "company",
|
||||
status: "active",
|
||||
});
|
||||
goalId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Agent → the customer's first hire, the lead the first task is
|
||||
// assigned to.
|
||||
let agentId = await resolveTargetAgentId(companyId, existing?.agentId ?? null);
|
||||
if (agentName) {
|
||||
if (agentId) {
|
||||
await agentSvc.update(agentId, { name: agentName, title: agentRole });
|
||||
} else {
|
||||
const created = await agentSvc.create(companyId, {
|
||||
name: agentName,
|
||||
role: SEEDED_AGENT_ROLE,
|
||||
title: agentRole,
|
||||
adapterType: seededAgentAdapterType(),
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
agentId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. First task → an issue in the Onboarding project, assigned to the
|
||||
// lead so the dashboard opens with work on it.
|
||||
let issueId = existing?.issueId ?? null;
|
||||
if (firstTaskTitle) {
|
||||
if (await issueStillExists(companyId, issueId)) {
|
||||
await issueSvc.update(issueId as string, {
|
||||
title: firstTaskTitle,
|
||||
description: firstTaskDetails,
|
||||
});
|
||||
} else {
|
||||
const projectId = await resolveOnboardingProjectId(companyId, goalId);
|
||||
const created = await issueSvc.create(companyId, {
|
||||
title: firstTaskTitle,
|
||||
...(firstTaskDetails ? { description: firstTaskDetails } : {}),
|
||||
...(agentId ? { assigneeAgentId: agentId } : {}),
|
||||
projectId,
|
||||
...(goalId ? { goalId } : {}),
|
||||
status: "todo",
|
||||
idempotencyKey: `onboarding-seed:${companyId}`,
|
||||
});
|
||||
issueId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Record the revision last. Everything above has to have landed before
|
||||
// this row claims the seed is applied.
|
||||
const now = new Date();
|
||||
const values = {
|
||||
companyId,
|
||||
revision: seed.revision,
|
||||
mission,
|
||||
agentName,
|
||||
agentRole,
|
||||
firstTaskTitle,
|
||||
firstTaskDetails,
|
||||
goalId,
|
||||
agentId,
|
||||
issueId,
|
||||
appliedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db
|
||||
.insert(companyOnboardingSeeds)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: companyOnboardingSeeds.companyId,
|
||||
set: {
|
||||
revision: values.revision,
|
||||
mission: values.mission,
|
||||
agentName: values.agentName,
|
||||
agentRole: values.agentRole,
|
||||
firstTaskTitle: values.firstTaskTitle,
|
||||
firstTaskDetails: values.firstTaskDetails,
|
||||
goalId: values.goalId,
|
||||
agentId: values.agentId,
|
||||
issueId: values.issueId,
|
||||
appliedAt: values.appliedAt,
|
||||
updatedAt: values.updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { revision: seed.revision, changed: true, goalId, agentId, issueId };
|
||||
}
|
||||
|
||||
return { apply, get: readRecord };
|
||||
}
|
||||
Reference in new issue
Block a user