Files
PaperClipAI/ui/src/pages/StatusCards/format.ts
T
7e40ed8c43 feat(status-cards): add experimental status card update view (#10101)
## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies.
> - Operators need a board-level way to monitor a changing slice of
company work without repeatedly rebuilding filters or reading raw task
threads.
> - Existing summaries are useful snapshots, but they do not provide a
dedicated query-backed card with refresh policy, change tracking, update
history, and per-update cost visibility.
> - The capability needs to be safe to evaluate before it becomes part
of the default product surface.
> - This pull request adds end-to-end experimental Status Cards, from
schema and query compilation through update orchestration and operator
UI.
> - The entire feature is gated behind the `enableStatusCards`
experimental toggle, including its route and sidebar entry.
> - The benefit is a governed, inspectable way to keep focused
operational rollups current while preserving explicit controls over
refresh frequency and spend.

## Linked Issues or Issue Description

### Subsystem affected

Cross-cutting (`packages/db`, `packages/shared`, `server/`, `ui/`, and
bundled skills/docs).

### Problem or motivation

Operators cannot currently define a reusable natural-language view of
company work, compile it into an inspectable query, and keep its summary
current as matching issues change. Rebuilding filters and rereading task
threads makes board-level monitoring repetitive and hides the
relationship between source changes, refresh cost, and the resulting
summary.

### Proposed solution

Add experimental Status Cards that compile operator intent into a query,
summarize matched work, record each update, expose
manual/interval/reactive refresh policies and costs, and preserve the
last good result across stale, updating, paused, and error states. The
capability is off by default and fully gated behind `enableStatusCards`,
including its route and navigation entry.

### Alternatives considered

- Extend existing one-off summaries: rejected because status cards
require persistent query provenance, refresh policy, update history, and
card-specific cost controls.
- Add a dashboard-only filter widget: rejected because it would not
provide governed background refresh, an update ledger, or an inspectable
compile pipeline.
- Ship the surface by default: rejected in favor of an experimental
toggle while behavior and operator value are evaluated.

### Roadmap alignment

This advances Paperclip’s board-level execution visibility and
output-first product goals. `ROADMAP.md` was checked and no duplicate
status-card initiative was found.

### Additional context

No related open PR was found in the public GitHub search for status
cards. The PR-only design wireframes were removed from the repository
after review; the published prototype remains external to the production
source tree.
## What Changed

- Added company-scoped status-card schema, CRUD APIs, compile
provenance, update ledger, shared contracts, validators, and OpenAPI
coverage.
- Added the text-to-query compile pipeline, bundled `status-card-query`
agent skill, query versioning, and authorized write-back flow.
- Added the experimental board, create flow, lifecycle tiles,
detail/settings/debug drawers, archived view, routing, navigation, and
instance setting.
- Added a change-gated update engine with manual, interval, and reactive
refresh policies, trigger selection, active hours, and daily token caps.
- Added per-update token/cost recording, today and lifetime rollups, and
policy-derived cost previews.
- Added operator documentation and agent-authoring hardening for compile
and update behavior.
- Added PR-prep integration coverage for settings/startup wiring and
replaced raw UI values with design-system tokens.
- Removed the PR-only `design/pap-15023-status-cards` wireframe
artifacts so the repository contains only production feature assets.

## Verification

- `pnpm -r typecheck` — passes on the PR head; includes `ui` `tsc -b`
passing. The UI compile gate was also independently recorded as passing
at `6d7f3cf96b` on July 23, 2026.
- `pnpm build` — passes.
- `pnpm check:token-gates` — passes with all three gates clean.
- `pnpm test:run` — 2,880 tests passed and 1 skipped; the sole failure
was an unrelated 10-second `afterAll` database-cleanup timeout in
`execution-workspaces-service.test.ts`.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/execution-workspaces-service.test.ts` — passes on
immediate focused rerun (25/25).
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/instance-settings-service.test.ts
src/__tests__/server-startup-feedback-export.test.ts` — passes (31/31).
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/StatusCards/StatusCardSettingsForm.test.tsx
src/pages/StatusCards/StatusCardTile.test.tsx
src/pages/StatusCards/format.test.ts src/lib/status-card-state.test.ts`
— passes (26/26).
- Recorded pre-PR QA: compile-pipeline e2e PASS; full lifecycle and cost
QA PASS; security re-review PASS after write-back hardening; UX
approved.
- `pnpm exec vitest run packages/db/src/status-card-migrations.test.ts`
— passes; reapplies migrations `0185`–`0189` against an already-migrated
embedded Postgres database.
- `pnpm --filter /db check:migrations` — passes migration numbering and
safety checks.
- `pnpm --filter /db typecheck` — passes.
- Merged current `origin/master` on July 24, 2026 with no conflicts;
migrations `0185`–`0189` remain unclaimed on master.

## Risks

- The feature introduces five database migrations and a new background
update path; all new DDL is repeat-safe after partial application,
migration numbering/safety checks pass, and update execution is
company-scoped and change-gated.
- Natural-language compilation can produce invalid or overly broad
queries; compile provenance, query validation, debug visibility, and
version history make failures inspectable and recoverable.
- Reactive or interval refresh could increase spend; active hours, max
refresh frequency, daily token caps, per-update cost records, and
budget-paused states bound and expose that risk.
- The branch name contains an internal execution identifier because it
is a fixed handoff branch; it was intentionally not renamed or rebased
per the release handoff instructions.
- Overall rollout risk is limited because the route, navigation,
services, and UI are disabled by default behind `enableStatusCards`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex using GPT-5.5 with reasoning, repository tool use, shell
execution, GitHub CLI, and test/build execution. The runtime did not
expose a context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change; the fixed execution-workspace
identifier is documented as an authorized handoff exception
- [x] I have run tests locally and they pass, with the one cleanup
timeout passing on focused rerun
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-24 12:26:43 -05:00

157 lines
5.9 KiB
TypeScript

import type { StatusCardRefreshPolicy, StatusCardUpdate } from "@paperclipai/shared";
/** "1.1k tok" / "940 tok" — compact token count for footers and chips. */
export function formatTokens(tokens: number | null | undefined): string | null {
if (tokens === null || tokens === undefined) return null;
if (tokens < 1000) return `${tokens} tok`;
return `${(tokens / 1000).toFixed(1)}k tok`;
}
/**
* Dollar cost from integer cents. Uses more precision for sub-cent amounts so a
* $0.006 incremental update does not collapse to $0.01.
*/
export function formatCents(cents: number | null | undefined): string | null {
if (cents === null || cents === undefined) return null;
const dollars = cents / 100;
if (dollars === 0) return "$0.00";
if (dollars < 0.1) return `$${dollars.toFixed(3)}`;
return `$${dollars.toFixed(2)}`;
}
export interface StatusCardRollup {
updateCount: number;
totalTokens: number;
totalCostCents: number;
}
// `compile` rows are cheap query (re)compiles, not summary updates. They still
// cost tokens (so they count toward token/cost totals), but they must not be
// counted as "updates" in the ledger's update count.
function accumulate(updates: StatusCardUpdate[]): StatusCardRollup {
return updates.reduce(
(acc, update) => ({
updateCount: acc.updateCount + (update.kind === "compile" ? 0 : 1),
totalTokens: acc.totalTokens + update.inputTokens + update.outputTokens,
totalCostCents: acc.totalCostCents + update.costCents,
}),
{ updateCount: 0, totalTokens: 0, totalCostCents: 0 },
);
}
/**
* Lifetime rollup across the whole update ledger — used for the archived-row
* "lifetime" cost label.
*/
export function rollupUpdates(updates: StatusCardUpdate[]): StatusCardRollup {
return accumulate(updates);
}
/**
* Today-scoped rollup — only updates started since the start of the UTC
* calendar day, matching the server-side daily token cap boundary.
*/
export function rollupUpdatesToday(updates: StatusCardUpdate[], now = new Date()): StatusCardRollup {
const startOfDay = new Date(now);
startOfDay.setUTCHours(0, 0, 0, 0);
const startMs = startOfDay.getTime();
return accumulate(updates.filter((update) => new Date(update.startedAt).getTime() >= startMs));
}
// Rough per-update estimates for the create/settings cost preview. These anchor
// on observed ledger data (a full rebuild ≈ 4.5k tokens ≈ 3¢; an incremental
// re-reads only the changed issues and runs cheaper). The preview is an
// upper-bound guide only — real cost is recorded per update in the ledger.
const EST_FULL_TOKENS = 4_500;
const EST_FULL_CENTS = 3;
const EST_INCREMENTAL_TOKENS = 2_000;
const EST_INCREMENTAL_CENTS = 1;
/** Minutes per day the card may auto-update, honouring the active-hours window. */
function activeWindowMinutes(policy: StatusCardRefreshPolicy): number {
const hours = policy.activeHours;
if (!hours) return 24 * 60;
const [startH, startM] = hours.start.split(":").map(Number);
const [endH, endM] = hours.end.split(":").map(Number);
const start = startH * 60 + startM;
const end = endH * 60 + endM;
const span = end > start ? end - start : 24 * 60 - (start - end);
return span > 0 ? span : 24 * 60;
}
export interface StatusCardCostEstimate {
/** Bare cost, e.g. "$0.48 · 96.0k tok" — shown to the right of the "=" sign. */
cost: string;
/** Headline cost line, e.g. "Up to ~48 updates/day ≈ $0.48 · 96.0k tok". */
primary: string;
/** Secondary qualifier (cap / no-op-check / manual-only), or null. */
note: string | null;
}
/**
* Derive a per-day / per-update token + cost preview from the chosen refresh
* policy. Reacts to mode (manual / interval / reactive), interval, active
* hours, and the daily token cap.
*/
export function estimateStatusCardCost(policy: StatusCardRefreshPolicy): StatusCardCostEstimate {
if (policy.mode === "manual") {
const cost = `${formatCents(EST_FULL_CENTS)} · ${formatTokens(EST_FULL_TOKENS)}`;
return {
cost,
primary: `~1 rebuild per refresh ≈ ${cost}`,
note: "Manual cards only cost tokens when you press Refresh.",
};
}
const windowMinutes = activeWindowMinutes(policy);
let maxPerDay: number;
let cadence: string;
if (policy.mode === "interval") {
const interval = policy.intervalMinutes ?? 15;
maxPerDay = Math.floor(windowMinutes / interval);
cadence = `every ${interval} min`;
} else {
const perHour = policy.maxUpdatesPerHour ?? 6;
maxPerDay = Math.round((windowMinutes / 60) * perHour);
cadence = `up to ${perHour}/hour`;
}
const cap = policy.dailyTokenCap ?? null;
const maxByCap = cap !== null ? Math.floor(cap / EST_INCREMENTAL_TOKENS) : Infinity;
const effective = Math.max(0, Math.min(maxPerDay, maxByCap));
const cappedByTokenCap = cap !== null && maxByCap < maxPerDay;
const tokens = effective * EST_INCREMENTAL_TOKENS;
const cents = effective * EST_INCREMENTAL_CENTS;
const withinHours = policy.activeHours ? " during active hours" : "";
const cost = `${formatCents(cents)} · ${formatTokens(tokens)}`;
return {
cost,
primary: `Up to ~${effective} updates/day (${cadence}${withinHours}) ≈ ${cost}`,
note: cappedByTokenCap
? `Capped by your ${formatTokens(cap!)} daily token cap — the card pauses when it's hit.`
: "Only runs when something changed; a cheap no-op check otherwise.",
};
}
/** "0.4k in / 0.2k out" — the per-update token split shown in history rows. */
export function formatTokenSplit(inputTokens: number, outputTokens: number): string {
const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
return `${fmt(inputTokens)} in / ${fmt(outputTokens)} out`;
}
/** Human label for an update's kind. */
export function updateKindLabel(kind: StatusCardUpdate["kind"]): string {
switch (kind) {
case "compile":
return "compile";
case "full":
return "full rebuild";
case "incremental":
return "incremental";
default:
return kind;
}
}