feat(server): receive and apply the Paperclip Cloud onboarding seed (#11098)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Cloud provisions a dedicated tenant stack for each
customer. During signup it asks for a mission, a name and role for the
first agent, and a first task.
> - Cloud pushes those answers into the new stack at activation, as
`POST /api/companies/:companyId/onboarding-seed`.
> - No route served that path. The tenant answered 404, so Cloud
recorded the push as unacknowledged and retried on every portfolio
fetch.
> - The failure was soft. The answers stayed durable in Cloud and the
stack still activated. But the stack opened on the empty first-run
wizard, and it asked the customer again for what they had already given.
> - This pull request adds the receiving endpoint. It validates the
seed, applies it, and acknowledges it.
> - The benefit is that a seeded stack opens with the mission, the agent
and the first task already in place.

## Linked Issues or Issue Description

No public GitHub issue covers this. The problem is described in-PR,
following the feature template.

**Subsystem affected**

server/ — Express REST API and orchestration services. Also
`packages/db` (one new table) and `packages/shared` (one new validator).

**Problem or motivation**

Paperclip Cloud collects onboarding answers at signup and pushes them to
the tenant stack at activation. The tenant had no route for that
request. It answered 404. Cloud treats a non-2xx as "not yet applied",
so it kept the answers and retried, but the stack itself stayed
unseeded. A customer who had already named their mission, their first
agent and their first task arrived at an empty first-run wizard that
asked for all three again.

**Proposed solution**

Serve `POST /api/companies/:companyId/onboarding-seed`. Validate the
body, apply it to the company, then acknowledge it.

The seed is customer free text, so it is bounded and validated in
`packages/shared` and read from the JSON body only. It is never read
from an `x-paperclip-cloud-*` header. That header set is the trusted
identity envelope: every member is derived server-side from the host
plus verified domain records, and that is exactly what makes it
trustworthy. Mixing user content into it would remove the property. A
test plants a mission on a cloud header and asserts that the body value
wins.

Application reuses the shapes the first-run wizard already produces, so
a seeded stack and a manually onboarded one look the same afterwards:

- The mission becomes the company-level goal. A multi-line mission
splits into a title and a description, as the wizard does.
- The agent becomes the company's first hire. Its free-text role ("Chief
of Staff") lands on `title`. The structural `role` stays `ceo`, which is
what the org chart and the default-instructions lookup read.
- The first task becomes an issue in the Onboarding project, assigned to
that agent.

Cloud retries until it gets a 2xx, and it reads any 2xx as "the tenant
holds this content". So the endpoint is idempotent per `revision`. A new
`company_onboarding_seeds` table records the applied revision together
with the goal, the agent and the issue it produced. A replay of a
revision that already matches is a successful no-op. A later revision —
the customer edited their answers — updates those three rows in place
instead of 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.

Everything is applied before the 200 is sent. This is an ordering
guarantee, not eventual consistency. 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.

**Alternatives considered**

Store the seed and let the tenant UI apply it on first load. Rejected:
the dashboard redirect is gated on the acknowledgement, so a background
apply would let the dashboard open before the agent and the task exist.
The whole point is that it must not.

Reuse `POST /companies/:companyId/agents` and `POST
/companies/:companyId/issues` over HTTP from Cloud. Rejected: it needs
three round trips with no shared idempotency key, and it moves the "did
all of it land?" decision to the caller.

**Roadmap alignment**

This completes an existing Cloud-to-tenant contract. It does not add a
new user-facing surface.

## What Changed

- Add `POST /api/companies/:companyId/onboarding-seed` in
`server/src/routes/onboarding-seed.ts`. It authenticates exactly as
`POST /api/companies/:companyId/logo` does, through
`assertCompanyAccess`.
- Add `server/src/services/onboarding-seed.ts`. It applies the mission,
the agent and the first task, and records the applied revision last.
- Add the `company_onboarding_seeds` table: schema, migration `0216`,
and journal entry. It holds the applied revision and the ids of the
goal, agent and issue the seed produced.
- Add `applyOnboardingSeedSchema` in `packages/shared`. It bounds
mission to 2000, agent name to 80, agent role to 120, task title to 200,
and task details to 2000 — the same limits Cloud enforces before it
sends.
- Mount the router in `server/src/app.ts` and register the path in the
OpenAPI document.
- Add `server/src/__tests__/onboarding-seed-route.test.ts` with 13
tests.
- The seeded agent is created on `claude_local`. This mirrors the
teams-catalog default for agents created server-side, where no human
runs an environment test first. `PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE`
overrides it.

## Verification

```sh
pnpm typecheck                      # whole workspace, passes
npx vitest run \
  server/src/__tests__/onboarding-seed-route.test.ts \
  server/src/__tests__/openapi-routes.test.ts        # 15 passed
```

The suite runs against embedded Postgres with migrations applied, so
migration `0216` is exercised by every test.

The route tests cover:

- the happy path — mission, agent and task all applied, read immediately
after the 200
- replay of the same revision — no second agent, no second task, no
second goal, no second project
- a later revision — the goal, agent and task are updated in place
- a multi-line mission splitting into a goal title and description
- a revision-only seed
- the activity log entry written once, and not again on a replay
- a caller without access to the company — 403, and nothing written
- a body with no revision — 400
- each field bound past its limit — 400
- a mission planted on an `x-paperclip-cloud-*` header — ignored, body
wins
- an existing Onboarding project — reused, not duplicated

Not verified here: the full Cloud-to-tenant walk against a live stack.
That needs a deployed Cloud and a provisioned tenant together, which is
separate staging work.

## Risks

Migration `0216` creates one new table. It adds no column to an existing
table, rewrites nothing, and backfills nothing, so it is safe to apply
online. The migration safety check passes.

The endpoint writes to a company. Access is enforced by
`assertCompanyAccess`, the same gate the company logo write uses, and a
test covers the denial.

Behavioral note for stacks that already hold data. If a company already
has a non-built-in `ceo` agent, a first seed updates that agent's name
and title rather than creating a second lead. Likewise a seed adopts an
existing company-level goal rather than adding a parallel one. This is
deliberate: the seed is the customer's own stated answer from signup,
and two competing missions or two leads would be worse than one updated
in place. In the intended case — a stack that Cloud has just activated —
none of these exist yet.

The seeded agent is created on `claude_local` with an empty adapter
config. It is idle and needs the usual credential setup before it runs.
Seeding it does not start it.

## Update — rebased onto master + review hardening

Master moved on after this PR was cut, so it was **rebased onto
`master`** and
the seed migration was **renumbered from `0212` to `0216`** (the merged
#11101
took `0212_onboarding_first_task_unique`); the drizzle journal was
re-stitched
and `check:migrations` passes.

Two things landed on top of the original receiver:

- **Mission-only walk contract (PAP-67 r17.4).** The tenant now owns the
first
agent and the first task via #11101's server-owned onboarding path,
which
stamps `ONBOARDING_FIRST_TASK_ORIGIN_KIND` and races safely on the
partial
unique index `issues_onboarding_first_task_uq`. A comment in the apply
path
documents why this receiver leaves the first task to that path on the
cloud
walk, and a paperclip-cloud `node:test`
(`src/onboarding/walk-seed.test.ts`)
asserts the walk's seed carries no `agent`/`firstTask`. The receiver
retains
the agent/first-task code for its documented body contract, kept inert
on the
  cloud path by the mission-only seed.
- **Three Greptile P1 fixes** (`95622fa37`): concurrent application is
now
  serialized under a per-company `pg_advisory_xact_lock` (no duplicate
goal/agent/project/task on overlapping pushes); a revised first task
carries
its resolved `assigneeAgentId`/`goalId`; and the
`company.onboarding_seed_applied`
  audit write is best-effort so a logging failure can't leave the entry
  permanently absent. Two new regression tests cover the first two.

## Model Used

Claude Opus 5 (`claude-opus-5`), 1M context window, extended thinking,
with tool use and code execution. Used for the original codebase
investigation, the implementation, and the tests. The rebase, migration
renumber, mission-only contract, and the three P1 fixes were done with
Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use
and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tonio 2026-08-12 22:54:07 -07:00 committed by GitHub
parent 1e07d5b9aa
commit f0e6c0f549
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1020 additions and 0 deletions

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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),
}),
);

View File

@ -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";

View File

@ -1859,6 +1859,8 @@ export {
updateGoalSchema,
type CreateGoal,
type UpdateGoal,
applyOnboardingSeedSchema,
type ApplyOnboardingSeed,
createApprovalSchema,
upsertBudgetPolicySchema,
resolveBudgetIncidentSchema,

View File

@ -590,6 +590,11 @@ export {
type UpdateGoal,
} from "./goal.js";
export {
applyOnboardingSeedSchema,
type ApplyOnboardingSeed,
} from "./onboarding-seed.js";
export {
createApprovalSchema,
resolveApprovalSchema,

View File

@ -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>;

View File

@ -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<typeof import("../services/activity-log.js")>();
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<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("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);
});
});

View File

@ -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",

View File

@ -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));

View File

@ -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";

View File

@ -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;
}

View File

@ -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",

View File

@ -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<typeof projectService>,
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<OnboardingSeedApplication> {
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<OnboardingSeedApplication> {
// 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) };
}