diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 211628e..c321d1a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -152,14 +152,27 @@ jobs: - name: Seed is idempotent # A seed that duplicates on a second run corrupts any database it is # pointed at twice, and nobody notices until the counts look odd. + # + # Two tables are counted, not one. This gate only ever watched + # `contacts`, and `contacts` is idempotent by an explicit existence + # check — so it could not see the class of regression it exists to + # catch, which is `onConflictDoNothing` firing at a constraint that is + # no longer there. The Motion starter library relies on exactly that + # clause against `motion_templates_slug_version_key`, so it is counted + # here too. Any table whose idempotency rests on a conflict target + # belongs in this list. run: | pnpm exec tsx packages/db/src/seed/index.ts > /dev/null - count() { docker exec "$PG_CONTAINER" psql -U pig -d pig -tAc "select count(*) from contacts"; } - BEFORE=$(count) + count() { docker exec "$PG_CONTAINER" psql -U pig -d pig -tAc "select count(*) from $1"; } + CONTACTS_BEFORE=$(count contacts) + TEMPLATES_BEFORE=$(count motion_templates) pnpm exec tsx packages/db/src/seed/index.ts > /dev/null - AFTER=$(count) - echo "contacts: $BEFORE -> $AFTER" - test "$BEFORE" = "$AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; } + CONTACTS_AFTER=$(count contacts) + TEMPLATES_AFTER=$(count motion_templates) + echo "contacts: $CONTACTS_BEFORE -> $CONTACTS_AFTER" + echo "motion_templates: $TEMPLATES_BEFORE -> $TEMPLATES_AFTER" + test "$CONTACTS_BEFORE" = "$CONTACTS_AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; } + test "$TEMPLATES_BEFORE" = "$TEMPLATES_AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; } - name: Critical path E2E against Postgres and Hono run: pnpm run test:e2e diff --git a/AGENTS.md b/AGENTS.md index e88357f..aa0b7cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,19 +31,21 @@ Everything else is plumbing that exists to keep that ledger honest. ``` packages/core Ontology (stages, tiers, enums) + permissions + margin + palette -packages/db Drizzle schema (47 tables), migrations, seeds + + motion.ts (template kinds, integer qualification scoring) +packages/db Drizzle schema (51 tables), migrations, seeds + schema/motion.ts — the library, engagements and the loop packages/prime Typed client for the Prime Intellect compute API -apps/api Hono HTTP API, auth, capacity/contract/calendar services +apps/api Hono HTTP API, auth, capacity/contract/calendar/motion services apps/web React + Vite + Tailwind + shadcn-idiom components apps/piggy The agent — lease-based queue worker + private chat server -apps/mcp MCP server (stdio) — 9 tools +apps/mcp MCP server (stdio) — 10 tools apps/cli `pig`, the HTTP surface for scripts and agent kernels -docs/ ontology.md, build-plan.md, agents.md, seed-data.md +docs/ ontology.md, motion.md, build-plan.md, agents.md, seed-data.md deploy/ README.md (deployment), Caddyfile example, autodeploy units ``` -~45,000 lines including tests. 261 tests across five packages -(core 62, prime 24, api 157, piggy 13, cli 5), plus a critical-path E2E suite +~48,000 lines including tests. 382 tests across five packages +(core 78, prime 24, api 212, piggy 63, cli 5), plus a critical-path E2E suite under `apps/api/e2e`. Node 22+. | | | @@ -231,11 +233,27 @@ Pass `reasoning_effort: "none"` for tool use, routing and extraction. **A route file with green tests can still be unmounted.** Every route module is a factory returning a `Hono` app, and `createApp` has to call it. The tests mount the factory themselves, so they pass whether or not `app.ts` ever does. -Four modules are in exactly that state right now — `read-guards.ts`, -`learn.ts`, `hubspot.ts`, `hubspot-webhook.ts` — which is why read -authorisation is unenforced and `/learn` answers 404 from a page that is in the -navigation. After adding a route file, curl the path against a running server; -the test suite cannot tell you. +`read-guards.ts` and `learn.ts` were in exactly that state — which is why read +authorisation went unenforced and `/learn` answered 404 from a page that was in +the navigation — and are now mounted. `hubspot.ts` and `hubspot-webhook.ts` +still are not. After adding a route file, curl the path against a running +server; the test suite cannot tell you. + +**A stale dev server on :8920 makes a mounted route look unmounted.** The curl +check the entry above recommends is only as good as the process answering it. `pnpm run dev:api` prints its `EADDRINUSE` and keeps running under +the process manager, so a server started hours earlier from an older checkout +goes on answering — and every new route 404s with a perfectly plausible +`{"error":"Not found"}` JSON body. Found this way: five Motion routes that were +correctly mounted read as missing for twenty minutes. Check +`ss -lptn 'sport = :8920'` before believing a 404, and read the dev server's log +rather than only its port. + +**The demo seed skips an account it has already seen, and the rows hanging off +that account never appear.** `seedDemo` is idempotent per account, so a database +carrying a partial demo book from an earlier run silently produces no demand +deals — and anything that looks a deal up by name, as `seed/demo/motion.ts` +does, then reports zero and reads exactly like a broken loader. The fix is +`pnpm db:demo -- --clear` and a reseed, not a patch to the lookup. **Deployment traps** live in `deploy/README.md` — chiefly that every Caddy site block on that host needs `bind 10.0.0.2`, and that the CI runner uses diff --git a/README.md b/README.md index ed00b59..507eb87 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ thesis: | `contracts` + `sla_terms` + `sla_metric_targets` + `contract_obligations` | Polymorphic over party and type — MSA, DPA, SLA, order form, capacity commitment — with negotiated SLA terms and dated obligations | The supply side negotiates heavyweight paper; the self-serve demand side runs on a reliability tier and a credits policy instead | | `export_authorizations`, `compliance_artifacts`, `compliance_decisions` | Export-control determinations recorded **on the allocation edge**, with reasoning and rule version | US controls apply an ultimate-parent test that reaches through the corporate tree, so country of incorporation is not a valid key | | `facts` | Every agent-derived claim, with score, band, evidence excerpt and source URL | An agent allowed to write unattributed claims will eventually write a wrong one and nobody will be able to tell which | +| `motion_templates` + `engagements` + `engagement_artifacts` + `qualification_scores` | The go-to-market motion: a reusable library bound to the stages of a demand deal, and the artefacts each engagement produced | A used template is never edited in place — promotion writes a **new version** pointing back at the artefact that proved it, which is what makes the next deployment cheaper than the last | | `agent_tasks` / `agent_runs` / `agent_actions` | The queue the API writes to and the agent drains, plus what it did | The API never calls the model; it writes a row | Two pipelines, with stages taken from how the market operates: @@ -351,13 +352,13 @@ apps/ web/ React 19 + Vite + Tailwind + shadcn-idiom components api/ Hono HTTP API — auth, validation, capacity and contract services piggy/ The agent: a lease-based queue worker plus a private chat server - mcp/ MCP server (stdio) — 9 tools + mcp/ MCP server (stdio) — 10 tools cli/ `pig`, the HTTP surface for scripts and agent kernels packages/ core/ Ontology, permissions, margin arithmetic, palette — no I/O - db/ Drizzle schema (47 tables), 14 migrations, seed and demo data + db/ Drizzle schema (51 tables), 15 migrations, seed and demo data prime/ Typed client for the Prime Intellect compute API -docs/ ontology.md, screenshots.md, build-plan.md, agents.md, seed-data.md +docs/ ontology.md, motion.md, screenshots.md, build-plan.md, agents.md, seed-data.md deploy/ Caddyfile example, autodeploy units, deployment notes ``` @@ -590,6 +591,7 @@ responsive to 393px; it is not a native app. - [Screenshots](./docs/screenshots.md) — every page, at 1440px and 393px, light and dark - [Ontology](./docs/ontology.md) — the domain model, and why it is shaped this way - [Build plan](./docs/build-plan.md) — what shipped, what remains, in dependency order +- [Motion](./docs/motion.md) — the template library, the promotion loop, and the private/shared departure - [Agent integration](./docs/agents.md) — MCP clients and the CLI - [Seed data provenance](./docs/seed-data.md) — every claim, graded and cited - [Deployment](./deploy/README.md) — self-hosting, the release poller, rollback diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 77c0f86..a4a2615 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -72,6 +72,7 @@ import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/n import { createGrowthRoutes } from './routes/growth'; import { createCalendarRoutes } from './routes/calendar'; import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn'; +import { createMotionRoutes } from './routes/motion'; import { createReadGuardRoutes } from './routes/read-guards'; import { createActivityRoutes } from './routes/activities'; import { NotificationOutbox } from './services/notification-outbox'; @@ -266,6 +267,7 @@ export function createApp( app.route('/', createGrowthRoutes(db)); app.route('/', createCalendarRoutes(db)); app.route('/', createLearnRoutes(db)); + app.route('/', createMotionRoutes(db)); app.route( '/', createPiggyChatRoutes({ diff --git a/apps/api/src/routes/motion.ts b/apps/api/src/routes/motion.ts new file mode 100644 index 0000000..792325c --- /dev/null +++ b/apps/api/src/routes/motion.ts @@ -0,0 +1,933 @@ +/** + * Motion — the HTTP surface for the library, the engagements and the loop. + * + * Thin by design. Every decision worth arguing about — who may see a private + * template, what promotion writes, how a score is computed — is in + * `services/motion.ts`, so Piggy reaches the same answers in-process without + * going through a handler. The writes are exported `MutationDefinition` + * factories in the register of `capacity-writes.ts`, which is what lets a test + * drive the rules in §7 without an HTTP server or a database. + * + * Two things here are not obvious from the endpoint table. + * + * **`motion:write` and `motion:publish` are team capabilities held on any + * team**, so they are enforced with `requireAnyTeamCapability` rather than a + * `{ capability, team }` pair. A motion template belongs to a person and a + * lineage, not to supply or demand; picking a team to check against would mean + * inventing one, and inventing one is how a research lead ends up unable to + * publish the reference architecture they are the one writing. + * + * **Creating or forking straight to `shared` needs `motion:publish`.** The + * publish endpoint is not the only way into the shared library — `POST + * /templates` and `POST /templates/:id/versions` both take a `visibility` — so + * the gate is applied in all three places rather than on the one door that + * happens to be named after it. + */ +import { + ARTIFACT_STATUSES, + DEMAND_STAGES, + ENGAGEMENT_STATUSES, + MOTION_KINDS, + MOTION_MAX_DIMENSION_SCORE, + MOTION_MIN_DIMENSION_SCORE, + MOTION_VISIBILITIES, + motionBand, + permissionGranted, + resolveWritePermissionGrants, +} from '@pig/core'; +import type { + Database, + Engagement, + EngagementArtifact, + MotionTemplate, + QualificationScore, +} from '@pig/db'; +import { demandDeals, engagementArtifacts, engagements, motionTemplates } from '@pig/db'; +import { eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { requireAnyTeamCapability, type Principal } from '../lib/auth'; +import { + apiError, + bodylessMutation, + MutationError, + mutation, + type ApiEnv, + type MutationDefinition, +} from '../lib/mutation'; +import { + assertTemplateEditable, + assertTemplateWritable, + instantiateArtifact, + loadEngagement, + loadTemplateForWrite, + lockNewestVersion, + motionSlug, + MotionService, + newestVisibleInLineage, + ownsTemplate, + promoteArtifact, + recordScore, + versionConflict, + type MotionTransaction, + type MotionViewer, +} from '../services/motion'; + +// ------------------------------------------------------------------- schemas + +const uuid = z.string().uuid(); +const title = z.string().trim().min(1).max(200); +const summary = z.string().trim().min(1).max(2_000); +/** Markdown, and long: a playbook is a document, not a field. */ +const bodyText = z.string().max(200_000); +const slug = z.string().trim().min(1).max(80); +const fields = z.record(z.unknown()).nullable(); + +const templateCreateSchema = z + .object({ + kind: z.enum(MOTION_KINDS), + slug: slug.optional(), + title, + summary, + body: bodyText, + stage: z.enum(DEMAND_STAGES), + fields: fields.optional(), + visibility: z.enum(MOTION_VISIBILITIES).optional(), + }) + .strict(); + +/** + * No `kind` and no `visibility`. A lineage that changes kind halfway is a + * different template wearing the same slug, and visibility moves through + * `/publish`, which is where the capability check lives. + */ +const templateUpdateSchema = z + .object({ title, summary, body: bodyText, stage: z.enum(DEMAND_STAGES), fields }) + .partial() + .strict() + .refine((input) => Object.keys(input).length > 0, 'At least one change is required.'); + +const templateVersionSchema = z + .object({ + title: title.optional(), + summary: summary.optional(), + body: bodyText.optional(), + stage: z.enum(DEMAND_STAGES).optional(), + fields: fields.optional(), + visibility: z.enum(MOTION_VISIBILITIES).optional(), + }) + .strict(); + +const emptySchema = z.object({}).strict(); + +const engagementCreateSchema = z + .object({ + demandDealId: uuid, + playbookTemplateId: uuid.nullable().optional(), + ownerUserId: uuid.nullable().optional(), + summary: z.string().trim().max(2_000).nullable().optional(), + }) + .strict(); + +const engagementUpdateSchema = z + .object({ + status: z.enum(ENGAGEMENT_STATUSES), + ownerUserId: uuid.nullable(), + playbookTemplateId: uuid.nullable(), + summary: z.string().trim().max(2_000).nullable(), + }) + .partial() + .strict() + .refine((input) => Object.keys(input).length > 0, 'At least one change is required.'); + +const artifactCreateSchema = z + .object({ + templateId: uuid.optional(), + kind: z.enum(MOTION_KINDS).optional(), + stage: z.enum(DEMAND_STAGES).optional(), + title: title.optional(), + body: bodyText.optional(), + fields: fields.optional(), + status: z.enum(ARTIFACT_STATUSES).optional(), + }) + .strict() + .refine( + (input) => + Boolean(input.templateId) || + Boolean(input.kind && input.stage && input.title && input.body !== undefined), + 'Supply a templateId to instantiate, or a kind, stage, title and body.', + ); + +const artifactUpdateSchema = z + .object({ + title, + body: bodyText, + fields, + stage: z.enum(DEMAND_STAGES), + status: z.enum(ARTIFACT_STATUSES), + archived: z.boolean(), + }) + .partial() + .strict() + .refine((input) => Object.keys(input).length > 0, 'At least one change is required.'); + +const promoteSchema = z + .object({ slug: slug.optional(), title: title.optional(), summary: summary.optional() }) + .strict(); + +/** + * Weights and scores are integers, and the total is recomputed server-side from + * these — the client never posts a score. Bounds come from `@pig/core` so the + * scale is defined in one place. + */ +const scoreSchema = z + .object({ + dimensions: z + .array( + z + .object({ + id: z.string().trim().min(1).max(120), + weight: z.number().int().min(0).max(1_000), + score: z + .number() + .int() + .min(MOTION_MIN_DIMENSION_SCORE) + .max(MOTION_MAX_DIMENSION_SCORE), + }) + .strict(), + ) + .max(50), + frameworkTemplateId: uuid.nullable().optional(), + note: z.string().trim().max(2_000).nullable().optional(), + }) + .strict(); + +const filtersSchema = z + .object({ + kind: z.enum(MOTION_KINDS).optional(), + stage: z.enum(DEMAND_STAGES).optional(), + visibility: z.enum(MOTION_VISIBILITIES).optional(), + q: z.string().trim().max(200).optional(), + all: z.string().optional(), + }) + .strict(); + +const engagementFiltersSchema = z + .object({ status: z.enum(ENGAGEMENT_STATUSES).optional() }) + .strict(); + +// ------------------------------------------------------------------- helpers + +function viewerOf(principal: Principal): MotionViewer { + return { userId: principal.userId, isPlatformAdmin: principal.isPlatformAdmin }; +} + +const authorizeWrite = { + authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:write'), +}; +const authorizePublish = { + authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:publish'), +}; + +/** + * A template id somebody sent, checked before it is stored. + * + * `playbook_template_id` and `framework_template_id` are provenance, and they + * are returned book-wide on every engagement summary. Written raw, an id + * nobody but its owner may read becomes public through the engagement that + * cites it, and a wrong id is a foreign-key 500 rather than a 404. Reading it + * through the visibility filter makes both cases a clean not-found. + */ +async function checkedTemplateId( + tx: MotionTransaction, + principal: Principal, + id: string | null | undefined, +): Promise { + if (!id) return; + await loadTemplateForWrite(tx, viewerOf(principal), id); +} + +/** The same gate as `authorizePublish`, applied once the body says `shared`. */ +function requirePublishFor(principal: Principal, visibility: string | undefined): void { + if (visibility === 'shared') requireAnyTeamCapability(principal, 'motion:publish'); +} + +/** + * The hint the detail page renders its Publish button from. It checks the + * `write` scope as well as the capability, because `requireAnyTeamCapability` + * does — a read-only API key belonging to a lead would otherwise be told it may + * publish and then be refused `insufficient_scope` by the endpoint itself. + */ +function canPublish(principal: Principal): boolean { + return ( + principal.scopes.includes('write') && + permissionGranted(resolveWritePermissionGrants(principal), 'motion:publish') + ); +} + +function requiredId(params: Readonly>, resource: string): string { + const id = params.id; + if (!id) throw MutationError.notFound(resource); + return id; +} + +// ------------------------------------------------------------ library writes + +export function motionTemplateCreateDefinition(): MutationDefinition< + typeof templateCreateSchema, + { template: MotionTemplate } +> { + return { + schema: templateCreateSchema, + permission: authorizeWrite, + invalidMessage: 'Invalid motion template.', + async mutate({ input, principal, tx, now }) { + requirePublishFor(principal, input.visibility); + const slug = motionSlug(input.slug ?? input.title); + + // Checked rather than left to `(slug, version)`, for the reason the + // engagement create gives below: a title that derives an existing slug — + // "Proposal Blocks" is one of the nine shipped starters — would otherwise + // be a 500 with `Internal error` and no way for the caller to tell what + // they collided with. + if (await lockNewestVersion(tx, slug)) { + throw new MutationError( + 'template_slug_exists', + `A template lineage already uses the slug "${slug}". POST /api/motion/templates//versions to add a version to it, or supply a different slug.`, + 409, + ); + } + + const [created] = await tx + .insert(motionTemplates) + .values({ + kind: input.kind, + slug, + version: 1, + title: input.title, + summary: input.summary, + body: input.body, + fields: input.fields ?? null, + stage: input.stage, + visibility: input.visibility ?? 'private', + // Never null. The CHECK refuses an unowned private row, and an + // unowned private row is one nobody could ever read back. + ownerUserId: principal.userId, + createdAt: now, + updatedAt: now, + }) + .returning() + .catch(versionConflict); + if (!created) throw new Error('Motion template insert returned no row'); + + return { + data: { template: created }, + activity: { + type: 'note', + subject: `Added a motion template: ${created.title}`, + meta: { + action: 'motion_template.created', + templateId: created.id, + kind: created.kind, + slug: created.slug, + visibility: created.visibility, + }, + }, + }; + }, + }; +} + +export function motionTemplateUpdateDefinition(): MutationDefinition< + typeof templateUpdateSchema, + { template: MotionTemplate } +> { + return { + schema: templateUpdateSchema, + permission: authorizeWrite, + invalidMessage: 'Invalid motion template change.', + async mutate({ input, params, principal, tx, now }) { + const viewer = viewerOf(principal); + const existing = await loadTemplateForWrite( + tx, + viewer, + requiredId(params, 'Motion template'), + ); + assertTemplateWritable(viewer, existing); + assertTemplateEditable(existing); + + const [updated] = await tx + .update(motionTemplates) + .set({ + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.summary !== undefined ? { summary: input.summary } : {}), + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.stage !== undefined ? { stage: input.stage } : {}), + ...(input.fields !== undefined ? { fields: input.fields } : {}), + updatedAt: now, + }) + .where(eq(motionTemplates.id, existing.id)) + .returning(); + if (!updated) throw MutationError.notFound('Motion template'); + + return { + data: { template: updated }, + activity: { + type: 'note', + subject: `Edited a motion template: ${updated.title}`, + meta: { + action: 'motion_template.updated', + templateId: updated.id, + fields: Object.keys(input), + }, + }, + }; + }, + }; +} + +/** + * A new version of a lineage — the answer to a `409 template_in_use`, and the + * "fork to private" affordance, in one endpoint. + * + * Ownership is deliberately NOT required: a shared template belongs to the + * book, and forking it into your own private draft is what a library is for. + * What is required is `motion:publish` if the fork is to land shared. + */ +export function motionTemplateVersionDefinition(): MutationDefinition< + typeof templateVersionSchema, + { template: MotionTemplate } +> { + return { + schema: templateVersionSchema, + permission: authorizeWrite, + invalidMessage: 'Invalid motion template version.', + async mutate({ input, params, principal, tx, now }) { + requirePublishFor(principal, input.visibility); + const viewer = viewerOf(principal); + const source = await loadTemplateForWrite( + tx, + viewer, + requiredId(params, 'Motion template'), + ); + // Two different questions, deliberately answered by two queries. The + // version has to be allocated against the whole lineage or it collides + // with a private fork nobody else can see, but `supersedes_id` must name + // a row this caller could actually fetch — otherwise a fork of a shared + // v1 comes back claiming to supersede somebody's private v4, which both + // leaks that the private versions exist and records a lineage edge that + // is not the one the user made. + const newest = await lockNewestVersion(tx, source.slug); + const previous = (await newestVisibleInLineage(tx, viewer, source.slug)) ?? source; + + const [created] = await tx + .insert(motionTemplates) + .values({ + kind: source.kind, + slug: source.slug, + version: (newest?.version ?? source.version) + 1, + title: input.title ?? source.title, + summary: input.summary ?? source.summary, + body: input.body ?? source.body, + fields: input.fields !== undefined ? input.fields : source.fields, + stage: input.stage ?? source.stage, + // Private unless asked otherwise, so forking a shared template is + // never accidentally a publication. + visibility: input.visibility ?? 'private', + ownerUserId: principal.userId, + supersedesId: previous.id, + isSystem: false, + createdAt: now, + updatedAt: now, + }) + .returning() + .catch(versionConflict); + if (!created) throw new Error('Motion template version insert returned no row'); + + return { + data: { template: created }, + activity: { + type: 'note', + subject: `New version of ${created.title} (v${created.version})`, + meta: { + action: 'motion_template.versioned', + templateId: created.id, + supersedesId: previous.id, + slug: created.slug, + version: created.version, + }, + }, + }; + }, + }; +} + +export function motionTemplatePublishDefinition(): MutationDefinition< + typeof emptySchema, + { template: MotionTemplate } +> { + return { + schema: emptySchema, + permission: authorizePublish, + invalidMessage: 'Invalid publish request.', + async mutate({ params, principal, tx, now }) { + const viewer = viewerOf(principal); + const existing = await loadTemplateForWrite( + tx, + viewer, + requiredId(params, 'Motion template'), + ); + assertTemplateWritable(viewer, existing); + + // Idempotent: publishing twice is the same statement made twice, and a + // 409 here would only ever fire on a double-clicked button. + const [published] = + existing.visibility === 'shared' + ? [existing] + : await tx + .update(motionTemplates) + .set({ visibility: 'shared', updatedAt: now }) + .where(eq(motionTemplates.id, existing.id)) + .returning(); + if (!published) throw MutationError.notFound('Motion template'); + + return { + data: { template: published }, + activity: { + type: 'note', + subject: `Published to the library: ${published.title} (v${published.version})`, + meta: { + action: 'motion_template.published', + templateId: published.id, + alreadyShared: existing.visibility === 'shared', + }, + }, + }; + }, + }; +} + +/** + * Archive, never delete: an engagement artifact records the template it came + * from, and provenance that can vanish is not provenance. + */ +export function motionTemplateArchiveDefinition(): MutationDefinition< + typeof emptySchema, + { id: string; archivedAt: Date | null } +> { + return { + schema: emptySchema, + permission: authorizeWrite, + invalidMessage: 'Invalid template removal.', + async mutate({ params, principal, tx, now }) { + const viewer = viewerOf(principal); + const existing = await loadTemplateForWrite( + tx, + viewer, + requiredId(params, 'Motion template'), + ); + assertTemplateWritable(viewer, existing); + + const [archived] = existing.archivedAt + ? [existing] + : await tx + .update(motionTemplates) + .set({ archivedAt: now, updatedAt: now }) + .where(eq(motionTemplates.id, existing.id)) + .returning(); + if (!archived) throw MutationError.notFound('Motion template'); + + return { + data: { id: archived.id, archivedAt: archived.archivedAt }, + activity: { + type: 'note', + subject: `Archived a motion template: ${archived.title}`, + meta: { + action: 'motion_template.archived', + templateId: archived.id, + alreadyArchived: existing.archivedAt !== null, + }, + }, + }; + }, + }; +} + +// --------------------------------------------------------- engagement writes + +export function motionEngagementCreateDefinition(): MutationDefinition< + typeof engagementCreateSchema, + { engagement: Engagement } +> { + return { + schema: engagementCreateSchema, + permission: authorizeWrite, + invalidMessage: 'Invalid engagement.', + async mutate({ input, principal, tx, now }) { + const [deal] = await tx + .select() + .from(demandDeals) + .where(eq(demandDeals.id, input.demandDealId)) + .limit(1); + if (!deal) throw MutationError.notFound('Demand deal'); + await checkedTemplateId(tx, principal, input.playbookTemplateId); + + // Checked rather than left to the unique constraint, so the caller gets + // the id of the engagement that already exists instead of a 500. + const [existing] = await tx + .select() + .from(engagements) + .where(eq(engagements.demandDealId, deal.id)) + .limit(1); + if (existing) { + throw new MutationError( + 'engagement_exists', + `This deal already has an engagement (${existing.id}).`, + 409, + ); + } + + const [created] = await tx + .insert(engagements) + .values({ + demandDealId: deal.id, + playbookTemplateId: input.playbookTemplateId ?? null, + ownerUserId: input.ownerUserId ?? principal.userId, + summary: input.summary ?? null, + openedAt: now, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (!created) throw new Error('Engagement insert returned no row'); + + return { + data: { engagement: created }, + activity: { + type: 'note', + subject: `Opened an engagement on ${deal.name}`, + accountId: deal.accountId, + demandDealId: deal.id, + meta: { action: 'engagement.opened', engagementId: created.id }, + }, + }; + }, + }; +} + +export function motionEngagementUpdateDefinition(): MutationDefinition< + typeof engagementUpdateSchema, + { engagement: Engagement } +> { + return { + schema: engagementUpdateSchema, + permission: authorizeWrite, + invalidMessage: 'Invalid engagement change.', + async mutate({ input, params, principal, tx, now }) { + const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement')); + await checkedTemplateId(tx, principal, input.playbookTemplateId); + + const [updated] = await tx + .update(engagements) + .set({ + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.ownerUserId !== undefined ? { ownerUserId: input.ownerUserId } : {}), + ...(input.playbookTemplateId !== undefined + ? { playbookTemplateId: input.playbookTemplateId } + : {}), + ...(input.summary !== undefined ? { summary: input.summary } : {}), + // `closed_at` follows the status rather than being sent, so a + // reopened engagement cannot keep a close date it no longer has. + ...(input.status !== undefined + ? { closedAt: input.status === 'won' || input.status === 'lost' ? now : null } + : {}), + updatedAt: now, + }) + .where(eq(engagements.id, engagement.id)) + .returning(); + if (!updated) throw MutationError.notFound('Engagement'); + + return { + data: { engagement: updated }, + activity: { + type: 'note', + subject: `Updated the engagement on ${deal.name}`, + accountId: deal.accountId, + demandDealId: deal.id, + meta: { + action: 'engagement.updated', + engagementId: updated.id, + fields: Object.keys(input), + status: updated.status, + }, + }, + }; + }, + }; +} + +export function motionArtifactCreateDefinition(): MutationDefinition< + typeof artifactCreateSchema, + { artifact: EngagementArtifact } +> { + return { + schema: artifactCreateSchema, + permission: authorizeWrite, + invalidMessage: 'Invalid engagement artefact.', + async mutate({ input, params, principal, tx, now }) { + const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement')); + const { artifact, template } = await instantiateArtifact( + tx, + viewerOf(principal), + { ...input, engagementId: engagement.id }, + now, + ); + + return { + data: { artifact }, + activity: { + type: 'note', + subject: template + ? `Instantiated ${template.title} (v${template.version}) on ${deal.name}` + : `Added a ${artifact.kind} artefact to ${deal.name}`, + accountId: deal.accountId, + demandDealId: deal.id, + meta: { + action: 'engagement_artifact.created', + engagementId: engagement.id, + artifactId: artifact.id, + templateId: template?.id ?? null, + kind: artifact.kind, + stage: artifact.stage, + }, + }, + }; + }, + }; +} + +/** + * Not restricted to the author: an engagement is worked by a team, and an + * artifact nobody but its author can finish is one that stalls the week they go + * on leave. The library — which everybody copies — is what ownership guards. + */ +export function motionArtifactUpdateDefinition(): MutationDefinition< + typeof artifactUpdateSchema, + { artifact: EngagementArtifact } +> { + return { + schema: artifactUpdateSchema, + permission: authorizeWrite, + invalidMessage: 'Invalid artefact change.', + async mutate({ input, params, tx, now }) { + const id = requiredId(params, 'Engagement artefact'); + const [existing] = await tx + .select() + .from(engagementArtifacts) + .where(eq(engagementArtifacts.id, id)) + .limit(1); + if (!existing) throw MutationError.notFound('Engagement artefact'); + const { deal } = await loadEngagement(tx, existing.engagementId); + + const [updated] = await tx + .update(engagementArtifacts) + .set({ + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.fields !== undefined ? { fields: input.fields } : {}), + ...(input.stage !== undefined ? { stage: input.stage } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.archived !== undefined ? { archivedAt: input.archived ? now : null } : {}), + updatedAt: now, + }) + .where(eq(engagementArtifacts.id, existing.id)) + .returning(); + if (!updated) throw MutationError.notFound('Engagement artefact'); + + return { + data: { artifact: updated }, + activity: { + type: 'note', + subject: `Updated ${updated.title} on ${deal.name}`, + accountId: deal.accountId, + demandDealId: deal.id, + meta: { + action: 'engagement_artifact.updated', + artifactId: updated.id, + engagementId: updated.engagementId, + fields: Object.keys(input), + status: updated.status, + }, + }, + }; + }, + }; +} + +export function motionArtifactPromoteDefinition(): MutationDefinition< + typeof promoteSchema, + { template: MotionTemplate; artifact: EngagementArtifact } +> { + return { + schema: promoteSchema, + permission: authorizePublish, + invalidMessage: 'Invalid promotion.', + async mutate({ input, params, principal, tx, now }) { + const result = await promoteArtifact( + tx, + viewerOf(principal), + requiredId(params, 'Engagement artefact'), + input, + now, + ); + + return { + data: { template: result.template, artifact: result.artifact }, + activity: { + type: 'note', + subject: `Promoted to the library: ${result.template.title} (v${result.template.version})`, + // The account, so the loop lands on the timeline of the deal that + // proved it rather than only in the library. + accountId: result.engagement.deal.accountId, + demandDealId: result.engagement.deal.id, + meta: { + action: 'engagement_artifact.promoted', + artifactId: result.artifact.id, + engagementId: result.engagement.engagement.id, + templateId: result.template.id, + supersedesId: result.supersedes?.id ?? null, + slug: result.template.slug, + version: result.template.version, + }, + }, + }; + }, + }; +} + +export function motionScoreDefinition(): MutationDefinition< + typeof scoreSchema, + { score: QualificationScore } +> { + return { + schema: scoreSchema, + permission: authorizeWrite, + invalidMessage: 'Invalid qualification score.', + async mutate({ input, params, principal, tx, now }) { + const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement')); + await checkedTemplateId(tx, principal, input.frameworkTemplateId); + const score = await recordScore(tx, viewerOf(principal), engagement.id, input, now); + + return { + data: { score }, + activity: { + type: 'note', + // Rounded, never truncated — the same rule money follows, for the + // same reason: this line is the only score most people will read. + subject: `Qualified ${deal.name}: ${score.band} (${Math.round(score.basisPoints / 100)}%)`, + accountId: deal.accountId, + demandDealId: deal.id, + meta: { + action: 'qualification_score.recorded', + engagementId: engagement.id, + scoreId: score.id, + basisPoints: score.basisPoints, + band: score.band, + tone: motionBand(score.basisPoints).tone, + }, + }, + }; + }, + }; +} + +// -------------------------------------------------------------------- routes + +export function createMotionRoutes(db: Database): Hono { + const routes = new Hono(); + const motion = new MotionService(db); + + routes.get('/api/motion', async (c) => + c.json(await motion.overview(viewerOf(c.get('principal')))), + ); + + routes.get('/api/motion/templates', async (c) => { + const parsed = filtersSchema.safeParse(c.req.query()); + if (!parsed.success) { + return c.json( + apiError('invalid_request', 'Invalid library filter.', parsed.error.issues), + 400, + ); + } + const { all, ...filters } = parsed.data; + return c.json( + await motion.listTemplates(viewerOf(c.get('principal')), { ...filters, all: all === '1' }), + ); + }); + + routes.get('/api/motion/templates/:id', async (c) => { + const principal = c.get('principal'); + const found = await motion.template(viewerOf(principal), c.req.param('id')); + // A private template belonging to somebody else answers exactly as an + // unknown id does. Anything else confirms that it exists. + if (!found) return c.json(apiError('not_found', 'Motion template not found.'), 404); + + const owned = ownsTemplate(viewerOf(principal), found.template); + return c.json({ + ...found, + /** The server's judgement, so the page does not re-derive §7a in TSX. */ + canEdit: owned && found.template.usageCount === 0, + canPublish: owned && found.template.visibility === 'private' && canPublish(principal), + }); + }); + + routes.get('/api/motion/engagements', async (c) => { + const parsed = engagementFiltersSchema.safeParse(c.req.query()); + if (!parsed.success) { + return c.json( + apiError('invalid_request', 'Invalid engagement filter.', parsed.error.issues), + 400, + ); + } + return c.json({ engagements: await motion.listEngagements(parsed.data.status) }); + }); + + routes.get('/api/motion/engagements/:id', async (c) => { + const found = await motion.engagement(viewerOf(c.get('principal')), c.req.param('id')); + if (!found) return c.json(apiError('not_found', 'Engagement not found.'), 404); + return c.json(found); + }); + + routes.post('/api/motion/templates', mutation(db, motionTemplateCreateDefinition())); + routes.patch('/api/motion/templates/:id', mutation(db, motionTemplateUpdateDefinition())); + routes.post( + '/api/motion/templates/:id/versions', + mutation(db, motionTemplateVersionDefinition()), + ); + routes.post( + '/api/motion/templates/:id/publish', + bodylessMutation(db, motionTemplatePublishDefinition()), + ); + routes.delete( + '/api/motion/templates/:id', + bodylessMutation(db, motionTemplateArchiveDefinition()), + ); + + routes.post('/api/motion/engagements', mutation(db, motionEngagementCreateDefinition())); + routes.patch('/api/motion/engagements/:id', mutation(db, motionEngagementUpdateDefinition())); + routes.post( + '/api/motion/engagements/:id/artifacts', + mutation(db, motionArtifactCreateDefinition()), + ); + routes.patch('/api/motion/artifacts/:id', mutation(db, motionArtifactUpdateDefinition())); + routes.post( + '/api/motion/artifacts/:id/promote', + mutation(db, motionArtifactPromoteDefinition()), + ); + routes.post( + '/api/motion/engagements/:id/scores', + mutation(db, motionScoreDefinition()), + ); + + return routes; +} diff --git a/apps/api/src/routes/read-guards.ts b/apps/api/src/routes/read-guards.ts index cae6825..721e33f 100644 --- a/apps/api/src/routes/read-guards.ts +++ b/apps/api/src/routes/read-guards.ts @@ -58,6 +58,22 @@ export const READ_RULES: readonly ReadRule[] = [ { method: 'GET', path: '/api/growth/accounts/:id', capability: 'book:read' }, { method: 'GET', path: '/api/facts', capability: 'book:read' }, + /** + * The motion library is not the cost book. A proposal block, a reference + * architecture and a qualification framework carry no supplier cost and no + * break-even, so gating them on `economics:read` would keep a research lead + * out of the reference architectures they are the ones writing. + * + * The private/shared split is NOT enforced here and could not be: this table + * is keyed on a path and knows nothing about rows. It is a real WHERE clause + * in `services/motion.ts`, which every one of these reads goes through. + */ + { method: 'GET', path: '/api/motion', capability: 'book:read' }, + { method: 'GET', path: '/api/motion/templates', capability: 'book:read' }, + { method: 'GET', path: '/api/motion/templates/:id', capability: 'book:read' }, + { method: 'GET', path: '/api/motion/engagements', capability: 'book:read' }, + { method: 'GET', path: '/api/motion/engagements/:id', capability: 'book:read' }, + { method: 'GET', path: '/api/team', capability: 'team:read' }, /** @@ -120,6 +136,16 @@ const PIGGY_PAGE_CAPABILITIES: Readonly> '/supply': 'book:read', '/calendar': 'book:read', '/contracts': 'book:read', + // The motion library is not the cost book. pig_get_motion_summary, + // pig_search_motion_library and pig_get_engagement return templates, + // artifacts and qualification scores — authored practice, carrying no + // supplier cost and no break-even — so the classification follows what those + // tools return rather than what the pages look like. The private/shared rule + // is a separate matter and is enforced in the query, not here: the library + // tool filters to `visibility = 'shared'` unconditionally. + '/motion': 'book:read', + '/motion/library': 'book:read', + '/motion/engagements': 'book:read', }; const PIGGY_RECORD_CAPABILITIES: Readonly> = { diff --git a/apps/api/src/services/motion.ts b/apps/api/src/services/motion.ts new file mode 100644 index 0000000..e426d49 --- /dev/null +++ b/apps/api/src/services/motion.ts @@ -0,0 +1,1132 @@ +/** + * Motion — the library, the engagements, and the loop between them. + * + * Everything with a decision in it lives here rather than in the route module, + * for the reason stated in AGENTS.md §4 and for one more: Piggy has to be able + * to ask these questions in-process, the way it asks `CalendarService`. So the + * reads take a `Database` and a `MotionViewer` — never a request, never a + * `Principal` — and the writes take the transaction the `mutation()` chokepoint + * already opened. + * + * ## The private filter is a WHERE clause, not an affordance + * + * `permissions.ts` says plainly that every read endpoint returns the whole book + * because no row-level filter exists anywhere in the query layer. Motion is the + * first exception and `visibleTemplates()` below is the whole of it. Two things + * about it are load-bearing: + * + * - A private row is matched by `owner_user_id = $viewer`, which is NULL-safe + * by construction: `owner_user_id IS NULL` never equals anything, so an + * orphaned private row would be invisible to everybody but a platform admin. + * Postgres will not currently produce one — running the delete proves it: + * `motion_templates_private_has_owner_check` is evaluated on the UPDATE that + * `ON DELETE SET NULL` performs, so deleting the owner of a private template + * raises a check violation and the delete fails outright. The predicate is + * kept in this NULL-safe form anyway, because the day that constraint is + * relaxed the obvious "fix" — `owner = $me OR owner IS NULL` — hands every + * departed colleague's drafts to the whole workspace, and it reads like a + * repair for rows that had gone missing. + * - A platform admin gets no predicate at all. That is deliberate and it is + * pinned by a test that says so in its name, because it looks like the filter + * failing open. + * + * ## Ownership is not the same question as capability + * + * `motion:write` says a person may author; ownership says whose row this is. + * Both are required to change a template, and they fail differently on purpose: + * a missing capability is `insufficient_permission` from the mutation + * chokepoint before the body is even read, and a wrong owner is `not_owner` + * from `assertTemplateWritable` once the row is known. Anything shared is + * readable book-wide but still only its owner's to edit — a shared template is + * published, not communal. + */ +import { + DEMAND_OPEN_STAGES, + DEMAND_STAGES, + MOTION_BANDS, + MOTION_KINDS, + motionBand, + motionScoreBasisPoints, + type ArtifactStatus, + type DemandStage, + type EngagementStatus, + type MotionBandTone, + type MotionDimensionScore, + type MotionKind, + type MotionVisibility, +} from '@pig/core'; +import type { + Database, + DemandDeal, + Engagement, + EngagementArtifact, + MotionTemplate, + QualificationScore, +} from '@pig/db'; +import { + accounts, + demandDeals, + engagementArtifacts, + engagements, + motionTemplates, + qualificationScores, +} from '@pig/db'; +import { and, desc, eq, ilike, inArray, isNotNull, isNull, or, sql, type SQL } from 'drizzle-orm'; +import { AuthError } from '../lib/auth'; +import { MutationError } from '../lib/mutation'; + +export type MotionTransaction = Parameters[0]>[0]; + +/** + * The subset of a `Principal` a motion read is allowed to see. A service that + * took the whole principal would be one refactor away from consulting teams or + * scopes, and the answer to "may I see this row" must stay these two fields. + */ +export interface MotionViewer { + readonly userId: string; + readonly isPlatformAdmin: boolean; +} + +/** No pagination anywhere in this codebase; every read is bounded instead. */ +const TEMPLATE_LIMIT = 200; +/** + * Latest-version-per-slug is folded in memory, so the scan has to be wider than + * the answer. Rows arrive ordered `(slug, version desc)`, which puts the + * winning version of a slug first and makes a truncated scan lose whole + * lineages rather than return a stale version of one. + */ +const TEMPLATE_SCAN_LIMIT = 400; +const ENGAGEMENT_LIMIT = 200; +const ARTIFACT_LIMIT = 400; +const SCORE_LIMIT = 200; +/** Enough to show a trend on the Motion home without being a second page. */ +const OVERVIEW_LIMIT = 8; + +// ---------------------------------------------------------------------- views + +export interface MotionTemplateSummary { + id: string; + kind: MotionKind; + slug: string; + version: number; + title: string; + summary: string; + stage: DemandStage; + visibility: MotionVisibility; + ownerUserId: string | null; + supersedesId: string | null; + originArtifactId: string | null; + isSystem: boolean; + usageCount: number; + archivedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface MotionTemplateDetail extends MotionTemplateSummary { + body: string; + fields: Record | null; +} + +export interface MotionArtifactView { + id: string; + engagementId: string; + templateId: string | null; + kind: MotionKind; + stage: DemandStage; + title: string; + body: string; + fields: Record | null; + status: ArtifactStatus; + authoredByUserId: string | null; + promotedTemplateId: string | null; + archivedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface MotionScoreView { + id: string; + engagementId: string; + frameworkTemplateId: string | null; + dimensions: MotionDimensionScore[]; + basisPoints: number; + band: string; + /** Derived, never stored: the palette must not be persisted alongside the score. */ + tone: MotionBandTone; + note: string | null; + scoredByUserId: string | null; + scoredAt: Date; +} + +export interface MotionEngagementSummary { + id: string; + demandDealId: string; + dealName: string | null; + /** The deal's stage. An engagement has no stage of its own — that is the point. */ + stage: DemandStage | null; + accountId: string | null; + accountName: string | null; + status: EngagementStatus; + summary: string | null; + ownerUserId: string | null; + playbookTemplateId: string | null; + openedAt: Date; + closedAt: Date | null; + createdAt: Date; + updatedAt: Date; + artifactCount: number; + latestScore: MotionScoreView | null; +} + +export interface MotionStageCoverage { + stage: DemandStage; + engagements: number; + templates: number; +} + +export interface MotionKindCoverage { + kind: MotionKind; + templates: number; + shared: number; +} + +export interface MotionPromotion { + templateId: string; + slug: string; + title: string; + kind: MotionKind; + version: number; + promotedAt: Date; + artifactId: string | null; + engagementId: string | null; + demandDealId: string | null; + dealName: string | null; +} + +export interface MotionOverview { + totals: { + templates: number; + shared: number; + private: number; + engagements: number; + open: number; + promotions: number; + }; + /** Always the eight open demand stages, in order, empty ones included. */ + stages: MotionStageCoverage[]; + /** Always the nine kinds, in `MOTION_KINDS` order, empty ones included. */ + library: MotionKindCoverage[]; + engagements: MotionEngagementSummary[]; + promotions: MotionPromotion[]; +} + +export interface MotionTemplateList { + templates: MotionTemplateSummary[]; + /** The scan hit its bound; the library is wider than the answer. */ + truncated: boolean; +} + +export interface MotionTemplateDetailView { + template: MotionTemplateDetail; + /** Every version of this slug the viewer may see, oldest first. */ + lineage: MotionTemplateSummary[]; +} + +export interface MotionEngagementDetail { + engagement: MotionEngagementSummary; + /** One entry per demand stage, in `DEMAND_STAGES` order, empty ones included. */ + stages: { stage: DemandStage; artifacts: MotionArtifactView[] }[]; + scores: MotionScoreView[]; + playbook: MotionTemplateSummary | null; +} + +export interface MotionTemplateFilters { + kind?: MotionKind; + stage?: DemandStage; + visibility?: MotionVisibility; + q?: string; + /** Every version, not just the newest of each lineage. */ + all?: boolean; +} + +// ------------------------------------------------------------------- mappers + +export function motionTemplateSummary(row: MotionTemplate): MotionTemplateSummary { + return { + id: row.id, + kind: row.kind, + slug: row.slug, + version: row.version, + title: row.title, + summary: row.summary, + stage: row.stage, + visibility: row.visibility, + ownerUserId: row.ownerUserId, + supersedesId: row.supersedesId, + originArtifactId: row.originArtifactId, + isSystem: row.isSystem, + usageCount: row.usageCount, + archivedAt: row.archivedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function motionTemplateDetail(row: MotionTemplate): MotionTemplateDetail { + return { ...motionTemplateSummary(row), body: row.body, fields: row.fields }; +} + +export function motionArtifactView(row: EngagementArtifact): MotionArtifactView { + return { + id: row.id, + engagementId: row.engagementId, + templateId: row.templateId, + kind: row.kind, + stage: row.stage, + title: row.title, + body: row.body, + fields: row.fields, + status: row.status, + authoredByUserId: row.authoredByUserId, + promotedTemplateId: row.promotedTemplateId, + archivedAt: row.archivedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function motionScoreView(row: QualificationScore): MotionScoreView { + return { + id: row.id, + engagementId: row.engagementId, + frameworkTemplateId: row.frameworkTemplateId, + dimensions: row.dimensions as unknown as MotionDimensionScore[], + basisPoints: row.basisPoints, + band: row.band, + tone: toneForBand(row.band, row.basisPoints), + note: row.note, + scoredByUserId: row.scoredByUserId, + scoredAt: row.scoredAt, + }; +} + +/** + * The colour that goes with the label that was stored, not with the label the + * score would earn today. + * + * `band` is persisted and `tone` is not, so moving a boundary in + * `MOTION_BANDS` would otherwise paint every historic row's old label in the + * new colour — "Qualified" in the danger red. Matching on the stored label + * keeps the two halves of one judgement together; a label from a band that no + * longer exists falls back to the score, which is the only other thing we have. + */ +function toneForBand(band: string, basisPoints: number): MotionBandTone { + return MOTION_BANDS.find((row) => row.label === band)?.tone ?? motionBand(basisPoints).tone; +} + +// -------------------------------------------------------------- the filter + +/** + * The row-level read filter — see the file header for why it is spelled this + * way and not the other, more obvious way. + * + * `undefined` means "no predicate", which `and()` drops, so a platform admin's + * query is the unfiltered one. + */ +export function visibleTemplates(viewer: MotionViewer): SQL | undefined { + if (viewer.isPlatformAdmin) return undefined; + return or( + eq(motionTemplates.visibility, 'shared'), + and( + eq(motionTemplates.visibility, 'private'), + eq(motionTemplates.ownerUserId, viewer.userId), + ), + ); +} + +/** Whose row is this? Shared does not mean communal — publishing is not donating. */ +export function ownsTemplate( + viewer: MotionViewer, + row: { ownerUserId: string | null }, +): boolean { + return viewer.isPlatformAdmin || (row.ownerUserId !== null && row.ownerUserId === viewer.userId); +} + +/** + * A slug is the identity of a lineage, so it is derived from the title only + * when the caller supplies nothing better. Two unrelated templates given the + * same title therefore land in the same lineage — which is why every write path + * that derives one also accepts an explicit `slug`. + */ +export function motionSlug(value: string): string { + const slug = value + .toLowerCase() + .normalize('NFKD') + // NFKD leaves the combining mark behind as its own character, which the + // next rule would turn into a hyphen: "Café strategy" becomes + // `caf-e-strategy` and the lineage of an accented title splits a word. + .replace(/\p{M}/gu, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) + .replace(/-+$/g, ''); + return slug || 'untitled'; +} + +/** + * `%` and `_` are LIKE wildcards, and `?q=` arrives from a URL somebody pasted. + * Unescaped, a search for `100%` matches every template in the book and reads + * as the filter being ignored. Piggy escapes the same way, in `likeFragment`. + */ +function likeFragment(value: string): string { + return `%${value.replace(/[\\%_]/g, (character) => `\\${character}`)}%`; +} + +// --------------------------------------------------------------------- reads + +export class MotionService { + constructor(private readonly db: Database) {} + + /** + * The Motion home: is the motion actually repeating? + * + * One grouped query answers the library half — counting in SQL rather than + * counting rows the endpoint also has to fetch, because the tile must stay + * exact when the list below it truncates. + */ + async overview(viewer: MotionViewer): Promise { + const [library, byStage, byStatus, live, promotions, promotionTotal] = await Promise.all([ + this.db + .select({ + kind: motionTemplates.kind, + stage: motionTemplates.stage, + visibility: motionTemplates.visibility, + total: sql`count(*)::int`, + }) + .from(motionTemplates) + .where(and(isNull(motionTemplates.archivedAt), visibleTemplates(viewer))) + .groupBy(motionTemplates.kind, motionTemplates.stage, motionTemplates.visibility), + this.db + .select({ stage: demandDeals.stage, total: sql`count(*)::int` }) + .from(engagements) + .innerJoin(demandDeals, eq(demandDeals.id, engagements.demandDealId)) + .where(eq(engagements.status, 'open')) + .groupBy(demandDeals.stage), + this.db + .select({ status: engagements.status, total: sql`count(*)::int` }) + .from(engagements) + .groupBy(engagements.status), + this.engagementSummaries(eq(engagements.status, 'open'), OVERVIEW_LIMIT), + this.promotions(viewer, OVERVIEW_LIMIT), + // Counted in SQL rather than taken from the list above it, which is + // capped at `OVERVIEW_LIMIT`. Promotions is the one figure on that page + // whose job is to show the loop closing, and a tile pegged at 8 forever + // is worse than no tile at all. + this.db + .select({ total: sql`count(*)::int` }) + .from(motionTemplates) + .where( + and( + isNotNull(motionTemplates.originArtifactId), + isNull(motionTemplates.archivedAt), + visibleTemplates(viewer), + ), + ), + ]); + + const templatesByStage = new Map(); + const templatesByKind = new Map(); + let templates = 0; + let shared = 0; + for (const row of library) { + templates += row.total; + if (row.visibility === 'shared') shared += row.total; + templatesByStage.set(row.stage, (templatesByStage.get(row.stage) ?? 0) + row.total); + const kind = templatesByKind.get(row.kind) ?? { total: 0, shared: 0 }; + kind.total += row.total; + if (row.visibility === 'shared') kind.shared += row.total; + templatesByKind.set(row.kind, kind); + } + + const engagementsByStage = new Map(byStage.map((row) => [row.stage, row.total])); + const engagementTotal = byStatus.reduce((sum, row) => sum + row.total, 0); + const openTotal = byStatus.find((row) => row.status === 'open')?.total ?? 0; + + return { + totals: { + templates, + shared, + private: templates - shared, + engagements: engagementTotal, + open: openTotal, + promotions: promotionTotal[0]?.total ?? 0, + }, + stages: DEMAND_OPEN_STAGES.map((stage) => ({ + stage, + engagements: engagementsByStage.get(stage) ?? 0, + templates: templatesByStage.get(stage) ?? 0, + })), + library: MOTION_KINDS.map((kind) => ({ + kind, + templates: templatesByKind.get(kind)?.total ?? 0, + shared: templatesByKind.get(kind)?.shared ?? 0, + })), + engagements: live, + promotions, + }; + } + + async listTemplates( + viewer: MotionViewer, + filters: MotionTemplateFilters = {}, + ): Promise { + const search = filters.q?.trim(); + const fragment = search ? likeFragment(search) : null; + const rows = await this.db + .select() + .from(motionTemplates) + .where( + and( + isNull(motionTemplates.archivedAt), + visibleTemplates(viewer), + filters.kind ? eq(motionTemplates.kind, filters.kind) : undefined, + filters.stage ? eq(motionTemplates.stage, filters.stage) : undefined, + filters.visibility ? eq(motionTemplates.visibility, filters.visibility) : undefined, + fragment + ? or( + ilike(motionTemplates.title, fragment), + ilike(motionTemplates.summary, fragment), + ilike(motionTemplates.slug, fragment), + ) + : undefined, + ), + ) + .orderBy(motionTemplates.slug, desc(motionTemplates.version)) + .limit(TEMPLATE_SCAN_LIMIT); + + const kept = filters.all ? rows : newestPerSlug(rows); + kept.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + return { + templates: kept.slice(0, TEMPLATE_LIMIT).map(motionTemplateSummary), + truncated: rows.length === TEMPLATE_SCAN_LIMIT || kept.length > TEMPLATE_LIMIT, + }; + } + + /** + * One template and its lineage. Null rather than an empty detail when the + * viewer may not see it: the route turns that into the same 404 an unknown id + * produces, so a private title cannot be confirmed by probing for one. + */ + async template(viewer: MotionViewer, id: string): Promise { + const [row] = await this.db + .select() + .from(motionTemplates) + .where(and(eq(motionTemplates.id, id), visibleTemplates(viewer))) + .limit(1); + if (!row) return null; + + const lineage = await this.db + .select() + .from(motionTemplates) + .where(and(eq(motionTemplates.slug, row.slug), visibleTemplates(viewer))) + .orderBy(motionTemplates.version) + .limit(TEMPLATE_LIMIT); + + return { template: motionTemplateDetail(row), lineage: lineage.map(motionTemplateSummary) }; + } + + async listEngagements(status?: EngagementStatus): Promise { + return this.engagementSummaries( + status ? eq(engagements.status, status) : undefined, + ENGAGEMENT_LIMIT, + ); + } + + async engagement(viewer: MotionViewer, id: string): Promise { + const [summary] = await this.engagementSummaries(eq(engagements.id, id), 1); + if (!summary) return null; + + const [artifacts, scores, playbook] = await Promise.all([ + this.db + .select() + .from(engagementArtifacts) + .where( + and( + eq(engagementArtifacts.engagementId, id), + isNull(engagementArtifacts.archivedAt), + ), + ) + .orderBy(desc(engagementArtifacts.updatedAt)) + .limit(ARTIFACT_LIMIT), + this.db + .select() + .from(qualificationScores) + .where(eq(qualificationScores.engagementId, id)) + .orderBy(desc(qualificationScores.scoredAt)) + .limit(SCORE_LIMIT), + summary.playbookTemplateId + ? this.db + .select() + .from(motionTemplates) + .where( + and( + eq(motionTemplates.id, summary.playbookTemplateId), + visibleTemplates(viewer), + ), + ) + .limit(1) + : Promise.resolve([]), + ]); + + return { + engagement: summary, + stages: DEMAND_STAGES.map((stage) => ({ + stage, + artifacts: artifacts.filter((row) => row.stage === stage).map(motionArtifactView), + })), + scores: scores.map(motionScoreView), + playbook: playbook[0] ? motionTemplateSummary(playbook[0]) : null, + }; + } + + /** + * What got easier this quarter. A promoted version is the only row in the + * library carrying an `origin_artifact_id`, so the loop needs no second table + * to be reportable. + */ + async promotions(viewer: MotionViewer, limit: number): Promise { + const rows = await this.db + .select({ + template: motionTemplates, + artifactId: engagementArtifacts.id, + engagementId: engagementArtifacts.engagementId, + demandDealId: engagements.demandDealId, + dealName: demandDeals.name, + }) + .from(motionTemplates) + .leftJoin( + engagementArtifacts, + eq(engagementArtifacts.id, motionTemplates.originArtifactId), + ) + .leftJoin(engagements, eq(engagements.id, engagementArtifacts.engagementId)) + .leftJoin(demandDeals, eq(demandDeals.id, engagements.demandDealId)) + .where( + and( + isNotNull(motionTemplates.originArtifactId), + isNull(motionTemplates.archivedAt), + visibleTemplates(viewer), + ), + ) + .orderBy(desc(motionTemplates.createdAt)) + .limit(limit); + + return rows.map((row) => ({ + templateId: row.template.id, + slug: row.template.slug, + title: row.template.title, + kind: row.template.kind, + version: row.template.version, + promotedAt: row.template.createdAt, + artifactId: row.artifactId, + engagementId: row.engagementId, + demandDealId: row.demandDealId, + dealName: row.dealName, + })); + } + + /** + * The engagement list shape, used by the list, the detail and the home page + * so that all three agree about what an engagement is. + */ + private async engagementSummaries( + where: SQL | undefined, + limit: number, + ): Promise { + const rows = await this.db + .select({ + engagement: engagements, + dealName: demandDeals.name, + stage: demandDeals.stage, + accountId: demandDeals.accountId, + accountName: accounts.name, + }) + .from(engagements) + .innerJoin(demandDeals, eq(demandDeals.id, engagements.demandDealId)) + .leftJoin(accounts, eq(accounts.id, demandDeals.accountId)) + .where(where) + .orderBy(desc(engagements.openedAt)) + .limit(limit); + if (rows.length === 0) return []; + + const ids = rows.map((row) => row.engagement.id); + const [counts, scores] = await Promise.all([ + this.db + .select({ + engagementId: engagementArtifacts.engagementId, + total: sql`count(*)::int`, + }) + .from(engagementArtifacts) + .where( + and( + inArray(engagementArtifacts.engagementId, ids), + isNull(engagementArtifacts.archivedAt), + ), + ) + .groupBy(engagementArtifacts.engagementId), + // Newest first, then the first sighting of each engagement wins. A + // `DISTINCT ON` would be tidier but this stays one bounded query and the + // history panel needs the same ordering anyway. + this.db + .select() + .from(qualificationScores) + .where(inArray(qualificationScores.engagementId, ids)) + .orderBy(desc(qualificationScores.scoredAt)) + .limit(SCORE_LIMIT), + ]); + + const countById = new Map(counts.map((row) => [row.engagementId, row.total])); + const latest = new Map(); + for (const score of scores) { + if (!latest.has(score.engagementId)) latest.set(score.engagementId, score); + } + + return rows.map(({ engagement, dealName, stage, accountId, accountName }) => ({ + id: engagement.id, + demandDealId: engagement.demandDealId, + dealName, + stage, + accountId, + accountName, + status: engagement.status, + summary: engagement.summary, + ownerUserId: engagement.ownerUserId, + playbookTemplateId: engagement.playbookTemplateId, + openedAt: engagement.openedAt, + closedAt: engagement.closedAt, + createdAt: engagement.createdAt, + updatedAt: engagement.updatedAt, + artifactCount: countById.get(engagement.id) ?? 0, + latestScore: (() => { + const score = latest.get(engagement.id); + return score ? motionScoreView(score) : null; + })(), + })); + } +} + +/** Rows arrive `(slug asc, version desc)`, so the first sighting of a slug wins. */ +function newestPerSlug(rows: readonly MotionTemplate[]): MotionTemplate[] { + const seen = new Set(); + const kept: MotionTemplate[] = []; + for (const row of rows) { + if (seen.has(row.slug)) continue; + seen.add(row.slug); + kept.push(row); + } + return kept; +} + +// -------------------------------------------------------------------- writes + +/** + * Load a template for a write, applying the read filter first. + * + * A row the viewer cannot see is a 404, not a 403: telling somebody that a + * template they may not read exists is the leak, and the status code is where + * it would happen. + */ +export async function loadTemplateForWrite( + tx: MotionTransaction, + viewer: MotionViewer, + id: string, +): Promise { + const [row] = await tx + .select() + .from(motionTemplates) + .where(and(eq(motionTemplates.id, id), visibleTemplates(viewer))) + .limit(1); + if (!row) throw MutationError.notFound('Motion template'); + return row; +} + +/** Owner or platform admin. Nobody else, whatever their role. */ +export function assertTemplateWritable(viewer: MotionViewer, row: MotionTemplate): void { + if (ownsTemplate(viewer, row)) return; + throw new AuthError( + 'This template belongs to someone else. Fork it into a new version instead.', + 403, + 'not_owner', + ); +} + +/** + * §7a — a used template is never edited in place. + * + * The refusal names the fix, because the caller is not doing anything wrong: + * they want a change, and a new version is how this feature spells one. A live + * engagement whose template changed underneath it has lost the provenance that + * makes a later promotion mean anything. + */ +export function assertTemplateEditable(row: MotionTemplate): void { + if (row.usageCount === 0) return; + throw new MutationError( + 'template_in_use', + `This template has been used ${row.usageCount} time(s), so it cannot be edited in place. POST /api/motion/templates/${row.id}/versions to publish a new version instead.`, + 409, + ); +} + +/** + * The highest version number in a lineage, locked for the rest of the + * transaction — and nothing else. + * + * This query is deliberately unfiltered, and deliberately returns no content. + * The version number has to be computed against the *true* newest row or the + * `(slug, version)` unique constraint collides, but a row the caller may not + * read must never supply a title, a summary or an id: promotion used to fall + * back to `supersedes.summary`, which copied a private draft's summary + * verbatim into the shared library for anyone holding `motion:publish`. + * + * `FOR UPDATE` is what makes two concurrent version allocations queue rather + * than both compute the same number and hand the loser a 500 from the unique + * constraint. There is nothing to lock for the first version of a slug, which + * is why the callers also map the unique violation to a 409. + */ +export async function lockNewestVersion( + tx: MotionTransaction, + slug: string, +): Promise<{ version: number } | null> { + const [row] = await tx + .select({ version: motionTemplates.version }) + .from(motionTemplates) + .where(eq(motionTemplates.slug, slug)) + .orderBy(desc(motionTemplates.version)) + .limit(1) + .for('update'); + return row ?? null; +} + +/** + * The newest row in a lineage *that this viewer may read*, which is what a new + * version may honestly claim to supersede. See `lockNewestVersion` for why the + * version number does not come from here. + */ +export async function newestVisibleInLineage( + tx: MotionTransaction, + viewer: MotionViewer, + slug: string, +): Promise { + const [row] = await tx + .select() + .from(motionTemplates) + .where(and(eq(motionTemplates.slug, slug), visibleTemplates(viewer))) + .orderBy(desc(motionTemplates.version)) + .limit(1); + return row ?? null; +} + +/** + * Two callers raced for one `(slug, version)`. The loser gets a 409 naming the + * retry rather than the 500 a driver error becomes at `app.onError`, which + * tells the caller nothing and rolls back an activity row they never saw. + */ +export function versionConflict(error: unknown): never { + if (typeof error === 'object' && error !== null && (error as { code?: string }).code === '23505') { + throw new MutationError( + 'template_version_conflict', + 'Somebody else added a version of this template a moment ago. Reload and try again.', + 409, + ); + } + throw error; +} + +export interface EngagementContext { + engagement: Engagement; + deal: DemandDeal; +} + +/** An engagement is only ever addressed with its deal, because the audit row needs the account. */ +export async function loadEngagement( + tx: MotionTransaction, + id: string, +): Promise { + const [row] = await tx + .select({ engagement: engagements, deal: demandDeals }) + .from(engagements) + .innerJoin(demandDeals, eq(demandDeals.id, engagements.demandDealId)) + .where(eq(engagements.id, id)) + .limit(1); + if (!row) throw MutationError.notFound('Engagement'); + return row; +} + +export interface InstantiateInput { + engagementId: string; + templateId?: string; + kind?: MotionKind; + stage?: DemandStage; + title?: string; + body?: string; + fields?: Record | null; + status?: ArtifactStatus; +} + +/** + * Instantiate a template into an engagement, or write an artifact from scratch. + * + * The copy is a copy, not a reference: the artifact carries its own body so + * that editing it cannot reach back into the library, and `template_id` records + * only where it came from. `usage_count` moves in the same transaction, which + * is what closes the template to further in-place edits from this moment on. + */ +export async function instantiateArtifact( + tx: MotionTransaction, + viewer: MotionViewer, + input: InstantiateInput, + now: Date, +): Promise<{ artifact: EngagementArtifact; template: MotionTemplate | null }> { + const template = input.templateId + ? await loadTemplateForWrite(tx, viewer, input.templateId) + : null; + // Every read path hides an archived template, so instantiating one can only + // happen by holding an id from before it was archived — and it would leave + // the row with a usage count that refuses an edit if it is ever restored. + if (template?.archivedAt) { + throw new MutationError( + 'template_archived', + 'This template has been archived. Instantiate a current version of it instead.', + 409, + ); + } + + const kind = input.kind ?? template?.kind; + const stage = input.stage ?? template?.stage; + const title = input.title ?? template?.title; + const body = input.body ?? template?.body; + if (!kind || !stage || !title || body === undefined) { + throw new MutationError( + 'invalid_request', + 'An artefact needs a template to instantiate, or a kind, stage, title and body of its own.', + 400, + ); + } + + const [artifact] = await tx + .insert(engagementArtifacts) + .values({ + engagementId: input.engagementId, + templateId: template?.id ?? null, + kind, + stage, + title, + body, + fields: input.fields ?? template?.fields ?? null, + status: input.status ?? 'draft', + authoredByUserId: viewer.userId, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (!artifact) throw new Error('Engagement artefact insert returned no row'); + + if (template) { + await tx + .update(motionTemplates) + // The increment is computed by the database, not in JS from the row read + // above. `READ COMMITTED` lets two instantiations of one template both + // read the same count and both write the same successor, so a JS `+ 1` + // loses a use — and the count is the only thing holding §7a shut. + .set({ usageCount: sql`${motionTemplates.usageCount} + 1`, updatedAt: now }) + .where(eq(motionTemplates.id, template.id)); + } + + return { artifact, template }; +} + +/** + * Which lineage a promotion lands in when the caller did not name one. + * + * The artifact's source template is the honest answer, but reading it applies + * the visibility filter, and a lead promoting the finished work of an + * engagement instantiated from somebody else's private template would + * otherwise get `404 Motion template not found` on a request whose subject is + * an artifact that plainly exists. Refusing with the fix named beats both that + * 404 and the alternative of silently promoting into a lineage the caller + * cannot see the shape of. + */ +async function lineageOf( + tx: MotionTransaction, + viewer: MotionViewer, + artifact: EngagementArtifact, +): Promise { + if (!artifact.templateId) return motionSlug(artifact.title); + + const [source] = await tx + .select({ slug: motionTemplates.slug }) + .from(motionTemplates) + .where(and(eq(motionTemplates.id, artifact.templateId), visibleTemplates(viewer))) + .limit(1); + if (source) return source.slug; + + throw new MutationError( + 'source_template_unreadable', + 'This artefact came from a template you cannot read, so its lineage cannot be inferred. Promote it again with an explicit slug.', + 409, + ); +} + +export interface PromoteInput { + slug?: string; + title?: string; + summary?: string; +} + +export interface PromotionResult { + artifact: EngagementArtifact; + template: MotionTemplate; + supersedes: MotionTemplate | null; + engagement: EngagementContext; +} + +/** + * §7b — promotion is the loop. + * + * The new row is version `previous + 1` of the lineage, pointing back at its + * predecessor and at the artifact that proved it, and it is shared: a promotion + * that landed privately would be a promotion nobody could copy, which is the + * only thing promotion is for. + * + * Two refusals, both 409 and both deliberate. An artifact promoted twice would + * fork its own lineage silently, and a draft promoted at all would put + * unfinished work in the place the next deployment copies from. + */ +export async function promoteArtifact( + tx: MotionTransaction, + viewer: MotionViewer, + artifactId: string, + input: PromoteInput, + now: Date, +): Promise { + const [artifact] = await tx + .select() + .from(engagementArtifacts) + // Locked before the two refusals below, because both of them read state + // this transaction is about to change. Without it, two promotions of one + // artifact with different slugs both pass `already_promoted`, both insert, + // and one of the two templates is left in the shared library pointing at + // an artifact that does not name it — the silent lineage fork the guard + // exists to prevent. + .where(eq(engagementArtifacts.id, artifactId)) + .limit(1) + .for('update'); + if (!artifact) throw MutationError.notFound('Engagement artefact'); + + if (artifact.archivedAt) { + throw new MutationError( + 'artifact_archived', + 'This artefact has been archived. Restore it before promoting it into the library.', + 409, + ); + } + if (artifact.promotedTemplateId) { + throw new MutationError( + 'already_promoted', + 'This artefact is already in the library. Promote a later revision instead.', + 409, + ); + } + if (artifact.status !== 'final') { + throw new MutationError( + 'artifact_not_final', + 'Only a final artefact may be promoted — the library is what the next deployment copies.', + 409, + ); + } + + const engagement = await loadEngagement(tx, artifact.engagementId); + + const slug = input.slug ? motionSlug(input.slug) : await lineageOf(tx, viewer, artifact); + // The number comes from the whole lineage and the row comes from the half of + // it this viewer may read — see `lockNewestVersion`. They are different + // questions and answering both from one query is how a private draft's + // summary reached the shared library. + const newest = await lockNewestVersion(tx, slug); + const supersedes = await newestVisibleInLineage(tx, viewer, slug); + + const [template] = await tx + .insert(motionTemplates) + .values({ + kind: artifact.kind, + slug, + version: (newest?.version ?? 0) + 1, + title: input.title ?? artifact.title, + summary: input.summary ?? supersedes?.summary ?? artifact.title, + body: artifact.body, + fields: artifact.fields, + stage: artifact.stage, + visibility: 'shared', + ownerUserId: viewer.userId, + supersedesId: supersedes?.id ?? null, + originArtifactId: artifact.id, + isSystem: false, + createdAt: now, + updatedAt: now, + }) + .returning() + .catch(versionConflict); + if (!template) throw new Error('Promoted motion template insert returned no row'); + + const [promoted] = await tx + .update(engagementArtifacts) + .set({ promotedTemplateId: template.id, updatedAt: now }) + .where(eq(engagementArtifacts.id, artifact.id)) + .returning(); + if (!promoted) throw MutationError.notFound('Engagement artefact'); + + return { artifact: promoted, template, supersedes, engagement }; +} + +export interface ScoreInput { + dimensions: MotionDimensionScore[]; + frameworkTemplateId?: string | null; + note?: string | null; +} + +/** + * Append a qualification score. + * + * The score is computed here from the dimensions, never taken from the body: a + * number a client can post is a number somebody can make true afterwards, and + * the whole reason this table is append-only is that the movement of the score + * is the evidence. + */ +export async function recordScore( + tx: MotionTransaction, + viewer: MotionViewer, + engagementId: string, + input: ScoreInput, + now: Date, +): Promise { + const basisPoints = motionScoreBasisPoints(input.dimensions); + const [row] = await tx + .insert(qualificationScores) + .values({ + engagementId, + frameworkTemplateId: input.frameworkTemplateId ?? null, + // Drizzle's jsonb `$type` will not accept a readonly-property interface, + // so the cast happens once, here, at the write boundary. + dimensions: input.dimensions as unknown as Record[], + basisPoints, + band: motionBand(basisPoints).label, + note: input.note ?? null, + scoredByUserId: viewer.userId, + scoredAt: now, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (!row) throw new Error('Qualification score insert returned no row'); + return row; +} diff --git a/apps/api/test/motion.test.ts b/apps/api/test/motion.test.ts new file mode 100644 index 0000000..d47fb20 --- /dev/null +++ b/apps/api/test/motion.test.ts @@ -0,0 +1,725 @@ +/** + * Motion — the two rules that make this a system, and the one departure that + * makes it a risk. + * + * The departure first. `permissions.ts` states that every read endpoint returns + * the whole book because no row-level filter exists anywhere in the query + * layer; motion templates are the first exception, and an exception that is + * only enforced by the UI is not an exception, it is a leak with a nice screen + * in front of it. So the first suite renders the actual predicate to SQL and + * asserts on it. It looks like a test of an implementation detail and is not: + * the WHERE clause IS the access policy, and the two ways of getting it wrong — + * dropping the owner comparison, or widening it with `OR owner IS NULL` — both + * produce a query that returns rows and reports nothing. + * + * The rest drive the exported mutation definitions against a scripted + * transaction, in the register of `activities.test.ts`. The fake answers + * queries in call order and records what ran; it is deliberately not a + * database, because a fake that pretends to run SQL is a fake that will one day + * assert a broken query works. + */ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import { motionScoreBasisPoints } from '@pig/core'; +import type { Database, MotionTemplate } from '@pig/db'; +import { Hono } from 'hono'; +import { AuthError, type Principal } from '../src/lib/auth'; +import { executeMutation, MutationError, type ApiEnv } from '../src/lib/mutation'; +import { + createMotionRoutes, + motionArtifactCreateDefinition, + motionArtifactPromoteDefinition, + motionEngagementCreateDefinition, + motionEngagementUpdateDefinition, + motionScoreDefinition, + motionTemplateCreateDefinition, + motionTemplatePublishDefinition, + motionTemplateUpdateDefinition, + motionTemplateVersionDefinition, +} from '../src/routes/motion'; +import { motionSlug, visibleTemplates } from '../src/services/motion'; +import { onTeam, principal } from './helpers/principal'; + +const OWNER = '00000000-0000-4000-8000-000000000001'; +const OTHER = '00000000-0000-4000-8000-0000000000aa'; +const DEAL_ID = '00000000-0000-4000-8000-0000000000d1'; +const ACCOUNT_ID = '00000000-0000-4000-8000-0000000000ac'; +const ENGAGEMENT_ID = '00000000-0000-4000-8000-0000000000e1'; +const ARTIFACT_ID = '00000000-0000-4000-8000-0000000000f1'; +const TEMPLATE_ID = '00000000-0000-4000-8000-0000000000b1'; +const NOW = new Date('2026-08-17T09:00:00.000Z'); + +/** A demand member: `motion:write`, no `motion:publish`. */ +const member = principal(onTeam('demand', 'member')); +/** A demand lead: both, which is the point of the split. */ +const lead = principal(onTeam('demand', 'lead')); + +// --------------------------------------------------------------- the fixtures + +function template(overrides: Partial = {}): MotionTemplate { + return { + id: TEMPLATE_ID, + kind: 'poc', + slug: 'poc-plan', + version: 1, + title: 'POC plan', + summary: 'What a proof of concept must show.', + body: '# POC plan', + fields: null, + stage: 'poc', + visibility: 'private', + ownerUserId: OWNER, + supersedesId: null, + originArtifactId: null, + isSystem: false, + usageCount: 0, + archivedAt: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +const deal = { id: DEAL_ID, accountId: ACCOUNT_ID, name: 'DEMO — Northwind training' }; +const engagement = { id: ENGAGEMENT_ID, demandDealId: DEAL_ID, status: 'open' }; + +function artifact(overrides: Record = {}) { + return { + id: ARTIFACT_ID, + engagementId: ENGAGEMENT_ID, + templateId: null, + kind: 'poc', + stage: 'poc', + title: 'Northwind POC plan', + body: '# What we proved', + fields: null, + status: 'final', + authoredByUserId: OWNER, + promotedTemplateId: null, + archivedAt: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +// ------------------------------------------------------------------ the fake + +interface Recorded { + events: string[]; + inserted: { table: string; row: Record }[]; + updated: { table: string; values: Record }[]; +} + +/** + * A transaction that answers selects from a script, in call order, and records + * every write with the table it landed in. Order-dependence is the price of not + * pretending to be Postgres, and it is what makes "no UPDATE ran" assertable. + */ +function database(script: unknown[][]): { db: Database; log: Recorded } { + const log: Recorded = { events: [], inserted: [], updated: [] }; + const results = [...script]; + const next = (): unknown[] => results.shift() ?? []; + + const selectChain = { + from: () => selectChain, + innerJoin: () => selectChain, + leftJoin: () => selectChain, + where: () => selectChain, + orderBy: () => selectChain, + groupBy: () => selectChain, + // `limit` returns the chain rather than a promise so that `.for('update')` + // can follow it, as it does on every query in this feature that allocates + // a version number. The chain is a thenable, so `await` still ends it. + limit: () => selectChain, + for: () => selectChain, + then: (resolve: (rows: unknown[]) => unknown) => resolve(next()), + }; + + const name = (table: unknown): string => (isTable(table) ? getTableName(table) : 'unknown'); + + const tx = { + select: () => { + log.events.push('select'); + return selectChain; + }, + insert: (table: unknown) => ({ + values: (row: Record) => { + log.events.push(`insert:${name(table)}`); + log.inserted.push({ table: name(table), row }); + const written = [{ id: `${name(table)}-${log.inserted.length}`, ...row }]; + return { + returning: async () => written, + then: (resolve: (value: unknown) => unknown) => resolve(undefined), + }; + }, + }), + update: (table: unknown) => ({ + set: (values: Record) => ({ + where: () => { + log.events.push(`update:${name(table)}`); + log.updated.push({ table: name(table), values }); + return Object.assign(Promise.resolve(undefined), { + returning: async () => [{ id: `${name(table)}-updated`, ...values }], + }); + }, + }), + }), + }; + + return { + db: { + transaction: async (work: (t: unknown) => Promise) => { + log.events.push('transaction'); + return work(tx); + }, + // The same scripted chain outside a transaction, so a read handler can be + // driven through the mounted routes rather than only its service. + select: tx.select, + } as unknown as Database, + log, + }; +} + +/** The motion routes with a principal already resolved, as `createApp` mounts them. */ +function mounted(db: Database, who: Principal): Hono { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('principal', who); + await next(); + }); + app.route('/', createMotionRoutes(db)); + return app; +} + +function rendered(userId: string, isPlatformAdmin = false) { + const predicate = visibleTemplates({ userId, isPlatformAdmin }); + return predicate ? new PgDialect().sqlToQuery(predicate) : null; +} + +// ------------------------------------------------------------------- the tests + +describe('the private library filter is a WHERE clause, not an affordance', () => { + it('matches shared rows for everyone and private rows only against the viewer id', () => { + const query = rendered(OWNER); + + assert.ok(query, 'a member must be filtered at all'); + assert.match(query.sql, /"visibility" = \$1/); + assert.match(query.sql, /"owner_user_id" = \$3/); + assert.deepEqual(query.params, ['shared', 'private', OWNER]); + }); + + it('never widens to `owner_user_id IS NULL` — an orphaned private draft belongs to nobody', () => { + const query = rendered(OWNER); + + // `ON DELETE SET NULL` on the owner column can produce a private row with + // no owner. Matching it here would publish every departed colleague's + // drafts to the whole workspace, and the query would look like a fix for + // rows that had "gone missing". + assert.ok(query); + assert.doesNotMatch(query.sql, /owner_user_id" is null/i); + }); + + it('is deliberately absent for a platform admin — an admin CAN read a private template', () => { + // Not a hole. An unfiltered query here is the decision: someone has to be + // able to answer "what is in this workspace" during an audit or a + // departure. If this ever starts returning a predicate, that was a choice + // somebody made, and this test is where they say so. + assert.equal(rendered(OTHER, true), null); + }); +}); + +describe('a slug is the identity of a lineage', () => { + it('strips the combining mark NFKD leaves behind rather than hyphenating through a word', () => { + // `normalize('NFKD')` splits "é" into "e" plus a combining acute, and the + // `[^a-z0-9]+` rule that follows turns that mark into a separator — so + // without the strip, "Café strategy" starts the lineage `caf-e-strategy` + // and the next person authoring the same title cannot find it. + assert.equal(motionSlug('Café strategy'), 'cafe-strategy'); + assert.equal(motionSlug('Proposal Blocks'), 'proposal-blocks'); + // Nothing latin survives, and an empty slug would violate the NOT NULL. + assert.equal(motionSlug('日本語'), 'untitled'); + }); +}); + +describe('a used template is never edited in place', () => { + it('refuses a PATCH once usage_count is above zero, and names the versions endpoint', async () => { + const { db, log } = database([[template({ usageCount: 3 })]]); + + await assert.rejects( + executeMutation( + db, + principal(onTeam('demand', 'member')), + async () => ({ body: '# Rewritten' }), + motionTemplateUpdateDefinition(), + { id: TEMPLATE_ID }, + ), + (error: unknown) => + error instanceof MutationError && + error.code === 'template_in_use' && + error.status === 409 && + error.message.includes('/versions'), + ); + assert.deepEqual(log.updated, [], 'a live engagement must not have its template move underneath it'); + }); + + it('edits freely while nobody has instantiated it — an unused template is still a draft', async () => { + const { db, log } = database([[template({ usageCount: 0 })]]); + + await executeMutation( + db, + member, + async () => ({ title: 'POC plan, tightened' }), + motionTemplateUpdateDefinition(), + { id: TEMPLATE_ID }, + ); + + assert.equal(log.updated.length, 1); + assert.equal(log.updated[0]?.table, 'motion_templates'); + assert.equal(log.updated[0]?.values.title, 'POC plan, tightened'); + }); + + it('refuses an edit to somebody else\'s template even when it is shared — publishing is not donating', async () => { + const { db, log } = database([[template({ visibility: 'shared', ownerUserId: OTHER })]]); + + await assert.rejects( + executeMutation( + db, + member, + async () => ({ body: '# Mine now' }), + motionTemplateUpdateDefinition(), + { id: TEMPLATE_ID }, + ), + (error: unknown) => error instanceof AuthError && error.code === 'not_owner', + ); + assert.deepEqual(log.updated, []); + }); +}); + +describe('publishing is a lead\'s judgement, and the owner\'s', () => { + it('refuses a member without motion:publish before the row is read at all', async () => { + const { db, log } = database([[template()]]); + + await assert.rejects( + executeMutation(db, member, async () => ({}), motionTemplatePublishDefinition(), { + id: TEMPLATE_ID, + }), + (error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission', + ); + assert.deepEqual(log.events, [], 'permission precedes the transaction, so nothing was queried'); + }); + + it('refuses a lead flipping somebody else\'s private template to shared', async () => { + const { db, log } = database([[template({ ownerUserId: OTHER })]]); + + await assert.rejects( + executeMutation(db, lead, async () => ({}), motionTemplatePublishDefinition(), { + id: TEMPLATE_ID, + }), + (error: unknown) => error instanceof AuthError && error.code === 'not_owner', + ); + assert.deepEqual(log.updated, []); + }); + + it('gates creating straight into the shared library on the same capability', async () => { + // The publish endpoint is not the only door into the shared library, so a + // member who simply POSTs `visibility: 'shared'` must be refused too. + const { db, log } = database([]); + + await assert.rejects( + executeMutation( + db, + member, + async () => ({ + kind: 'poc', + title: 'POC plan', + summary: 'What a POC must show.', + body: '# POC', + stage: 'poc', + visibility: 'shared', + }), + motionTemplateCreateDefinition(), + ), + (error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission', + ); + assert.deepEqual(log.inserted, []); + }); + + it('creates a private template owned by the author, because an unowned private row is unreadable', async () => { + const { db, log } = database([]); + + await executeMutation( + db, + member, + async () => ({ + kind: 'poc', + title: 'POC plan', + summary: 'What a POC must show.', + body: '# POC', + stage: 'poc', + }), + motionTemplateCreateDefinition(), + ); + + const written = log.inserted.find((row) => row.table === 'motion_templates'); + assert.equal(written?.row.visibility, 'private'); + assert.equal(written?.row.ownerUserId, member.userId); + assert.equal(written?.row.version, 1); + assert.equal(written?.row.slug, 'poc-plan'); + }); +}); + +describe('promotion is the loop', () => { + function promote(db: Database, input: Record = {}) { + return executeMutation( + db, + lead, + async () => input, + motionArtifactPromoteDefinition(), + { id: ARTIFACT_ID }, + ); + } + + it('refuses to promote an artifact that is already in the library', async () => { + const { db, log } = database([[artifact({ promotedTemplateId: TEMPLATE_ID })]]); + + await assert.rejects( + promote(db), + (error: unknown) => + error instanceof MutationError && + error.code === 'already_promoted' && + error.status === 409, + ); + assert.deepEqual(log.inserted, [], 'a second promotion would fork the lineage silently'); + }); + + it('refuses to promote a draft — the library is what the next deployment copies', async () => { + const { db, log } = database([[artifact({ status: 'draft' })]]); + + await assert.rejects( + promote(db), + (error: unknown) => + error instanceof MutationError && + error.code === 'artifact_not_final' && + error.status === 409, + ); + assert.deepEqual(log.inserted, []); + }); + + it('writes a shared version pointing back at the artifact that proved it', async () => { + const { db, log } = database([[artifact()], [{ engagement, deal }], []]); + + const result = (await promote(db, { slug: 'poc-plan' })) as { + template: { version: number; visibility: string; originArtifactId: string }; + }; + + assert.equal(result.template.version, 1); + assert.equal(result.template.visibility, 'shared'); + assert.equal(result.template.originArtifactId, ARTIFACT_ID); + assert.equal( + log.updated.find((row) => row.table === 'engagement_artifacts')?.values.promotedTemplateId, + 'motion_templates-1', + ); + }); + + it('chains supersedes_id across three promotions of one lineage', async () => { + let previous: { id: string; version: number } | null = null; + const chain: { id: string; version: number; supersedesId: string | null }[] = []; + + for (let round = 0; round < 3; round += 1) { + // The lineage is read twice and on purpose: once unfiltered for the + // version number, once through the visibility filter for the row the new + // version may claim to supersede. + const lineage = previous ? [{ ...template(), ...previous, slug: 'poc-plan' }] : []; + const { db } = database([ + [artifact({ id: `${ARTIFACT_ID}-${round}` })], + [{ engagement, deal }], + lineage, + lineage, + ]); + + const result = (await promote(db, { slug: 'poc-plan' })) as { + template: { id: string; version: number; supersedesId: string | null }; + }; + chain.push({ + id: result.template.id, + version: result.template.version, + supersedesId: result.template.supersedesId, + }); + previous = { id: result.template.id, version: result.template.version }; + } + + assert.deepEqual( + chain.map((row) => row.version), + [1, 2, 3], + ); + assert.equal(chain[0]?.supersedesId, null, 'the first version supersedes nothing'); + assert.equal(chain[1]?.supersedesId, chain[0]?.id); + assert.equal(chain[2]?.supersedesId, chain[1]?.id); + }); + + it('lands the audit row on the account of the deal that proved it', async () => { + const { db, log } = database([[artifact()], [{ engagement, deal }], []]); + + await promote(db, { slug: 'poc-plan' }); + + const activity = log.inserted.find((row) => row.table === 'activities')?.row; + assert.equal(activity?.accountId, ACCOUNT_ID); + assert.equal(activity?.demandDealId, DEAL_ID); + assert.match(String(activity?.subject), /^Promoted to the library:/); + }); +}); + +describe('a lineage nobody can read never supplies content to one everybody can', () => { + /** + * The version number and the predecessor row are two different questions, and + * answering both from one unfiltered query is the shape of the bug these pin. + * Both suites script a lineage whose newest row is private to somebody else: + * the unfiltered read finds v4, the filtered read finds nothing. + */ + const PRIVATE_SUMMARY = 'Unreleased pricing: 40% floor, Northwind only.'; + /** Alice's v4, which the unfiltered read finds and the filtered read must not. */ + const hidden = () => + template({ + id: '00000000-0000-4000-8000-0000000000c4', + version: 4, + ownerUserId: OTHER, + summary: PRIVATE_SUMMARY, + }); + + it('promotes past a private newest version without copying its summary or naming its id', async () => { + const { db, log } = database([ + [artifact()], + [{ engagement, deal }], + [hidden()], + [], + ]); + + await executeMutation(db, lead, async () => ({ slug: 'poc-plan' }), motionArtifactPromoteDefinition(), { + id: ARTIFACT_ID, + }); + + const written = log.inserted.find((row) => row.table === 'motion_templates')?.row; + // v5, because the unique constraint is on the whole lineage and a private + // fork still consumes a number. + assert.equal(written?.version, 5); + // But nothing else from that row. The summary fell back to the artifact's + // own title, and the new version supersedes nothing it cannot show. + assert.notEqual(written?.summary, PRIVATE_SUMMARY); + assert.equal(written?.summary, artifact().title); + assert.equal(written?.supersedesId, null); + }); + + it('forks a shared template without disclosing that private versions of it exist', async () => { + const source = template({ id: TEMPLATE_ID, visibility: 'shared', ownerUserId: OTHER }); + const { db, log } = database([[source], [hidden()], []]); + + await executeMutation(db, member, async () => ({}), motionTemplateVersionDefinition(), { + id: TEMPLATE_ID, + }); + + const written = log.inserted.find((row) => row.table === 'motion_templates')?.row; + assert.equal(written?.version, 5, 'the number is allocated against the whole lineage'); + assert.equal( + written?.supersedesId, + source.id, + 'the edge points at the row that was actually forked, not at a private row the forker cannot fetch', + ); + }); + + it('refuses to promote an artifact whose source template is unreadable, rather than 404ing on the artifact', async () => { + const { db, log } = database([ + [artifact({ templateId: TEMPLATE_ID })], + [{ engagement, deal }], + [], + ]); + + await assert.rejects( + executeMutation(db, lead, async () => ({}), motionArtifactPromoteDefinition(), { + id: ARTIFACT_ID, + }), + (error: unknown) => + error instanceof MutationError && + error.code === 'source_template_unreadable' && + error.status === 409 && + error.message.includes('slug'), + ); + assert.deepEqual(log.inserted, []); + }); +}); + +describe('a template id somebody sent is read before it is stored', () => { + it('refuses a playbook the setter cannot read, so a private id cannot go book-wide on an engagement', async () => { + const { db, log } = database([[{ engagement, deal }], []]); + + await assert.rejects( + executeMutation( + db, + member, + async () => ({ playbookTemplateId: TEMPLATE_ID }), + motionEngagementUpdateDefinition(), + { id: ENGAGEMENT_ID }, + ), + (error: unknown) => error instanceof MutationError && error.status === 404, + ); + assert.deepEqual(log.updated, [], 'an unreadable id must not reach the foreign key either'); + }); +}); + +describe('a UI hint that disagrees with its endpoint is the hint that is wrong', () => { + it('does not offer publish to a read-only key, which the endpoint would refuse for scope', async () => { + const readOnlyLead = principal({ ...onTeam('demand', 'lead'), via: 'api_key', scopes: ['read'] }); + const mine = template({ ownerUserId: readOnlyLead.userId }); + const { db } = database([[mine], [mine]]); + + const response = await mounted(db, readOnlyLead).request(`/api/motion/templates/${TEMPLATE_ID}`); + const body = (await response.json()) as { canPublish: boolean }; + + assert.equal(response.status, 200); + assert.equal( + body.canPublish, + false, + 'the button would 403 insufficient_scope, and a button that cannot work must not be offered', + ); + }); + + it('still offers it to the same lead on a full-scope session', async () => { + const mine = template({ ownerUserId: lead.userId }); + const { db } = database([[mine], [mine]]); + + const response = await mounted(db, lead).request(`/api/motion/templates/${TEMPLATE_ID}`); + const body = (await response.json()) as { canPublish: boolean }; + + assert.equal(body.canPublish, true); + }); +}); + +describe('a slug collision is the caller\'s to fix, not a 500', () => { + it('answers 409 naming the versions endpoint when the title derives a slug already in use', async () => { + // "Proposal Blocks" is one of the nine shipped starter templates, so this + // is the first thing a new author trips over rather than an edge case. + const { db, log } = database([[{ version: 1 }]]); + + await assert.rejects( + executeMutation( + db, + member, + async () => ({ + kind: 'proposal', + title: 'Proposal Blocks', + summary: 'Reusable proposal language.', + body: '# Blocks', + stage: 'proposal', + }), + motionTemplateCreateDefinition(), + ), + (error: unknown) => + error instanceof MutationError && + error.code === 'template_slug_exists' && + error.status === 409 && + error.message.includes('/versions'), + ); + assert.deepEqual(log.inserted, []); + }); +}); + +describe('instantiating a template', () => { + it('increments usage_count in the same transaction that writes the artifact', async () => { + const { db, log } = database([ + [{ engagement, deal }], + [template({ usageCount: 2, visibility: 'shared', ownerUserId: OTHER })], + ]); + + await executeMutation( + db, + member, + async () => ({ templateId: TEMPLATE_ID }), + motionArtifactCreateDefinition(), + { id: ENGAGEMENT_ID }, + ); + + // One transaction, both writes: a count that could be committed without the + // artifact would close the template to edits for a use that never happened. + assert.equal(log.updated[0]?.table, 'motion_templates'); + // And the count is incremented by the database, not by JS arithmetic on the + // row that was read a moment ago. Two people instantiating one template + // under READ COMMITTED both read 2 and both write 3, so a use is lost — and + // `usage_count` is the only thing holding §7a shut. Asserting the rendered + // SQL is what makes the difference between the two visible at all: the + // wrong version writes the literal 3 and passes every behavioural test. + const increment = log.updated[0]?.values.usageCount; + assert.ok( + isSQLWrapper(increment), + 'usage_count must be written as an expression, never as a number computed in JS', + ); + assert.match(new PgDialect().sqlToQuery(increment as SQL).sql, /"usage_count" \+ \$?1/); + assert.equal(log.inserted[0]?.table, 'engagement_artifacts'); + assert.equal(log.inserted[0]?.row.templateId, TEMPLATE_ID); + // The body is copied, not referenced — editing the artifact must not reach + // back into the library. + assert.equal(log.inserted[0]?.row.body, template().body); + }); +}); + +describe('one engagement per deal', () => { + it('answers 409 with the id of the engagement that already exists', async () => { + const { db, log } = database([[deal], [engagement]]); + + await assert.rejects( + executeMutation( + db, + member, + async () => ({ demandDealId: DEAL_ID }), + motionEngagementCreateDefinition(), + ), + (error: unknown) => + error instanceof MutationError && + error.code === 'engagement_exists' && + error.status === 409 && + error.message.includes(ENGAGEMENT_ID), + ); + assert.deepEqual(log.inserted, []); + }); +}); + +describe('qualification scores', () => { + it('computes the score from the dimensions rather than believing the body', async () => { + const { db, log } = database([[{ engagement, deal }]]); + const dimensions = [ + { id: 'budget', weight: 30, score: 4 }, + { id: 'urgency', weight: 20, score: 2 }, + { id: 'fit', weight: 50, score: 3 }, + ]; + + await executeMutation( + db, + member, + async () => ({ dimensions }), + motionScoreDefinition(), + { id: ENGAGEMENT_ID }, + ); + + const written = log.inserted.find((row) => row.table === 'qualification_scores')?.row; + assert.equal(written?.basisPoints, motionScoreBasisPoints(dimensions)); + assert.equal(written?.band, 'Strategic'); + assert.equal(typeof written?.basisPoints, 'number'); + assert.ok(Number.isInteger(written?.basisPoints), 'a score is an integer, exactly as money is'); + }); + + it('refuses a posted basisPoints outright — a score somebody can send is a score somebody can fix', async () => { + const { db } = database([[{ engagement, deal }]]); + + await assert.rejects( + executeMutation( + db, + member, + async () => ({ dimensions: [{ id: 'fit', weight: 1, score: 1 }], basisPoints: 10_000 }), + motionScoreDefinition(), + { id: ENGAGEMENT_ID }, + ), + (error: unknown) => error instanceof MutationError && error.code === 'invalid_request', + ); + }); +}); diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index ec5c1eb..0f3b89a 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -9,7 +9,7 @@ * * Design rules, learned from tool surfaces that went wrong: * - * **Keep it small.** Nine tools, each doing one thing. A sprawling tool list + * **Keep it small.** Ten tools, each doing one thing. A sprawling tool list * degrades model performance more than it adds capability; anything genuinely * niche belongs behind `pig_search` or the HTTP API. * @@ -22,6 +22,7 @@ * and quote back to a human. A wall of raw JSON forces the model to re-derive * meaning that the server already knows. */ +import { DEMAND_STAGES, MOTION_KINDS } from '@pig/core'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -542,6 +543,77 @@ export function createPigMcpServer(options: PigMcpOptions): McpServer { }, ); + // ----------------------------------------------------------- motion library + + server.registerTool( + 'pig_motion_library', + { + title: 'Search the motion library', + description: + 'Find reusable go-to-market practice: discovery guides, qualification frameworks, POC ' + + 'structures, proposal blocks, pricing inputs, reference architectures, case studies, ' + + 'technical narratives and deployment playbooks, each bound to the demand stage it ' + + 'serves. Use it before writing a proposal or scoping a POC from scratch — a template ' + + 'with a usage count is language that has already survived a customer.', + inputSchema: { + kind: z + .enum(MOTION_KINDS) + .optional() + .describe('Restrict to one kind of template. Omit for all nine.'), + stage: z + .enum(DEMAND_STAGES) + .optional() + .describe('Restrict to templates serving one demand stage, e.g. proposal.'), + query: z.string().optional().describe('Matched against title, summary and slug'), + }, + }, + async (input) => { + const params = new URLSearchParams(); + if (input.kind) params.set('kind', input.kind); + if (input.stage) params.set('stage', input.stage); + if (input.query) params.set('q', input.query); + + const { templates, truncated } = await api.request<{ + templates: { + id: string; + kind: string; + slug: string; + version: number; + title: string; + summary: string; + stage: string; + visibility: string; + usageCount: number; + updatedAt: string; + }[]; + truncated: boolean; + }>(`/api/motion/templates?${params}`); + + if (templates.length === 0) { + return ok( + 'No template matches that request. Note that this key sees shared templates and ' + + "the caller's own private drafts, so a colleague's draft will not appear.", + ); + } + + const lines = [ + `${templates.length}${truncated ? '+' : ''} template(s), newest version of each:\n`, + ]; + for (const t of templates.slice(0, 25)) { + lines.push( + `▸ ${t.title} — ${t.kind}, ${t.stage} stage, v${t.version}` + + `${t.visibility === 'private' ? ' [private draft]' : ''}`, + ` ${t.summary}`, + // Usage is the difference between practice and a document nobody + // opened, and it is the field a model should rank on. + ` Instantiated ${t.usageCount} time(s) · id: ${t.id}`, + '', + ); + } + return ok(lines.join('\n')); + }, + ); + // ------------------------------------------------------------- log activity server.registerTool( diff --git a/apps/piggy/src/page-routes.ts b/apps/piggy/src/page-routes.ts index 3340bba..c4bb4a3 100644 --- a/apps/piggy/src/page-routes.ts +++ b/apps/piggy/src/page-routes.ts @@ -22,6 +22,9 @@ export const PIGGY_PAGE_TOOL_NAMES = [ 'pig_get_idle_capacity', 'pig_get_pipeline', 'pig_get_calendar_ahead', + 'pig_get_motion_summary', + 'pig_search_motion_library', + 'pig_get_engagement', ] as const; export type PiggyPageToolName = (typeof PIGGY_PAGE_TOOL_NAMES)[number]; @@ -97,6 +100,24 @@ const GUIDES: Partial> = { label: 'the contracts list — Piggy reads its dates here, not its terms', tool: 'pig_get_calendar_ahead', }, + '/motion': { + label: 'the Motion home — stage coverage, the shared library, and recent promotions', + tool: 'pig_get_motion_summary', + }, + /* + * The label says "shared" because the tool reads nothing else, and the model + * is otherwise free to conclude that a template it cannot find is missing + * rather than private. A user asking "where is my draft?" should be told + * Piggy cannot see private drafts, not that no such template exists. + */ + '/motion/library': { + label: 'the motion library — Piggy reads the shared templates here, never a private draft', + tool: 'pig_search_motion_library', + }, + '/motion/engagements': { + label: 'the engagement list — the demand deals with a motion running against them', + tool: 'pig_get_engagement', + }, '/imports': { label: 'the CSV import page', tool: 'pig_get_workspace_summary' }, '/team': { label: 'the team and permissions page', tool: 'pig_get_workspace_summary' }, '/facts': { label: 'the fact review queue', tool: 'pig_get_workspace_summary' }, diff --git a/apps/piggy/src/page-tools.ts b/apps/piggy/src/page-tools.ts index 1f3ede7..539be7a 100644 --- a/apps/piggy/src/page-tools.ts +++ b/apps/piggy/src/page-tools.ts @@ -28,6 +28,8 @@ import { CONSUMING_ALLOCATION_STATUSES, DEMAND_OPEN_STAGES, + DEMAND_STAGES, + MOTION_KINDS, RESERVING_ALLOCATION_STATUSES, SUPPLY_OPEN_STAGES, aggregateMargin, @@ -37,19 +39,27 @@ import { type AllocationInput, type CalendarEvent, type CalendarEventKind, + type DemandStage, type MarginResult, + type MotionKind, type PiggyPageRoute, } from '@pig/core'; import { + accounts, allocations, capacityCommitments, demandDeals, + engagementArtifacts, + engagements, + motionTemplates, + qualificationScores, supplyDeals, type Database, } from '@pig/db'; import { CalendarService } from '@pig/api/src/services/calendar'; -import { and, gte, inArray, isNull } from 'drizzle-orm'; +import { and, desc, eq, gte, ilike, inArray, isNotNull, isNull, or, type SQL } from 'drizzle-orm'; import { z } from 'zod'; +import { likeFragment } from './chat-tools'; import { piggyPageGuide, type PiggyPageToolName } from './page-routes'; import { defineTool, type AgentTool } from './provider'; @@ -161,6 +171,72 @@ function pageTool(db: Database, name: PiggyPageToolName): AgentTool { .strict(), execute: async ({ withinDays }) => readCalendarAhead(db, withinDays ?? 30), }); + case 'pig_get_motion_summary': + return defineTool({ + name, + description: + 'Read whether the go-to-market motion is repeating: which demand stages the shared ' + + 'library covers and which it does not, how many live engagements sit at each stage, ' + + 'the shared library by kind, and the artifacts most recently promoted back into it. ' + + 'Private drafts are not visible to this tool.', + inputSchema: noInput, + execute: async () => readMotionSummary(db), + }); + case 'pig_search_motion_library': + return defineTool({ + name, + description: + 'Search the shared motion library — discovery guides, qualification frameworks, POC ' + + 'structures, proposal blocks, pricing inputs, reference architectures, case studies, ' + + 'technical narratives and deployment playbooks. Returns the newest version of each ' + + 'template. Private drafts are never searched, whoever is asking.', + inputSchema: z + .object({ + kind: z + .enum(MOTION_KINDS) + .describe('Restrict to one kind of template. null for every kind.') + .nullish(), + stage: z + .enum(DEMAND_STAGES) + .describe('Restrict to templates serving one demand stage. null for every stage.') + .nullish(), + query: z + .string() + .trim() + .min(2) + .max(64) + .describe( + 'Word or phrase matched against the title, summary and slug. null returns the ' + + 'whole shared library.', + ) + .nullish(), + }) + .strict(), + execute: async (filter) => readMotionLibrary(db, filter), + }); + case 'pig_get_engagement': + return defineTool({ + name, + description: + 'Read the engagements running against demand deals: which stage the deal sits at, ' + + 'how many artifacts have been produced and how many are final, and the latest ' + + 'qualification score with its band.', + inputSchema: z + .object({ + query: z + .string() + .trim() + .min(2) + .max(64) + .describe( + 'Word or phrase matched against the deal name and the engagement summary. ' + + 'null returns the most recently opened engagements.', + ) + .nullish(), + }) + .strict(), + execute: async ({ query }) => readEngagements(db, query ?? null), + }); case 'pig_get_workspace_summary': return defineTool({ name, @@ -585,6 +661,325 @@ function countByState(events: readonly CalendarEvent[]): Record return counts; } +// --------------------------------------------------------------------------- +// The motion +// --------------------------------------------------------------------------- + +export interface MotionLibraryFilter { + kind?: MotionKind | null; + stage?: DemandStage | null; + query?: string | null; +} + +/** + * Shared templates only, unconditionally — not "unless the asker owns it". + * + * Motion is the one place in PIG with a row-level access rule: a `private` + * template is readable by its owner and by a platform admin, and by nobody + * else. Piggy has no reliable notion of who is asking. The dock publishes a + * route, the relay checks a capability, and the tool then runs as the process; + * nothing reaches this query that identifies a person strongly enough to widen + * it on. So it is not widened. A model that can be argued into reading a + * colleague's private draft is a leak with the extra step of asking politely, + * and the argument would arrive as ordinary conversation the guard never sees. + * + * This is the only clause here that must not become a parameter. If private + * drafts ever need an answer, the identity has to arrive with the request and + * be enforced in `services/motion.ts` where the API already enforces it — not + * by relaxing this. `motion-tools.test.ts` fails if it is. + */ +export function motionLibraryWhere(filter: MotionLibraryFilter): SQL { + const conditions: SQL[] = [ + eq(motionTemplates.visibility, 'shared'), + isNull(motionTemplates.archivedAt), + ]; + if (filter.kind) conditions.push(eq(motionTemplates.kind, filter.kind)); + if (filter.stage) conditions.push(eq(motionTemplates.stage, filter.stage)); + if (filter.query) { + const fragment = likeFragment(filter.query); + conditions.push( + or( + ilike(motionTemplates.title, fragment), + ilike(motionTemplates.summary, fragment), + ilike(motionTemplates.slug, fragment), + )!, + ); + } + return and(...conditions)!; +} + +interface LineageRow { + slug: string; + kind: MotionKind; + stage: DemandStage; + version: number; + title: string; + summary: string; + usageCount: number; +} + +/** + * One row per lineage, newest version winning. + * + * The library counts lineages rather than rows because a template promoted + * three times is one piece of practice with a history, and counting its + * versions reports a library four times the size of the one anybody can choose + * from. The slug is the identity of the lineage — see the schema header. + */ +function newestPerSlug(rows: readonly Row[]): Row[] { + const newest = new Map(); + for (const row of rows) { + const held = newest.get(row.slug); + if (!held || row.version > held.version) newest.set(row.slug, row); + } + return [...newest.values()]; +} + +function countBy(rows: readonly Row[], key: (row: Row) => string): Record { + const counts: Record = {}; + for (const row of rows) counts[key(row)] = (counts[key(row)] ?? 0) + 1; + return counts; +} + +/** + * The question the /motion page exists to answer: is the motion repeating? + * + * Coverage is reported as the stages the shared library does NOT reach, not + * only as a count, because "6 of 8 stages covered" is a figure nobody acts on + * and "nothing covers procurement or deployment" is a piece of work. The two + * closed stages are excluded throughout — a won deal has left the motion. + */ +async function readMotionSummary(db: Database): Promise { + const [libraryRead, engagementRead, promotions] = await Promise.all([ + db + .select({ + slug: motionTemplates.slug, + kind: motionTemplates.kind, + stage: motionTemplates.stage, + version: motionTemplates.version, + title: motionTemplates.title, + summary: motionTemplates.summary, + usageCount: motionTemplates.usageCount, + }) + .from(motionTemplates) + .where(motionLibraryWhere({})) + .limit(SCAN_LIMIT + 1), + db + .select({ stage: demandDeals.stage, dealName: demandDeals.name }) + .from(engagements) + .innerJoin(demandDeals, eq(engagements.demandDealId, demandDeals.id)) + .where(eq(engagements.status, 'open')) + .limit(SCAN_LIMIT + 1), + db + .select({ + title: motionTemplates.title, + kind: motionTemplates.kind, + version: motionTemplates.version, + createdAt: motionTemplates.createdAt, + }) + .from(motionTemplates) + .where(and(motionLibraryWhere({}), isNotNull(motionTemplates.originArtifactId))) + .orderBy(desc(motionTemplates.createdAt)) + .limit(EXEMPLARS), + ]); + const { rows: templateRows, truncated: libraryTruncated } = bounded(libraryRead); + const { rows: openEngagements, truncated: engagementsTruncated } = bounded(engagementRead); + const truncated = libraryTruncated || engagementsTruncated; + + const lineages = newestPerSlug(templateRows); + const engagementsByStage = countBy(openEngagements, (row) => row.stage); + const uncovered = DEMAND_OPEN_STAGES.filter( + (stage) => !lineages.some((template) => template.stage === stage), + ); + + return { + headline: + `${atLeast(lineages.length, libraryTruncated)} shared template(s) across ` + + `${Object.keys(countBy(lineages, (row) => row.kind)).length} of ${MOTION_KINDS.length} ` + + `kind(s), and ${atLeast(openEngagements.length, engagementsTruncated)} open engagement(s). ` + + (uncovered.length === 0 + ? 'Every live demand stage has at least one shared template.' + : `No shared template covers ${uncovered.join(', ')}.`) + + ` ${promotions.length} artifact(s) promoted back into the library recently.` + + (truncated ? ` ${TRUNCATION_NOTE}` : ''), + truncated, + sharedTemplates: lineages.length, + openEngagements: openEngagements.length, + uncoveredStages: uncovered, + stages: DEMAND_OPEN_STAGES.map((stage) => ({ + stage, + openEngagements: engagementsByStage[stage] ?? 0, + sharedTemplates: lineages.filter((template) => template.stage === stage).length, + })), + libraryByKind: countBy(lineages, (row) => row.kind), + // The loop made visible: what the last few engagements gave back. + recentPromotions: promotions.map((row) => ({ + title: row.title, + kind: row.kind, + version: row.version, + promotedAt: row.createdAt.toISOString(), + })), + }; +} + +async function readMotionLibrary(db: Database, filter: MotionLibraryFilter): Promise { + const { rows, truncated } = bounded( + await db + .select({ + slug: motionTemplates.slug, + kind: motionTemplates.kind, + stage: motionTemplates.stage, + version: motionTemplates.version, + title: motionTemplates.title, + summary: motionTemplates.summary, + usageCount: motionTemplates.usageCount, + }) + .from(motionTemplates) + .where(motionLibraryWhere(filter)) + .orderBy(desc(motionTemplates.updatedAt)) + .limit(SCAN_LIMIT + 1), + ); + + const lineages = newestPerSlug(rows as LineageRow[]); + const described = [ + filter.kind ? `kind ${filter.kind}` : null, + filter.stage ? `stage ${filter.stage}` : null, + filter.query ? `"${filter.query}"` : null, + ].filter((part): part is string => part !== null); + const scope = described.length ? ` matching ${described.join(', ')}` : ''; + + return { + headline: + (lineages.length === 0 + ? `No shared template${scope}. Private drafts are not searched, so a template may ` + + 'exist and not be visible here.' + : `${atLeast(lineages.length, truncated)} shared template(s)${scope}, newest version ` + + 'of each.') + (truncated ? ` ${TRUNCATION_NOTE}` : ''), + truncated, + count: lineages.length, + byKind: countBy(lineages, (row) => row.kind), + // Most recently updated first: the practice people are actually amending. + templates: lineages.slice(0, EXEMPLARS).map((template) => ({ + slug: template.slug, + kind: template.kind, + stage: template.stage, + version: template.version, + title: template.title, + summary: template.summary, + // How many engagements have instantiated it — the only evidence here of + // whether a template is practice or an unread document. + usageCount: template.usageCount, + })), + }; +} + +/** + * Engagements, with the two figures anyone asks for: how much has been produced + * and where qualification landed. + * + * Artifacts and scores are read only for the exemplars, so the second and third + * queries stay small however wide the book is. The engagement's playbook + * template is deliberately not joined: it may be a private draft, and naming it + * would walk round the library rule by another door. + */ +async function readEngagements(db: Database, query: string | null): Promise { + const fragment = query ? likeFragment(query) : null; + const { rows, truncated } = bounded( + await db + .select({ + id: engagements.id, + status: engagements.status, + summary: engagements.summary, + openedAt: engagements.openedAt, + stage: demandDeals.stage, + dealName: demandDeals.name, + accountName: accounts.name, + }) + .from(engagements) + .innerJoin(demandDeals, eq(engagements.demandDealId, demandDeals.id)) + .leftJoin(accounts, eq(demandDeals.accountId, accounts.id)) + .where( + fragment + ? or(ilike(demandDeals.name, fragment), ilike(engagements.summary, fragment)) + : undefined, + ) + .orderBy(desc(engagements.openedAt)) + .limit(SCAN_LIMIT + 1), + ); + + const exemplars = rows.slice(0, EXEMPLARS); + const ids = exemplars.map((row) => row.id); + const [artifacts, scores] = ids.length + ? await Promise.all([ + db + .select({ engagementId: engagementArtifacts.engagementId, status: engagementArtifacts.status }) + .from(engagementArtifacts) + .where( + and( + inArray(engagementArtifacts.engagementId, ids), + isNull(engagementArtifacts.archivedAt), + ), + ) + .limit(SCAN_LIMIT), + db + .select({ + engagementId: qualificationScores.engagementId, + basisPoints: qualificationScores.basisPoints, + band: qualificationScores.band, + scoredAt: qualificationScores.scoredAt, + }) + .from(qualificationScores) + .where(inArray(qualificationScores.engagementId, ids)) + .orderBy(desc(qualificationScores.scoredAt)) + .limit(SCAN_LIMIT), + ]) + : [[], []]; + + // Newest first out of the query, so the first score seen for an engagement is + // its latest; a later one must not overwrite it. + const latestScore = new Map(); + for (const score of scores) if (!latestScore.has(score.engagementId)) latestScore.set(score.engagementId, score); + + return { + headline: + (rows.length === 0 + ? query + ? `No engagement matches "${query}".` + : 'No demand deal has an engagement running against it yet.' + : `${atLeast(rows.length, truncated)} engagement(s)${query ? ` matching "${query}"` : ''}, ` + + `of which ${rows.filter((row) => row.status === 'open').length} open.`) + + (truncated ? ` ${TRUNCATION_NOTE}` : ''), + truncated, + count: rows.length, + byStatus: countBy(rows, (row) => row.status), + byStage: countBy(rows, (row) => row.stage), + engagements: exemplars.map((row) => { + const mine = artifacts.filter((artifact) => artifact.engagementId === row.id); + const score = latestScore.get(row.id); + return { + dealName: row.dealName, + accountName: row.accountName, + stage: row.stage, + status: row.status, + summary: row.summary, + openedAt: row.openedAt.toISOString(), + artifacts: mine.length, + finalArtifacts: mine.filter((artifact) => artifact.status === 'final').length, + latestScore: score + ? { + // Basis points of the maximum, so a tenth of a per cent — the + // band is the part a seller acts on. + basisPoints: score.basisPoints, + percent: `${(score.basisPoints / 100).toFixed(1)}%`, + band: score.band, + scoredAt: score.scoredAt.toISOString(), + } + : null, + }; + }), + }; +} + // --------------------------------------------------------------------------- // The fallback // --------------------------------------------------------------------------- diff --git a/apps/piggy/test/motion-tools.test.ts b/apps/piggy/test/motion-tools.test.ts new file mode 100644 index 0000000..4c85e20 --- /dev/null +++ b/apps/piggy/test/motion-tools.test.ts @@ -0,0 +1,211 @@ +/** + * The motion page tools, and the one rule none of them may relax. + * + * Motion is the first feature in PIG with a row-level access rule: a `private` + * template belongs to its owner and to a platform admin, and to nobody else. + * Every other read in this process is book-wide, so the habit of the codebase + * is against this clause rather than for it — which is exactly why it is pinned + * here rather than left to a review. + * + * Piggy cannot enforce ownership because it does not reliably know who is + * asking: the dock publishes a route and the relay checks a capability, and + * neither reaches the query. So the query is closed instead. The tests below + * assert the closed form under every filter combination a model can send, + * because the plausible-but-wrong version of this code is one where the clause + * is present in the unfiltered read and lost in a branch. + * + * The unit suite runs in CI BEFORE the migration step, against a database with + * no tables, so nothing here may execute a query. The WHERE clause is rendered + * with `PgDialect` rather than run. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { MOTION_KINDS } from '@pig/core'; +import type { Database } from '@pig/db'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { assertPigToolBoundary } from '../src/chat'; +import { createPagePigTools, motionLibraryWhere } from '../src/page-tools'; + +/** Schema and SQL-shape checks only: no query is executed. */ +const db = {} as Database; +const dialect = new PgDialect(); + +function renderedWhere(filter: Parameters[0]): { + text: string; + params: unknown[]; +} { + const query = dialect.sqlToQuery(motionLibraryWhere(filter)); + return { text: query.sql, params: query.params }; +} + +function tool(route: '/motion' | '/motion/library' | '/motion/engagements') { + const [only, ...rest] = createPagePigTools(db, route); + assert.ok(only, `${route} has a tool`); + // One tool per page: a second is a second thing to choose wrongly, and a + // wrong choice costs one of four turns. + assert.equal(rest.length, 0); + return only; +} + +// --------------------------------------------------------------------------- +// The visibility invariant. Do not relax this to "unless the user owns it". +// --------------------------------------------------------------------------- + +test('the library query filters to shared templates UNCONDITIONALLY, under every filter', () => { + const filters: Parameters[0][] = [ + {}, + { kind: 'proposal' }, + { stage: 'poc' }, + { query: 'sovereign' }, + { kind: 'playbook', stage: 'deployment', query: 'inference' }, + // The nulls a schema-abiding model sends for "no filter" must not read as + // "no visibility filter either". + { kind: null, stage: null, query: null }, + ]; + + for (const filter of filters) { + const { params } = renderedWhere(filter); + assert.ok( + params.includes('shared'), + `visibility = 'shared' is missing for ${JSON.stringify(filter)}`, + ); + // Bound to the column, not merely present somewhere in the statement. + assert.match(renderedWhere(filter).text, /"visibility" = \$\d+/); + } +}); + +test('no filter a model can send widens the library beyond shared — private is not a parameter', () => { + const library = tool('/motion/library'); + const accepts = (input: unknown) => library.inputSchema.safeParse(input).success; + + // `.strict()`, so every one of these is refused rather than ignored. A tool + // that silently drops an unknown key teaches a model to keep trying. + assert.equal(accepts({ visibility: 'private' }), false); + assert.equal(accepts({ ownerUserId: '20000000-0000-4000-8000-000000000002' }), false); + assert.equal(accepts({ includePrivate: true }), false); + assert.equal(accepts({ all: '1' }), false); + assert.equal(accepts({ kind: 'proposal' }), true); +}); + +test('a private template is invisible even when its title is the search term', () => { + // The query filter is an AND alongside the visibility clause, never an OR + // beside it: an `or(...)` at the top level would make any matching title + // satisfy the whole WHERE and return the private row. + const { text, params } = renderedWhere({ query: 'Halcyon' }); + const [visibility] = text.split('and'); + assert.ok(visibility?.includes('"visibility"'), text); + assert.ok(text.startsWith('('), text); + assert.match(text, /^\("[a-z_]+"\."visibility" = \$1 and /); + assert.equal(params[0], 'shared'); +}); + +test('archived templates are excluded from every library read', () => { + // Archiving is how deletion works here, so a query that ignores it hands the + // model practice somebody deliberately withdrew. + assert.match(renderedWhere({}).text, /"archived_at" is null/); + assert.match(renderedWhere({ kind: 'case_study' }).text, /"archived_at" is null/); +}); + +test('LIKE wildcards in the model-supplied query are escaped, not honoured', () => { + // Unescaped, `%` matches every shared template and the model is handed the + // first eight as though they answered the question. + assert.deepEqual(renderedWhere({ query: '%' }).params, ['shared', '%\\%%', '%\\%%', '%\\%%']); +}); + +// --------------------------------------------------------------------------- +// The boundary +// --------------------------------------------------------------------------- + +test('every motion page tool sits inside the PIG tool boundary', () => { + const tools = [tool('/motion'), tool('/motion/library'), tool('/motion/engagements')]; + assert.deepEqual(tools.map((entry) => entry.name), [ + 'pig_get_motion_summary', + 'pig_search_motion_library', + 'pig_get_engagement', + ]); + // The assertion the chat provider runs on every request: a name that fails it + // takes the whole conversation down rather than one tool. + assert.doesNotThrow(() => assertPigToolBoundary(tools)); + for (const entry of tools) { + assert.ok(entry.description.length > 40, `${entry.name} has a usable description`); + } +}); + +test('the library and engagement tools say out loud that private drafts are not read', () => { + // The description is the only place the model learns the limit, and "I found + // nothing" is a materially different answer from "I cannot see private + // drafts" to someone looking at their own. + assert.match(tool('/motion/library').description, /[Pp]rivate drafts are never searched/); + assert.match(tool('/motion').description, /[Pp]rivate drafts are not visible/); +}); + +// --------------------------------------------------------------------------- +// The input bounds +// --------------------------------------------------------------------------- + +test('the motion summary takes no input at all', () => { + const summary = tool('/motion'); + assert.equal(summary.inputSchema.safeParse({}).success, true); + assert.equal(summary.inputSchema.safeParse({ stage: 'poc' }).success, false); +}); + +test('the library filters accept only ontology values, and a bounded query', () => { + const library = tool('/motion/library'); + const accepts = (input: unknown) => library.inputSchema.safeParse(input).success; + + for (const kind of MOTION_KINDS) assert.equal(accepts({ kind }), true); + // A kind the model invented reaches the database as a cast error rather than + // a miss, so it is refused at the schema. + assert.equal(accepts({ kind: 'battlecard' }), false); + assert.equal(accepts({ stage: 'closed_won' }), true); + assert.equal(accepts({ stage: 'negotiation' }), false); + // Trimmed before the length check, so trailing whitespace cannot smuggle a + // one-character query past the floor and match the whole library. + assert.equal(accepts({ query: ' a ' }), false); + assert.equal(accepts({ query: 'x'.repeat(64) }), true); + assert.equal(accepts({ query: 'x'.repeat(65) }), false); + assert.equal(accepts({ query: 'x'.repeat(4000) }), false); +}); + +test('the engagement query is bounded and refuses anything it does not name', () => { + const engagement = tool('/motion/engagements'); + const accepts = (input: unknown) => engagement.inputSchema.safeParse(input).success; + + assert.equal(accepts({}), true); + assert.equal(accepts({ query: 'Halcyon' }), true); + assert.equal(accepts({ query: 'a' }), false); + assert.equal(accepts({ query: 'x'.repeat(65) }), false); + assert.equal(accepts({ engagementId: '20000000-0000-4000-8000-000000000002' }), false); + assert.equal(accepts({ limit: 500 }), false); +}); + +/** + * What the model is actually sent, rather than what the zod reads like. + * + * `zodToJsonSchema(..., { target: 'openAi' })` — the exact call both inference + * paths make — emits an optional field as REQUIRED and nullable, so a + * schema-abiding model sends `null` for every filter it does not want and + * `.optional()` would reject the call. A `.describe()` applied after the + * wrapper is dropped from the emitted schema entirely. + */ +test('an omitted motion filter arrives as the null the emitted schema asks for', () => { + const library = tool('/motion/library'); + assert.equal( + library.inputSchema.safeParse({ kind: null, stage: null, query: null }).success, + true, + ); + assert.equal(tool('/motion/engagements').inputSchema.safeParse({ query: null }).success, true); + + const emitted = zodToJsonSchema(library.inputSchema, { + $refStrategy: 'none', + target: 'openAi', + }) as { properties?: Record; required?: string[] }; + assert.deepEqual(emitted.required, ['kind', 'stage', 'query']); + for (const [parameter, shape] of Object.entries(emitted.properties ?? {})) { + assert.ok( + shape.description && shape.description.length > 10, + `${parameter} reaches the model with no description`, + ); + } +}); diff --git a/apps/web/package.json b/apps/web/package.json index 2979fe1..a39597e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,7 +38,9 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.85.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.1.1", + "remark-gfm": "^4.0.1", "sonner": "^2.0.8", "streamdown": "^2.5.0", "tailwind-merge": "^2.6.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a30fdd9..b328f6d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -36,6 +36,14 @@ const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth }))); const Calendar = lazy(() => import('@/pages/Calendar').then(({ Calendar }) => ({ default: Calendar }))); const Learn = lazy(() => import('@/pages/Learn').then(({ Learn }) => ({ default: Learn }))); +// Lazy is load-bearing for these five and not merely conventional: they are the +// only pages that pull react-markdown and remark-gfm, and an eager import would +// put a markdown parser into the entry chunk every route pays for. +const Motion = lazy(() => import('@/pages/Motion').then(({ Motion }) => ({ default: Motion }))); +const MotionLibrary = lazy(() => import('@/pages/MotionLibrary').then(({ MotionLibrary }) => ({ default: MotionLibrary }))); +const MotionTemplate = lazy(() => import('@/pages/MotionTemplate').then(({ MotionTemplate }) => ({ default: MotionTemplate }))); +const MotionEngagements = lazy(() => import('@/pages/MotionEngagements').then(({ MotionEngagements }) => ({ default: MotionEngagements }))); +const Engagement = lazy(() => import('@/pages/Engagement').then(({ Engagement }) => ({ default: Engagement }))); const queryClient = new QueryClient({ defaultOptions: { @@ -248,6 +256,16 @@ function AppRoutes() { the next four record routes will read. */} } /> + {/* + Flat, in the register of the routes above — nesting these under a + layout route would give Motion a chrome no other group has, and the + five pages share no shell of their own. + */} + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/motion/CodeBlock.tsx b/apps/web/src/components/motion/CodeBlock.tsx new file mode 100644 index 0000000..daec3c6 --- /dev/null +++ b/apps/web/src/components/motion/CodeBlock.tsx @@ -0,0 +1,75 @@ +/** + * Source with a copy button — the fence chrome, in one place. + * + * Reference architectures arrive as `mermaid`, both in a template's structured + * `fields` and inside the markdown body a promoted artefact brings with it, and + * neither is rendered as a picture. Bundling mermaid means roughly two + * megabytes and evaluating author-supplied text, and the proxy allows exactly + * one inline script by hash (AGENTS.md §5), so it would drag a CSP change onto + * the deployment host too. Source with a copy button gets the reader into their + * own diagram tool in two clicks; the picture is tracked as follow-up in + * `docs/motion.md`. + * + * The copy button is here rather than in each caller because a diagram in a + * body and a diagram in a field are the same thing to the person reading it, + * and one of the two silently lacking the button is the sort of difference + * nobody reports. + */ +import { useState } from 'react'; +import { Check, Copy } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui'; + +export function CodeBlock({ + source, + language, + label, +}: { + source: string; + /** The fence's language, shown as the block's caption. Absent for a bare fence. */ + language?: string | null; + /** What the copy button says it copies, for a screen reader. */ + label?: string; +}) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + // `navigator.clipboard` is absent outside a secure context, which the LAN + // dev server is — so this branch is reached routinely, not exceptionally. + if (!navigator.clipboard) { + toast.error('The browser refused clipboard access. Select the source and copy it by hand.'); + return; + } + try { + await navigator.clipboard.writeText(source); + setCopied(true); + toast.success('Source copied.'); + window.setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('The browser refused clipboard access. Select the source and copy it by hand.'); + } + }; + + return ( +
+
+ + {language ?? 'source'} + + +
+
+        {source}
+      
+
+ ); +} diff --git a/apps/web/src/components/motion/FieldsView.tsx b/apps/web/src/components/motion/FieldsView.tsx new file mode 100644 index 0000000..a666af8 --- /dev/null +++ b/apps/web/src/components/motion/FieldsView.tsx @@ -0,0 +1,637 @@ +/** + * The structured half of a template, rendered per kind. + * + * `fields` is authored JSON. It is not validated on the way out of the + * database, it is edited by anyone with `motion:write`, it survives promotion + * from an engagement artefact unchanged, and it will outlive whatever shape + * this file expects today. So every access below is narrowed, every list is + * filtered to the entries that carry the property being rendered, and a shape + * this file does not recognise renders as nothing at all. + * + * That last rule is the important one. The alternative — assume the shape and + * let the page throw — turns one badly-typed seed row into a blank library for + * everybody, and the crash surfaces in a route far from the row that caused it. + * An omitted section is a bug someone reports; a white screen is an outage. + * + * A qualification framework's dimensions are shown with their weights and + * their 0–4 anchors, because the anchors are what stop a score being a vibe: + * the number only means something if two people reading the same evidence pick + * the same one. + */ +import { type ReactNode } from 'react'; +import { Skull, TriangleAlert } from 'lucide-react'; +import { DEMAND_STAGE_LABELS, type DemandStage, type MotionKind } from '@pig/core'; +import { Badge, cn } from '@/components/ui'; +import { CodeBlock } from './CodeBlock'; +import { anchorScale, asRecord, number, recordList, text, textList } from './fields'; + +export function FieldsView({ + kind, + fields, + className, +}: { + kind: MotionKind; + /** Untrusted. `unknown` on purpose — see the header. */ + fields: unknown; + className?: string; +}) { + const record = asRecord(fields); + if (!record) return null; + + const body = renderKind(kind, record); + if (!body) return null; + + return
{body}
; +} + +function renderKind(kind: MotionKind, fields: Record): ReactNode { + switch (kind) { + case 'discovery': + return ; + case 'qualification': + return ; + case 'poc': + return ; + case 'proposal': + return ; + case 'pricing': + return ; + case 'architecture': + return ; + case 'case_study': + return ; + case 'narrative': + return ; + case 'playbook': + return ; + } +} + +// ------------------------------------------------------------------ the kinds + +function DiscoveryFields({ fields }: { fields: Record }) { + const sections = recordList(fields.sections); + if (sections.length === 0) return null; + + return ( + <> + {sections.map((section, index) => { + const name = text(section.name); + if (!name) return null; + const questions = recordList(section.questions); + return ( +
+ + + {questions.length === 0 ? null : ( +
    + {questions.map((question, questionIndex) => { + const asked = text(question.q); + if (!asked) return null; + return ( +
  1. +

    {asked}

    + + +
  2. + ); + })} +
+ )} +
+ ); + })} + + ); +} + +function QualificationFields({ fields }: { fields: Record }) { + const dimensions = recordList(fields.dimensions); + const bands = recordList(fields.bands); + const disqualifiers = recordList(fields.disqualifiers); + if (dimensions.length === 0 && bands.length === 0 && disqualifiers.length === 0) return null; + + const weightTotal = dimensions.reduce((total, dimension) => total + (number(dimension.weight) ?? 0), 0); + + return ( + <> + {dimensions.length === 0 ? null : ( +
0 ? `${weightTotal} weight in total` : undefined} + > +
+ {dimensions.map((dimension, index) => { + const name = text(dimension.name); + if (!name) return null; + const weight = number(dimension.weight); + const group = text(dimension.group); + return ( +
+
+ {name} + {group ? {group} : null} + {weight === null ? null : ( + weight {weight} + )} +
+ + +
+ ); + })} +
+
+ )} + + {bands.length === 0 ? null : ( +
+
    + {bands.map((band, index) => { + const label = text(band.label); + if (!label) return null; + const min = number(band.min); + const max = number(band.max); + return ( +
  • + {label} + {min === null || max === null ? null : ( + + {min}–{max} + + )} + +
  • + ); + })} +
+
+ )} + + {disqualifiers.length === 0 ? null : ( +
}> +
    + {disqualifiers.map((disqualifier, index) => { + const name = text(disqualifier.name); + if (!name) return null; + return ( +
  • +

    {name}

    + + +
  • + ); + })} +
+
+ )} + + ); +} + +/** The 0–4 scale, extracted by the same rule the scorer reads it with. */ +function Anchors({ anchors }: { anchors: unknown }) { + const rows = anchorScale(anchors); + if (rows.length === 0) return null; + + return ( +
+ {rows.map((row) => ( +
+
{row.score}
+
{row.anchor}
+
+ ))} +
+ ); +} + +function PocFields({ fields }: { fields: Record }) { + const hypothesis = text(fields.hypothesis); + const milestones = recordList(fields.milestones); + const metrics = recordList(fields.successMetrics); + const risks = recordList(fields.risks); + if (!hypothesis && milestones.length === 0 && metrics.length === 0 && risks.length === 0) return null; + + return ( + <> + {hypothesis ? ( +
+

{hypothesis}

+
+ ) : null} + + {milestones.length === 0 ? null : ( +
+
    + {milestones.map((milestone, index) => { + const title = text(milestone.title); + if (!title) return null; + return ( +
  1. +
    + {text(milestone.week) ? ( + {text(milestone.week)} + ) : null} + {title} + {/* A kill gate is the only reason a POC ends early rather + than drifting into a second quarter, so it is the one + property here that earns a colour. */} + {milestone.killGate === true ? Kill gate : null} +
    + + +
  2. + ); + })} +
+
+ )} + + {metrics.length === 0 ? null : ( +
+ [ + text(metric.metric), + text(metric.baseline), + text(metric.target), + text(metric.measuredBy), + ])} + /> +
+ )} + + {risks.length === 0 ? null : ( +
}> + [text(risk.risk), text(risk.owner), text(risk.mitigation)])} + /> +
+ )} + + ); +} + +function ProposalFields({ fields }: { fields: Record }) { + const blocks = recordList(fields.blocks); + if (blocks.length === 0) return null; + + return ( + <> + {blocks.map((block, index) => { + const title = text(block.title); + if (!title) return null; + const body = text(block.text); + return ( +
+ + + {body ? ( + // Proposal blocks are lifted verbatim into a document, so the + // whitespace the author wrote is part of the block. +

+ {body} +

+ ) : null} +
+ ); + })} + + ); +} + +function PricingFields({ fields }: { fields: Record }) { + const inputs = recordList(fields.inputs); + const packages = recordList(fields.packages); + const tradeables = recordList(fields.tradeables); + if (inputs.length === 0 && packages.length === 0 && tradeables.length === 0) return null; + + return ( + <> + {inputs.length === 0 ? null : ( +
+
+ {inputs.map((input, index) => { + const name = text(input.name); + if (!name) return null; + const unit = text(input.unit); + return ( +
+
+ {name} + {unit ? {unit} : null} +
+ + +
+ ); + })} +
+
+ )} + + {packages.length === 0 ? null : ( +
+ [ + text(entry.name), + text(entry.shape), + text(entry.fitsWhen), + text(entry.failsWhen), + ])} + /> +
+ )} + + {tradeables.length === 0 ? null : ( +
+ [text(entry.give), text(entry.get)])} + /> +
+ )} + + ); +} + +function ArchitectureFields({ fields }: { fields: Record }) { + const architectures = recordList(fields.architectures); + if (architectures.length === 0) return null; + + return ( + <> + {architectures.map((architecture, index) => { + const name = text(architecture.name); + if (!name) return null; + const components = recordList(architecture.components); + return ( +
+ + + + {components.length === 0 ? null : ( + [ + text(component.component), + text(component.runBy), + text(component.why), + ])} + /> + )} +
+ ); + })} + + ); +} + +/** + * The diagram, as source rather than as a picture — deliberately, for now. See + * `CodeBlock` for why, and for the copy button that makes it usable. + */ +function Diagram({ source, name }: { source: string | null; name: string }) { + if (!source) return null; + return ; +} + +function CaseStudyFields({ fields }: { fields: Record }) { + const sections = recordList(fields.sections); + const harvest = recordList(fields.harvest); + if (sections.length === 0 && harvest.length === 0) return null; + + return ( + <> + {sections.length === 0 ? null : ( +
+
    + {sections.map((section, index) => { + const name = text(section.name); + if (!name) return null; + return ( +
  1. +

    {name}

    + + {/* Evidence rules are the reason a case study can be shown to + the next customer at all — see AGENTS.md §4. */} + +
  2. + ); + })} +
+
+ )} + + {harvest.length === 0 ? null : ( +
+ [text(entry.when), text(entry.capture), text(entry.why)])} + /> +
+ )} + + ); +} + +function NarrativeFields({ fields }: { fields: Record }) { + const narratives = recordList(fields.narratives); + if (narratives.length === 0) return null; + + return ( + <> + {narratives.map((narrative, index) => { + const audience = text(narrative.audience); + if (!audience) return null; + const body = text(narrative.text); + return ( +
+ + {body ? ( +

+ {body} +

+ ) : null} + + + + +
+ ); + })} + + ); +} + +function PlaybookFields({ fields }: { fields: Record }) { + const stages = recordList(fields.stages); + const research = asRecord(fields.researchInterface); + const promotion = recordList(fields.promotion); + if (stages.length === 0 && !research && promotion.length === 0) return null; + + return ( + <> + {stages.map((entry, index) => { + const stage = stageLabel(entry.stage); + if (!stage) return null; + const days = number(entry.typicalDays); + return ( +
+ + + + + + {/* Where the stage dies is the part of a playbook that gets read + twice — the sequence is obvious, the failure is not. */} + + + +
+ ); + })} + + {research ? ( +
+ + +
+ ) : null} + + {promotion.length === 0 ? null : ( +
+ [text(entry.trigger), text(entry.promote), text(entry.into)])} + /> +
+ )} + + ); +} + +// ------------------------------------------------------------------- fragments + +function Section({ + title, + aside, + icon, + children, +}: { + title: string; + aside?: string; + icon?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+

+ {icon ? {icon} : null} + {title} +

+ {aside ? {aside} : null} +
+
{children}
+
+ ); +} + +/** A labelled paragraph. Renders nothing at all when the value is absent. */ +function Note({ + label, + value, + tone, +}: { + label: string; + value: string | null; + tone?: 'warning'; +}) { + if (!value) return null; + return ( +

+ + {label} + + {value} +

+ ); +} + +function Pills({ label, items }: { label: string; items: string[] }) { + if (items.length === 0) return null; + return ( +
+ {label} +
+ {items.map((item, index) => ( + + {item} + + ))} +
+
+ ); +} + +/** + * A table whose overflow stays inside its own box. Rows are dropped when every + * cell in them is empty, so a partially-authored list does not render as a + * column of blank stripes. + */ +function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][] }) { + const present = rows.filter((row) => row.some((cell) => cell !== null)); + if (present.length === 0) return null; + + return ( +
+ + + + {head.map((heading) => ( + + ))} + + + + {present.map((row, index) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {heading} +
+ {cell} +
+
+ ); +} + +// -------------------------------------------------------------------- narrowing + +/** + * A playbook stage names a `DemandStage`, and the label comes from `@pig/core` + * so a renamed stage renames here too. An unrecognised value is shown as + * written rather than dropped: content authored ahead of an ontology change is + * still worth reading. + */ +function stageLabel(value: unknown): string | null { + const raw = text(value); + if (!raw) return null; + return DEMAND_STAGE_LABELS[raw as DemandStage] ?? raw; +} diff --git a/apps/web/src/components/motion/InstantiateDialog.tsx b/apps/web/src/components/motion/InstantiateDialog.tsx new file mode 100644 index 0000000..640d9d9 --- /dev/null +++ b/apps/web/src/components/motion/InstantiateDialog.tsx @@ -0,0 +1,263 @@ +/** + * Half of the loop: library template → engagement artefact. + * + * The picker is a list rather than a grid of `TemplateCard`s, because the + * question being answered here is not "what is in the library" but "which of + * these four is the one for this stage" — so the rows are dense, the stage + * filter opens pre-set to the deal's own stage, and usage is on every row. + * Usage is the only quality signal the library has: a v3 used eleven times is + * tested and a v1 used never is somebody's draft, and that distinction matters + * more when copying into a live deal than when browsing. + * + * Instantiating copies the body and fields and increments the template's + * usage count, which is what closes the template to in-place edits (§7a). That + * is stated on the dialog rather than left to be discovered later by whoever + * tries to fix a typo in it. + */ +import { useDeferredValue, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AlertTriangle, Library, RefreshCw, Search } from 'lucide-react'; +import { toast } from 'sonner'; +import { + DEMAND_STAGES, + DEMAND_STAGE_LABELS, + MOTION_KINDS, + MOTION_KIND_LABELS, + type DemandStage, + type MotionKind, +} from '@pig/core'; +import { get, post } from '@/lib/api'; +import { Badge, Button, EmptyState, Input, Skeleton } from '@/components/ui'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { MotionKindBadge } from './MotionKindBadge'; + +/** `GET /api/motion/templates` — summaries, so no body and no fields. */ +interface TemplateRow { + id: string; + kind: MotionKind; + slug: string; + version: number; + title: string; + summary: string; + stage: DemandStage; + visibility: 'private' | 'shared'; + isSystem: boolean; + usageCount: number; + updatedAt: string; +} + +export function InstantiateDialog({ + engagementId, + defaultStage = null, + defaultKind = null, + open, + onOpenChange, +}: { + engagementId: string; + /** The deal's stage, so the list opens on the templates that serve it. */ + defaultStage?: DemandStage | null; + /** Set when the caller is after one kind — a framework to score against. */ + defaultKind?: MotionKind | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const [kind, setKind] = useState<'all' | MotionKind>(defaultKind ?? 'all'); + const [stage, setStage] = useState<'all' | DemandStage>(defaultStage ?? 'all'); + const [query, setQuery] = useState(''); + const search = useDeferredValue(query.trim()); + + const templates = useQuery({ + queryKey: ['motion', 'library', { kind, stage, search }], + queryFn: () => { + const params = new URLSearchParams(); + if (kind !== 'all') params.set('kind', kind); + if (stage !== 'all') params.set('stage', stage); + if (search) params.set('q', search); + const suffix = params.toString(); + return get<{ templates: TemplateRow[]; truncated: boolean }>( + `/api/motion/templates${suffix ? `?${suffix}` : ''}`, + ); + }, + enabled: open, + }); + + const instantiate = useMutation({ + mutationFn: (templateId: string) => + post<{ artifact: { id: string } }>(`/api/motion/engagements/${engagementId}/artifacts`, { + templateId, + }), + onSuccess: async () => { + // The prefix invalidation is deliberate: instantiating moves the + // template's usage count too, so the library and the OS home are stale + // the moment this succeeds, not only the engagement. + await queryClient.invalidateQueries({ queryKey: ['motion'] }); + toast.success('Artefact added to the engagement'); + onOpenChange(false); + }, + onError: (error: Error) => toast.error(error.message), + }); + + const rows = templates.data?.templates ?? []; + const filtered = kind !== 'all' || stage !== 'all' || Boolean(search); + + return ( + + + + Instantiate from the library + + The body and structured fields are copied into this engagement, and the template is + closed to in-place edits from here on — later changes to it become a new version. + + + +
+
+ + setQuery(event.target.value)} + /> +
+ + +
+ +
+ {templates.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ) : null} + + {templates.isError ? ( + } + title="Library unavailable" + description={ + templates.error instanceof Error + ? templates.error.message + : 'The library could not be loaded.' + } + action={ + + } + /> + ) : null} + + {!templates.isLoading && !templates.isError && rows.length === 0 ? ( + } + title={filtered ? 'No template matches' : 'The library is empty'} + description={ + filtered + ? 'Clear a filter, or write the artefact from scratch and promote it once it has proved itself.' + : 'Nothing has been published yet. An artefact written here can be promoted into the library once it is final.' + } + /> + ) : null} + + {rows.length ? ( +
    + {rows.map((template) => ( +
  • +
    +
    + + + {DEMAND_STAGE_LABELS[template.stage]} + + + v{template.version} ·{' '} + {template.usageCount === 1 + ? 'used once' + : `used ${template.usageCount} times`} + +
    +

    + {template.title} +

    + {template.summary ? ( +

    + {template.summary} +

    + ) : null} +
    + +
  • + ))} +
+ ) : null} + + {templates.data?.truncated ? ( +

+ The library is wider than this answer. Narrow it with a kind, a stage or a search. +

+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/motion/Markdown.tsx b/apps/web/src/components/motion/Markdown.tsx new file mode 100644 index 0000000..7b5bb36 --- /dev/null +++ b/apps/web/src/components/motion/Markdown.tsx @@ -0,0 +1,203 @@ +/** + * Motion bodies, rendered as markdown. + * + * A template body is a document — a discovery guide, a playbook, a narrative — + * and the app had nowhere to render one: Piggy's transcript uses Streamdown, + * which is built for a half-finished token stream in a 22rem dock, not for a + * page of authored prose. This is the page-width counterpart, on plain + * `react-markdown` with GFM for the tables and task lists the content uses. + * + * Every element is styled from the map below. There is no + * `@tailwindcss/typography` in this repo and one is deliberately not being + * added for this, so there is no `prose` to fall back on and an element absent + * from the map renders with bare browser defaults. + * + * `rehypePlugins` is deliberately empty. Without `rehype-raw`, react-markdown + * does not render embedded HTML at all, and its default `urlTransform` already + * drops `javascript:` and other non-navigational protocols — so the safe + * behaviour here is the behaviour of adding nothing. Template bodies are + * author-written but they are also promoted out of engagement artefacts that + * anyone with `motion:write` can edit, so they are treated as untrusted. + */ +import { isValidElement, type ComponentProps, type CSSProperties, type ReactNode } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { ArrowUpRight } from 'lucide-react'; +import { cn } from '@/components/ui'; +import { CodeBlock } from './CodeBlock'; + +/** Fenced blocks carry their language as `language-` on the `code` element. */ +const LANGUAGE_CLASS = /language-([\w-]+)/; + +export function Markdown({ content, className }: { content: string; className?: string }) { + return ( +
*:first-child]:pt-0', + className, + )} + > + + {content} + +
+ ); +} + +const MARKDOWN_COMPONENTS: Components = { + p: ({ children }) =>

{children}

, + + /* + * Headings buy their air with padding, not margin — the container's + * `space-y-4` sets the gap below, and a margin above would be collapsed + * against it inconsistently. The scale stays close to body size: these are + * section headings inside a card, not the page's own title. + */ + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>
{children}
, + h6: ({ children }) => ( +
{children}
+ ), + + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + // A nested list is the first *element* child of its item even when prose + // precedes it, so the parent's `space-y` never reaches it. + li: ({ children }) =>
  • {children}
  • , + + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + del: ({ children }) => {children}, + a: MarkdownLink, + + blockquote: ({ children }) => ( +
    + {children} +
    + ), + hr: () =>
    , + + img: ({ src, alt }) => ( + // `referrerPolicy` so an image URL that arrived with a promoted artefact + // cannot use the referer to learn which template the reader had open. + {alt + ), + + /* + * The fence chrome is built entirely in `pre`, which never renders the + * `code` element react-markdown handed it — it reads the language and the + * text off it instead. That is what makes the `code` entry below reachable + * only for inline code: react-markdown 10 stopped passing an `inline` flag, + * and the usual replacement — guessing from the `language-` class — gets a + * fenced block with no language wrong every time. + */ + pre: ({ children }) => {children}, + code: ({ children }) => ( + + {children} + + ), + + table: ({ children }) => ( + // Without this the widest table on the page sets the width of the page, + // and every route scrolls sideways on a phone. +
    + {/* `w-max min-w-full`: fill the box when the table is narrow, spill into + the scroller rather than squash the columns when it is not. */} + + {children} +
    +
    + ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + // A row highlight is what lets you keep your place across a table that is + // wider than the pane and has been scrolled sideways. + tr: ({ children }) => {children}, + th: ({ children, style }) => ( + + {children} + + ), + // No `nums` here, deliberately: tabular figures on every cell render an + // author's prose column in the digit-width of a ledger. + td: ({ children, style }) => ( + + {children} + + ), +}; + +/** + * Links in a template body point out of PIG — a vendor's docs, a paper, a + * customer's status page — so they open in a new tab rather than navigating a + * workspace someone has unsaved edits in, carry no referer, and wear a marker + * glyph so a plausible phrase cannot pass itself off as internal navigation. + */ +function MarkdownLink({ href, children }: ComponentProps<'a'>) { + return ( + + {children} + + + ); +} + +/** + * A fenced code block, built from the `code` element rather than around it. + * + * The chrome — the language caption and the copy button — comes from + * `CodeBlock`, so a mermaid fence inside a promoted body offers the same copy + * action a mermaid field does in `FieldsView`. They are the same thing to the + * person reading them. + */ +function CodeFence({ children }: { children: ReactNode }) { + const element = isValidElement<{ className?: string; children?: ReactNode }>(children) + ? children + : null; + const language = LANGUAGE_CLASS.exec(element?.props.className ?? '')?.[1]; + const text = codeText(element ? element.props.children : children); + + return ; +} + +/** + * GFM column alignment — the `---:` in a delimiter row — is the one piece of + * element styling the markdown itself owns, and a currency column that + * silently reverts to the left is the difference between a readable table and + * a wall. It reaches the cell as `style.textAlign`; only that property is + * taken, so nothing else an author writes can style the page. + */ +function alignStyle(style: CSSProperties | undefined): CSSProperties | undefined { + const value = style?.textAlign; + return value === 'right' || value === 'center' || value === 'left' ? { textAlign: value } : undefined; +} + +/** The fence body reaches us as React children, normally one text node deep. */ +function codeText(children: ReactNode): string { + if (typeof children === 'string') return children; + if (Array.isArray(children)) return (children as ReactNode[]).map(codeText).join(''); + if (isValidElement<{ children?: ReactNode }>(children)) return codeText(children.props.children); + return ''; +} diff --git a/apps/web/src/components/motion/MotionKindBadge.tsx b/apps/web/src/components/motion/MotionKindBadge.tsx new file mode 100644 index 0000000..c124149 --- /dev/null +++ b/apps/web/src/components/motion/MotionKindBadge.tsx @@ -0,0 +1,62 @@ +/** + * Which of the nine kinds a template or artefact is. + * + * The tone is neutral for every kind, deliberately. The badge palette here is + * semantic — positive, warning, danger, info mean something about a number — + * and the kinds are a taxonomy, not a severity scale; colouring them would + * teach people to read "case study" as good news. The differentiation comes + * from the glyph instead, which is what lets a mixed list of artefacts be + * scanned by shape rather than read word by word. + */ +import { + BookOpen, + FileText, + FlaskConical, + Gauge, + MessagesSquare, + Network, + Route, + Search, + Tags, + type LucideIcon, +} from 'lucide-react'; +import { MOTION_KIND_DESCRIPTIONS, MOTION_KIND_LABELS, type MotionKind } from '@pig/core'; +import { Badge, cn } from '@/components/ui'; + +const KIND_ICONS: Record = { + discovery: Search, + qualification: Gauge, + poc: FlaskConical, + proposal: FileText, + pricing: Tags, + architecture: Network, + case_study: BookOpen, + narrative: MessagesSquare, + playbook: Route, +}; + +export function MotionKindBadge({ + kind, + /** Glyph only, for a dense row where the label is already in the title. */ + iconOnly = false, + className, +}: { + kind: MotionKind; + iconOnly?: boolean; + className?: string; +}) { + const Icon = KIND_ICONS[kind]; + const label = MOTION_KIND_LABELS[kind]; + return ( + + + {iconOnly ? {label} : {label}} + + ); +} diff --git a/apps/web/src/components/motion/QualificationScorer.tsx b/apps/web/src/components/motion/QualificationScorer.tsx new file mode 100644 index 0000000..4e1468d --- /dev/null +++ b/apps/web/src/components/motion/QualificationScorer.tsx @@ -0,0 +1,287 @@ +/** + * Scoring a deal against a qualification framework. + * + * The arithmetic is `motionScoreBasisPoints` from `@pig/core` — the same pure + * function the API calls before it writes the row. That import is the whole + * design of this component: a client that computes the total itself would + * eventually disagree with the server about a number people act on, and a + * qualification score that reads 74% while you are filling the form in and + * 71% once it is saved is worse than one the form never showed at all. + * Nothing here posts a score; it posts the dimensions and lets the server + * recompute, so the two can never diverge even if this file is wrong. + * + * Every dimension must be answered before Save enables. A partial score is not + * a smaller score — the maximum shrinks with it, so answering only the three + * dimensions that went well produces a band of "Strategic" on a deal nobody + * has qualified. The running total shown while the form is incomplete is + * labelled provisional for exactly that reason. + * + * `fields` is authored JSON and arrives unvalidated, the same way it does in + * `FieldsView`: a dimension without an id cannot be posted (the API keys on + * it) and a dimension without anchors cannot be scored honestly, so both are + * dropped rather than rendered as an empty control. + */ +import { useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'; +import { toast } from 'sonner'; +import { + MOTION_MAX_DIMENSION_SCORE, + MOTION_MIN_DIMENSION_SCORE, + motionBand, + motionScoreBasisPoints, + type MotionDimensionScore, +} from '@pig/core'; +import { post } from '@/lib/api'; +import { Badge, Button, cn } from '@/components/ui'; +import { Textarea } from '@/components/ui/textarea'; +import { anchorScale, asRecord, number, text, type AnchorRow } from './fields'; +import { percent } from './format'; + +/** A dimension this component was able to make sense of. */ +interface ScorableDimension { + id: string; + name: string; + weight: number; + group: string | null; + why: string | null; + anchors: AnchorRow[]; +} + +export function QualificationScorer({ + engagementId, + frameworkTemplateId, + fields, + previousBasisPoints = null, + onScored, + onCancel, +}: { + engagementId: string; + /** Recorded on the score so the framework behind it stays identifiable. */ + frameworkTemplateId: string | null; + /** The framework's `fields`. Untrusted author JSON — see the header. */ + fields: unknown; + /** The last score on this engagement, if there is one, for the movement. */ + previousBasisPoints?: number | null; + onScored: () => void; + onCancel: () => void; +}) { + const queryClient = useQueryClient(); + const dimensions = useMemo(() => scorableDimensions(fields), [fields]); + const [answers, setAnswers] = useState>({}); + const [note, setNote] = useState(''); + + const answered: MotionDimensionScore[] = dimensions + .filter((dimension) => answers[dimension.id] !== undefined) + .map((dimension) => ({ + id: dimension.id, + weight: dimension.weight, + score: answers[dimension.id] as number, + })); + const complete = dimensions.length > 0 && answered.length === dimensions.length; + const basisPoints = motionScoreBasisPoints(answered); + const band = motionBand(basisPoints); + const movement = + complete && previousBasisPoints !== null ? basisPoints - previousBasisPoints : null; + + const save = useMutation({ + mutationFn: () => + post<{ score: { id: string } }>(`/api/motion/engagements/${engagementId}/scores`, { + dimensions: answered, + frameworkTemplateId, + note: note.trim() ? note.trim() : null, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['motion'] }); + toast.success('Qualification scored'); + onScored(); + }, + onError: (error: Error) => toast.error(error.message), + }); + + if (dimensions.length === 0) { + return ( +
    +

    This framework has no scorable dimensions

    +

    + A dimension needs an id, a name and at least one anchor before it can be scored. Fix + the framework in the library, then score against a new version of it. +

    + +
    + ); + } + + return ( +
    { + event.preventDefault(); + save.mutate(); + }} + > + {/* Sticky, because the reason to show a running total at all is to let + somebody see a marginal answer move the band while they are still + looking at the anchors that produced it. */} +
    +
    + {percent(basisPoints)} + {band.label} + {movement === null ? null : } +
    +

    + {complete ? ( + <> + Weighted across {dimensions.length} dimensions ·{' '} + {basisPoints} basis points + + ) : ( + <> + Provisional — {answered.length} of {dimensions.length} scored. The maximum shrinks + with the dimensions you leave blank, so this band is not the deal's band yet. + + )} +

    +
    + +
    + {dimensions.map((dimension) => ( +
    + + {dimension.name} + {dimension.group ? {dimension.group} : null} + + {dimension.weight === 0 ? 'no weight' : `weight ${dimension.weight}`} + + + {dimension.why ? ( +

    {dimension.why}

    + ) : null} +
    + {dimension.anchors.map((anchor) => { + const selected = answers[dimension.id] === anchor.score; + return ( + + ); + })} +
    +
    + ))} +
    + +
    + +