diff --git a/packages/db/src/migrations/0216_company_onboarding_seeds.sql b/packages/db/src/migrations/0216_company_onboarding_seeds.sql new file mode 100644 index 0000000000..f4e8332483 --- /dev/null +++ b/packages/db/src/migrations/0216_company_onboarding_seeds.sql @@ -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"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 564e7ec58a..122ced2055 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema/company_onboarding_seeds.ts b/packages/db/src/schema/company_onboarding_seeds.ts new file mode 100644 index 0000000000..bdfa4840ab --- /dev/null +++ b/packages/db/src/schema/company_onboarding_seeds.ts @@ -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), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index b00b272dd9..66114d8d04 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -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"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 78d8f27599..ad7b5e8d6f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1859,6 +1859,8 @@ export { updateGoalSchema, type CreateGoal, type UpdateGoal, + applyOnboardingSeedSchema, + type ApplyOnboardingSeed, createApprovalSchema, upsertBudgetPolicySchema, resolveBudgetIncidentSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 5bc25bd431..e6f26a0adb 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -590,6 +590,11 @@ export { type UpdateGoal, } from "./goal.js"; +export { + applyOnboardingSeedSchema, + type ApplyOnboardingSeed, +} from "./onboarding-seed.js"; + export { createApprovalSchema, resolveApprovalSchema, diff --git a/packages/shared/src/validators/onboarding-seed.ts b/packages/shared/src/validators/onboarding-seed.ts new file mode 100644 index 0000000000..de302a8400 --- /dev/null +++ b/packages/shared/src/validators/onboarding-seed.ts @@ -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; diff --git a/server/src/__tests__/onboarding-seed-route.test.ts b/server/src/__tests__/onboarding-seed-route.test.ts new file mode 100644 index 0000000000..16059d91dd --- /dev/null +++ b/server/src/__tests__/onboarding-seed-route.test.ts @@ -0,0 +1,394 @@ +import { randomUUID } from "node:crypto"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterEach, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + companyOnboardingSeeds, + goals, + issues, + projects, +} from "@paperclipai/db"; +import { onboardingSeedRoutes } from "../routes/onboarding-seed.js"; +import { logActivity } from "../services/activity-log.js"; +import { + describeEmbeddedPostgres, + resetCompanyIssueFixtures, + routeApp, + seedCompanyWithBoardAccess, + useEmbeddedPostgres, + type BoardActor, +} from "./helpers/route-test-harness.js"; + +// Wrapped, not replaced: every other test here asserts the real activity row, +// so the default implementation stays the genuine one and a single test opts +// into failure with `mockRejectedValueOnce`. +vi.mock("../services/activity-log.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logActivity: vi.fn(actual.logActivity) }; +}); + +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, 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("rolls the whole seed back when the audit entry cannot be written", async () => { + // The audit entry shares the seed's transaction, so a failure to write it + // must leave nothing behind. The alternative — commit the seed and lose the + // entry — is unrecoverable: Cloud stops retrying on a 2xx, and a later + // replay reports `changed: false` and never logs, so the entry would be + // permanently absent. + const { companyId, app } = await seedCompany(); + + // Injected at the module boundary, not on `ctx.db`: the audit write goes + // through the transaction handle, so a spy on the outer connection would + // never be reached and the test would pass for the wrong reason. + vi.mocked(logActivity).mockRejectedValueOnce(new Error("activity log unavailable")); + + const failed = await post(app, companyId, SEED); + expect(failed.status).toBeGreaterThanOrEqual(500); + + // Nothing committed: no seed record, so Cloud has no acknowledged revision + // and keeps retrying, and no orphaned agent from the rolled-back attempt. + expect( + await ctx.db + .select() + .from(companyOnboardingSeeds) + .where(eq(companyOnboardingSeeds.companyId, companyId)), + ).toHaveLength(0); + expect(await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0); + + // And the retry recovers completely — seed applied, entry present. + await post(app, companyId, SEED).expect(200); + expect( + await ctx.db + .select() + .from(activityLog) + .where(and( + eq(activityLog.companyId, companyId), + eq(activityLog.action, "company.onboarding_seed_applied"), + )), + ).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); + }); + + it("does not duplicate entities when two identical pushes race", async () => { + const { companyId, app } = await seedCompany(); + + // Cloud's reconcile runs off portfolio fetches that can overlap, so the + // same revision can be pushed twice at once. The per-company advisory lock + // must serialize them: without it both pass the revision check before + // either writes the seed record and each creates a goal, an agent, a + // project and a task. + const [first, second] = await Promise.all([ + post(app, companyId, SEED), + post(app, companyId, SEED), + ]); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + + expect(await ctx.db.select().from(goals).where(eq(goals.companyId, companyId))).toHaveLength(1); + expect(await ctx.db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1); + expect(await ctx.db.select().from(projects).where(eq(projects.companyId, companyId))).toHaveLength(1); + expect(await ctx.db.select().from(issues).where(eq(issues.companyId, companyId))).toHaveLength(1); + expect(await ctx.db.select().from(companyOnboardingSeeds)).toHaveLength(1); + }); + + it("refreshes a first task's assignee and goal when a later revision adds them", async () => { + const { companyId, app } = await seedCompany(); + + // First push seeds a task but no agent and no mission, so the issue is + // created unassigned and goal-less. + const taskOnly = await post(app, companyId, { + revision: "1".repeat(32), + firstTask: { title: "Draft the strategy" }, + }); + expect(taskOnly.status).toBe(200); + + const beforeIssue = (await ctx.db.select().from(issues).where(eq(issues.companyId, companyId)))[0]; + expect(beforeIssue?.assigneeAgentId).toBeNull(); + + // A later revision supplies the mission and the agent. The existing task is + // updated in place, and must pick up the newly-created assignee and goal + // rather than reporting them on the seed record while the issue row stays + // stale. + const withAgent = await post(app, companyId, { + revision: "2".repeat(32), + mission: "Make robotics boring enough to trust", + agent: { name: "Ada", role: "Chief of Staff" }, + firstTask: { title: "Draft the strategy" }, + }); + expect(withAgent.status).toBe(200); + + const companyIssues = await ctx.db.select().from(issues).where(eq(issues.companyId, companyId)); + expect(companyIssues).toHaveLength(1); + const companyAgents = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId)); + const companyGoals = await ctx.db.select().from(goals).where(eq(goals.companyId, companyId)); + expect(companyIssues[0]?.assigneeAgentId).toBe(companyAgents[0]?.id); + expect(companyIssues[0]?.goalId).toBe(companyGoals[0]?.id); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 15d3b67b42..4c25d1cbdf 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -44,6 +44,7 @@ const apiPrefixes: Record = { "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", diff --git a/server/src/app.ts b/server/src/app.ts index c641afd423..e9798f191a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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"; @@ -439,6 +440,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)); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 14924a9a6c..835c54bb76 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -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"; diff --git a/server/src/routes/onboarding-seed.ts b/server/src/routes/onboarding-seed.ts new file mode 100644 index 0000000000..e167fd81dc --- /dev/null +++ b/server/src/routes/onboarding-seed.ts @@ -0,0 +1,65 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { applyOnboardingSeedSchema } from "@paperclipai/shared"; +import { validate } from "../middleware/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. + * + * "Every part" includes the audit entry, which `apply` writes inside the same + * transaction as the seed. Because Cloud stops retrying on a 2xx, anything this + * route reports as applied must already be durable — a half that can still be + * lost after the response is a half that is lost for good. + */ +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); + + // The audit entry is written inside `apply`'s own transaction, so it + // commits with the seed or not at all. A logging failure therefore rolls + // the seed back and surfaces as a 500 — which is the *recoverable* + // outcome, because Cloud's retry then finds no stored revision, re-applies + // and re-logs. Handling it here instead, as this route used to, could only + // pick which half to lose: 500 and the retry reports `changed: false` and + // never logs; 200 and Cloud stops retrying with the entry still absent. + const result = await svc.apply(companyId, req.body, getActorInfo(req)); + + res.status(200).json({ + companyId, + revision: result.revision, + applied: true, + changed: result.changed, + goalId: result.goalId, + agentId: result.agentId, + issueId: result.issueId, + }); + }, + ); + + return router; +} diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 27619b7b59..773d2c47bf 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/onboarding-seed.ts b/server/src/services/onboarding-seed.ts new file mode 100644 index 0000000000..eba8a0d143 --- /dev/null +++ b/server/src/services/onboarding-seed.ts @@ -0,0 +1,415 @@ +import { and, eq, ne, sql } 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"; +import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.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; +}; + +/** + * The actor fields the audit entry needs, as `getActorInfo` produces them. + * Narrowed to what {@link LogActivityInput} reads so the route can hand its + * actor straight through without the service depending on Express. + */ +export type OnboardingSeedAuditActor = { + actorType: "agent" | "user" | "system" | "plugin"; + actorId: string; + agentId?: string | null; + runId?: string | null; + agentApiKeyId?: string | null; +}; + +export function onboardingSeedService(db: Db) { + async function readRecord(dbx: Db, companyId: string) { + return dbx + .select() + .from(companyOnboardingSeeds) + .where(eq(companyOnboardingSeeds.companyId, companyId)) + .then((rows) => rows[0] ?? null); + } + + async function goalStillExists(dbx: Db, companyId: string, goalId: string | null) { + if (!goalId) return false; + return dbx + .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(dbx: Db, companyId: string, recordedAgentId: string | null) { + if (recordedAgentId) { + const recorded = await dbx + .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 dbx + .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(dbx: Db, companyId: string, issueId: string | null) { + if (!issueId) return false; + return dbx + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows.length > 0); + } + + async function resolveOnboardingProjectId( + dbx: Db, + projectSvc: ReturnType, + companyId: string, + goalId: string | null, + ) { + const existing = await dbx + .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; + } + + /** + * The seed application proper, run inside the per-company transaction the + * public `apply` opens. Every read and write goes through `dbx` — the locked + * transaction — so it is serialized against a concurrent push for the same + * company. Services are reconstructed on `dbx` for the same reason. + */ + async function applyWithin( + dbx: Db, + companyId: string, + seed: ApplyOnboardingSeed, + ): Promise { + const agentSvc = agentService(dbx); + const goalSvc = goalService(dbx); + const projectSvc = projectService(dbx); + const issueSvc = issueService(dbx); + + const existing = await readRecord(dbx, 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(dbx, 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(dbx, 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. + // + // No-first-task contract (PAP-67 r17.4): on the Cloud walk this branch + // never runs. The seed Cloud sends is mission-only — `agent` and + // `firstTask` are unpopulated by the signup wizard and a paperclip-cloud + // `node:test` in `src/onboarding/` pins that — so `firstTaskTitle` is + // null here and the first task stays owned by the tenant's own + // server-owned onboarding path (`POST /issues` with + // `onboardingFirstTask: true`). That path is the only one that stamps + // `ONBOARDING_FIRST_TASK_ORIGIN_KIND` and races safely on the partial + // unique index `issues_onboarding_first_task_uq`. If this receiver ever + // created the first task on the cloud walk it would produce a *second*, + // unstamped one: no agent-authored greeting, the brief rendered as a + // right-aligned user bubble, and two onboarding tasks — silently, + // because the uq index only guards origin-stamped rows. The branch is + // retained for the endpoint's documented body contract, but the + // mission-only seed is what keeps it inert on the cloud path. + let issueId = existing?.issueId ?? null; + if (firstTaskTitle) { + if (await issueStillExists(dbx, companyId, issueId)) { + await issueSvc.update( + issueId as string, + { + title: firstTaskTitle, + description: firstTaskDetails, + // Keep the task's relationships in step with a later revision that + // supplied the agent or goal after the task already existed — + // otherwise the record would report an assignee/goal the issue row + // does not actually carry. Only set them when resolved, so an + // absent value never clears an assignment the tenant made. + ...(agentId ? { assigneeAgentId: agentId } : {}), + ...(goalId ? { goalId } : {}), + }, + dbx, + ); + } else { + const projectId = await resolveOnboardingProjectId(dbx, projectSvc, companyId, goalId); + // The idempotency key is what protects two pushes that arrive at once + // — Cloud's reconcile runs off portfolio fetches, which can overlap. + // It is deliberately not revision-scoped: if the recorded issue is + // lost, a later revision should still dedupe against whatever the + // first push created. + 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 dbx + .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 }; + } + + /** + * 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. + * + * Concurrency: Cloud's reconcile runs off portfolio fetches, which can + * overlap, so two pushes for the same company can arrive at once. Both would + * otherwise pass the revision check before either wrote the seed record and + * each create a company goal, a lead agent and an Onboarding project. A + * per-company advisory lock held for the transaction serializes them — the + * same idiom `folders` and `decision-queues` use — so the second push sees + * the first push's writes (the record, the reused goal/agent/project) and + * updates in place instead of duplicating. + * + * Auditing: when `audit` is supplied and the push changed anything, the + * `company.onboarding_seed_applied` entry is written *inside* this same + * transaction. That is the only arrangement in which the entry cannot go + * permanently missing. Logging after the commit forces a choice between two + * broken outcomes — answer 500 and the retry returns `changed: false` and + * never logs, or answer 200 and Cloud stops retrying while the entry stays + * absent. Writing it transactionally removes the choice: either both land, or + * neither does and the retry re-applies from a clean slate. + */ + async function apply( + companyId: string, + seed: ApplyOnboardingSeed, + audit?: OnboardingSeedAuditActor, + ): Promise { + // Collected inside the transaction, published only after it commits: the + // activity row is transactional but its realtime/plugin fan-out is not, and + // announcing a seed that then rolled back would be worse than announcing it + // late. + const publications: ActivityPublication[] = []; + + const result = await db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:onboarding-seed:${companyId}`}, 0))`, + ); + const dbx = tx as unknown as Db; + const applied = await applyWithin(dbx, companyId, seed); + + if (applied.changed && audit) { + await logActivity( + dbx, + { + companyId, + actorType: audit.actorType, + actorId: audit.actorId, + agentId: audit.agentId, + runId: audit.runId, + agentApiKeyId: audit.agentApiKeyId, + action: "company.onboarding_seed_applied", + entityType: "company", + entityId: companyId, + details: { + revision: applied.revision, + goalId: applied.goalId, + agentId: applied.agentId, + issueId: applied.issueId, + }, + }, + publications, + ); + } + + return applied; + }); + + for (const publication of publications) publishActivity(publication); + return result; + } + + return { apply, get: (companyId: string) => readRecord(db, companyId) }; +}