18d5f5bfc0
Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace around it. The layout was already right — the audit found the approval card to be the best-designed object in the repo, and the account page's empty panels less finished than anything in the workspace. What was wrong was vocabulary: nobody had written the small things down, so both halves kept inventing them. Piggy was drawn with five different marks — a pig in the dock, a sparkle in the sidebar and again on the model picker, a speech bubble on the Ask buttons, and a stock robot glyph on every assistant message, which is the one people look at most. There is now one mark. The composer, which is the first control in the product since sign-in lands on /piggy, was the only un-adapted shadcn field left: 6px radius against a 12px Send button it sat 8px from. A stat tile had been reinvented six times at three numeral scales, and the same uppercase micro-label existed in five variants, two of them one tab apart in the same rail. There were 63 hand-written font sizes: not a scale, sixty-three opinions. Underneath that, the focus ring was invisible. The global rule used ring-accent, which Tailwind deliberately aliases onto the hover tint, so the ring measured 1.01:1 against the light canvas — no visible focus indicator anywhere in the product, for any accent, in either theme. It is ring-brand now and measures 17:1. The warning, positive and info tones were darkened until each clears 4.5:1 on a card, on inset and on its own chip, and the light canvas moved to 98% so a card lifts without leaning on its shadow. The mobile work is the part worth reading. A landscape phone gave the transcript 28% of the viewport and a keyboard-up phone 16%, against a 45% floor — and the fixed tab bar painted over the composer, covering the safety sentence and half the Send button, because two source comments asserted the bar stood down on short viewports and it never had. Both fixed and measured by hit-testing rather than by screenshot. The composer itself was 64px tall for a blank second line nobody typed, because the auto-resize effect sizes to scrollHeight and scrollHeight counts rows — a CSS height could not win against an inline style, so the attribute was the honest lever. Verified across both themes driven through the app's own control: no horizontal overflow on 15 routes at four viewports, 672 stat values that fit, 297 labels at exactly 11px/500, Escape returning focus to its opener rather than the body on every overlay, and a rejected write no longer reporting "Succeeded" with a green check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
447 lines
18 KiB
TypeScript
447 lines
18 KiB
TypeScript
/**
|
|
* The read side of the agent ledger.
|
|
*
|
|
* `agent_runs`, `agent_tasks` and `agent_actions` have been written to since
|
|
* the first wave and read by nothing. This service is what makes them visible:
|
|
* what Piggy has done, what is still queued, and what the whole thing has cost.
|
|
* Nothing here writes.
|
|
*
|
|
* Three decisions are worth stating, because each of them is a place where an
|
|
* audit surface can quietly start lying.
|
|
*
|
|
* **Cost is carried as an integer all the way to the browser.** The column is
|
|
* micro-cents — millionths of a cent — because a turn costs a fraction of a
|
|
* cent and rounding it per turn would drift. Nothing in this file divides; the
|
|
* conversion to money happens once, in the panel, against a labelled unit. A
|
|
* factor-of-100 error here would be the worst possible bug on this surface, so
|
|
* the unit is spelled out in the field name at every hop.
|
|
*
|
|
* **Scope is a predicate, not a filter applied afterwards.** A caller sees
|
|
* their own runs; a platform admin sees the workspace, because the ledger is
|
|
* the audit surface and an auditor who can only see their own spend is not an
|
|
* auditor. That is the opposite of `piggy-conversations.ts`, where an admin is
|
|
* deliberately NOT an exception — and the two are consistent: cost and outcome
|
|
* are the company's record, the transcript is the person's.
|
|
*
|
|
* **A conversation link is never handed across an ownership boundary.** An
|
|
* admin reading the workspace ledger sees that a run happened, what it cost and
|
|
* what it answered, but gets no doorway into somebody else's transcript. The
|
|
* link is resolved only against conversations the caller owns.
|
|
*/
|
|
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
|
import { PIGGY_MODES, type AgentTaskKind, type AgentTaskOutcome, type PiggyMode } from '@pig/core';
|
|
import type { Database } from '@pig/db';
|
|
import { agentRuns, agentTasks, piggyConversations, users } from '@pig/db';
|
|
import type { Principal } from '../lib/auth';
|
|
|
|
/** How many runs the panel lists. A ledger, not an export. */
|
|
export const PIGGY_RUN_LIMIT = 25;
|
|
|
|
/** Outstanding tasks are all shown; finished ones are the recent tail. */
|
|
export const PIGGY_TASK_LIMIT = 12;
|
|
|
|
/** Long enough to identify a turn in a narrow column, short enough to fit. */
|
|
const SNIPPET_MAX = 180;
|
|
|
|
/**
|
|
* Where a run came from. A queued background task and a question typed into the
|
|
* workspace cost the same money and belong in the same ledger, but they are not
|
|
* the same event and a reader who cannot tell them apart cannot audit either.
|
|
*/
|
|
export type PiggyRunKind = 'chat' | 'task';
|
|
|
|
export interface PiggyRunSummary {
|
|
id: string;
|
|
kind: PiggyRunKind;
|
|
/** 'piggy', or a user's own connected client. */
|
|
agent: string;
|
|
/**
|
|
* Left as free text rather than narrowed to a union, because the column is
|
|
* free text: the worker and the chat relay both write it, and a status this
|
|
* service had never heard of would be silently mislabelled by a mapping. The
|
|
* panel styles the four known values and shows anything else as it is.
|
|
*/
|
|
status: string;
|
|
model: string | null;
|
|
/**
|
|
* What this turn was allowed to do — the whole safety argument, per row.
|
|
*
|
|
* PIG's claim is that nothing lands until a person presses Apply, and that
|
|
* claim is only auditable if the ledger records which turns were even offered
|
|
* write tools. Without it a run that quietly applied five changes under `auto`
|
|
* is indistinguishable from one that could not have changed a thing.
|
|
*
|
|
* Null means the mode was not recorded, which is two real cases and not a
|
|
* failure: a queued task run, which has no mode because nobody chose one, and
|
|
* a chat turn from before the relay started stamping it. Reported as null
|
|
* rather than defaulted to `read_only`, because guessing the safe answer on an
|
|
* audit surface is the one direction a wrong guess must never go.
|
|
*/
|
|
mode: PiggyMode | null;
|
|
/** The question, for a chat turn; the queued work, for a task run. */
|
|
label: string;
|
|
/** The first line of what Piggy answered. Null on a turn that said nothing. */
|
|
summary: string | null;
|
|
error: string | null;
|
|
inputTokens: number | null;
|
|
outputTokens: number | null;
|
|
/** Millionths of a cent. Divide by 100,000,000 for US dollars. */
|
|
costMicroCents: number | null;
|
|
startedAt: string;
|
|
finishedAt: string | null;
|
|
/** Null while the run is still going — the panel counts up from `startedAt`. */
|
|
durationMs: number | null;
|
|
/** The queued work this run drained, when it came from the queue. */
|
|
taskKind: AgentTaskKind | null;
|
|
/** Present only when the transcript belongs to the caller. See the header. */
|
|
conversation: { id: string; title: string } | null;
|
|
/**
|
|
* Whose turn it was — populated ONLY when that is somebody other than the
|
|
* caller, which is the only case where the answer is information. A viewer
|
|
* scoped to their own runs would otherwise read their own name on every row,
|
|
* and an admin reading the workspace could not tell at a glance which rows
|
|
* were theirs.
|
|
*/
|
|
principal: { id: string; name: string } | null;
|
|
}
|
|
|
|
/**
|
|
* What a queued task is doing, as one word.
|
|
*
|
|
* Derived rather than stored: the table records timestamps and an outcome, and
|
|
* "queued" versus "scheduled" versus "running" is a question about now. A
|
|
* lapsed lease is deliberately reported as queued rather than running — the
|
|
* worker holding it is gone, and a row that shows as running forever is how a
|
|
* stuck queue hides.
|
|
*/
|
|
export type PiggyTaskState = 'running' | 'queued' | 'scheduled' | AgentTaskOutcome;
|
|
|
|
export interface PiggyTaskSummary {
|
|
id: string;
|
|
kind: AgentTaskKind;
|
|
/** The account, contact or commitment id the work is about. */
|
|
subject: string;
|
|
/** Why it was queued. Written for a person to read. */
|
|
reason: string | null;
|
|
state: PiggyTaskState;
|
|
attempts: number;
|
|
maxAttempts: number;
|
|
priority: number;
|
|
/** Not eligible before this. In the future means scheduled, not late. */
|
|
dueAt: string;
|
|
startedAt: string | null;
|
|
finishedAt: string | null;
|
|
error: string | null;
|
|
}
|
|
|
|
/**
|
|
* The money question, in the unit the column stores.
|
|
*
|
|
* `turns` counts the month's runs, so the monthly figure can be read as an
|
|
* average per turn without a second request. Both windows are calendar
|
|
* boundaries in the API process's timezone, not rolling 24-hour spans: "today"
|
|
* that silently means "since this time yesterday" is a number nobody can
|
|
* reconcile against a provider's invoice.
|
|
*/
|
|
export interface PiggySpendSummary {
|
|
todayMicroCents: number;
|
|
monthMicroCents: number;
|
|
turns: number;
|
|
}
|
|
|
|
export interface PiggyActivityOverview {
|
|
runs: PiggyRunSummary[];
|
|
tasks: PiggyTaskSummary[];
|
|
spend: PiggySpendSummary;
|
|
}
|
|
|
|
/** Canonical UUID text. See `conversationIdOf` for the row this saved. */
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
function snippet(value: string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
// First line only: an answer is often a table or a bulleted list, and pouring
|
|
// the whole of it into a ledger row turns the list into a wall.
|
|
const [first = ''] = value.trim().split('\n');
|
|
const line = first.trim();
|
|
if (!line) return null;
|
|
return line.length > SNIPPET_MAX ? `${line.slice(0, SNIPPET_MAX - 1).trimEnd()}…` : line;
|
|
}
|
|
|
|
function readString(bag: Record<string, unknown> | null, key: string): string | null {
|
|
const value = bag?.[key];
|
|
return typeof value === 'string' && value.trim() ? value : null;
|
|
}
|
|
|
|
/**
|
|
* The mode a chat turn ran in, from the run's `input` blob.
|
|
*
|
|
* `agent_runs` has no `mode` column; the chat relay writes it into `input`
|
|
* alongside the message and the conversation id (`chat-server.ts`,
|
|
* `startChatRun`). That is a free-text bag, so the value is checked against the
|
|
* ontology rather than cast — a run whose blob says `"mode": "yolo"` must report
|
|
* no mode at all, not put an invented one in the ledger.
|
|
*
|
|
* Exported for the test, which is the only way to exercise a blob the relay
|
|
* would never write without standing up a database to hold it.
|
|
*/
|
|
export function runMode(input: Record<string, unknown> | null): PiggyMode | null {
|
|
const claimed = readString(input, 'mode');
|
|
return PIGGY_MODES.find((mode) => mode === claimed) ?? null;
|
|
}
|
|
|
|
/**
|
|
* The conversation a run answered.
|
|
*
|
|
* `agent_runs.piggy_conversation_id` is the column that means this, and the
|
|
* chat relay does not yet populate it — it writes the id into the run's `input`
|
|
* blob instead. Reading both keeps the panel honest today without pretending
|
|
* the column is redundant; when the relay starts stamping it, this falls back
|
|
* to the column and the second arm becomes dead weight worth deleting.
|
|
*
|
|
* The value in `input` is whatever the client sent, and a real row in this
|
|
* database has `"conversationId": "drive-write-1"` in it, so it is validated
|
|
* rather than cast. An unguarded `::uuid` here would take the whole endpoint
|
|
* down with a Postgres syntax error on that one row.
|
|
*/
|
|
function conversationIdOf(row: {
|
|
piggyConversationId: string | null;
|
|
input: Record<string, unknown> | null;
|
|
}): string | null {
|
|
if (row.piggyConversationId) return row.piggyConversationId;
|
|
const claimed = readString(row.input, 'conversationId');
|
|
return claimed && UUID_PATTERN.test(claimed) ? claimed : null;
|
|
}
|
|
|
|
function humaniseKind(kind: string): string {
|
|
return kind.replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase());
|
|
}
|
|
|
|
function taskState(row: {
|
|
outcome: AgentTaskOutcome | null;
|
|
finishedAt: Date | null;
|
|
startedAt: Date | null;
|
|
leasedUntil: Date | null;
|
|
dueAt: Date;
|
|
}, now: Date): PiggyTaskState {
|
|
if (row.finishedAt || row.outcome) return row.outcome ?? 'succeeded';
|
|
if (row.leasedUntil && row.leasedUntil > now) return 'running';
|
|
return row.dueAt > now ? 'scheduled' : 'queued';
|
|
}
|
|
|
|
export class PiggyActivityService {
|
|
constructor(private readonly db: Database) {}
|
|
|
|
async overview(principal: Principal, now = new Date()): Promise<PiggyActivityOverview> {
|
|
const [runs, tasks, spend] = await Promise.all([
|
|
this.runs(principal),
|
|
this.tasks(principal, now),
|
|
this.spend(principal, now),
|
|
]);
|
|
return { runs, tasks, spend };
|
|
}
|
|
|
|
private async runs(principal: Principal): Promise<PiggyRunSummary[]> {
|
|
const rows = await this.db
|
|
.select({
|
|
id: agentRuns.id,
|
|
agent: agentRuns.agent,
|
|
status: agentRuns.status,
|
|
model: agentRuns.model,
|
|
summary: agentRuns.summary,
|
|
error: agentRuns.error,
|
|
input: agentRuns.input,
|
|
inputTokens: agentRuns.inputTokens,
|
|
outputTokens: agentRuns.outputTokens,
|
|
costMicroCents: agentRuns.costMicroCents,
|
|
startedAt: agentRuns.startedAt,
|
|
finishedAt: agentRuns.finishedAt,
|
|
agentTaskId: agentRuns.agentTaskId,
|
|
piggyConversationId: agentRuns.piggyConversationId,
|
|
taskKind: agentTasks.kind,
|
|
taskSubject: agentTasks.subject,
|
|
principalId: users.id,
|
|
principalName: users.name,
|
|
})
|
|
.from(agentRuns)
|
|
.leftJoin(agentTasks, eq(agentTasks.id, agentRuns.agentTaskId))
|
|
.leftJoin(users, eq(users.id, agentRuns.principalUserId))
|
|
.where(this.scope(principal))
|
|
.orderBy(desc(agentRuns.startedAt))
|
|
.limit(PIGGY_RUN_LIMIT);
|
|
|
|
const titles = await this.conversationTitles(principal, rows.map(conversationIdOf));
|
|
|
|
return rows.map((row) => {
|
|
const conversationId = conversationIdOf(row);
|
|
const title = conversationId ? titles.get(conversationId) : undefined;
|
|
const kind: PiggyRunKind = row.agentTaskId ? 'task' : 'chat';
|
|
const ask = snippet(readString(row.input, 'message'));
|
|
return {
|
|
id: row.id,
|
|
kind,
|
|
agent: row.agent,
|
|
status: row.status,
|
|
model: row.model,
|
|
mode: runMode(row.input),
|
|
// A run with neither a question nor a task kind is a row written before
|
|
// the turn got anywhere; naming it after its status beats an empty cell.
|
|
label:
|
|
ask ??
|
|
(row.taskKind ? humaniseKind(row.taskKind) : null) ??
|
|
(kind === 'task' ? 'Queued work' : 'Untitled turn'),
|
|
summary: snippet(row.summary),
|
|
error: row.error,
|
|
inputTokens: row.inputTokens,
|
|
outputTokens: row.outputTokens,
|
|
costMicroCents: row.costMicroCents,
|
|
startedAt: row.startedAt.toISOString(),
|
|
finishedAt: row.finishedAt?.toISOString() ?? null,
|
|
durationMs: row.finishedAt
|
|
? row.finishedAt.getTime() - row.startedAt.getTime()
|
|
: null,
|
|
taskKind: row.taskKind ?? null,
|
|
conversation: conversationId && title ? { id: conversationId, title } : null,
|
|
principal:
|
|
row.principalId && row.principalId !== principal.userId
|
|
? { id: row.principalId, name: row.principalName ?? 'Another member' }
|
|
: null,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Titles for the runs' conversations, and only for the caller's own.
|
|
*
|
|
* One statement for the whole page rather than a join per row, and the
|
|
* ownership predicate is in the statement — so a run belonging to somebody
|
|
* else simply resolves to no title, and the panel renders it without a link
|
|
* rather than with a link that 404s.
|
|
*/
|
|
private async conversationTitles(
|
|
principal: Principal,
|
|
ids: (string | null)[],
|
|
): Promise<Map<string, string>> {
|
|
const wanted = [...new Set(ids.filter((id): id is string => id !== null))];
|
|
if (wanted.length === 0) return new Map();
|
|
|
|
const rows = await this.db
|
|
.select({ id: piggyConversations.id, title: piggyConversations.title })
|
|
.from(piggyConversations)
|
|
.where(
|
|
and(
|
|
inArray(piggyConversations.id, wanted),
|
|
eq(piggyConversations.userId, principal.userId),
|
|
),
|
|
);
|
|
return new Map(rows.map((row) => [row.id, row.title]));
|
|
}
|
|
|
|
/**
|
|
* Outstanding work first, then the recent tail of finished work.
|
|
*
|
|
* Two statements rather than one: what is queued must never be truncated by a
|
|
* busy week of completions, and a finished-task list that grows without bound
|
|
* is not a panel. A failed task stays in the tail with its error — hiding a
|
|
* failure is how a queue looks healthy while nothing drains.
|
|
*/
|
|
private async tasks(principal: Principal, now: Date): Promise<PiggyTaskSummary[]> {
|
|
const columns = {
|
|
id: agentTasks.id,
|
|
kind: agentTasks.kind,
|
|
subject: agentTasks.subject,
|
|
reason: agentTasks.reason,
|
|
outcome: agentTasks.outcome,
|
|
attempts: agentTasks.attempts,
|
|
maxAttempts: agentTasks.maxAttempts,
|
|
priority: agentTasks.priority,
|
|
dueAt: agentTasks.dueAt,
|
|
leasedUntil: agentTasks.leasedUntil,
|
|
startedAt: agentTasks.startedAt,
|
|
finishedAt: agentTasks.finishedAt,
|
|
error: agentTasks.error,
|
|
};
|
|
const mine = principal.isPlatformAdmin
|
|
? undefined
|
|
: eq(agentTasks.requestedByUserId, principal.userId);
|
|
|
|
const [outstanding, finished] = await Promise.all([
|
|
this.db
|
|
.select(columns)
|
|
.from(agentTasks)
|
|
.where(and(isNull(agentTasks.finishedAt), mine))
|
|
.orderBy(agentTasks.dueAt)
|
|
.limit(PIGGY_TASK_LIMIT),
|
|
this.db
|
|
.select(columns)
|
|
.from(agentTasks)
|
|
.where(and(isNotNull(agentTasks.finishedAt), mine))
|
|
.orderBy(desc(agentTasks.finishedAt))
|
|
.limit(PIGGY_TASK_LIMIT),
|
|
]);
|
|
|
|
return [...outstanding, ...finished].map((row) => ({
|
|
id: row.id,
|
|
kind: row.kind,
|
|
subject: row.subject,
|
|
reason: row.reason,
|
|
state: taskState(row, now),
|
|
attempts: row.attempts,
|
|
maxAttempts: row.maxAttempts,
|
|
priority: row.priority,
|
|
dueAt: row.dueAt.toISOString(),
|
|
startedAt: row.startedAt?.toISOString() ?? null,
|
|
finishedAt: row.finishedAt?.toISOString() ?? null,
|
|
error: row.error,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Today's and this month's spend, and the month's turn count.
|
|
*
|
|
* Summed as `double precision` rather than the column's `int`: a year of
|
|
* turns overflows int4 long before it troubles a double's 2^53 of integer
|
|
* precision, and `sum()` over a numeric would come back as a string and get
|
|
* quietly concatenated somewhere. The result is rounded back to an integer
|
|
* because the wire unit is micro-cents, which have no fractional part.
|
|
*/
|
|
private async spend(principal: Principal, now: Date): Promise<PiggySpendSummary> {
|
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
const [row] = await this.db
|
|
.select({
|
|
/*
|
|
* The boundary is bound as ISO text and cast in SQL. A raw fragment
|
|
* hands its parameters straight to the driver with none of the column
|
|
* mapping drizzle applies to `gte()`, and postgres.js answers a Date
|
|
* there with `ERR_INVALID_ARG_TYPE` — a 500 on the whole panel.
|
|
*/
|
|
today: sql<number>`coalesce(sum(${agentRuns.costMicroCents}) filter (
|
|
where ${agentRuns.startedAt} >= ${dayStart.toISOString()}::timestamptz
|
|
), 0)::double precision`,
|
|
month: sql<number>`coalesce(sum(${agentRuns.costMicroCents}), 0)::double precision`,
|
|
turns: sql<number>`count(*)::int`,
|
|
})
|
|
.from(agentRuns)
|
|
.where(and(gte(agentRuns.startedAt, monthStart), this.scope(principal)));
|
|
|
|
return {
|
|
todayMicroCents: Math.round(row?.today ?? 0),
|
|
monthMicroCents: Math.round(row?.month ?? 0),
|
|
turns: row?.turns ?? 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whose ledger this is. Undefined widens to the workspace, which drizzle's
|
|
* `and()` treats as no predicate at all — deliberate, and the only place the
|
|
* admin exception is expressed.
|
|
*/
|
|
private scope(principal: Principal) {
|
|
return principal.isPlatformAdmin
|
|
? undefined
|
|
: eq(agentRuns.principalUserId, principal.userId);
|
|
}
|
|
}
|