Fix twenty findings from the Motion review
CI / verify (push) Successful in 4m47s
CI / publish (push) Failing after 3s

Each was raised by a reviewer and then survived an independent attempt to
refute it. The four that mattered most:

- A third of the starter library was invisible. Three templates authored
  `fields` shapes no renderer read — decisions, blockingSet, checks,
  steps and the rest — so about forty records rendered as no DOM at all,
  in the library and again on the engagement that instantiated them.
  Nothing failed: a renderer returns null for a key set it does not
  recognise, and a header-plus-body page looks like a template written
  that way. FieldsView now reads every key the seeds carry.
- "Add a framework" opened a picker that could never match, because the
  dialog was seeded with both the forced kind and the deal's stage, and
  qualification serves only the qualification stage. The stage is now
  dropped when MOTION_KIND_STAGES says the pair is incoherent.
- Piggy reported the promotion count as an exact figure capped at 8,
  against a tile showing the true count beside it. It is now counted in
  SQL, and all three motion tools carry a ResultScope whose denominator
  is shared lineages — never rows, never private drafts.
- No Motion test went through createApp, so the whole feature could be
  unmounted with a green suite. That is the AGENTS.md §5 trap that
  already cost this project read-guards.ts and learn.ts.

Also: both sides of the instantiate/edit race now lock, so a template
cannot be rewritten under an artefact that has copied it; concurrent
engagement opens queue on the deal row and get the 409 the handler
already promised rather than a 500; latestScore uses DISTINCT ON instead
of losing engagements past a 200-row cap; the migration adds the
scored_by_user_id foreign key the schema declares; and the demo clear
refunds usage_count for engagements it reaches by cascade, which
otherwise left starter templates permanently un-editable.

Verified on a fresh database: 16 migrations apply and re-apply as a
no-op, both seeds idempotent, usage_count back to zero after --clear.
564 unit tests pass. Every Motion route measures zero horizontal
overflow at 393 and 1440 in both themes, and all twelve seeded field
trees are asserted onto the screen by scripts/motion-fields-check.mjs.

One thing left open deliberately: the shipped qualification scorecard's
five bands and MOTION_BANDS' four are calibrated differently. The
framework's table is now titled as its own guidance rather than the
product's verdict, which removes the contradiction on screen. Making the
framework's calibration authoritative over the persisted band column is
a product decision nobody has made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:08:55 -07:00
parent 376ef3d597
commit 15c72ade1c
16 changed files with 1281 additions and 66 deletions
+2 -2
View File
@@ -46,8 +46,8 @@ docs/ ontology.md, motion.md, build-plan.md, agents.md, seed-data.md
deploy/ README.md (deployment), Caddyfile example, autodeploy units deploy/ README.md (deployment), Caddyfile example, autodeploy units
``` ```
~45,000 lines including tests. 547 unit tests across five packages ~45,000 lines including tests. 564 unit tests across five packages
(core 78, prime 24, api 268, piggy 172, cli 5), plus E2E suites under (core 78, prime 24, api 276, piggy 181, cli 5), plus E2E suites under
`apps/api/e2e` and `apps/piggy/e2e` that need a database — and, for one Piggy `apps/api/e2e` and `apps/piggy/e2e` that need a database — and, for one Piggy
case, a key. Node 22+. case, a key. Node 22+.
+39 -3
View File
@@ -42,7 +42,7 @@ import type {
MotionTemplate, MotionTemplate,
QualificationScore, QualificationScore,
} from '@pig/db'; } from '@pig/db';
import { demandDeals, engagementArtifacts, engagements, motionTemplates } from '@pig/db'; import { demandDeals, engagementArtifacts, engagements, motionTemplates, users } from '@pig/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
@@ -62,6 +62,7 @@ import {
loadEngagement, loadEngagement,
loadTemplateForWrite, loadTemplateForWrite,
lockNewestVersion, lockNewestVersion,
lockTemplateForWrite,
motionSlug, motionSlug,
MotionService, MotionService,
newestVisibleInLineage, newestVisibleInLineage,
@@ -247,6 +248,27 @@ async function checkedTemplateId(
await loadTemplateForWrite(tx, viewerOf(principal), id); await loadTemplateForWrite(tx, viewerOf(principal), id);
} }
/**
* An owner id somebody sent, checked before it is stored.
*
* `owner_user_id` is a foreign key with nothing in front of it, so an id for a
* user who has since been removed leaves as a 500 from the constraint rather
* than the 404 every other reference here answers with. Only ever the id the
* client actually supplied: an engagement created without one defaults to the
* caller, and re-reading a user the request has just authenticated would be a
* query bought with nothing — the same decision `calendar.ts` makes, and one a
* test there pins. It also leaves an explicit `ownerUserId: null` — unassigning
* — as the no-op it is.
*/
async function checkedOwnerUserId(
tx: MotionTransaction,
id: string | null | undefined,
): Promise<void> {
if (!id) return;
const [owner] = await tx.select({ id: users.id }).from(users).where(eq(users.id, id)).limit(1);
if (!owner) throw MutationError.notFound('User');
}
/** The same gate as `authorizePublish`, applied once the body says `shared`. */ /** The same gate as `authorizePublish`, applied once the body says `shared`. */
function requirePublishFor(principal: Principal, visibility: string | undefined): void { function requirePublishFor(principal: Principal, visibility: string | undefined): void {
if (visibility === 'shared') requireAnyTeamCapability(principal, 'motion:publish'); if (visibility === 'shared') requireAnyTeamCapability(principal, 'motion:publish');
@@ -365,7 +387,11 @@ export function motionTemplateUpdateDefinition(): MutationDefinition<
invalidMessage: 'Invalid motion template change.', invalidMessage: 'Invalid motion template change.',
async mutate({ input, params, principal, tx, now }) { async mutate({ input, params, principal, tx, now }) {
const viewer = viewerOf(principal); const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite( // Locked, so the `usage_count` that §7a is about to be judged on is the
// one belonging to the row this transaction goes on to write. Read
// without the lock it is a count that a concurrent instantiation can
// move between the check and the UPDATE.
const existing = await lockTemplateForWrite(
tx, tx,
viewer, viewer,
requiredId(params, 'Motion template'), requiredId(params, 'Motion template'),
@@ -581,13 +607,22 @@ export function motionEngagementCreateDefinition(): MutationDefinition<
permission: authorizeWrite, permission: authorizeWrite,
invalidMessage: 'Invalid engagement.', invalidMessage: 'Invalid engagement.',
async mutate({ input, principal, tx, now }) { async mutate({ input, principal, tx, now }) {
// Locked, because the existence check below is only worth making if it
// cannot be raced. Two simultaneous opens of one deal both saw nothing,
// both inserted, and the loser got `23505` on
// `engagements_demand_deal_key` — not a `MutationError`, so a 500 with
// no way to tell that an engagement now exists. Concurrent opens queue
// on this row instead, and the loser's check sees the committed
// engagement and answers with its id, as the comment below promises.
const [deal] = await tx const [deal] = await tx
.select() .select()
.from(demandDeals) .from(demandDeals)
.where(eq(demandDeals.id, input.demandDealId)) .where(eq(demandDeals.id, input.demandDealId))
.limit(1); .limit(1)
.for('update');
if (!deal) throw MutationError.notFound('Demand deal'); if (!deal) throw MutationError.notFound('Demand deal');
await checkedTemplateId(tx, principal, input.playbookTemplateId); await checkedTemplateId(tx, principal, input.playbookTemplateId);
await checkedOwnerUserId(tx, input.ownerUserId);
// Checked rather than left to the unique constraint, so the caller gets // Checked rather than left to the unique constraint, so the caller gets
// the id of the engagement that already exists instead of a 500. // the id of the engagement that already exists instead of a 500.
@@ -643,6 +678,7 @@ export function motionEngagementUpdateDefinition(): MutationDefinition<
async mutate({ input, params, principal, tx, now }) { async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement')); const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
await checkedTemplateId(tx, principal, input.playbookTemplateId); await checkedTemplateId(tx, principal, input.playbookTemplateId);
await checkedOwnerUserId(tx, input.ownerUserId);
const [updated] = await tx const [updated] = await tx
.update(engagements) .update(engagements)
+61 -11
View File
@@ -692,22 +692,28 @@ export class MotionService {
), ),
) )
.groupBy(engagementArtifacts.engagementId), .groupBy(engagementArtifacts.engagementId),
// Newest first, then the first sighting of each engagement wins. A // `DISTINCT ON`, because folding a `scored_at desc` scan in memory drops
// `DISTINCT ON` would be tidier but this stays one bounded query and the // engagements rather than truncating a list. A hundred engagements
// history panel needs the same ordering anyway. // carrying three scores each can spend the whole 200-row budget on the
// sixty most recently scored, and every other row then renders "never
// scored" beside an exact artefact count, with nothing anywhere saying
// so. Postgres requires the ORDER BY to lead with the distinct
// expression, which is why `engagement_id` comes first; that is also the
// leading column of `qualification_scores_engagement_idx`, so this reads
// straight off the index. The result is bounded by `ids.length` and the
// limit is left as a cap rather than a budget.
this.db this.db
.select() .selectDistinctOn([qualificationScores.engagementId])
.from(qualificationScores) .from(qualificationScores)
.where(inArray(qualificationScores.engagementId, ids)) .where(inArray(qualificationScores.engagementId, ids))
.orderBy(desc(qualificationScores.scoredAt)) .orderBy(qualificationScores.engagementId, desc(qualificationScores.scoredAt))
.limit(SCORE_LIMIT), .limit(SCORE_LIMIT),
]); ]);
const countById = new Map(counts.map((row) => [row.engagementId, row.total])); const countById = new Map(counts.map((row) => [row.engagementId, row.total]));
const latest = new Map<string, QualificationScore>(); const latest = new Map<string, QualificationScore>(
for (const score of scores) { scores.map((score) => [score.engagementId, score]),
if (!latest.has(score.engagementId)) latest.set(score.engagementId, score); );
}
return rows.map(({ engagement, dealName, stage, accountId, accountName }) => ({ return rows.map(({ engagement, dealName, stage, accountId, accountName }) => ({
id: engagement.id, id: engagement.id,
@@ -759,11 +765,52 @@ export async function loadTemplateForWrite(
viewer: MotionViewer, viewer: MotionViewer,
id: string, id: string,
): Promise<MotionTemplate> { ): Promise<MotionTemplate> {
const [row] = await tx return templateForWrite(tx, viewer, id, false);
}
/**
* The same read, holding the row until this transaction commits.
*
* `usage_count` is the whole of §7a, and under READ COMMITTED reading it
* without the lock is a decision taken about a row somebody else is already
* changing. Two requests against a template at zero: an instantiation copies
* the body and increments the count, while a PATCH that read zero a moment
* earlier blocks on the row lock and then applies anyway leaving an artefact
* whose `template_id` names a template that no longer contains what it copied,
* which is the provenance failure the rule exists to prevent.
*
* Both sides therefore take this lock, and locking only the edit does not
* work: the copy has to hold the row until its increment commits, or it is
* still reading a body that is about to change. Whichever transaction locks
* first serialises the other, and a blocked locking read re-fetches the
* committed tuple so the edit honestly sees `usage_count = 1` and answers
* `409 template_in_use`, or the instantiation honestly copies the edited body.
*
* `no key update` rather than `update`, because the row is a foreign-key
* target `engagement_artifacts.template_id` and nothing here is a reason
* to block an insert that merely references it.
*/
export async function lockTemplateForWrite(
tx: MotionTransaction,
viewer: MotionViewer,
id: string,
): Promise<MotionTemplate> {
return templateForWrite(tx, viewer, id, true);
}
/** One predicate for both, so the lock cannot drift away from the read filter. */
async function templateForWrite(
tx: MotionTransaction,
viewer: MotionViewer,
id: string,
lock: boolean,
): Promise<MotionTemplate> {
const query = tx
.select() .select()
.from(motionTemplates) .from(motionTemplates)
.where(and(eq(motionTemplates.id, id), visibleTemplates(viewer))) .where(and(eq(motionTemplates.id, id), visibleTemplates(viewer)))
.limit(1); .limit(1);
const [row] = lock ? await query.for('no key update') : await query;
if (!row) throw MutationError.notFound('Motion template'); if (!row) throw MutationError.notFound('Motion template');
return row; return row;
} }
@@ -905,8 +952,11 @@ export async function instantiateArtifact(
input: InstantiateInput, input: InstantiateInput,
now: Date, now: Date,
): Promise<{ artifact: EngagementArtifact; template: MotionTemplate | null }> { ): Promise<{ artifact: EngagementArtifact; template: MotionTemplate | null }> {
// Locked, not merely read: the body copied below has to be the body the
// `usage_count` increment at the end of this function is counting, or a
// concurrent edit rewrites the template between the two.
const template = input.templateId const template = input.templateId
? await loadTemplateForWrite(tx, viewer, input.templateId) ? await lockTemplateForWrite(tx, viewer, input.templateId)
: null; : null;
// Every read path hides an archived template, so instantiating one can only // 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 // happen by holding an id from before it was archived — and it would leave
+164 -1
View File
@@ -17,6 +17,11 @@
* queries in call order and records what ran; it is deliberately not a * 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 * database, because a fake that pretends to run SQL is a fake that will one day
* assert a broken query works. * assert a broken query works.
*
* That fake is also why the last suite goes through `createApp` instead. A
* definition driven directly, or a route mounted by this file's own `mounted`,
* passes whether or not `app.ts` ever calls `createMotionRoutes` the state
* `read-guards.ts` and `learn.ts` were both in while their tests were green.
*/ */
import { strict as assert } from 'node:assert'; import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
@@ -24,8 +29,12 @@ import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core'; import { PgDialect } from 'drizzle-orm/pg-core';
import { motionScoreBasisPoints } from '@pig/core'; import { motionScoreBasisPoints } from '@pig/core';
import type { Database, MotionTemplate } from '@pig/db'; import type { Database, MotionTemplate } from '@pig/db';
import { teamMemberships, users } from '@pig/db';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { createApp } from '../src/app';
import { AuthError, type Principal } from '../src/lib/auth'; import { AuthError, type Principal } from '../src/lib/auth';
import type { AuthProvider } from '../src/lib/auth-provider';
import { loadConfig } from '../src/lib/config';
import { executeMutation, MutationError, type ApiEnv } from '../src/lib/mutation'; import { executeMutation, MutationError, type ApiEnv } from '../src/lib/mutation';
import { import {
createMotionRoutes, createMotionRoutes,
@@ -134,7 +143,13 @@ function database(script: unknown[][]): { db: Database; log: Recorded } {
// can follow it, as it does on every query in this feature that allocates // 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. // a version number. The chain is a thenable, so `await` still ends it.
limit: () => selectChain, limit: () => selectChain,
for: () => selectChain, // Recorded, not merely tolerated: two of the rules here are held shut by a
// row lock, and a lock that quietly stops being taken changes nothing a
// behavioural assertion can see.
for: (strength: string) => {
log.events.push(`for:${strength}`);
return selectChain;
},
then: (resolve: (rows: unknown[]) => unknown) => resolve(next()), then: (resolve: (rows: unknown[]) => unknown) => resolve(next()),
}; };
@@ -281,6 +296,26 @@ describe('a used template is never edited in place', () => {
assert.equal(log.updated[0]?.values.title, 'POC plan, tightened'); assert.equal(log.updated[0]?.values.title, 'POC plan, tightened');
}); });
it('locks the row before it trusts usage_count, so a concurrent instantiation cannot be missed', async () => {
const { db, log } = database([[template({ usageCount: 0 })]]);
await executeMutation(
db,
member,
async () => ({ body: '# Tightened' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
);
// Read without the lock, `usage_count` is a number another transaction is
// already moving: an instantiation copies the body and increments the
// count while this PATCH, having seen zero, waits on the row and then
// rewrites the body anyway — leaving an artefact whose `template_id` names
// a template that no longer contains what it copied. `no key update`
// rather than `update` because the row is a foreign-key target.
assert.deepEqual(log.events.slice(0, 3), ['transaction', 'select', 'for:no key update']);
});
it('refuses an edit to somebody else\'s template even when it is shared — publishing is not donating', async () => { 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 })]]); const { db, log } = database([[template({ visibility: 'shared', ownerUserId: OTHER })]]);
@@ -661,6 +696,30 @@ describe('instantiating a template', () => {
// back into the library. // back into the library.
assert.equal(log.inserted[0]?.row.body, template().body); assert.equal(log.inserted[0]?.row.body, template().body);
}); });
it('holds the template row while it copies the body, not only while it counts the use', async () => {
const { db, log } = database([
[{ engagement, deal }],
[template({ visibility: 'shared', ownerUserId: OTHER })],
]);
await executeMutation(
db,
member,
async () => ({ templateId: TEMPLATE_ID }),
motionArtifactCreateDefinition(),
{ id: ENGAGEMENT_ID },
);
// Locking only the PATCH does not close the race: the copy has to hold the
// row until its own increment commits, or an edit lands between the read
// that copied the body and the count that was supposed to have shut the
// template to edits.
assert.deepEqual(
log.events.slice(0, 4),
['transaction', 'select', 'select', 'for:no key update'],
);
});
}); });
describe('one engagement per deal', () => { describe('one engagement per deal', () => {
@@ -682,6 +741,27 @@ describe('one engagement per deal', () => {
); );
assert.deepEqual(log.inserted, []); assert.deepEqual(log.inserted, []);
}); });
it('locks the deal before it looks, so two simultaneous opens queue instead of both inserting', async () => {
const { db, log } = database([[deal], []]);
await executeMutation(
db,
member,
async () => ({ demandDealId: DEAL_ID }),
motionEngagementCreateDefinition(),
);
// The race is not expressible against this fake — it runs no SQL and has
// no concurrency — so what is pinned is the lock that removes it. Without
// it both requests' checks above see nothing, both insert, and the loser
// gets `23505` on `engagements_demand_deal_key`, which is not a
// `MutationError` and so leaves as `500 Internal error` with no way to
// tell that an engagement now exists. The lock is taken on the deal
// because the row the loser must wait behind is the engagement that does
// not exist yet.
assert.deepEqual(log.events.slice(0, 3), ['transaction', 'select', 'for:update']);
});
}); });
describe('qualification scores', () => { describe('qualification scores', () => {
@@ -784,3 +864,86 @@ describe('an id that cannot name a row is a row that does not exist', () => {
assert.deepEqual(log.events, ['transaction'], 'refused on shape, without a query'); assert.deepEqual(log.events, ['transaction'], 'refused on shape, without a query');
}); });
}); });
// --------------------------------------------------- the mount, not the mock
const SUBJECT = 'motion-auth-subject';
/**
* The whole app, with the cheapest database that can carry an authenticated
* request through it the fixture `http-auth.test.ts` uses, which answers by
* table identity rather than in call order because the queries `loadPrincipal`
* and the overview make are an implementation detail.
*/
function createdApp(): ReturnType<typeof createApp> {
const user = {
id: OWNER,
email: 'seller@example.com',
name: 'Seller',
authSubject: SUBJECT,
deactivatedAt: null,
isPlatformAdmin: false,
};
function chain(rows: unknown[]): Record<string, unknown> {
const self: Record<string, unknown> = {
leftJoin: () => self,
innerJoin: () => self,
where: () => self,
orderBy: () => self,
groupBy: () => self,
limit: () => self,
then: (resolve: (value: unknown[]) => unknown) => resolve(rows),
};
return self;
}
const db = {
select: () => ({
from: (table: unknown) => {
if (table === users) return chain([user]);
if (table === teamMemberships) return chain([{ team: 'demand', role: 'member' }]);
return chain([]);
},
}),
update: () => ({ set: () => ({ where: async () => undefined }) }),
transaction: async (work: (tx: unknown) => Promise<unknown>) => work({}),
} as unknown as Database;
const provider: AuthProvider = {
name: 'test',
async verifyAccessToken(token: string) {
if (token !== 'good-token') throw new Error('bad token');
return { subject: SUBJECT, email: user.email };
},
};
// A real `loadConfig`, for the reason `http-auth.test.ts` gives: a hand-built
// Config object would let this pass under one the server refuses to start on.
const config = loadConfig({
NODE_ENV: 'test',
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig-not-connected',
PIG_PUBLIC_URL: 'http://localhost:8920',
PIG_ADMIN_EMAILS: '',
} as NodeJS.ProcessEnv);
return createApp(config, db, provider);
}
describe('the routes are mounted in app.ts, not only in this file', () => {
it('answers a member holding book:read on GET /api/motion', async () => {
// AGENTS.md §5, verbatim: every other test here mounts the factory itself,
// so all twelve routes could be dropped from `app.ts` and this file would
// stay green. The request is authenticated deliberately — `app.use('/api/*')`
// authenticates ahead of every feature route, so an anonymous GET answers
// 401 whether or not anything is mounted behind it, and the same assertion
// would pass against a feature that had been deleted outright.
const response = await createdApp().request('/api/motion', {
headers: { authorization: 'Bearer good-token' },
});
assert.notEqual(response.status, 404, 'createMotionRoutes is not mounted in createApp');
assert.notEqual(response.status, 401, 'the request never reached the motion handler');
assert.notEqual(response.status, 403, 'the read guard answered, so the mount is untested');
});
});
+130 -19
View File
@@ -62,6 +62,7 @@ import { CalendarService } from '@pig/api/src/services/calendar';
import { import {
and, and,
count, count,
countDistinct,
desc, desc,
eq, eq,
gte, gte,
@@ -210,6 +211,16 @@ const SUPPLY_DEALS_LABEL = 'supply deal(s) on the book';
const ACCOUNTS_LABEL = 'account(s) on the book'; const ACCOUNTS_LABEL = 'account(s) on the book';
const CONTACTS_LABEL = 'contact(s) in the CRM'; const CONTACTS_LABEL = 'contact(s) in the CRM';
/**
* The denominators the motion tools are drawn from.
*
* The library one says `shared` and says `lineage` because both are real
* restrictions on the figure: private drafts are outside it by design, and a
* lineage is one piece of practice however many versions it has carried.
*/
const MOTION_LIBRARY_LABEL = 'shared template lineage(s) in the motion library';
const ENGAGEMENTS_LABEL = 'engagement(s) on the book';
/** One whole-table count, for use as a denominator. */ /** One whole-table count, for use as a denominator. */
function rowCount(rows: readonly { value: number }[]): number { function rowCount(rows: readonly { value: number }[]): number {
return rows[0]?.value ?? 0; return rows[0]?.value ?? 0;
@@ -1086,7 +1097,12 @@ function countBy<Row>(rows: readonly Row[], key: (row: Row) => string): Record<s
* closed stages are excluded throughout a won deal has left the motion. * closed stages are excluded throughout a won deal has left the motion.
*/ */
async function readMotionSummary(db: Database): Promise<unknown> { async function readMotionSummary(db: Database): Promise<unknown> {
const [libraryRead, engagementRead, promotions] = await Promise.all([ // One predicate for the exemplars and for the count below them, so the list
// and the figure cannot come to describe different sets. It is also the
// predicate the /motion tile counts: `motionLibraryWhere({})` already excludes
// archived rows, and promotion always writes `visibility: 'shared'`.
const promoted = and(motionLibraryWhere({}), isNotNull(motionTemplates.originArtifactId))!;
const [libraryRead, engagementRead, promotions, promotionTotal] = await Promise.all([
db db
.select({ .select({
slug: motionTemplates.slug, slug: motionTemplates.slug,
@@ -1114,13 +1130,21 @@ async function readMotionSummary(db: Database): Promise<unknown> {
createdAt: motionTemplates.createdAt, createdAt: motionTemplates.createdAt,
}) })
.from(motionTemplates) .from(motionTemplates)
.where(and(motionLibraryWhere({}), isNotNull(motionTemplates.originArtifactId))) .where(promoted)
.orderBy(desc(motionTemplates.createdAt)) .orderBy(desc(motionTemplates.createdAt))
.limit(EXEMPLARS), .limit(EXEMPLARS),
// Counted in SQL rather than read off the list above it, which is capped at
// EXEMPLARS. Promotions is the one figure whose job is to show the loop
// closing, so it is the one figure that must not stop moving: read off the
// list it would say 8 the moment the loop started working, for ever, beside
// a tile counting 12 exactly. Versions rather than lineages, like the tile —
// a second promotion into one lineage is a second time the loop closed.
db.select({ value: count() }).from(motionTemplates).where(promoted),
]); ]);
const { rows: templateRows, truncated: libraryTruncated } = bounded(libraryRead); const { rows: templateRows, truncated: libraryTruncated } = bounded(libraryRead);
const { rows: openEngagements, truncated: engagementsTruncated } = bounded(engagementRead); const { rows: openEngagements, truncated: engagementsTruncated } = bounded(engagementRead);
const truncated = libraryTruncated || engagementsTruncated; const truncated = libraryTruncated || engagementsTruncated;
const promotionCount = rowCount(promotionTotal);
const lineages = newestPerSlug(templateRows); const lineages = newestPerSlug(templateRows);
const engagementsByStage = countBy(openEngagements, (row) => row.stage); const engagementsByStage = countBy(openEngagements, (row) => row.stage);
@@ -1133,21 +1157,55 @@ async function readMotionSummary(db: Database): Promise<unknown> {
`${atLeast(lineages.length, libraryTruncated)} shared template(s) across ` + `${atLeast(lineages.length, libraryTruncated)} shared template(s) across ` +
`${Object.keys(countBy(lineages, (row) => row.kind)).length} of ${MOTION_KINDS.length} ` + `${Object.keys(countBy(lineages, (row) => row.kind)).length} of ${MOTION_KINDS.length} ` +
`kind(s), and ${atLeast(openEngagements.length, engagementsTruncated)} open engagement(s). ` + `kind(s), and ${atLeast(openEngagements.length, engagementsTruncated)} open engagement(s). ` +
(uncovered.length === 0 (libraryTruncated
? 'Which stages the library misses cannot be told from this scan, because it hit its ' +
'row cap. '
: uncovered.length === 0
? 'Every live demand stage has at least one shared template. ' ? 'Every live demand stage has at least one shared template. '
: `No shared template covers ${uncovered.join(', ')}. `) + : `No shared template covers ${uncovered.join(', ')}. `) +
` ${promotions.length} artifact(s) promoted back into the library recently.` + `${promotionCount} artifact(s) promoted back into the library in all` +
(promotions.length ? `, newest ${promotions.length} listed.` : '.') +
(truncated ? ` ${TRUNCATION_NOTE}` : ''), (truncated ? ` ${TRUNCATION_NOTE}` : ''),
/** Unfiltered within the shared library, so this read IS its own denominator. */
scope: resultScope({
covers: 'are shared and not archived',
matched: lineages.length,
total: lineages.length,
totalLabel: MOTION_LIBRARY_LABEL,
listed: 0,
truncated: libraryTruncated,
}),
truncated, truncated,
sharedTemplates: lineages.length, sharedTemplates: lineages.length,
openEngagements: openEngagements.length, openEngagements: openEngagements.length,
uncoveredStages: uncovered, /**
* Null rather than a list when the scan was capped. "No shared template
* covers procurement" is a definite negative, and a definite negative drawn
* from part of the library is a claim the read cannot support the stage
* may well be covered by a lineage beyond the cap. An exact answer under
* truncation needs a per-slug newest-version aggregate in SQL, which is a
* larger change than this figure is worth.
*/
uncoveredStages: libraryTruncated ? null : uncovered,
stages: DEMAND_OPEN_STAGES.map((stage) => ({ stages: DEMAND_OPEN_STAGES.map((stage) => ({
stage, stage,
openEngagements: engagementsByStage[stage] ?? 0, openEngagements: engagementsByStage[stage] ?? 0,
sharedTemplates: lineages.filter((template) => template.stage === stage).length, sharedTemplates: lineages.filter((template) => template.stage === stage).length,
})), })),
libraryByKind: countBy(lineages, (row) => row.kind), libraryByKind: countBy(lineages, (row) => row.kind),
promotions: promotionCount,
/**
* The exact figure sits beside the list on purpose. A model handed eight
* rows and no total reads the length of the list as the count, which is the
* mistake `ResultScope` exists for.
*/
recentPromotionsScope: resultScope({
covers: 'were promoted back into the library from an engagement artefact',
matched: promotionCount,
total: promotionCount,
totalLabel: 'promoted template version(s) in the motion library',
listed: promotions.length,
}),
// The loop made visible: what the last few engagements gave back. // The loop made visible: what the last few engagements gave back.
recentPromotions: promotions.map((row) => ({ recentPromotions: promotions.map((row) => ({
title: row.title, title: row.title,
@@ -1159,8 +1217,8 @@ async function readMotionSummary(db: Database): Promise<unknown> {
} }
async function readMotionLibrary(db: Database, filter: MotionLibraryFilter): Promise<unknown> { async function readMotionLibrary(db: Database, filter: MotionLibraryFilter): Promise<unknown> {
const { rows, truncated } = bounded( const [libraryRead, libraryTotal] = await Promise.all([
await db db
.select({ .select({
slug: motionTemplates.slug, slug: motionTemplates.slug,
kind: motionTemplates.kind, kind: motionTemplates.kind,
@@ -1174,25 +1232,60 @@ async function readMotionLibrary(db: Database, filter: MotionLibraryFilter): Pro
.where(motionLibraryWhere(filter)) .where(motionLibraryWhere(filter))
.orderBy(desc(motionTemplates.updatedAt)) .orderBy(desc(motionTemplates.updatedAt))
.limit(SCAN_LIMIT + 1), .limit(SCAN_LIMIT + 1),
); /**
* The denominator, and both halves of it are decisions.
*
* `motionLibraryWhere({})` rather than a bare `count()` over the table: a
* total that included private rows would publish the existence and the size
* of colleagues' drafts through the back door, which is the one thing the
* library rule exists to withhold a denominator leaks as readily as a
* list. And `countDistinct(slug)` rather than `count()`, because the slug is
* the identity of a lineage: counting versions reports a library several
* times the size of the one anybody can choose from, which is the same
* mistake `newestPerSlug` exists to avoid on the matched side. Counted in
* SQL, so the total stays exact when the read beside it hits its cap.
*/
db
.select({ value: countDistinct(motionTemplates.slug) })
.from(motionTemplates)
.where(motionLibraryWhere({})),
]);
const { rows, truncated } = bounded(libraryRead);
const lineages = newestPerSlug(rows as LineageRow[]); const lineages = newestPerSlug(rows as LineageRow[]);
const sharedTemplates = rowCount(libraryTotal);
const described = [ const described = [
filter.kind ? `kind ${filter.kind}` : null, filter.kind ? `kind ${filter.kind}` : null,
filter.stage ? `stage ${filter.stage}` : null, filter.stage ? `stage ${filter.stage}` : null,
filter.query ? `"${filter.query}"` : null, filter.query ? `"${filter.query}"` : null,
].filter((part): part is string => part !== null); ].filter((part): part is string => part !== null);
const scope = described.length ? ` matching ${described.join(', ')}` : ''; const matching = described.length ? ` matching ${described.join(', ')}` : '';
return { return {
headline: headline:
(lineages.length === 0 (lineages.length === 0
? `No shared template${scope}. Private drafts are not searched, so a template may ` + ? `None of the ${sharedTemplates} ${MOTION_LIBRARY_LABEL}${matching}. Private drafts are ` +
'exist and not be visible here.' 'not searched, so a template may exist and not be visible here.'
: `${atLeast(lineages.length, truncated)} shared template(s)${scope}, newest version ` + : `${atLeast(lineages.length, truncated)} of ${sharedTemplates} ` +
'of each.') + (truncated ? ` ${TRUNCATION_NOTE}` : ''), `${MOTION_LIBRARY_LABEL}${matching}, newest version of each.`) +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
scope: resultScope({
covers: described.length ? `match ${described.join(', ')}` : 'are shared and not archived',
matched: lineages.length,
total: sharedTemplates,
totalLabel: MOTION_LIBRARY_LABEL,
listed: Math.min(lineages.length, EXEMPLARS),
filters: {
...(filter.kind ? { kind: filter.kind } : {}),
...(filter.stage ? { stage: filter.stage } : {}),
...(filter.query ? { query: filter.query } : {}),
},
truncated,
}),
truncated, truncated,
count: lineages.length, count: lineages.length,
/** The denominator as a bare field: this is how big the shared library is. */
sharedTemplates,
byKind: countBy(lineages, (row) => row.kind), byKind: countBy(lineages, (row) => row.kind),
// Most recently updated first: the practice people are actually amending. // Most recently updated first: the practice people are actually amending.
templates: lineages.slice(0, EXEMPLARS).map((template) => ({ templates: lineages.slice(0, EXEMPLARS).map((template) => ({
@@ -1220,8 +1313,8 @@ async function readMotionLibrary(db: Database, filter: MotionLibraryFilter): Pro
*/ */
async function readEngagements(db: Database, query: string | null): Promise<unknown> { async function readEngagements(db: Database, query: string | null): Promise<unknown> {
const fragment = query ? likeFragment(query) : null; const fragment = query ? likeFragment(query) : null;
const { rows, truncated } = bounded( const [engagementRead, engagementTotal] = await Promise.all([
await db db
.select({ .select({
id: engagements.id, id: engagements.id,
status: engagements.status, status: engagements.status,
@@ -1241,7 +1334,13 @@ async function readEngagements(db: Database, query: string | null): Promise<unkn
) )
.orderBy(desc(engagements.openedAt)) .orderBy(desc(engagements.openedAt))
.limit(SCAN_LIMIT + 1), .limit(SCAN_LIMIT + 1),
); // Every engagement, open or closed and whatever the query. Without it a
// search that matches two is the only figure in the payload, and "we have
// two engagements" is the answer that comes back.
db.select({ value: count() }).from(engagements),
]);
const { rows, truncated } = bounded(engagementRead);
const totalEngagements = rowCount(engagementTotal);
const exemplars = rows.slice(0, EXEMPLARS); const exemplars = rows.slice(0, EXEMPLARS);
const ids = exemplars.map((row) => row.id); const ids = exemplars.map((row) => row.id);
@@ -1280,13 +1379,25 @@ async function readEngagements(db: Database, query: string | null): Promise<unkn
headline: headline:
(rows.length === 0 (rows.length === 0
? query ? query
? `No engagement matches "${query}".` ? `None of the ${totalEngagements} ${ENGAGEMENTS_LABEL} match "${query}".`
: 'No demand deal has an engagement running against it yet.' : 'No demand deal has an engagement running against it yet.'
: `${atLeast(rows.length, truncated)} engagement(s)${query ? ` matching "${query}"` : ''}, ` + : `${atLeast(rows.length, truncated)} of ${totalEngagements} ${ENGAGEMENTS_LABEL}` +
`of which ${rows.filter((row) => row.status === 'open').length} open.`) + `${query ? ` match "${query}"` : ''}, of which ` +
`${rows.filter((row) => row.status === 'open').length} open.`) +
(truncated ? ` ${TRUNCATION_NOTE}` : ''), (truncated ? ` ${TRUNCATION_NOTE}` : ''),
scope: resultScope({
covers: query ? `match "${query}"` : 'are on the book',
matched: rows.length,
total: totalEngagements,
totalLabel: ENGAGEMENTS_LABEL,
listed: exemplars.length,
filters: query ? { query } : {},
truncated,
}),
truncated, truncated,
count: rows.length, count: rows.length,
/** The denominator as a bare field: engagements exist that this did not match. */
totalEngagements,
byStatus: countBy(rows, (row) => row.status), byStatus: countBy(rows, (row) => row.status),
byStage: countBy(rows, (row) => row.stage), byStage: countBy(rows, (row) => row.stage),
engagements: exemplars.map((row) => { engagements: exemplars.map((row) => {
+340 -3
View File
@@ -16,16 +16,25 @@
* *
* The unit suite runs in CI BEFORE the migration step, against a database with * 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 * no tables, so nothing here may execute a query. The WHERE clause is rendered
* with `PgDialect` rather than run. * with `PgDialect` rather than run, and the tools themselves are executed
* against a stub handle the second half of this file, which pins the figures
* they report and the denominators those figures are drawn from.
*/ */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { MOTION_KINDS } from '@pig/core'; import { MOTION_KINDS } from '@pig/core';
import type { Database } from '@pig/db'; import {
engagementArtifacts,
engagements,
motionTemplates,
qualificationScores,
type Database,
} from '@pig/db';
import type { SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core'; import { PgDialect } from 'drizzle-orm/pg-core';
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
import { assertPigToolBoundary } from '../src/chat'; import { assertPigToolBoundary } from '../src/chat';
import { createPagePigTools, motionLibraryWhere } from '../src/page-tools'; import { createPagePigTools, motionLibraryWhere, type ResultScope } from '../src/page-tools';
/** Schema and SQL-shape checks only: no query is executed. */ /** Schema and SQL-shape checks only: no query is executed. */
const db = {} as Database; const db = {} as Database;
@@ -209,3 +218,331 @@ test('an omitted motion filter arrives as the null the emitted schema asks for',
); );
} }
}); });
// ---------------------------------------------------------------------------
// The figures the tools report
// ---------------------------------------------------------------------------
/**
* A stub handle, because the unit suite still may not execute a query.
*
* Drizzle's builder is a promise you can keep calling methods on, so this is
* the same: every chaining method returns itself and `then` resolves the rows.
* What is under test here is the shaping which figure reaches the headline
* and what denominator sits beside it not the SQL, which
* `e2e/page-tools.test.ts` covers against a real book.
*
* The where clauses are not evaluated: a stub that reimplemented them would be
* testing itself, so a filtered read is fixtured as the rows it returned. They
* ARE rendered, for the one assertion that has to see them a denominator
* taken over the whole table rather than the shared library would publish the
* size of everybody's private drafts, and it would look exactly like this from
* the outside.
*/
interface StubMotion {
/** What the library read returned. Shared, non-archived, versions included. */
templates: readonly Record<string, unknown>[];
/** `count(distinct slug)`: lineages, not rows. Fixtured, not derived. */
lineageTotal: number;
/** The promotion exemplars, capped by the tool, and their exact total. */
promotionRows?: readonly Record<string, unknown>[];
promotionTotal?: number;
/** The open engagements the summary counts by stage. */
openEngagements?: readonly Record<string, unknown>[];
/** What the engagement search matched, against every engagement on the book. */
engagementRows?: readonly Record<string, unknown>[];
engagementTotal?: number;
artifacts?: readonly Record<string, unknown>[];
scores?: readonly Record<string, unknown>[];
}
function stubBook(book: StubMotion): { handle: Database; denominators: string[] } {
const denominators: string[] = [];
const rowsFor = (
table: unknown,
projection: Record<string, unknown>,
where: string,
): readonly unknown[] => {
const counting = Object.hasOwn(projection, 'value');
if (table === motionTemplates) {
if (counting) {
denominators.push(where);
// Both counts are taken over `motion_templates`; only the promotion one
// asks for an origin artefact, which is what tells them apart here.
return where.includes('origin_artifact_id')
? [{ value: book.promotionTotal ?? 0 }]
: [{ value: book.lineageTotal }];
}
// The promotion exemplars are the only template read carrying a date.
return Object.hasOwn(projection, 'createdAt') ? (book.promotionRows ?? []) : book.templates;
}
if (table === engagements) {
if (counting) {
denominators.push(where);
return [{ value: book.engagementTotal ?? 0 }];
}
// The summary asks for a stage per open engagement; the search asks for
// the row.
return Object.hasOwn(projection, 'id')
? (book.engagementRows ?? [])
: (book.openEngagements ?? []);
}
if (table === engagementArtifacts) return book.artifacts ?? [];
if (table === qualificationScores) return book.scores ?? [];
throw new Error('the stub was asked for a table this suite does not fixture');
};
const select = (projection: Record<string, unknown>) => ({
from: (table: unknown) => {
let where = '';
const builder: Record<string, unknown> = {
where: (clause: SQL | undefined) => {
if (clause) where = dialect.sqlToQuery(clause).sql;
return builder;
},
};
for (const method of ['limit', 'orderBy', 'groupBy', 'innerJoin', 'leftJoin']) {
builder[method] = () => builder;
}
builder.then = (resolve: (value: readonly unknown[]) => unknown) =>
resolve(rowsFor(table, projection, where));
return builder;
},
});
return { handle: { select } as unknown as Database, denominators };
}
type Reading = Record<string, unknown> & { headline?: string; scope?: ResultScope };
async function read(
route: '/motion' | '/motion/library' | '/motion/engagements',
handle: Database,
input: Record<string, unknown> = {},
): Promise<Reading> {
const [only] = createPagePigTools(handle, route);
assert.ok(only, `no tool for ${route}`);
return (await only.execute(input)) as Reading;
}
function template(slug: string, stage: string, version = 1) {
return {
slug,
kind: 'playbook',
stage,
version,
title: `Template ${slug} v${version}`,
summary: 'A shared template.',
usageCount: 3,
};
}
/**
* Twelve lineages in thirteen rows, covering six of the eight open stages.
*
* The thirteenth row is a second version of the first lineage, and it is there
* because the two figures differ: a library reported by rows says 13 when the
* number of pieces of practice anybody can choose from is 12.
*/
const COVERED_STAGES = ['qualification', 'legal', 'scoping', 'proposal', 'poc', 'expansion'];
const SHARED_ROWS = [
...Array.from({ length: 12 }, (_, i) =>
template(`starter-${i}`, COVERED_STAGES[i % COVERED_STAGES.length]!),
),
template('starter-0', 'qualification', 2),
];
const LINEAGES = 12;
/** Twelve promotions, of which the tool may show eight. The gap is the point. */
const PROMOTION_TOTAL = 12;
const PROMOTION_ROWS = Array.from({ length: 8 }, (_, i) => ({
title: `Promoted ${i}`,
kind: 'case_study',
version: 2,
createdAt: new Date(Date.UTC(2026, 0, i + 1)),
}));
const LIBRARY = stubBook({
templates: SHARED_ROWS,
lineageTotal: LINEAGES,
promotionRows: PROMOTION_ROWS,
promotionTotal: PROMOTION_TOTAL,
openEngagements: Array.from({ length: 5 }, () => ({ stage: 'poc', dealName: 'DEMO — Halcyon' })),
});
test('the promotion figure is the exact total, not the length of the list beside it', async () => {
const reading = await read('/motion', LIBRARY.handle);
// The /motion tile counts promotions in SQL for exactly this reason. Piggy
// reading the length of its own capped list would peg the answer at 8 the
// moment the loop started working, and disagree with the tile on screen.
assert.equal(reading.promotions, PROMOTION_TOTAL);
assert.match(String(reading.headline), /12 artifact\(s\) promoted back into the library/);
assert.doesNotMatch(String(reading.headline), /8 artifact\(s\) promoted/);
const scope = reading.recentPromotionsScope as ResultScope;
assert.equal(scope.total, PROMOTION_TOTAL);
assert.equal(scope.listed, PROMOTION_ROWS.length);
assert.equal((reading.recentPromotions as unknown[]).length, PROMOTION_ROWS.length);
});
test('the motion summary counts lineages, not versions', async () => {
const reading = await read('/motion', LIBRARY.handle);
const scope = reading.scope;
assert.ok(scope);
// 13 rows, 12 lineages. A denominator of 13 would be a library four times the
// size of the one anybody can choose from, in miniature.
assert.equal(scope.total, LINEAGES);
assert.equal(reading.sharedTemplates, LINEAGES);
assert.notEqual(scope.total, SHARED_ROWS.length);
assert.match(scope.totalLabel, /shared template lineage\(s\)/);
});
test('no motion denominator is taken over the whole template table', async () => {
await read('/motion', LIBRARY.handle);
await read('/motion/library', LIBRARY.handle, {});
assert.ok(LIBRARY.denominators.length >= 2);
for (const where of LIBRARY.denominators) {
// A total that counted private rows would publish the existence and the
// size of colleagues' drafts through a figure nobody thinks of as a read.
assert.match(where, /"visibility" = \$\d+/, where);
assert.match(where, /"archived_at" is null/, where);
}
});
test('the stages the library misses are named when the whole library was read', async () => {
const reading = await read('/motion', LIBRARY.handle);
// Six stages are covered by the fixture, so the two that are not are a piece
// of work somebody can act on — which is why this is a list and not a count.
assert.deepEqual(reading.uncoveredStages, ['procurement', 'deployment']);
assert.match(String(reading.headline), /No shared template covers procurement, deployment\./);
});
test('stage coverage is not asserted from a capped scan', async () => {
// SCAN_LIMIT is 500 and the read asks for one more, so 501 rows is a library
// that certainly continues past the cap.
const capped = stubBook({
templates: Array.from({ length: 501 }, (_, i) => template(`over-${i}`, 'qualification')),
lineageTotal: 501,
promotionRows: PROMOTION_ROWS,
promotionTotal: PROMOTION_TOTAL,
});
const reading = await read('/motion', capped.handle);
// "No shared template covers deployment" is a definite negative, and the rows
// that would refute it are precisely the ones the cap dropped. Null says the
// question was not answered; an empty list would say every stage is covered
// and a full list would say seven are not, and both are inventions.
assert.equal(reading.uncoveredStages, null);
assert.doesNotMatch(String(reading.headline), /No shared template covers/);
assert.doesNotMatch(String(reading.headline), /Every live demand stage/);
assert.match(String(reading.headline), /row cap/);
});
test('a filtered library search is counted against the whole shared library', async () => {
// The stub evaluates no where clause, so what a kind filter matched is
// fixtured: three lineages, out of a library that still holds twelve.
const filtered = stubBook({
templates: [
template('proposal-blocks', 'proposal'),
template('proposal-terms', 'procurement'),
template('proposal-exec', 'proposal'),
],
lineageTotal: LINEAGES,
});
const reading = await read('/motion/library', filtered.handle, { kind: 'proposal' });
const scope = reading.scope;
assert.ok(scope);
assert.equal(scope.matched, 3);
assert.equal(scope.total, LINEAGES);
assert.equal(reading.count, 3);
assert.equal(reading.sharedTemplates, LINEAGES);
// The denominator has to reach the headline, because the headline is the
// field a small model quotes: "3 shared templates" alone is the size of a
// filter presented as the size of the library.
assert.match(String(reading.headline), /3 of 12 shared template lineage\(s\)/);
assert.match(scope.summary, /the total is 12/);
assert.equal(scope.filters.kind, 'proposal');
});
test('an unfiltered library search says so rather than hedging an exact figure', async () => {
const reading = await read('/motion/library', LIBRARY.handle, {
kind: null,
stage: null,
query: null,
});
const scope = reading.scope;
assert.ok(scope);
// Nothing was filtered out, so `matched` IS the total. Reporting the nulls a
// schema-abiding model sends as filters would teach it to distrust a figure
// that is exact.
assert.equal(scope.matched, scope.total);
assert.deepEqual(scope.filters, {});
assert.match(scope.summary, /All 12 shared template lineage\(s\) in the motion library/);
});
test('an engagement search is counted against every engagement on the book', async () => {
const book = stubBook({
templates: [],
lineageTotal: LINEAGES,
engagementTotal: 9,
engagementRows: [
{
id: 'engagement-1',
status: 'open',
summary: 'Mid POC.',
openedAt: new Date(Date.UTC(2026, 1, 1)),
stage: 'poc',
dealName: 'DEMO — Halcyon Research',
accountName: 'DEMO — Halcyon Research',
},
{
id: 'engagement-2',
status: 'closed',
summary: 'Won.',
openedAt: new Date(Date.UTC(2025, 10, 1)),
stage: 'expansion',
dealName: 'DEMO — Halcyon Expansion',
accountName: 'DEMO — Halcyon Research',
},
],
artifacts: [{ engagementId: 'engagement-1', status: 'final' }],
scores: [],
});
const reading = await read('/motion/engagements', book.handle, { query: 'Halcyon' });
const scope = reading.scope;
assert.ok(scope);
assert.equal(scope.matched, 2);
assert.equal(scope.total, 9);
assert.equal(reading.totalEngagements, 9);
assert.match(String(reading.headline), /2 of 9 engagement\(s\) on the book match "Halcyon"/);
assert.equal(scope.filters.query, 'Halcyon');
});
test('every motion result carries a complete scope, and no scope outruns its own total', async () => {
const readings = [
await read('/motion', LIBRARY.handle),
await read('/motion/library', LIBRARY.handle, {}),
await read('/motion/engagements', stubBook({ templates: [], lineageTotal: 0 }).handle),
];
for (const reading of readings) {
const found = Object.entries(reading)
.filter(([key]) => key === 'scope' || key.endsWith('Scope'))
.map(([, value]) => value as ResultScope);
assert.ok(found.length > 0, `a motion result carries no scope at all: ${reading.headline}`);
for (const scope of found) {
assert.ok(scope.summary.length > 0);
assert.ok(scope.totalLabel.length > 0);
// The denominator has to reach the sentence, because the sentence is what
// gets quoted.
assert.match(scope.summary, new RegExp(`\\b${scope.total}\\b`));
assert.ok(scope.matched <= scope.total, 'matched exceeds its own denominator');
assert.ok(scope.listed <= scope.matched, 'more rows listed than matched');
}
}
});
+17 -10
View File
@@ -9,9 +9,9 @@
*/ */
import { Fragment } from 'react'; import { Fragment } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Link, useMatch, useResolvedPath } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useIdentity } from '@/lib/identity'; import { useIdentity } from '@/lib/identity';
import { NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav'; import { activeNavItem, NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
import { AccountSwitcher } from './AccountSwitcher'; import { AccountSwitcher } from './AccountSwitcher';
import { Button, Label } from './ui'; import { Button, Label } from './ui';
import { import {
@@ -33,6 +33,16 @@ export function AppSidebar() {
const identity = useIdentity(); const identity = useIdentity();
const items = visibleNav(identity); const items = visibleNav(identity);
const { isMobile, setOpenMobile } = useSidebar(); const { isMobile, setOpenMobile } = useSidebar();
const { pathname } = useLocation();
/*
* One winner for the whole rail, decided here rather than by each row asking
* the router about itself. Motion is the first group whose destinations nest
* `/motion` is a prefix of `/motion/library` and a per-row match lit both
* of those at once, so the sidebar and the header disagreed about which page
* you were on. `activeNavItem` is the same longest-match helper the header
* titles with, which is what keeps them from ever disagreeing again.
*/
const current = activeNavItem(items, pathname);
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
@@ -75,7 +85,7 @@ export function AppSidebar() {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{groupItems.map((item) => ( {groupItems.map((item) => (
<NavItemRow key={item.to} item={item} /> <NavItemRow key={item.to} item={item} isActive={current?.to === item.to} />
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
@@ -103,14 +113,11 @@ export function AppSidebar() {
); );
} }
function NavItemRow({ item }: { item: NavItem }) { // `asChild` renders the row *as* the link rather than wrapping one, so there is
// a single focusable element per row. Active state arrives as a prop because it
// is a question about the whole table — see the caller.
function NavItemRow({ item, isActive }: { item: NavItem; isActive: boolean }) {
const { setOpenMobile, isMobile } = useSidebar(); const { setOpenMobile, isMobile } = useSidebar();
// `asChild` renders the row *as* the link rather than wrapping one, so there
// is a single focusable element per row. Active state is asked of the router
// instead of compared against a pathname, so `/demand/abc` still lights
// Demand and `/` does not light everything.
const resolved = useResolvedPath(item.to);
const isActive = useMatch({ path: resolved.pathname, end: item.to === '/' }) !== null;
return ( return (
<SidebarMenuItem> <SidebarMenuItem>
+343 -8
View File
@@ -38,10 +38,10 @@ export function FieldsView({
const record = asRecord(fields); const record = asRecord(fields);
if (!record) return null; if (!record) return null;
const body = renderKind(kind, record); // Emptiness is decided inside each kind's renderer, not here: `renderKind`
if (!body) return null; // hands back an element, and an element is truthy however little it draws.
// A guard at this level could only ever read as one and never fire.
return <div className={cn('min-w-0 space-y-5', className)}>{body}</div>; return <div className={cn('min-w-0 space-y-5', className)}>{renderKind(kind, record)}</div>;
} }
function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode { function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode {
@@ -71,7 +71,11 @@ function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNod
function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) { function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
const sections = recordList(fields.sections); const sections = recordList(fields.sections);
if (sections.length === 0) return null; const decisions = recordList(fields.decisions);
const blockingSet = recordList(fields.blockingSet);
if (sections.length === 0 && decisions.length === 0 && blockingSet.length === 0) return null;
const blockingCount = decisions.filter((decision) => decision.blocking === true).length;
return ( return (
<> <>
@@ -101,6 +105,84 @@ function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
</Section> </Section>
); );
})} })}
{decisions.length === 0 ? null : (
<Section
title="Decisions"
// A decision brief is read to find what is still open, and a blocking
// row is a different object from the rest: it ends the deal rather
// than delaying it, so the count leads.
aside={blockingCount > 0 ? `${blockingCount} of ${decisions.length} blocking` : undefined}
>
<div className="min-w-0 space-y-3">
{decisions.map((decision, index) => {
const question = text(decision.question);
if (!question) return null;
const area = text(decision.area);
const id = text(decision.id);
const options = recordList(decision.options);
return (
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{area ? <Badge tone="neutral">{area}</Badge> : null}
{decision.blocking === true ? <Badge tone="danger">Blocking</Badge> : null}
{/* The blocking set below names decisions by id, so the id
is content here rather than a React key. */}
{id ? <span className="whitespace-nowrap text-xs text-muted">{id}</span> : null}
</div>
<p className="mt-2 min-w-0 break-words font-medium leading-6">{question}</p>
<Note label="Answered by" value={text(decision.answeredBy)} />
{options.length === 0 ? null : (
<ul className="mt-3 min-w-0 space-y-3 border-t border-border pt-3">
{options.map((option, optionIndex) => {
const choice = text(option.option);
if (!choice) return null;
const verdict = text(option.verdict);
const escalatesTo = text(option.escalatesTo);
return (
<li key={optionIndex} className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{verdict ? <Badge tone={verdictTone(verdict)}>{verdict}</Badge> : null}
{/* Who answers follows the option chosen, not the
topic: a lead who settles a weights question
off the cuff has priced nothing. */}
{escalatesTo ? (
<span className="whitespace-nowrap text-xs text-muted">
escalates to {escalatesTo}
</span>
) : null}
</div>
<p className="mt-1.5 min-w-0 break-words text-sm leading-6">{choice}</p>
<Note label="Costs" value={text(option.costs)} />
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
</Section>
)}
{blockingSet.length === 0 ? null : (
<Section title="The blocking set" icon={<Skull className="size-4" aria-hidden />}>
<ul className="min-w-0 space-y-3">
{blockingSet.map((entry, index) => {
const item = text(entry.item);
if (!item) return null;
return (
<li key={index} className="min-w-0">
<p className="min-w-0 break-words font-medium leading-6">{item}</p>
<Pills label="Decisions" items={textList(entry.decisionIds)} />
<Note label="Why it kills the deal" value={text(entry.whyItKills)} tone="warning" />
</li>
);
})}
</ul>
</Section>
)}
</> </>
); );
} }
@@ -147,7 +229,14 @@ function QualificationFields({ fields }: { fields: Record<string, unknown> }) {
)} )}
{bands.length === 0 ? null : ( {bands.length === 0 ? null : (
<Section title="What each score means"> // Not "what each score means": these bands are the framework author's
// own calibration on their own scale, and `MOTION_BANDS` is the
// product's — four bands in basis points, shown as a badge on the same
// page. The shipped scorecard's five bands genuinely disagree with it
// at 7500 (Strategic, against "do not start compute"), so a heading
// that read as the product's verdict put two opposite instructions
// about one number on one screen.
<Section title="What this framework says to do at each score">
<ul className="min-w-0 space-y-2"> <ul className="min-w-0 space-y-2">
{bands.map((band, index) => { {bands.map((band, index) => {
const label = text(band.label); const label = text(band.label);
@@ -308,7 +397,30 @@ function PricingFields({ fields }: { fields: Record<string, unknown> }) {
const inputs = recordList(fields.inputs); const inputs = recordList(fields.inputs);
const packages = recordList(fields.packages); const packages = recordList(fields.packages);
const tradeables = recordList(fields.tradeables); const tradeables = recordList(fields.tradeables);
if (inputs.length === 0 && packages.length === 0 && tradeables.length === 0) return null; const budgetSources = recordList(fields.budgetSources);
const forecast = asRecord(fields.computeForecast);
const questionnaire = recordList(fields.questionnaireMap);
const justification = asRecord(fields.soleSourceJustification);
const steps = recordList(fields.steps);
const championHomework = recordList(fields.championHomework);
if (
inputs.length === 0 &&
packages.length === 0 &&
tradeables.length === 0 &&
budgetSources.length === 0 &&
!forecast &&
questionnaire.length === 0 &&
!justification &&
steps.length === 0 &&
championHomework.length === 0
) {
return null;
}
// Serial is the number the champion's own calendar produces if nobody runs
// anything beside anything else, and it is what makes the `parallelWith`
// column below worth reading.
const serialDays = steps.reduce((total, step) => total + (number(step.typicalDays) ?? 0), 0);
return ( return (
<> <>
@@ -356,6 +468,130 @@ function PricingFields({ fields }: { fields: Record<string, unknown> }) {
/> />
</Section> </Section>
)} )}
{budgetSources.length === 0 ? null : (
<Section title="Where the money comes from">
<div className="min-w-0 space-y-3">
{budgetSources.map((entry, index) => {
const source = text(entry.source);
if (!source) return null;
const days = number(entry.typicalDays);
return (
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words font-medium leading-6">{source}</span>
{entry.fastest === true ? <Badge tone="positive">Fastest</Badge> : null}
{days === null ? null : (
<span className="nums whitespace-nowrap text-xs text-muted">~{days} days</span>
)}
</div>
<Note label="Approvers" value={text(entry.approvers)} />
{/* Speed and durability are different questions and the
fastest source is routinely the least durable one, so
neither is shown without the other. */}
<Note label="Durability" value={text(entry.durability)} />
<Note label="Durability test" value={text(entry.durabilityTest)} />
<Note label="Watch for" value={text(entry.watchFor)} tone="warning" />
</div>
);
})}
</div>
</Section>
)}
{forecast ? (
<Section title="Compute forecast">
<Note label="Audience" value={text(forecast.audience)} />
<ScrollTable
head={['Line', 'Low', 'Expected', 'High']}
rows={recordList(forecast.lines).map((line) => [
text(line.line),
text(line.low),
text(line.expected),
text(line.high),
])}
/>
<Note label="Ceiling rule" value={text(forecast.ceilingRule)} />
{/* Cost is charged against the full commitment, not the hours that
sold AGENTS.md §4 so the rule travels with the forecast that
tempts a reader to model it the other way. */}
<Note label="Commitment rule" value={text(forecast.commitmentRule)} />
</Section>
) : null}
{questionnaire.length === 0 ? null : (
<Section title="AI vendor questionnaire">
<ScrollTable
head={['Topic', 'Answered from']}
rows={questionnaire.map((entry) => [text(entry.topic), text(entry.source)])}
/>
</Section>
)}
{justification ? (
<Section title="Sole-source justification">
<Note label="When it is needed" value={text(justification.whenNeeded)} />
{recordList(justification.paragraphs).map((paragraph, index) => {
const heading = text(paragraph.heading);
if (!heading) return null;
const draft = text(paragraph.draft);
return (
<div key={index} className="mt-3 min-w-0">
<p className="min-w-0 break-words text-sm font-medium leading-6">{heading}</p>
{draft ? (
// The champion pastes this into their own requisition, so
// the whitespace the author wrote is part of the paragraph.
<p className="mt-1.5 min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
{draft}
</p>
) : null}
</div>
);
})}
<Note label="Note" value={text(justification.note)} />
</Section>
) : null}
{steps.length === 0 ? null : (
<Section
title="The critical path"
aside={serialDays > 0 ? `${serialDays} days end to end, serially` : undefined}
>
<ol className="min-w-0 space-y-3">
{steps.map((entry, index) => {
const step = text(entry.step);
if (!step) return null;
const days = number(entry.typicalDays);
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words font-medium leading-6">{step}</span>
{days === null ? null : (
<span className="nums whitespace-nowrap text-xs text-muted">~{days} days</span>
)}
</div>
<Note label="Produces" value={text(entry.produces)} />
<Note label="Runs beside" value={text(entry.parallelWith)} />
{/* Procurement never says no, it goes quiet, and the stated
reason for the silence is almost never the real one so
the symptom and the move are the part that gets read. */}
<Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
<Note label="Unstick move" value={text(entry.unstickMove)} />
</li>
);
})}
</ol>
</Section>
)}
{championHomework.length === 0 ? null : (
<Section title="The champion's homework">
<ScrollTable
head={['What they do', 'Why']}
rows={championHomework.map((entry) => [text(entry.task), text(entry.why)])}
/>
</Section>
)}
</> </>
); );
} }
@@ -473,7 +709,21 @@ function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
const stages = recordList(fields.stages); const stages = recordList(fields.stages);
const research = asRecord(fields.researchInterface); const research = asRecord(fields.researchInterface);
const promotion = recordList(fields.promotion); const promotion = recordList(fields.promotion);
if (stages.length === 0 && !research && promotion.length === 0) return null; const checks = recordList(fields.checks);
const fallbacks = recordList(fields.fallbacks);
const firstThirtyDays = recordList(fields.firstThirtyDays);
if (
stages.length === 0 &&
!research &&
promotion.length === 0 &&
checks.length === 0 &&
fallbacks.length === 0 &&
firstThirtyDays.length === 0
) {
return null;
}
const gateCount = checks.filter((check) => check.gate === true).length;
return ( return (
<> <>
@@ -504,6 +754,79 @@ function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
</Section> </Section>
) : null} ) : null}
{checks.length === 0 ? null : (
<Section
title="Readiness checks"
// A checklist nobody can fail is a document. The gates are what make
// this one a gate, so their share is stated before the list.
aside={gateCount > 0 ? `${gateCount} of ${checks.length} are gates` : undefined}
>
<ul className="min-w-0 space-y-3">
{checks.map((entry, index) => {
const check = text(entry.check);
if (!check) return null;
const area = text(entry.area);
const id = text(entry.id);
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{area ? <Badge tone="neutral">{area}</Badge> : null}
{/* A gate stops the traffic ramp rather than being work
somebody catches up on, which is the same reason a
POC's kill gate is the one property that earns colour. */}
{entry.gate === true ? <Badge tone="danger">Gate</Badge> : null}
{/* The fallbacks and the first thirty days name checks by
id, so the id is content rather than a React key. */}
{id ? <span className="whitespace-nowrap text-xs text-muted">{id}</span> : null}
</div>
<p className="mt-2 min-w-0 break-words leading-6">{check}</p>
<Note label="Evidence" value={text(entry.evidence)} />
<Note label="Owner" value={text(entry.owner)} />
</li>
);
})}
</ul>
</Section>
)}
{fallbacks.length === 0 ? null : (
<Section title="When it fails" icon={<TriangleAlert className="size-4" aria-hidden />}>
<ul className="min-w-0 space-y-3">
{fallbacks.map((entry, index) => {
const failure = text(entry.failure);
if (!failure) return null;
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{failure}</p>
{/* A response nobody can trigger is not a fallback, so how
the failure is detected is shown before what to do. */}
<Note label="Detection" value={text(entry.detection)} />
<Note label="Response" value={text(entry.response)} />
</li>
);
})}
</ul>
</Section>
)}
{firstThirtyDays.length === 0 ? null : (
<Section title="The first thirty days">
<ol className="min-w-0 space-y-3">
{firstThirtyDays.map((entry, index) => {
const when = text(entry.when);
if (!when) return null;
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{when}</p>
<Note label="Watch" value={text(entry.watch)} />
<Note label="Escalate if" value={text(entry.escalateIf)} tone="warning" />
</li>
);
})}
</ol>
</Section>
)}
{promotion.length === 0 ? null : ( {promotion.length === 0 ? null : (
<Section title="What to promote, and when"> <Section title="What to promote, and when">
<ScrollTable <ScrollTable
@@ -624,6 +947,18 @@ function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][]
// -------------------------------------------------------------------- narrowing // -------------------------------------------------------------------- narrowing
/**
* A decision option's verdict colours its badge. The text stays as the author
* wrote it and an unrecognised verdict keeps the neutral tone, so a brief that
* adds a fourth one still reads rather than losing its options to a colour
* lookup that matched nothing.
*/
function verdictTone(verdict: string): 'positive' | 'danger' | 'neutral' {
if (verdict === 'recommended') return 'positive';
if (verdict === 'avoid') return 'danger';
return 'neutral';
}
/** /**
* A playbook stage names a `DemandStage`, and the label comes from `@pig/core` * 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 * so a renamed stage renames here too. An unrecognised value is shown as
+17 -1
View File
@@ -44,6 +44,7 @@ import {
DEMAND_STAGE_LABELS, DEMAND_STAGE_LABELS,
ENGAGEMENT_STATUSES, ENGAGEMENT_STATUSES,
ENGAGEMENT_STATUS_LABELS, ENGAGEMENT_STATUS_LABELS,
MOTION_KIND_STAGES,
toPiggyPageRoute, toPiggyPageRoute,
type ArtifactStatus, type ArtifactStatus,
type DemandStage, type DemandStage,
@@ -400,7 +401,22 @@ export function Engagement() {
// open on the frameworks rather than on whatever was filtered last. // open on the frameworks rather than on whatever was filtered last.
key={instantiating.kind ?? 'any'} key={instantiating.kind ?? 'any'}
engagementId={engagement.id} engagementId={engagement.id}
defaultStage={engagement.stage} /*
* The deal's stage seeds the filter, but only where the forced kind
* actually serves it. The service ANDs kind and stage, so "Add a
* framework" on a deal at POC asked for a qualification template at
* POC a pair no shipped template can satisfy and the picker said
* "No template matches" over a library that has three. Where the two
* are coherent the stage is kept, because an architecture prefilter
* on a deal at POC should still open on POC.
*/
defaultStage={
instantiating.kind
? engagement.stage && MOTION_KIND_STAGES[instantiating.kind].includes(engagement.stage)
? engagement.stage
: null
: engagement.stage
}
defaultKind={instantiating.kind} defaultKind={instantiating.kind}
open open
onOpenChange={(open) => { onOpenChange={(open) => {
+34 -1
View File
@@ -394,6 +394,7 @@ function OpenEngagementSheet({
const taken = new Set((allEngagements.data?.engagements ?? []).map((row) => row.demandDealId)); const taken = new Set((allEngagements.data?.engagements ?? []).map((row) => row.demandDealId));
const available = (deals.data?.deals ?? []).filter((row) => !taken.has(row.deal.id)); const available = (deals.data?.deals ?? []).filter((row) => !taken.has(row.deal.id));
const failed = deals.isError || allEngagements.isError;
const create = useMutation({ const create = useMutation({
mutationFn: () => mutationFn: () =>
@@ -445,7 +446,39 @@ function OpenEngagementSheet({
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
{!deals.isLoading && !allEngagements.isLoading && available.length === 0 ? ( {/*
A failed read is not an empty book. Without this the sheet told
people "every demand deal already has an engagement" when the deal
query had simply errored. The engagement query's failure has to be
said too rather than tolerated: `taken` is then empty, so the list
is every deal including the ones already running, and choosing one
of those can only answer 409.
*/}
{failed ? (
<>
<p className="text-xs text-danger">
{(deals.error ?? allEngagements.error)?.message ??
'The deal list could not be loaded.'}
</p>
<p className="text-xs text-muted">
{deals.isError
? 'No deal can be offered until this loads.'
: 'Deals that already have an engagement cannot be filtered out, so a choice here may be refused.'}
</p>
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() => {
void deals.refetch();
void allEngagements.refetch();
}}
>
<RefreshCw aria-hidden />
Try again
</Button>
</>
) : !deals.isLoading && !allEngagements.isLoading && available.length === 0 ? (
<p className="text-xs text-muted"> <p className="text-xs text-muted">
Every demand deal already has an engagement. Open one from the deal in Pipeline Every demand deal already has an engagement. Open one from the deal in Pipeline
once there is a new deal to run. once there is a new deal to run.
+28
View File
@@ -403,8 +403,36 @@ export function MotionTemplate() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]"> <CardContent className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
{/*
Pending and failed are answered before empty. "No open engagement to
instantiate into" is a claim about the book, and it was being made
while the query was still in flight and again when it had failed
so a reader was told to go and open an engagement they already have.
`isPending` is a safe reading of "still loading" here only because
both halves of this query's `enabled` are already established:
`mayWrite` by the arm above, `id` by the detail data this branch is
rendered from.
*/}
{!mayWrite ? ( {!mayWrite ? (
<p className="text-sm text-muted">{WRITE_DENIED}</p> <p className="text-sm text-muted">{WRITE_DENIED}</p>
) : engagements.isPending ? (
<Select disabled>
<SelectTrigger aria-label="Choose an engagement" className="h-11 min-w-0">
<SelectValue placeholder="Loading engagements…" />
</SelectTrigger>
</Select>
) : engagements.isError ? (
<>
<p className="text-sm text-muted">
{engagements.error instanceof Error
? engagements.error.message
: 'The open engagements could not be loaded.'}
</p>
<Button variant="outline" onClick={() => void engagements.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
</>
) : openEngagements.length === 0 ? ( ) : openEngagements.length === 0 ? (
<p className="text-sm text-muted"> <p className="text-sm text-muted">
No open engagement to instantiate into.{' '} No open engagement to instantiate into.{' '}
+3 -2
View File
@@ -83,6 +83,7 @@ and is redacted if an upstream error happens to echo it.
| `pig_inventory_search` | What could we buy to cover demand we cannot serve? | | `pig_inventory_search` | What could we buy to cover demand we cannot serve? |
| `pig_search` | Find an account | | `pig_search` | Find an account |
| `pig_get_account` | Everything about one account | | `pig_get_account` | Everything about one account |
| `pig_motion_library` | What practice have we already written for this stage? |
| `pig_log_activity` | Record a call, meeting or note | | `pig_log_activity` | Record a call, meeting or note |
`pig_capacity_match` is the one worth learning. Ask it in plain language: `pig_capacity_match` is the one worth learning. Ask it in plain language:
@@ -96,10 +97,10 @@ break-even.
## Why the surface is small ## Why the surface is small
Nine tools, each doing one thing. A sprawling tool list measurably degrades Ten tools, each doing one thing. A sprawling tool list measurably degrades
model performance, and anything genuinely niche is reachable through model performance, and anything genuinely niche is reachable through
`pig_search` or the HTTP API. If you need something that is not here, it is `pig_search` or the HTTP API. If you need something that is not here, it is
probably better added as a service method than as a tenth tool. probably better added as a service method than as an eleventh tool.
## What it cannot do ## What it cannot do
+1
View File
@@ -98,6 +98,7 @@ ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_promoted
ALTER TABLE "motion_templates" ADD CONSTRAINT "motion_templates_origin_artifact_fk" FOREIGN KEY ("origin_artifact_id") REFERENCES "public"."engagement_artifacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "motion_templates" ADD CONSTRAINT "motion_templates_origin_artifact_fk" FOREIGN KEY ("origin_artifact_id") REFERENCES "public"."engagement_artifacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_engagement_id_engagements_id_fk" FOREIGN KEY ("engagement_id") REFERENCES "public"."engagements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_engagement_id_engagements_id_fk" FOREIGN KEY ("engagement_id") REFERENCES "public"."engagements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_framework_template_fk" FOREIGN KEY ("framework_template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_framework_template_fk" FOREIGN KEY ("framework_template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_scored_by_user_id_users_id_fk" FOREIGN KEY ("scored_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "motion_templates_kind_stage_idx" ON "motion_templates" USING btree ("kind","stage");--> statement-breakpoint CREATE INDEX "motion_templates_kind_stage_idx" ON "motion_templates" USING btree ("kind","stage");--> statement-breakpoint
CREATE INDEX "motion_templates_visibility_kind_idx" ON "motion_templates" USING btree ("visibility","kind");--> statement-breakpoint CREATE INDEX "motion_templates_visibility_kind_idx" ON "motion_templates" USING btree ("visibility","kind");--> statement-breakpoint
CREATE INDEX "motion_templates_owner_idx" ON "motion_templates" USING btree ("owner_user_id");--> statement-breakpoint CREATE INDEX "motion_templates_owner_idx" ON "motion_templates" USING btree ("owner_user_id");--> statement-breakpoint
+14 -2
View File
@@ -7,7 +7,7 @@
* situation this command is for someone demoing on top of their own data * situation this command is for someone demoing on top of their own data
* and it must leave that data untouched. * and it must leave that data untouched.
*/ */
import { eq, inArray, like, sql } from 'drizzle-orm'; import { eq, inArray, like, or, sql } from 'drizzle-orm';
import { unstampDemoActivity } from './activities'; import { unstampDemoActivity } from './activities';
import { import {
accounts, accounts,
@@ -96,11 +96,23 @@ export async function clear(context: DemoContext): Promise<void> {
* does. The starter library itself is NOT touched: it is authored product * does. The starter library itself is NOT touched: it is authored product
* content from the base seed, carries no prefix, and survives `--clear` the * content from the base seed, carries no prefix, and survives `--clear` the
* same way the PIG-hosted learn rows do. * same way the PIG-hosted learn rows do.
*
* Found by parentage as well as by prefix, because the cascade off the demo
* deals takes a wider set than the prefix does: an engagement opened on a
* demo deal through the app carries whatever summary its author typed, or
* none. Every one of those the prefix scan missed would be deleted anyway a
* few lines below, silently, with its artefacts' borrowed `usage_count`
* never given back and a starter template left above zero can never be
* edited again.
*/ */
const demoEngagements = await db const demoEngagements = await db
.select({ id: engagements.id }) .select({ id: engagements.id })
.from(engagements) .from(engagements)
.where(like(engagements.summary, `${prefix}%`)); .where(
demandDealIds.length > 0
? or(like(engagements.summary, `${prefix}%`), inArray(engagements.demandDealId, demandDealIds))
: like(engagements.summary, `${prefix}%`),
);
const engagementIds = demoEngagements.map((engagement) => engagement.id); const engagementIds = demoEngagements.map((engagement) => engagement.id);
if (engagementIds.length > 0) { if (engagementIds.length > 0) {
/* /*
File diff suppressed because one or more lines are too long
+85
View File
@@ -0,0 +1,85 @@
/*
* Does every seeded template's `fields` actually reach the screen?
*
* node scripts/motion-fields-check.mjs # PIG_WEB_URL=http://127.0.0.1:8975
*
* Three of the twelve starter templates shipped with `fields` shapes no renderer
* in `FieldsView` read `decisions`, `blockingSet`, `checks`, `steps` and the
* rest so about forty authored records rendered as no DOM at all, in the
* library and again on the engagement that instantiated them. Nothing failed:
* each renderer returned null for a key set it did not recognise, and a template
* page that is header-plus-body looks like a template that was written that way.
*
* So the check is not "does the page load". It samples the actual authored
* strings out of the seed JSON and asserts they are present in the rendered
* text, which is the only claim that distinguishes a rendered field from a
* dropped one.
*/
import { chromium } from 'playwright';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const BASE = process.env.PIG_WEB_URL ?? 'http://127.0.0.1:8975';
const SEEDS = 'packages/db/src/seed/motion';
/** Every string of real prose in a fields tree, longest first. */
function strings(node, out = []) {
if (typeof node === 'string') out.push(node);
else if (Array.isArray(node)) node.forEach((n) => strings(n, out));
else if (node && typeof node === 'object') Object.values(node).forEach((n) => strings(n, out));
return out;
}
const templates = readdirSync(SEEDS)
.filter((f) => f.endsWith('.json'))
.map((f) => JSON.parse(readFileSync(join(SEEDS, f), 'utf8')))
.filter((t) => t.fields && Object.keys(t.fields).length > 0);
const live = await fetch(`${BASE}/api/motion/templates?all=1`).then((r) => r.json());
const bySlug = new Map(live.templates.map((t) => [t.slug, t]));
const browser = await chromium.launch({ channel: 'chrome' });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
let failures = 0;
for (const template of templates) {
const row = bySlug.get(template.slug);
if (!row) {
console.log(`SKIP ${template.slug}: not in the seeded library`);
continue;
}
// Long strings only: a short one ("Owner", "Yes") can appear in the chrome by
// coincidence and would make this pass on a page that dropped every field.
const sample = strings(template.fields)
.filter((s) => s.length > 45)
.sort((a, b) => b.length - a.length)
.slice(0, 6);
if (sample.length === 0) {
console.log(`SKIP ${template.slug}: no prose long enough to sample`);
continue;
}
const page = await ctx.newPage();
await page.goto(`${BASE}/motion/library/${row.id}`, { waitUntil: 'networkidle' });
await page.waitForTimeout(900);
const text = await page.evaluate(() => document.body.innerText);
// The markdown body is rendered too, so a string that also appears there
// would not prove the FIELDS reached the screen. Those are excluded.
const fieldsOnly = sample.filter((s) => !template.body.includes(s));
const checking = fieldsOnly.length ? fieldsOnly : sample;
const missing = checking.filter((s) => !text.includes(s));
if (missing.length) {
failures++;
console.log(`FAIL ${template.slug} (${template.kind}): ${missing.length}/${checking.length} authored strings absent`);
console.log(` e.g. "${missing[0].slice(0, 90)}…"`);
} else {
console.log(`ok ${template.slug} (${template.kind}): ${checking.length} authored strings on screen`);
}
await page.close();
}
await browser.close();
console.log(failures === 0 ? '\nevery seeded fields tree reaches the screen' : `\n${failures} template(s) render authored content nowhere`);
process.exit(failures === 0 ? 0 : 1);