Files
paseo/packages/cli/src/commands/usage/presentation.ts
T
Mohamed Boudra cf62b00911 Keep last successful usage visible when refresh fails (#7246)
* fix(usage): retain last successful data when polls fail

Keep the latest poll outcome separate from its successful snapshot so errors remain visible without discarding known usage or advancing its freshness. Preserve attempt timestamps for response ordering and isolate retained values by scoped login set.

* fix(usage): preserve history through discovery failures

A failed account-store read does not confirm that an account was removed. Retain its existing host mapping until successful discovery replaces it, without carrying mappings across agent launch environments.
2026-10-18 11:06:31 +07:00

163 lines
5.9 KiB
TypeScript

import type {
UsageReportEntry,
UsageReport,
UsageProblem,
ProviderUsageWindow,
ProviderUsageBalance,
} from "@getpaseo/protocol/messages";
import { renderTable, type OutputSchema } from "../../output/index.js";
function value(input: string | number | null | undefined): string {
return input == null ? "-" : String(input);
}
function percent(input: number | null | undefined): string {
return input == null ? "-" : `${input}%`;
}
function problemText(problem: UsageProblem): string {
if (problem.kind === "no_quota") return problem.detail;
const remedy = problem.refreshedBy
? `Run ${problem.refreshedBy} to refresh login.`
: "Sign in again.";
if (problem.kind === "expired") return `Login expired at ${problem.expiresAt}. ${remedy}`;
return `Login rejected (HTTP ${problem.status}). ${remedy}`;
}
function failureText(report: UsageReport): string {
if (report.status === "error") return report.error;
if (report.status === "unavailable") return problemText(report.problem);
return "-";
}
function windowText(window: ProviderUsageWindow): string {
return `${window.label}: ${percent(window.usedPct)} used, ${percent(window.remainingPct)} remaining`;
}
function balanceText(balance: ProviderUsageBalance): string {
return `${balance.label}: ${value(balance.used)} used, ${value(balance.remaining)} remaining, ${value(balance.limit)} limit (${balance.unit})`;
}
function summary(entry: UsageReportEntry): string[] {
const { report } = entry;
if (report.status !== "available") {
const lines = [failureText(report)];
for (const login of entry.loginErrors ?? [])
lines.push(`${login.harness}: ${failureText(login.report)}`);
if (entry.lastSuccessful) {
lines.push(`Last successful: ${entry.lastSuccessful.fetchedAt}`);
lines.push(
...availableSummary(entry.lastSuccessful.report).map((line) => `Retained: ${line}`),
);
} else lines.push("No successful update");
return lines;
}
return [...availableSummary(report), `Last successful: ${successfulTime(entry)}`];
}
function availableSummary(report: Extract<UsageReport, { status: "available" }>): string[] {
const lines = report.windows.map(windowText);
lines.push(...(report.balances ?? []).map(balanceText));
return lines.length ? lines : ["-"];
}
interface SummaryRow {
id: string;
source: string;
account: string;
status: string;
usage: string;
fetched: string;
}
const summarySchema: OutputSchema<SummaryRow> = {
idField: "id",
columns: [
{ header: "REPORT ID", field: "id" },
{ header: "SOURCE", field: "source" },
{ header: "ACCOUNT", field: "account" },
{ header: "STATUS", field: "status" },
{ header: "USAGE", field: "usage" },
{ header: "FETCHED (LATEST ATTEMPT)", field: "fetched" },
],
};
function inspect(entry: UsageReportEntry): string {
const lines = [
`Report ID: ${entry.id}`,
`Source: ${entry.sourceLabel}`,
`Source ID: ${entry.sourceId}`,
`Account: ${value(entry.account.label)}`,
`Status: ${entry.report.status}`,
`Latest attempt: ${value(entry.fetchedAt)}`,
];
const report = entry.report.status === "available" ? entry.report : entry.lastSuccessful?.report;
const successfulAt = successfulTime(entry);
lines.push(successfulAt ? `Last successful: ${successfulAt}` : "No successful update");
if (entry.report.status !== "available") lines.push(`Reason: ${failureText(entry.report)}`);
if (report) {
if (entry.report.status !== "available") lines.push("Retained usage:");
lines.push(...availableDetails(report));
}
if (entry.loginErrors?.length) {
lines.push("Login failures:");
for (const login of entry.loginErrors)
lines.push(` ${login.harness}: ${failureText(login.report)}`);
}
return lines.join("\n");
}
export const usageSchema: OutputSchema<UsageReportEntry> = {
idField: "id",
columns: [],
renderHuman(result, options) {
if (result.type === "single") return inspect(result.data);
if (!result.data.length) return "No usage reports returned";
const rows: SummaryRow[] = [];
for (const entry of result.data) {
for (const [index, usage] of summary(entry).entries()) {
const first = index === 0;
rows.push({
id: first ? entry.id : "",
source: first ? entry.sourceLabel : "",
account: first ? value(entry.account.label) : "",
status: first ? entry.report.status : "",
usage,
fetched: first ? value(entry.fetchedAt) : "",
});
}
}
return renderTable<SummaryRow>({ type: "list", data: rows, schema: summarySchema }, options);
},
};
function availableDetails(report: Extract<UsageReport, { status: "available" }>): string[] {
const lines: string[] = [];
lines.push(`Plan: ${value(report.planLabel)}`, "Windows:");
if (!report.windows.length) lines.push(" -");
for (const window of report.windows) {
lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`);
if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`);
if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`);
}
lines.push("Balances:");
if (!report.balances?.length) lines.push(" -");
for (const balance of report.balances ?? []) {
lines.push(
` ${balanceText(balance)} [${balance.id}]`,
` Resets: ${value(balance.resetsAt)}`,
);
}
lines.push("Details:");
if (!report.details?.length) lines.push(" -");
for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`);
return lines;
}
function successfulTime(entry: UsageReportEntry): string | undefined {
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Only available reports from older daemons can use their attempt timestamp as freshness.
if (entry.report.status === "available") return entry.successfulAt ?? entry.fetchedAt;
return entry.lastSuccessful?.fetchedAt;
}