mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
* feat(agent-status): add workspace live-tasks endpoint and TaskFailureReason type Lays the API + type contract for the front-end agent presence cache: - New `GET /api/active-tasks` returns active (queued/dispatched/running) tasks plus failed tasks within the last 2 minutes for the current workspace. The 2-minute window powers a UI-side auto-clearing "Failed" agent state without back-end pollers. - `agent_task_queue` has no workspace_id column, so the query JOINs agent; `SELECT atq.*` keeps `failure_reason` (migration 055) on the wire. - Adds `TaskFailureReason` to `AgentTask` so the UI can map the 5 backend classifiers (agent_error / timeout / runtime_offline / runtime_recovery / manual) to copy without parsing free-text errors. - New `api.getActiveTasksForWorkspace()` client method; workspace is resolved server-side from the X-Workspace-Slug header (no path param, matching /api/agents and /api/runtimes conventions). Includes the joint engineering plan and designer brief that scope the broader Agent / Runtime status redesign — Phase 0 is this contract plus the front-end derivation layer landing in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent-status): derive presence/health states with WS sync and desktop IPC bridge Adds the front-end derivation layer that turns raw server data into the user-facing 5-state agent / 4-state runtime enums. UI files are deliberately untouched in this commit — derivation lives behind hooks (useAgentPresence, useRuntimeHealth) that any component can call with zero additional network traffic. Architecture: - Derivation is pure functions in packages/core/{agents,runtimes}; the back-end stays free of UI translation. Agents algorithm: runtime offline > recent failed (2-min window) > running > queued > available. Runtimes algorithm: status + last_seen_at -> online / recently_lost / offline / about_to_gc. - A single workspace-wide active-tasks query backs all per-agent presence reads, eliminating N+1 across hover cards, list rows, and pickers. 30-second tick re-renders the hooks so the failed window expires even when no underlying data changes. - WS task lifecycle events (dispatch / completed / failed / cancelled) invalidate active-tasks via the prefix dispatcher. completed/failed were removed from specificEvents so they go through both the prefix invalidate and the existing chat ws.on() handlers. Reconnect refetch picks up active-tasks too. - Desktop bridges window.daemonAPI.onStatusChange directly into the runtimes cache via setQueryData, giving the local daemon sub-second feedback (vs. 75s server sweep). Bridge is wsId-bound so workspace switches automatically rebind the subscription; daemon_id matching covers the same-daemon-multiple-providers case. 24 derivation unit tests cover all branches plus null/empty/boundary inputs (FAILED_WINDOW_MS edges, null last_seen_at, missing completed_at). Full core suite: 112 tests passing. Typecheck green across all 8 workspace packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent-status): redesign agent runtime status as two orthogonal dimensions Splits the conflated 5-state agent presence into two independent axes: - AgentAvailability (3-state): online / unstable / offline — drives the dot indicator everywhere a dot appears. Pure runtime reachability; never sticky-red because of a past task outcome. - LastTaskState (5-state): running / completed / failed / cancelled / idle — surfaced as text + icon on focused surfaces (hover card, agent detail page, agents list, runtime detail). Never colours the dot. Major changes: * Domain layer: AgentPresence union → AgentAvailability + LastTaskState. derive-presence split into deriveAgentAvailability + deriveLastTaskState + deriveAgentPresenceDetail orchestrator. Tests reorganised into three groups (availability invariants, last-task invariants, composition). * Visual config: presenceConfig (5 entries) → availabilityConfig (3) + taskStateConfig (5). availabilityOrder + lastTaskOrder for filter chips. * Workspace-level presence prefetch: new useWorkspacePresencePrefetch hook + WorkspacePresencePrefetch mount component, wired into DashboardLayout (web) and WorkspaceRouteLayout (desktop). Hover cards render synchronously with no skeleton flash on first hover. * ActorAvatar hover: flipped default — disableHoverCard removed, enableHoverCard added (default false). Opt-in at ~14 decision-moment surfaces; pickers / decoration sub-chips stay plain. Status dot decoupled (showStatusDot prop) so picker rows can show presence without nesting popovers. * Hover cards: AgentProfileCard simplified — availability dot only, Detail link top-right (logs live on the detail page). New MemberProfileCard mirrors the structure: name + role + email + top-2 owned agents (sorted by 30d run count) with click-through to agent detail. * Agents list: split Status into two columns — availability (3-color dot + label) and Last run (task icon + label, optional running counts). Two independent filter chip groups (Status + Last run); combination acts as intersection ("online + failed" finds broken- but-alive agents). * Other UI surfaces (issue list/board/detail, comments, autopilots, projects, runtimes, mention autocomplete, subscribers picker) updated to the new dot semantics; status dot now strictly 3-color. Server changes accompany the client redesign — workspace-wide agent-task-snapshot endpoint, runtime usage queries, etc. — to feed the derive layer with the data it needs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent-detail): drop last-task chip from detail header + inspector The Recent work section on the agent detail page already shows the same data (with task titles, timestamps, error context) — surfacing "Completed" / "Failed" / etc. up in the header was redundant chrome. Detail surfaces now show only the 3-state availability dot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tables): handle narrow viewports across agents / skills / runtimes Three table layouts were squeezing content into adjacent cells at intermediate widths. Each fix is small and targeted: * runtime-list: the Runtime cell's base name had `shrink-0`, so it refused to truncate when its grid column was narrowed under width pressure — the name visually overflowed into the Health column ("ClaudeOnline" etc). Removed shrink-0, added truncate. The Health column was also a fixed 9.5rem reservation for the worst-case "Recently lost · 2m 14s ago" copy; switched to minmax(0,1fr) so it competes fairly with Runtime. * skills-page: had a single grid template with no responsive breakpoints — all 6 columns were rendered at any width and got visually jammed below md. Added a <md template that drops Source + Updated; the row markup hides those cells via `hidden md:block` / `md:contents`. * agent-list-item: the new Last run column was reserved at minmax(8rem, max-content); on narrow md viewports the 8rem floor pushed the row past available width. Changed to minmax(0,max-content) so the cell shrinks under pressure (its content already truncates). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(agent-card): hover-only Detail + add Runtime row + breathing room Three small polish tweaks to the agent hover card: - Detail link gets `mr-1` + fades in only on card hover (group-hover). It was visually flush against the popover edge and competing for attention; now it stays out of the way during a quick glance and surfaces only when the user is dwelling on the card. - Runtime row is back, in the meta block (cloud/local icon + runtime name). The earlier removal was over-aggressive — knowing where an agent runs is part of "who is this agent". The wifi badge stays dropped because the availability dot in the header already conveys reachability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(runtime): wifi-style health icon (4-state) for runtime list + agent card Replaces the 6px coloured dot with a wifi-shape icon that carries both state (Wifi vs WifiOff) and severity (success/warning/muted/destructive). Mapping: - online → Wifi (success) - recently_lost → WifiHigh (warning) — transient hiccup, fewer bars - offline → WifiOff (muted) — long unreachable - about_to_gc → WifiOff (destructive) — sweeper coming soon Used in two places: - Runtime list: replaces HealthDot in the dedicated leading-icon column. Bumped the column from 0.5rem (dot-sized) to 0.875rem (icon-sized). - Agent profile card RuntimeRow: derives runtime health from runtime + clock (matching the 4-state semantics) and renders HealthIcon next to the runtime name. Cloud runtimes always read as online. The duplicate signal with the header availability dot is intentional — it confirms WHICH runtime is the one currently in the dot's state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
import type {
|
||
RuntimeUsage,
|
||
RuntimeUsageByAgent,
|
||
RuntimeUsageByHour,
|
||
} from "@multica/core/types";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Formatting helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Compound-unit relative timestamp ("2m 14s ago", "1d 4h ago", "6d 19h ago")
|
||
// — gives the user enough precision to tell "just lost" from "long lost"
|
||
// at a glance without forcing them to mouse-over for a full timestamp.
|
||
export function formatLastSeen(lastSeenAt: string | null): string {
|
||
if (!lastSeenAt) return "Never";
|
||
const diffMs = Date.now() - new Date(lastSeenAt).getTime();
|
||
if (diffMs < 5_000) return "Just now";
|
||
|
||
const seconds = Math.floor(diffMs / 1000);
|
||
const minutes = Math.floor(seconds / 60);
|
||
const hours = Math.floor(minutes / 60);
|
||
const days = Math.floor(hours / 24);
|
||
|
||
if (minutes < 1) return `${seconds}s ago`;
|
||
if (hours < 1) {
|
||
const s = seconds % 60;
|
||
return s > 0 ? `${minutes}m ${s}s ago` : `${minutes}m ago`;
|
||
}
|
||
if (days < 1) {
|
||
const m = minutes % 60;
|
||
return m > 0 ? `${hours}h ${m}m ago` : `${hours}h ago`;
|
||
}
|
||
const h = hours % 24;
|
||
return h > 0 ? `${days}d ${h}h ago` : `${days}d ago`;
|
||
}
|
||
|
||
// Turns the back-end's `device_info` string ("MacBook-Pro · darwin-amd64",
|
||
// "some-host · linux-amd64") into something humans recognise. We don't have
|
||
// hardware model or geo data on the wire today, so we settle for an OS-aware
|
||
// rewrite of the GOOS/GOARCH suffix while preserving the hostname.
|
||
export function formatDeviceInfo(raw: string | null): string | null {
|
||
if (!raw) return null;
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) return null;
|
||
return trimmed
|
||
.split(" · ")
|
||
.map((part) => prettifyOsArch(part))
|
||
.join(" · ");
|
||
}
|
||
|
||
function prettifyOsArch(part: string): string {
|
||
const lower = part.toLowerCase();
|
||
// Pattern: <os>-<arch>; e.g. darwin-amd64, linux-arm64, windows-amd64.
|
||
const match = lower.match(/^(darwin|linux|windows|freebsd|openbsd|netbsd)-(amd64|arm64|386|arm)$/);
|
||
if (!match) return part;
|
||
const os = match[1] ?? "";
|
||
const arch = match[2] ?? "";
|
||
const osLabel = OS_LABEL[os] ?? os;
|
||
const archLabel = ARCH_LABEL[arch] ?? arch;
|
||
return `${osLabel} (${archLabel})`;
|
||
}
|
||
|
||
const OS_LABEL: Record<string, string> = {
|
||
darwin: "macOS",
|
||
linux: "Linux",
|
||
windows: "Windows",
|
||
freebsd: "FreeBSD",
|
||
openbsd: "OpenBSD",
|
||
netbsd: "NetBSD",
|
||
};
|
||
|
||
const ARCH_LABEL: Record<string, string> = {
|
||
amd64: "x86_64",
|
||
arm64: "arm64",
|
||
"386": "x86",
|
||
arm: "arm",
|
||
};
|
||
|
||
// Strip leading "v" from version strings — GitHub releases ship `v0.2.17`,
|
||
// daemon metadata reports `0.2.15`; normalising lets us compare both.
|
||
function stripVersionPrefix(v: string): string {
|
||
return v.replace(/^v/, "");
|
||
}
|
||
|
||
// True iff `latest` is strictly newer than `current` by dotted-numeric
|
||
// comparison. Non-numeric / missing segments compare as 0 ("0.2" < "0.2.1").
|
||
// Used by the runtime-list CLI column to decide whether to surface the ↑
|
||
// marker; same logic also lives inline in update-section.tsx for now.
|
||
export function isVersionNewer(latest: string, current: string): boolean {
|
||
const l = stripVersionPrefix(latest).split(".").map(Number);
|
||
const c = stripVersionPrefix(current).split(".").map(Number);
|
||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||
const lv = l[i] ?? 0;
|
||
const cv = c[i] ?? 0;
|
||
if (lv > cv) return true;
|
||
if (lv < cv) return false;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function formatTokens(n: number): string {
|
||
if (n >= 1_000_000) {
|
||
const m = n / 1_000_000;
|
||
return m % 1 < 0.05 ? `${Math.round(m)}M` : `${m.toFixed(1)}M`;
|
||
}
|
||
if (n >= 1_000) {
|
||
const k = n / 1_000;
|
||
return k % 1 < 0.05 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
|
||
}
|
||
return n.toLocaleString();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cost estimation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Pricing per million tokens (USD). Sourced from
|
||
// https://platform.claude.com/docs/en/about-claude/pricing — keep in sync
|
||
// when Anthropic releases new models or adjusts prices. cacheWrite reflects
|
||
// the 5-minute cache TTL (1.25× input); the daemon reports
|
||
// cache_creation_input_tokens without TTL metadata, so 5m is the safest /
|
||
// cheapest assumption (matches the API default).
|
||
//
|
||
// Iteration order matters: the resolver's startsWith() fallback walks this
|
||
// object in insertion order, so MORE SPECIFIC keys (e.g. claude-sonnet-4-5)
|
||
// must precede SHORTER prefixes (e.g. claude-sonnet-4) of the same family.
|
||
const MODEL_PRICING: Record<
|
||
string,
|
||
{ input: number; output: number; cacheRead: number; cacheWrite: number }
|
||
> = {
|
||
// -- Current generation (4.5+ — Opus dropped from 15/75 to 5/25 here) --
|
||
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
|
||
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
|
||
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
|
||
"claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
|
||
|
||
// -- Pre-4.5 Opus (legacy, still served at original price tier) --
|
||
"claude-opus-4-1": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
|
||
"claude-opus-4": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
|
||
|
||
// -- Sonnet 4.0 (deprecated; same price as the 4.x family) --
|
||
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||
|
||
// -- Older Haiku tier (defensive entry for the rare runtime still on it) --
|
||
"claude-haiku-3-5": { input: 0.80, output: 4, cacheRead: 0.08, cacheWrite: 1.00 },
|
||
};
|
||
|
||
// Resolve a model string to its pricing tier. Two layers of fallback so the
|
||
// daemon-reported model name doesn't have to match the keys exactly:
|
||
// 1. Exact match.
|
||
// 2. Strip a trailing date / "latest" tag (Claude Code typically reports
|
||
// `claude-sonnet-4-5-20250929` — the date is volatile, the family is
|
||
// what we price). Try exact match again on the stripped name.
|
||
// 3. startsWith on either the raw or stripped name.
|
||
// Anything that misses all three is genuinely unknown; we return undefined
|
||
// so callers can distinguish "$0 spend" from "spent but model not priced".
|
||
function resolvePricing(model: string) {
|
||
if (!model) return undefined;
|
||
if (MODEL_PRICING[model]) return MODEL_PRICING[model];
|
||
|
||
const stripped = model.replace(/-(20\d{6}|latest)$/, "");
|
||
if (stripped !== model && MODEL_PRICING[stripped]) return MODEL_PRICING[stripped];
|
||
|
||
for (const [key, p] of Object.entries(MODEL_PRICING)) {
|
||
if (model.startsWith(key) || stripped.startsWith(key)) return p;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// Cheap predicate for the empty-state diagnostic: which model strings in a
|
||
// usage batch failed pricing resolution. Useful when the user is staring at
|
||
// "$0.00 / 2M tokens" and wants to know why.
|
||
export function isModelPriced(model: string): boolean {
|
||
return resolvePricing(model) !== undefined;
|
||
}
|
||
|
||
// Returns the unique, sorted list of model strings present in `rows` that
|
||
// don't resolve to a price. Empty when everything's priced or there are no
|
||
// rows.
|
||
export function collectUnmappedModels(rows: readonly Priceable[]): string[] {
|
||
const set = new Set<string>();
|
||
for (const r of rows) {
|
||
if (r.model && !isModelPriced(r.model)) set.add(r.model);
|
||
}
|
||
return [...set].sort();
|
||
}
|
||
|
||
// Anything carrying per-model token totals can be priced — RuntimeUsage,
|
||
// RuntimeUsageByAgent, RuntimeUsageByHour all share this shape on purpose
|
||
// (the back-end keeps the model dimension specifically so the client can
|
||
// run this calculation for any aggregation axis).
|
||
type Priceable = Pick<
|
||
RuntimeUsage,
|
||
"model" | "input_tokens" | "output_tokens" | "cache_read_tokens" | "cache_write_tokens"
|
||
>;
|
||
|
||
export function estimateCost(usage: Priceable): number {
|
||
const pricing = resolvePricing(usage.model);
|
||
if (!pricing) return 0;
|
||
return (
|
||
(usage.input_tokens * pricing.input +
|
||
usage.output_tokens * pricing.output +
|
||
usage.cache_read_tokens * pricing.cacheRead +
|
||
usage.cache_write_tokens * pricing.cacheWrite) /
|
||
1_000_000
|
||
);
|
||
}
|
||
|
||
export interface CostBreakdown {
|
||
input: number;
|
||
output: number;
|
||
cacheRead: number;
|
||
cacheWrite: number;
|
||
}
|
||
|
||
export function estimateCostBreakdown(usage: Priceable): CostBreakdown {
|
||
const pricing = resolvePricing(usage.model);
|
||
if (!pricing) {
|
||
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||
}
|
||
return {
|
||
input: (usage.input_tokens * pricing.input) / 1_000_000,
|
||
output: (usage.output_tokens * pricing.output) / 1_000_000,
|
||
cacheRead: (usage.cache_read_tokens * pricing.cacheRead) / 1_000_000,
|
||
cacheWrite: (usage.cache_write_tokens * pricing.cacheWrite) / 1_000_000,
|
||
};
|
||
}
|
||
|
||
// Cache savings: what cache *reads* would have cost at full input pricing
|
||
// minus what they actually cost at the discounted cache-hit rate. This is a
|
||
// reconstruction of "money the cache saved you", not real-world spend.
|
||
export function estimateCacheSavings(usage: Priceable): number {
|
||
const pricing = resolvePricing(usage.model);
|
||
if (!pricing) return 0;
|
||
const wouldHaveCost = (usage.cache_read_tokens * pricing.input) / 1_000_000;
|
||
const actualCost = (usage.cache_read_tokens * pricing.cacheRead) / 1_000_000;
|
||
return wouldHaveCost - actualCost;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Data aggregation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface DailyTokenData {
|
||
date: string;
|
||
label: string;
|
||
input: number;
|
||
output: number;
|
||
cacheRead: number;
|
||
cacheWrite: number;
|
||
}
|
||
|
||
export interface DailyCostData {
|
||
date: string;
|
||
label: string;
|
||
cost: number;
|
||
}
|
||
|
||
// Stacked variant — splits the daily $ figure into the three components that
|
||
// drive billing (cache reads excluded; their cost is tracked separately as
|
||
// "savings" since they're typically dominated by the cached-input discount).
|
||
export interface DailyCostStackData {
|
||
date: string;
|
||
label: string;
|
||
input: number;
|
||
output: number;
|
||
cacheWrite: number;
|
||
total: number;
|
||
}
|
||
|
||
export interface ModelDistribution {
|
||
model: string;
|
||
tokens: number;
|
||
cost: number;
|
||
}
|
||
|
||
export function aggregateByDate(usage: RuntimeUsage[]): {
|
||
dailyTokens: DailyTokenData[];
|
||
dailyCost: DailyCostData[];
|
||
dailyCostStack: DailyCostStackData[];
|
||
modelDist: ModelDistribution[];
|
||
} {
|
||
const dateMap = new Map<string, Omit<DailyTokenData, "label">>();
|
||
const costMap = new Map<string, number>();
|
||
const stackMap = new Map<
|
||
string,
|
||
{ input: number; output: number; cacheWrite: number }
|
||
>();
|
||
const modelMap = new Map<string, { tokens: number; cost: number }>();
|
||
|
||
for (const u of usage) {
|
||
const existing = dateMap.get(u.date) ?? {
|
||
date: u.date,
|
||
input: 0,
|
||
output: 0,
|
||
cacheRead: 0,
|
||
cacheWrite: 0,
|
||
};
|
||
existing.input += u.input_tokens;
|
||
existing.output += u.output_tokens;
|
||
existing.cacheRead += u.cache_read_tokens;
|
||
existing.cacheWrite += u.cache_write_tokens;
|
||
dateMap.set(u.date, existing);
|
||
|
||
const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u);
|
||
costMap.set(u.date, dayCost);
|
||
|
||
const breakdown = estimateCostBreakdown(u);
|
||
const stack = stackMap.get(u.date) ?? {
|
||
input: 0,
|
||
output: 0,
|
||
cacheWrite: 0,
|
||
};
|
||
stack.input += breakdown.input;
|
||
stack.output += breakdown.output;
|
||
stack.cacheWrite += breakdown.cacheWrite;
|
||
stackMap.set(u.date, stack);
|
||
|
||
const modelName = u.model || u.provider;
|
||
const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 };
|
||
m.tokens +=
|
||
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||
m.cost += estimateCost(u);
|
||
modelMap.set(modelName, m);
|
||
}
|
||
|
||
const formatLabel = (d: string) => {
|
||
const date = new Date(d + "T00:00:00");
|
||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||
};
|
||
|
||
const dailyTokens = [...dateMap.values()]
|
||
.sort((a, b) => a.date.localeCompare(b.date))
|
||
.map((d) => ({ ...d, label: formatLabel(d.date) }));
|
||
|
||
const dailyCost = [...costMap.entries()]
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([date, cost]) => ({
|
||
date,
|
||
label: formatLabel(date),
|
||
cost: Math.round(cost * 100) / 100,
|
||
}));
|
||
|
||
const dailyCostStack = [...stackMap.entries()]
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([date, s]) => {
|
||
const round = (n: number) => Math.round(n * 100) / 100;
|
||
const input = round(s.input);
|
||
const output = round(s.output);
|
||
const cacheWrite = round(s.cacheWrite);
|
||
return {
|
||
date,
|
||
label: formatLabel(date),
|
||
input,
|
||
output,
|
||
cacheWrite,
|
||
total: round(input + output + cacheWrite),
|
||
};
|
||
});
|
||
|
||
const modelDist = [...modelMap.entries()]
|
||
.map(([model, data]) => ({ model, ...data }))
|
||
.sort((a, b) => b.tokens - a.tokens);
|
||
|
||
return { dailyTokens, dailyCost, dailyCostStack, modelDist };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cost-by-X aggregations
|
||
//
|
||
// All three "Cost by …" tabs share the same shape: a sorted list of rows
|
||
// where each row carries a key (agent name, model name, or hour-of-day),
|
||
// total tokens and total cost. The chart / list components are oblivious
|
||
// to which axis they're rendering — they just see {key, tokens, cost}.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface CostByKey {
|
||
key: string;
|
||
tokens: number;
|
||
cost: number;
|
||
taskCount: number;
|
||
}
|
||
|
||
// Per-(agent, model) rows → per-agent totals. Cost is summed across all
|
||
// models for that agent, then the list is sorted by cost desc so the
|
||
// heaviest-spending agent appears first.
|
||
export function aggregateCostByAgent(rows: RuntimeUsageByAgent[]): CostByKey[] {
|
||
const map = new Map<string, CostByKey>();
|
||
for (const r of rows) {
|
||
const entry = map.get(r.agent_id) ?? {
|
||
key: r.agent_id,
|
||
tokens: 0,
|
||
cost: 0,
|
||
taskCount: 0,
|
||
};
|
||
entry.tokens +=
|
||
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
|
||
entry.cost += estimateCost(r);
|
||
entry.taskCount += r.task_count;
|
||
map.set(r.agent_id, entry);
|
||
}
|
||
return [...map.values()].sort((a, b) => b.cost - a.cost);
|
||
}
|
||
|
||
// Per-(date, model) rows → per-model totals (the "By model" tab reuses the
|
||
// daily-grain data we already cache, so no extra request is needed).
|
||
export function aggregateCostByModel(rows: RuntimeUsage[]): CostByKey[] {
|
||
const map = new Map<string, CostByKey>();
|
||
for (const r of rows) {
|
||
const key = r.model || r.provider || "unknown";
|
||
const entry = map.get(key) ?? { key, tokens: 0, cost: 0, taskCount: 0 };
|
||
entry.tokens +=
|
||
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
|
||
entry.cost += estimateCost(r);
|
||
map.set(key, entry);
|
||
}
|
||
return [...map.values()].sort((a, b) => b.cost - a.cost);
|
||
}
|
||
|
||
// Per-(hour, model) rows → 24 fixed buckets (0..23). Hours with no activity
|
||
// stay in the list as empty rows so the bar chart axis stays continuous.
|
||
export function aggregateCostByHour(rows: RuntimeUsageByHour[]): CostByKey[] {
|
||
const buckets = new Map<number, CostByKey>();
|
||
for (let h = 0; h < 24; h++) {
|
||
buckets.set(h, { key: String(h), tokens: 0, cost: 0, taskCount: 0 });
|
||
}
|
||
for (const r of rows) {
|
||
const entry = buckets.get(r.hour);
|
||
if (!entry) continue;
|
||
entry.tokens +=
|
||
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
|
||
entry.cost += estimateCost(r);
|
||
entry.taskCount += r.task_count;
|
||
}
|
||
return [...buckets.values()];
|
||
}
|
||
|
||
// "Cost · 30D" KPI hint: percentage delta vs. the immediately prior window
|
||
// of equal length. Returns null when there's no comparable prior data
|
||
// (caller renders nothing rather than a misleading "+∞%").
|
||
// Sum of estimated cost over the trailing window
|
||
// [today − offsetDays − daysBack, today − offsetDays).
|
||
// `offsetDays = 0, daysBack = 7` → last 7 days.
|
||
// `offsetDays = 7, daysBack = 7` → the 7 days *before* the last 7 (the
|
||
// "previous" window for the runtime-list ↑/↓ delta).
|
||
//
|
||
// Walks the same daily-grain `RuntimeUsage` rows that `aggregateByDate` uses,
|
||
// so the runtime-list cost stays consistent with the runtime-detail KPIs
|
||
// (and crucially, hits the same TanStack Query cache key).
|
||
export function computeCostInWindow(
|
||
rows: readonly RuntimeUsage[],
|
||
daysBack: number,
|
||
offsetDays: number = 0,
|
||
): number {
|
||
const now = new Date();
|
||
const end = new Date(now);
|
||
end.setDate(now.getDate() - offsetDays);
|
||
const start = new Date(now);
|
||
start.setDate(now.getDate() - offsetDays - daysBack);
|
||
const isoEnd = end.toISOString().slice(0, 10);
|
||
const isoStart = start.toISOString().slice(0, 10);
|
||
let total = 0;
|
||
for (const r of rows) {
|
||
if (r.date >= isoStart && r.date < isoEnd) total += estimateCost(r);
|
||
}
|
||
return total;
|
||
}
|
||
|
||
export function pctChange(current: number, previous: number): number | null {
|
||
if (previous <= 0) return null;
|
||
return Math.round(((current - previous) / previous) * 100);
|
||
}
|