Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things worth knowing before reading the diff: RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago. So this does not rebuild them; it closes the gaps an audit found. The big one is that reads were entirely ungoverned: every GET was "any authenticated member", so a junior demand rep and a research contractor could both pull per-block supplier cost and break-even prices from /api/capacity/margin, and every contract's negotiated terms. For a company whose margin is the business, that was the hole that mattered. Adds book:read / economics:read / team:read, a readGuard middleware, and a `viewer` role below member. THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen. Contracts.tsx never called can() at all, so its save button was always enabled against a server requiring contract:sign; Capacity.tsx gated commitment creation on deal:write/demand while the server wanted commitment:write/supply. POST /api/activities was the one write bypassing executeMutation: no capability check, and any member could mutate accounts.lastActivityAt as a side effect. It is now a proper mutation() behind activity:write. The shell becomes three panes — a collapsible shadcn sidebar with an account switcher on the Piggy accent, a header with real search, and Piggy docked to the right, page-aware and persistent across navigation. The phone keeps its bottom tab bar, which is the thing this product already beat trycompai/crm on, and gains the sidebar as a sheet. Calendar is a projection over thirteen dated sources rather than a new table, because a table would duplicate dates that already live on contracts, deals and commitments and would drift — and one ledger answering the question is the whole argument. It surfaces export_authorizations and compliance_artifacts, which had indexed expires_at columns, schema comments saying they must be alerted on, and no read endpoint or UI anywhere. Learn carries two tracks. Concepts are members-only; the platform track can be opened with a share code by someone with no account. The code mints a scoped learn-only token and never a Principal — every route here resolves a principal and then checks capabilities, so a principal-minting code would be one missing check away from leaking the book. "Only platform-track rows may be code-visible" is a database CHECK constraint as well as a write-path rule, and a test asserts a valid learn token still gets 401 on /api/dashboard, /api/accounts and /api/contracts — the same invariant scripts/deploy.sh refuses to ship without. CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a release-* tag and cloud-2 pulls it, so no credential on the shared runner can execute anything on production — by construction rather than by policy. Both halves of deploy.sh's original rule survive: nothing on the runner reaches the host, and a human still decides when it ships. deploy.sh gains a rollback and a public-origin check, and PIG_IMAGE now reaches compose through `sudo env`, without which sudo's env_reset silently resolved every release to pig:local. Tests 141 -> 261. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* The page tools, executed against a real Postgres.
|
||||
*
|
||||
* `test/chat-tools.test.ts` passes `{} as Database` and asserts on tool names,
|
||||
* which is the right shape for a selection test and no shape at all for the
|
||||
* five `execute` bodies underneath: every where clause, every `Number(gpuHours)`
|
||||
* coercion and every date comparison was covered by tsc alone. The defect that
|
||||
* prompted this suite — a headline quoting `list.length` from a capped query as
|
||||
* if it were a total — typechecks perfectly.
|
||||
*
|
||||
* It lives in `e2e/` rather than `test/` for one reason: the unit suite runs in
|
||||
* CI BEFORE the migration step, against a database with no tables. `test:e2e`
|
||||
* runs after migrate and seed, which is the only point at which a query here
|
||||
* can mean anything.
|
||||
*
|
||||
* Every assertion is a DELTA against a reading taken before the fixture is
|
||||
* inserted. The tools are book-wide by design — there is no tenant to scope
|
||||
* them to — so they see the seed, the demo book and whatever the API's own E2E
|
||||
* left behind. Absolute figures would be a test of the seed, not of the tool.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import test, { after, before } from 'node:test';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
accounts,
|
||||
allocations,
|
||||
capacityCommitments,
|
||||
contractObligations,
|
||||
contracts,
|
||||
createDatabase,
|
||||
demandDeals,
|
||||
type Database,
|
||||
} from '@pig/db';
|
||||
import { createPagePigTools } from '../src/page-tools';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL is required for the Piggy page-tool E2E tests.');
|
||||
|
||||
const db: Database = createDatabase({ url: databaseUrl, max: 4 });
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** Marks every fixture row so cleanup can never reach somebody else's data. */
|
||||
const marker = `PIGGY-E2E-${randomUUID()}`;
|
||||
|
||||
/** How many exemplars each result may carry — mirrors EXEMPLARS in page-tools. */
|
||||
const EXEMPLARS = 8;
|
||||
|
||||
interface Reading {
|
||||
headline: string;
|
||||
truncated?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function run(route: '/margin' | '/capacity' | '/demand' | '/calendar' | '/'): Promise<Reading> {
|
||||
const [tool] = createPagePigTools(db, route);
|
||||
assert.ok(tool, `no tool for ${route}`);
|
||||
// The calendar tool is the only one taking input, and its default is 30.
|
||||
return (await tool.execute({})) as Reading;
|
||||
}
|
||||
|
||||
const created = {
|
||||
accountId: '',
|
||||
contractId: '',
|
||||
commitmentId: '',
|
||||
dealIds: [] as string[],
|
||||
};
|
||||
|
||||
/**
|
||||
* The fixture is deliberately lopsided.
|
||||
*
|
||||
* Twenty obligations fall due inside the horizon and twelve are already late,
|
||||
* because the capped exemplar lists hold sixteen and eight — a headline that
|
||||
* reports the list length rather than the count cannot survive those numbers.
|
||||
*/
|
||||
const UPCOMING_OBLIGATIONS = 20;
|
||||
const OVERDUE_OBLIGATIONS = 12;
|
||||
|
||||
let before30: Reading;
|
||||
let after30: Reading;
|
||||
let marginBefore: Reading;
|
||||
let marginAfter: Reading;
|
||||
let idleBefore: Reading;
|
||||
let idleAfter: Reading;
|
||||
let pipelineBefore: Reading;
|
||||
let pipelineAfter: Reading;
|
||||
let workspaceBefore: Reading;
|
||||
let workspaceAfter: Reading;
|
||||
|
||||
before(async () => {
|
||||
const now = Date.now();
|
||||
|
||||
[before30, marginBefore, idleBefore, pipelineBefore, workspaceBefore] = await Promise.all([
|
||||
run('/calendar'),
|
||||
run('/margin'),
|
||||
run('/capacity'),
|
||||
run('/demand'),
|
||||
run('/'),
|
||||
]);
|
||||
|
||||
const [account] = await db
|
||||
.insert(accounts)
|
||||
.values({ name: `${marker} counterparty`, side: 'supply' })
|
||||
.returning();
|
||||
assert.ok(account);
|
||||
created.accountId = account.id;
|
||||
|
||||
const [contract] = await db
|
||||
.insert(contracts)
|
||||
.values({
|
||||
accountId: account.id,
|
||||
type: 'msa',
|
||||
status: 'executed',
|
||||
side: 'demand',
|
||||
title: `${marker} master agreement`,
|
||||
// Inside the 30-day horizon, so the projection must emit a
|
||||
// contract_expiry — a kind the old two-table version could not see.
|
||||
expiresAt: new Date(now + 10 * DAY),
|
||||
// Auto-renewal with notice puts a renewal_notice inside the horizon too.
|
||||
isAutoRenew: true,
|
||||
noticeDays: 5,
|
||||
})
|
||||
.returning();
|
||||
assert.ok(contract);
|
||||
created.contractId = contract.id;
|
||||
|
||||
await db.insert(contractObligations).values([
|
||||
...Array.from({ length: UPCOMING_OBLIGATIONS }, (_, i) => ({
|
||||
contractId: contract.id,
|
||||
title: `${marker} upcoming ${i}`,
|
||||
kind: 'milestone',
|
||||
dueAt: new Date(now + (i + 1) * MINUTE),
|
||||
})),
|
||||
...Array.from({ length: OVERDUE_OBLIGATIONS }, (_, i) => ({
|
||||
contractId: contract.id,
|
||||
title: `${marker} overdue ${i}`,
|
||||
kind: 'milestone',
|
||||
// i = 0 lapsed a minute ago, i = 11 twelve minutes ago.
|
||||
dueAt: new Date(now - (i + 1) * MINUTE),
|
||||
})),
|
||||
{
|
||||
contractId: contract.id,
|
||||
title: `${marker} already done`,
|
||||
kind: 'milestone',
|
||||
dueAt: new Date(now + 2 * DAY),
|
||||
// Completed work is a dated fact, not a workload; it must not be counted.
|
||||
completedAt: new Date(now - DAY),
|
||||
},
|
||||
]);
|
||||
|
||||
// 1,000 GPU-hours bought at 100c. Half sells at exactly cost, 100 more are
|
||||
// held; the block therefore loses money on the hours nobody bought, which is
|
||||
// the arithmetic PIG exists to keep honest.
|
||||
const [commitment] = await db
|
||||
.insert(capacityCommitments)
|
||||
.values({
|
||||
accountId: account.id,
|
||||
name: `${marker} block`,
|
||||
gpuType: 'H100',
|
||||
gpuCount: 8,
|
||||
startsAt: new Date(now - DAY),
|
||||
endsAt: new Date(now + 20 * DAY),
|
||||
totalGpuHours: '1000.00',
|
||||
costPerGpuHourCents: 100,
|
||||
})
|
||||
.returning();
|
||||
assert.ok(commitment);
|
||||
created.commitmentId = commitment.id;
|
||||
|
||||
const [openDeal, closingDeal] = await db
|
||||
.insert(demandDeals)
|
||||
.values([
|
||||
{
|
||||
accountId: account.id,
|
||||
name: `${marker} open deal`,
|
||||
stage: 'proposal',
|
||||
acvCents: 1_000_000,
|
||||
tcvCents: 2_500_000,
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
name: `${marker} closing deal`,
|
||||
stage: 'procurement',
|
||||
acvCents: 4_000_000,
|
||||
tcvCents: 4_000_000,
|
||||
probability: '0.50',
|
||||
expectedCloseDate: new Date(now + 3 * DAY),
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
assert.ok(openDeal && closingDeal);
|
||||
created.dealIds = [openDeal.id, closingDeal.id];
|
||||
|
||||
await db.insert(allocations).values([
|
||||
{
|
||||
capacityCommitmentId: commitment.id,
|
||||
demandDealId: openDeal.id,
|
||||
gpuHours: '500.00',
|
||||
pricePerGpuHourCents: 100,
|
||||
startsAt: new Date(now - HOUR),
|
||||
endsAt: new Date(now + 10 * DAY),
|
||||
status: 'committed',
|
||||
},
|
||||
{
|
||||
capacityCommitmentId: commitment.id,
|
||||
demandDealId: closingDeal.id,
|
||||
gpuHours: '100.00',
|
||||
pricePerGpuHourCents: 300,
|
||||
startsAt: new Date(now - HOUR),
|
||||
endsAt: new Date(now + 10 * DAY),
|
||||
status: 'planned',
|
||||
// A live hold: removed from availability, never revenue. Inside the
|
||||
// horizon, so it is also a hold_expiry event on the calendar.
|
||||
holdExpiresAt: new Date(now + 4 * DAY),
|
||||
},
|
||||
]);
|
||||
|
||||
[after30, marginAfter, idleAfter, pipelineAfter, workspaceAfter] = await Promise.all([
|
||||
run('/calendar'),
|
||||
run('/margin'),
|
||||
run('/capacity'),
|
||||
run('/demand'),
|
||||
run('/'),
|
||||
]);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (created.commitmentId) {
|
||||
await db
|
||||
.delete(allocations)
|
||||
.where(eq(allocations.capacityCommitmentId, created.commitmentId));
|
||||
await db.delete(capacityCommitments).where(eq(capacityCommitments.id, created.commitmentId));
|
||||
}
|
||||
if (created.dealIds.length > 0) {
|
||||
await db.delete(demandDeals).where(inArray(demandDeals.id, created.dealIds));
|
||||
}
|
||||
// Obligations cascade from the contract.
|
||||
if (created.contractId) await db.delete(contracts).where(eq(contracts.id, created.contractId));
|
||||
if (created.accountId) await db.delete(accounts).where(eq(accounts.id, created.accountId));
|
||||
await db.$client.end();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CalendarReading extends Reading {
|
||||
exactTotals: { obligationsDue: number; dealsExpectedToClose: number };
|
||||
upcoming: { count: number; byKind: Record<string, number>; events: { startsAt: string }[] };
|
||||
overdue: {
|
||||
count: number;
|
||||
byKind: Record<string, number>;
|
||||
events: { title: string; startsAt: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
test('the calendar headline counts the whole set, not the capped exemplar list', () => {
|
||||
const from = before30 as CalendarReading;
|
||||
const to = after30 as CalendarReading;
|
||||
|
||||
// The defect, pinned. Twenty obligations were added and the exemplar list
|
||||
// holds sixteen; a headline built from `list.length` reports sixteen.
|
||||
assert.equal(
|
||||
to.exactTotals.obligationsDue - from.exactTotals.obligationsDue,
|
||||
UPCOMING_OBLIGATIONS,
|
||||
);
|
||||
assert.equal(
|
||||
(to.upcoming.byKind.obligation_due ?? 0) - (from.upcoming.byKind.obligation_due ?? 0),
|
||||
UPCOMING_OBLIGATIONS,
|
||||
);
|
||||
assert.equal(to.upcoming.events.length, EXEMPLARS * 2);
|
||||
assert.ok(to.upcoming.count > to.upcoming.events.length);
|
||||
assert.match(to.headline, new RegExp(`${to.exactTotals.obligationsDue} obligation\\(s\\) due`));
|
||||
|
||||
assert.equal(
|
||||
(to.overdue.byKind.obligation_due ?? 0) - (from.overdue.byKind.obligation_due ?? 0),
|
||||
OVERDUE_OBLIGATIONS,
|
||||
);
|
||||
assert.equal(to.overdue.events.length, EXEMPLARS);
|
||||
assert.ok(to.overdue.count > to.overdue.events.length);
|
||||
assert.match(to.headline, new RegExp(`${to.overdue.count} item\\(s\\) overdue`));
|
||||
});
|
||||
|
||||
test('a completed obligation is a dated fact, not a workload', () => {
|
||||
const to = after30 as CalendarReading;
|
||||
const from = before30 as CalendarReading;
|
||||
// Twenty-one obligations were inserted inside the horizon; the completed one
|
||||
// is excluded from both the SQL total and the state-filtered list.
|
||||
assert.equal(
|
||||
to.exactTotals.obligationsDue - from.exactTotals.obligationsDue,
|
||||
UPCOMING_OBLIGATIONS,
|
||||
);
|
||||
const states = (to.upcoming as unknown as { byState: Record<string, number> }).byState;
|
||||
assert.equal(states.done, undefined);
|
||||
});
|
||||
|
||||
test('the projection reaches the kinds the old two-table version could not', () => {
|
||||
const to = after30 as CalendarReading;
|
||||
const from = before30 as CalendarReading;
|
||||
const gained = (kind: string): number =>
|
||||
(to.upcoming.byKind[kind] ?? 0) - (from.upcoming.byKind[kind] ?? 0);
|
||||
|
||||
assert.equal(gained('contract_expiry'), 1);
|
||||
assert.equal(gained('renewal_notice'), 1);
|
||||
assert.equal(gained('hold_expiry'), 1);
|
||||
assert.equal(gained('expected_close'), 1);
|
||||
// The commitment window overlaps the horizon on both edges.
|
||||
assert.ok(gained('capacity_window') >= 1);
|
||||
assert.ok(gained('allocation_window') >= 1);
|
||||
assert.equal(to.exactTotals.dealsExpectedToClose - from.exactTotals.dealsExpectedToClose, 1);
|
||||
});
|
||||
|
||||
test('overdue exemplars are the most recently lapsed, not the oldest in the book', () => {
|
||||
const to = after30 as CalendarReading;
|
||||
const dates = to.overdue.events.map((event) => event.startsAt);
|
||||
assert.deepEqual(dates, [...dates].sort().reverse(), 'overdue exemplars run newest first');
|
||||
|
||||
const titles = to.overdue.events.map((event) => event.title);
|
||||
// Lapsed one minute ago: present. Lapsed twelve minutes ago: cut, because
|
||||
// twelve rows were inserted and only eight are carried.
|
||||
assert.ok(titles.includes(`${marker} overdue 0`));
|
||||
assert.ok(!titles.includes(`${marker} overdue ${OVERDUE_OBLIGATIONS - 1}`));
|
||||
});
|
||||
|
||||
test('a book this size is not truncated, and says so', () => {
|
||||
assert.equal(after30.truncated, false);
|
||||
assert.doesNotMatch(after30.headline, /at least/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The book
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MarginReading extends Reading {
|
||||
totals: { revenueCents: number; costCents: number; grossMarginCents: number };
|
||||
liveCommitments: number;
|
||||
}
|
||||
|
||||
test('margin charges cost against the full commitment and coerces numeric strings', () => {
|
||||
const from = marginBefore as MarginReading;
|
||||
const to = marginAfter as MarginReading;
|
||||
|
||||
// 500 sold hours × 100c. The held 100 are not revenue.
|
||||
assert.equal(to.totals.revenueCents - from.totals.revenueCents, 50_000);
|
||||
// 1,000 committed hours × 100c — not the 500 that sold.
|
||||
assert.equal(to.totals.costCents - from.totals.costCents, 100_000);
|
||||
// Charging cost against the sold hours alone would report break-even here
|
||||
// instead of a 50,000c hole, which is the reading the rule forbids.
|
||||
assert.equal(to.totals.grossMarginCents - from.totals.grossMarginCents, -50_000);
|
||||
assert.equal(to.liveCommitments - from.liveCommitments, 1);
|
||||
|
||||
// `gpuHours` arrives as a string. Concatenation would give "1000.00500.00"
|
||||
// and a revenue in the billions rather than a delta of exactly 50,000c.
|
||||
assert.ok(Number.isSafeInteger(to.totals.revenueCents));
|
||||
});
|
||||
|
||||
test('the idle block appears with its break-even price', () => {
|
||||
const from = idleBefore as Reading & { totalIdleCostCents: number; blocks: unknown[] };
|
||||
const to = idleAfter as Reading & {
|
||||
totalIdleCostCents: number;
|
||||
blocks: { name: string; idleGpuHours: number; breakEvenPricePerGpuHourCents: number }[];
|
||||
};
|
||||
|
||||
// 50% unsold, well past the 25% threshold: 500 idle hours at 100c.
|
||||
assert.equal(to.totalIdleCostCents - from.totalIdleCostCents, 50_000);
|
||||
const mine = to.blocks.find((block) => block.name === `${marker} block`);
|
||||
assert.ok(mine, 'the fixture block is idle enough to be listed');
|
||||
assert.equal(mine.idleGpuHours, 500);
|
||||
// The remaining 500 hours must fetch 100c each to cover the whole block.
|
||||
assert.equal(mine.breakEvenPricePerGpuHourCents, 100);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The pipelines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PipelineReading extends Reading {
|
||||
demand: { openDeals: number; valueCents: number; byStage: Record<string, number> };
|
||||
supply: { openDeals: number };
|
||||
}
|
||||
|
||||
test('the pipeline totals TCV where it is known and reports open stages', () => {
|
||||
const from = pipelineBefore as PipelineReading;
|
||||
const to = pipelineAfter as PipelineReading;
|
||||
|
||||
assert.equal(to.demand.openDeals - from.demand.openDeals, 2);
|
||||
// 2,500,000 + 4,000,000, both by TCV.
|
||||
assert.equal(to.demand.valueCents - from.demand.valueCents, 6_500_000);
|
||||
assert.equal((to.demand.byStage.proposal ?? 0) - (from.demand.byStage.proposal ?? 0), 1);
|
||||
assert.equal((to.demand.byStage.procurement ?? 0) - (from.demand.byStage.procurement ?? 0), 1);
|
||||
assert.deepEqual(to.truncated, { demandDeals: false, supplyDeals: false });
|
||||
});
|
||||
|
||||
test('the workspace summary agrees with the tools it summarises', () => {
|
||||
const from = workspaceBefore as Reading & { book: { costCents: number }; openDemandDeals: number };
|
||||
const to = workspaceAfter as Reading & { book: { costCents: number }; openDemandDeals: number };
|
||||
|
||||
assert.equal(to.book.costCents - from.book.costCents, 100_000);
|
||||
assert.equal(to.openDemandDeals - from.openDemandDeals, 2);
|
||||
assert.deepEqual(to.truncated, {
|
||||
commitments: false,
|
||||
demandDeals: false,
|
||||
supplyDeals: false,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user