Rebuild Piggy's interface, and give the demo book a business to describe
CI / verify (push) Successful in 4m57s
CI / publish (push) Has been skipped

Piggy answered in raw markdown, threw away every tool result it streamed,
and fought the reader's scroll on every token. The three surfaces that
made it worth having — what it read, how it reasoned, what it cost — were
all on the wire and none of them reached the screen.

The transcript is now composed of five parts under components/piggy:
answers render through streamdown, the container sticks to the bottom
without pinning the reader there, tool steps say what they read and link
to the record, and each turn carries its model and token count. Three
lifecycle bugs went with them: Stop left a permanent spinner, a truncated
stream was indistinguishable from thinking, and a failed send destroyed
the message it failed to send.

Underneath, the inference path grew timeouts, jittered retries on 429 and
5xx, tolerance of the malformed frames a 30B model emits, and an
agent_runs row per turn so chat spend is observable. The system prompt now
states that a field ending in Cents is cents — without it nemotron renders
costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on
the most scrutinised number in the room.

The demo book was arithmetically incoherent: every deal's value
contradicted its own allocation revenue by up to 3.6x, nothing had ever
closed, no customer had any paper, and the marketplace was empty. Deal
value is now derived from the allocation, the book clears 5.3% across five
blocks with one deliberately underwater, and the renewal, compliance and
agent-provenance machinery finally has rows to act on. A --clear that
deleted every obligation, SLA term and capacity request in the database
regardless of origin is scoped to the demo's own ids.

Around that: accounts have a detail page, ⌘K searches the book, Settings
can mint the API keys it always claimed to, and deploy.sh actually ships
the agent instead of silently skipping its compose profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 00:33:41 -07:00
parent 76e3caa1cb
commit 99d165b5e5
81 changed files with 21780 additions and 2250 deletions
+190 -21
View File
@@ -1,8 +1,9 @@
import { timingSafeEqual } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { PIGGY_PAGE_ROUTES, PIGGY_RECORD_TYPES } from '@pig/core';
import type { Database } from '@pig/db';
import { agentRuns, type Database } from '@pig/db';
import {
PrimeOpenAIChatProvider,
type PiggyChatEvent,
@@ -56,12 +57,24 @@ interface ChatRunner {
run(request: PiggyChatRequest): AsyncIterable<PiggyChatEvent>;
}
/**
* What a token costs, in cents per million, so the cost arithmetic stays
* integral: micro-cents = tokens x cents-per-million. Omitted, the tokens are
* still recorded and the cost is left null — an unpriced run is honest, an
* invented price is not.
*/
export interface ChatTokenPricing {
inputCentsPerMillionTokens: number;
outputCentsPerMillionTokens: number;
}
export interface PiggyChatServerOptions {
host?: string;
port: number;
internalToken: string;
provider: ChatRunner;
allowNonLoopback?: boolean;
tokenPricing?: ChatTokenPricing;
}
export function startPiggyChatServer(
@@ -77,26 +90,57 @@ export function startPiggyChatServer(
}
const server = createServer(async (request, response) => {
// Unauthenticated on purpose: a container healthcheck and a load balancer
// have no token, and this says nothing an attacker on loopback could not
// learn by watching the port.
if (request.method === 'GET' && request.url === '/internal/health') {
respondJson(response, 200, {
ok: true,
service: 'piggy-chat',
model: options.provider.model,
});
return;
}
if (request.method !== 'POST' || request.url !== '/internal/chat') {
response.writeHead(404).end();
return;
}
if (!tokenMatches(request.headers.authorization, options.internalToken)) {
response.writeHead(401, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: 'Unauthorised internal request.' }));
respondJson(response, 401, { error: 'Unauthorised internal request.' });
return;
}
let body: z.infer<typeof piggyChatRequestSchema>;
try {
const body = piggyChatRequestSchema.parse(JSON.parse(await readBoundedBody(request, 32_768)));
const abort = new AbortController();
response.on('close', () => abort.abort());
response.writeHead(200, {
'content-type': 'application/x-ndjson; charset=utf-8',
'cache-control': 'no-cache, no-transform',
'x-content-type-options': 'nosniff',
});
body = piggyChatRequestSchema.parse(JSON.parse(await readBoundedBody(request, 32_768)));
} catch {
// Every failure reachable here — an oversized body, malformed JSON, a
// context arm this schema has not been told about — genuinely is the
// caller's. Nothing below may borrow this message: a ZodError raised
// mid-stream is an upstream fault, and reporting it as invalid input
// told the user their question was malformed when it was not.
respondJson(response, 400, { error: 'Invalid Piggy chat request.' });
return;
}
const abort = new AbortController();
response.on('close', () => abort.abort());
const run = await startChatRun(db, {
principalUserId: body.principalUserId,
model: options.provider.model,
message: body.message,
context: body.context,
historyTurns: body.history?.length ?? 0,
});
const spend: ChatRunOutcome = { toolCalls: 0 };
response.writeHead(200, {
'content-type': 'application/x-ndjson; charset=utf-8',
'cache-control': 'no-cache, no-transform',
'x-content-type-options': 'nosniff',
});
try {
for await (const event of options.provider.run({
message: body.message,
history: body.history,
@@ -104,20 +148,28 @@ export function startPiggyChatServer(
tools: createInteractivePigTools(db, body.context),
signal: abort.signal,
})) {
recordEvent(spend, event);
response.write(`${JSON.stringify(event)}\n`);
}
spend.completed = true;
response.end();
} catch (error) {
const invalidRequest = error instanceof z.ZodError;
const message = invalidRequest ? 'Invalid Piggy chat request.' : 'Piggy chat failed.';
if (!response.headersSent) {
response.writeHead(invalidRequest ? 400 : 500, {
'content-type': 'application/json',
});
response.end(JSON.stringify({ error: message }));
return;
}
response.end(`${JSON.stringify({ type: 'error', message })}\n`);
// Read before the error frame is written: ending the response fires
// 'close' as well, so a reading of the abort state taken afterwards
// cannot tell a reader who walked away from one who got the answer.
spend.aborted = abort.signal.aborted;
spend.error = error instanceof Error ? error.message : String(error);
// Server-side, with the real reason. The client gets none of it: the
// upstream body is echoed into these messages and is not ours to relay.
console.error('[piggy] chat turn failed:', spend.error);
response.end(`${JSON.stringify({ type: 'error', message: 'Piggy chat failed.' })}\n`);
} finally {
// In a finally so that every exit closes the row, including the exit
// that is not a fault at all: a reader who navigates away aborts the
// turn mid-answer. A row left `running` cannot be told from a turn still
// in flight by any later query — which is exactly the query a per-user
// daily cap would have to make.
await finishChatRun(db, run, spend, options.tokenPricing);
}
});
server.listen(options.port, host);
@@ -128,6 +180,123 @@ export function createPrimeChatProvider(options: ConstructorParameters<typeof Pr
return new PrimeOpenAIChatProvider(options);
}
/**
* The chat's cost ledger.
*
* `agent_runs` existed and only the queued worker ever wrote to it, so every
* token the docked panel spent was invisible: nothing in the API or the web app
* could answer "what has Piggy cost today", let alone cap it per user. A chat
* turn is one run, with `agent_task_id` left null — the column is nullable for
* precisely this case, a run with no queued task behind it.
*
* A failure to write the ledger never fails the answer. Losing the accounting
* for one turn is a smaller harm than refusing to talk to the user because a
* bookkeeping insert did not land.
*/
interface ChatRunOutcome {
toolCalls: number;
answer?: string;
inputTokens?: number | null;
outputTokens?: number | null;
/** The stream ran to its end. */
completed?: boolean;
/** The reader hung up before it did. */
aborted?: boolean;
error?: string;
}
function recordEvent(outcome: ChatRunOutcome, event: PiggyChatEvent): void {
if (event.type === 'content_delta') outcome.answer = (outcome.answer ?? '') + event.delta;
if (event.type === 'tool_call') outcome.toolCalls += 1;
if (event.type === 'done') {
outcome.inputTokens = event.inputTokens;
outcome.outputTokens = event.outputTokens;
}
if (event.type === 'error') outcome.error = event.message;
}
async function startChatRun(
db: Database,
input: {
principalUserId: string;
model: string;
message: string;
context?: z.infer<typeof contextSchema>;
historyTurns: number;
},
): Promise<string | null> {
try {
const [run] = await db
.insert(agentRuns)
.values({
principalUserId: input.principalUserId,
model: input.model,
input: {
surface: 'chat',
message: input.message,
context: input.context ?? null,
historyTurns: input.historyTurns,
},
})
.returning({ id: agentRuns.id });
return run?.id ?? null;
} catch (error) {
console.error('[piggy] could not open an agent run for this chat turn:', error);
return null;
}
}
async function finishChatRun(
db: Database,
runId: string | null,
outcome: ChatRunOutcome,
pricing?: ChatTokenPricing,
): Promise<void> {
if (!runId) return;
const summary = outcome.answer?.trim();
try {
await db
.update(agentRuns)
.set({
// An abandoned turn is not a failed one — the answer was fine, the
// reader left — and counting it as failed would make the failure rate
// read as an outage every time somebody closed a tab.
status: outcome.completed ? 'succeeded' : outcome.aborted ? 'aborted' : 'failed',
summary: summary || null,
result: { toolCalls: outcome.toolCalls },
inputTokens: outcome.inputTokens ?? null,
outputTokens: outcome.outputTokens ?? null,
costMicroCents: costMicroCents(outcome, pricing),
error: outcome.error ?? null,
finishedAt: new Date(),
})
.where(eq(agentRuns.id, runId));
} catch (error) {
console.error(`[piggy] could not close agent run ${runId}:`, error);
}
}
/**
* Tokens are billed per million, so cents-per-million multiplied by tokens is
* already micro-cents. Doing it that way keeps the whole calculation in
* integers rather than rounding a fraction of a cent per turn and drifting.
*/
function costMicroCents(outcome: ChatRunOutcome, pricing?: ChatTokenPricing): number | null {
if (!pricing) return null;
const input = outcome.inputTokens ?? null;
const output = outcome.outputTokens ?? null;
if (input === null && output === null) return null;
return Math.round(
(input ?? 0) * pricing.inputCentsPerMillionTokens +
(output ?? 0) * pricing.outputCentsPerMillionTokens,
);
}
function respondJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(body));
}
function tokenMatches(header: string | undefined, expected: string): boolean {
const supplied = header?.startsWith('Bearer ') ? header.slice(7) : '';
const suppliedBytes = Buffer.from(supplied);