Rebuild the shell, add Calendar and Learn, and govern reads
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped

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:
2026-08-13 15:02:48 -07:00
parent 6cf80747cc
commit 13dec6b4b8
102 changed files with 28638 additions and 913 deletions
+650
View File
@@ -0,0 +1,650 @@
/**
* The page-scoped read tools Piggy gets while docked.
*
* The equivalent answers already exist in the MCP server, but every one of
* those tools is an authenticated HTTP call carrying a `pig_…` API key. Piggy
* has no way to mint one and calling the API back through the network to read
* a database it already holds a handle to would be a round trip for nothing —
* so the queries are ported here as direct Drizzle reads.
*
* The calendar is the exception, and deliberately so. Its projection spans
* thirteen kinds across nine tables and it is the answer a user is looking at
* on /calendar; a second implementation here would not merely duplicate it,
* it would disagree with it, and Piggy contradicting the page it has just been
* told it is reading is worse than Piggy having no calendar tool. So @pig/piggy
* depends on @pig/api and calls `CalendarService` in-process — the service
* layer takes a `Database`, not a request, precisely so it can be called this
* way. Lifting it into @pig/core instead would drag nine table imports into a
* package the browser bundles.
*
* The hard constraint is size, not capability. Interactive chat runs at
* `max_tokens` 1024 across at most four turns, so a tool that returns rows
* spends the whole budget on transcription and truncates mid-answer. Every
* result here is aggregated first and capped at a handful of exemplar rows:
* the model is given the conclusion and enough evidence to quote, never the
* ledger. The bounded reads that feed them are wide, but that width never
* leaves this process.
*/
import {
CONSUMING_ALLOCATION_STATUSES,
DEMAND_OPEN_STAGES,
RESERVING_ALLOCATION_STATUSES,
SUPPLY_OPEN_STAGES,
aggregateMargin,
breakEvenPricePerGpuHourCents,
computeMargin,
formatCents,
type AllocationInput,
type CalendarEvent,
type CalendarEventKind,
type MarginResult,
type PiggyPageRoute,
} from '@pig/core';
import {
allocations,
capacityCommitments,
demandDeals,
supplyDeals,
type Database,
} from '@pig/db';
import { CalendarService } from '@pig/api/src/services/calendar';
import { and, gte, inArray, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { piggyPageGuide, type PiggyPageToolName } from './page-routes';
import { defineTool, type AgentTool } from './provider';
const noInput = z.object({}).strict();
/** How many exemplar rows a result may carry. Everything else is a total. */
const EXEMPLARS = 8;
/** Bound on the internal read. Wide enough for a real book, still finite. */
const SCAN_LIMIT = 500;
/**
* A bounded read that knows whether it was bounded.
*
* Every list here is capped, and a cap the caller cannot see is how a
* book-level figure ends up asserted over an arbitrary slice: the model is
* told these results are already aggregated and quotes them verbatim. So each
* read asks for one row more than its budget — the same trick the calendar
* service uses — and every result that could have been cut carries the flag.
*/
function bounded<Row>(rows: Row[], limit = SCAN_LIMIT): { rows: Row[]; truncated: boolean } {
const truncated = rows.length > limit;
return { rows: truncated ? rows.slice(0, limit) : rows, truncated };
}
/**
* Written into the headline because that is the field the model quotes. A
* `truncated: true` sitting further down the payload is routinely ignored.
*/
const TRUNCATION_NOTE =
'One or more reads hit their row cap, so these figures cover part of a larger ' +
'book — present them as a lower bound, not as the whole.';
/** Prefixes a count the model must not read as exact. */
function atLeast(count: number, truncated: boolean): string {
return truncated ? `at least ${count}` : `${count}`;
}
/**
* One tool per page. The dock is present everywhere, so the model sees this
* list on every message — a second tool would be a second thing to choose
* wrongly, and choosing wrongly costs one of four turns.
*/
export function createPagePigTools(db: Database, route: PiggyPageRoute): AgentTool[] {
return [pageTool(db, piggyPageGuide(route).tool)];
}
function pageTool(db: Database, name: PiggyPageToolName): AgentTool {
switch (name) {
case 'pig_get_margin_summary':
return defineTool({
name,
description:
'Read book-level margin across every live capacity commitment: revenue, cost, ' +
'gross margin, utilisation and idle hours, plus the largest blocks. Cost is charged ' +
'against the full commitment, not only the hours that sold.',
inputSchema: noInput,
execute: async () => readMarginSummary(db),
});
case 'pig_get_idle_capacity':
return defineTool({
name,
description:
'Read committed capacity that is bought and unsold, ranked by what the idle hours ' +
'cost, with the break-even price for the remainder of each block.',
inputSchema: noInput,
execute: async () => readIdleCapacity(db),
});
case 'pig_get_pipeline':
return defineTool({
name,
description:
'Read the open demand and supply pipelines: how many deals sit at each stage, what ' +
'they are worth, and the largest few on each side.',
inputSchema: noInput,
execute: async () => readPipeline(db),
});
case 'pig_get_calendar_ahead':
return defineTool({
name,
description:
'Read the same calendar projection the /calendar page renders: everything dated in ' +
'the near future — deals expected to close, contract effective, expiry and execution ' +
'dates, renewal notices, obligations due, capacity and allocation windows, hold ' +
'expiries, supply availability, export authorisation and compliance artefact ' +
'expiries, and calendar entries — plus what is already overdue.',
inputSchema: z
.object({
withinDays: z
.number()
.int()
.min(1)
.max(365)
.optional()
.describe('Horizon in days. Default 30.'),
})
.strict(),
execute: async ({ withinDays }) => readCalendarAhead(db, withinDays ?? 30),
});
case 'pig_get_workspace_summary':
return defineTool({
name,
description:
'Read a bounded overview of the PIG workspace: book margin and utilisation, open ' +
'deal counts on both sides, and the worst idle capacity. This cannot inspect the ' +
'filesystem or external systems.',
inputSchema: noInput,
execute: async () => readWorkspaceSummary(db),
});
}
}
// ---------------------------------------------------------------------------
// The book
// ---------------------------------------------------------------------------
interface LiveBlock {
name: string;
gpuType: string;
gpuCount: number;
startsAt: Date;
endsAt: Date;
totalGpuHours: number;
soldGpuHours: number;
/** Held by a live hold: removed from availability, but not revenue. */
heldGpuHours: number;
costPerGpuHourCents: number;
/** The sold slices, kept so book totals can sum cents rather than ratios. */
sold: readonly AllocationInput[];
margin: MarginResult;
breakEvenPriceCents: number | null;
}
interface LiveBook {
blocks: LiveBlock[];
/** True when the book is wider than SCAN_LIMIT, so the totals are partial. */
truncated: boolean;
}
/**
* Live commitments with sold and held hours counted separately.
*
* A port of `CapacityService.availability`, minus the shape integration and
* matching the API does not need here. Sold and held stay distinct because a
* pipeline of optimistic holds must never be able to make the book look full.
* Expired holds are ignored rather than swept, so the figures are right even
* when the cleanup job is behind.
*
* The cap is reported rather than hidden: revenue, cost and gross margin here
* are sums over whatever came back, and past 500 live commitments that is an
* arbitrary slice of the book being stated as the book.
*/
async function readLiveBlocks(db: Database, now = new Date()): Promise<LiveBook> {
const { rows: commitments, truncated } = bounded(
await db
.select()
.from(capacityCommitments)
.where(and(isNull(capacityCommitments.terminatedAt), gte(capacityCommitments.endsAt, now)))
.limit(SCAN_LIMIT + 1),
);
if (commitments.length === 0) return { blocks: [], truncated };
const reservations = await db
.select()
.from(allocations)
.where(
and(
inArray(
allocations.capacityCommitmentId,
commitments.map((commitment) => commitment.id),
),
inArray(allocations.status, [...RESERVING_ALLOCATION_STATUSES]),
),
);
const blocks = commitments.map((commitment) => {
const mine = reservations.filter((row) => row.capacityCommitmentId === commitment.id);
let soldGpuHours = 0;
let heldGpuHours = 0;
for (const row of mine) {
// numeric columns arrive as strings; adding them unconverted concatenates.
const hours = Number(row.gpuHours);
if ((CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(row.status)) {
soldGpuHours += hours;
} else if (!row.holdExpiresAt || row.holdExpiresAt > now) {
heldGpuHours += hours;
}
}
const book = {
gpuHours: Number(commitment.totalGpuHours),
costPerGpuHourCents: commitment.costPerGpuHourCents,
};
const sold = mine
.filter((row) => (CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(row.status))
.map((row) => ({
gpuHours: Number(row.gpuHours),
pricePerGpuHourCents: row.pricePerGpuHourCents,
}));
return {
name: commitment.name,
gpuType: commitment.gpuType,
gpuCount: commitment.gpuCount,
startsAt: commitment.startsAt,
endsAt: commitment.endsAt,
totalGpuHours: book.gpuHours,
soldGpuHours,
heldGpuHours,
costPerGpuHourCents: commitment.costPerGpuHourCents,
sold,
margin: computeMargin(book, sold),
breakEvenPriceCents: breakEvenPricePerGpuHourCents(book, sold),
};
});
return { blocks, truncated };
}
function bookTotals(blocks: readonly LiveBlock[]): MarginResult {
// Sum cents, never average per-block percentages: an average of ratios
// weights a tiny block equally with a huge one.
return aggregateMargin(
blocks.map((block) => ({
commitment: {
gpuHours: block.totalGpuHours,
costPerGpuHourCents: block.costPerGpuHourCents,
},
allocations: block.sold,
})),
);
}
async function readMarginSummary(db: Database): Promise<unknown> {
const { blocks, truncated } = await readLiveBlocks(db);
const totals = bookTotals(blocks);
const largest = [...blocks]
.sort((a, b) => b.margin.costCents - a.margin.costCents)
.slice(0, EXEMPLARS);
return {
headline:
`Revenue ${formatCents(totals.revenueCents)} against cost ${formatCents(totals.costCents)}; ` +
`gross margin ${formatCents(totals.grossMarginCents)} (${percent(totals.grossMarginPct)}) ` +
`at ${percent(totals.utilisation)} utilisation across ` +
`${atLeast(blocks.length, truncated)} live commitment(s).` +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
totals: {
revenueCents: totals.revenueCents,
costCents: totals.costCents,
grossMarginCents: totals.grossMarginCents,
grossMarginPct: totals.grossMarginPct,
utilisation: totals.utilisation,
idleGpuHours: Math.round(totals.idleGpuHours),
marginPerAllocatedGpuHourCents: totals.marginPerAllocatedGpuHourCents,
},
liveCommitments: blocks.length,
largestBlocks: largest.map((block) => ({
name: block.name,
gpuType: block.gpuType,
utilisation: block.margin.utilisation,
soldGpuHours: Math.round(block.soldGpuHours),
totalGpuHours: Math.round(block.totalGpuHours),
costPerGpuHourCents: block.costPerGpuHourCents,
grossMarginCents: block.margin.grossMarginCents,
})),
};
}
/** Idle blocks, on the same defaults the API and MCP use: 25% within 30 days. */
async function readIdleCapacity(db: Database): Promise<unknown> {
const now = new Date();
const horizon = new Date(now.getTime() + 30 * 86_400_000);
const { blocks, truncated } = await readLiveBlocks(db, now);
const idle = blocks
.filter((block) => block.startsAt <= horizon && 1 - block.margin.utilisation >= 0.25)
.map((block) => ({
block,
idleGpuHours: block.margin.idleGpuHours,
// The number that makes the case: what the unsold hours already cost us.
idleCostCents: Math.round(block.margin.idleGpuHours * block.costPerGpuHourCents),
}))
.sort((a, b) => b.idleCostCents - a.idleCostCents);
const totalIdleCostCents = idle.reduce((sum, row) => sum + row.idleCostCents, 0);
return {
headline:
(idle.length === 0
? 'No live block is more than 25% unsold within the next 30 days.'
: `${atLeast(idle.length, truncated)} block(s) at least 25% unsold within 30 days, ` +
`${formatCents(totalIdleCostCents)} of capacity bought and not yet earning.`) +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
thresholdPct: 0.25,
withinDays: 30,
totalIdleCostCents,
blocks: idle.slice(0, EXEMPLARS).map((row) => ({
name: row.block.name,
gpuType: row.block.gpuType,
gpuCount: row.block.gpuCount,
utilisation: row.block.margin.utilisation,
idleGpuHours: Math.round(row.idleGpuHours),
idleCostCents: row.idleCostCents,
// What the rest of the block must fetch to come out even.
breakEvenPricePerGpuHourCents: row.block.breakEvenPriceCents,
endsAt: row.block.endsAt.toISOString(),
})),
};
}
// ---------------------------------------------------------------------------
// The two pipelines
// ---------------------------------------------------------------------------
async function readPipeline(db: Database): Promise<unknown> {
const [demandRead, supplyRead] = await Promise.all([
db
.select()
.from(demandDeals)
.where(inArray(demandDeals.stage, [...DEMAND_OPEN_STAGES]))
.limit(SCAN_LIMIT + 1),
db
.select()
.from(supplyDeals)
.where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES]))
.limit(SCAN_LIMIT + 1),
]);
const { rows: demand, truncated: demandTruncated } = bounded(demandRead);
const { rows: supply, truncated: supplyTruncated } = bounded(supplyRead);
const truncated = demandTruncated || supplyTruncated;
// Total contract value where it is known, annual value otherwise: a deal
// valued only by ACV is still worth counting, and treating it as zero would
// understate the pipeline rather than admit the gap.
const valueOf = (deal: (typeof demand)[number]) => deal.tcvCents ?? deal.acvCents ?? 0;
const demandValueCents = demand.reduce((sum, deal) => sum + valueOf(deal), 0);
return {
headline:
`${atLeast(demand.length, demandTruncated)} open demand deal(s) worth ` +
`${formatCents(demandValueCents)} and ${atLeast(supply.length, supplyTruncated)} ` +
'open supply deal(s).' +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated: { demandDeals: demandTruncated, supplyDeals: supplyTruncated },
demand: {
openDeals: demand.length,
valueCents: demandValueCents,
byStage: countByStage(demand.map((deal) => deal.stage)),
largest: [...demand]
.sort((a, b) => valueOf(b) - valueOf(a))
.slice(0, EXEMPLARS)
.map((deal) => ({
name: deal.name,
stage: deal.stage,
valueCents: valueOf(deal),
expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null,
})),
},
supply: {
openDeals: supply.length,
byStage: countByStage(supply.map((deal) => deal.stage)),
largest: [...supply]
.sort((a, b) => (b.gpuCount ?? 0) - (a.gpuCount ?? 0))
.slice(0, EXEMPLARS)
.map((deal) => ({
name: deal.name,
stage: deal.stage,
gpuType: deal.gpuType,
gpuCount: deal.gpuCount,
targetCostPerGpuHourCents: deal.targetCostPerGpuHourCents,
})),
},
};
}
function countByStage(stages: readonly string[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const stage of stages) counts[stage] = (counts[stage] ?? 0) + 1;
return counts;
}
// ---------------------------------------------------------------------------
// Dates
// ---------------------------------------------------------------------------
/**
* How far back a lapsed item still counts as this week's problem.
*
* Unbounded, the overdue arm surfaced whatever was oldest — a stale obligation
* from two years ago crowding out a renewal notice that lapsed on Friday. Past
* a quarter it is a data-hygiene job, not an operational one, so the window
* stops there and the exemplars run most-recent-first within it.
*/
const OVERDUE_LOOKBACK_DAYS = 90;
/**
* The kinds that can honestly be late.
*
* Lateness needs a completion column: an obligation, a renewal notice and a
* deal's expected close all have somewhere to record that the thing happened.
* A capacity window that has ended is finished, not overdue, and an expiry
* that has passed is a state of the world rather than an errand — listing
* either as overdue work invents a backlog.
*/
const OVERDUE_KINDS = [
'obligation_due',
'renewal_notice',
'expected_close',
] as const satisfies readonly CalendarEventKind[];
/**
* What is dated in the near future — the same projection /calendar renders.
*
* This used to reimplement the projection over two tables. The page shows
* thirteen kinds, so Piggy asserted a total that was missing contract
* expiries, renewal notices, hold expiries, capacity and allocation windows,
* authorisation and artefact expiries, and every human-owned calendar entry.
* Being confidently wrong about the screen in front of the reader is the one
* failure that costs the tool its credibility, so it calls the service.
*
* Two projections, not one: overdue work sits BEFORE `now` and the horizon
* starts at it, and a single wide window would let a quarter of stale rows
* consume the per-source budget that the coming month needs.
*
* Counts are taken over the full projected set and only then sliced for
* exemplars — the previous version interpolated the capped list lengths, so a
* book with two hundred overdue obligations reported eight, and the system
* prompt tells the model to quote these figures rather than recompute them.
*/
async function readCalendarAhead(db: Database, withinDays: number): Promise<unknown> {
const now = new Date();
const horizon = new Date(now.getTime() + withinDays * 86_400_000);
const lookback = new Date(now.getTime() - OVERDUE_LOOKBACK_DAYS * 86_400_000);
const calendar = new CalendarService(db, () => now);
const [ahead, behind] = await Promise.all([
calendar.project({ from: now, to: horizon }),
calendar.project({ from: lookback, to: now, kinds: OVERDUE_KINDS }),
]);
// A done event is a dated fact, not something anyone must act on; the page
// shows it greyed out and a count that includes it reads as a workload.
const upcoming = ahead.events.filter((event) => event.state !== 'done');
const overdue = behind.events.filter((event) => event.state === 'overdue');
const truncated = ahead.truncated || behind.truncated;
const upcomingByKind = countByKind(upcoming);
return {
headline:
`Next ${withinDays} day(s): ${atLeast(upcoming.length, ahead.truncated)} dated item(s) ` +
`across ${Object.keys(upcomingByKind).length} kind(s), of which ` +
`${ahead.totals.obligationCount} obligation(s) due, ${ahead.totals.closingCount} demand ` +
`deal(s) expected to close worth ${formatCents(ahead.totals.weightedPipelineCents)} ` +
`weighted, ${ahead.totals.renewalCount} renewal notice(s) and ` +
`${ahead.totals.expiringAuthorizationCount} export authorisation(s) expiring; ` +
`${atLeast(overdue.length, behind.truncated)} item(s) overdue in the last ` +
`${OVERDUE_LOOKBACK_DAYS} day(s).` +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
withinDays,
truncated,
/**
* Counted in SQL by the service, so these five stay exact even when a
* source truncates. Everything else on this payload is counted off the
* event list and moves with `truncated`.
*/
exactTotals: {
obligationsDue: ahead.totals.obligationCount,
dealsExpectedToClose: ahead.totals.closingCount,
weightedPipelineCents: ahead.totals.weightedPipelineCents,
renewalNotices: ahead.totals.renewalCount,
expiringExportAuthorizations: ahead.totals.expiringAuthorizationCount,
},
upcoming: {
count: upcoming.length,
truncated: ahead.truncated,
byKind: upcomingByKind,
byState: countByState(upcoming),
// Soonest first: the near edge of the horizon is what gets acted on.
events: upcoming.slice(0, EXEMPLARS * 2).map(exemplar),
},
overdue: {
count: overdue.length,
truncated: behind.truncated,
lookbackDays: OVERDUE_LOOKBACK_DAYS,
byKind: countByKind(overdue),
events: [...overdue]
.sort((a, b) => b.startsAt.localeCompare(a.startsAt))
.slice(0, EXEMPLARS)
.map(exemplar),
},
};
}
/**
* One event, small enough to quote. `meta`, `id` and the ids are dropped: the
* model cannot navigate and a uuid in a 1024-token answer is pure cost.
*/
function exemplar(event: CalendarEvent): Record<string, unknown> {
return {
kind: event.kind,
title: event.title,
startsAt: event.startsAt,
endsAt: event.endsAt,
state: event.state,
accountName: event.accountName,
amountCents: event.amountCents,
};
}
function countByKind(events: readonly CalendarEvent[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const event of events) counts[event.kind] = (counts[event.kind] ?? 0) + 1;
return counts;
}
function countByState(events: readonly CalendarEvent[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const event of events) counts[event.state] = (counts[event.state] ?? 0) + 1;
return counts;
}
// ---------------------------------------------------------------------------
// The fallback
// ---------------------------------------------------------------------------
/**
* The default when no page names a better tool.
*
* This replaced a dump of up to 600 rows. That version could not survive one
* turn of a 1024-token budget, so the model saw a truncated ledger and
* answered from the fragment it happened to receive.
*/
async function readWorkspaceSummary(db: Database): Promise<unknown> {
const [book, demandRead, supplyRead] = await Promise.all([
readLiveBlocks(db),
db
.select({ id: demandDeals.id })
.from(demandDeals)
.where(inArray(demandDeals.stage, [...DEMAND_OPEN_STAGES]))
.limit(SCAN_LIMIT + 1),
db
.select({ id: supplyDeals.id })
.from(supplyDeals)
.where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES]))
.limit(SCAN_LIMIT + 1),
]);
const { blocks } = book;
const { rows: demand, truncated: demandTruncated } = bounded(demandRead);
const { rows: supply, truncated: supplyTruncated } = bounded(supplyRead);
const truncated = {
commitments: book.truncated,
demandDeals: demandTruncated,
supplyDeals: supplyTruncated,
};
const anyTruncated = Object.values(truncated).some(Boolean);
const totals = bookTotals(blocks);
const worstIdle = [...blocks]
.filter((block) => block.margin.idleGpuHours > 0)
.sort(
(a, b) =>
b.margin.idleGpuHours * b.costPerGpuHourCents -
a.margin.idleGpuHours * a.costPerGpuHourCents,
)
.slice(0, 3);
return {
headline:
`${atLeast(blocks.length, truncated.commitments)} live commitment(s) at ` +
`${percent(totals.utilisation)} utilisation; ` +
`gross margin ${formatCents(totals.grossMarginCents)}; ` +
`${atLeast(demand.length, demandTruncated)} open demand and ` +
`${atLeast(supply.length, supplyTruncated)} open supply deal(s).` +
(anyTruncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
book: {
liveCommitments: blocks.length,
revenueCents: totals.revenueCents,
costCents: totals.costCents,
grossMarginCents: totals.grossMarginCents,
utilisation: totals.utilisation,
idleGpuHours: Math.round(totals.idleGpuHours),
},
openDemandDeals: demand.length,
openSupplyDeals: supply.length,
worstIdleBlocks: worstIdle.map((block) => ({
name: block.name,
gpuType: block.gpuType,
idleGpuHours: Math.round(block.margin.idleGpuHours),
idleCostCents: Math.round(block.margin.idleGpuHours * block.costPerGpuHourCents),
})),
};
}
function percent(value: number | null): string {
return value == null ? 'n/a' : `${Math.round(value * 100)}%`;
}